Exiger un second facteur des rôles administrateur et RH

La matrice n° 15 l'impose ; les colonnes existaient en base depuis WP-01 mais
rien ne les remplissait. TOTP (RFC 6238) plutôt qu'un code envoyé par message :
le second facteur ne doit pas dépendre du canal qui sert déjà à réinitialiser le
mot de passe, faute de quoi un accès à la boîte donnerait les deux d'un coup.

L'algorithme est écrit ici plutôt qu'emprunté — trente lignes, et une dépendance
de moins sur le chemin d'authentification. Il est éprouvé contre les vecteurs
publiés de la RFC, ce qui distingue « le code change toutes les trente
secondes » de « le code est celui qu'attend l'application du téléphone ».

L'obligation est adossée aux capacités, pas à des rôles nommés qu'un client
renomme librement : distribuer les droits, ou lire les rémunérations. Elle est
tenue au point de passage de toutes les routes applicatives, où l'écran
d'enrôlement remplace le contenu. Il remplace plutôt qu'il ne redirige : une
redirection depuis un layout se joue aussi pendant la navigation qui suit la
connexion, et Next y répond par une page vide.

Le secret n'est enregistré qu'après qu'un code en a été tiré — l'enregistrer
d'avance laisserait des comptes porteurs d'un facteur que leur détenteur ne sait
pas produire, c'est-à-dire des comptes fermés. Le rejeu d'un code est refusé
dans sa propre fenêtre : sans cela le facteur protège d'un mot de passe volé,
pas d'un code lu par-dessus l'épaule.

Dix codes de secours accompagnent chaque activation, conservés hachés. Et parce
que PlanFlow est auto-hébergé et qu'il n'y a pas d'éditeur à appeler, un retrait
depuis le serveur existe — l'accès « break glass » que demande la même ligne de
la matrice. Sans lui, le second facteur deviendrait le risque principal plutôt
que la protection.

Un défaut trouvé par les tests, et il aurait été grave : la réactualisation de
la route après activation remplaçait l'écran par celui d'un compte déjà enrôlé,
emportant les codes de secours avant que leur destinataire ait pu les noter.

