From 2dc09234732998cd7ec921a250d5fdc718366c7a Mon Sep 17 00:00:00 2001 From: githoboman Date: Mon, 29 Jun 2026 18:02:09 +0100 Subject: [PATCH] feat :Field-level AES-256-GCM encryption at rest for reversible secrets --- .env.example | 16 + docs/field-encryption.md | 130 +++++++ docs/security-ci.md | 7 + jest.config.cjs | 1 + jest.setup.cjs | 12 + src/config/env.ts | 39 +++ src/lib/encryption.ts | 274 +++++++++++++++ .../webhookSubscriberRepository.ts | 29 +- src/tests/encryption.test.ts | 320 ++++++++++++++++++ src/tests/webhookSubscriber.rotation.test.ts | 17 +- 10 files changed, 835 insertions(+), 10 deletions(-) create mode 100644 docs/field-encryption.md create mode 100644 jest.setup.cjs create mode 100644 src/lib/encryption.ts create mode 100644 src/tests/encryption.test.ts diff --git a/.env.example b/.env.example index 3257e938..363a5666 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,22 @@ JWT_REFRESH_EXPIRES_IN=7d # MUST be changed from the default in production. DOWNLOAD_SECRET=change-me-in-production +# ── Field encryption (reversible secrets at rest) ───────────────────────────── +# +# Reversible secrets that cannot be hashed (e.g. webhook HMAC signing secrets) +# are encrypted at rest with AES-256-GCM. Required in any environment that reads +# or writes those columns. See docs/field-encryption.md for the rotation runbook. +# +# Single-key shorthand: a base64-encoded 32-byte key (used under key id "default"). +# Generate with: openssl rand -base64 32 +FIELD_ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_base64_32 +# +# Multi-key form for rotation: a JSON array of { kid, key } objects. The FIRST +# entry is the active key used to encrypt new data; the rest are retained so +# data written under a retired key id still decrypts. If set, this takes +# precedence over FIELD_ENCRYPTION_KEY. +# FIELD_ENCRYPTION_KEYS=[{"kid":"2026-06","key":"BASE64_32_BYTES"},{"kid":"2026-01","key":"OLD_BASE64_32_BYTES"}] + # ── Soroban ────────────────────────────────────────────────────────────────────── # Soroban contract ID (56-char base32 starting with C). Required for submit mode. diff --git a/docs/field-encryption.md b/docs/field-encryption.md new file mode 100644 index 00000000..64226293 --- /dev/null +++ b/docs/field-encryption.md @@ -0,0 +1,130 @@ +# Field Encryption at Rest + +Some secrets stored by Disciplr cannot be hashed because they must be recovered +in plaintext at use time. The clearest example is a **webhook HMAC signing +secret**: to sign every outbound delivery the server must present the original +secret, so a one-way hash (as used for API keys) is not an option. Those columns +are instead **encrypted at rest** with authenticated encryption. + +This document describes the scheme and the key-rotation runbook. + +## What is encrypted + +| Column | Reversible? | Storage | +| --------------------------------------- | ----------- | ---------------- | +| `webhook_subscribers.secret` | yes | AES-256-GCM | +| `webhook_subscribers.previous_secret` | yes | AES-256-GCM | +| `api_keys.key_hash` | no | Argon2id + SHA-256 (unchanged) | + +API keys remain hashed (irreversible) — they are verified, never re-presented, +so they do **not** use this mechanism. + +## Scheme + +Encryption is implemented in [`src/lib/encryption.ts`](../src/lib/encryption.ts) +using **AES-256-GCM** (authenticated encryption). Each value is stored as a +single self-describing string: + +``` +v1:::: +``` + +- `v1` — scheme version, so the format can evolve. +- `` — id of the key that produced this ciphertext. Resolved back to key + material on decryption, which is what makes rotation possible without bulk + re-encryption. +- `` — 12-byte random nonce (a fresh IV per encryption). +- `` — 16-byte GCM authentication tag. +- `` — the ciphertext. + +Properties: + +- **Confidentiality** — a database dump alone never exposes the plaintext. +- **Integrity / tamper detection** — any modification to the stored value fails + the GCM authentication check on decrypt. +- **Fail closed** — decryption throws `DecryptionError` on a wrong key, an + unknown key id, malformed input, or a failed auth check. It **never** returns + the ciphertext or a partial result, so corrupt or forged data can never be + silently mistaken for plaintext. + +## Key configuration + +Keys are supplied via environment variables (see +[`src/config/env.ts`](../src/config/env.ts)). Two forms, in priority order: + +1. **`FIELD_ENCRYPTION_KEYS`** — a JSON array of `{ kid, key }` objects, where + `key` is a base64-encoded **32-byte** (AES-256) key. The **first** entry is + the active key used to encrypt new data; the remaining entries are retained + only so ciphertext written under an older `kid` still decrypts. + + ``` + FIELD_ENCRYPTION_KEYS=[{"kid":"2026-06","key":""},{"kid":"2026-01","key":""}] + ``` + +2. **`FIELD_ENCRYPTION_KEY`** — a single base64-encoded 32-byte key, treated as + the active key under the reserved key id `default`. Convenient for + development and single-key deployments. Ignored when `FIELD_ENCRYPTION_KEYS` + is set. + +Generate a key: + +```sh +openssl rand -base64 32 +``` + +If neither variable is set, the encryption helpers throw `EncryptionKeyError` — +an encryption layer with no key is treated as a misconfiguration (fail closed), +never as a silent no-op. + +## Key-rotation runbook + +Rotation is **zero-downtime** and requires no bulk re-encryption, because every +ciphertext carries the `kid` of the key that produced it. + +1. **Generate a new key.** + + ```sh + openssl rand -base64 32 + ``` + +2. **Prepend it to `FIELD_ENCRYPTION_KEYS`, keeping the old key.** The new key + becomes active (it is first); the old key is retained so existing rows keep + decrypting. Choose a `kid` that is stable and meaningful (e.g. a date). + + ``` + FIELD_ENCRYPTION_KEYS=[ + {"kid":"2026-09","key":""}, + {"kid":"2026-06","key":""} + ] + ``` + + > If you were previously using the single-key `FIELD_ENCRYPTION_KEY`, migrate + > to `FIELD_ENCRYPTION_KEYS` and include the old key under the `kid` + > `"default"`, since that is the id under which it encrypted existing rows. + +3. **Deploy.** From this point, all newly written secrets are encrypted under + the new `kid`; old rows continue to decrypt under the retained key. + +4. **(Optional) Re-encrypt existing rows.** Any write path that re-saves a + secret (creating, upserting, or rotating a webhook subscriber's secret) + automatically re-encrypts it under the active key. To migrate all rows + eagerly, read and re-save each affected row. + +5. **Retire the old key.** Once you are confident no ciphertext is still tagged + with the old `kid` (after step 4, or after enough time that all such rows + have been rewritten), remove that entry from `FIELD_ENCRYPTION_KEYS` and + deploy again. + + > **Do not remove a key while rows tagged with its `kid` still exist** — + > those rows would become permanently undecryptable and reads would throw + > `DecryptionError`. + +## Operational notes + +- **Never log key material.** Keys are provided only through environment + variables / your secrets manager and are never written to logs. +- **Back up keys** in your secrets manager. Losing a key whose `kid` still tags + live rows means losing those secrets irrecoverably. +- **Decryption failures are loud.** A `DecryptionError` on read indicates either + a removed/rotated-out key (`No field encryption key configured for key id …`) + or tampered data (`authentication failed`). Investigate rather than suppress. diff --git a/docs/security-ci.md b/docs/security-ci.md index aaa8efc8..93037d34 100644 --- a/docs/security-ci.md +++ b/docs/security-ci.md @@ -42,6 +42,13 @@ To ensure consistent builds and prevent malicious dependency injection during th - **Secrets Management:** Sensitive tokens or keys are never logged in CI output. - **Actionable Output:** Security reports are generated in JSON format for potential integration with external monitoring tools. +## Encryption at Rest + +Reversible secrets that cannot be hashed (e.g. webhook HMAC signing secrets) are +encrypted at rest with AES-256-GCM and a rotatable, key-id-tagged key. See +[field-encryption.md](./field-encryption.md) for the scheme and the key-rotation +runbook. + ## Best Practices for Developers - Always run `npm audit` locally before committing dependency changes. diff --git a/jest.config.cjs b/jest.config.cjs index 55bbc7d8..d42c4f28 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -19,6 +19,7 @@ module.exports = { }, ], }, + setupFiles: ["/jest.setup.cjs"], testMatch: ["**/tests/**/*.test.ts", "**/src/tests/**/*.test.ts", "**/src/repositories/**/*.test.ts"], moduleDirectories: ["node_modules", "/node_modules"], clearMocks: true, diff --git a/jest.setup.cjs b/jest.setup.cjs new file mode 100644 index 00000000..8b062eb8 --- /dev/null +++ b/jest.setup.cjs @@ -0,0 +1,12 @@ +// Global Jest setup: runs before each test file, before the test framework is +// installed. Used to seed environment defaults required by modules under test. + +// Field encryption requires a key to be configured. Provide a deterministic, +// non-secret 32-byte (base64) key for the test environment so repositories that +// encrypt/decrypt reversible secrets work without each suite wiring its own key. +// Individual suites may override FIELD_ENCRYPTION_KEY / FIELD_ENCRYPTION_KEYS +// (e.g. to test rotation) and reset the cached env via _resetEnvForTesting(). +if (!process.env.FIELD_ENCRYPTION_KEY && !process.env.FIELD_ENCRYPTION_KEYS) { + // 32 zero bytes, base64-encoded — test-only, never use in production. + process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 0).toString('base64') +} diff --git a/src/config/env.ts b/src/config/env.ts index d519214f..8aa94d8d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -60,6 +60,24 @@ export const envSchema = z "REDIS_URL must be a valid Redis connection URL (starting with redis:// or rediss://)", ), + // ── Field encryption (envelope encryption for reversible secrets) ── + // + // Two ways to configure, in priority order: + // 1. FIELD_ENCRYPTION_KEYS – JSON array of { kid, key } objects, where + // `key` is a base64-encoded 32-byte (AES-256) key. The FIRST entry is + // the active key used to encrypt new data; the remaining entries are + // retained so ciphertext written under an older key id still decrypts + // after a rotation. + // 2. FIELD_ENCRYPTION_KEY – a single base64-encoded 32-byte key, treated + // as the active key under the reserved key id "default". Convenient for + // development / single-key deployments. + // + // Validation of the actual key material (length, base64) is performed in + // src/lib/encryption.ts so that decryption failures surface with precise, + // security-conscious error messages rather than as opaque Zod issues. + FIELD_ENCRYPTION_KEY: z.string().optional(), + FIELD_ENCRYPTION_KEYS: z.string().optional(), + // ── Auth / secrets ────────────────────────────────────── JWT_SECRET: z .string() @@ -440,3 +458,24 @@ export function validateEnv(raw?: Record): { export function getJwtKeys(env: Env): JwtKey[] { return (env as any).JWT_KEYS as JwtKey[]; } + +/** + * Raw field-encryption configuration, read directly from the environment. + * + * Resolved independently of the full {@link initEnv} validation so the + * encryption helpers (src/lib/encryption.ts) can be used in isolation — e.g. in + * unit tests — without requiring a complete, valid application environment. + * The actual key material (base64, 32-byte length, key-id uniqueness) is + * validated in src/lib/encryption.ts, where decryption failures can surface + * precise, security-conscious errors. + * + * @param env Defaults to `process.env` — pass a custom record in tests. + */ +export function getFieldEncryptionConfig( + env: Record = process.env, +): { key?: string; keys?: string } { + return { + key: env.FIELD_ENCRYPTION_KEY, + keys: env.FIELD_ENCRYPTION_KEYS, + }; +} diff --git a/src/lib/encryption.ts b/src/lib/encryption.ts new file mode 100644 index 00000000..12574712 --- /dev/null +++ b/src/lib/encryption.ts @@ -0,0 +1,274 @@ +/** + * Field-level envelope encryption for reversible secrets at rest. + * + * Some secrets cannot be hashed because they must be recovered in plaintext at + * use time — most notably webhook HMAC signing secrets, which have to be + * re-presented to re-sign outbound payloads. For those columns we encrypt with + * authenticated encryption (AES-256-GCM) so that: + * + * - a database dump alone never exposes the plaintext secret; + * - any tampering with the stored ciphertext is detected (GCM auth tag); + * - keys can be rotated without a bulk re-encryption: every ciphertext is + * tagged with the id of the key that produced it, so old rows keep + * decrypting under the retired key while new writes use the active key. + * + * ── Stored format ────────────────────────────────────────────────────────── + * encryptField() returns a single self-describing string: + * + * v1:::: + * + * - `v1` scheme version, so the format can evolve. + * - `` id of the key used; resolved back to key material on decrypt. + * - `` 12-byte random nonce, base64. + * - `` 16-byte GCM authentication tag, base64. + * - `` the ciphertext, base64. + * + * Decryption never silently returns the input on failure — a bad key, an + * unknown key id, a malformed value, or a failed auth-tag check all throw a + * DecryptionError so corrupt/forged data can never be mistaken for plaintext. + * + * ── Key configuration ────────────────────────────────────────────────────── + * See src/config/env.ts for FIELD_ENCRYPTION_KEY / FIELD_ENCRYPTION_KEYS and + * docs/field-encryption.md for the key-rotation runbook. + */ + +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' +import { getFieldEncryptionConfig } from '../config/env.js' + +/** Current stored-format scheme version. */ +const SCHEME_VERSION = 'v1' + +/** AES-256-GCM constants. */ +const ALGORITHM = 'aes-256-gcm' +const KEY_BYTES = 32 // AES-256 +const IV_BYTES = 12 // 96-bit nonce recommended for GCM +const AUTH_TAG_BYTES = 16 + +/** Key id reserved for the single-key FIELD_ENCRYPTION_KEY shorthand. */ +const DEFAULT_KEY_ID = 'default' + +/** A resolved encryption key: an id and its 32-byte secret material. */ +export interface FieldEncryptionKey { + kid: string + key: Buffer +} + +/** Raised on any condition that prevents recovering the original plaintext. */ +export class DecryptionError extends Error { + constructor(message: string) { + super(message) + this.name = 'DecryptionError' + } +} + +/** Raised when encryption keys are missing or malformed (a startup/config bug). */ +export class EncryptionKeyError extends Error { + constructor(message: string) { + super(message) + this.name = 'EncryptionKeyError' + } +} + +/** + * Decodes and validates a single base64 AES-256 key. + * Throws EncryptionKeyError if it is not exactly 32 decoded bytes. + */ +const decodeKey = (kid: string, raw: string): Buffer => { + let buf: Buffer + try { + buf = Buffer.from(raw, 'base64') + } catch { + throw new EncryptionKeyError(`Field encryption key "${kid}" is not valid base64`) + } + if (buf.length !== KEY_BYTES) { + throw new EncryptionKeyError( + `Field encryption key "${kid}" must decode to ${KEY_BYTES} bytes (got ${buf.length}); ` + + `generate one with: openssl rand -base64 32`, + ) + } + return buf +} + +/** + * Resolves the configured field-encryption keys, in priority order. + * + * The FIRST key in the returned list is the active key used to encrypt new + * data. Subsequent keys are retained only so older ciphertext keeps decrypting. + * + * Resolution rules: + * - FIELD_ENCRYPTION_KEYS (JSON array of {kid, key}) takes precedence. + * - Otherwise FIELD_ENCRYPTION_KEY is used as the single active "default" key. + * - If neither is set, EncryptionKeyError is thrown (fail closed): an + * encryption helper with no keys is a misconfiguration, never a no-op. + * + * Duplicate key ids are rejected so a typo cannot make a retired key + * unexpectedly shadow the active one. + */ +export const resolveKeys = (): FieldEncryptionKey[] => { + const { key: singleKey, keys: keysJson } = getFieldEncryptionConfig() + + let keys: FieldEncryptionKey[] = [] + + if (keysJson && keysJson.trim() !== '') { + let parsed: unknown + try { + parsed = JSON.parse(keysJson) + } catch (e) { + throw new EncryptionKeyError( + `FIELD_ENCRYPTION_KEYS is not valid JSON: ${(e as Error).message}`, + ) + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new EncryptionKeyError( + 'FIELD_ENCRYPTION_KEYS must be a non-empty JSON array of { kid, key } objects', + ) + } + keys = parsed.map((entry, i) => { + if ( + typeof entry !== 'object' || + entry === null || + typeof (entry as any).kid !== 'string' || + typeof (entry as any).key !== 'string' + ) { + throw new EncryptionKeyError( + `FIELD_ENCRYPTION_KEYS[${i}] must be an object with string "kid" and "key" fields`, + ) + } + const kid = (entry as any).kid as string + return { kid, key: decodeKey(kid, (entry as any).key as string) } + }) + } else if (singleKey && singleKey.trim() !== '') { + keys = [{ kid: DEFAULT_KEY_ID, key: decodeKey(DEFAULT_KEY_ID, singleKey) }] + } + + if (keys.length === 0) { + throw new EncryptionKeyError( + 'No field encryption key configured: set FIELD_ENCRYPTION_KEY or FIELD_ENCRYPTION_KEYS', + ) + } + + const seen = new Set() + for (const { kid } of keys) { + if (seen.has(kid)) { + throw new EncryptionKeyError(`Duplicate field encryption key id "${kid}"`) + } + seen.add(kid) + } + + return keys +} + +/** Returns the active key (first configured) used to encrypt new data. */ +const activeKey = (): FieldEncryptionKey => resolveKeys()[0] + +/** Looks up a key by id, returning undefined if no such key is configured. */ +const keyById = (kid: string): FieldEncryptionKey | undefined => + resolveKeys().find((k) => k.kid === kid) + +/** + * Encrypts a plaintext field value under the active key. + * + * Empty strings are encrypted like any other value (round-trips back to "") + * so callers don't need a special case; null/undefined handling is left to the + * caller since columns differ in nullability. + * + * @returns the self-describing ciphertext string (see module docs for format). + */ +export const encryptField = (plaintext: string): string => { + const { kid, key } = activeKey() + const iv = randomBytes(IV_BYTES) + const cipher = createCipheriv(ALGORITHM, key, iv) + const ciphertext = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]) + const authTag = cipher.getAuthTag() + + return [ + SCHEME_VERSION, + kid, + iv.toString('base64'), + authTag.toString('base64'), + ciphertext.toString('base64'), + ].join(':') +} + +/** + * Returns true if `value` looks like output of encryptField(). + * + * Useful for read paths that may encounter a mix of already-encrypted rows and + * legacy plaintext rows during a migration window. + */ +export const isEncrypted = (value: string): boolean => + value.startsWith(`${SCHEME_VERSION}:`) && value.split(':').length === 5 + +/** + * Decrypts a value produced by encryptField(). + * + * Throws DecryptionError on any failure — malformed input, unknown key id, or a + * failed GCM authentication check (tampered ciphertext / wrong key). It never + * returns the ciphertext or a partial result, so corrupt or forged data can + * never be silently treated as plaintext. + */ +export const decryptField = (stored: string): string => { + const parts = stored.split(':') + if (parts.length !== 5) { + throw new DecryptionError('Malformed ciphertext: expected 5 colon-separated segments') + } + + const [version, kid, ivB64, authTagB64, ciphertextB64] = parts + if (version !== SCHEME_VERSION) { + throw new DecryptionError(`Unsupported ciphertext scheme version "${version}"`) + } + + const resolved = keyById(kid) + if (!resolved) { + throw new DecryptionError( + `No field encryption key configured for key id "${kid}"; ` + + 'the key may have been rotated out before all data was re-encrypted', + ) + } + + const iv = Buffer.from(ivB64, 'base64') + const authTag = Buffer.from(authTagB64, 'base64') + const ciphertext = Buffer.from(ciphertextB64, 'base64') + + if (iv.length !== IV_BYTES) { + throw new DecryptionError(`Malformed ciphertext: IV must be ${IV_BYTES} bytes`) + } + if (authTag.length !== AUTH_TAG_BYTES) { + throw new DecryptionError( + `Malformed ciphertext: auth tag must be ${AUTH_TAG_BYTES} bytes`, + ) + } + + try { + const decipher = createDecipheriv(ALGORITHM, resolved.key, iv) + decipher.setAuthTag(authTag) + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]) + return plaintext.toString('utf8') + } catch { + // GCM final() throws when the auth tag does not verify: wrong key or the + // ciphertext/iv/tag was tampered with. Surface a clear, non-leaky error. + throw new DecryptionError( + `Failed to decrypt field under key id "${kid}": authentication failed ` + + '(wrong key or tampered ciphertext)', + ) + } +} + +/** + * Encrypts a nullable field value: passes null/undefined through unchanged so + * nullable columns (e.g. previous_secret) keep their NULL semantics. + */ +export const encryptNullable = (plaintext: string | null | undefined): string | null => + plaintext === null || plaintext === undefined ? null : encryptField(plaintext) + +/** + * Decrypts a nullable stored value: passes null/undefined through unchanged. + */ +export const decryptNullable = (stored: string | null | undefined): string | null => + stored === null || stored === undefined ? null : decryptField(stored) diff --git a/src/repositories/webhookSubscriberRepository.ts b/src/repositories/webhookSubscriberRepository.ts index f8779da3..6e2b2d85 100644 --- a/src/repositories/webhookSubscriberRepository.ts +++ b/src/repositories/webhookSubscriberRepository.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex' import type { WebhookSubscriber, BreakerState, BreakerStateValue } from '../services/webhooks.js' import { FieldPolicy, parseFieldPolicy, DEFAULT_FIELD_POLICY } from '../utils/webhookFieldMasking.js' +import { encryptField, decryptField, isEncrypted } from '../lib/encryption.js' interface SubscriberRow { id: string @@ -39,13 +40,29 @@ interface BreakerRow { updated_at: Date } +/** + * Decrypts a stored secret column for in-memory use. + * + * Signing secrets are encrypted at rest (AES-256-GCM). During the rollout + * window a column may still hold a legacy plaintext value written before this + * feature existed; such values are passed through unchanged and will be + * re-encrypted on their next write. Anything that *looks* encrypted is + * decrypted strictly — a failure throws rather than leaking ciphertext as if it + * were the secret. + */ +function decryptSecretColumn(value: string | null): string | null { + if (value === null) return null + return isEncrypted(value) ? decryptField(value) : value +} + function toSubscriber(row: SubscriberRow): WebhookSubscriber { return { id: row.id, organizationId: row.organization_id, url: row.url, - secret: row.secret, - previousSecret: row.previous_secret ?? null, + // Decrypted only here, in memory, at read time. + secret: decryptSecretColumn(row.secret)!, + previousSecret: decryptSecretColumn(row.previous_secret), rotatedAt: row.rotated_at instanceof Date ? row.rotated_at.toISOString() : (row.rotated_at ? String(row.rotated_at) : null), @@ -121,7 +138,7 @@ export class WebhookSubscriberRepository { .insert({ organization_id: data.organizationId, url: data.url, - secret: data.secret, + secret: encryptField(data.secret), events: JSON.stringify(data.events) as any, schema_version: data.schemaVersion ?? 1, field_policy: JSON.stringify(data.fieldPolicy ?? DEFAULT_FIELD_POLICY) as any, @@ -169,7 +186,7 @@ export class WebhookSubscriberRepository { { organizationId: data.organizationId, url: data.url, - secret: data.secret, + secret: encryptField(data.secret), events: JSON.stringify(data.events), fieldPolicy: fieldPolicyJson, }, @@ -219,8 +236,10 @@ export class WebhookSubscriberRepository { const rows = await this.db('webhook_subscribers') .where({ id, organization_id: organizationId }) .update({ + // The current (already-encrypted) secret column is copied verbatim into + // previous_secret — it stays ciphertext, no re-encryption needed. previous_secret: this.db.raw('secret'), - secret: newSecret, + secret: encryptField(newSecret), rotated_at: this.db.fn.now(), updated_at: this.db.fn.now(), }) diff --git a/src/tests/encryption.test.ts b/src/tests/encryption.test.ts new file mode 100644 index 00000000..3c14a1e8 --- /dev/null +++ b/src/tests/encryption.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for field-level envelope encryption (src/lib/encryption.ts). + * + * Covers the security-critical behaviours called out in the design: + * - round-trip of arbitrary plaintext (including empty / unicode); + * - tamper detection via the GCM auth tag; + * - wrong-key rejection; + * - key-id tagged rotation: data written under a retired key still decrypts; + * - fail-closed when no key is configured (startup misconfiguration); + * - never silently returning ciphertext on a decryption failure. + * + * Keys are configured purely through process.env, which resolveKeys() reads + * fresh on every call, so each test sets the exact key set it needs. + */ + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals' +import { randomBytes } from 'node:crypto' +import { + encryptField, + decryptField, + encryptNullable, + decryptNullable, + isEncrypted, + resolveKeys, + DecryptionError, + EncryptionKeyError, +} from '../lib/encryption.js' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const genKey = (): string => randomBytes(32).toString('base64') + +/** Snapshot and restore the encryption env vars around each test. */ +let savedKey: string | undefined +let savedKeys: string | undefined + +beforeEach(() => { + savedKey = process.env.FIELD_ENCRYPTION_KEY + savedKeys = process.env.FIELD_ENCRYPTION_KEYS +}) + +afterEach(() => { + if (savedKey === undefined) delete process.env.FIELD_ENCRYPTION_KEY + else process.env.FIELD_ENCRYPTION_KEY = savedKey + if (savedKeys === undefined) delete process.env.FIELD_ENCRYPTION_KEYS + else process.env.FIELD_ENCRYPTION_KEYS = savedKeys +}) + +const useSingleKey = (key = genKey()): string => { + delete process.env.FIELD_ENCRYPTION_KEYS + process.env.FIELD_ENCRYPTION_KEY = key + return key +} + +// ─── Round-trip ──────────────────────────────────────────────────────────────── + +describe('encryptField / decryptField round-trip', () => { + beforeEach(() => useSingleKey()) + + it('round-trips a typical secret', () => { + const plaintext = 'whsec_' + randomBytes(24).toString('hex') + const encrypted = encryptField(plaintext) + expect(encrypted).not.toContain(plaintext) + expect(decryptField(encrypted)).toBe(plaintext) + }) + + it('round-trips an empty string', () => { + const encrypted = encryptField('') + expect(isEncrypted(encrypted)).toBe(true) + expect(decryptField(encrypted)).toBe('') + }) + + it('round-trips unicode and long plaintext', () => { + const plaintext = '🔐 secret — ' + 'x'.repeat(10_000) + ' Ω' + expect(decryptField(encryptField(plaintext))).toBe(plaintext) + }) + + it('produces a different ciphertext each time (random IV)', () => { + const a = encryptField('same') + const b = encryptField('same') + expect(a).not.toBe(b) + expect(decryptField(a)).toBe('same') + expect(decryptField(b)).toBe('same') + }) + + it('tags ciphertext with the v1 scheme and the active key id', () => { + const encrypted = encryptField('hello') + const [version, kid] = encrypted.split(':') + expect(version).toBe('v1') + expect(kid).toBe('default') // single-key shorthand uses the "default" id + }) +}) + +// ─── isEncrypted ──────────────────────────────────────────────────────────────── + +describe('isEncrypted', () => { + beforeEach(() => useSingleKey()) + + it('recognises encryptField output', () => { + expect(isEncrypted(encryptField('x'))).toBe(true) + }) + + it('rejects plaintext and other shapes', () => { + expect(isEncrypted('plain text secret')).toBe(false) + expect(isEncrypted('v1:only:three')).toBe(false) + expect(isEncrypted('v2:a:b:c:d')).toBe(false) + }) +}) + +// ─── Tamper detection ─────────────────────────────────────────────────────────── + +describe('tamper detection (GCM auth failure)', () => { + beforeEach(() => useSingleKey()) + + it('rejects a flipped byte in the ciphertext segment', () => { + const encrypted = encryptField('tamper-me') + const parts = encrypted.split(':') + const cipherBuf = Buffer.from(parts[4], 'base64') + cipherBuf[0] ^= 0x01 // flip one bit + parts[4] = cipherBuf.toString('base64') + const tampered = parts.join(':') + + expect(() => decryptField(tampered)).toThrow(DecryptionError) + }) + + it('rejects a tampered auth tag', () => { + const encrypted = encryptField('tamper-me') + const parts = encrypted.split(':') + const tagBuf = Buffer.from(parts[3], 'base64') + tagBuf[0] ^= 0xff + parts[3] = tagBuf.toString('base64') + + expect(() => decryptField(parts.join(':'))).toThrow(DecryptionError) + }) + + it('rejects a tampered IV', () => { + const encrypted = encryptField('tamper-me') + const parts = encrypted.split(':') + const ivBuf = Buffer.from(parts[2], 'base64') + ivBuf[0] ^= 0xff + parts[2] = ivBuf.toString('base64') + + expect(() => decryptField(parts.join(':'))).toThrow(DecryptionError) + }) + + it('never returns the ciphertext on failure (no silent passthrough)', () => { + const encrypted = encryptField('secret') + const parts = encrypted.split(':') + parts[4] = Buffer.from('garbage').toString('base64') + const tampered = parts.join(':') + + let returned: string | undefined + try { + returned = decryptField(tampered) + } catch { + returned = undefined + } + expect(returned).toBeUndefined() + }) +}) + +// ─── Malformed input ───────────────────────────────────────────────────────────── + +describe('malformed ciphertext', () => { + beforeEach(() => useSingleKey()) + + it('rejects a value with the wrong number of segments', () => { + expect(() => decryptField('not-encrypted')).toThrow(DecryptionError) + expect(() => decryptField('v1:default:iv:tag')).toThrow(/5 colon-separated/) + }) + + it('rejects an unsupported scheme version', () => { + const e = encryptField('x').split(':') + e[0] = 'v9' + expect(() => decryptField(e.join(':'))).toThrow(/scheme version/) + }) + + it('rejects an IV of the wrong length', () => { + const e = encryptField('x').split(':') + e[2] = Buffer.alloc(8, 1).toString('base64') // 8 bytes, expected 12 + expect(() => decryptField(e.join(':'))).toThrow(/IV must be/) + }) +}) + +// ─── Wrong key ─────────────────────────────────────────────────────────────────── + +describe('wrong key rejection', () => { + it('cannot decrypt with a different key under the same key id', () => { + useSingleKey(genKey()) + const encrypted = encryptField('secret') + + // Swap in a different key but keep the id ("default") so lookup succeeds + // and only the GCM auth check fails. + useSingleKey(genKey()) + expect(() => decryptField(encrypted)).toThrow(DecryptionError) + expect(() => decryptField(encrypted)).toThrow(/authentication failed/) + }) +}) + +// ─── Key rotation (key-id tagging) ────────────────────────────────────────────── + +describe('key rotation via FIELD_ENCRYPTION_KEYS', () => { + it('decrypts data written under a now-retired key', () => { + const oldKey = genKey() + const newKey = genKey() + + // 1. Write under the original key (id "k1"). + delete process.env.FIELD_ENCRYPTION_KEY + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([{ kid: 'k1', key: oldKey }]) + const ciphertext = encryptField('rotate-me') + expect(ciphertext.split(':')[1]).toBe('k1') + + // 2. Rotate: k2 becomes active, k1 retained for decryption only. + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([ + { kid: 'k2', key: newKey }, + { kid: 'k1', key: oldKey }, + ]) + + // Old data still decrypts (tagged k1)… + expect(decryptField(ciphertext)).toBe('rotate-me') + // …and new data is written under the active key k2. + const fresh = encryptField('new-data') + expect(fresh.split(':')[1]).toBe('k2') + expect(decryptField(fresh)).toBe('new-data') + }) + + it('the first key in the list is the active encryption key', () => { + const k1 = genKey() + const k2 = genKey() + delete process.env.FIELD_ENCRYPTION_KEY + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([ + { kid: 'primary', key: k1 }, + { kid: 'secondary', key: k2 }, + ]) + expect(resolveKeys()[0].kid).toBe('primary') + expect(encryptField('x').split(':')[1]).toBe('primary') + }) + + it('fails decryption when the tagged key id was rotated out entirely', () => { + const oldKey = genKey() + delete process.env.FIELD_ENCRYPTION_KEY + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([{ kid: 'k1', key: oldKey }]) + const ciphertext = encryptField('orphan') + + // Replace k1 with an unrelated key id — k1 is no longer resolvable. + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([{ kid: 'k2', key: genKey() }]) + expect(() => decryptField(ciphertext)).toThrow(/No field encryption key configured for key id "k1"/) + }) +}) + +// ─── Key configuration / startup failures ─────────────────────────────────────── + +describe('key configuration validation', () => { + it('throws when no key is configured (fail closed)', () => { + delete process.env.FIELD_ENCRYPTION_KEY + delete process.env.FIELD_ENCRYPTION_KEYS + expect(() => resolveKeys()).toThrow(EncryptionKeyError) + expect(() => encryptField('x')).toThrow(/No field encryption key configured/) + }) + + it('rejects a key that is not 32 bytes', () => { + useSingleKey(randomBytes(16).toString('base64')) // 16 bytes, too short + expect(() => resolveKeys()).toThrow(/must decode to 32 bytes/) + }) + + it('rejects malformed FIELD_ENCRYPTION_KEYS JSON', () => { + delete process.env.FIELD_ENCRYPTION_KEY + process.env.FIELD_ENCRYPTION_KEYS = '{ not json' + expect(() => resolveKeys()).toThrow(/not valid JSON/) + }) + + it('rejects an empty FIELD_ENCRYPTION_KEYS array', () => { + delete process.env.FIELD_ENCRYPTION_KEY + process.env.FIELD_ENCRYPTION_KEYS = '[]' + expect(() => resolveKeys()).toThrow(/non-empty JSON array/) + }) + + it('rejects entries missing kid or key', () => { + delete process.env.FIELD_ENCRYPTION_KEY + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([{ kid: 'k1' }]) + expect(() => resolveKeys()).toThrow(/string "kid" and "key"/) + }) + + it('rejects duplicate key ids', () => { + const key = genKey() + delete process.env.FIELD_ENCRYPTION_KEY + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([ + { kid: 'dup', key }, + { kid: 'dup', key }, + ]) + expect(() => resolveKeys()).toThrow(/Duplicate field encryption key id "dup"/) + }) + + it('prefers FIELD_ENCRYPTION_KEYS over FIELD_ENCRYPTION_KEY when both are set', () => { + process.env.FIELD_ENCRYPTION_KEY = genKey() + process.env.FIELD_ENCRYPTION_KEYS = JSON.stringify([{ kid: 'json-key', key: genKey() }]) + expect(resolveKeys()).toHaveLength(1) + expect(resolveKeys()[0].kid).toBe('json-key') + }) +}) + +// ─── Nullable helpers ──────────────────────────────────────────────────────────── + +describe('encryptNullable / decryptNullable', () => { + beforeEach(() => useSingleKey()) + + it('passes null and undefined through unchanged', () => { + expect(encryptNullable(null)).toBeNull() + expect(encryptNullable(undefined)).toBeNull() + expect(decryptNullable(null)).toBeNull() + expect(decryptNullable(undefined)).toBeNull() + }) + + it('encrypts and decrypts a present value', () => { + const encrypted = encryptNullable('present') + expect(encrypted).not.toBeNull() + expect(isEncrypted(encrypted!)).toBe(true) + expect(decryptNullable(encrypted)).toBe('present') + }) +}) diff --git a/src/tests/webhookSubscriber.rotation.test.ts b/src/tests/webhookSubscriber.rotation.test.ts index 116248a2..21db2017 100644 --- a/src/tests/webhookSubscriber.rotation.test.ts +++ b/src/tests/webhookSubscriber.rotation.test.ts @@ -12,6 +12,10 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals' import knex, { Knex } from 'knex' import { WebhookSubscriberRepository } from '../repositories/webhookSubscriberRepository.js' +import { decryptField } from '../lib/encryption.js' + +// Signing secrets are encrypted at rest (a test key is configured globally in +// jest.setup.cjs), so reads of the raw `secret` column return ciphertext. // ─── Conditional suite ──────────────────────────────────────────────────────── @@ -146,7 +150,9 @@ describeDb('WebhookSubscriberRepository – upsert & secret rotation', () => { // Must have exactly one row — no duplicates expect(all).toHaveLength(1) - expect(all[0].secret).toBe('new-secret') + // Stored secret is ciphertext at rest; decrypt to compare. + expect(all[0].secret).not.toBe('new-secret') + expect(decryptField(all[0].secret)).toBe('new-secret') expect(all[0].events).toEqual(['vault_created', 'vault_completed']) }) @@ -198,8 +204,9 @@ describeDb('WebhookSubscriberRepository – upsert & secret rotation', () => { expect(rowsA).toHaveLength(1) expect(rowsB).toHaveLength(1) // Org A's secret must remain untouched when org B upserts the same URL - expect(rowsA[0].secret).toBe('secret-a') - expect(rowsB[0].secret).toBe('secret-b') + // (stored encrypted at rest, so decrypt to compare). + expect(decryptField(rowsA[0].secret)).toBe('secret-a') + expect(decryptField(rowsB[0].secret)).toBe('secret-b') }) it('preserves delivery history (dead letter rows) across re-registration', async () => { @@ -284,9 +291,9 @@ describeDb('WebhookSubscriberRepository – upsert & secret rotation', () => { const result = await repo.rotateSecret(sub.id, ORG_B, 'attacker-secret') expect(result).toBeNull() - // Confirm the row is unchanged + // Confirm the row is unchanged (stored encrypted, so decrypt to compare) const row = await db('webhook_subscribers').where({ id: sub.id }).first() - expect(row.secret).toBe('secret-a') + expect(decryptField(row.secret)).toBe('secret-a') }) it('overwrites previous_secret on a second rotation', async () => {