// Chiffrement au repos des secrets applicatifs (server_secret HMAC, clé privée VAPID) stockés dans // la table `settings`. node:sqlite (DatabaseSync) ne supporte pas sqlcipher → on chiffre au niveau // applicatif les VALEURS sensibles, en AES-256-GCM (authentifié), avant insertion. // // Gestion de clé (par ordre de priorité) : // 1. ARBORETUM_SECRET_KEY (variable d'env) → vraie protection : la clé ne vit pas sur le disque, // donc une fuite de la base seule (backup, WAL) ne révèle pas les secrets ; // 2. sinon, fichier clé `dataDir/secret.key` (0o600), généré au 1er démarrage. Protection partielle : // couvre la fuite de la base seule, PAS celle du dossier de données complet (clé + base ensemble). // Compromis assumé et documenté (docs/ENTERPRISE_DEPLOYMENT.md) : pour une protection forte, fournir // ARBORETUM_SECRET_KEY et sauvegarder cette clé séparément des backups de la base. import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'; import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const PREFIX = 'v1:'; // versionne le format ; absence de préfixe = valeur en clair (legacy → migration douce) const SCRYPT_SALT = 'arboretum-secret-box-v1'; // sel fixe : l'entropie vient de la passphrase fournie export class SecretBox { constructor(private readonly key: Buffer) {} /** Chiffre en `v1:::` (hex). */ encrypt(plaintext: string): string { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', this.key, iv); const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `${PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`; } /** Déchiffre une valeur `v1:` ; une valeur sans préfixe est retournée telle quelle (clair legacy). */ decrypt(stored: string): string { if (!this.isEncrypted(stored)) return stored; const [, ivHex, tagHex, ctHex] = stored.split(':'); if (!ivHex || !tagHex || !ctHex) throw new Error('secret-box: format chiffré invalide'); const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(ivHex, 'hex')); decipher.setAuthTag(Buffer.from(tagHex, 'hex')); return Buffer.concat([decipher.update(Buffer.from(ctHex, 'hex')), decipher.final()]).toString('utf8'); } isEncrypted(stored: string): boolean { return stored.startsWith(PREFIX); } } /** * Construit le SecretBox de production : clé dérivée d'ARBORETUM_SECRET_KEY si fournie, sinon * d'un fichier clé `dataDir/secret.key` (0o600) généré au besoin. */ export function loadSecretBox(dataDir: string): SecretBox { const passphrase = process.env.ARBORETUM_SECRET_KEY; if (passphrase && passphrase.length > 0) { return new SecretBox(scryptSync(passphrase, SCRYPT_SALT, 32)); } const keyPath = join(dataDir, 'secret.key'); let keyHex: string; if (existsSync(keyPath)) { keyHex = readFileSync(keyPath, 'utf8').trim(); } else { keyHex = randomBytes(32).toString('hex'); writeFileSync(keyPath, keyHex + '\n', { mode: 0o600 }); } try { chmodSync(keyPath, 0o600); } catch { /* FS sans permissions POSIX : ignoré */ } return new SecretBox(Buffer.from(keyHex, 'hex')); }