La suite de tests suit le produit : la mise en place enrôle réellement le compte
de direction et calcule les codes comme le ferait un téléphone. Les écrans qui
n'éprouvent pas l'authentification ont migré vers la session commune — deux
connexions parallèles sur un même compte se heurtent au refus de rejeu, ce qui
est le comportement voulu et non un défaut à contourner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 22:05:57 +00:00
1 parent 8701fe8105
commit 2047d7f5cc
27 files changed
+1997 -79

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
/**
* Qui doit porter un second facteur — matrice n° 15.
*
* La matrice dit « administrateurs et RH ». Ces deux mots ne désignent pas des
* rôles nommés — un client renomme ses rôles librement — mais ce que le rôle
* permet de faire. La règle est donc adossée aux capacités :
*
* - `settings.roles.manage` — qui distribue les droits peut se les donner ;
* - `members.salary.view` — qui lit les rémunérations lit la donnée RH la plus
* sensible après la santé.
*
* Une liste plutôt qu'une condition dispersée : ce qui déclenche l'obligation
* doit se lire d'un seul endroit.
*/
export const MFA_REQUIRED_CAPABILITIES = [
'settings.roles.manage',
'members.salary.view',
] as const;
export function mfaRequired(permissions: ReadonlySet<string>): boolean {
return MFA_REQUIRED_CAPABILITIES.some((code) => permissions.has(code));
}
+183
View File
@@ -0,0 +1,183 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Second facteur temporel (TOTP, RFC 6238) — matrice n° 15.
*
* Implémenté ici plutôt qu'emprunté : l'algorithme tient en trente lignes, et
* une dépendance de plus sur le chemin d'authentification est une surface de
* plus à surveiller.
*
* Le choix du TOTP plutôt que d'un code envoyé par message tient à une raison
* simple : le second facteur ne doit pas dépendre du canal qui sert déjà à
* réinitialiser le mot de passe. Un accès à la boîte électronique donnerait
* sinon les deux facteurs d'un coup.
*/
/** 30 secondes — la valeur qu'attendent toutes les applications d'authentification. */
export const TOTP_STEP_SECONDS = 30;
export const TOTP_DIGITS = 6;
/**
* Tolérance d'un pas de part et d'autre.
*
* Zéro rejetterait un téléphone désynchronisé de quelques secondes, ce qui est
* courant ; élargir davantage allongerait d'autant la fenêtre exploitable par
* un code intercepté.
*/
export const TOTP_WINDOW = 1;
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = '';
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
export function base32Decode(encoded: string): Uint8Array {
// Les utilisateurs recopient parfois le secret à la main : espaces, minuscules
// et remplissage « = » sont tolérés plutôt que refusés sans explication.
const cleaned = encoded.replace(/[\s=]/g, '').toUpperCase();
let bits = 0;
let value = 0;
const output: number[] = [];
for (const character of cleaned) {
const index = BASE32_ALPHABET.indexOf(character);
if (index === -1) {
throw new Error('Secret TOTP invalide : caractère hors alphabet base32');
}
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Uint8Array.from(output);
}
/** Numéro de pas courant. Sert aussi à interdire le rejeu d'un code déjà employé. */
export function totpStep(atMs: number): number {
return Math.floor(atMs / 1000 / TOTP_STEP_SECONDS);
}
export function totpCodeAtStep(secret: string, step: number): string {
const key = base32Decode(secret);
const counter = Buffer.alloc(8);
// Compteur sur 64 bits big-endian. `writeBigUInt64BE` évite le débordement
// qu'un décalage 32 bits produirait au-delà de 2038.
counter.writeBigUInt64BE(BigInt(step));
const digest = createHmac('sha1', key).update(counter).digest();
// Troncature dynamique (RFC 4226 §5.3).
const offset = digest[digest.length - 1]! & 0x0f;
const binary =
((digest[offset]! & 0x7f) << 24) |
((digest[offset + 1]! & 0xff) << 16) |
((digest[offset + 2]! & 0xff) << 8) |
(digest[offset + 3]! & 0xff);
return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, '0');
}
export function totpCode(secret: string, atMs: number): string {
return totpCodeAtStep(secret, totpStep(atMs));
}
export interface TotpVerification {
ok: boolean;
/** Pas employé, à mémoriser pour refuser le rejeu du même code. */
step?: number;
}
/**
* Vérifie un code.
*
* `lastUsedStep` interdit qu'un code intercepté serve une seconde fois dans sa
* fenêtre de validité : sans cela, le TOTP protège d'un mot de passe volé mais
* pas d'un code lu par-dessus l'épaule.
*/
export function verifyTotp(
secret: string,
code: string,
atMs: number,
lastUsedStep: number | null = null,
): TotpVerification {
const cleaned = code.replace(/\s/g, '');
if (!/^\d{6}$/.test(cleaned)) return { ok: false };
const current = totpStep(atMs);
for (let offset = -TOTP_WINDOW; offset <= TOTP_WINDOW; offset += 1) {
const step = current + offset;
if (lastUsedStep !== null && step <= lastUsedStep) continue;
if (equals(totpCodeAtStep(secret, step), cleaned)) {
return { ok: true, step };
}
}
return { ok: false };
}
/**
* Comparaison à durée constante.
*
* Sur six chiffres le gain est théorique, mais un comparateur qui s'arrête au
* premier écart n'a aucune raison d'être employé ici.
*/
function equals(a: string, b: string): boolean {
const left = Buffer.from(a, 'utf8');
const right = Buffer.from(b, 'utf8');
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
export interface OtpauthInput {
secret: string;
/** Ce que l'application affichera dans sa liste : l'adresse du salarié. */
account: string;
issuer: string;
}
/**
* URI `otpauth://` que lit une application d'authentification.
*
* L'émetteur est répété dans le chemin **et** en paramètre : les applications
* n'ont jamais convergé sur l'une des deux formes, et n'en fournir qu'une donne
* des entrées mal nommées chez la moitié des utilisateurs.
*/
export function otpauthUri({ secret, account, issuer }: OtpauthInput): string {
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
const parameters = new URLSearchParams({
secret,
issuer,
algorithm: 'SHA1',
digits: String(TOTP_DIGITS),
period: String(TOTP_STEP_SECONDS),
});
return `otpauth://totp/${label}?${parameters.toString()}`;
}
/** Groupes de quatre — un secret recopié à la main l'est sans erreur. */
export function formatSecret(secret: string): string {
return secret.replace(/(.{4})/g, '$1 ').trim();
}