Skip to content

feat(state): encrypt secrets in state by default (auto local key, KMS on S3) - #1400

Draft
its-rosetta wants to merge 2 commits into
alchemy-run:mainfrom
its-rosetta:feat/state-secret-encryption
Draft

feat(state): encrypt secrets in state by default (auto local key, KMS on S3)#1400
its-rosetta wants to merge 2 commits into
alchemy-run:mainfrom
its-rosetta:feat/state-secret-encryption

Conversation

@its-rosetta

@its-rosetta its-rosetta commented Aug 28, 2026

Copy link
Copy Markdown

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:

- { "apiKey": { "__redacted__": "sk-live-..." } }
+ { "apiKey": { "__secret__": "v1:kx4X0f..." } }
  • Local (.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.
  • S3 (AWS.state()): KMS envelope encryption via the auto-managed alias/alchemy-state key, with the KMS-wrapped data key at {prefix}__state_key__.json. Engaged lazily — the codec resolves on write only when the value contains a Redacted, on read only when the raw JSON carries a __secret__ marker, so secret-free stacks never touch KMS (no kms:* permission, no CMK minted). Recovers the key from an out-of-band pending deletion. Opt out with secretEncryption: "off".
  • ALCHEMY_PASSWORD overrides both with a scrypt-derived key.
  • One codec (State/SecretCodec.ts): AES-256-GCM, random per-value IV, v1: framing. Sync by design (runs inside JSON.parse/stringify revivers); its own module so node:crypto stays out of workerd bundles (StateEncoding imports it type-only).
  • Only Redacted payloads are ciphertext — the rest of state stays introspectable, and change detection is unaffected (secrets decrypt on read).
  • Missing/wrong key fails with an actionable StateStoreError (shared stateDecodeError), never a defect or silent corruption.
  • HTTP stores are untouched: shared by definition, at-rest protection is the server's job (the hosted store already encrypts server-side).

Rebase deltas vs. #1030

  resources: exported.resources.map((r) => ({
    ...r,
-   state: encodeState(r.state),
+   state: encodeState(r.state, codec),
  })),

Hardening from adversarial review

Three independent review passes (crypto, KMS/concurrency, integration) drove a hardening commit:

  • Codec resolution memoizes on success onlyEffect.cached persists failure Exits forever, so a transient S3/KMS/fs failure would have poisoned every later secret operation.
  • recoverKmsKey rides out KMS eventual consistency (EnableKey retried through the post-CancelKeyDeletion window, then waits for KeyState=Enabled) instead of swallowing KMSInvalidStateException and leaving the key Disabled.
  • The alias-race loser retries DescribeKey on NotFoundException and logs (instead of silently ignoring) a failed orphan-key cleanup; the IfNoneMatch put retries on 409 ConditionalRequestConflict per S3's documented semantics.
  • Corrupt __state_key__.json / ~/.alchemy/state.key fail as typed StateStoreErrors, not JSON.parse/crypto defects; truncated v1: frames are rejected before reaching the cipher (Node accepts short GCM tags).
  • The reviver only decrypts exact single-key { "__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 a Redacted; state get/export stay side-effect-free for secret-free output.

Known accepted trade-off: the scrypt salt is a fixed context string (documented in SecretCodec.ts) — ALCHEMY_PASSWORD is expected to be high-entropy (docs show openssl rand -base64 32), and a per-envelope salt would require a v2: frame format.

Compatibility

  • Old plaintext __redacted__ markers still revive; the next write re-encrypts (explicit migration tests, local and S3).
  • One-way format migration: alchemy versions predating __secret__ read the envelope as a plain object instead of failing. Readers of a shared store must upgrade together — called out in a :::caution in 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

colelawrence and others added 2 commits August 28, 2026 16:18
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants