Repository navigation
feat(secrets): encrypt local project credentials at rest - #2565
Conversation
There was a problem hiding this comment.
AgentCore Harness Review
Verdict: Looks good
Clean, well-tested port of the local-secrets-at-rest feature. Design choices look right:
- AES-256-GCM with an authenticated
enc:v1:envelope, random IVs, and fail-closed decryption (src/secrets/cipher.ts). - Keychain-first with a documented, owner-only
secrets.keyfallback; the optional@napi-rs/keyringis correctly markedexternalfor both the Node bundle and the standalone binary builds, with a gracefultry/catcharoundrequire()so standalone and keychain-less environments silently fall back. writeFileSync(..., { flag: "wx" })with EEXIST re-read is the right concurrency pattern for shared fallback keyfile creation, andindependent writers reuse a fallback keycovers it.EnvLocalFile.encryptContentreparses withparseEnvfor value semantics and uses the regex only to pick replacement spans, so comments, multiline values, and unrelated content are preserved. Already-encrypted (enc:v1:prefix), empty, and identifier-suffixed values are skipped, soencryptis idempotent on migrated files.- Rollback semantics are preserved because
encryptContentsnapshots the originalexistingeven when the only change is legacy migration — provisioning that fails after a migratinginsertIfNewcall still unwinds correctly. - Credential provisioning reads via
EnvLocalFile.read()→readEnvFile, so a missing/corrupt key fails before any Identity call (verified byrefuses an unreadable encrypted secret before calling Identity). - Tests exercise real tmpdirs plus injected
KeychainEntryfakes rather than mockingfs— in line with the project's testing guidance. testing/setup.tspinsAGENTCORE_DISABLE_KEYCHAIN=1and a tmpdirAGENTCORE_CONFIG_DIR, so the suite can't touch the real OS keychain.
Nothing blocking from me — ship it.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## refactor #2565 +/- ##
==========================================
Coverage 97.50% 97.50%
==========================================
Files 635 641 +6
Lines 47250 47479 +229
==========================================
+ Hits 46071 46296 +225
- Misses 1179 1183 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Claude Security Review: no high-confidence findings. (run) |
|
Claude Security Review: no high-confidence findings. (run) |
| let password: string | null = null; | ||
| try { | ||
| password = entry.getPassword(); | ||
| } catch { |
There was a problem hiding this comment.
This can overwrite the existing keychain key. Any getPassword() error is swallowed here, password stays null, and the code then calls setPassword with a new random key. In @napi-rs/keyring 1.3.0 a missing entry returns null and does not throw, so this catch only ever sees real failures such as a locked keychain or a denied access prompt.
I reproduced this with an injected keychain whose getPassword throws once. The key was replaced, and every earlier enc:v1 value then failed with "Could not decrypt a stored secret". The old key can't be recovered.
Suggested fix: let the error propagate, or fall back to the keyfile without writing. Only call setPassword when getPassword() returned null.
There was a problem hiding this comment.
Fixed in 4716f91. A thrown read now exits the keychain path without calling setPassword. I also pinned keyring 2.1.1: 1.3.0 itself collapsed native read errors into null, whereas 2.1.1 distinguishes absence from failure. The regression confirms no writes occur after a denied read and previously encrypted values remain readable when access returns.
| try { | ||
| password = entry.getPassword(); | ||
| } catch { | ||
| // Some native stores report an absent entry by throwing. |
There was a problem hiding this comment.
This comment is not accurate for @napi-rs/keyring 1.3.0. I checked against the real library: new Entry("<missing service>", "x").getPassword() returns null. Separately, the PR adds six // comments in non-test code (four in this file, one in cipher.ts, one in envLocal.ts). Please remove the ones that restate the code.
There was a problem hiding this comment.
Removed the inaccurate missing-entry comment and the comments that restated the implementation. Kept the parser/span-replacement rationale in envLocal because it explains why value parsing and preserving surrounding file text use different mechanisms.
|
|
||
| private encryptionKey(): Buffer { | ||
| if (this.key) return this.key; | ||
| const keychainKey = this.keychainKey(true); |
There was a problem hiding this comment.
npm and binary users end up with two different keys. This prefers the keychain even when secrets.key already exists. Together with the build.ts comment (the binary never loads the keychain), values written by the npm CLI can't be read by the binary.
End to end on one project: the binary added fromBin (encrypted with the keyfile), then the npm bundle added fromNpm (encrypted with the keychain). Using only the keyfile, which is what the binary sees, FROMBIN decrypts and FROMNPM fails. Using the keychain and the keyfile, which is what npm sees, both decrypt.
Suggested fix: if a keyfile already exists, use it for encryption instead of creating a keychain key.
There was a problem hiding this comment.
Fixed in 4716f91. An existing valid keyfile now takes precedence for encryption. Decryption tries that file key before touching the keychain, while still supporting older keychain-encrypted values. The regression supplies an available keychain and confirms it is never accessed when the file key handles both operations.
| define: DEFINE, | ||
| root: REPO_ROOT, | ||
| naming: { asset: ASSET_NAMING }, | ||
| external: ["@napi-rs/keyring"], |
There was a problem hiding this comment.
The keychain never loads in the standalone binary. With the module marked external, createRequire(import.meta.url) inside the compiled binary can't resolve @napi-rs/keyring, so defaultKeychain() silently returns null.
I built agentcore-darwin-arm64, ran add credentials api-key from /tmp, and it created ~/.agentcore/secrets.key even though this Mac already has an aws-agentcore keychain entry. Binary users always get the plaintext keyfile and are never told.
There was a problem hiding this comment.
Fixed in 56e65fd. The npm loader stays optional/external, but standalone builds replace the loader with a direct require of the target-specific .node addon so Bun embeds it. All six targets compile locally. The runtime regression hides the original addon before executing the compiled probe, proving it does not load from the build checkout. Fresh macOS/Windows CI will exercise the same probe on those platforms.
| } catch (error) { | ||
| throw new InputValidationError(`Invalid local environment file at ${path}`, { cause: error }); | ||
| } | ||
| return (config.secrets ?? new LocalSecrets()).decrypt(env); |
There was a problem hiding this comment.
One value that can't be decrypted fails the whole read. This includes keys the caller never uses. A .env.local holding an undecryptable SOME_API_KEY makes EnvLocalFile.read() throw, and agentcore dev goes through this same function.
Suggested fix: decrypt lazily, or only the keys the caller asks for, so an unrelated value doesn't block the command.
There was a problem hiding this comment.
Added a caller-provided selection predicate before decryption. Deployment selects only required credential fields, so unrelated ciphertext no longer blocks it. Dev excludes runner-owned PORT/FASTMCP_PORT/LOCAL_DEV values before reading them. Dev still forwards arbitrary other file variables to the agent; those remain fail-closed rather than silently dropping a value the agent may need.
| const content = await this.readOrNull(); | ||
| if (content === null) return {}; | ||
| return parseEnv(content); | ||
| return readEnvFile(this.path, { secrets: this.secrets }); |
There was a problem hiding this comment.
This blocks the process environment override in deploy. createCredentialProvisioner (credentials.ts:183) spreads credentialEnvironment(processEnv) over this read so a deploy can get its secrets from the environment. This read now throws first.
I ran the provisioner with AGENTCORE_CREDENTIAL_OPENAI=from-env and one undecryptable value in .env.local. It threw "Could not decrypt a stored secret" before any Identity call.
There was a problem hiding this comment.
Fixed in 0fb1fb3. Plaintext process-environment overrides are applied before decryption: overridden file values are excluded entirely. Updated the existing provisioning test to use both an undecryptable stored value for the overridden credential and unrelated bad ciphertext; the supplied environment value reaches Identity successfully.
| const separator = content === "" || content.endsWith("\n") ? "" : "\n"; | ||
| // Each entry is two lines: # <comment>\n<key>=<value> | ||
| content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`; | ||
| const encrypted = await this.secrets.encrypt({ [entry.key]: entry.value ?? "" }); |
There was a problem hiding this comment.
This per-entry encrypt is redundant. encryptContent(content) runs right after the loop and already encrypts any plaintext sensitive value in the file. Dropping this call and writing entry.value directly gives the same file content.
There was a problem hiding this comment.
I kept encryption before formatting here: formatValue rejects a single quote in plaintext, so simply replacing the encrypted argument with entry.value throws before encryptContent can run. The existing quote-bearing-secret regression covers that case. We could batch the pre-serialization encryption separately, but dropping this call alone would lose that behavior. I removed the surrounding narrating comment.
| "_API_KEY", | ||
| ]; | ||
|
|
||
| const REFERENCE_SUFFIXES = ["_API_KEY_ID", "_APP_ID", "_CLIENT_ID", "_AUTHORIZATION_ID"]; |
There was a problem hiding this comment.
These suffix lists repeat the secret and identifier split in src/projectSchemas/credential.ts (CREDENTIAL_FIELD_SUFFIXES and credentialEnvironmentVariableNames). Every field written today is classified correctly. If a new field is added there and not here, it falls back to the AGENTCORE_CREDENTIAL_ prefix rule, and an identifier could end up encrypted. Consider deriving these lists from credential.ts.
There was a problem hiding this comment.
Addressed in ef4e6ce. credential.ts now owns the secret and reference suffix arrays; both credential-name validation and sensitive-value classification derive from those definitions. The generic _API_KEY suffix remains supported outside the credential namespace. Added _NAME to the existing readable-reference test table.
|
Claude Security Review: no high-confidence findings. (run) |
56e65fd to
64edb02
Compare
|
Claude Security Review: no high-confidence findings. (run) |
Description
Ports the local secret-at-rest behavior from
main(#1621) into therefactorarchitecture.enc:v1:envelope, sensitive-name rules, OS keychain identity, andsecrets.keyfallback so existing encrypted projects remain readable.LocalSecretsundersrc/io.EnvLocalFile, preserving comments, existing values, blank placeholders, atomic writes, owner-only permissions, and rollback.The follow-up test review removed duplicate envelope coverage and the simulated concurrent-writer test, which did not exercise concurrent IO. Redundant cipher input variations were removed while retaining v1 compatibility, empty-envelope decoding, fresh IVs, tamper rejection, candidate keys, and bad-key handling. Existing migration, rollback, and command-flow tests remain.
Review fixes:
No popup notice was added. Quote-safe encryption before serialization remains intact.
Related Issue
None. No issue was opened, as requested.
Documentation PR
The README addition was removed as requested. No replacement documentation was added, and README.md is unchanged from the PR base.
Type of Change
Testing
Rebased onto
refactorat609ae000; preserved upstream's removal of the model/API-key flags from headlesscreate. Encryption assertions remain on the supported add commands and create wizard.Full local
bun teston rebased head64edb029: 4007 passed, 0 failed.Three minimal regressions failed on the old PR head and pass with the fixes: keychain overwrite after a read error, file-key precedence, and environment overrides blocked by unrelated/overridden ciphertext.
Typecheck, lint, formatting, secret scanning, and the Node bundle passed locally.
All six supported standalone targets compiled successfully.
Node and Linux standalone
--helpsmoke tests passed.The native-addon regression passes with the original addon file temporarily renamed, so the compiled probe must load its embedded copy.
The isolated CI checkout passed 90 focused tests plus typecheck and lint before the local validation.
Actual macOS permission dialogs and cross-version OS-keychain contents were not tested locally; the host is Linux. Existing keychain identity and envelope format are preserved.
No AWS request shapes or infrastructure changed. Credential provisioning tests verify plaintext reaches Identity from encrypted storage. No live AWS tests were rerun for this test-only conflict resolution.
Pre-rebase Linux, Windows, and macOS verification and AWS CodeBuild e2e passed on
56e65fdb. The first macOS attempt timed out in the unrelated Harness endpoint-delete cancellation test; its unchanged-head rerun passed. Fresh CI for the rebased head is pending.I ran
bun testI ran the relevant end-to-end tests with
bun run test:e2e, or explained why they are not applicableI ran
bun run typecheckI ran
bun run lint:checkI ran
bun run format:checkI ran
bun run buildIf I modified
src/assets/, I updated affected snapshots withbun test <test-file> --update-snapshotsand committed themNo files in
src/assets/were modified.Checklist
README changes were intentionally removed. No companion SDK/CDK changes are required.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the
terms of your choice.