feat(secrets): encrypt sensitive .env.local values at rest#1621
feat(secrets): encrypt sensitive .env.local values at rest#1621aidandaly24 wants to merge 11 commits into
Conversation
…yption Adds @napi-rs/keyring@^1.3.0 as an optionalDependency so the OS keychain path in key-provider.ts is backed by a declared package. The native module installed successfully on this machine, so the now-redundant @ts-expect-error suppression comment is removed (it was guarding against "module not found"; with the dep declared and resolved, TypeScript no longer needs it). npm ci continues to succeed even when the native build fails on a given platform because it is optional. Updates docs/payments.md with the at-rest encryption subsection and a revised credential rotation section that recommends the re-add path.
… its native binary
Package TarballHow to installgh release download pr-1621-tarball --repo aws/agentcore-cli --pattern "*.tgz" --dir /tmp/pr-tarball
npm install -g /tmp/pr-tarball/aws-agentcore-0.20.2.tgz |
|
Claude Security Review: no high-confidence findings. (run) |
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
Nice scoped, transparent design — single chokepoint, self-migrating, opt-out keychain. A few issues to address before merge, mostly around the keyfile-fallback path and integ-test isolation. Inline comments below.
| mkdirSync(resolveConfigDir(), { recursive: true }); | ||
| const key = randomBytes(KEY_BYTES); | ||
| writeFileSync(path, key, { mode: 0o600 }); | ||
| return key; |
There was a problem hiding this comment.
Silent data-loss on a wrong-sized keyfile. If secrets.key exists but isn't 32 bytes (e.g., truncated by a bad backup/sync, partially-written from a crash, or hand-edited), keyfileKey() silently falls through to the writeFileSync below and overwrites it with a fresh random key. Every encrypted secret in every project on this machine becomes permanently undecryptable, with no warning — the next readEnvFile will surface as SecretDecryptionError: Re-add the credential, but the user has no signal that the key (not the ciphertext) is what got clobbered.
Options:
- Throw
SecretEncryptionErrorwhen the keyfile exists with the wrong size (treat as corruption — fail loudly, let the user investigate / move it aside). - Only write the keyfile when it does not exist; if it exists with the wrong size, refuse and require explicit deletion.
Either way, the create-new-key branch should be reached only when the file genuinely doesn't exist.
| } | ||
| mkdirSync(resolveConfigDir(), { recursive: true }); | ||
| const key = randomBytes(KEY_BYTES); | ||
| writeFileSync(path, key, { mode: 0o600 }); |
There was a problem hiding this comment.
Race on first-time keyfile creation. Two CLI processes calling resolveEncryptionKey() concurrently (e.g., a script doing agentcore add … & in parallel, or two terminals) can both see existsSync(path) === false, both randomBytes(32), and both writeFileSync — the second one wins and silently invalidates whatever the first one just encrypted.
Use writeFileSync(path, key, { mode: 0o600, flag: 'wx' }) and, on EEXIST, re-read the file (and validate its length). That makes first-time creation last-writer-safe.
| // no stored password yet | ||
| } | ||
| const key = randomBytes(KEY_BYTES); | ||
| entry.setPassword(key.toString('base64')); |
There was a problem hiding this comment.
Same race exists in the keychain path: between entry.getPassword() returning null and entry.setPassword(...), a concurrent process can win and store a different key. After the setPassword, you should re-read with getPassword() and use whichever value is now persisted, rather than trusting the local key variable. (Less likely than the keyfile race, but the failure mode is identical: encrypted-with-one-key, decrypted-with-another.)
| @@ -47,6 +48,10 @@ describe('multi-agent credential behavior', () => { | |||
| } | |||
There was a problem hiding this comment.
Integ tests pollute the developer's real keychain / ~/.agentcore. runCLI inherits process.env, and these tests don't set AGENTCORE_DISABLE_KEYCHAIN=1 or AGENTCORE_CONFIG_DIR. On a dev/CI machine with a keychain (macOS, Linux with libsecret), the test creates/uses the aws-agentcore / env-local-secret-key entry in the user's keychain; without a keychain, it writes to ~/.agentcore/secrets.key. That's a real side effect from npm run test:integ and (more concerning) means once the developer's keychain has a key from a previous run, these tests will validate using that key rather than a fresh one — cross-run state leakage.
Same issue in integ-tests/add-agent-auth.test.ts (the test added on line 5/120-129 of that file).
Fix: set AGENTCORE_DISABLE_KEYCHAIN=1 and AGENTCORE_CONFIG_DIR=<tmpdir> in the spawned env (via runCLI(..., { env: { ... } }) and createTestProject/beforeAll).
| `${ANSI.yellow}Warning: passing secrets as CLI flags exposes them to shell history and the ` + | ||
| `process table. Prefer interactive mode (run \`agentcore add payment-connector\` with no ` + | ||
| `secret flags) for masked entry.${ANSI.reset}\n` | ||
| ); |
There was a problem hiding this comment.
The CLI-flag leak warning only fires for add payment-connector. The same hazard applies to add agent --api-key … and add agent --client-secret … (both also persist secrets to .env.local and both currently accept secret values via positional flags in non-interactive mode). Either:
- Extract this warning into a shared helper (e.g.,
warnOnLiteralSecretFlag(flagNames: string[])incli/primitives/) and call it fromAgentPrimitive/CredentialPrimitive/auth-utilswherever a secret flag is consumed; or - Document why the warning is payment-only (e.g., "agent api-keys are typed differently" — but I don't see a reason).
Given the PR scope says "Covers the whole CLI uniformly … not just payments," option 1 fits the stated goal.
Coverage Report
|
Description
Encrypts sensitive credential values in
agentcore/.env.localat rest, closing a security finding that provider secrets (payment provider keys, OAuth client secrets, model-provider API keys) were persisted in plaintext on disk — so copying, syncing, or backing up a project directory exposed live credentials.How it works
src/lib/secrets/module: a sensitive-key registry (isSensitiveKey), an AES-256-GCM cipher (enc:v1:envelope), and a key-provider chain..env.localreader/writer already uses (readEnvFile/writeEnvFileinsrc/lib/utils/env.ts): sensitive values are encrypted on write and decrypted on read, so deploy/dev/validate/fetch-access are unaffected and need no changes.@napi-rs/keyring), falling back to a0600~/.agentcore/secrets.keyon machines/CI without a keychain. A copiedagentcore/folder therefore contains only ciphertext.enc:v1:...; reference IDs (_API_KEY_ID,_APP_ID,_CLIENT_ID,_AUTHORIZATION_ID) stay readable..env.localfiles are read as-is and re-encrypted on the next write. No migration command, no breakage.Decryption failure throws a
SecretDecryptionError— ciphertext is never silently surfaced as if it were the real secret, and never sent to an AWS API.Related Issue
Closes the SIM-T AppSec finding for plaintext-at-rest payment/credential secrets (tracked internally; not a GitHub issue).
Closes #
Documentation PR
N/A —
docs/payments.mdis updated in this PR (Secret encryption at rest + rotation guidance).Type of Change
Testing
npm run test:unitandnpm run test:integnpm run typechecknpm run lintsrc/assets/, I rannpm run test:update-snapshotsand committed the updated snapshotsNotes:
typecheck(0 errors),lint(0 errors),build(succeeds —@napi-rs/keyringis marked esbuild-external so its native binary isn't bundled),test:unit(5434 passed),test:integ(291 passed). Nosrc/assets/changes.0600; sensitive-key registry; env.ts encrypt-on-write, decrypt-on-read, legacy self-migration, no-double-encrypt, removeEnvVars-keeps-encrypted.enc:v1:on disk AND round-trips back viareadEnvFile(OAuth client secret; model-provider keys).add payment-connectorwritesenc:v1:for the secret (reference ID stays plaintext) and prints the leak warning;agentcore validatereads the secret back and passes — confirming the cross-process encrypt→decrypt round-trip deploy depends on.Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.