Rien ne pouvait être téléversé jusqu'ici : ni pièce d'identité, ni relevé d'identité bancaire, ni arrêt de travail. Le dossier RH n'existait qu'en champs de formulaire. Les fichiers vivent sur disque, chiffrés avec la clé qui protège déjà le NIR et l'IBAN. Le plan n'exige le chiffrement que des pièces jointes de santé ; les chiffrer toutes supprime une branche dont l'oubli serait silencieux et ne coûte rien de plus. Leur emplacement est tiré au sort, jamais dérivé du nom déposé : la traversée de chemin devient impossible par construction plutôt que par filtrage, et un filtre s'oublie. Le caractère sensible se déduit de la catégorie et n'est jamais saisi : laisser déclarer qu'un arrêt de travail n'est pas une donnée de santé reviendrait à laisser désactiver la journalisation de sa lecture. Cette lecture est inscrite au journal avant d'être servie, et l'écran l'annonce — celui qui ouvre la pièce doit savoir que sa consultation laisse une trace nominative. Les liens sont signés et durent deux minutes, comme l'exige le plan. La signature ne remplace pas le contrôle d'accès : la route revérifie session, capacité et périmètre. Elle s'y ajoute pour qu'un lien recopié dans un message cesse de fonctionner de lui-même, sans attendre qu'une session expire. La signature est éprouvée avant l'échéance, sans quoi répondre « expiré » à un lien fabriqué indiquerait qu'il aurait pu marcher. L'empreinte du clair est conservée et revérifiée à chaque lecture : servir un contenu qui ne correspond plus reviendrait à présenter comme authentique une pièce altérée. Retirer une pièce efface le contenu mais garde la ligne : le dossier doit conserver trace qu'elle a existé et qui l'a retirée. Aucune durée de conservation n'est appliquée par défaut — le plan l'interdit explicitement (§12.5). L'échéance reste nulle et l'écran le dit, plutôt que d'inventer « cinq ans partout ». Deux pièges d'outillage rencontrés et documentés dans les tests : le cookie de session étant marqué Secure, le client HTTP de Playwright ne l'émet pas sur http et faisait passer les refus pour de bonnes raisons sans rien prouver ; et la visionneuse PDF intégrée de Chromium ne restitue pas le corps d'une navigation, ce qui masquait la vérification d'intégrité du contenu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
randomBytes,
|
|
timingSafeEqual,
|
|
createHash,
|
|
} from 'node:crypto';
|
|
|
|
import { env } from '@/lib/env';
|
|
|
|
/**
|
|
* Chiffrement applicatif des colonnes sensibles — PLAN.md §3.6.
|
|
*
|
|
* NIR, IBAN, BIC et secrets MFA. AES-256-GCM : le mode authentifié détecte
|
|
* qu'un chiffré a été modifié, ce qu'un CBC laisserait passer en produisant du
|
|
* clair corrompu.
|
|
*
|
|
* La clé vit hors de la base. Une sauvegarde volée ne doit pas suffire à lire
|
|
* ces colonnes — sans quoi le chiffrement ne protège que du vol de disque nu.
|
|
*/
|
|
|
|
const ALGORITHM = 'aes-256-gcm';
|
|
const IV_LENGTH = 12;
|
|
const TAG_LENGTH = 16;
|
|
|
|
function key(): Buffer {
|
|
return Buffer.from(env.ENCRYPTION_KEY, 'base64');
|
|
}
|
|
|
|
/** Format : [iv (12) | tag (16) | chiffré]. */
|
|
export function encrypt(plaintext: string): Buffer {
|
|
const iv = randomBytes(IV_LENGTH);
|
|
const cipher = createCipheriv(ALGORITHM, key(), iv);
|
|
const encrypted = Buffer.concat([
|
|
cipher.update(plaintext, 'utf8'),
|
|
cipher.final(),
|
|
]);
|
|
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
|
|
}
|
|
|
|
export function decrypt(payload: Uint8Array): string {
|
|
// Prisma 7 renvoie les colonnes `Bytes` en Uint8Array, pas en Buffer.
|
|
const buffer = Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
return decryptBuffer(buffer);
|
|
}
|
|
|
|
function decryptBuffer(payload: Buffer): string {
|
|
if (payload.length < IV_LENGTH + TAG_LENGTH) {
|
|
throw new Error('Chiffré invalide : trop court pour contenir iv et tag');
|
|
}
|
|
|
|
const iv = payload.subarray(0, IV_LENGTH);
|
|
const tag = payload.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
const encrypted = payload.subarray(IV_LENGTH + TAG_LENGTH);
|
|
|
|
const decipher = createDecipheriv(ALGORITHM, key(), iv);
|
|
decipher.setAuthTag(tag);
|
|
|
|
return Buffer.concat([
|
|
decipher.update(encrypted),
|
|
decipher.final(),
|
|
]).toString('utf8');
|
|
}
|
|
|
|
/**
|
|
* Chiffrement d'un contenu binaire — pièces du dossier salarié.
|
|
*
|
|
* Même format et même clé que les colonnes : un arrêt de travail est une donnée
|
|
* de santé, et le plan (§3.6) exige qu'il soit chiffré au repos. Chiffrer
|
|
* **toutes** les pièces plutôt que les seules pièces de santé évite une branche
|
|
* dont l'oubli serait silencieux, et ne coûte rien de plus.
|
|
*/
|
|
export function encryptBytes(plaintext: Uint8Array): Buffer {
|
|
const iv = randomBytes(IV_LENGTH);
|
|
const cipher = createCipheriv(ALGORITHM, key(), iv);
|
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
|
|
}
|
|
|
|
export function decryptBytes(payload: Uint8Array): Buffer {
|
|
const buffer = Buffer.from(
|
|
payload.buffer,
|
|
payload.byteOffset,
|
|
payload.byteLength,
|
|
);
|
|
if (buffer.length < IV_LENGTH + TAG_LENGTH) {
|
|
throw new Error('Chiffré invalide : trop court pour contenir iv et tag');
|
|
}
|
|
|
|
const iv = buffer.subarray(0, IV_LENGTH);
|
|
const tag = buffer.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
const encrypted = buffer.subarray(IV_LENGTH + TAG_LENGTH);
|
|
|
|
const decipher = createDecipheriv(ALGORITHM, key(), iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
}
|
|
|
|
/** Empreinte d'un contenu, pour détecter l'altération du fichier stocké. */
|
|
export function checksumBytes(payload: Uint8Array): string {
|
|
return createHash('sha256').update(payload).digest('hex');
|
|
}
|
|
|
|
export function encryptOptional(value: string | null | undefined): Buffer | null {
|
|
return value ? encrypt(value) : null;
|
|
}
|
|
|
|
export function decryptOptional(
|
|
payload: Uint8Array | null | undefined,
|
|
): string | null {
|
|
return payload ? decrypt(payload) : null;
|
|
}
|
|
|
|
/**
|
|
* Empreinte d'un jeton de session ou d'invitation.
|
|
*
|
|
* La base ne stocke que l'empreinte : une fuite de la table ne donne pas de
|
|
* sessions utilisables. SHA-256 suffit ici — le jeton est déjà 256 bits
|
|
* d'aléa, il n'y a pas de dictionnaire à ralentir, contrairement à un mot de
|
|
* passe.
|
|
*/
|
|
export function hashToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
export function generateToken(): string {
|
|
return randomBytes(32).toString('base64url');
|
|
}
|
|
|
|
/** Comparaison à temps constant, pour ne pas fuiter par la durée. */
|
|
export function safeEqual(a: string, b: string): boolean {
|
|
const bufferA = Buffer.from(a);
|
|
const bufferB = Buffer.from(b);
|
|
if (bufferA.length !== bufferB.length) return false;
|
|
return timingSafeEqual(bufferA, bufferB);
|
|
}
|