Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions packages/alchemy/src/Cloudflare/SecretsStore/Secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -432,12 +456,16 @@ export const SecretProviderLocal = () =>

// Seed β€” write the value into the local simulator so the dev
// worker's `env.<binding>.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(),
Expand Down
2 changes: 1 addition & 1 deletion packages/alchemy/src/Cloudflare/StateStore/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions packages/alchemy/src/Cloudflare/StateStore/EntryCodec.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKey> =>
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<string> => {
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 <T>(
cryptoKey: CryptoKey,
entry: string,
): Promise<T | undefined> => {
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<ArrayBuffer> =>
new Uint8Array(new ArrayBuffer(size));
78 changes: 78 additions & 0 deletions packages/alchemy/src/Cloudflare/StateStore/KeyFingerprint.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<R = never> {
readonly get: (key: string) => Effect.Effect<string | undefined, never, R>;
readonly put: (key: string, value: string) => Effect.Effect<void, never, R>;
}

/**
* 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 = <R = never>(
storage: FingerprintStorage<R>,
fingerprint: string,
): Effect.Effect<KeyFingerprintCheck, never, R> =>
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 <stack> --backend cloudflare --recursive`.",
);
}
}
Loading