Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
48 changes: 48 additions & 0 deletions docs/encryption.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions scripts/reencrypt-directory-configs.ts
Original file line number Diff line number Diff line change
@@ -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<object>(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())
59 changes: 59 additions & 0 deletions src/lib/directory/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
54 changes: 38 additions & 16 deletions src/lib/directory/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -32,11 +42,23 @@ export function decryptConfig<T>(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")
}