diff --git a/.env.example b/.env.example index 7c937cd..ffedda5 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,14 @@ AUTH_URL=http://localhost:3000 # 32 characters minimum. Encrypts directory connection secrets at rest. # The app refuses to handle directory configs without it. +# Use a high-entropy random value, e.g. `openssl rand -base64 32`. DIRECTORY_ENCRYPTION_KEY=change-me-to-a-long-random-32-char-secret +# Optional. Set only during key rotation: the former DIRECTORY_ENCRYPTION_KEY. +# Decryption falls back to it so existing rows stay readable until they are +# re-encrypted (npm run reencrypt:directory). Remove it once rotation is done. +# DIRECTORY_ENCRYPTION_KEY_PREVIOUS= + # Optional. Enables Have I Been Pwned breach lookups. HIBP_API_KEY= diff --git a/docs/encryption.md b/docs/encryption.md new file mode 100644 index 0000000..5bc2c1c --- /dev/null +++ b/docs/encryption.md @@ -0,0 +1,48 @@ +# Directory credentials encryption + +Directory connection configs (Azure AD, Google Workspace, LDAP, AWS, Okta +secrets and SCIM bearer tokens) are encrypted at rest in +`DirectoryConnection.encryptedConfig`. Implementation: `src/lib/directory/crypto.ts`. + +## Algorithm review + +- **Cipher**: AES-256-GCM, an authenticated cipher. Confidentiality plus + integrity, so a tampered ciphertext is rejected instead of silently + decrypting to garbage. +- **Nonce/IV**: 12 random bytes (`randomBytes`) generated per encryption, the + recommended size for GCM. A fresh IV per call means encrypting the same + config twice yields different ciphertexts (no deterministic leak). +- **Auth tag**: 16 bytes, verified on decrypt; a wrong key or modified + ciphertext throws. +- **Storage format**: `base64(iv | tag | ciphertext)` in a single column. +- **Key**: derived as `sha256(DIRECTORY_ENCRYPTION_KEY)` to normalize any input + to a 32-byte AES key. The app refuses to start crypto operations if the key + is missing or shorter than 32 characters. + +### Known limitations + +- `sha256` of the env value is a normalizer, not a password KDF (no salt, no + stretching). This is safe **only if the key is a high-entropy random value**. + Generate it with `openssl rand -base64 32`; do not use a human-chosen + passphrase. +- No additional authenticated data (AAD). Ciphertexts are not bound to their + row, so a database-level actor could swap one connection's blob onto another. + Acceptable given DB access is already full compromise; revisit if needed. + +## Key rotation procedure + +Decryption tries the current key first, then the optional previous key, so +rotation is zero-downtime. + +1. Generate a new key: `openssl rand -base64 32`. +2. In the environment, set `DIRECTORY_ENCRYPTION_KEY` to the **new** key and + `DIRECTORY_ENCRYPTION_KEY_PREVIOUS` to the **old** key. Deploy. New writes + use the new key; existing rows still decrypt via the previous key. +3. Re-encrypt all stored configs under the new key: + `npm run reencrypt:directory`. The script is idempotent. +4. Once it reports success for every row, remove + `DIRECTORY_ENCRYPTION_KEY_PREVIOUS` and redeploy. + +If the script reports rows it could not decrypt, do **not** drop the previous +key: those rows were encrypted under a key neither value matches, and dropping +the fallback makes them permanently unreadable. diff --git a/package.json b/package.json index 8b39506..a955b8a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "db:migrate": "prisma migrate deploy", "postinstall": "prisma generate", "prepare": "sh .githooks/install.sh", - "seed:dev": "dotenv -e .env.local -- tsx prisma/seed.dev.ts" + "seed:dev": "dotenv -e .env.local -- tsx prisma/seed.dev.ts", + "reencrypt:directory": "dotenv -e .env.local -- tsx scripts/reencrypt-directory-configs.ts" }, "dependencies": { "@aws-sdk/client-identitystore": "^3.1069.0", diff --git a/scripts/reencrypt-directory-configs.ts b/scripts/reencrypt-directory-configs.ts new file mode 100644 index 0000000..e376c40 --- /dev/null +++ b/scripts/reencrypt-directory-configs.ts @@ -0,0 +1,53 @@ +/** + * Re-encrypts every DirectoryConnection.encryptedConfig with the current + * DIRECTORY_ENCRYPTION_KEY. Used during key rotation. + * + * Procedure: + * 1. Set DIRECTORY_ENCRYPTION_KEY to the new key and + * DIRECTORY_ENCRYPTION_KEY_PREVIOUS to the old key. + * 2. Deploy (decryption already falls back to the previous key). + * 3. Run this script: it decrypts each row (current or previous key) and + * writes it back encrypted under the current key. + * 4. Once it completes cleanly, remove DIRECTORY_ENCRYPTION_KEY_PREVIOUS. + * + * Idempotent: re-running re-encrypts rows already on the current key. + * + * npx tsx scripts/reencrypt-directory-configs.ts + */ +import { prisma } from "../src/lib/prisma" +import { decryptConfig, encryptConfig } from "../src/lib/directory/crypto" + +async function main() { + const rows = await prisma.directoryConnection.findMany({ + select: { id: true, encryptedConfig: true }, + }) + + let ok = 0 + const failed: string[] = [] + + for (const row of rows) { + try { + const config = decryptConfig(row.encryptedConfig) + await prisma.directoryConnection.update({ + where: { id: row.id }, + data: { encryptedConfig: encryptConfig(config) }, + }) + ok++ + } catch { + failed.push(row.id) + } + } + + console.log(`Re-encrypted ${ok}/${rows.length} connection(s).`) + if (failed.length) { + console.error(`Could not decrypt ${failed.length}: ${failed.join(", ")}`) + process.exitCode = 1 + } +} + +main() + .catch((e) => { + console.error(e) + process.exitCode = 1 + }) + .finally(() => prisma.$disconnect()) diff --git a/src/lib/directory/crypto.test.ts b/src/lib/directory/crypto.test.ts new file mode 100644 index 0000000..3290813 --- /dev/null +++ b/src/lib/directory/crypto.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { encryptConfig, decryptConfig } from "./crypto" + +const KEY_A = "a".repeat(40) +const KEY_B = "b".repeat(40) + +describe("crypto config encryption", () => { + beforeEach(() => { + process.env.DIRECTORY_ENCRYPTION_KEY = KEY_A + delete process.env.DIRECTORY_ENCRYPTION_KEY_PREVIOUS + }) + afterEach(() => { + delete process.env.DIRECTORY_ENCRYPTION_KEY + delete process.env.DIRECTORY_ENCRYPTION_KEY_PREVIOUS + }) + + it("round-trips an object", () => { + const data = { token: "s3cret", nested: { a: 1 } } + expect(decryptConfig(encryptConfig(data))).toEqual(data) + }) + + it("produces a different IV each call (non-deterministic ciphertext)", () => { + const a = encryptConfig({ x: 1 }) + const b = encryptConfig({ x: 1 }) + expect(a).not.toBe(b) + }) + + it("rejects a tampered ciphertext via the GCM auth tag", () => { + const enc = encryptConfig({ x: 1 }) + const buf = Buffer.from(enc, "base64") + buf[buf.length - 1] ^= 0xff + expect(() => decryptConfig(buf.toString("base64"))).toThrow() + }) + + it("throws when the key is missing or too short", () => { + delete process.env.DIRECTORY_ENCRYPTION_KEY + expect(() => encryptConfig({ x: 1 })).toThrow(/missing or too short/i) + process.env.DIRECTORY_ENCRYPTION_KEY = "short" + expect(() => encryptConfig({ x: 1 })).toThrow(/missing or too short/i) + }) + + it("decrypts with the previous key after rotation", () => { + // Encrypted under KEY_A (the old key). + const old = encryptConfig({ token: "old" }) + // Rotate: KEY_B becomes current, KEY_A moves to previous. + process.env.DIRECTORY_ENCRYPTION_KEY = KEY_B + process.env.DIRECTORY_ENCRYPTION_KEY_PREVIOUS = KEY_A + expect(decryptConfig(old)).toEqual({ token: "old" }) + // New writes use KEY_B and still decrypt. + const fresh = encryptConfig({ token: "new" }) + expect(decryptConfig(fresh)).toEqual({ token: "new" }) + }) + + it("fails to decrypt once the previous key is dropped", () => { + const old = encryptConfig({ token: "old" }) + process.env.DIRECTORY_ENCRYPTION_KEY = KEY_B + expect(() => decryptConfig(old)).toThrow() + }) +}) diff --git a/src/lib/directory/crypto.ts b/src/lib/directory/crypto.ts index 24e78f5..306fb37 100644 --- a/src/lib/directory/crypto.ts +++ b/src/lib/directory/crypto.ts @@ -3,22 +3,32 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypt const IV_LENGTH = 12 // nonce recommandé pour AES-GCM const TAG_LENGTH = 16 -// Dérive une clé AES-256 (32 octets) depuis le secret d'environnement. -// Aucun repli silencieux : on échoue explicitement si le secret est absent ou trop court. -function getKey(): Buffer { - const raw = process.env.DIRECTORY_ENCRYPTION_KEY - if (!raw || raw.length < 32) { +// sha256 normalizes any input length to exactly 32 bytes (AES-256 key). +function deriveKey(raw: string): Buffer { + return createHash("sha256").update(raw, "utf8").digest() +} + +// Returns the decryption keys in priority order: the current key first, then +// the optional previous key. Encryption always uses the first (current) key; +// decryption falls back to the previous one so a key rotation can run without +// downtime (rotate env, then re-encrypt rows in the background). +// No silent fallback on a missing/short current key: fail loudly. +function getKeys(): Buffer[] { + const current = process.env.DIRECTORY_ENCRYPTION_KEY + if (!current || current.length < 32) { throw new Error( - "DIRECTORY_ENCRYPTION_KEY absente ou trop courte (32 caractères minimum)." + "DIRECTORY_ENCRYPTION_KEY missing or too short (32 characters minimum)." ) } - // sha256 normalise n'importe quelle longueur d'entrée en exactement 32 octets. - return createHash("sha256").update(raw, "utf8").digest() + const keys = [deriveKey(current)] + const previous = process.env.DIRECTORY_ENCRYPTION_KEY_PREVIOUS + if (previous && previous.length >= 32) keys.push(deriveKey(previous)) + return keys } export function encryptConfig(data: object): string { const iv = randomBytes(IV_LENGTH) - const cipher = createCipheriv("aes-256-gcm", getKey(), iv) + const cipher = createCipheriv("aes-256-gcm", getKeys()[0], iv) const encrypted = Buffer.concat([ cipher.update(JSON.stringify(data), "utf8"), cipher.final(), @@ -32,11 +42,23 @@ export function decryptConfig(encoded: string): T { const iv = buf.subarray(0, IV_LENGTH) const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH) const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH) - const decipher = createDecipheriv("aes-256-gcm", getKey(), iv) - decipher.setAuthTag(tag) - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final(), - ]).toString("utf8") - return JSON.parse(decrypted) as T + + // Try each key in order. The GCM auth tag check throws on the wrong key, so + // a failure just means "try the previous key". Throw the last error if none + // succeed, rather than leaking a partial result. + let lastError: unknown + for (const key of getKeys()) { + try { + const decipher = createDecipheriv("aes-256-gcm", key, iv) + decipher.setAuthTag(tag) + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString("utf8") + return JSON.parse(decrypted) as T + } catch (e) { + lastError = e + } + } + throw lastError ?? new Error("Failed to decrypt config") }