feat(state): encrypt secrets in state by default (auto local key, KMS on S3) - #1400
Draft
its-rosetta wants to merge 2 commits into
Draft
feat(state): encrypt secrets in state by default (auto local key, KMS on S3)#1400its-rosetta wants to merge 2 commits into
its-rosetta wants to merge 2 commits into
Conversation
… on S3) Re-implementation of alchemy-run#1030 rebased onto current main. State stores that own their key material now encrypt every Redacted<T> at rest as a { "__secret__": "v1:..." } envelope (AES-256-GCM, per-value IV) instead of the plaintext { "__redacted__": ... } marker: - Local (.alchemy/state/): auto-generated 32-byte key at ~/.alchemy/state.key (mode 0600, wx-exclusive create). - S3 (AWS.state()): KMS envelope encryption via alias/alchemy-state, lazily engaged — secret-free stacks never touch KMS. Recovers the key from a pending deletion. Opt out with secretEncryption: "off". - ALCHEMY_PASSWORD overrides both with a scrypt-derived key. - Legacy plaintext markers still revive; the next write re-encrypts. - alchemy state get/export print __secret__ envelopes, never plaintext. New since alchemy-run#1030: the state export command (added on main in alchemy-run#1043) is covered by the same codec treatment as state get. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes from a three-lens review (crypto, KMS/concurrency, integration): - Codec resolution memoizes on SUCCESS only (S3 and Local): Effect.cached persists a failure Exit forever, so one transient S3/KMS/fs failure poisoned every later secret read/write for the process lifetime. - recoverKmsKey rides out KMS eventual consistency: EnableKey retries through the post-CancelKeyDeletion window instead of swallowing KMSInvalidStateException (which left the key Disabled), then waits for KeyState=Enabled before the Decrypt retry. - Alias-race loser retries DescribeKey on NotFoundException (winner's CreateAlias may not be visible yet) and logs a warning instead of silently ignoring a failed orphan-key ScheduleKeyDeletion. - The IfNoneMatch put retries on 409 ConditionalRequestConflict (S3 documents it as retryable; our put may not have committed). - Corrupt __state_key__.json and corrupt ~/.alchemy/state.key fail as typed StateStoreError, not JSON.parse/crypto defects. - makeStateReviver only decrypts exact single-key __secret__ envelopes; a user object containing the key alongside other fields is data. - SecretCodec rejects truncated v1 frames before touching the cipher (Node accepts short GCM tags) and keeps all crypto ops in the try. - Reads engage the codec only when the raw JSON carries a __secret__ marker, and a codec-resolution failure only surfaces when a codec-less parse cannot revive the state (substring false-positives are benign). Local writes resolve the key file only when the value holds a Redacted; state get/export never create the key file for secret-free output. - PostgresState now honors ALCHEMY_PASSWORD (opt-in, shared-store codec): encrypts Redacted values on write, revives __secret__ envelopes on read as typed errors instead of defects. Without the password its behavior is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Re-implementation of #1030 (base had drifted to conflict), rebased onto current main. Opened by Claude (Fable) on behalf of @colelawrence.
State stores persist every
Redacted<T>as a plaintext{ "__redacted__": ... }marker. Secrets in state are now encrypted by default in the stores that own their key material, with nothing for the user to manage:.alchemy/state/): auto-generated 32-byte key at~/.alchemy/state.key(created on first use, mode 0600,wx-exclusive so concurrent creators converge). Repo state alone never exposes a secret.AWS.state()): KMS envelope encryption via the auto-managedalias/alchemy-statekey, with the KMS-wrapped data key at{prefix}__state_key__.json. Engaged lazily — the codec resolves on write only when the value contains aRedacted, on read only when the raw JSON carries a__secret__marker, so secret-free stacks never touch KMS (nokms:*permission, no CMK minted). Recovers the key from an out-of-band pending deletion. Opt out withsecretEncryption: "off".ALCHEMY_PASSWORDoverrides both with a scrypt-derived key.State/SecretCodec.ts): AES-256-GCM, random per-value IV,v1:framing. Sync by design (runs insideJSON.parse/stringifyrevivers); its own module sonode:cryptostays out of workerd bundles (StateEncodingimports it type-only).Redactedpayloads are ciphertext — the rest of state stays introspectable, and change detection is unaffected (secrets decrypt on read).StateStoreError(sharedstateDecodeError), never a defect or silent corruption.Rebase deltas vs. #1030
StateEncoding.tsmerged with the newerDATE_MARKERsupport;containsRedactedskipsDateleaves.LocalState.tsmerged with main's hardening (initialCwdanchoring, atomic temp-file writes,recoverMissingDir,deleteStackcache invalidation) — all preserved.alchemy state export(feat(cli): add bulk state read viaalchemy state export#1043, post-dates feat(state): encrypt secrets in state by default (auto local key, KMS on S3) #1030) printed plaintext secrets; it now prints__secret__envelopes via the same codec asstate get:resources: exported.resources.map((r) => ({ ...r, - state: encodeState(r.state), + state: encodeState(r.state, codec), })),PostgresState(feat(prisma): object storage (resources + bindings), locked Postgres state backend, Deployment.redeployOn #1061) writes rows client-side (unlike the HTTP store, the database never encrypts the JSON), but is shared across machines so it has no automatic key source. It now honorsALCHEMY_PASSWORDas an opt-in shared codec — encrypts on write, revives__secret__envelopes on read (as typedStateStoreErrors, never defects). Without the password its behavior is unchanged.Hardening from adversarial review
Three independent review passes (crypto, KMS/concurrency, integration) drove a hardening commit:
Effect.cachedpersists failure Exits forever, so a transient S3/KMS/fs failure would have poisoned every later secret operation.recoverKmsKeyrides out KMS eventual consistency (EnableKey retried through the post-CancelKeyDeletion window, then waits forKeyState=Enabled) instead of swallowingKMSInvalidStateExceptionand leaving the key Disabled.DescribeKeyonNotFoundExceptionand logs (instead of silently ignoring) a failed orphan-key cleanup; theIfNoneMatchput retries on 409ConditionalRequestConflictper S3's documented semantics.__state_key__.json/~/.alchemy/state.keyfail as typedStateStoreErrors, notJSON.parse/crypto defects; truncatedv1:frames are rejected before reaching the cipher (Node accepts short GCM tags).{ "__secret__": ... }envelopes — a user object merely containing the key is data. Reads engage the codec only when the raw JSON carries the marker, and a resolution failure surfaces only when a codec-less parse can't revive the state, so marker false-positives never demand KMS. Local writes touch the key file only when the value actually holds aRedacted;state get/exportstay side-effect-free for secret-free output.Known accepted trade-off: the scrypt salt is a fixed context string (documented in
SecretCodec.ts) —ALCHEMY_PASSWORDis expected to be high-entropy (docs showopenssl rand -base64 32), and a per-envelope salt would require av2:frame format.Compatibility
__redacted__markers still revive; the next write re-encrypts (explicit migration tests, local and S3).__secret__read the envelope as a plain object instead of failing. Readers of a shared store must upgrade together — called out in a:::cautionin the docs; should be in the release notes too.Tested: 14 hermetic encoding/keyfile/migration tests, plus live S3 tests covering the KMS flow end-to-end (raw-object ciphertext assertion, legacy roll-forward without engaging KMS, secret-free-never-touches-KMS, pending-deletion recovery).
Docs: rewritten "Secrets in state are encrypted" section on environments/secrets.
🤖 Generated with Claude Code