diff --git a/packages/alchemy/src/Cloudflare/SecretsStore/Secret.ts b/packages/alchemy/src/Cloudflare/SecretsStore/Secret.ts index 7b45d5c11c..09cb11f56f 100644 --- a/packages/alchemy/src/Cloudflare/SecretsStore/Secret.ts +++ b/packages/alchemy/src/Cloudflare/SecretsStore/Secret.ts @@ -52,6 +52,20 @@ export type StoreSecretProps = { * Optional free-form description. */ comment?: string; + /** + * Only send `value` when the secret is first created. Once a secret with + * this name exists in the store — including one adopted, or found again + * after alchemy's own state was lost — its stored value is kept and never + * overwritten; a later change to `value` still reconciles `scopes` and + * `comment` but leaves the value alone. + * + * The Secrets Store API is write-only, so an overwritten value can never + * be read back. Use this for secrets whose rotation is destructive — an + * encryption key protecting data at rest — where a stale value is + * strictly safer than an unintended new one. + * @default false + */ + preserveExistingValue?: boolean; }; export type Secret = Resource< @@ -232,13 +246,23 @@ export const SecretProviderLive = () => observed = existing; } + // The secret already exists (routine update, adoption, or recovery + // after state loss). With `preserveExistingValue` its stored value is + // authoritative: never PATCH it, whatever `news.value` says now. + if (news.preserveExistingValue) { + yield* Effect.logInfo( + `Secret '${name}' already exists; keeping its stored value (preserveExistingValue).`, + ); + } const patched = yield* secretsStore.patchStoreSecret({ accountId, storeId, secretId: observed.id, scopes, comment: news.comment, - value: Redacted.value(news.value), + ...(news.preserveExistingValue + ? {} + : { value: Redacted.value(news.value) }), }); const status = yield* waitForSecretActive( { accountId, storeId, secretId: observed.id }, @@ -432,12 +456,16 @@ export const SecretProviderLocal = () => // Seed — write the value into the local simulator so the dev // worker's `env..get()` returns it. An overwrite is - // idempotent, so re-running after a crash converges. - yield* seedLocalSecret( - storeId, - name, - Redacted.value(news.value), - ).pipe(Effect.provideContext(runtimeContext)); + // idempotent, so re-running after a crash converges. Mirrors the + // live rule for `preserveExistingValue`: an already-seeded secret + // keeps its value. + if (!(news.preserveExistingValue && output?.secretId)) { + yield* seedLocalSecret( + storeId, + name, + Redacted.value(news.value), + ).pipe(Effect.provideContext(runtimeContext)); + } return { secretId: output?.secretId ?? generateLocalId(), diff --git a/packages/alchemy/src/Cloudflare/StateStore/Api.ts b/packages/alchemy/src/Cloudflare/StateStore/Api.ts index cfc5c07b47..da5d1b86fb 100644 --- a/packages/alchemy/src/Cloudflare/StateStore/Api.ts +++ b/packages/alchemy/src/Cloudflare/StateStore/Api.ts @@ -32,7 +32,7 @@ export const STATE_STORE_SCRIPT_NAME = "alchemy-state-store" as const; * compare against this constant; a mismatch (or 404) triggers a * forced redeploy via the bootstrap flow. */ -export const STATE_STORE_VERSION = 7 as const; +export const STATE_STORE_VERSION = 8 as const; /** * Hard-coded OTLP/HTTP endpoints. Point at the public ingest relay diff --git a/packages/alchemy/src/Cloudflare/StateStore/EntryCodec.ts b/packages/alchemy/src/Cloudflare/StateStore/EntryCodec.ts new file mode 100644 index 0000000000..8eeefc0f35 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/StateStore/EntryCodec.ts @@ -0,0 +1,87 @@ +import { encodeState } from "../../State/StateEncoding.ts"; + +/** + * At-rest encryption of the Cloudflare State Store's Durable Object + * entries: AES-CTR over the JSON-encoded state, framed as a single base64 + * string `nonce || ciphertext`. + * + * Kept as plain Web Crypto functions (no Effect, no DO bindings) so the + * codec can be unit-tested outside a worker. `Store.ts` wraps them. + */ + +/** AES-CTR counter block length. */ +export const NONCE_BYTES = 16; + +/** Import the hex-encoded 32-byte key held in the Secrets Store. */ +export const importEntryKey = (keyHex: string): Promise => + crypto.subtle.importKey( + "raw", + Buffer.from(keyHex, "hex"), + { name: "AES-CTR" }, + false, + ["encrypt", "decrypt"], + ); + +/** Encode (`encodeState`) and encrypt a value under a fresh random nonce. */ +export const encryptEntry = async ( + cryptoKey: CryptoKey, + value: unknown, +): Promise => { + const plaintext = new TextEncoder().encode( + JSON.stringify(encodeState(value)), + ); + const counter = crypto.getRandomValues(allocBytes(NONCE_BYTES)); + const ct = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-CTR", counter, length: 64 }, + cryptoKey, + plaintext, + ), + ); + // Frame as a single base64 string: nonce || ciphertext. + return Buffer.concat([counter, ct]).toString("base64"); +}; + +/** + * Decrypt and decode an entry. Resolves `undefined` — never rejects — when + * the entry cannot be read. + * + * In 2.0.0-beta.45 the state store bootstrap rotated encryption keys + * unnecessarily, so entries written under the previous key exist in the + * wild. AES-CTR is unauthenticated: decrypting with the wrong key does NOT + * fail in Web Crypto, it yields random bytes, and the failure only surfaces + * when those bytes are decoded as JSON. Both the decrypt step and the JSON + * decode therefore live inside the same guard, so an unreadable entry + * degrades to "absent" and the engine reconciles the resource (users may + * lose some data) instead of the whole deploy dying on a `SyntaxError`. + */ +export const decryptEntry = async ( + cryptoKey: CryptoKey, + entry: string, +): Promise => { + try { + const framed = Buffer.from(entry, "base64"); + const counter = framed.subarray(0, NONCE_BYTES); + const ciphertext = framed.subarray(NONCE_BYTES); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-CTR", counter, length: 64 }, + cryptoKey, + ciphertext, + ); + return JSON.parse(new TextDecoder().decode(plaintext)) as T; + } catch (error) { + console.error( + "Error decrypting or decoding entry. Returning undefined instead.", + error, + ); + return undefined; + } +}; + +/** + * Allocate a `Uint8Array` over a fresh `ArrayBuffer` (not shared) so + * the resulting buffer satisfies Web Crypto's `BufferSource` type + * constraint under strict DOM typings. + */ +const allocBytes = (size: number): Uint8Array => + new Uint8Array(new ArrayBuffer(size)); diff --git a/packages/alchemy/src/Cloudflare/StateStore/KeyFingerprint.ts b/packages/alchemy/src/Cloudflare/StateStore/KeyFingerprint.ts new file mode 100644 index 0000000000..15940a2654 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/StateStore/KeyFingerprint.ts @@ -0,0 +1,78 @@ +import * as Effect from "effect/Effect"; + +/** + * Guard against an encryption-key rotation silently wiping a state store. + * + * Every stack Durable Object records a fingerprint of the key that encrypts + * its entries the first time it decrypts or encrypts anything. Later boots + * compare the key they were handed against that record. A mismatch means + * the `AlchemyStateStoreEncryptionKey` secret changed underneath the data: + * every entry would decode as garbage, read back as "absent", and the next + * deploy would overwrite the ciphertext with entries under the new key — + * the 2.0.0-beta.45 incident. The store refuses to serve data instead. + * + * The fingerprint is a SHA-256 of the raw key bytes: safe to persist next + * to the data, and useless for recovering the key. + */ + +/** + * Storage key of the recorded fingerprint inside a stack Durable Object. + * Deliberately outside the `r\0` (resource) and `o\0` (stack output) + * prefixes so listings never surface it, and outside the root DO's `s:` + * stack index. + */ +export const KEY_FINGERPRINT_KEY = "k:fingerprint"; + +/** SHA-256 hex digest of the hex-encoded key. */ +export const keyFingerprint = async (keyHex: string): Promise => { + const digest = await crypto.subtle.digest( + "SHA-256", + Buffer.from(keyHex, "hex"), + ); + return Buffer.from(digest).toString("hex"); +}; + +export type KeyFingerprintCheck = "recorded" | "match" | "mismatch"; + +/** The subset of Durable Object storage the check needs. */ +export interface FingerprintStorage { + readonly get: (key: string) => Effect.Effect; + readonly put: (key: string, value: string) => Effect.Effect; +} + +/** + * Record `fingerprint` if this store has none yet, otherwise compare. The + * first v8 boot of a pre-existing store records whatever key it has — + * entries written under an older, rotated key (the beta.45 leftovers) stay + * unreadable and keep degrading to "absent", exactly as before, but any + * *future* rotation is caught. + */ +export const verifyKeyFingerprint = ( + storage: FingerprintStorage, + fingerprint: string, +): Effect.Effect => + Effect.gen(function* () { + const recorded = yield* storage.get(KEY_FINGERPRINT_KEY); + if (recorded === undefined) { + yield* storage.put(KEY_FINGERPRINT_KEY, fingerprint); + return "recorded"; + } + return recorded === fingerprint ? "match" : "mismatch"; + }); + +/** Raised (as a defect) by every data method once a mismatch is detected. */ +export class EncryptionKeyChangedError extends Error { + override readonly name = "EncryptionKeyChangedError"; + constructor() { + super( + "Cloudflare State Store: the encryption key bound to this store " + + "(Secrets Store secret 'AlchemyStateStoreEncryptionKey') is not the " + + "key that encrypted its data. Refusing to read or write state — " + + "serving it would report every resource as missing and the next " + + "deploy would overwrite the still-recoverable ciphertext. Restore " + + "the previous secret value to recover. If that key is truly lost, " + + "delete the affected stack's state deliberately with " + + "`alchemy state delete --backend cloudflare --recursive`.", + ); + } +} diff --git a/packages/alchemy/src/Cloudflare/StateStore/Store.ts b/packages/alchemy/src/Cloudflare/StateStore/Store.ts index c9ed54f489..cfbec46da4 100644 --- a/packages/alchemy/src/Cloudflare/StateStore/Store.ts +++ b/packages/alchemy/src/Cloudflare/StateStore/Store.ts @@ -6,10 +6,15 @@ import type { ReplacedResourceState, ResourceState, } from "../../State/ResourceState.ts"; -import { encodeState } from "../../State/StateEncoding.ts"; import * as Secret from "../SecretsStore/index.ts"; import { DurableObject } from "../Workers/DurableObject.ts"; import { DurableObjectState } from "../Workers/DurableObjectState.ts"; +import * as EntryCodec from "./EntryCodec.ts"; +import { + EncryptionKeyChangedError, + keyFingerprint, + verifyKeyFingerprint, +} from "./KeyFingerprint.ts"; import { EncryptionKey } from "./Token.ts"; export default class Store extends DurableObject()( @@ -27,56 +32,52 @@ export default class Store extends DurableObject()( .get() .pipe(Effect.map(Redacted.value), Effect.orDie); const cryptoKey = yield* Effect.tryPromise(() => - crypto.subtle.importKey( - "raw", - Buffer.from(keyHex, "hex"), - { name: "AES-CTR" }, - false, - ["encrypt", "decrypt"], - ), + EntryCodec.importEntryKey(keyHex), ).pipe(Effect.orDie); const encryptValue = (value: unknown) => - Effect.tryPromise(async () => { - const plaintext = new TextEncoder().encode( - JSON.stringify(encodeState(value)), - ); - const counter = crypto.getRandomValues(allocBytes(NONCE_BYTES)); - const ct = new Uint8Array( - await crypto.subtle.encrypt( - { name: "AES-CTR", counter, length: 64 }, - cryptoKey, - plaintext, - ), - ); - // Frame as a single base64 string: nonce || ciphertext. - return Buffer.concat([counter, ct]).toString("base64"); - }).pipe(Effect.orDie); + Effect.tryPromise(() => EntryCodec.encryptEntry(cryptoKey, value)).pipe( + Effect.orDie, + ); + // Unreadable entries (wrong key, corrupt frame) resolve `undefined` + // inside the codec — the promise never rejects for bad data, so + // `orDie` here only covers genuine runtime faults. See + // `EntryCodec.decryptEntry` for why. const decryptEntry = (entry: string) => - Effect.tryPromise(async () => { - const framed = Buffer.from(entry, "base64"); - const counter = framed.subarray(0, NONCE_BYTES); - const ciphertext = framed.subarray(NONCE_BYTES); - let pt; - try { - pt = await crypto.subtle.decrypt( - { name: "AES-CTR", counter, length: 64 }, - cryptoKey, - ciphertext, - ); - } catch (error) { - // We return undefined here because in 2.0.0-beta.45, we rotated encryption keys unnecessarily. - // So, we catch a decryption error here and return undefined instead. - // The engine should reconcile, hopefully, but users may lose some data - console.error( - "Error decrypting entry. Returning undefined instead.", - error, - ); - return undefined; - } - return JSON.parse(new TextDecoder().decode(pt)) as ResourceState; - }).pipe(Effect.orDie); + Effect.tryPromise(() => + EntryCodec.decryptEntry(cryptoKey, entry), + ).pipe(Effect.orDie); + + // Rotation guard (see KeyFingerprint.ts). Checked once per DO instance, + // lazily on the first data method so the check runs inside a request + // context; a mismatch dies loudly on every data method rather than + // letting unreadable entries degrade to "absent". Listing and deleting + // stay available so a deliberate wipe is still possible. + const fingerprint = yield* Effect.tryPromise(() => + keyFingerprint(keyHex), + ).pipe(Effect.orDie); + const guard = yield* Effect.cached( + verifyKeyFingerprint( + { + get: (key) => storage.get(key), + put: (key, value) => storage.put(key, value), + }, + fingerprint, + ).pipe( + Effect.flatMap((check) => + check === "mismatch" + ? Effect.logError( + "Cloudflare State Store encryption key changed; refusing to serve state.", + ).pipe( + Effect.andThen(Effect.die(new EncryptionKeyChangedError())), + ) + : Effect.void, + ), + ), + ); + const guarded = (effect: Effect.Effect) => + guard.pipe(Effect.andThen(effect)); return { // -- Root DO methods ----------------------------------------- @@ -149,13 +150,17 @@ export default class Store extends DurableObject()( * null if missing. */ get: ({ stage, fqn }: { stage: string; fqn: string }) => - storage - .get(resourceKey(stage, fqn)) - .pipe( - Effect.flatMap((entry) => - entry == null ? Effect.succeed(undefined) : decryptEntry(entry), + guarded( + storage + .get(resourceKey(stage, fqn)) + .pipe( + Effect.flatMap((entry) => + entry == null + ? Effect.succeed(undefined) + : decryptEntry(entry), + ), ), - ), + ), /** * (Stack DO only) Persist a resource. Returns the stored @@ -170,13 +175,15 @@ export default class Store extends DurableObject()( fqn: string; value: ResourceState; }) => - encryptValue(value).pipe( - Effect.flatMap((encrypted) => - storage - .put(resourceKey(stage, fqn), encrypted) - .pipe(Effect.asVoid), + guarded( + encryptValue(value).pipe( + Effect.flatMap((encrypted) => + storage + .put(resourceKey(stage, fqn), encrypted) + .pipe(Effect.asVoid), + ), + Effect.map(() => value), ), - Effect.map(() => value), ), /** @@ -209,26 +216,32 @@ export default class Store extends DurableObject()( * Returns `undefined` when the stage has not been deployed. */ getOutput: ({ stage }: { stage: string }) => - storage - .get(stackOutputKey(stage)) - .pipe( - Effect.flatMap((entry) => - entry == null ? Effect.succeed(undefined) : decryptEntry(entry), + guarded( + storage + .get(stackOutputKey(stage)) + .pipe( + Effect.flatMap((entry) => + entry == null + ? Effect.succeed(undefined) + : decryptEntry(entry), + ), ), - ), + ), /** * (Stack DO only) Persist the resolved stack output for * `stage`. Returns the stored value unchanged. */ setOutput: ({ stage, value }: { stage: string; value: any }) => - encryptValue(value).pipe( - Effect.flatMap((encrypted) => - storage - .put(stackOutputKey(stage), encrypted) - .pipe(Effect.asVoid), + guarded( + encryptValue(value).pipe( + Effect.flatMap((encrypted) => + storage + .put(stackOutputKey(stage), encrypted) + .pipe(Effect.asVoid), + ), + Effect.map(() => value), ), - Effect.map(() => value), ), /** @@ -237,8 +250,7 @@ export default class Store extends DurableObject()( * `status` field can be inspected. */ getReplacedResources: ({ stage }: { stage: string }) => - pipe( - storage.list({ prefix: stagePrefix(stage) }), + guarded(storage.list({ prefix: stagePrefix(stage) })).pipe( Effect.map((entries) => [...entries.values()].filter((e): e is string => !!e), ), @@ -275,9 +287,6 @@ const STACK_OUTPUT_PREFIX = `o${SEP}`; /** Key prefix for stack-index entries in the root DO. */ const STACK_INDEX_PREFIX = "s:"; -/** AES-CTR counter block length. */ -const NONCE_BYTES = 16; - /** Build the resource key inside a *stack DO*. */ const resourceKey = (stage: string, fqn: string) => `${RESOURCE_PREFIX}${stage}${SEP}${fqn}`; @@ -301,11 +310,3 @@ const parseResourceKey = ( if (sep < 0) return undefined; return { stage: rest.slice(0, sep), fqn: rest.slice(sep + 1) }; }; - -/** - * Allocate a `Uint8Array` over a fresh `ArrayBuffer` (not shared) so - * the resulting buffer satisfies Web Crypto's `BufferSource` type - * constraint under strict DOM typings. - */ -const allocBytes = (size: number): Uint8Array => - new Uint8Array(new ArrayBuffer(size)); diff --git a/packages/alchemy/src/Cloudflare/StateStore/Token.ts b/packages/alchemy/src/Cloudflare/StateStore/Token.ts index 6df2650cef..1ee50ef302 100644 --- a/packages/alchemy/src/Cloudflare/StateStore/Token.ts +++ b/packages/alchemy/src/Cloudflare/StateStore/Token.ts @@ -64,5 +64,12 @@ export const EncryptionKey = Effect.gen(function* () { name: EncryptionKeySecretName, store, value: random.text, + // The key that encrypts every entry in the store must never rotate: a + // rotation makes all persisted state unreadable (2.0.0-beta.45). The + // value above is only used to CREATE the secret. If the secret already + // exists — the stack is adopted, the bootstrap resumes from a stale + // local state, or {@link EncryptionKeyValue}'s state row is missing or + // unreadable and a fresh random is minted — the existing value wins. + preserveExistingValue: true, }); }); diff --git a/packages/alchemy/test/Cloudflare/SecretsStore/SecretPreserve.test.ts b/packages/alchemy/test/Cloudflare/SecretsStore/SecretPreserve.test.ts new file mode 100644 index 0000000000..7ba61ea993 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/SecretsStore/SecretPreserve.test.ts @@ -0,0 +1,136 @@ +import { adopt } from "@/AdoptPolicy"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Alchemy"; +import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { + CONTROL_SECRET_NAME, + PRESERVED_SECRET_NAME, + PreserveStore, + PROGRAM_VALUE, +} from "./fixtures/preserve-secret.ts"; +import PreserveSecretWorker from "./fixtures/preserve-worker.ts"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +/** The value that "was already there" before alchemy adopted the secret. */ +const SEEDED_VALUE = "sk-seeded-before-alchemy"; + +class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ + status: number; +}> {} + +const readValue = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new WorkerNotReady({ status: res.status })), + ), + Effect.retry({ + while: (e): e is WorkerNotReady => e instanceof WorkerNotReady, + schedule: Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(30), + ]), + }), + ); + return ((yield* res.json) as { value: string }).value; + }); + +/** + * Drop any leftover fixture secrets from an earlier run, then seed both + * names out-of-band with a value alchemy has never seen. Waits for the + * seeded secrets to activate so the adopting deploy observes them. + */ +const seedSecrets = (store: { accountId: string; storeId: string }) => + Effect.gen(function* () { + const names = new Set([PRESERVED_SECRET_NAME, CONTROL_SECRET_NAME]); + const existing = yield* secretsStore.listStoreSecrets.items(store).pipe( + Stream.filter((s) => names.has(s.name)), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + yield* Effect.forEach(existing, (s) => + secretsStore + .deleteStoreSecret({ ...store, secretId: s.id }) + .pipe(Effect.catchTag("SecretNotFound", () => Effect.void)), + ); + // Deletion is asynchronous on Cloudflare's side; a re-create races it. + yield* secretsStore + .createStoreSecret({ + ...store, + body: [...names].map((name) => ({ + name, + value: SEEDED_VALUE, + scopes: ["workers"], + })), + }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "SecretNameAlreadyExists", + schedule: Schedule.spaced("2 seconds"), + times: 15, + }), + ); + yield* Effect.forEach([...names], (name) => + secretsStore.listStoreSecrets.items(store).pipe( + Stream.filter((s) => s.name === name && s.status === "active"), + Stream.runHead, + Effect.repeat({ + until: (found) => found._tag === "Some", + schedule: Schedule.spaced("1 second"), + times: 30, + }), + ), + ); + }); + +// The Cloudflare state store bootstrap adopts its encryption-key secret with +// `adopt(true)`; the default provider then PATCHes the adopted secret with the +// program's value — the beta.45 key-rotation mechanism. `preserveExistingValue` +// must keep the stored value in exactly that situation, while a plain secret +// keeps today's overwrite semantics. +test.provider( + "preserveExistingValue keeps an adopted secret's stored value; a plain secret is overwritten", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const store = yield* stack.deploy(PreserveStore); + yield* seedSecrets({ + accountId: store.accountId, + storeId: store.storeId, + }); + + const worker = yield* Effect.gen(function* () { + return yield* PreserveSecretWorker; + }).pipe(adopt(true), stack.deploy); + + const url = worker.url as string; + expect(yield* readValue(`${url}/preserved`)).toBe(SEEDED_VALUE); + expect(yield* readValue(`${url}/control`)).toBe(PROGRAM_VALUE); + + // A second deploy (routine update path) leaves the preserved value alone. + yield* Effect.gen(function* () { + return yield* PreserveSecretWorker; + }).pipe(stack.deploy); + expect(yield* readValue(`${url}/preserved`)).toBe(SEEDED_VALUE); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/SecretsStore/fixtures/preserve-secret.ts b/packages/alchemy/test/Cloudflare/SecretsStore/fixtures/preserve-secret.ts new file mode 100644 index 0000000000..7b64c1a580 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/SecretsStore/fixtures/preserve-secret.ts @@ -0,0 +1,39 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +/** + * Fixtures for the `preserveExistingValue` integration test: two secrets + * declared with the SAME program value, one opted into + * `preserveExistingValue`. The test seeds both names out-of-band with a + * different value first, then deploys with `adopt(true)` — the state-store + * bootstrap's exact situation — and reads both back through a Worker. + */ +export const PRESERVED_SECRET_NAME = "PreserveTestPreservedKey"; +export const CONTROL_SECRET_NAME = "PreserveTestControlKey"; +export const PROGRAM_VALUE = "sk-program-value"; + +export const PreserveStore = Cloudflare.SecretsStore.Store( + "PreserveSecretStore", +); + +/** Opted in: an already-existing secret keeps its stored value. */ +export const Preserved = Effect.gen(function* () { + const store = yield* PreserveStore; + return yield* Cloudflare.SecretsStore.Secret("PreserveTestPreserved", { + store, + name: PRESERVED_SECRET_NAME, + value: Redacted.make(PROGRAM_VALUE), + preserveExistingValue: true, + }); +}); + +/** Control: default behavior overwrites an adopted secret's value. */ +export const Control = Effect.gen(function* () { + const store = yield* PreserveStore; + return yield* Cloudflare.SecretsStore.Secret("PreserveTestControl", { + store, + name: CONTROL_SECRET_NAME, + value: Redacted.make(PROGRAM_VALUE), + }); +}); diff --git a/packages/alchemy/test/Cloudflare/SecretsStore/fixtures/preserve-worker.ts b/packages/alchemy/test/Cloudflare/SecretsStore/fixtures/preserve-worker.ts new file mode 100644 index 0000000000..a9b7fe8632 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/SecretsStore/fixtures/preserve-worker.ts @@ -0,0 +1,43 @@ +import * as Cloudflare from "@/Cloudflare"; +import type * as runtime from "@cloudflare/workers-types"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Control, Preserved } from "./preserve-secret.ts"; + +/** + * Echoes the runtime value of both fixture secrets so the test can observe + * what the Secrets Store actually holds (the API never returns values). + */ +export default class PreserveSecretWorker extends Cloudflare.Worker()( + "PreserveSecretWorker", + { + main: import.meta.url, + workersDev: { enabled: true, previewsEnabled: false }, + env: { + PRESERVED: Preserved, + CONTROL: Control, + }, + }, + Effect.gen(function* () { + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const pathname = new URL(request.originalUrl, "http://x").pathname; + const env = yield* Cloudflare.Workers.WorkerEnvironment; + const secrets = env as Record; + const binding = + pathname === "/preserved" + ? secrets.PRESERVED + : pathname === "/control" + ? secrets.CONTROL + : undefined; + if (binding === undefined) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const value = yield* Effect.promise(() => binding.get()); + return yield* HttpServerResponse.json({ value }); + }), + }; + }), +) {} diff --git a/packages/alchemy/test/Cloudflare/StateStore/EntryCodec.test.ts b/packages/alchemy/test/Cloudflare/StateStore/EntryCodec.test.ts new file mode 100644 index 0000000000..4f191ecfe4 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/StateStore/EntryCodec.test.ts @@ -0,0 +1,112 @@ +import { + decryptEntry, + encryptEntry, + importEntryKey, +} from "@/Cloudflare/StateStore/EntryCodec.ts"; +import { encodeState } from "@/State/StateEncoding.ts"; +import { describe, expect, it } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { randomBytes } from "node:crypto"; + +/** + * Hermetic tests for the Cloudflare State Store's entry codec — the same + * Web Crypto code the Durable Object runs, exercised in-process. + */ + +const keyHex = () => Buffer.from(randomBytes(32)).toString("hex"); + +const entry = { + kind: "resource", + resourceType: "Test.Resource", + fqn: "worker", + logicalId: "worker", + instanceId: "i-1", + providerVersion: 1, + status: "created", + downstream: [], + bindings: [], + props: { apiKey: Redacted.make("sk-live-do-secret"), name: "wörker ✓" }, + attr: { url: "https://example.com" }, +}; + +/** + * Run `f` with `console.error` captured, returning its result and the + * captured calls. The codec logs (rather than throws) on unreadable + * entries, so the tests assert the log line exists instead of letting it + * spray into the runner output. + */ +const capturingConsoleError = (f: () => Promise) => + Effect.promise(async () => { + const original = console.error; + const calls: unknown[][] = []; + console.error = (...args: unknown[]) => { + calls.push(args); + }; + try { + return { result: await f(), calls }; + } finally { + console.error = original; + } + }); + +describe("Cloudflare State Store entry codec", () => { + it.effect( + "round-trips an entry as the encoded (marker) form under a random nonce", + () => + Effect.promise(async () => { + const key = await importEntryKey(keyHex()); + const a = await encryptEntry(key, entry); + const b = await encryptEntry(key, entry); + + // Base64 ciphertext, never the plaintext; a fresh nonce per write. + expect(a).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(Buffer.from(a, "base64").toString("latin1")).not.toContain( + "sk-live-do-secret", + ); + expect(a).not.toBe(b); + + // The DO stores `encodeState`'s output and hands it back un-revived; + // the HTTP client revives `__redacted__` markers on its side. + expect(await decryptEntry(key, a)).toEqual(encodeState(entry)); + expect(await decryptEntry(key, b)).toEqual(encodeState(entry)); + }), + ); + + it.effect( + "an entry written under a different key reads as absent (undefined), not as a thrown SyntaxError", + () => + Effect.gen(function* () { + // AES-CTR is unauthenticated: Web Crypto happily "decrypts" with + // the wrong key and returns garbage, so the only failure signal is + // the JSON decode. Before the fix that SyntaxError escaped the guard + // and killed the whole deploy through `Effect.orDie`. + const writer = yield* Effect.promise(() => importEntryKey(keyHex())); + const reader = yield* Effect.promise(() => importEntryKey(keyHex())); + const stored = yield* Effect.promise(() => encryptEntry(writer, entry)); + const { result, calls } = yield* capturingConsoleError(() => + decryptEntry(reader, stored), + ); + expect(result).toBeUndefined(); + expect(calls).toHaveLength(1); + expect(String(calls[0]![0])).toContain("Returning undefined instead"); + }), + { exclusive: true }, + ); + + it.effect( + "malformed or truncated entries read as absent, not as a rejection", + () => + Effect.gen(function* () { + const key = yield* Effect.promise(() => importEntryKey(keyHex())); + const { result, calls } = yield* capturingConsoleError(async () => [ + await decryptEntry(key, ""), + await decryptEntry(key, "not base64!!"), + await decryptEntry(key, Buffer.from("short").toString("base64")), + ]); + expect(result).toEqual([undefined, undefined, undefined]); + expect(calls.length).toBeGreaterThanOrEqual(1); + }), + { exclusive: true }, + ); +}); diff --git a/packages/alchemy/test/Cloudflare/StateStore/KeyFingerprint.test.ts b/packages/alchemy/test/Cloudflare/StateStore/KeyFingerprint.test.ts new file mode 100644 index 0000000000..fbe1f0dcc9 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/StateStore/KeyFingerprint.test.ts @@ -0,0 +1,81 @@ +import { + KEY_FINGERPRINT_KEY, + keyFingerprint, + verifyKeyFingerprint, + type FingerprintStorage, +} from "@/Cloudflare/StateStore/KeyFingerprint.ts"; +import { describe, expect, it } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { randomBytes } from "node:crypto"; + +/** In-memory stand-in for a stack Durable Object's storage. */ +const fakeStorage = (): FingerprintStorage & { rows: Map } => { + const rows = new Map(); + return { + rows, + get: (key) => Effect.sync(() => rows.get(key)), + put: (key, value) => + Effect.sync(() => { + rows.set(key, value); + }), + }; +}; + +const keyHex = () => Buffer.from(randomBytes(32)).toString("hex"); + +describe("Cloudflare State Store encryption-key fingerprint", () => { + it.effect( + "is a stable SHA-256 of the key that reveals nothing about it", + () => + Effect.promise(async () => { + const key = keyHex(); + const a = await keyFingerprint(key); + const b = await keyFingerprint(key); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + expect(a).not.toBe(key); + expect(a).not.toContain(key.slice(0, 16)); + expect(await keyFingerprint(keyHex())).not.toBe(a); + }), + ); + + it.effect("records the key on a store that has none, then matches it", () => + Effect.gen(function* () { + const storage = fakeStorage(); + const fp = yield* Effect.promise(() => keyFingerprint(keyHex())); + + // First v8 boot of any store (fresh or pre-existing): record. + expect(yield* verifyKeyFingerprint(storage, fp)).toBe("recorded"); + expect(storage.rows.get(KEY_FINGERPRINT_KEY)).toBe(fp); + + // Every later boot with the same key: match, nothing rewritten. + expect(yield* verifyKeyFingerprint(storage, fp)).toBe("match"); + expect(storage.rows.size).toBe(1); + }), + ); + + it.effect( + "reports a rotated key as a mismatch and never overwrites the record", + () => + Effect.gen(function* () { + const storage = fakeStorage(); + const original = yield* Effect.promise(() => keyFingerprint(keyHex())); + const rotated = yield* Effect.promise(() => keyFingerprint(keyHex())); + yield* verifyKeyFingerprint(storage, original); + + expect(yield* verifyKeyFingerprint(storage, rotated)).toBe("mismatch"); + // The record still names the key that encrypted the data, so + // restoring that key restores the store. + expect(storage.rows.get(KEY_FINGERPRINT_KEY)).toBe(original); + expect(yield* verifyKeyFingerprint(storage, original)).toBe("match"); + }), + ); + + it("keeps the record outside every listed key space", () => { + // Stack DOs list `r\0…` (resources) and `o\0…` (outputs); the root DO + // lists `s:…`. The record must never surface in any of them. + expect(KEY_FINGERPRINT_KEY.startsWith("r\x00")).toBe(false); + expect(KEY_FINGERPRINT_KEY.startsWith("o\x00")).toBe(false); + expect(KEY_FINGERPRINT_KEY.startsWith("s:")).toBe(false); + }); +});