diff --git a/packages/alchemy/src/AWS/StateStore/State.ts b/packages/alchemy/src/AWS/StateStore/State.ts index 7a1064fbbf..efb3114aaf 100644 --- a/packages/alchemy/src/AWS/StateStore/State.ts +++ b/packages/alchemy/src/AWS/StateStore/State.ts @@ -1,8 +1,12 @@ import type { Credentials } from "@distilled.cloud/aws/Credentials"; import type { Region } from "@distilled.cloud/aws/Region"; +import * as kms from "@distilled.cloud/aws/kms"; import * as s3 from "@distilled.cloud/aws/s3"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import type { HttpClient } from "effect/unstable/http/HttpClient"; @@ -11,11 +15,23 @@ import { decodeFqn, encodeFqn } from "../../FQN.ts"; import { STATE_STORE_VERSION } from "../../State/HttpStateApi.ts"; import { State, + stateDecodeError, StateStoreError, type PersistedState, type StateService, } from "../../State/State.ts"; -import { encodeState, reviveState } from "../../State/StateEncoding.ts"; +import { + makeSecretCodec, + makeSecretCodecFromKey, + resolveSecretPassword, + type SecretCodec, +} from "../../State/SecretCodec.ts"; +import { + containsRedacted, + encodeState, + hasSecretMarker, + makeStateReviver, +} from "../../State/StateEncoding.ts"; import { recordStateStoreInit } from "../../Telemetry/Metrics.ts"; import { AwsAuth } from "../AuthProvider.ts"; import * as AwsCredentials from "../Credentials.ts"; @@ -62,8 +78,33 @@ export interface S3StateOptions { * @default `{ sseAlgorithm: "AES256" }` */ encryption?: BucketEncryption; + /** + * How `Redacted` secret values are protected inside state objects + * (bucket-level SSE protects the objects at rest, but their JSON + * content is still readable by anyone with `s3:GetObject`). + * + * - `"kms"` (default): envelope encryption with an auto-managed KMS + * key. On first use the store creates (or reuses) the + * `alias/alchemy-state` KMS key, mints a data key, and stores the + * KMS-wrapped data key at `{prefix}__state_key__.json` in the + * bucket. Anyone who can deploy (`kms:Decrypt` on the key) can read + * state — no key to manage or distribute. + * - `"off"`: secrets are persisted as plaintext + * `{ "__redacted__": ... }` markers (the legacy behavior). + * + * Setting `ALCHEMY_PASSWORD` overrides both modes with password-based + * encryption — use it when readers can't reach KMS. Use one key source + * consistently per bucket: values encrypted under one key cannot be + * read with another. + * + * @default "kms" + */ + secretEncryption?: "kms" | "off"; } +/** Alias of the auto-managed KMS key that wraps state data keys. */ +const STATE_KMS_ALIAS = "alias/alchemy-state"; + /** Context required by the distilled S3 operations. */ type S3Deps = Credentials | HttpClient | Region; @@ -167,13 +208,15 @@ export const makeS3State = (options: S3StateOptions = {}) => : ""; const toError = (cause: unknown) => - new StateStoreError({ - message: - cause instanceof Error - ? cause.message - : `S3 state store error: ${String(cause)}`, - cause: cause instanceof Error ? cause : undefined, - }); + cause instanceof StateStoreError + ? cause + : new StateStoreError({ + message: + cause instanceof Error + ? cause.message + : `S3 state store error: ${String(cause)}`, + cause: cause instanceof Error ? cause : undefined, + }); // Anything that touches AWS credentials must NOT run at layer // construction time. Resolving the environment (account/region), @@ -192,6 +235,285 @@ export const makeS3State = (options: S3StateOptions = {}) => }).pipe(Effect.provideContext(context), Effect.mapError(toError)), ); + // Resolve the secret codec once, lazily (it may need the bucket for + // the wrapped data key, so it cannot run before `bucket`): + // ALCHEMY_PASSWORD → password codec; `secretEncryption: "off"` → + // plaintext markers; otherwise KMS envelope encryption (below). + // + // Resolution is deferred further still by the read/write paths: it + // only runs when a value actually holds a secret. A stack with no + // `Redacted` values never touches KMS — no key is minted and no + // `kms:*` permission is required, exactly as before encryption + // existed. + // Memoized on SUCCESS only: `Effect.cached` persists the full Exit — + // failures included — forever, so one transient S3/KMS/credential + // failure would poison every later secret read/write in this store. + // Invalidating on failure lets the next operation retry resolution + // from scratch. + const [codecMemo, invalidateCodec] = yield* Effect.cachedInvalidateWithTTL( + Effect.gen(function* () { + const password = yield* resolveSecretPassword; + if (Option.isSome(password)) { + return yield* Effect.sync(() => makeSecretCodec(password.value)); + } + if (options.secretEncryption === "off") return undefined; + const bucketName = yield* bucket; + return yield* resolveKmsCodec(bucketName).pipe( + Effect.provideContext(context), + Effect.mapError(toError), + ); + }), + Duration.infinity, + ); + const codecCached = codecMemo.pipe(Effect.tapError(() => invalidateCodec)); + + /** S3 key of the KMS-wrapped data key, shared by every stack. */ + const stateKeyObjectKey = `${prefix}__state_key__.json`; + + interface WrappedStateKey { + keyId?: string; + ciphertext: string; + } + + const readWrappedKey = (bucketName: string) => + s3.getObject({ Bucket: bucketName, Key: stateKeyObjectKey }).pipe( + Effect.flatMap((result) => + result.Body === undefined + ? Effect.succeed(undefined) + : Stream.mkString(Stream.decodeText(result.Body)).pipe( + // A corrupt data-key object fails as a StateStoreError, + // never as a JSON.parse defect crashing the engine. + Effect.flatMap((text) => + Effect.try({ + try: (): WrappedStateKey => { + const parsed = JSON.parse( + text, + ) as Partial | null; + if (typeof parsed?.ciphertext !== "string") { + throw new Error( + "missing string 'ciphertext' field — the object may be corrupted", + ); + } + return { + keyId: + typeof parsed.keyId === "string" + ? parsed.keyId + : undefined, + ciphertext: parsed.ciphertext, + }; + }, + catch: stateDecodeError(stateKeyObjectKey), + }), + ), + ), + ), + Effect.catchTag("NoSuchKey", () => Effect.succeed(undefined)), + ); + + /** + * Recover the state key after an out-of-band cleanup (e.g. an + * account nuke) scheduled it for deletion: cancel the pending + * deletion and re-enable it. State encrypted under the key's data + * key would become permanently unreadable if the deletion + * completed, so recovery — not replacement — is the only correct + * move. Both calls tolerate a concurrent deployer racing the same + * recovery. + */ + const recoverKmsKey = (keyId: string) => + kms.cancelKeyDeletion({ KeyId: keyId }).pipe( + // Not pending deletion (already cancelled by a racer, or only + // disabled) — fall through to enable. + Effect.catchTag("KMSInvalidStateException", () => Effect.void), + // CancelKeyDeletion is eventually consistent: the key sits in + // PendingDeletion for a moment before landing on Disabled, and + // EnableKey rejects with KMSInvalidStateException until then. + // Retry through the window instead of swallowing the error — + // swallowing would leave the key Disabled and state unreadable. + Effect.andThen( + kms.enableKey({ KeyId: keyId }).pipe( + Effect.retry({ + while: (e) => e._tag === "KMSInvalidStateException", + schedule: Schedule.spaced("2 seconds"), + times: 15, + }), + ), + ), + // Enable propagation is asynchronous too — wait until KMS + // reports the key usable before the caller retries Decrypt. + Effect.andThen( + kms.describeKey({ KeyId: keyId }).pipe( + Effect.repeat({ + until: (r) => r.KeyMetadata?.KeyState === "Enabled", + schedule: Schedule.spaced("2 seconds"), + times: 15, + }), + ), + ), + Effect.asVoid, + ); + + const unwrapDataKey = (wrapped: WrappedStateKey) => { + const decrypt = kms + .decrypt({ + CiphertextBlob: Buffer.from(wrapped.ciphertext, "base64"), + }) + .pipe( + Effect.flatMap((result) => { + const plaintext = Redacted.isRedacted(result.Plaintext) + ? Redacted.value(result.Plaintext) + : result.Plaintext; + return plaintext === undefined + ? Effect.fail( + new StateStoreError({ + message: + "KMS Decrypt returned no plaintext for the state data key", + }), + ) + : Effect.sync(() => makeSecretCodecFromKey(plaintext)); + }), + ); + return decrypt.pipe( + Effect.catchTag( + ["KMSInvalidStateException", "DisabledException"], + (error) => + wrapped.keyId === undefined + ? Effect.fail(error) + : recoverKmsKey(wrapped.keyId).pipe(Effect.andThen(decrypt)), + ), + ); + }; + + /** Resolve the KeyId behind `alias/alchemy-state`, creating key + alias on first use. */ + const ensureKmsKey = Effect.gen(function* () { + const existing = yield* kms.describeKey({ KeyId: STATE_KMS_ALIAS }).pipe( + Effect.map((r) => r.KeyMetadata), + Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)), + ); + if (existing?.KeyId !== undefined) { + if ( + existing.KeyState === "PendingDeletion" || + existing.KeyState === "Disabled" + ) { + yield* recoverKmsKey(existing.KeyId); + } + return existing.KeyId; + } + const created = yield* kms.createKey({ + Description: "Alchemy state store secret encryption key", + }); + const keyId = created.KeyMetadata?.KeyId; + if (keyId === undefined) { + return yield* Effect.fail( + new StateStoreError({ message: "KMS CreateKey returned no KeyId" }), + ); + } + return yield* kms + .createAlias({ AliasName: STATE_KMS_ALIAS, TargetKeyId: keyId }) + .pipe( + Effect.map(() => keyId), + // Lost the alias-creation race: schedule our now-orphaned key + // for deletion and converge on the winner behind the alias. + Effect.catchTag("AlreadyExistsException", () => + kms + .scheduleKeyDeletion({ KeyId: keyId, PendingWindowInDays: 7 }) + .pipe( + // Best-effort cleanup of our now-orphaned key — but leave + // a trace when it fails (throttling, missing permission) + // so an enabled orphan CMK is never silently left behind. + Effect.catchCause((cause) => + Effect.logWarning( + `Failed to schedule orphaned KMS key ${keyId} for deletion; delete it manually`, + cause, + ), + ), + Effect.flatMap(() => + kms.describeKey({ KeyId: STATE_KMS_ALIAS }).pipe( + // The winner's CreateAlias may not be visible yet. + Effect.retry({ + while: (e) => e._tag === "NotFoundException", + schedule: Schedule.spaced("1 second"), + times: 10, + }), + ), + ), + Effect.flatMap((r) => + r.KeyMetadata?.KeyId === undefined + ? Effect.fail( + new StateStoreError({ + message: `KMS alias ${STATE_KMS_ALIAS} exists but has no target key`, + }), + ) + : Effect.succeed(r.KeyMetadata.KeyId), + ), + ), + ), + ); + }); + + /** + * Envelope encryption: a 32-byte data key is minted via KMS + * `GenerateDataKey` against the auto-managed `alias/alchemy-state` + * key and persisted (KMS-wrapped) in the bucket. Every reader with + * `kms:Decrypt` unwraps the same data key, so teammates and CI work + * with nothing to distribute. The conditional `IfNoneMatch: "*"` put + * plus the read-back make concurrent first-time minters converge on + * a single key. + */ + const resolveKmsCodec = (bucketName: string) => + Effect.gen(function* () { + const existing = yield* readWrappedKey(bucketName); + if (existing !== undefined) return yield* unwrapDataKey(existing); + const keyId = yield* ensureKmsKey; + const dataKey = yield* kms.generateDataKey({ + KeyId: keyId, + KeySpec: "AES_256", + }); + if (dataKey.CiphertextBlob === undefined) { + return yield* Effect.fail( + new StateStoreError({ + message: "KMS GenerateDataKey returned no CiphertextBlob", + }), + ); + } + const wrapped: WrappedStateKey = { + keyId: dataKey.KeyId, + ciphertext: Buffer.from(dataKey.CiphertextBlob).toString("base64"), + }; + yield* s3 + .putObject({ + Bucket: bucketName, + Key: stateKeyObjectKey, + Body: JSON.stringify(wrapped, null, 2), + ContentType: "application/json", + IfNoneMatch: "*", + }) + .pipe( + // S3 documents 409 ConditionalRequestConflict on concurrent + // conditional writes as "retry the request": OUR put may not + // have been committed, so retry until it lands or a racer's + // object makes it fail 412 (which the read-back converges on). + Effect.retry({ + while: (e) => e._tag === "ConditionalRequestConflict", + schedule: Schedule.spaced("1 second"), + times: 10, + }), + Effect.catchTag( + ["PreconditionFailed", "ConditionalRequestConflict"], + () => Effect.void, + ), + ); + // Converge on whatever is stored — covers losing the put race. + const stored = yield* readWrappedKey(bucketName); + if (stored === undefined) { + return yield* Effect.fail( + new StateStoreError({ + message: `State data key object '${stateKeyObjectKey}' missing after write`, + }), + ); + } + return yield* unwrapDataKey(stored); + }); + // Close over the captured context so every StateService method is // self-contained (`R = never`), matching the StateService contract. const run = ( @@ -247,6 +569,9 @@ export const makeS3State = (options: S3StateOptions = {}) => Effect.map((names) => Array.from(names)), ); + /** Resolve the codec only when the value at hand actually needs it. */ + const noCodec = Effect.succeed(undefined); + /** Read and revive a JSON object; `undefined` when the key is absent. */ const readJson = (bucket: string, key: string) => s3.getObject({ Bucket: bucket, Key: key }).pipe( @@ -254,28 +579,40 @@ export const makeS3State = (options: S3StateOptions = {}) => result.Body === undefined ? Effect.succeed(undefined) : Stream.mkString(Stream.decodeText(result.Body)).pipe( - Effect.flatMap((text) => - Effect.try({ - try: () => JSON.parse(text, reviveState) as T, - catch: (cause) => - new StateStoreError({ - message: `Failed to parse state object '${key}'`, - cause: cause instanceof Error ? cause : undefined, - }), - }), - ), + Effect.flatMap((text) => { + const parse = (codec: SecretCodec | undefined) => + Effect.try({ + try: () => JSON.parse(text, makeStateReviver(codec)) as T, + catch: stateDecodeError(key), + }); + if (!hasSecretMarker(text)) return parse(undefined); + // The substring gate can false-positive on a plain + // string value containing the quoted marker. If codec + // resolution fails (KMS unreachable, no permission), + // only surface that failure when the state genuinely + // holds an encrypted envelope — i.e. when a codec-less + // parse cannot revive it either. + return codecCached.pipe( + Effect.flatMap(parse), + Effect.catchTag("StateStoreError", (codecError) => + parse(undefined).pipe(Effect.mapError(() => codecError)), + ), + ); + }), ), ), Effect.catchTag("NoSuchKey", () => Effect.succeed(undefined)), ); const writeJson = (bucket: string, key: string, value: unknown) => - s3.putObject({ - Bucket: bucket, - Key: key, - Body: JSON.stringify(encodeState(value), null, 2), - ContentType: "application/json", - }); + Effect.flatMap(containsRedacted(value) ? codecCached : noCodec, (codec) => + s3.putObject({ + Bucket: bucket, + Key: key, + Body: JSON.stringify(encodeState(value, codec), null, 2), + ContentType: "application/json", + }), + ); /** Delete every object under `keyPrefix` in batches. Idempotent. */ const deleteAll = (bucket: string, keyPrefix: string) => diff --git a/packages/alchemy/src/State/LocalState.ts b/packages/alchemy/src/State/LocalState.ts index 0b183362a6..6142a5c952 100644 --- a/packages/alchemy/src/State/LocalState.ts +++ b/packages/alchemy/src/State/LocalState.ts @@ -1,3 +1,4 @@ +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -8,8 +9,19 @@ import { decodeFqn, encodeFqn } from "../FQN.ts"; import { recordStateStoreInit } from "../Telemetry/Metrics.ts"; import { writeFileAtomic } from "../Util/AtomicFile.ts"; import { STATE_STORE_VERSION } from "./HttpStateApi.ts"; -import { State, StateStoreError, type StateService } from "./State.ts"; -import { encodeState, reviveState } from "./StateEncoding.ts"; +import { resolveLocalSecretCodec, type SecretCodec } from "./SecretCodec.ts"; +import { + State, + stateDecodeError, + StateStoreError, + type StateService, +} from "./State.ts"; +import { + containsRedacted, + encodeState, + hasSecretMarker, + makeStateReviver, +} from "./StateEncoding.ts"; /** * The process's working directory, captured ONCE at module load. @@ -42,10 +54,40 @@ export const localState = () => }), ); +/** + * Construct the local file-based state store (`.alchemy/state/`). + * + * `Redacted` values are encrypted at rest with an auto-generated + * machine key at `~/.alchemy/state.key` (created on first use). Set + * `ALCHEMY_PASSWORD` to use a shared password-derived key instead — + * e.g. to share one state tree across machines. + */ export const makeLocalState = () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const context = yield* Effect.context(); + // Local state is encrypted by default: ALCHEMY_PASSWORD when set, + // otherwise the auto-generated `~/.alchemy/state.key`. Resolved + // lazily so store construction stays infallible and no key file is + // created until a secret is actually read or written. Memoized on + // SUCCESS only — `Effect.cached` would persist a failure (e.g. a + // transiently unreadable `~/.alchemy`) for the process lifetime. + const [codecMemo, invalidateCodec] = yield* Effect.cachedInvalidateWithTTL( + resolveLocalSecretCodec.pipe( + Effect.mapError( + (e) => + new StateStoreError({ + message: `Failed to initialize the state secret key: ${e.message}`, + cause: e, + }), + ), + Effect.provideContext(context), + ), + Duration.infinity, + ); + const getCodec = codecMemo.pipe(Effect.tapError(() => invalidateCodec)); + const noCodec = Effect.succeed(undefined); const dotAlchemy = path.join(initialCwd, ".alchemy"); const stateDir = path.join(dotAlchemy, "state"); @@ -57,7 +99,9 @@ export const makeLocalState = () => }), ); - const recover = (effect: Effect.Effect) => + const recover = ( + effect: Effect.Effect, + ) => effect.pipe( Effect.catchTag("PlatformError", (e) => e.reason._tag === "NotFound" ? Effect.void : fail(e), @@ -130,10 +174,30 @@ export const makeLocalState = () => // linger from a write that was interrupted before this atomic-write change // (or any non-atomic external writer); treat it as "absent" rather than // throwing a JSON parse error that would abort the whole operation. - const parseState = (contents: string) => - contents.trim().length === 0 - ? undefined - : JSON.parse(contents, reviveState); + // Decode failures (malformed JSON, wrong state key for `__secret__` + // envelopes) surface as StateStoreError, not defects. The codec is + // resolved only when the file carries a `__secret__` marker, so + // secret-free and legacy state never needs the key file at all — + // and a codec failure only surfaces when the state genuinely holds + // an encrypted envelope (the marker gate can false-positive on a + // plain string containing the marker text). + const parseState = (contents: string, what: string) => { + const parse = (codec: SecretCodec | undefined) => + Effect.try({ + try: () => + contents.trim().length === 0 + ? undefined + : JSON.parse(contents, makeStateReviver(codec)), + catch: stateDecodeError(what), + }); + if (!hasSecretMarker(contents)) return parse(undefined); + return getCodec.pipe( + Effect.flatMap(parse), + Effect.catchTag("StateStoreError", (codecError) => + parse(undefined).pipe(Effect.mapError(() => codecError)), + ), + ); + }; const created = new Set(); @@ -159,7 +223,7 @@ export const makeLocalState = () => ), get: (request) => fs.readFile(resource(request)).pipe( - Effect.map((file) => parseState(file.toString())), + Effect.flatMap((file) => parseState(file.toString(), request.fqn)), recover, ), getReplacedResources: Effect.fn(function* (request) { @@ -174,11 +238,15 @@ export const makeLocalState = () => )).filter((r) => r?.status === "replaced"); }), set: (request) => - ensure(stageDir(request)).pipe( - Effect.flatMap(() => + Effect.all([ + // Secret-free values never need (or create) the key file. + containsRedacted(request.value) ? getCodec : noCodec, + ensure(stageDir(request)), + ]).pipe( + Effect.flatMap(([codec]) => writeAtomic( resource(request), - JSON.stringify(encodeState(request.value), null, 2), + JSON.stringify(encodeState(request.value, codec), null, 2), ), ), recover, @@ -252,15 +320,20 @@ export const makeLocalState = () => ), getOutput: (request) => fs.readFile(outputFile(request)).pipe( - Effect.map((file) => parseState(file.toString())), + Effect.flatMap((file) => + parseState(file.toString(), "__stack_output__"), + ), recover, ), setOutput: (request) => - ensure(stageDir(request)).pipe( - Effect.flatMap(() => + Effect.all([ + containsRedacted(request.value) ? getCodec : noCodec, + ensure(stageDir(request)), + ]).pipe( + Effect.flatMap(([codec]) => writeAtomic( outputFile(request), - JSON.stringify(encodeState(request.value as any), null, 2), + JSON.stringify(encodeState(request.value as any, codec), null, 2), ), ), recover, diff --git a/packages/alchemy/src/State/PostgresState.ts b/packages/alchemy/src/State/PostgresState.ts index a718d46ede..cf7342dbf4 100644 --- a/packages/alchemy/src/State/PostgresState.ts +++ b/packages/alchemy/src/State/PostgresState.ts @@ -10,8 +10,10 @@ import type * as SqlError from "effect/unstable/sql/SqlError"; import { recordStateStoreInit } from "../Telemetry/Metrics.ts"; import { STATE_STORE_VERSION } from "./HttpStateApi.ts"; import type { ReplacedResourceState } from "./ResourceState.ts"; +import { resolveSecretCodec } from "./SecretCodec.ts"; import { State, + stateDecodeError, StateStoreError, type PersistedState, type StateService, @@ -478,7 +480,27 @@ export const makePostgresState = ( ), ); - const jsonParam = (value: unknown) => JSON.stringify(encodeState(value)); + // Postgres state is shared across machines, so there is no automatic + // key source — encryption of `Redacted` values is opt-in via the + // shared `ALCHEMY_PASSWORD` (a scrypt-derived AES-256 key). Without + // it, secrets persist as plaintext `__redacted__` markers (legacy + // behavior); with it, rows written elsewhere with the same password + // (or plaintext legacy rows) both revive. + const codec = yield* resolveSecretCodec; + + const jsonParam = (value: unknown) => + JSON.stringify(encodeState(value, codec)); + + /** + * Revive a persisted row value, surfacing decode failures (an + * encrypted `__secret__` envelope without/with the wrong + * ALCHEMY_PASSWORD) as StateStoreError rather than a defect. + */ + const reviveRow = (value: unknown, what: string) => + Effect.try({ + try: () => reviveStateRecursive(value, codec), + catch: stateDecodeError(what), + }); const deleteStage = (stack: string, stage: string) => run( @@ -524,13 +546,16 @@ export const makePostgresState = ( (sql) => sql`select value from alchemy_resource_state where stack = ${request.stack} and stage = ${request.stage} and fqn = ${request.fqn}`, ).pipe( - Effect.map((rows) => { + Effect.flatMap((rows) => { const row = rows[0]; // Every row was written by `set` through `encodeState`, so // reviving it recovers a PersistedState by construction. return row === undefined - ? undefined - : (reviveStateRecursive(row.value) as PersistedState); + ? Effect.succeed(undefined) + : (reviveRow(row.value, request.fqn) as Effect.Effect< + PersistedState, + StateStoreError + >); }), ), ), @@ -544,10 +569,14 @@ export const makePostgresState = ( (sql) => sql`select value from alchemy_resource_state where stack = ${request.stack} and stage = ${request.stage} and value ->> 'status' = 'replaced'`, ).pipe( - Effect.map((rows) => - rows.map( + Effect.flatMap((rows) => + Effect.forEach( + rows, (row) => - reviveStateRecursive(row.value) as ReplacedResourceState, + reviveRow(row.value, "replaced resources") as Effect.Effect< + ReplacedResourceState, + StateStoreError + >, ), ), ), @@ -618,11 +647,11 @@ export const makePostgresState = ( (sql) => sql`select value from alchemy_stack_output where stack = ${request.stack} and stage = ${request.stage}`, ).pipe( - Effect.map((rows) => { + Effect.flatMap((rows) => { const row = rows[0]; return row === undefined - ? undefined - : reviveStateRecursive(row.value); + ? Effect.succeed(undefined) + : reviveRow(row.value, "__stack_output__"); }), ), ), diff --git a/packages/alchemy/src/State/SecretCodec.ts b/packages/alchemy/src/State/SecretCodec.ts new file mode 100644 index 0000000000..e75ee8a2bb --- /dev/null +++ b/packages/alchemy/src/State/SecretCodec.ts @@ -0,0 +1,219 @@ +import * as NodeCrypto from "node:crypto"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import type { PlatformError } from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import { rootDir } from "../Auth/Paths.ts"; +import { StateStoreError } from "./State.ts"; + +/** + * Encrypts / decrypts the payload of a `Redacted` value before it is + * persisted by a state store. When a codec is active, secrets are written + * as `{ "__secret__": "" }` envelopes instead of the plaintext + * `{ "__redacted__": ... }` marker, so state files never contain secret + * values in the clear. + * + * Both operations are synchronous so they can run inside `JSON.stringify` + * / `JSON.parse` reviver callbacks on the state encode/decode path. Key + * material is therefore obtained *before* codec construction — from + * `ALCHEMY_PASSWORD`, the local `~/.alchemy/state.key` file, or a KMS + * data key — and closed over. + */ +export interface SecretCodec { + readonly encrypt: (plaintext: string) => string; + readonly decrypt: (payload: string) => string; +} + +/** Ciphertext format version prefix. */ +const VERSION_PREFIX = "v1:"; + +/** + * Fixed KDF context string. The password is expected to be a high-entropy + * secret (not a memorable human password), so a per-store random salt — + * which would need its own persistence and rotation story — is not used. + */ +const KDF_SALT = "alchemy-state-secret:v1"; + +/** AES-GCM recommended IV length. */ +const IV_BYTES = 12; + +/** AES-GCM auth tag length. */ +const TAG_BYTES = 16; + +/** + * Build a {@link SecretCodec} from raw 32-byte key material. Payloads are + * AES-256-GCM with a random per-value IV, framed as + * `v1:base64(iv || authTag || ciphertext)`. + */ +export const makeSecretCodecFromKey = (key: Uint8Array): SecretCodec => { + if (key.length !== 32) { + throw new Error( + `State secret key must be 32 bytes (got ${key.length}). The key material may be corrupted.`, + ); + } + return { + encrypt: (plaintext) => { + const iv = NodeCrypto.randomBytes(IV_BYTES); + const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + return ( + VERSION_PREFIX + + Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64") + ); + }, + decrypt: (payload) => { + if (!payload.startsWith(VERSION_PREFIX)) { + throw new Error( + `Unrecognized encrypted state secret format (expected "${VERSION_PREFIX}" prefix). The state may have been written by a newer version of alchemy.`, + ); + } + const framed = Buffer.from( + payload.slice(VERSION_PREFIX.length), + "base64", + ); + // Reject truncated frames before touching the cipher: a short + // frame would hand a short auth tag to setAuthTag (Node accepts + // some non-16-byte GCM tags, weakening authentication) and + // surface raw crypto errors instead of a malformed-frame one. + if (framed.length < IV_BYTES + TAG_BYTES) { + throw new Error( + "Malformed encrypted state secret: truncated envelope.", + ); + } + try { + const iv = framed.subarray(0, IV_BYTES); + const tag = framed.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); + const ciphertext = framed.subarray(IV_BYTES + TAG_BYTES); + const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString("utf8"); + } catch (cause) { + throw new Error( + "Failed to decrypt a secret in state: the configured key does not match the one that wrote this state (check ALCHEMY_PASSWORD, or the state key it was written with).", + { cause }, + ); + } + }, + }; +}; + +/** + * Build a {@link SecretCodec} from a password: scrypt (N=16384, one-shot + * at store init) derives the AES-256 key. + */ +export const makeSecretCodec = ( + password: Redacted.Redacted, +): SecretCodec => + makeSecretCodecFromKey( + NodeCrypto.scryptSync(Redacted.value(password), KDF_SALT, 32), + ); + +/** + * Read the optional `ALCHEMY_PASSWORD` override. When set, it takes + * precedence over every automatic key source (local key file, KMS) so + * teams and CI can pin one shared key across machines and stores. + */ +export const resolveSecretPassword: Effect.Effect< + Option.Option> +> = Config.option(Config.redacted("ALCHEMY_PASSWORD")).pipe(Effect.orDie); + +/** + * Resolve the state-secret codec from the `ALCHEMY_PASSWORD` config value + * alone. Returns `undefined` when no password is configured. Used by + * stores whose state is shared across machines (e.g. Postgres) where an + * automatic machine-local key would break other readers, so encryption + * is opt-in via the shared password. + */ +export const resolveSecretCodec: Effect.Effect = + resolveSecretPassword.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(undefined), + onSome: (password) => Effect.sync(() => makeSecretCodec(password)), + }), + ), + ); + +/** File name of the auto-generated local state key under `~/.alchemy`. */ +export const localStateKeyFileName = "state.key"; + +/** + * Resolve the codec for machine-local state (the `.alchemy/state/` file + * store): `ALCHEMY_PASSWORD` when set, otherwise an auto-generated + * 32-byte key persisted at `~/.alchemy/state.key` (created on first use, + * mode 0600). Always yields a codec — local state is encrypted by + * default with zero key management. + * + * Concurrent first-time creation is safe: the key file is created with + * the exclusive `wx` flag, and every process then reads back whatever + * won the race, so all writers converge on one key. + */ +export const resolveLocalSecretCodec: Effect.Effect< + SecretCodec, + PlatformError | StateStoreError, + FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const password = yield* resolveSecretPassword; + if (Option.isSome(password)) { + return yield* Effect.sync(() => makeSecretCodec(password.value)); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = rootDir(); + const keyFile = path.join(home, localStateKeyFileName); + + // Corrupted key material (truncated hex, wrong length) fails as a + // typed StateStoreError, not a defect escaping through Effect.map. + const readKey = fs.readFileString(keyFile).pipe( + Effect.flatMap((hex) => + Effect.try({ + try: () => makeSecretCodecFromKey(Buffer.from(hex.trim(), "hex")), + catch: (cause) => + new StateStoreError({ + message: `Invalid state secret key at ${keyFile}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause: cause instanceof Error ? cause : undefined, + }), + }), + ), + ); + + return yield* readKey.pipe( + Effect.catchTag("PlatformError", (e) => + e.reason._tag !== "NotFound" + ? Effect.fail(e) + : Effect.gen(function* () { + const fresh = yield* Effect.sync(() => + Buffer.from(NodeCrypto.randomBytes(32)).toString("hex"), + ); + yield* fs.makeDirectory(home, { recursive: true }); + // `wx` fails if the file already exists, so a concurrent + // creator can never be overwritten; the read-back below + // converges every process on the winning key. + yield* fs + .writeFileString(keyFile, `${fresh}\n`, { + flag: "wx", + mode: 0o600, + }) + .pipe( + Effect.catchTag("PlatformError", (we) => + we.reason._tag === "AlreadyExists" + ? Effect.void + : Effect.fail(we), + ), + ); + return yield* readKey; + }), + ), + ); +}); diff --git a/packages/alchemy/src/State/State.ts b/packages/alchemy/src/State/State.ts index 1249680b38..436f8c9f86 100644 --- a/packages/alchemy/src/State/State.ts +++ b/packages/alchemy/src/State/State.ts @@ -23,6 +23,20 @@ export class StateStoreError extends Data.TaggedError("StateStoreError")<{ cause?: Error; }> {} +/** + * Wrap a state decode failure (malformed JSON, missing/wrong + * `ALCHEMY_PASSWORD` for encrypted `__secret__` envelopes) in a + * {@link StateStoreError}. Shared by every client-side store so decode + * failures carry the same message shape everywhere. + */ +export const stateDecodeError = (what: string) => (cause: unknown) => + new StateStoreError({ + message: `Failed to decode state '${what}': ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause: cause instanceof Error ? cause : undefined, + }); + export class State extends Context.Service< State, Effect.Effect diff --git a/packages/alchemy/src/State/StateEncoding.ts b/packages/alchemy/src/State/StateEncoding.ts index 88c761468c..16cfaaf308 100644 --- a/packages/alchemy/src/State/StateEncoding.ts +++ b/packages/alchemy/src/State/StateEncoding.ts @@ -1,6 +1,9 @@ import * as Duration from "effect/Duration"; import * as Redacted from "effect/Redacted"; import { isResource } from "../Resource.ts"; +// Type-only: SecretCodec.ts pulls in node:crypto, which must not be +// bundled into workerd consumers of this module (Cloudflare StateStore DO). +import type { SecretCodec } from "./SecretCodec.ts"; /** * JSON marker used to tag a `Redacted` value when writing state. @@ -9,6 +12,14 @@ import { isResource } from "../Resource.ts"; */ export const REDACTED_MARKER = "__redacted__"; +/** + * JSON marker used to tag an *encrypted* `Redacted` value when a + * {@link SecretCodec} is active. The payload is the codec's ciphertext + * of the JSON-encoded inner value, so secrets never reach the state + * store in the clear. + */ +export const SECRET_MARKER = "__secret__"; + /** * JSON marker used to tag a `Duration` value when writing state. * The reviver recognises objects with exactly this key and rebuilds @@ -70,12 +81,14 @@ export const decodeDuration = ( * * - `Redacted` values are wrapped as `{ [REDACTED_MARKER]: }` * so the actual string is persisted rather than the `` - * placeholder produced by the default `toJSON`. + * placeholder produced by the default `toJSON` — unless a `codec` is + * provided, in which case they are wrapped as + * `{ [SECRET_MARKER]: }` and never persisted in plaintext. * - `Resource` instances are flattened to `{ id, type, props, attr }` * so persisted state matches the schema used by the loader. * - Plain objects and arrays are walked structurally. */ -export const encodeState = (value: unknown): unknown => { +export const encodeState = (value: unknown, codec?: SecretCodec): unknown => { if ( value === null || value === undefined || @@ -85,9 +98,17 @@ export const encodeState = (value: unknown): unknown => { ) return value; if (Redacted.isRedacted(value)) { - return { - [REDACTED_MARKER]: encodeState(Redacted.value(value)), - }; + // The inner value is encoded WITHOUT the codec: the whole envelope is + // encrypted, so nested markers inside it stay plain. + return codec + ? { + [SECRET_MARKER]: codec.encrypt( + JSON.stringify(encodeState(Redacted.value(value))), + ), + } + : { + [REDACTED_MARKER]: encodeState(Redacted.value(value)), + }; } if (Duration.isDuration(value)) { // `JSON.stringify(Duration.seconds(N))` already invokes Duration.toJSON, @@ -113,15 +134,15 @@ export const encodeState = (value: unknown): unknown => { return { id: value.LogicalId, type: value.Type, - props: encodeState(value.Props), - attr: encodeState(value.Attributes), + props: encodeState(value.Props, codec), + attr: encodeState(value.Attributes, codec), }; } - if (Array.isArray(value)) return value.map(encodeState); + if (Array.isArray(value)) return value.map((v) => encodeState(v, codec)); if (typeof value === "object") { const result: Record = {}; for (const [k, v] of Object.entries(value)) { - result[k] = encodeState(v); + result[k] = encodeState(v, codec); } return result; } @@ -129,31 +150,101 @@ export const encodeState = (value: unknown): unknown => { }; /** - * JSON reviver that rebuilds `Redacted` values that were written - * through {@link encodeState}. Intended for use with `JSON.parse`. + * Whether a state value contains any `Redacted` — i.e. whether + * {@link encodeState} would need a {@link SecretCodec} at all. Lets + * stores with expensive codec resolution (S3/KMS) skip it entirely for + * secret-free values. */ -export const reviveState = (_key: string, value: unknown): unknown => { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { - const obj = value as Record; - if (REDACTED_MARKER in obj) { - return Redacted.make(obj[REDACTED_MARKER]); - } - if (DURATION_MARKER in obj) { - const decoded = decodeDuration(obj[DURATION_MARKER]); - if (decoded !== undefined) return decoded; - } - // Exact single-key match only — unlike the legacy REDACTED/DURATION - // markers there is no pre-marker data to tolerate, so a user object - // that merely CONTAINS the key alongside other fields is never - // collapsed into a Date (mirrors reviveStateRecursive's strictness). - if (DATE_MARKER in obj && Object.keys(obj).length === 1) { - const decoded = decodeDate(obj[DATE_MARKER]); - if (decoded !== undefined) return decoded; - } +export const containsRedacted = (value: unknown): boolean => { + if (value === null || typeof value !== "object") return false; + if (Redacted.isRedacted(value)) return true; + if (Duration.isDuration(value) || value instanceof Date) return false; + if (isResource(value)) { + return containsRedacted(value.Props) || containsRedacted(value.Attributes); } - return value; + if (Array.isArray(value)) return value.some(containsRedacted); + return Object.values(value).some(containsRedacted); }; +/** + * Cheap pre-parse check for encrypted envelopes in raw state JSON. A + * plain string value containing the marker text can false-positive — + * harmless, the reviver only decrypts actual single-key envelope + * objects — but a real `{ "__secret__": ... }` key always matches, so + * a `false` result guarantees the codec is not needed. + */ +export const hasSecretMarker = (json: string): boolean => + json.includes(`"${SECRET_MARKER}"`); + +/** + * Rebuild a `Redacted` from a `{ [SECRET_MARKER]: }` + * envelope. Throws with an actionable message when no codec is available + * (state was encrypted but no key source is configured) — the state + * stores surface this as a `StateStoreError`. + */ +const decodeSecret = ( + payload: unknown, + codec: SecretCodec | undefined, +): Redacted.Redacted => { + if (typeof payload !== "string") { + throw new Error( + `Malformed "${SECRET_MARKER}" envelope in state: expected a string ciphertext.`, + ); + } + if (codec === undefined) { + throw new Error( + `State contains encrypted secrets ("${SECRET_MARKER}") but no decryption key is available. Set ALCHEMY_PASSWORD to the key that wrote this state.`, + ); + } + return Redacted.make( + reviveStateRecursive(JSON.parse(codec.decrypt(payload))), + ); +}; + +/** + * Build a JSON reviver that rebuilds `Redacted` values that were + * written through {@link encodeState}. Intended for use with `JSON.parse`. + * Pass the ambient {@link SecretCodec} (if any) so `{ __secret__: ... }` + * envelopes decrypt on read. + */ +export const makeStateReviver = + (codec?: SecretCodec) => + (_key: string, value: unknown): unknown => { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const obj = value as Record; + if (REDACTED_MARKER in obj) { + return Redacted.make(obj[REDACTED_MARKER]); + } + // Exact single-key envelopes only: encodeState always writes + // `{ __secret__: }` alone, so a user object that + // merely CONTAINS the key alongside other fields is data, not an + // envelope — attempting to decrypt it would fail and make the + // whole state unreadable. + if (SECRET_MARKER in obj && Object.keys(obj).length === 1) { + return decodeSecret(obj[SECRET_MARKER], codec); + } + if (DURATION_MARKER in obj) { + const decoded = decodeDuration(obj[DURATION_MARKER]); + if (decoded !== undefined) return decoded; + } + // Exact single-key match only — unlike the legacy REDACTED/DURATION + // markers there is no pre-marker data to tolerate, so a user object + // that merely CONTAINS the key alongside other fields is never + // collapsed into a Date (mirrors reviveStateRecursive's strictness). + if (DATE_MARKER in obj && Object.keys(obj).length === 1) { + const decoded = decodeDate(obj[DATE_MARKER]); + if (decoded !== undefined) return decoded; + } + } + return value; + }; + +/** + * JSON reviver without a secret codec — plaintext `__redacted__` markers + * revive; encrypted `__secret__` envelopes throw (missing key). + */ +export const reviveState = makeStateReviver(); + /** * Recursively walk an already-decoded value and rebuild `Redacted` * instances from `{ [REDACTED_MARKER]: }` envelopes. Mirror @@ -161,14 +252,21 @@ export const reviveState = (_key: string, value: unknown): unknown => { * value rather than a JSON string (e.g. the HTTP state-store client, * which receives values pre-parsed by `HttpApiClient`). */ -export const reviveStateRecursive = (value: unknown): unknown => { +export const reviveStateRecursive = ( + value: unknown, + codec?: SecretCodec, +): unknown => { if (value === null || typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(reviveStateRecursive); + if (Array.isArray(value)) + return value.map((v) => reviveStateRecursive(v, codec)); const obj = value as Record; const keys = Object.keys(obj); if (keys.length === 1 && keys[0] === REDACTED_MARKER) { return Redacted.make(reviveStateRecursive(obj[REDACTED_MARKER])); } + if (keys.length === 1 && keys[0] === SECRET_MARKER) { + return decodeSecret(obj[SECRET_MARKER], codec); + } if (keys.length === 1 && keys[0] === DURATION_MARKER) { const decoded = decodeDuration(obj[DURATION_MARKER]); if (decoded !== undefined) return decoded; @@ -179,7 +277,7 @@ export const reviveStateRecursive = (value: unknown): unknown => { } const result: Record = {}; for (const [k, v] of Object.entries(obj)) { - result[k] = reviveStateRecursive(v); + result[k] = reviveStateRecursive(v, codec); } return result; }; diff --git a/packages/alchemy/src/State/index.ts b/packages/alchemy/src/State/index.ts index aae55bf96a..bde559fafd 100644 --- a/packages/alchemy/src/State/index.ts +++ b/packages/alchemy/src/State/index.ts @@ -2,6 +2,10 @@ // into worker bundles via the core engine, and PostgresState loads // "@effect/sql-pg" (and through it "pg", which is Node-only). Deep-import it // instead: `alchemy/State/PostgresState`. +// +// SecretCodec is kept out for the same reason: it loads "node:crypto" and +// the `~/.alchemy` path helpers. The stores that need it deep-import it; +// `StateEncoding` only imports its type. export * from "./Export.ts"; export * from "./HttpStateApi.ts"; export * from "./HttpStateStore.ts"; diff --git a/packages/alchemy/test/AWS/StateStore/State.test.ts b/packages/alchemy/test/AWS/StateStore/State.test.ts index 19f58e2ea1..ff632b38f6 100644 --- a/packages/alchemy/test/AWS/StateStore/State.test.ts +++ b/packages/alchemy/test/AWS/StateStore/State.test.ts @@ -2,10 +2,14 @@ import * as AWS from "@/AWS"; import { makeS3State } from "@/AWS"; import { createStateBucketName } from "@/AWS/StateStore/State.ts"; import type { ResourceState, StateService } from "@/State"; +import { encodeState } from "@/State/StateEncoding.ts"; import * as Test from "@/Test/Alchemy"; +import * as kms from "@distilled.cloud/aws/kms"; import * as s3 from "@distilled.cloud/aws/s3"; import { expect } from "alchemy-test"; import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Stream from "effect/Stream"; const { test } = Test.make({ providers: AWS.providers() }); @@ -246,3 +250,262 @@ test.provider( }), { timeout: 120_000 }, ); + +test.provider( + "rolls forward legacy plaintext state: reads without KMS, re-writes encrypted", + () => + Effect.gen(function* () { + // Distinct prefix so the per-prefix data-key object proves exactly + // when KMS was (and was not) engaged. + const prefix = "test-state-legacy"; + const state = yield* makeS3State({ prefix }); + const stage = "roll-forward"; + const { accountId, region } = yield* AWS.AWSEnvironment.current; + const bucket = createStateBucketName(accountId, region); + const objectKey = `${prefix}/${STACK}/${stage}/LegacyResource.json`; + const dataKeyObject = `${prefix}/__state_key__.json`; + + // Runs the bucket-ensure and clears any prior run (incl. the + // per-prefix data key, so the KMS-laziness assertion is fresh). + yield* state.deleteStack({ stack: STACK, stage }); + yield* s3 + .deleteObject({ Bucket: bucket, Key: dataKeyObject }) + .pipe(Effect.orDie); + + const readRaw = s3 + .getObject({ Bucket: bucket, Key: objectKey }) + .pipe( + Effect.flatMap((r) => + r.Body === undefined + ? Effect.succeed("") + : Stream.mkString(Stream.decodeText(r.Body)), + ), + ); + const dataKeyExists = s3 + .getObject({ Bucket: bucket, Key: dataKeyObject }) + .pipe( + Effect.map(() => true), + Effect.catchTag("NoSuchKey", () => Effect.succeed(false)), + ); + + yield* Effect.gen(function* () { + const value = { + ...resource("LegacyResource", { url: "https://example.com" }), + props: { apiKey: Redacted.make("sk-live-legacy-secret") }, + } as ResourceState; + + // 1. The old world: hand-write the exact plaintext-marker JSON a + // pre-encryption version persisted (encodeState without a + // codec is that legacy writer). + yield* s3.putObject({ + Bucket: bucket, + Key: objectKey, + Body: JSON.stringify(encodeState(value), null, 2), + ContentType: "application/json", + }); + + // 2. The new version reads it — and because there is no + // __secret__ marker, without engaging KMS at all. + const revived = (yield* state.get({ + stack: STACK, + stage, + fqn: "LegacyResource", + })) as ResourceState | undefined; + expect( + Redacted.value( + (revived?.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-legacy-secret"); + expect(yield* dataKeyExists).toBe(false); + + // 3. The next write (what any subsequent deploy does) migrates + // the object to the encrypted envelope. + yield* state.set({ + stack: STACK, + stage, + fqn: "LegacyResource", + value: revived!, + }); + const migrated = yield* readRaw; + expect(migrated).toContain("__secret__"); + expect(migrated).not.toContain("__redacted__"); + expect(migrated).not.toContain("sk-live-legacy-secret"); + expect(yield* dataKeyExists).toBe(true); + + // 4. The migrated state round-trips. + const reread = (yield* state.get({ + stack: STACK, + stage, + fqn: "LegacyResource", + })) as ResourceState | undefined; + expect( + Redacted.value( + (reread?.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-legacy-secret"); + }).pipe(Effect.ensuring(cleanStage(state, stage))); + }), + { timeout: 120_000 }, +); + +test.provider( + "secret-free state never engages KMS", + () => + Effect.gen(function* () { + // Distinct prefix: the KMS-wrapped data key object is per-prefix, + // so its absence proves the codec was never resolved for this + // store — no KMS permission or key is needed without secrets. + const prefix = "test-state-plain"; + const state = yield* makeS3State({ prefix }); + const stage = "no-secrets"; + + yield* state.deleteStack({ stack: STACK, stage }); + + yield* Effect.gen(function* () { + const value = resource("PlainResource", { url: "https://example.com" }); + yield* state.set({ stack: STACK, stage, fqn: value.fqn, value }); + expect( + yield* state.get({ stack: STACK, stage, fqn: value.fqn }), + ).toEqual(value); + + const { accountId, region } = yield* AWS.AWSEnvironment.current; + const wrappedKey = yield* s3 + .getObject({ + Bucket: createStateBucketName(accountId, region), + Key: `${prefix}/__state_key__.json`, + }) + .pipe( + Effect.map(() => "exists"), + Effect.catchTag("NoSuchKey", () => Effect.succeed("absent")), + ); + expect(wrappedKey).toBe("absent"); + }).pipe(Effect.ensuring(cleanStage(state, stage))); + }), + { timeout: 120_000 }, +); + +test.provider( + "recovers the KMS state key from a pending deletion", + () => + Effect.gen(function* () { + const stage = "kms-recovery"; + + // Ensure the alias key and wrapped data key exist by writing a + // secret through a store first. + const before = yield* makeS3State({ prefix: "test-state" }); + yield* before.deleteStack({ stack: STACK, stage }); + + yield* Effect.gen(function* () { + const value = { + ...resource("RecoveryResource", {}), + props: { apiKey: Redacted.make("sk-live-recovery-secret") }, + } as ResourceState; + yield* before.set({ stack: STACK, stage, fqn: value.fqn, value }); + + // Schedule the state key for deletion out-of-band — exactly what + // an account-wide cleanup (`bun nuke`) does. Resolve the key from + // the wrapped data key object rather than the alias: a cleanup + // deletes the alias immediately while the key sits in its + // pending-deletion window. + const { accountId, region } = yield* AWS.AWSEnvironment.current; + const wrappedBody = yield* s3 + .getObject({ + Bucket: createStateBucketName(accountId, region), + Key: "test-state/__state_key__.json", + }) + .pipe( + Effect.flatMap((r) => + r.Body === undefined + ? Effect.succeed("") + : Stream.mkString(Stream.decodeText(r.Body)), + ), + ); + const keyId = (JSON.parse(wrappedBody) as { keyId?: string }).keyId; + expect(keyId).toBeDefined(); + yield* kms + .scheduleKeyDeletion({ KeyId: keyId!, PendingWindowInDays: 7 }) + .pipe( + // another concurrent test may have raced the same transition + Effect.catchTag("KMSInvalidStateException", () => Effect.void), + ); + + // A fresh store (fresh codec cache) must cancel the deletion, + // re-enable the key, and read the secret back. + const after = yield* makeS3State({ prefix: "test-state" }); + const revived = (yield* after.get({ + stack: STACK, + stage, + fqn: "RecoveryResource", + })) as ResourceState | undefined; + expect( + Redacted.value( + (revived?.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-recovery-secret"); + + const recovered = yield* kms.describeKey({ KeyId: keyId! }); + expect(recovered.KeyMetadata?.KeyState).toBe("Enabled"); + }).pipe(Effect.ensuring(cleanStage(before, stage))); + }), + { timeout: 120_000 }, +); + +test.provider( + "secrets are KMS-encrypted at rest and revive on read", + () => + Effect.gen(function* () { + const state = yield* makeS3State({ prefix: "test-state" }); + const stage = "kms-secrets"; + + yield* state.deleteStack({ stack: STACK, stage }); + + yield* Effect.gen(function* () { + const value = { + ...resource("SecretResource", { url: "https://example.com" }), + props: { apiKey: Redacted.make("sk-live-kms-secret") }, + } as ResourceState; + yield* state.set({ stack: STACK, stage, fqn: value.fqn, value }); + + const { accountId, region } = yield* AWS.AWSEnvironment.current; + const bucket = createStateBucketName(accountId, region); + const readRaw = (key: string) => + s3 + .getObject({ Bucket: bucket, Key: key }) + .pipe( + Effect.flatMap((r) => + r.Body === undefined + ? Effect.succeed("") + : Stream.mkString(Stream.decodeText(r.Body)), + ), + ); + + // The raw S3 object holds an encrypted envelope, never plaintext. + const raw = yield* readRaw( + `test-state/${STACK}/${stage}/SecretResource.json`, + ); + expect(raw).toContain("__secret__"); + expect(raw).not.toContain("sk-live-kms-secret"); + // Non-secret state stays introspectable. + expect(raw).toContain("https://example.com"); + + // The KMS-wrapped data key is persisted at the prefix root. + const wrapped = yield* readRaw("test-state/__state_key__.json"); + expect(wrapped).toContain("ciphertext"); + expect(wrapped).not.toContain("sk-live-kms-secret"); + + // Reading through the store unwraps the data key via KMS and + // decrypts back into a Redacted. + const revived = (yield* state.get({ + stack: STACK, + stage, + fqn: "SecretResource", + })) as ResourceState | undefined; + expect( + Redacted.value( + (revived?.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-kms-secret"); + }).pipe(Effect.ensuring(cleanStage(state, stage))); + }), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/State/PostgresState.test.ts b/packages/alchemy/test/State/PostgresState.test.ts index 4aa4313cef..76ccb94f01 100644 --- a/packages/alchemy/test/State/PostgresState.test.ts +++ b/packages/alchemy/test/State/PostgresState.test.ts @@ -4,8 +4,10 @@ import { type PostgresStateOptions, } from "@/State/PostgresState"; import { StateStoreError, type StateService } from "@/State/State"; +import { encodeState } from "@/State/StateEncoding"; import { describe, expect, it } from "alchemy-test"; import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -709,3 +711,99 @@ describe("Postgres state store", () => { ); }); }); + +/** + * Secret-encryption compatibility for the Postgres store. Postgres state is + * shared across machines, so encryption is opt-in via `ALCHEMY_PASSWORD`; + * without it the store must behave exactly as before, and with it the + * plaintext rows written by older versions must still revive. + */ +describe("Postgres state store: secret encryption compatibility", () => { + const noPassword = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); + const withPassword = (password: string) => + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { ALCHEMY_PASSWORD: password } }), + ); + const rowKey = "app prod app/prod/db"; + /** The exact row a pre-encryption version wrote: plaintext markers. */ + const legacyRow = JSON.parse(JSON.stringify(encodeState(sampleState))); + const passwordOf = (revived: unknown) => + Redacted.value( + (revived as { output: { password: Redacted.Redacted } }).output + .password, + ); + + it.effect( + "without ALCHEMY_PASSWORD nothing changes: plaintext markers are written and legacy rows revive", + () => { + const fake = makeFakePostgres(); + return withStore(fake, {}, (store) => + Effect.gen(function* () { + yield* store.set({ ...request, value: sampleState }); + const stored = JSON.stringify(fake.resources.get(rowKey)); + expect(stored).toContain("__redacted__"); + expect(stored).not.toContain("__secret__"); + expect(stored).toContain("s3cret"); + + fake.resources.set(rowKey, legacyRow); + expect(passwordOf(yield* store.get(request))).toBe("s3cret"); + }), + ).pipe(Effect.provide(noPassword)); + }, + ); + + it.effect( + "with ALCHEMY_PASSWORD rows are encrypted, legacy plaintext rows still revive, and a wrong password fails typed", + () => { + const fake = makeFakePostgres(); + return Effect.gen(function* () { + yield* withStore(fake, {}, (store) => + Effect.gen(function* () { + yield* store.set({ ...request, value: sampleState }); + const stored = JSON.stringify(fake.resources.get(rowKey)); + expect(stored).toContain("__secret__"); + expect(stored).not.toContain("__redacted__"); + expect(stored).not.toContain("s3cret"); + expect(passwordOf(yield* store.get(request))).toBe("s3cret"); + + // Replaced-resource scans decrypt too. + fake.resources.set(rowKey, { + ...(fake.resources.get(rowKey) as object), + status: "replaced", + }); + const replaced = yield* store.getReplacedResources(request); + expect(replaced).toHaveLength(1); + expect(passwordOf(replaced[0])).toBe("s3cret"); + + // A row written by a pre-encryption version revives unchanged. + fake.resources.set(rowKey, legacyRow); + expect(passwordOf(yield* store.get(request))).toBe("s3cret"); + // ...and is re-encrypted on its next write. + yield* store.set({ + ...request, + value: (yield* store.get(request)) as never, + }); + expect(JSON.stringify(fake.resources.get(rowKey))).toContain( + "__secret__", + ); + }), + ).pipe(Effect.provide(withPassword("password-a"))); + + // Another machine reading the same rows with the wrong password + // gets a typed StateStoreError, never a defect. + const error = yield* withStore(fake, {}, (store) => + store.get(request).pipe(Effect.flip), + ).pipe(Effect.provide(withPassword("password-b"))); + expect(error._tag).toBe("StateStoreError"); + expect(error.message).toMatch(/does not match/); + + // ...and with no password at all the failure names the fix. + const missing = yield* withStore(fake, {}, (store) => + store.get(request).pipe(Effect.flip), + ).pipe(Effect.provide(noPassword)); + expect(missing._tag).toBe("StateStoreError"); + expect(missing.message).toContain("ALCHEMY_PASSWORD"); + }); + }, + ); +}); diff --git a/packages/alchemy/test/State/SecretEncryptionCompat.test.ts b/packages/alchemy/test/State/SecretEncryptionCompat.test.ts new file mode 100644 index 0000000000..0a24baad1f --- /dev/null +++ b/packages/alchemy/test/State/SecretEncryptionCompat.test.ts @@ -0,0 +1,517 @@ +import { rootDir } from "@/Auth/Paths.ts"; +import { makeLocalState } from "@/State/LocalState.ts"; +import type { ResourceState } from "@/State/ResourceState.ts"; +import { localStateKeyFileName } from "@/State/SecretCodec.ts"; +import type { PersistedState, StateStoreError } from "@/State/State.ts"; +import { + encodeState, + REDACTED_MARKER, + SECRET_MARKER, +} from "@/State/StateEncoding.ts"; +import { initialCwd } from "@/Util/Node.ts"; +import { PlatformServices } from "@/Util/PlatformServices.ts"; +import { describe, expect, it } from "alchemy-test"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; + +/** + * Backwards-compatibility matrix for secret encryption in the LOCAL state + * store. The invariant under test: upgrading alchemy must never make + * existing state unreadable, and a read that cannot be decrypted must fail + * with a typed `StateStoreError` — never a defect, never a silent rewrite. + * + * Tests that exercise the auto-generated machine key point `ALCHEMY_HOME` + * at a scoped temp directory (and are `exclusive`, the env var is + * process-global) so they never read, create, or delete the developer's + * real `~/.alchemy/state.key`. + */ + +const SECRET = "sk-live-compat-secret"; + +/** A `ConfigProvider` with NO `ALCHEMY_PASSWORD`, whatever the real env holds. */ +const noPassword = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); + +const withPassword = (password: string) => + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { ALCHEMY_PASSWORD: password } }), + ); + +const resource = ( + fqn: string, + props: Record, + overrides?: Partial, +): ResourceState => + ({ + kind: "resource", + resourceType: "Test.Resource", + namespace: undefined, + fqn, + logicalId: fqn, + instanceId: `instance-${fqn}`, + providerVersion: 1, + status: "created", + downstream: [], + bindings: [], + props, + attr: { url: "https://example.com" }, + ...overrides, + }) as ResourceState; + +const secretOf = (state: PersistedState | undefined) => + Redacted.value( + ( + (state as ResourceState | undefined)?.props as { + apiKey: Redacted.Redacted; + } + ).apiKey, + ); + +/** Absolute path of a state file, anchored like `makeLocalState` itself. */ +const stateFile = (stack: string, stage: string, name: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join( + initialCwd, + ".alchemy", + "state", + stack, + stage, + `${name}.json`, + ); + }); + +/** + * The exact bytes every pre-encryption version of alchemy persisted: + * `encodeState` without a codec IS the legacy writer (plaintext + * `__redacted__` markers), pretty-printed the way `LocalState.set` does. + */ +const legacyJson = (value: unknown) => + JSON.stringify(encodeState(value), null, 2); + +const writeLegacyFile = (file: string, value: unknown) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, legacyJson(value)); + }); + +/** + * Point `ALCHEMY_HOME` at a scoped temp directory for the duration of + * `effect`, so the machine key lives (and dies) there. Returns the temp + * home so callers can inspect / remove the key file. + */ +const withTempHome = ( + effect: (home: string) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-state-key-", + }); + const previous = process.env.ALCHEMY_HOME; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env.ALCHEMY_HOME = dir; + }), + () => + Effect.sync(() => { + if (previous === undefined) delete process.env.ALCHEMY_HOME; + else process.env.ALCHEMY_HOME = previous; + }), + ); + return yield* effect(dir); + }).pipe(Effect.scoped); + +const keyFileIn = (home: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(home, localStateKeyFileName); + }); + +describe("secret encryption: local state backwards compatibility", () => { + it.effect( + "reads legacy plaintext state on a machine with no key — and never creates one", + () => + withTempHome((home) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stack = "secret-compat-legacy-read"; + const stage = "test"; + const keyFile = yield* keyFileIn(home); + expect(rootDir()).toBe(home); + + // The old world: a resource, a replaced resource and a stack output, + // all with plaintext `__redacted__` markers, written by hand. + const worker = resource("worker", { apiKey: Redacted.make(SECRET) }); + const replaced = resource( + "old-worker", + { apiKey: Redacted.make("sk-old") }, + { + status: "replaced", + deleteFirst: false, + old: resource("old-worker", { + apiKey: Redacted.make("sk-older"), + }), + } as Partial, + ); + yield* writeLegacyFile( + yield* stateFile(stack, stage, "worker"), + worker, + ); + yield* writeLegacyFile( + yield* stateFile(stack, stage, "old-worker"), + replaced, + ); + yield* writeLegacyFile( + yield* stateFile(stack, stage, "__stack_output__"), + { token: Redacted.make("out-secret"), url: "https://example.com" }, + ); + + const store = yield* makeLocalState().pipe( + Effect.provide(noPassword), + ); + + // Every read path revives the legacy markers into Redacted values. + expect( + secretOf(yield* store.get({ stack, stage, fqn: "worker" })), + ).toBe(SECRET); + expect([...(yield* store.list({ stack, stage }))].sort()).toEqual([ + "old-worker", + "worker", + ]); + const replacedRows = yield* store.getReplacedResources({ + stack, + stage, + }); + expect(replacedRows.map((r) => r.fqn)).toEqual(["old-worker"]); + expect(secretOf(replacedRows[0]!.old as ResourceState)).toBe( + "sk-older", + ); + const output = (yield* store.getOutput({ stack, stage })) as { + token: Redacted.Redacted; + url: string; + }; + expect(Redacted.value(output.token)).toBe("out-secret"); + expect(output.url).toBe("https://example.com"); + + // A secret-free write does not need a key either. + yield* store.set({ + stack, + stage, + fqn: "plain", + value: resource("plain", { name: "no secrets here" }), + }); + expect(yield* store.get({ stack, stage, fqn: "plain" })).toEqual( + resource("plain", { name: "no secrets here" }), + ); + + // Nothing above needed the machine key: reading legacy state and + // writing secret-free state on a fresh machine (a CI runner, a + // teammate's laptop) never creates `~/.alchemy/state.key`. + expect(yield* fs.exists(keyFile)).toBe(false); + // ...and the legacy files were not rewritten by the reads. + expect( + yield* fs.readFileString(yield* stateFile(stack, stage, "worker")), + ).toBe(legacyJson(worker)); + + yield* store.deleteStack({ stack }); + }), + ).pipe(Effect.provide(PlatformServices)), + { exclusive: true }, + ); + + it.effect( + "encrypted and legacy entries coexist in one stage; a legacy entry migrates on its next write", + () => + withTempHome((home) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stack = "secret-compat-mixed-stage"; + const stage = "test"; + const keyFile = yield* keyFileIn(home); + + const legacy = resource("legacy", { + apiKey: Redacted.make("sk-legacy"), + }); + const legacyFile = yield* stateFile(stack, stage, "legacy"); + yield* writeLegacyFile(legacyFile, legacy); + + const store = yield* makeLocalState().pipe( + Effect.provide(noPassword), + ); + + // The first secret write mints the machine key and encrypts. + const fresh = resource("fresh", { apiKey: Redacted.make(SECRET) }); + yield* store.set({ stack, stage, fqn: "fresh", value: fresh }); + expect(yield* fs.exists(keyFile)).toBe(true); + const freshRaw = yield* fs.readFileString( + yield* stateFile(stack, stage, "fresh"), + ); + expect(freshRaw).toContain(SECRET_MARKER); + expect(freshRaw).not.toContain(SECRET); + expect(freshRaw).toContain("https://example.com"); + + // Both formats read back through the same store... + expect( + secretOf(yield* store.get({ stack, stage, fqn: "legacy" })), + ).toBe("sk-legacy"); + expect( + secretOf(yield* store.get({ stack, stage, fqn: "fresh" })), + ).toBe(SECRET); + expect([...(yield* store.list({ stack, stage }))].sort()).toEqual([ + "fresh", + "legacy", + ]); + // ...and the legacy file is untouched until it is written again. + expect(yield* fs.readFileString(legacyFile)).toBe(legacyJson(legacy)); + + // The next write of the legacy resource (what any deploy does) + // migrates it to the encrypted envelope, and it still round-trips. + const revived = (yield* store.get({ stack, stage, fqn: "legacy" }))!; + yield* store.set({ stack, stage, fqn: "legacy", value: revived }); + const migrated = yield* fs.readFileString(legacyFile); + expect(migrated).toContain(SECRET_MARKER); + expect(migrated).not.toContain(REDACTED_MARKER); + expect(migrated).not.toContain("sk-legacy"); + expect( + secretOf(yield* store.get({ stack, stage, fqn: "legacy" })), + ).toBe("sk-legacy"); + + yield* store.deleteStack({ stack }); + }), + ).pipe(Effect.provide(PlatformServices)), + { exclusive: true }, + ); + + it.effect( + "a lost machine key fails reads with a typed StateStoreError, leaves the file intact, and restoring the key restores reads", + () => + withTempHome((home) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stack = "secret-compat-lost-key"; + const stage = "test"; + const key = { stack, stage, fqn: "worker" }; + const keyFile = yield* keyFileIn(home); + const file = yield* stateFile(stack, stage, "worker"); + + const writer = yield* makeLocalState().pipe( + Effect.provide(noPassword), + ); + yield* writer.set({ + ...key, + value: resource("worker", { apiKey: Redacted.make(SECRET) }), + }); + const originalKey = yield* fs.readFileString(keyFile); + const originalFile = yield* fs.readFileString(file); + + // Simulate a new laptop / wiped ~/.alchemy: the key is gone but the + // (committed or copied) state directory is still there. + yield* fs.remove(keyFile); + + // A fresh store resolves a fresh key, which cannot decrypt. + const reader = yield* makeLocalState().pipe( + Effect.provide(noPassword), + ); + const error = yield* reader.get(key).pipe(Effect.flip); + expect(error._tag).toBe("StateStoreError"); + expect(error.message).toContain("worker"); + expect(error.message).toMatch(/does not match/); + // The failed read must not have touched the state file — the data + // is still recoverable once the right key is back. + expect(yield* fs.readFileString(file)).toBe(originalFile); + // A replaced-resource scan hits the same undecryptable file and + // fails the same way rather than silently dropping the row. + const scanError = yield* reader + .getReplacedResources({ stack, stage }) + .pipe(Effect.flip); + expect(scanError._tag).toBe("StateStoreError"); + + // Restoring the original key makes the state readable again. + yield* fs.writeFileString(keyFile, originalKey); + const restored = yield* makeLocalState().pipe( + Effect.provide(noPassword), + ); + expect(secretOf(yield* restored.get(key))).toBe(SECRET); + + yield* restored.deleteStack({ stack }); + }), + ).pipe(Effect.provide(PlatformServices)), + { exclusive: true }, + ); + + it.effect( + "concurrent first-time writers converge on a single machine key", + () => + withTempHome((home) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stack = "secret-compat-key-race"; + const stage = "test"; + const keyFile = yield* keyFileIn(home); + expect(yield* fs.exists(keyFile)).toBe(false); + + // Two independent stores (two processes, in effect) race to create + // the key while writing their first secret. + const stores = yield* Effect.all( + Array.from({ length: 4 }, () => + makeLocalState().pipe(Effect.provide(noPassword)), + ), + ); + yield* Effect.all( + stores.map((store, i) => + store.set({ + stack, + stage, + fqn: `worker-${i}`, + value: resource(`worker-${i}`, { + apiKey: Redacted.make(`${SECRET}-${i}`), + }), + }), + ), + { concurrency: "unbounded" }, + ); + + const keyHex = (yield* fs.readFileString(keyFile)).trim(); + expect(keyHex).toMatch(/^[0-9a-f]{64}$/); + // The key is private to the user (mode 0600), whichever writer won. + const mode = (yield* fs.stat(keyFile)).mode & 0o777; + expect(mode.toString(8)).toBe("600"); + + // Whatever key won, every writer used it: a fresh store reads all. + const reader = yield* makeLocalState().pipe( + Effect.provide(noPassword), + ); + for (let i = 0; i < stores.length; i++) { + expect( + secretOf(yield* reader.get({ stack, stage, fqn: `worker-${i}` })), + ).toBe(`${SECRET}-${i}`); + } + + yield* reader.deleteStack({ stack }); + }), + ).pipe(Effect.provide(PlatformServices)), + { exclusive: true }, + ); + + it.effect( + "a wrong ALCHEMY_PASSWORD fails with a typed StateStoreError, not a defect", + () => + Effect.gen(function* () { + const stack = "secret-compat-wrong-password"; + const stage = "test"; + const key = { stack, stage, fqn: "worker" }; + + const writer = yield* makeLocalState().pipe( + Effect.provide(withPassword("password-a")), + ); + yield* writer.set({ + ...key, + value: resource("worker", { apiKey: Redacted.make(SECRET) }), + }); + + const reader = yield* makeLocalState().pipe( + Effect.provide(withPassword("password-b")), + ); + const error: StateStoreError = yield* reader.get(key).pipe(Effect.flip); + expect(error._tag).toBe("StateStoreError"); + expect(error.message).toMatch(/does not match/); + expect(error.message).toContain("ALCHEMY_PASSWORD"); + + // Secret-free reads in the same stage are unaffected by the key. + yield* writer.set({ + ...key, + fqn: "plain", + value: resource("plain", { name: "public" }), + }); + expect(yield* reader.get({ ...key, fqn: "plain" })).toEqual( + resource("plain", { name: "public" }), + ); + + yield* writer.deleteStack({ stack }); + }).pipe(Effect.provide(PlatformServices)), + ); + + it.effect( + "stack outputs holding secrets are encrypted at rest and revive; legacy plaintext outputs still revive", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stack = "secret-compat-outputs"; + const stage = "test"; + const store = yield* makeLocalState().pipe( + Effect.provide(withPassword("password-a")), + ); + const file = yield* stateFile(stack, stage, "__stack_output__"); + + yield* store.setOutput({ + stack, + stage, + value: { token: Redacted.make(SECRET), url: "https://example.com" }, + }); + const raw = yield* fs.readFileString(file); + expect(raw).toContain(SECRET_MARKER); + expect(raw).not.toContain(SECRET); + expect(raw).toContain("https://example.com"); + const output = (yield* store.getOutput({ stack, stage })) as { + token: Redacted.Redacted; + }; + expect(Redacted.value(output.token)).toBe(SECRET); + + // A pre-encryption output file (plaintext marker) is still readable. + yield* fs.writeFileString( + file, + legacyJson({ token: Redacted.make("legacy-out") }), + ); + const legacy = (yield* store.getOutput({ stack, stage })) as { + token: Redacted.Redacted; + }; + expect(Redacted.value(legacy.token)).toBe("legacy-out"); + + yield* store.deleteStack({ stack }); + }).pipe(Effect.provide(PlatformServices)), + ); + + it("an encrypted envelope is a plain single-key object to a pre-encryption reader, with no plaintext anywhere", () => { + // Pins the forward-compatibility story documented for shared stores: an + // alchemy version that predates `__secret__` has no reviver for it, so + // `JSON.parse` hands it the envelope as an ordinary object — never a + // crash, and never the secret. + const codec = { + encrypt: (plaintext: string) => + `v1:${Buffer.from(plaintext).toString("base64")}`, + decrypt: (payload: string) => + Buffer.from(payload.slice(3), "base64").toString(), + }; + const value = resource("worker", { + apiKey: Redacted.make(SECRET), + nested: { tokens: [Redacted.make("t-1")] }, + }); + const json = JSON.stringify(encodeState(value, codec)); + expect(json).not.toContain(SECRET); + expect(json).not.toContain("t-1"); + expect(json).not.toContain(REDACTED_MARKER); + + const legacyView = JSON.parse(json) as { + props: { + apiKey: Record; + nested: { tokens: Array> }; + }; + }; + expect(Object.keys(legacyView.props.apiKey)).toEqual([SECRET_MARKER]); + expect(String(legacyView.props.apiKey[SECRET_MARKER])).toMatch(/^v1:/); + expect(Object.keys(legacyView.props.nested.tokens[0]!)).toEqual([ + SECRET_MARKER, + ]); + + // And without a codec the writer is byte-for-byte the legacy writer. + expect(JSON.stringify(encodeState(value))).toContain( + `{"${REDACTED_MARKER}":"${SECRET}"}`, + ); + }); +}); diff --git a/packages/alchemy/test/State/StateEncoding.test.ts b/packages/alchemy/test/State/StateEncoding.test.ts new file mode 100644 index 0000000000..a736fb0015 --- /dev/null +++ b/packages/alchemy/test/State/StateEncoding.test.ts @@ -0,0 +1,356 @@ +import { rootDir } from "@/Auth/Paths.ts"; +import { makeLocalState } from "@/State/LocalState.ts"; +import type { ResourceState } from "@/State/ResourceState.ts"; +import { + localStateKeyFileName, + makeSecretCodec, + resolveSecretCodec, +} from "@/State/SecretCodec.ts"; +import { + encodeState, + makeStateReviver, + reviveState, + reviveStateRecursive, + REDACTED_MARKER, + SECRET_MARKER, +} from "@/State/StateEncoding.ts"; +import { PlatformServices } from "@/Util/PlatformServices.ts"; +import { describe, expect, it } from "alchemy-test"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; + +const PASSWORD = Redacted.make("correct horse battery staple"); +const codec = makeSecretCodec(PASSWORD); + +const sampleState = { + props: { + name: "my-worker", + apiKey: Redacted.make("sk-live-super-secret"), + nested: { tokens: [Redacted.make("t-1"), Redacted.make("t-2")] }, + }, + attr: { url: "https://example.com" }, +}; + +describe("StateEncoding secrets", () => { + it("round-trips Redacted values without a codec (legacy plaintext marker)", () => { + const json = JSON.stringify(encodeState(sampleState)); + expect(json).toContain(REDACTED_MARKER); + expect(json).toContain("sk-live-super-secret"); + const revived = JSON.parse(json, reviveState); + expect(Redacted.value(revived.props.apiKey)).toBe("sk-live-super-secret"); + expect(Redacted.value(revived.props.nested.tokens[1])).toBe("t-2"); + }); + + it("encrypts Redacted values with a codec — plaintext never in the output", () => { + const json = JSON.stringify(encodeState(sampleState, codec)); + expect(json).toContain(SECRET_MARKER); + expect(json).not.toContain(REDACTED_MARKER); + expect(json).not.toContain("sk-live-super-secret"); + expect(json).not.toContain("t-1"); + // Non-secret values stay introspectable. + expect(json).toContain("my-worker"); + expect(json).toContain("https://example.com"); + + const revived = JSON.parse(json, makeStateReviver(codec)); + expect(Redacted.value(revived.props.apiKey)).toBe("sk-live-super-secret"); + expect(Redacted.value(revived.props.nested.tokens[0])).toBe("t-1"); + }); + + it("reviveStateRecursive decrypts __secret__ envelopes", () => { + const encoded = encodeState(sampleState, codec); + // Simulate the HTTP store: value arrives pre-parsed, not as a JSON string. + const roundTripped = JSON.parse(JSON.stringify(encoded)); + const revived = reviveStateRecursive(roundTripped, codec) as any; + expect(Redacted.value(revived.props.apiKey)).toBe("sk-live-super-secret"); + }); + + it("still revives legacy plaintext __redacted__ markers when a codec is active", () => { + const legacyJson = JSON.stringify(encodeState(sampleState)); + const revived = JSON.parse(legacyJson, makeStateReviver(codec)); + expect(Redacted.value(revived.props.apiKey)).toBe("sk-live-super-secret"); + }); + + it("reviveStateRecursive revives legacy plaintext markers when a codec is active", () => { + // The HTTP store path: legacy state arrives pre-parsed with plaintext + // markers written by an older version. + const legacy = JSON.parse(JSON.stringify(encodeState(sampleState))); + const revived = reviveStateRecursive(legacy, codec) as any; + expect(Redacted.value(revived.props.apiKey)).toBe("sk-live-super-secret"); + expect(Redacted.value(revived.props.nested.tokens[1])).toBe("t-2"); + }); + + it("fails with an actionable error when encrypted state is read without a password", () => { + const json = JSON.stringify(encodeState(sampleState, codec)); + expect(() => JSON.parse(json, reviveState)).toThrow(/ALCHEMY_PASSWORD/); + }); + + it("fails with an actionable error on a wrong password", () => { + const json = JSON.stringify(encodeState(sampleState, codec)); + const wrong = makeSecretCodec(Redacted.make("not the password")); + expect(() => JSON.parse(json, makeStateReviver(wrong))).toThrow( + /does not match/, + ); + }); + + it("only decrypts exact single-key __secret__ envelopes", () => { + // A user object that merely CONTAINS the marker key alongside other + // fields is data, not an envelope — it must pass through untouched + // instead of failing the whole read. + const json = JSON.stringify({ + props: { [SECRET_MARKER]: "just a value", keep: true }, + }); + const revived = JSON.parse(json, makeStateReviver(codec)); + expect(revived.props[SECRET_MARKER]).toBe("just a value"); + expect(revived.props.keep).toBe(true); + const recursive = reviveStateRecursive(JSON.parse(json), codec) as any; + expect(recursive.props[SECRET_MARKER]).toBe("just a value"); + }); + + it("rejects truncated envelopes as malformed, not with raw crypto errors", () => { + const json = JSON.stringify({ apiKey: { [SECRET_MARKER]: "v1:AAAA" } }); + expect(() => JSON.parse(json, makeStateReviver(codec))).toThrow( + /truncated/, + ); + }); + + it.effect( + "resolveSecretCodec is undefined when ALCHEMY_PASSWORD is unset", + () => + Effect.gen(function* () { + const resolved = yield* resolveSecretCodec; + expect(resolved).toBeUndefined(); + }).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + ), + ), + ); + + it.effect( + "resolveSecretCodec builds a working codec from ALCHEMY_PASSWORD", + () => + Effect.gen(function* () { + const resolved = yield* resolveSecretCodec; + expect(resolved).toBeDefined(); + const ciphertext = resolved!.encrypt("hello"); + expect(ciphertext).not.toContain("hello"); + // Interoperates with a codec built directly from the same password. + expect(codec.decrypt(ciphertext)).toBe("hello"); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { ALCHEMY_PASSWORD: Redacted.value(PASSWORD) }, + }), + ), + ), + ), + ); + + it.effect( + "LocalState writes encrypted secrets to disk and revives them on read", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const state = yield* makeLocalState(); + const stack = "state-encoding-secret-codec-test"; + const key = { stack, stage: "test", fqn: "worker" }; + const value: ResourceState = { + kind: "resource", + resourceType: "Test.Resource", + namespace: undefined, + fqn: "worker", + logicalId: "worker", + instanceId: "i-1", + providerVersion: 1, + status: "created", + downstream: [], + bindings: [], + props: { apiKey: Redacted.make("sk-live-super-secret") }, + attr: { url: "https://example.com" }, + }; + yield* state.set({ ...key, value }); + + // The raw file on disk must not contain the plaintext secret. + const file = path.join( + process.cwd(), + ".alchemy", + "state", + stack, + "test", + "worker.json", + ); + const raw = yield* fs.readFileString(file); + expect(raw).toContain(SECRET_MARKER); + expect(raw).not.toContain("sk-live-super-secret"); + // Non-secret state stays introspectable. + expect(raw).toContain("https://example.com"); + + // Reading through the store decrypts back into a Redacted. + const revived = (yield* state.get(key)) as ResourceState; + expect( + Redacted.value( + (revived.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-super-secret"); + + yield* state.deleteStack({ stack }); + }).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { ALCHEMY_PASSWORD: Redacted.value(PASSWORD) }, + }), + ), + PlatformServices, + ), + ), + ), + ); + + it.effect( + "migrates legacy unencrypted local state: reads plaintext markers, re-writes encrypted", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stack = "state-encoding-migration-test"; + const key = { stack, stage: "test", fqn: "worker" }; + const file = path.join( + process.cwd(), + ".alchemy", + "state", + stack, + "test", + "worker.json", + ); + const value: ResourceState = { + kind: "resource", + resourceType: "Test.Resource", + namespace: undefined, + fqn: "worker", + logicalId: "worker", + instanceId: "i-1", + providerVersion: 1, + status: "created", + downstream: [], + bindings: [], + props: { apiKey: Redacted.make("sk-live-super-secret") }, + attr: { url: "https://example.com" }, + }; + + // 1. The old world: hand-write the exact plaintext-marker JSON + // every pre-encryption version of alchemy persisted (encodeState + // without a codec is that legacy writer). + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString( + file, + JSON.stringify(encodeState(value), null, 2), + ); + const legacyRaw = yield* fs.readFileString(file); + expect(legacyRaw).toContain(REDACTED_MARKER); + expect(legacyRaw).toContain("sk-live-super-secret"); + + // 2. The user sets ALCHEMY_PASSWORD: the legacy plaintext state + // must still read (backwards compatibility). + const store = yield* makeLocalState().pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { ALCHEMY_PASSWORD: Redacted.value(PASSWORD) }, + }), + ), + ), + ); + const revived = (yield* store.get(key)) as ResourceState; + expect( + Redacted.value( + (revived.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-super-secret"); + + // 3. The next write (what any subsequent deploy does) migrates the + // file to the encrypted envelope. + yield* store.set({ ...key, value: revived }); + const migratedRaw = yield* fs.readFileString(file); + expect(migratedRaw).toContain(SECRET_MARKER); + expect(migratedRaw).not.toContain(REDACTED_MARKER); + expect(migratedRaw).not.toContain("sk-live-super-secret"); + + // 4. The migrated state round-trips. + const migrated = (yield* store.get(key)) as ResourceState; + expect( + Redacted.value( + (migrated.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-super-secret"); + + yield* store.deleteStack({ stack }); + }).pipe(Effect.provide(PlatformServices)), + ); + + it.effect( + "encrypts local state by default via the auto-generated ~/.alchemy/state.key", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stack = "state-encoding-keyfile-default-test"; + const key = { stack, stage: "test", fqn: "worker" }; + const value: ResourceState = { + kind: "resource", + resourceType: "Test.Resource", + namespace: undefined, + fqn: "worker", + logicalId: "worker", + instanceId: "i-1", + providerVersion: 1, + status: "created", + downstream: [], + bindings: [], + props: { apiKey: Redacted.make("sk-live-super-secret") }, + attr: { url: "https://example.com" }, + }; + + // No ALCHEMY_PASSWORD anywhere in scope — the store must fall + // back to (and auto-create) the machine key. + const store = yield* makeLocalState().pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + ), + ); + yield* store.set({ ...key, value }); + + const keyFile = path.join(rootDir(), localStateKeyFileName); + expect(yield* fs.exists(keyFile)).toBe(true); + + const raw = yield* fs.readFileString( + path.join( + process.cwd(), + ".alchemy", + "state", + stack, + "test", + "worker.json", + ), + ); + expect(raw).toContain(SECRET_MARKER); + expect(raw).not.toContain("sk-live-super-secret"); + + const revived = (yield* store.get(key)) as ResourceState; + expect( + Redacted.value( + (revived.props as { apiKey: Redacted.Redacted }).apiKey, + ), + ).toBe("sk-live-super-secret"); + + yield* store.deleteStack({ stack }); + }).pipe(Effect.provide(PlatformServices)), + ); +}); diff --git a/website/src/content/docs/environments/secrets.mdx b/website/src/content/docs/environments/secrets.mdx index f28b2b861a..8c87336416 100644 --- a/website/src/content/docs/environments/secrets.mdx +++ b/website/src/content/docs/environments/secrets.mdx @@ -173,6 +173,94 @@ For the step-by-step "wire up `OPENAI_API_KEY` from `.env`" walk, see [Secrets & env on Cloudflare](/cloudflare/security/secrets-env) or [on AWS](/aws/security/secrets-env). +## Secrets in state are encrypted + +Every secret that flows through your stack — a `Config.redacted` +value, a resource attribute like an API token's `value` — is also +persisted to your state store, because Alchemy must remember the +previous value to detect changes on the next deploy. + +Secrets are encrypted (AES-256-GCM) before they are written: + +```json +{ "apiKey": { "__secret__": "v1:kx4X0f..." } } +``` + +Everything else in the state file stays plain JSON, so state remains +introspectable — only the secret payloads are ciphertext. There is no +key for you to manage; each store has an automatic key source: + +- **Local state** (`.alchemy/state/`) uses an auto-generated key at + `~/.alchemy/state.key`, created on first use. Because the key lives + in your home directory, state inside the repo is unreadable on its + own — an agent (or an accidental commit) never sees a plaintext + secret. To share one state tree across machines, set + `ALCHEMY_PASSWORD` (a scrypt-derived AES-256 key replaces the + machine key): + + ```bash + ALCHEMY_PASSWORD=$(openssl rand -base64 32) # save this somewhere safe + ``` + +- **S3 state** (`AWS.state()`) uses KMS envelope encryption: the first + time a secret is written, the store creates (or reuses) the + `alias/alchemy-state` KMS key, mints a data key, and stores the + KMS-wrapped data key in the bucket. Anyone who can deploy + (`kms:Decrypt`) can read state — teammates and CI work with nothing + to distribute. A stack with no secrets never touches KMS, so no + `kms:*` permission is required and no key is created. When readers + can't reach KMS, set `ALCHEMY_PASSWORD` to use a password-derived + key instead; opt out entirely with + `AWS.state({ secretEncryption: "off" })`. +- **The Cloudflare-hosted state store** (`Cloudflare.state()`) + encrypts every record server-side with its own generated key held in + the Secrets Store. + +Behavior to know: + +- **Change detection still works.** Secrets are decrypted when state + is read, so a changed secret value triggers an update exactly as + before. +- **Existing plaintext state migrates automatically.** Old + `__redacted__` markers still read fine; each resource is re-written + encrypted the next time it is deployed. +- **Use one key source consistently per state store.** Values + encrypted under one key cannot be read with another — if you set + `ALCHEMY_PASSWORD`, set it everywhere that reads that state + (including CI). Reading with a missing or wrong key fails with an + explicit error, not silent corruption. +- **Local state is machine-local.** The key lives in + `~/.alchemy/state.key`, not in the repo. Copying a repo's + `.alchemy/state/` to another machine, deleting the key file, or + moving to a new laptop leaves every record that holds a secret + unreadable: reads fail with an explicit `StateStoreError` (the deploy + stops; nothing is rewritten or silently dropped) until the original + key is restored. If a team shares `.alchemy/state/` through git, or + CI reads it, set the same `ALCHEMY_PASSWORD` everywhere **before** + upgrading — otherwise the first machine to deploy encrypts under its + own key and every other reader fails. If a key is truly lost, the + encrypted secrets in those records cannot be recovered: delete the + affected records with `alchemy state delete` and re-create (or + `--adopt`) the resources. +- **Postgres state** (`postgresState()`) is shared across machines and + has no automatic key, so it encrypts only when `ALCHEMY_PASSWORD` is + set; without it, secrets stay as plaintext `__redacted__` markers + exactly as before. +- `alchemy state read` decrypts through the store, so it needs the + same key a deploy would. + +:::caution +Encrypting a secret into state is a one-way format migration. Alchemy +versions older than the one that introduced `__secret__` envelopes do +not understand them: instead of failing, they read the envelope as a +plain object, which shows up as spurious diffs or a malformed secret +value at deploy time. When a state store is shared — S3, Postgres with +`ALCHEMY_PASSWORD`, or a committed `.alchemy/state/` — upgrade every +machine and CI job that reads it at the same time. HTTP-backed stores +(`Cloudflare.state()`) are not affected: the client never writes +envelopes to them. +::: + ## Where next - [Local development](/environments/local-development) — `alchemy dev`: local code, real cloud resources.