/** * Centralized secrets module * * Single source of truth for site-level cryptographic secrets: * * - `EMDASH_ENCRYPTION_KEY` — primary key for encrypting plugin secrets at * rest. Multi-key (comma-separated) for rotation forward-compat. v1 ships * single-key. Format: `emdash_enc_v1_<43 base64url chars>` representing * 32 random bytes. **Operator-provided; never stored in the database.** * Losing the key means losing every secret encrypted with it. Validated * at runtime startup via `validateEncryptionKeyAtStartup` — request-time * resolution does not depend on it, so a malformed key can't 500 the * preview/comment hot paths for unrelated visitors. * - `EMDASH_IP_SALT` (optional) / DB-stored `emdash:ip_salt` — site-specific * salt for hashing commenter IPs. Generated and persisted on first need * if no env override is set. Replaces the previous hardcoded * `"emdash-ip-salt"` constant which was correlatable across installs. * - `EMDASH_PREVIEW_SECRET` (optional) / DB-stored `emdash:preview_secret` — * HMAC secret for signing preview URLs. Generated and persisted on first * need if no env override is set. Replaces the previous empty-string * fallback which silently disabled preview-token verification. * * The `EMDASH_AUTH_SECRET` env var is consulted only as a legacy fallback * source for the IP salt — that's the only path the prior code actually * read it from. New deployments don't need to set it. * * Modeled on `resolveS3Config` in `../storage/s3.ts`. */ import { sha256 } from "@oslojs/crypto/sha2"; import { encodeHexLowerCase } from "@oslojs/encoding"; import type { Kysely } from "kysely"; import { after } from "../after.js"; import { OptionsRepository } from "../database/repositories/options.js"; import type { Database } from "../database/types.js"; import { decodeBase64url, encodeBase64url } from "../utils/base64.js"; import { createSingleFlightCache, type SingleFlightCache, singleFlightCached, } from "../utils/single-flight-cache.js"; /** v1 encryption key prefix. Bumping requires a separate KDF version. */ export const ENCRYPTION_KEY_PREFIX = "emdash_enc_v1_"; /** 32 random bytes encoded as unpadded base64url = 43 chars. */ const ENCRYPTION_KEY_BODY_LENGTH = 43; const REGEX_META_PATTERN = /[.*+?^${}()|[\]\\]/g; /** * Built from the prefix constant via interpolation. The prefix has no regex * metacharacters today (`emdash_enc_v1_`), but escaping is cheap defense * against anyone changing the prefix in a future bump without remembering. */ const ENCRYPTION_KEY_PATTERN = new RegExp( `^${ENCRYPTION_KEY_PREFIX.replace(REGEX_META_PATTERN, "\\$&")}[A-Za-z0-9_-]{${ENCRYPTION_KEY_BODY_LENGTH}}$`, ); /** Options-table key for the persisted commenter-IP salt. */ export const IP_SALT_OPTION_KEY = "emdash:ip_salt"; /** Options-table key for the persisted preview HMAC secret. */ export const PREVIEW_SECRET_OPTION_KEY = "emdash:preview_secret"; /** Length in bytes of generated values. 32 bytes = 256 bits. */ const GENERATED_SECRET_BYTES = 32; /** * A parsed encryption key with its kid (key id) fingerprint. * * `kid` is the first 8 chars of the SHA-256 hash of the decoded key bytes * (lowercase hex), used to tag envelopes so the decryptor can pick the right * key during rotation. */ export interface ParsedEncryptionKey { /** 8-char lowercase hex fingerprint derived from the decoded key bytes. */ kid: string; /** The 32 raw key bytes, ready for `crypto.subtle.importKey`. */ key: Uint8Array; /** The original env-var-formatted string (kept for re-emit; never log). */ raw: string; } /** Resolved site secrets. */ export interface ResolvedSecrets { /** HMAC secret for preview URLs. Always non-empty after resolution. */ previewSecret: string; /** * Source of `previewSecret`. Useful for diagnostics; never expose the * value itself, only the source. */ previewSecretSource: "env" | "db"; /** Salt for hashing commenter IPs. Always non-empty after resolution. */ ipSalt: string; /** Source of `ipSalt`. */ ipSaltSource: "env" | "db"; } /** Inputs for `resolveSecrets`. */ export interface ResolveSecretsOptions { /** * The Kysely DB used to persist (and read back) generated salt/preview * secret values. Required — these values must be stable across requests * within a deployment. */ db: Kysely; /** * Optional explicit env override map. When omitted, falls back to * `import.meta.env` via the global accessor below. Tests pass an * explicit map to avoid leaking process state. */ env?: SecretsEnv; /** * @internal Test seam: inject a custom OptionsRepository to exercise * the lost-race re-read branch. Production callers never set this. */ _repo?: OptionsRepository; } /** Environment-variable shape consulted by the resolver. */ export interface SecretsEnv { /** * Read by `validateEncryptionKeyAtStartup` and (in a follow-up PR) by the * plugin-secret encryption layer. **Not** consulted by `resolveSecrets`, * so a malformed value can't 500 the preview/comment hot paths. */ EMDASH_ENCRYPTION_KEY?: string; EMDASH_PREVIEW_SECRET?: string; /** Legacy alias; new docs point at EMDASH_PREVIEW_SECRET. */ PREVIEW_SECRET?: string; EMDASH_IP_SALT?: string; /** * Legacy fallback. Prior code derived the IP salt from * `EMDASH_AUTH_SECRET || AUTH_SECRET || "emdash-ip-salt"`. We preserve * the env-var fallback (so existing installs keep their stable salt) * but no longer read it from `import.meta.env` in route handlers. */ EMDASH_AUTH_SECRET?: string; /** Legacy alias. */ AUTH_SECRET?: string; } /** * Class of validation failures raised by this module. * * Errors here are operator-facing config problems (malformed key, etc.). * They are thrown rather than soft-skipped so misconfiguration fails loudly * at startup instead of silently degrading at request time. */ export class EmDashSecretsError extends Error { override readonly name = "EmDashSecretsError"; readonly code: string; constructor(message: string, code: string) { super(message); this.code = code; } } // --------------------------------------------------------------------------- // Encryption key parsing // --------------------------------------------------------------------------- /** * Parse the `EMDASH_ENCRYPTION_KEY` env var. * * Accepts a single key or a comma-separated list. The first entry is the * primary (used for new writes); all entries are tried for decryption, * matched by `kid`. Whitespace around commas is tolerated. Empty entries * (e.g. trailing comma) are ignored. * * Returns `null` for an unset/empty input. Throws `EmDashSecretsError` on * any malformed entry — silent skipping would mask deployment mistakes. */ export async function parseEncryptionKeys( raw: string | undefined, ): Promise { if (!raw) return null; const entries = raw .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); if (entries.length === 0) return null; const parsed: ParsedEncryptionKey[] = []; const seenKids = new Set(); for (const entry of entries) { if (!ENCRYPTION_KEY_PATTERN.test(entry)) { throw new EmDashSecretsError( `EMDASH_ENCRYPTION_KEY entry is malformed (expected "${ENCRYPTION_KEY_PREFIX}" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars). Generate one with \`emdash secrets generate\`.`, "INVALID_ENCRYPTION_KEY", ); } const body = entry.slice(ENCRYPTION_KEY_PREFIX.length); const key = decodeBase64urlStrict(body); if (!key) { throw new EmDashSecretsError( "EMDASH_ENCRYPTION_KEY body is not valid base64url", "INVALID_ENCRYPTION_KEY", ); } if (key.length !== GENERATED_SECRET_BYTES) { throw new EmDashSecretsError( `EMDASH_ENCRYPTION_KEY must decode to ${GENERATED_SECRET_BYTES} bytes, got ${key.length}`, "INVALID_ENCRYPTION_KEY", ); } // Reject non-canonical base64url. 43 chars decode to 32 bytes but // the last char only carries 2 information bits — multiple raw // strings can decode to the same bytes. Forcing canonical form // guarantees `kid` (derived from bytes) is stable per key // material, regardless of how the operator pasted it. const canonical = encodeBase64url(key); if (canonical !== body) { throw new EmDashSecretsError( "EMDASH_ENCRYPTION_KEY body is not canonical base64url. Generate one with `emdash secrets generate`.", "INVALID_ENCRYPTION_KEY", ); } const kid = fingerprintKeyBytes(key); if (seenKids.has(kid)) { // Duplicate keys are user error (paste mistake during rotation). // We dedupe rather than throw — the rotation flow is forgiving. continue; } seenKids.add(kid); parsed.push({ kid, key, raw: entry }); } // `parsed` always has at least one entry here: `entries` was non-empty // after filtering, the loop runs at least once, the first iteration // always passes the empty-`seenKids` check. return parsed; } /** * Compute the kid for a raw key string (the env-var form including the * `emdash_enc_v1_` prefix). Public so the CLI's `fingerprint` subcommand * and admin endpoints can show kids without exposing raw keys. * * The kid is derived from the decoded key **bytes**, not the raw string, * so admin endpoints / future rotation flows can match envelope kids * against bytes regardless of how the env var was originally spelled. * * Validates the same shape as `parseEncryptionKeys` — including canonical * base64url — so the CLI can't print a kid for a key the runtime would * later refuse to load. * * Throws `EmDashSecretsError` for malformed or non-canonical input. */ export async function fingerprintKey(raw: string): Promise { if (!ENCRYPTION_KEY_PATTERN.test(raw)) { throw new EmDashSecretsError( `Key must match "${ENCRYPTION_KEY_PREFIX}" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars`, "INVALID_ENCRYPTION_KEY", ); } const body = raw.slice(ENCRYPTION_KEY_PREFIX.length); const bytes = decodeBase64urlStrict(body); if (!bytes || bytes.length !== GENERATED_SECRET_BYTES || encodeBase64url(bytes) !== body) { throw new EmDashSecretsError( `Key body must decode to ${GENERATED_SECRET_BYTES} canonical base64url bytes`, "INVALID_ENCRYPTION_KEY", ); } return fingerprintKeyBytes(bytes); } /** * Internal: kid derivation from raw key bytes. The single source of truth * for what makes two keys "the same key" — used by both `parseEncryptionKeys` * and `fingerprintKey`. */ function fingerprintKeyBytes(key: Uint8Array): string { return encodeHexLowerCase(sha256(key)).slice(0, 8); } /** * Generate a fresh `EMDASH_ENCRYPTION_KEY` value. Used by the CLI's * `secrets generate` subcommand and by `create-emdash` scaffolding. */ export function generateEncryptionKey(): string { const bytes = new Uint8Array(GENERATED_SECRET_BYTES); crypto.getRandomValues(bytes); return `${ENCRYPTION_KEY_PREFIX}${encodeBase64url(bytes)}`; } // --------------------------------------------------------------------------- // Site-secret resolution (DB-backed with env override) // --------------------------------------------------------------------------- /** * Resolve site secrets. Reads env vars; for IP salt and preview secret, * falls back to a DB-stored value, generating one atomically on first need. * * Idempotent. Concurrent callers race on the atomic `setIfAbsent`; whichever * wins, all callers converge on the same stored value. * * Note: `EMDASH_ENCRYPTION_KEY` is **not** consumed here. It's validated * separately at runtime startup (see `validateEncryptionKeyAtStartup`) so a * malformed key can't take down preview-token verification or comment * submission for unrelated visitors. Future plugin-secret encryption code * will read it via its own dedicated helper. */ export async function resolveSecrets(options: ResolveSecretsOptions): Promise { const env = options.env ?? readDefaultEnv(); const repo = options._repo ?? new OptionsRepository(options.db); const previewEnvOverride = pickFirstNonEmpty(env.EMDASH_PREVIEW_SECRET, env.PREVIEW_SECRET); const ipSaltEnvOverride = pickFirstNonEmpty( env.EMDASH_IP_SALT, env.EMDASH_AUTH_SECRET, env.AUTH_SECRET, ); const [previewSecret, ipSalt] = await Promise.all([ previewEnvOverride !== null ? Promise.resolve({ value: previewEnvOverride, source: "env" as const }) : ensureGeneratedOption(repo, PREVIEW_SECRET_OPTION_KEY), ipSaltEnvOverride !== null ? Promise.resolve({ value: ipSaltEnvOverride, source: "env" as const }) : ensureGeneratedOption(repo, IP_SALT_OPTION_KEY), ]); return { previewSecret: previewSecret.value, previewSecretSource: previewSecret.source, ipSalt: ipSalt.value, ipSaltSource: ipSalt.source, }; } /** * Validate `EMDASH_ENCRYPTION_KEY` once at runtime startup. Logs an * operator-facing error if the value is malformed but does **not** throw — * the key is currently inert (no consumers), and the follow-up PR that * actually uses it will throw at point of use. This way, deployment * mistakes surface immediately in startup logs without wedging unrelated * request paths in the meantime. * * Returns `true` if the key is unset or valid, `false` if it was malformed. */ export async function validateEncryptionKeyAtStartup(env?: SecretsEnv): Promise { const resolved = env ?? readDefaultEnv(); try { await parseEncryptionKeys(resolved.EMDASH_ENCRYPTION_KEY); return true; } catch (error) { if (error instanceof EmDashSecretsError) { console.error( `[emdash] EMDASH_ENCRYPTION_KEY is invalid: ${error.message} ` + "Plugin-secret encryption will fail once it ships. " + "Generate a fresh key with `emdash secrets generate`.", ); return false; } throw error; } } /** * Per-DB cache of resolved secrets, keyed by Kysely instance identity. * * The resolved values are stable for the lifetime of the deployment (env * vars don't change without a restart, and DB-stored values are written * once via `setIfAbsent`). Caching avoids one options-table read per * request on the hot paths (preview verification, comment hashing). * * Lives on `globalThis` so module-duplication during SSR bundling can't * fragment the cache. See `request-context.ts` for the same pattern. * * Each db gets its own poison-immune single-flight cache (see * `utils/single-flight-cache.ts`): the resolved *value* is cached, never an * in-flight promise, so a request cancelled mid-resolve can't strand later * preview/comment requests on the isolate. */ // Versioned to prevent cache fragmentation if `ResolvedSecrets`'s shape // ever changes. Bump the suffix on incompatible changes so a co-resident // older build doesn't read a newer-shape value. Bumped to @2 when the cached // value changed from a bare promise to a single-flight cache. const SECRETS_CACHE_KEY = Symbol.for("@emdash-cms/core/secrets-cache@2"); interface SecretsCacheHolder { cache: WeakMap, SingleFlightCache>; } function getSecretsCache(): WeakMap, SingleFlightCache> { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern const holder = globalThis as Record; let entry = holder[SECRETS_CACHE_KEY]; if (!entry) { entry = { cache: new WeakMap() }; holder[SECRETS_CACHE_KEY] = entry; } return entry.cache; } /** * Memoized wrapper around `resolveSecrets`. Use this from request-time hot * paths (preview verification, comment IP hashing) so they don't reread * env / re-query options on every request. * * The cache is keyed by `Kysely` instance, so playground / per-DO / per-test * databases each get their own resolution. Concurrent cold callers coalesce * onto one resolution via the single-flight lock; a failed resolution * propagates to the caller and releases the lock so the next caller retries. */ export function resolveSecretsCached(db: Kysely): Promise { const caches = getSecretsCache(); let cache = caches.get(db); if (!cache) { cache = createSingleFlightCache(); caches.set(db, cache); } return singleFlightCached(cache, () => resolveSecrets({ db }), { anchor: (promise) => after(() => promise), ownerTimeoutMs: 30_000, }); } /** * Test-only helper: clear the secrets cache. Tests that mutate env between * cases need this so a stale resolution doesn't leak across cases. * * @internal */ export function _clearSecretsCacheForTesting(): void { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern const holder = globalThis as Record; holder[SECRETS_CACHE_KEY] = undefined; } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- /** * Read or generate-and-persist a random base64url secret stored in the * options table. * * Concurrency: `setIfAbsent` is an atomic INSERT...ON CONFLICT DO NOTHING. * On race, the loser re-reads to converge on the winner's value. */ async function ensureGeneratedOption( repo: OptionsRepository, optionKey: string, ): Promise<{ value: string; source: "db" }> { const existing = await repo.get(optionKey); if (typeof existing === "string" && existing.length > 0) { return { value: existing, source: "db" }; } const generated = generateRandomSecret(); const inserted = await repo.setIfAbsent(optionKey, generated); if (inserted) { return { value: generated, source: "db" }; } // Lost the race — another process inserted first. Re-read to pick up // the winner. If the row is somehow still missing or empty, treat that // as a real error rather than looping. const winner = await repo.get(optionKey); if (typeof winner !== "string" || winner.length === 0) { throw new EmDashSecretsError( `Failed to persist generated secret for "${optionKey}"`, "SECRET_PERSIST_FAILED", ); } return { value: winner, source: "db" }; } /** Generate 32 random bytes encoded as unpadded base64url. */ function generateRandomSecret(): string { const bytes = new Uint8Array(GENERATED_SECRET_BYTES); crypto.getRandomValues(bytes); return encodeBase64url(bytes); } /** Return the first non-empty string from `values`, or `null` if all are empty. */ function pickFirstNonEmpty(...values: (string | undefined)[]): string | null { for (const value of values) { if (typeof value === "string" && value.length > 0) { return value; } } return null; } const BASE64URL_CHARSET_PATTERN = /^[A-Za-z0-9_-]+$/; /** * Validate base64url shape and decode. Returns `null` on malformed input * (rather than throwing) so the caller can produce a config-specific error. */ function decodeBase64urlStrict(input: string): Uint8Array | null { // `decodeBase64url` accepts padded input too; the env-var format is // strictly unpadded base64url, so we do a charset check first. if (!BASE64URL_CHARSET_PATTERN.test(input)) return null; try { return decodeBase64url(input); } catch { return null; } } /** * Default env reader. * * Note: this is the **only** code path in core that reads both * `import.meta.env` and `process.env`. Route handlers should not — they * always run inside the Astro/Vite bundle where `import.meta.env` is * the correct source. This resolver is shared with the CLI surface (via * `cli/commands/secrets.ts`) which runs outside the bundle, so we * deliberately consult both. `import.meta.env` wins so build-time * substitutions are honored when present. * * The convention documented in AGENTS.md ("import.meta.env.EMDASH_X || * import.meta.env.X") is the route-handler convention; this is the * shared-with-CLI exception. */ function readDefaultEnv(): SecretsEnv { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is loose by design const meta = (import.meta.env ?? {}) as Record; const proc = typeof process !== "undefined" && process.env ? process.env : {}; return { EMDASH_ENCRYPTION_KEY: meta.EMDASH_ENCRYPTION_KEY ?? proc.EMDASH_ENCRYPTION_KEY, EMDASH_PREVIEW_SECRET: meta.EMDASH_PREVIEW_SECRET ?? proc.EMDASH_PREVIEW_SECRET, PREVIEW_SECRET: meta.PREVIEW_SECRET ?? proc.PREVIEW_SECRET, EMDASH_IP_SALT: meta.EMDASH_IP_SALT ?? proc.EMDASH_IP_SALT, EMDASH_AUTH_SECRET: meta.EMDASH_AUTH_SECRET ?? proc.EMDASH_AUTH_SECRET, AUTH_SECRET: meta.AUTH_SECRET ?? proc.AUTH_SECRET, }; }