feat: clean install (Gitea en dur, scan off) + durcissement sécurité entreprise
Gitea - lien « Code source » hardcodé (REPO_SOURCE_URL) vers le dépôt, toujours visible - retrait complet du réglage configurable (api.ts, route+store settings, SettingsView, i18n, help, tests) Découverte des dépôts - aucune racine de scan par défaut → pas de scan au premier démarrage (clean install) - corrige la découverte des dépôts à l'INTÉRIEUR d'une racine qui est elle-même un repo (depth 0 = conteneur de scan, on descend ; depth > 0 = feuille) Sécurité « enterprise-deployable » - en-têtes HTTP durcis (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS conditionnel, no-store API), header Server retiré - permissions DB 0o600 / dossier de données 0o700 ; error handler sanitisé ; garde Content-Type sur les mutations - chiffrement au repos AES-256-GCM des secrets (server_secret, clé privée VAPID) via SecretBox - journal d'audit (migration #7) + endpoint /audit-logs + RGPD export/effacement + UI Réglages - SECURITY.md, docs/ENTERPRISE_DEPLOYMENT.md, sections README EN/FR, SBOM CycloneDX en CI CI - pack-smoke packe depuis le contexte du package (cd packages/server) au lieu de « npm pack -w » : corrige l'embarquement de @arboretum/shared dans le tarball Purge des données personnelles du dépôt public - suppression de spikes/s4-discovery/result.json, reformulation du VERDICT - chemin de test générique, anonymisation des 5 fixtures de dialogues
This commit is contained in:
69
packages/server/src/core/secret-box.ts
Normal file
69
packages/server/src/core/secret-box.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// 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:<iv>:<tag>:<ciphertext>` (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'));
|
||||
}
|
||||
Reference in New Issue
Block a user