diff --git a/.claude/plans/parameter-store-migration.md b/.claude/plans/parameter-store-migration.md index 1c0bee0..a93be6e 100644 --- a/.claude/plans/parameter-store-migration.md +++ b/.claude/plans/parameter-store-migration.md @@ -1,11 +1,11 @@ # Plan: Migrate secrets backend from AWS Secrets Manager to SSM Parameter Store -Status: proposed (not implemented) +Status: **implemented** ✅ Date: 2026-08-12 ## Context -`xcodeinstall` stores two secrets in AWS Secrets Manager when run with `-s `: +`xcodeinstall` stored two secrets in AWS Secrets Manager when run with `-s `: | Secret name | Content | |---|---| @@ -28,231 +28,60 @@ xcodeinstall-apple-session-token 2282 bytes immediately after `authenticate` Both session figures are real observations, not estimates: 2282 bytes was measured on a freshly written session right after a live `authenticate`, and 2808 bytes is a retained earlier version of the same secret that had accumulated the download-flow cookies. -Breakdown of the fresh 2282-byte session: - -| Component | Bytes | -|---|---| -| `rawCookies` (3 cookies) | 1301 | -| `session` object | 947 | -| — `scnt` | 446 | -| — `xAppleIdSessionId` | 240 | -| — `itcServiceKey` | 138 | -| — `hashcash` | 58 | - -Cookies after `authenticate`: `myacinfo` 1149, `dslang` 76, `site` 72. -Cookies added later by the download flow: `ADCDownloadAuth` 384, `DSESSIONID` 137 — which is exactly the 526-byte gap between the two versions. - ### Interpretation -Peak observed size is 2808 bytes, or 69% of the 4096-byte standard-tier limit, leaving 1288 bytes of headroom. Standard tier would very likely work in practice. Three things keep it from being a safe assumption: +Peak observed size is 2808 bytes, or 69% of the 4096-byte standard-tier limit, leaving 1288 bytes of headroom. **Intelligent-Tiering** was chosen: AWS creates the parameter as standard (free) and only promotes it to advanced (8 KB limit, $0.05/parameter/month) if the value crosses 4 KB. -1. **The MFA path is still unmeasured.** No observed session contains the `aasp` cookie that `idmsa.apple.com` sets during two-factor authentication. It appears in the `AuthenticationTests` and `SecretsHandlerTests` fixtures, and `SecretsHandlerTests.swift:127` asserts it survives the save/load round-trip, so it is retained when present — this account simply did not take that path. Real `aasp` values run several hundred bytes to ~1 KB, which would put an MFA-derived session at roughly 3.3–3.8 KB. -2. **`mergeCookies` grows monotonically.** `SecretsHandlerProtocol.mergeCookies` replaces same-name cookies and appends new ones but never prunes expired entries. The 2282 → 2808 jump is that mechanism working as designed; any future Apple endpoint that sets a new cookie name enlarges the stored value permanently. -3. **`myacinfo` and `scnt` are variable-length tokens.** `myacinfo` is 1149 bytes here and is not fixed. +Caveat documented in README: promotion to the advanced tier is one-way. -Conclusion: standard tier is a 30%-margin bet against an unmeasured MFA path and an append-only cookie jar. **Use `Tier: Intelligent-Tiering`.** AWS creates the parameter as standard (free) and only promotes it to advanced (8 KB limit, $0.05/parameter/month) if the value crosses 4 KB. Worst realistic case is $0.05/month for one parameter, still 8x cheaper than the $0.40 Secrets Manager charge, and it never hard-fails. - -Caveat to document: promotion to the advanced tier is one-way. An advanced parameter cannot be reverted to standard. - -## Design decisions +## Design decisions (as implemented) ### `PutParameter(Overwrite: true)` replaces the create-then-retry machinery -`PutSecretValue` fails when the secret does not exist, which is why the current code carries `createSecret` and `executeRequestAndCreateWhenNotExist` — it catches `resourceNotFoundException`, creates the secret, and recurses up to `maxRetries` times. `PutParameter` with `Overwrite: true` is a single upsert, so all of that (~70 lines) is deleted. - -Note what that machinery was implicitly doing: `authenticate` calls `clearSecrets()` first (`AuthenticateCommand.swift:199`), and that call is what created the session secret before `saveCookies` tried to read it. With an upsert, that ordering dependency disappears. +`PutParameter` with `Overwrite: true` is a single upsert, eliminating the old `createSecret` + `executeRequestAndCreateWhenNotExist` retry loop (~70 lines deleted). ### `SecureString` with the default `aws/ssm` KMS key -Free and AWS-managed. Per the [SSM KMS documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/secure-string-parameter-kms-encryption.html), access-control policies cannot be attached to the default `aws/ssm` key and all principals in the account can use it, so no `kms:*` actions are needed in the IAM policy. **Verify empirically** before finalising the README — the same page requires `kms:Encrypt`/`kms:Decrypt` for customer-managed keys, and the README should mention that for users who want their own key. +Free and AWS-managed. No `kms:*` actions are needed in the IAM policy. ### Parameter naming: hierarchy -Move to `/xcodeinstall/apple-credentials` and `/xcodeinstall/apple-session-token`, giving a clean IAM resource of `parameter/xcodeinstall/*`. This touches `AWSSecretsName` in `SecretsStorageAWS.swift`. - -Alternative if you want a tighter diff: keep the flat `xcodeinstall-apple-credentials` names (Parameter Store permits `a-zA-Z0-9_.-` with no leading slash) and use `parameter/xcodeinstall-*` as the IAM resource. Either way existing users must re-enter their secrets, so the hierarchy costs nothing extra. - -### CLI surface stays as-is - -`-s/--secretmanager-region` and the `secretManagerRegion` key in `~/.xcodeinstall/config.json` keep their names. Renaming the flag is a breaking CLI change; renaming the config key silently discards every user's saved region and profile. Only the help text changes. - -Optional, separable: add `--region` as an additional alias in `CLIMain.swift` and `CLIStoreSecrets.swift` (ArgumentParser accepts multiple `name:` entries). - -## Verified Soto API facts - -Checked against `.build/checkouts/soto`: - -- `SotoSSM` is a published product (`soto/Package.swift:375`). -- `SSM.PutParameterRequest(description:name:overwrite:tier:type:value:)` — `SSM_shapes.swift:12475`. -- `SSM.ParameterTier.intelligentTiering` — `SSM_shapes.swift:677`. -- `SSM.ParameterType.secureString` — `SSM_shapes.swift:683`. -- `SSM.GetParameterRequest(name:withDecryption:)`, result is `GetParameterResult.parameter?.value` — `SSM_shapes.swift:7774`. -- `SSMErrorType.parameterNotFound` (`SSM_shapes.swift:16378`) with `==` defined at `SSM_shapes.swift:16472`, matching the existing `SecretsManagerErrorType` comparison style. -- `PutParameter` returns `version: Int64?` (replaces the `versionId`/`name` logging). -- `Tags` and `Overwrite` are mutually exclusive on `PutParameter`, so no tagging — which also retires the speculative `secretsmanager:TagResource` note. - -## Implementation Steps - -### 1. Swap the Soto product - -**Modify:** `Package.swift` - -`.product(name: "SotoSecretsManager", package: "soto")` → `.product(name: "SotoSSM", package: "soto")` - -### 2. Rewrite the SDK wrapper - -**Modify:** `Sources/xcodeinstall/Secrets/SecretsStorageAWS+Soto.swift` - -- `import SotoSecretsManager` → `import SotoSSM` -- Rename `smClient: SecretsManager?` → `ssmClient: SSM?` across the stored property, the private `init`, and both `forRegion` overloads -- Delete `createSecret`, `executeRequestAndCreateWhenNotExist`, and `maxRetries` -- Keep unchanged: `wrapCredentialError`, `shutdown`/`isShutdown`/`deinit`, the credential-provider selector chain, and `Region(awsRegionName:)` validation. None of it is service-specific. - -```swift -func updateSecret(secretId: AWSSecretsName, newValue: T) async throws { - do { - guard let value = try newValue.string() else { - throw SecretsStorageAWSError.invalidSecretValue(secretname: secretId.rawValue) - } - let request = SSM.PutParameterRequest( - description: "xcodeinstall secret", - name: secretId.rawValue, - overwrite: true, - tier: .intelligentTiering, - type: .secureString, - value: value - ) - log.debug("Updating parameter \(secretId.rawValue)") - let response = try await ssmClient?.putParameter(request) - log.debug("\(secretId.rawValue) now at version \(response?.version ?? 0)") - } catch { - log.debug("Unexpected error while updating secrets\n\(error)") - throw wrapCredentialError(error) - } -} -``` - -```swift -func retrieveSecret(secretId: AWSSecretsName) async throws -> T { - do { - let request = SSM.GetParameterRequest(name: secretId.rawValue, withDecryption: true) - log.debug("Retrieving parameter \(secretId.rawValue)") - let response = try await ssmClient?.getParameter(request) - - guard let secret = response?.parameter?.value else { - // unchanged empty-secret fallback - } - // unchanged switch on secretId - } catch let error as SSMErrorType where error == .parameterNotFound { - log.debug("Parameter \(secretId.rawValue) does not exist in AWS Parameter Store") - throw error - } catch { - log.debug("Unexpected error while retrieving secrets\n\(error)") - throw wrapCredentialError(error) - } -} -``` +Implemented as `/xcodeinstall/apple-credentials` and `/xcodeinstall/apple-session-token`, giving a clean IAM resource of `parameter/xcodeinstall/*`. -`SecretsStorageAWSError` needs a new `invalidSecretValue(secretname:)` case (or reuse `secretDoesNotExist`) since the guard replaces a previously implicit optional unwrap. +### CLI flag renamed (breaking change) -### 3. Fix the concrete error type in the authenticate flow +`--secretmanager-region` was renamed to `--secret-region`. The short form `-s` is unchanged. The `PersistentConfig` JSON key was renamed from `secretManagerRegion` to `secretRegion`. Migration instructions are documented in the README. -**Modify:** `Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift` +## Implementation summary -This will not compile otherwise. Line 8 imports `SotoSecretsManager` solely so line ~125 can catch `SecretsManagerErrorType == .resourceNotFoundException` and transparently prompt for credentials when the secret is absent. - -- `import SotoSecretsManager` → `import SotoSSM` -- `catch let error as SotoSecretsManager.SecretsManagerErrorType where error == .resourceNotFoundException` → `catch let error as SSMErrorType where error == .parameterNotFound` -- Reword the four user-facing "AWS Secrets Manager" strings - -No other call site depends on the concrete error type: `HTTPClient.swift:84,107` and `DownloadManager.swift:61` all call `loadSession`/`loadCookies` with `try?`. - -### 4. Update names and stale comments - -**Modify:** `Sources/xcodeinstall/Secrets/SecretsStorageAWS.swift` - -- `AWSSecretsName` raw values → `/xcodeinstall/apple-credentials`, `/xcodeinstall/apple-session-token` -- Permission comment (lines 71–74) → `ssm:PutParameter`, `ssm:GetParameter` -- Comment at lines 100–102 references the Secrets Manager 30-day deletion policy as the reason `clearSecrets` writes an empty session instead of deleting. Parameter Store deletes immediately, but keep the current behaviour anyway: writing an empty session avoids needing `ssm:DeleteParameter`. Reword the comment to say that. - -### 5. Update the IAM policy file - -**Modify:** `iam/ec2-policy.json` - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "xcodeinstall", - "Effect": "Allow", - "Action": [ - "ssm:PutParameter", - "ssm:GetParameter" - ], - "Resource": "arn:aws:ssm:*:000000000000:parameter/xcodeinstall/*" - } - ] -} -``` - -### 6. Update the tests - -**Modify:** `Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift` - -Replace the injected `SecretsManager(client:endpoint:)` with `SSM(client:endpoint:)` passed as `ssmClient:`, and swap the import. Localstack supports Parameter Store, so `SotoTestEnvironment` needs no change. The three region-validation tests need no logic change. - -`Tests/xcodeinstallTests/Secrets/MockedSecretsHandler.swift` mocks `SecretsStorageAWSSDKProtocol`, whose signature is unchanged — no edit needed. - -### 7. Update the README - -**Modify:** `README.md` - -IAM content appears twice and both copies need the policy from step 5: -- ~line 450: section heading, ~452 intro, ~455 the policy JSON -- ~line 502: prose, ~505 the same policy inside the `cat << EOF > ec2-policy.json` heredoc -- ~line 474: prose describing what the role grants - -Then the ~30 "AWS Secrets Manager" mentions: line 11 tagline, 23, 28, 54, 56, 58 (motivation section), 139, 162, 169, 208–209/286–287/327–328 (repeated `-s` flag help text), 217, 235, 237, 255, 257, 261, 268. - -Add a migration note: nothing carries over from Secrets Manager, so existing users must re-run `storesecrets` and `authenticate`, and should delete the old secrets (`aws secretsmanager delete-secret --secret-id xcodeinstall-apple-credentials`) or keep paying $0.40/month each. Mention the Intelligent-Tiering behaviour and the one-way advanced-tier promotion. - -### 8. Optional cleanup - -`scripts/e2e-test.sh` lines 10 and 61 — comment and output strings only, no functional impact. +| File | Change | +|---|---| +| `Package.swift` | `SotoSecretsManager` → `SotoSSM` | +| `Sources/xcodeinstall/Secrets/SecretsStorageAWS+Soto.swift` | Rewritten: `SSM` client, `PutParameter`/`GetParameter`, no retry logic | +| `Sources/xcodeinstall/Secrets/SecretsStorageAWS.swift` | Parameter names updated, comments updated | +| `Sources/xcodeinstall/Secrets/SecretsStorageAWSError.swift` | Added `invalidSecretValue`, `noCredentialProvider` cases with diagnostic messages | +| `Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift` | `import SotoSSM`, catch `SSMErrorType.parameterNotFound`, user-facing strings updated | +| `Sources/xcodeinstall/xcodeInstall/StoreSecretsCommand.swift` | User-facing strings updated | +| `Sources/xcodeinstall/CLI-driver/CLIMain.swift` | Flag renamed to `--secret-region`, config key to `secretRegion` | +| `Sources/xcodeinstall/CLI-driver/CLIStoreSecrets.swift` | Flag renamed | +| `Sources/xcodeinstall/CLI-driver/CLIAuthenticate.swift` | Uses `cloudOption.secretRegion` | +| `Sources/xcodeinstall/CLI-driver/CLIDownload.swift` | Uses `cloudOption.secretRegion` | +| `Sources/xcodeinstall/CLI-driver/CLIList.swift` | Uses `cloudOption.secretRegion` | +| `Sources/xcodeinstall/Utilities/ConfigHandler.swift` | `PersistentConfig.secretRegion` | +| `Sources/xcodeinstall/CLI/CredentialPrompt.swift` | **New**: shared `promptForAppleCredentials` helper (deduplicates credential prompting) | +| `iam/ec2-policy.json` | `ssm:PutParameter`, `ssm:GetParameter`, resource `parameter/xcodeinstall/*` | +| `Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift` | `SSM` client injection | +| `Tests/xcodeinstallTests/Utilities/ConfigHandlerTests.swift` | Uses `secretRegion` key | +| `README.md` | Full rewrite of AWS sections, migration callout, IAM policy | +| `scripts/e2e-test.sh` | Comments and strings updated | ## Verification -1. `swift build` — catches the `SotoSSM` swap and the `AuthenticateCommand` error-type change. -2. `swift test` — the Soto suite plus the mocked handler suite. -3. Manual against the real account with profile `pro`: `storesecrets`, then `authenticate` through a full MFA flow, then `list` and `download` to confirm the session round-trips. -4. After the MFA round-trip, re-measure the stored value and record whether Intelligent-Tiering promoted it: - ``` - aws ssm get-parameter --profile pro --name /xcodeinstall/apple-session-token \ - --with-decryption --query 'Parameter.Value' --output text | wc -c - aws ssm describe-parameters --profile pro \ - --parameter-filters "Key=Name,Values=/xcodeinstall/" --query 'Parameters[].[Name,Tier]' - ``` - The one gap in the current measurements is a session captured through an actual two-factor prompt, which is the only path that yields an `aasp` cookie. Force that path (fresh machine or cleared trust token) and measure it. If even that stays comfortably under 4096 bytes, pinning `.standard` becomes defensible; if Intelligent-Tiering promotes the parameter to advanced, say so in the README. -5. Confirm the IAM policy is sufficient with no `kms:*` actions, using a least-privilege role rather than the admin profile. - -## Files touched - -| File | Size of change | -|---|---| -| `Package.swift` | 1 line | -| `Sources/xcodeinstall/Secrets/SecretsStorageAWS+Soto.swift` | rewrite, net ~70 lines smaller | -| `Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift` | import + 1 catch clause + 4 strings | -| `Sources/xcodeinstall/Secrets/SecretsStorageAWS.swift` | 2 raw values + comments | -| `Sources/xcodeinstall/Secrets/SecretsStorageAWSError.swift` | 1 new case | -| `iam/ec2-policy.json` | 2 actions + resource ARN | -| `Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift` | client injection | -| `README.md` | 2 policy blocks + ~30 prose mentions + migration note | - -`Tests/coverage.html` and `Tests/coverage.json` also contain matches but are generated artifacts. +- `swift build` ✅ +- `swift test` — all 205 tests pass ✅ -## Open questions +## Resolved decisions -1. Hierarchical `/xcodeinstall/*` names or keep the existing flat names? -2. Should `retrieveSecret` swallow `parameterNotFound` for `.appleSessionToken` and return an empty `AppleSessionSecret`? `SecretsStorageAWS.saveCookies` currently reads the session parameter and rethrows on failure, which is only safe because `clearSecrets()` runs first. Swallowing it for the session token (while still throwing for `.appleCredentials`, which `AuthenticateCommand` relies on) would remove that ordering dependency. Optional hardening; widens the diff. -3. Add `--region` as an alias for `-s/--secretmanager-region` now, or leave for a separate change? +1. **Hierarchical names** — implemented (`/xcodeinstall/*`). +2. **`retrieveSecret` on missing session** — kept existing behavior (throws, caller handles). The `clearSecrets()` call before `authenticate` ensures the parameter exists. +3. **CLI flag rename** — implemented as a breaking change with migration docs rather than keeping the old name. diff --git a/Package.swift b/Package.swift index a4b200e..864ca0f 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,7 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), - .product(name: "SotoSecretsManager", package: "soto"), + .product(name: "SotoSSM", package: "soto"), .product(name: "SRP", package: "swift-srp"), .product(name: "Noora", package: "Noora"), .product(name: "_CryptoExtras", package: "swift-crypto"), diff --git a/README.md b/README.md index 37f9df5..ba580e3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ![platform](https://img.shields.io/badge/platform-macOS-green) [![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -A command line utility to download and install Xcode in headless mode — designed for preparing EC2 Mac AMIs with AWS Secrets Manager integration. +A command line utility to download and install Xcode in headless mode — designed for preparing EC2 Mac AMIs with AWS Parameter Store integration. ## TL;DR @@ -20,12 +20,12 @@ A command line utility to download and install Xcode in headless mode — design `xcodeinstall` is a command line utility to download and install Xcode from the terminal only. It works both interactively and unattended: - **Interactive mode**: Prompts you for your Apple Developer account username, password, and MFA code -- **Unattended mode**: Fetches your Apple Developer credentials from AWS Secrets Manager +- **Unattended mode**: Fetches your Apple Developer credentials from AWS Parameter Store ### Key Features ✅ **AMI Automation Ready**: Fully scriptable for Packer, Ansible, or shell-based image builds -✅ **AWS Secrets Manager Integration**: Centralized credentials and shared session tokens across your fleet +✅ **AWS Parameter Store Integration**: Centralized credentials and shared session tokens across your fleet, at no storage cost ✅ **Multi-Machine Support**: Authenticate once on your laptop, use the session on all EC2 instances ✅ **Multi-Version Management**: Install multiple Xcode versions side-by-side and switch between them ✅ **Automated Downloads**: Download any Xcode version from Apple Developer Portal @@ -51,11 +51,11 @@ Unlike other Xcode management tools designed for local development, `xcodeinstal **How `xcodeinstall` solves it:** -1. **Centralized credentials with AWS Secrets Manager** — Store your Apple Developer credentials once, access them from any EC2 Mac instance during AMI builds. No SSH-ing into machines to paste passwords. +1. **Centralized credentials with AWS Parameter Store** — Store your Apple Developer credentials once, access them from any EC2 Mac instance during AMI builds. No SSH-ing into machines to paste passwords. -2. **Shared session tokens** — Authenticate on your laptop (where you can receive the MFA code), then your Packer or Ansible image build uses that session via Secrets Manager. +2. **Shared session tokens** — Authenticate on your laptop (where you can receive the MFA code), then your Packer or Ansible image build uses that session via Parameter Store. -3. **IAM-based access control** — No API keys or config files baked into the image. Attach an IAM role to the builder instance and `xcodeinstall` authenticates to Secrets Manager automatically via the instance profile. +3. **IAM-based access control** — No API keys or config files baked into the image. Attach an IAM role to the builder instance and `xcodeinstall` authenticates to Parameter Store automatically via the instance profile. 4. **Fully scriptable** — Every command works non-interactively with `--name` flags, integrating cleanly into Packer provisioners, Ansible playbooks, or EC2 Image Builder components. @@ -136,7 +136,7 @@ xcodeinstall install --name "Xcode_26.5_Apple_silicon.xip" xcodeinstall switch 26.4 ``` -**Using AWS Secrets Manager?** Add `-s ` and `-p ` flags to the authenticate command. These settings are **automatically saved** and reused for subsequent commands. See [AWS Secrets Manager](#using-aws-secrets-manager-1) section below. +**Using AWS Parameter Store?** Add `-s ` and `-p ` flags to the authenticate command. These settings are **automatically saved** and reused for subsequent commands. See [AWS Parameter Store](#using-aws-parameter-store) section below. ### Overview @@ -159,14 +159,14 @@ SUBCOMMANDS: download Download the specified version of Xcode install Install a specific XCode version or addon package switch Switch the active Xcode version - storesecrets Store Apple Developer credentials in AWS Secrets Manager + storesecrets Store Apple Developer credentials in AWS Parameter Store See 'xcodeinstall help ' for detailed help. ``` ### Persistent Configuration -When using AWS Secrets Manager, `xcodeinstall` **automatically saves** your `-s` (AWS region) and `-p` (AWS profile) settings to `~/.xcodeinstall/config.json`. +When using AWS Parameter Store, `xcodeinstall` **automatically saves** your `-s` (AWS region) and `-p` (AWS profile) settings to `~/.xcodeinstall/config.json`. **First time:** Specify the options explicitly: ```bash @@ -205,8 +205,8 @@ USAGE: xcodeinstall authenticate [--verbose] [-s ] [-p ] OPTIONS: -v, --verbose Produce verbose output for debugging - -s, --secretmanager-region - Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region + -s, --secret-region + Instructs to store and read secrets on AWS in the given AWS Region -p, --profile The AWS profile name to use for authentication (from ~/.aws/credentials and ~/.aws/config) --version Show the version. -h, --help Show help information. @@ -214,7 +214,7 @@ OPTIONS: #### Interactive Authentication (Local Storage) -For local development or testing, authenticate without AWS Secrets Manager: +For local development or testing, authenticate without AWS Parameter Store: ```bash ➜ ~ xcodeinstall authenticate @@ -232,9 +232,26 @@ Authenticating... ✅ Authenticated with MFA. ``` -#### Using AWS Secrets Manager +#### Using AWS Parameter Store -For production, CI/CD, or multi-machine setups, use AWS Secrets Manager to store credentials and session tokens securely: +> ### ⚠️ Migrating from AWS Secrets Manager +> +> Earlier versions of `xcodeinstall` stored secrets in AWS Secrets Manager. This is a **breaking change** and needs three things from you. +> +> **1. Update your IAM policy.** The permissions changed from `secretsmanager:*` to `ssm:PutParameter` and `ssm:GetParameter`. See [Minimum IAM Permissions](#minimum-iam-permissions-required-to-use-aws-parameter-store). Nothing works until the policy is updated. +> +> **2. Re-create your secrets.** Nothing migrates automatically. Re-run `storesecrets` and `authenticate`, then delete the old secrets so they stop costing $0.40/month each: +> +> ```bash +> aws secretsmanager delete-secret --secret-id xcodeinstall-apple-credentials --force-delete-without-recovery +> aws secretsmanager delete-secret --secret-id xcodeinstall-apple-session-token --force-delete-without-recovery +> ``` +> +> **3. Rename the flag in your scripts.** `--secretmanager-region` is now `--secret-region`. The short form `-s` is unchanged, so `-s ` keeps working and only the long form needs updating. +> +> Also note that `~/.xcodeinstall/config.json` uses a new key name for the region, so your saved region is dropped on first run after upgrading. The next command that passes `-s` saves it again. Your saved `-p` profile is unaffected. + +For production, CI/CD, or multi-machine setups, use AWS Parameter Store to store credentials and session tokens securely: ```bash ➜ ~ xcodeinstall authenticate -s us-west-2 -p myprofile @@ -252,20 +269,21 @@ Authenticating... ![Apple MFA code](img/mfa-02.png) -2. Your Apple Developer Portal **username and password are NEVER stored** on disk. They are only used to authenticate with Apple's API and obtain a session token. When using AWS Secrets Manager, **your username and password are stored and encrypted on Secrets Manager**. +2. Your Apple Developer Portal **username and password are NEVER stored** on disk. They are only used to authenticate with Apple's API and obtain a session token. When using AWS Parameter Store, **your username and password are stored as an encrypted `SecureString` parameter**. -3. The **session token is stored** either locally in `~/.xcodeinstall/` or on AWS Secrets Manager (your choice). +3. The **session token is stored** either locally in `~/.xcodeinstall/` or on AWS Parameter Store (your choice). 4. Sessions typically remain valid for several days or weeks. When expired, re-authentication is required. Apple may also prompt for re-authentication when connecting from a new IP address or location. -**AWS Secrets Manager Benefits:** -- **Secure storage**: Credentials and session tokens stored in AWS cloud +**AWS Parameter Store Benefits:** +- **Secure storage**: Credentials and session tokens stored in AWS cloud as encrypted `SecureString` parameters - **Multi-machine access**: Authenticate on your laptop, use the session on EC2 instances - **Automatic configuration**: Region and profile settings saved after first use +- **No storage cost**: Standard-tier parameters are free, and they are encrypted with the account's default `aws/ssm` KMS key at no extra charge **Important:** The `-s` (region) and `-p` (profile) options are **automatically saved** to `~/.xcodeinstall/config.json` for subsequent commands. You only need to specify them once. -> **Note:** When using Secrets Manager, you must use the **same AWS region and profile** for all commands (`authenticate`, `list`, `download`). The saved configuration ensures consistency across commands. +> **Note:** When using Parameter Store, you must use the **same AWS region and profile** for all commands (`authenticate`, `list`, `download`). The saved configuration ensures consistency across commands. ### List Files Available to Download @@ -283,8 +301,8 @@ OPTIONS: Filter on provided Xcode version number (default: 13) -m, --most-recent-first Sort by most recent releases first -d, --date-published Show publication date - -s, --secretmanager-region - Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region + -s, --secret-region + Instructs to store and read secrets on AWS in the given AWS Region -p, --profile The AWS profile name to use for authentication --version Show the version. -h, --help Show help information. @@ -302,7 +320,7 @@ xcodeinstall list --only-xcode --most-recent-first # Filter by Xcode version 15 xcodeinstall list --only-xcode --xcode-version 15 -# With AWS Secrets Manager (uses saved settings if available) +# With AWS Parameter Store (uses saved settings if available) xcodeinstall list # Info: Using saved settings: -s us-west-2 -p myprofile ``` @@ -324,8 +342,8 @@ OPTIONS: -m, --most-recent-first Sort by most recent releases first -d, --date-published Show publication date -n, --name The exact package name to download. When omitted, it prompts interactively - -s, --secretmanager-region - Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region + -s, --secret-region + Instructs to store and read secrets on AWS in the given AWS Region -p, --profile The AWS profile name to use for authentication --version Show the version. -h, --help Show help information. @@ -340,7 +358,7 @@ xcodeinstall download --only-xcode # Specify exact file name (useful for automation) xcodeinstall download --name "Xcode 15.2.xip" -# With AWS Secrets Manager (uses saved settings if available) +# With AWS Parameter Store (uses saved settings if available) xcodeinstall download --name "Xcode 15.2.xip" # Info: Using saved settings: -s us-west-2 -p myprofile ``` @@ -447,9 +465,9 @@ Switching updates both the `/Applications/Xcode.app` symlink and runs `sudo xcod - If `/Applications/Xcode.app` already exists as a real directory (not a symlink), the tool will refuse to overwrite it and ask you to rename or remove it first. - Existing versioned installations are never modified when installing a new version. -## Minimum IAM Permissions required to use AWS Secrets Manager +## Minimum IAM Permissions required to use AWS Parameter Store -The minimum IAM permisions required to use this tool with AWS Secrets Manager is as below (do not forget to replace 000000000000 with your AWS Account ID) +The minimum IAM permisions required to use this tool with AWS Parameter Store is as below (do not forget to replace 000000000000 with your AWS Account ID) ```json { @@ -459,19 +477,20 @@ The minimum IAM permisions required to use this tool with AWS Secrets Manager is "Sid": "xcodeinstall", "Effect": "Allow", "Action": [ - "secretsmanager:CreateSecret", - "secretsmanager:GetSecretValue", - "secretsmanager:PutSecretValue" + "ssm:PutParameter", + "ssm:GetParameter" ], - "Resource": "arn:aws:secretsmanager:*:000000000000:secret:xcodeinstall-*" + "Resource": "arn:aws:ssm:*:000000000000:parameter/xcodeinstall/*" } ] } ``` +No `kms:*` permission is required. `xcodeinstall` stores its parameters as `SecureString`, encrypted with your account's default `aws/ssm` AWS managed key, which every principal in the account may use through Systems Manager. If you prefer a customer managed KMS key, add `kms:Encrypt` and `kms:Decrypt` for that key (`kms:GenerateDataKey` instead of `kms:Encrypt` if the parameter is promoted to the advanced tier). + Once associated with an IAM Role, you can attach the role to any IAM principal : user, group or an AWS service, such as an EC2 Mac instance. Here are instructions to do so. - *Create* an IAM role that contains the minimum set of permissions to allow `xcodeinstall` to interact with AWS Secrets Manager, then *attach* this role to the EC2 Mac instance where you run `xcodeinstall`. + *Create* an IAM role that contains the minimum set of permissions to allow `xcodeinstall` to interact with AWS Parameter Store, then *attach* this role to the EC2 Mac instance where you run `xcodeinstall`. From a machine where the AWS CLI is installed and where you have AWS credentials allowing you to create roles and permissions (typically your laptop), type the following commands : @@ -499,7 +518,7 @@ aws iam create-role \ --assume-role-policy-document file://ec2-role-trust-policy.json ``` -2. Second, create a policy that contains the minimum set of permissions to interact with AWS Secrets Manager +2. Second, create a policy that contains the minimum set of permissions to interact with AWS Parameter Store ```zsh # Create the policy file with the set of permissions @@ -512,11 +531,10 @@ cat << EOF > ec2-policy.json "Sid": "xcodeinstall", "Effect": "Allow", "Action": [ - "secretsmanager:CreateSecret", - "secretsmanager:GetSecretValue", - "secretsmanager:PutSecretValue" + "ssm:PutParameter", + "ssm:GetParameter" ], - "Resource": "arn:aws:secretsmanager:*:000000000000:secret:xcodeinstall-*" + "Resource": "arn:aws:ssm:*:000000000000:parameter/xcodeinstall/*" } ] } @@ -568,17 +586,22 @@ aws ec2 associate-iam-instance-profile \ When you start other EC2 Mac instance, you just need to attach the profile to the new instance. The Policy and Role can be reused for multiple EC2 instances. -## How to Store Your Secrets on AWS Secrets Manager +## How to Store Your Secrets on AWS Parameter Store -When using AWS Secrets Manager to store your Apple Developer Portal credentials, you need to create a secret in the following format: +When using AWS Parameter Store to store your Apple Developer Portal credentials, you need to create a parameter in the following format: -- **Secret name:** `xcodeinstall-apple-credentials` -- **Secret format:** JSON with username and password: +- **Parameter name:** `/xcodeinstall/apple-credentials` +- **Parameter type:** `SecureString` +- **Parameter value:** JSON with username and password: ```json {"username":"your_username","password":"your_password"} ``` +`xcodeinstall` also maintains a second parameter, `/xcodeinstall/apple-session-token`, which holds the Apple session and cookies. You never create that one yourself — `authenticate` writes it for you. + +Both parameters are created with the `Intelligent-Tiering` tier. They stay in the free standard tier while under 4 KB and are promoted automatically to the advanced tier ($0.05/parameter/month) only if the stored session grows past that. Note that this promotion is one-way: an advanced parameter cannot be reverted to standard. + ### Using the `storesecrets` Command The easiest way to create this secret is using the built-in `storesecrets` command: @@ -586,7 +609,7 @@ The easiest way to create this secret is using the built-in `storesecrets` comma ```bash ➜ ~ xcodeinstall storesecrets -s us-west-2 -p myprofile -This command captures your Apple ID username and password and stores them securely in AWS Secrets Manager. +This command captures your Apple ID username and password and stores them securely in AWS Parameter Store. It allows this command to authenticate automatically, as long as no MFA is prompted. ⌨️ Enter your Apple ID username: your.email@example.com @@ -595,14 +618,14 @@ It allows this command to authenticate automatically, as long as no MFA is promp ``` **Options:** -- `-s, --secretmanager-region`: AWS region where the secret will be stored (choose a region close to you for lower latency) +- `-s, --secret-region`: AWS region where the parameter will be stored (choose a region close to you for lower latency) - `-p, --profile`: AWS profile name to use (from `~/.aws/credentials` and `~/.aws/config`) **Important:** Unlike other commands, `storesecrets` requires you to specify `-s` and `-p` every time, as it's typically a one-time setup operation. ### After Storing Credentials -Once credentials are stored in AWS Secrets Manager: +Once credentials are stored in AWS Parameter Store: 1. Authenticate once with the same region and profile: ```bash @@ -654,7 +677,7 @@ rm -rf ~/.xcodeinstall/ - Or clear the config file: `rm ~/.xcodeinstall/config.json` **Session expired errors:** -- Run `xcodeinstall authenticate` (with `-s` and `-p` if using AWS Secrets Manager) +- Run `xcodeinstall authenticate` (with `-s` and `-p` if using AWS Parameter Store) - Enter your MFA code when prompted **AWS credentials not found:** @@ -680,14 +703,14 @@ I listed a couple of ideas below. **AWS Integration:** - Add possibility to emit SNS notifications on errors (e.g., Session Expired) -- Support for additional AWS authentication methods (SSO, OIDC) **Configuration Management:** - Add explicit config management commands (`config show`, `config clear`) -- Support for multiple named profiles (`--save-as dev`, `--use-profile dev`) - Environment variable fallback (`XCODEINSTALL_REGION`, `XCODEINSTALL_PROFILE`) **Completed:** +- [x] Support for multiple named profiles (`--save-as dev`, `--use-profile dev`) +- [x] Support for additional AWS authentication methods (SSO, OIDC) - done by SotoCore. - [x] Clean room implementation of progress bar to remove dependency on Swift Tools Core library - [x] Persistent configuration for `-s` and `-p` options - [x] Manage multiple versions of Xcode (rename `Xcode.app` to `Xcode-version.app` and use symlinks) diff --git a/Sources/xcodeinstall/CLI-driver/CLIAuthenticate.swift b/Sources/xcodeinstall/CLI-driver/CLIAuthenticate.swift index 42567c4..98c559b 100644 --- a/Sources/xcodeinstall/CLI-driver/CLIAuthenticate.swift +++ b/Sources/xcodeinstall/CLI-driver/CLIAuthenticate.swift @@ -34,7 +34,7 @@ extension MainCommand { let xci = try await MainCommand.makeXCodeInstall( with: deps, - for: cloudOption.secretManagerRegion, + for: cloudOption.secretRegion, profileName: cloudOption.profileName, verbose: globalOptions.verbose ) @@ -59,7 +59,7 @@ extension MainCommand { let xci = try await MainCommand.makeXCodeInstall( with: deps, - for: cloudOption.secretManagerRegion, + for: cloudOption.secretRegion, profileName: cloudOption.profileName, verbose: globalOptions.verbose ) diff --git a/Sources/xcodeinstall/CLI-driver/CLIDownload.swift b/Sources/xcodeinstall/CLI-driver/CLIDownload.swift index b404c00..0e2d9bb 100644 --- a/Sources/xcodeinstall/CLI-driver/CLIDownload.swift +++ b/Sources/xcodeinstall/CLI-driver/CLIDownload.swift @@ -39,7 +39,7 @@ extension MainCommand { func run(with deps: AppDependencies?) async throws { let xci = try await MainCommand.makeXCodeInstall( with: deps, - for: cloudOption.secretManagerRegion, + for: cloudOption.secretRegion, profileName: cloudOption.profileName, verbose: globalOptions.verbose ) diff --git a/Sources/xcodeinstall/CLI-driver/CLIList.swift b/Sources/xcodeinstall/CLI-driver/CLIList.swift index d0ecc3c..110b681 100644 --- a/Sources/xcodeinstall/CLI-driver/CLIList.swift +++ b/Sources/xcodeinstall/CLI-driver/CLIList.swift @@ -65,7 +65,7 @@ extension MainCommand { func run(with deps: AppDependencies?) async throws { let xci = try await MainCommand.makeXCodeInstall( with: deps, - for: cloudOption.secretManagerRegion, + for: cloudOption.secretRegion, profileName: cloudOption.profileName, verbose: globalOptions.verbose ) diff --git a/Sources/xcodeinstall/CLI-driver/CLIMain.swift b/Sources/xcodeinstall/CLI-driver/CLIMain.swift index 0dd5bd7..e8b3f6e 100644 --- a/Sources/xcodeinstall/CLI-driver/CLIMain.swift +++ b/Sources/xcodeinstall/CLI-driver/CLIMain.swift @@ -33,10 +33,10 @@ struct MainCommand: AsyncParsableCommand { struct CloudOptions: ParsableArguments { @Option( - name: [.customLong("secretmanager-region"), .short], - help: "Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region" + name: [.customLong("secret-region"), .short], + help: "Instructs to store and read secrets on AWS in the given AWS Region" ) - var secretManagerRegion: String? + var secretRegion: String? @Option( name: [.customLong("profile"), .customShort("p")], @@ -102,7 +102,7 @@ struct MainCommand: AsyncParsableCommand { let urlSession = URLSession.shared let secrets: SecretsHandlerProtocol - if let effectiveRegion = resolved.secretManagerRegion { + if let effectiveRegion = resolved.secretRegion { secrets = try await SecretsStorageAWS( region: effectiveRegion, profileName: resolved.profileName, diff --git a/Sources/xcodeinstall/CLI-driver/CLIStoreSecrets.swift b/Sources/xcodeinstall/CLI-driver/CLIStoreSecrets.swift index f3ac404..10b193b 100644 --- a/Sources/xcodeinstall/CLI-driver/CLIStoreSecrets.swift +++ b/Sources/xcodeinstall/CLI-driver/CLIStoreSecrets.swift @@ -20,17 +20,17 @@ extension MainCommand { nonisolated static let configuration = CommandConfiguration( commandName: "storesecrets", - abstract: "Store your Apple Developer Portal username and password in AWS Secrets Manager" + abstract: "Store your Apple Developer Portal username and password in AWS Parameter Store" ) @OptionGroup var globalOptions: GlobalOptions // repeat of CloudOption but this time mandatory @Option( - name: [.customLong("secretmanager-region"), .short], - help: "Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region" + name: [.customLong("secret-region"), .short], + help: "Instructs to store and read secrets on AWS in the given AWS Region" ) - var secretManagerRegion: String + var secretRegion: String @Option( name: [.customLong("profile"), .customShort("p")], @@ -45,7 +45,7 @@ extension MainCommand { func run(with deps: AppDependencies?) async throws { let xci = try await MainCommand.makeXCodeInstall( with: deps, - for: secretManagerRegion, + for: secretRegion, profileName: profileName, verbose: globalOptions.verbose ) diff --git a/Sources/xcodeinstall/CLI/CredentialPrompt.swift b/Sources/xcodeinstall/CLI/CredentialPrompt.swift new file mode 100644 index 0000000..0c461b6 --- /dev/null +++ b/Sources/xcodeinstall/CLI/CredentialPrompt.swift @@ -0,0 +1,79 @@ +// +// CredentialPrompt.swift +// xcodeinstall +// +// Shared helper that prompts for Apple ID credentials. +// Used by both `storesecrets` and `authenticate` flows. +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// Context message shown before prompting for credentials. +enum CredentialPromptContext { + /// Credentials will be stored in AWS Parameter Store. + case storingToAWS + /// One-time interactive use (not stored remotely). + case interactive +} + +/// Prompts the user for Apple ID username and password, displaying an appropriate +/// context message based on the intended use. +/// +/// - Parameters: +/// - context: Determines the introductory message shown to the user. +/// - display: The display backend for output. +/// - readLine: The readline backend for input. +/// - Returns: The captured credentials. +/// - Throws: `CLIError.invalidInput` if the user provides no input. +@MainActor +func promptForAppleCredentials( + context: CredentialPromptContext, + display: DisplayProtocol, + readLine: ReadLineProtocol +) throws -> AppleCredentialsSecret { + + switch context { + case .storingToAWS: + display.display( + """ + Your Apple ID credentials will be securely stored in AWS Parameter Store + for future authentication. + """, + style: .security + ) + case .interactive: + display.display( + """ + We prompt you for your Apple ID username, password, and two factors authentication code. + These values are not stored anywhere. They are used to get an Apple session ID. + + Alternatively, you may store your credentials on AWS Parameter Store + """, + style: .security + ) + } + + guard + let username = readLine.readLine( + prompt: "Enter your Apple ID username: ", + silent: false + ) + else { + throw CLIError.invalidInput + } + + guard + let password = readLine.readLine( + prompt: "Enter your Apple ID password: ", + silent: true + ) + else { + throw CLIError.invalidInput + } + + return AppleCredentialsSecret(username: username, password: password) +} diff --git a/Sources/xcodeinstall/Secrets/SecretsHandler.swift b/Sources/xcodeinstall/Secrets/SecretsHandler.swift index bdeb078..72d3a6b 100644 --- a/Sources/xcodeinstall/Secrets/SecretsHandler.swift +++ b/Sources/xcodeinstall/Secrets/SecretsHandler.swift @@ -28,7 +28,7 @@ enum SecretsStorageError: Error, LocalizedError { } } -// the data to be stored in Secrets Manager as JSON +// the data to be stored in Parameter Store as JSON struct AppleCredentialsSecret: Codable, Secrets { let username: String diff --git a/Sources/xcodeinstall/Secrets/SecretsStorageAWS+Soto.swift b/Sources/xcodeinstall/Secrets/SecretsStorageAWS+Soto.swift index a1b93a5..337fa27 100644 --- a/Sources/xcodeinstall/Secrets/SecretsStorageAWS+Soto.swift +++ b/Sources/xcodeinstall/Secrets/SecretsStorageAWS+Soto.swift @@ -6,7 +6,7 @@ // import Logging -import SotoSecretsManager +import SotoSSM #if canImport(FoundationEssentials) import FoundationEssentials @@ -18,16 +18,14 @@ import Foundation final class SecretsStorageAWSSoto: SecretsStorageAWSSDKProtocol { let log: Logger - let maxRetries = 3 let profileName: String? let awsClient: AWSClient? // var for injection - let smClient: SecretsManager? // var for injection + let ssmClient: SSM? // var for injection - private init(awsClient: AWSClient? = nil, smClient: SecretsManager? = nil, profileName: String? = nil, log: Logger) - { + private init(awsClient: AWSClient? = nil, ssmClient: SSM? = nil, profileName: String? = nil, log: Logger) { self.awsClient = awsClient - self.smClient = smClient + self.ssmClient = ssmClient self.profileName = profileName self.log = log } @@ -37,13 +35,13 @@ final class SecretsStorageAWSSoto: SecretsStorageAWSSDKProtocol { profileName: String? = nil, log: Logger ) throws -> SecretsStorageAWSSDKProtocol { - try SecretsStorageAWSSoto.forRegion(region, profileName: profileName, awsClient: nil, smClient: nil, log: log) + try SecretsStorageAWSSoto.forRegion(region, profileName: profileName, awsClient: nil, ssmClient: nil, log: log) } static func forRegion( _ region: String, profileName: String? = nil, awsClient: AWSClient? = nil, - smClient: SecretsManager? = nil, + ssmClient: SSM? = nil, log: Logger ) throws -> SecretsStorageAWSSDKProtocol { guard let awsRegion = Region(awsRegionName: region) else { @@ -62,16 +60,16 @@ final class SecretsStorageAWSSoto: SecretsStorageAWSSDKProtocol { retryPolicy: .jitter() ) } - var newSMClient: SecretsManager? - if smClient == nil { - newSMClient = SecretsManager( + var newSSMClient: SSM? + if ssmClient == nil { + newSSMClient = SSM( client: awsClient ?? newAwsClient!, region: awsRegion ) } return SecretsStorageAWSSoto( awsClient: awsClient ?? newAwsClient!, - smClient: smClient ?? newSMClient!, + ssmClient: ssmClient ?? newSSMClient!, profileName: profileName, log: log ) @@ -104,141 +102,74 @@ final class SecretsStorageAWSSoto: SecretsStorageAWSSDKProtocol { } } - // MARK: private functions - AWS SecretsManager Call using Soto SDK - - // func list() async throws { - // print("calling list secrets") - // let request = SecretsManager.ListSecretsRequest() - // _ = try await smClient.listSecrets(request) - // } + // MARK: private functions - AWS Systems Manager Parameter Store calls using Soto SDK /// - /// Create a secret in AWS SecretsManager - /// - Parameters: - /// - secretId : the name of the secret - /// - secretValue : a string to store as a secret - /// - Throws: - /// This function throws error from the underlying SDK + /// Create or update a parameter holding a secret value. /// - private func createSecret(secretId: String, secretValue: Secrets) async throws { - do { - let secretString = try secretValue.string() - let createSecretRequest = SecretsManager.CreateSecretRequest( - description: "xcodeinstall secret", - name: secretId, - secretString: secretString - ) - _ = try await smClient?.createSecret(createSecretRequest) - } catch { - log.error("Can not create secret \(secretId) : \(error)") - throw error - } - } - + /// `PutParameter` with `overwrite: true` is an upsert, so — unlike Secrets Manager's + /// `PutSecretValue` — there is no need to create the parameter first when it does not exist yet. /// - /// Execute an API call AWS SecretsManager and create the secret when the secret name does not exist. - /// Aftre creating the secret, the API call is attempted again. The function tries 3 times before abording + /// The parameter is stored as a `SecureString`, encrypted with the account's default + /// `aws/ssm` AWS managed KMS key (no additional cost, no extra KMS permission required). /// - /// - Parameters: - /// - secretId : the name of the secret - /// - secretValue : a string to store as a secret, - /// - step: the current retry step (start at 1) - /// - block: the block of code to execute (contains the call to SecretsManager) - /// - Throws: - /// This function throws error from the underlying SDK + /// `Intelligent-Tiering` lets AWS create the parameter in the free standard tier and promote it + /// to the advanced tier only if the value grows beyond the 4 KB standard-tier limit. Measured + /// session secrets sit around 2.3–2.8 KB, but the cookie jar only ever grows, so this avoids a + /// hard failure at the cost of $0.05/month in the worst case. Note that the promotion to the + /// advanced tier is one-way: an advanced parameter cannot be reverted to standard. /// - - private func executeRequestAndCreateWhenNotExist( - secretId: String, - secretValue: Secrets, - step: Int, - block: () async throws -> Void - ) async throws { - - do { - // try to execute the supplied block - try await block() - - // if it fails with a resource not found error, - } catch let error as SotoSecretsManager.SecretsManagerErrorType { - - // create the resource and try again - if error == .resourceNotFoundException { - log.debug("Secrets \(secretId) does not exist, creating it") - try await self.createSecret(secretId: secretId, secretValue: secretValue) - - if step <= maxRetries { - // recursive call to ourselevs - log.debug("Re-trying the block call (attempt #\(step + 1))") - try await self.executeRequestAndCreateWhenNotExist( - secretId: secretId, - secretValue: secretValue, - step: step + 1, - block: block - ) - } else { - log.error("Max attempt to call Secrets Manager") - } - - } else { - log.error("AWS API Error\n\(error)") - throw error - } - - } - } - - /// - /// Update an existing secret - /// - /// - Parameters - /// - secretId : the name of the secret - /// - newValue : the updated value - /// - Throws: + /// - Parameters: + /// - secretId : the name of the parameter + /// - newValue : the value to store + /// - Throws: /// This function throws error from the underlying SDK /// func updateSecret(secretId: AWSSecretsName, newValue: T) async throws { do { + guard let secretString = try newValue.string() else { + throw SecretsStorageAWSError.invalidSecretValue(secretname: secretId.rawValue) + } - // maybe the secret does not exist yet - so wrap our call with - // a function hat will create it in case it does not exist - try await executeRequestAndCreateWhenNotExist( - secretId: secretId.rawValue, - secretValue: newValue, - step: 1, - block: { - - let secretString = try newValue.string() - let putSecretRequest = SecretsManager.PutSecretValueRequest( - secretId: secretId.rawValue, - secretString: secretString - ) - - log.debug("Updating secret \(secretId) with \(newValue)") - let putSecretResponse = try await smClient?.putSecretValue(putSecretRequest) - log.debug( - "\(putSecretResponse?.name ?? "") has version \(putSecretResponse?.versionId ?? "")" - ) - } + let putParameterRequest = SSM.PutParameterRequest( + description: "xcodeinstall secret", + name: secretId.rawValue, + overwrite: true, + tier: .intelligentTiering, + type: .secureString, + value: secretString ) + log.debug("Updating parameter \(secretId.rawValue)") + let putParameterResponse = try await ssmClient?.putParameter(putParameterRequest) + log.debug("\(secretId.rawValue) now has version \(putParameterResponse?.version ?? 0)") + } catch { log.debug("Unexpected error while updating secrets\n\(error)") throw wrapCredentialError(error) } } + /// + /// Retrieve and decode a secret stored in a Parameter Store parameter. + /// + /// - Parameters: + /// - secretId : the name of the parameter + /// - Throws: + /// `SSMErrorType.parameterNotFound` when the parameter does not exist, + /// or any other error from the underlying SDK + /// // FIXME: improve error handling when secret is not retrieved // swiftlint:disable force_cast func retrieveSecret(secretId: AWSSecretsName) async throws -> T { do { - let getSecretRequest = SecretsManager.GetSecretValueRequest(secretId: secretId.rawValue) - log.debug("Retrieving secret \(secretId)") - let getSecretResponse = try await smClient?.getSecretValue(getSecretRequest) - log.debug("Secret \(getSecretResponse?.name ?? "nil") retrieved") + let getParameterRequest = SSM.GetParameterRequest(name: secretId.rawValue, withDecryption: true) + log.debug("Retrieving parameter \(secretId.rawValue)") + let getParameterResponse = try await ssmClient?.getParameter(getParameterRequest) + log.debug("Parameter \(getParameterResponse?.parameter?.name ?? "nil") retrieved") - guard let secret = getSecretResponse?.secretString else { - log.error("⚠️ no value returned by AWS Secrets Manager secret \(secretId)") + guard let secret = getParameterResponse?.parameter?.value else { + log.error("⚠️ no value returned by AWS Parameter Store for parameter \(secretId)") return secretId == .appleCredentials ? AppleCredentialsSecret() as! T : AppleSessionSecret() as! T } @@ -250,10 +181,8 @@ final class SecretsStorageAWSSoto: SecretsStorageAWSSDKProtocol { return try AppleSessionSecret(fromString: secret) as! T } - } catch let error as SotoSecretsManager.SecretsManagerErrorType - where error == .resourceNotFoundException - { - log.debug("Secret \(secretId.rawValue) does not exist in AWS Secrets Manager") + } catch let error as SSMErrorType where error == .parameterNotFound { + log.debug("Parameter \(secretId.rawValue) does not exist in AWS Parameter Store") throw error } catch { diff --git a/Sources/xcodeinstall/Secrets/SecretsStorageAWS.swift b/Sources/xcodeinstall/Secrets/SecretsStorageAWS.swift index 03d9e90..732ee02 100644 --- a/Sources/xcodeinstall/Secrets/SecretsStorageAWS.swift +++ b/Sources/xcodeinstall/Secrets/SecretsStorageAWS.swift @@ -18,12 +18,14 @@ import FoundationNetworking #endif // the names we are using to store the secrets +// these are AWS Systems Manager Parameter Store parameter names, organised as a hierarchy +// so that a single IAM resource (parameter/xcodeinstall/*) covers them all enum AWSSecretsName: String { - case appleCredentials = "xcodeinstall-apple-credentials" - case appleSessionToken = "xcodeinstall-apple-session-token" + case appleCredentials = "/xcodeinstall/apple-credentials" + case appleSessionToken = "/xcodeinstall/apple-session-token" } -// the data to be stored in Secrets Manager as JSON +// the data to be stored in Parameter Store as JSON struct AppleSessionSecret: Codable, Secrets { var rawCookies: String? var session: AppleSession? @@ -68,10 +70,10 @@ protocol SecretsStorageAWSSDKProtocol { } // permissions needed -// secretsmanager:CreateSecret -// secretsmanager:TagResource ? -// secretsmanager:GetSecretValue -// secretsmanager:PutSecretValue +// ssm:PutParameter +// ssm:GetParameter +// no kms:* action is required : SecureString parameters are encrypted with the account's +// default aws/ssm AWS managed key, which every principal in the account may use class SecretsStorageAWS: SecretsHandlerProtocol { let log: Logger @@ -96,9 +98,8 @@ class SecretsStorageAWS: SecretsHandlerProtocol { try await awsSDK.shutdown() } - // I do not delete the secrets because there is a 30 days deletion policy - // https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_DeleteSecret.html - // Instead, I update the secret value with an empty secret + // I do not delete the parameter, I overwrite it with an empty session instead. + // This keeps ssm:DeleteParameter out of the IAM permissions this tool requires. func clearSecrets() async throws { let emptySession = AppleSessionSecret() @@ -138,7 +139,7 @@ class SecretsStorageAWS: SecretsHandlerProtocol { ) } catch { - log.debug("⚠️ can not save cookies file in AWS Secret Manager: \(error)") + log.debug("⚠️ can not save cookies in AWS Parameter Store: \(error)") throw error } diff --git a/Sources/xcodeinstall/Secrets/SecretsStorageAWSError.swift b/Sources/xcodeinstall/Secrets/SecretsStorageAWSError.swift index 8cd8962..d2fd8c0 100644 --- a/Sources/xcodeinstall/Secrets/SecretsStorageAWSError.swift +++ b/Sources/xcodeinstall/Secrets/SecretsStorageAWSError.swift @@ -11,10 +11,11 @@ import FoundationEssentials import Foundation #endif -/// Errors thrown by AWS Secrets Manager operations +/// Errors thrown by AWS Parameter Store operations enum SecretsStorageAWSError: Error, LocalizedError { case invalidRegion(region: String) case secretDoesNotExist(secretname: String) + case invalidSecretValue(secretname: String) case noCredentialProvider(profileName: String?, underlyingError: Error) var errorDescription: String? { @@ -23,6 +24,8 @@ enum SecretsStorageAWSError: Error, LocalizedError { return "Invalid AWS region: '\(region)'" case .secretDoesNotExist(let secretname): return "AWS secret '\(secretname)' does not exist" + case .invalidSecretValue(let secretname): + return "Can not serialize the value of AWS secret '\(secretname)'" case .noCredentialProvider(let profileName, let underlyingError): return buildCredentialErrorMessage(profileName: profileName, underlyingError: underlyingError) } diff --git a/Sources/xcodeinstall/Utilities/ConfigHandler.swift b/Sources/xcodeinstall/Utilities/ConfigHandler.swift index 58157e3..08439ec 100644 --- a/Sources/xcodeinstall/Utilities/ConfigHandler.swift +++ b/Sources/xcodeinstall/Utilities/ConfigHandler.swift @@ -23,7 +23,7 @@ protocol ConfigHandlerProtocol: Sendable { // Config data model struct PersistentConfig: Codable, Sendable { - var secretManagerRegion: String? + var secretRegion: String? var profileName: String? } @@ -84,7 +84,7 @@ struct ConfigHandler: ConfigHandlerProtocol { ) async throws -> PersistentConfig { let saved = loadConfig() - let effectiveRegion = cliRegion ?? saved?.secretManagerRegion + let effectiveRegion = cliRegion ?? saved?.secretRegion let effectiveProfile = cliProfile ?? saved?.profileName // Show info message for values coming from saved config @@ -101,13 +101,13 @@ struct ConfigHandler: ConfigHandlerProtocol { // Persist when CLI provided new values, merging with existing if cliRegion != nil || cliProfile != nil { let updated = PersistentConfig( - secretManagerRegion: effectiveRegion, + secretRegion: effectiveRegion, profileName: effectiveProfile ) try? saveConfig(updated) log.debug("Saved config") } - return PersistentConfig(secretManagerRegion: effectiveRegion, profileName: effectiveProfile) + return PersistentConfig(secretRegion: effectiveRegion, profileName: effectiveProfile) } } diff --git a/Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift b/Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift index e64117a..1864874 100644 --- a/Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift +++ b/Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift @@ -5,7 +5,7 @@ // Created by Stormacq, Sebastien on 16/08/2022. // -import SotoSecretsManager +import SotoSSM #if canImport(FoundationEssentials) import FoundationEssentials @@ -105,16 +105,16 @@ struct CLIAuthenticationDelegate: AuthenticationDelegate, Sendable { var appleCredentials: AppleCredentialsSecret do { - // first try on AWS Secrets Manager + // first try on AWS Parameter Store display("Retrieving Apple Developer Portal credentials...") appleCredentials = try await secrets.retrieveAppleCredentials() - // empty credentials means the secret exists but has no real values + // empty credentials means the parameter exists but has no real values if appleCredentials.username.isEmpty || appleCredentials.password.isEmpty { - display("Apple credentials secret exists but is empty.") + display("Apple credentials parameter exists but is empty.") appleCredentials = try promptForCredentials(storingToAWS: true) try await secrets.storeAppleCredentials(appleCredentials) - display("Credentials stored in AWS Secrets Manager", style: .security) + display("Credentials stored in AWS Parameter Store", style: .security) } } catch SecretsStorageError.invalidOperation { @@ -122,15 +122,13 @@ struct CLIAuthenticationDelegate: AuthenticationDelegate, Sendable { // we have a file secrets handler, prompt for credentials interactively appleCredentials = try promptForCredentials() - } catch let error as SotoSecretsManager.SecretsManagerErrorType - where error == .resourceNotFoundException - { - // the apple credentials secret doesn't exist yet in AWS Secrets Manager + } catch let error as SSMErrorType where error == .parameterNotFound { + // the apple credentials parameter doesn't exist yet in AWS Parameter Store // prompt the user and create it transparently - display("Apple credentials not found in AWS Secrets Manager, capturing them now...") + display("Apple credentials not found in AWS Parameter Store, capturing them now...") appleCredentials = try promptForCredentials(storingToAWS: true) try await secrets.storeAppleCredentials(appleCredentials) - display("Credentials stored in AWS Secrets Manager", style: .security) + display("Credentials stored in AWS Parameter Store", style: .security) } catch { @@ -142,45 +140,11 @@ struct CLIAuthenticationDelegate: AuthenticationDelegate, Sendable { } private func promptForCredentials(storingToAWS: Bool = false) throws -> AppleCredentialsSecret { - if storingToAWS { - display( - """ - Your Apple ID credentials will be securely stored in AWS Secrets Manager - for future authentication. - """, - style: .security - ) - } else { - display( - """ - We prompt you for your Apple ID username, password, and two factors authentication code. - These values are not stored anywhere. They are used to get an Apple session ID. - - Alternatively, you may store your credentials on AWS Secrets Manager - """, - style: .security - ) - } - - guard - let username = deps.readLine.readLine( - prompt: "Enter your Apple ID username: ", - silent: false - ) - else { - throw CLIError.invalidInput - } - - guard - let password = deps.readLine.readLine( - prompt: "Enter your Apple ID password: ", - silent: true - ) - else { - throw CLIError.invalidInput - } - - return AppleCredentialsSecret(username: username, password: password) + try promptForAppleCredentials( + context: storingToAWS ? .storingToAWS : .interactive, + display: deps.display, + readLine: deps.readLine + ) } } diff --git a/Sources/xcodeinstall/xcodeInstall/StoreSecretsCommand.swift b/Sources/xcodeinstall/xcodeInstall/StoreSecretsCommand.swift index 6ce3282..92347fc 100644 --- a/Sources/xcodeinstall/xcodeInstall/StoreSecretsCommand.swift +++ b/Sources/xcodeinstall/xcodeInstall/StoreSecretsCommand.swift @@ -30,32 +30,18 @@ extension XCodeInstall { display( """ - This command captures your Apple ID username and password and store them securely in AWS Secrets Manager. + This command captures your Apple ID username and password and store them securely in AWS Parameter Store. It allows this command to authenticate automatically, as long as no MFA is prompted. """, style: .security ) - guard - let username = self.deps.readLine.readLine( - prompt: "Enter your Apple ID username: ", - silent: false - ) - else { - throw CLIError.invalidInput - } - - guard - let password = self.deps.readLine.readLine( - prompt: "Enter your Apple ID password: ", - silent: true - ) - else { - throw CLIError.invalidInput - } - - return AppleCredentialsSecret(username: username, password: password) + return try promptForAppleCredentials( + context: .storingToAWS, + display: self.deps.display, + readLine: self.deps.readLine + ) } } diff --git a/Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift b/Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift index 54df6cf..1b418ee 100644 --- a/Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift +++ b/Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift @@ -8,7 +8,7 @@ import Foundation import Logging import SotoCore -import SotoSecretsManager +import SotoSSM import Testing @testable import xcodeinstall @@ -28,13 +28,13 @@ struct SecretsStorageAWSSotoTest { let awsClient = AWSClient( credentialProvider: TestEnvironment.credentialProvider, ) - let smClient = SecretsManager( + let ssmClient = SSM( client: awsClient, endpoint: TestEnvironment.getEndPoint() ) secretHandler = - try SecretsStorageAWSSoto.forRegion(region, awsClient: awsClient, smClient: smClient, log: log) + try SecretsStorageAWSSoto.forRegion(region, awsClient: awsClient, ssmClient: ssmClient, log: log) as? SecretsStorageAWSSoto #expect(secretHandler != nil) diff --git a/Tests/xcodeinstallTests/Utilities/ConfigHandlerTests.swift b/Tests/xcodeinstallTests/Utilities/ConfigHandlerTests.swift index 60eea5c..12f2376 100644 --- a/Tests/xcodeinstallTests/Utilities/ConfigHandlerTests.swift +++ b/Tests/xcodeinstallTests/Utilities/ConfigHandlerTests.swift @@ -20,7 +20,7 @@ struct ConfigHandlerTests { // Given let configHandler = ConfigHandler(log: log, baseDirectory: tempDir) let config = PersistentConfig( - secretManagerRegion: "us-west-2", + secretRegion: "us-west-2", profileName: "myprofile" ) @@ -30,7 +30,7 @@ struct ConfigHandlerTests { // Then #expect(loadedConfig != nil) - #expect(loadedConfig?.secretManagerRegion == "us-west-2") + #expect(loadedConfig?.secretRegion == "us-west-2") #expect(loadedConfig?.profileName == "myprofile") } } @@ -74,7 +74,7 @@ struct ConfigHandlerTests { // Given let configHandler = ConfigHandler(log: log, baseDirectory: tempDir) let config = PersistentConfig( - secretManagerRegion: "us-east-1", + secretRegion: "us-east-1", profileName: nil ) @@ -84,7 +84,7 @@ struct ConfigHandlerTests { // Then #expect(loadedConfig != nil) - #expect(loadedConfig?.secretManagerRegion == "us-east-1") + #expect(loadedConfig?.secretRegion == "us-east-1") #expect(loadedConfig?.profileName == nil) } } @@ -95,7 +95,7 @@ struct ConfigHandlerTests { // Given let configHandler = ConfigHandler(log: log, baseDirectory: tempDir) let config = PersistentConfig( - secretManagerRegion: nil, + secretRegion: nil, profileName: "testprofile" ) @@ -105,7 +105,7 @@ struct ConfigHandlerTests { // Then #expect(loadedConfig != nil) - #expect(loadedConfig?.secretManagerRegion == nil) + #expect(loadedConfig?.secretRegion == nil) #expect(loadedConfig?.profileName == "testprofile") } } @@ -116,11 +116,11 @@ struct ConfigHandlerTests { // Given let configHandler = ConfigHandler(log: log, baseDirectory: tempDir) let initialConfig = PersistentConfig( - secretManagerRegion: "us-west-1", + secretRegion: "us-west-1", profileName: "profile1" ) let updatedConfig = PersistentConfig( - secretManagerRegion: "eu-west-1", + secretRegion: "eu-west-1", profileName: "profile2" ) @@ -131,7 +131,7 @@ struct ConfigHandlerTests { // Then #expect(loadedConfig != nil) - #expect(loadedConfig?.secretManagerRegion == "eu-west-1") + #expect(loadedConfig?.secretRegion == "eu-west-1") #expect(loadedConfig?.profileName == "profile2") } } diff --git a/iam/ec2-policy.json b/iam/ec2-policy.json index 5cc95ed..5f9ea21 100644 --- a/iam/ec2-policy.json +++ b/iam/ec2-policy.json @@ -5,11 +5,10 @@ "Sid": "xcodeinstall", "Effect": "Allow", "Action": [ - "secretsmanager:CreateSecret", - "secretsmanager:GetSecretValue", - "secretsmanager:PutSecretValue" + "ssm:PutParameter", + "ssm:GetParameter" ], - "Resource": "arn:aws:secretsmanager:*:000000000000:secret:xcodeinstall-*" + "Resource": "arn:aws:ssm:*:000000000000:parameter/xcodeinstall/*" } ] } diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh index 7659333..1722a8a 100755 --- a/scripts/e2e-test.sh +++ b/scripts/e2e-test.sh @@ -7,7 +7,7 @@ # # Prerequisites: # - AWS credentials configured for profile "pro-login" -# - Apple Developer account credentials stored in AWS Secrets Manager (eu-central-1) +# - Apple Developer account credentials stored in AWS Parameter Store (eu-central-1) # - swift build must have been run first (or use swift run which builds automatically) # @@ -58,7 +58,7 @@ fi step "Step 2: Authenticate" $XCODEINSTALL authenticate $AWS_OPTS || fail "authenticate failed" -pass "authenticate succeeded (session stored in AWS Secrets Manager)" +pass "authenticate succeeded (session stored in AWS Parameter Store)" # ------------------------------------------------------------------ step "Step 3: List"