Commit 3b88c9a2 authored by Kourser's avatar Kourser
Browse files

Authentification : mot de passe, lien à usage unique, TOTP, codes de secours

Achève le lot L0. L'identité est portée par l'instance, sans aucune fédération
avec l'annuaire du client (EF-106) : ce n'est pas une lacune mais le produit —
un SSO adossé à l'annuaire qu'on soupçonne compromis ne vaudrait rien le jour
où il faut s'en servir.

Deux voies d'entrée
  Mot de passe ou lien à usage unique, au choix à chaque connexion. Aucune des
  deux ne dispense du second facteur : recevoir un courriel prouve l'accès à
  une boîte, pas la possession du téléphone.

  Aucune réponse ne révèle si une adresse est connue — même message, même coût
  en temps, le hachage étant exécuté jusque pour les comptes inexistants. La
  liste des membres d'une cellule de crise est un renseignement, pas un détail.

Second facteur
  TOTP seulement, jamais de SMS : le SMS n'est pas un facteur mais un canal,
  interceptable par portage de numéro et indisponible précisément quand l'outil
  doit servir. Il reste réservé à la mobilisation, où sa faiblesse est sans
  conséquence. Le compteur du dernier code accepté est retenu, sans quoi un
  code intercepté resterait rejouable pendant sa fenêtre.

  Codes de secours imprimables — la « carte de crise ». 80 bits d'entropie sur
  un alphabet sans I, O, 0 ni 1, parce qu'ils se lisent sur du papier, la nuit,
  sous pression. Le secret TOTP n'est enregistré qu'une fois un code valide
  présenté : un secret perdu entre l'écran et l'application n'enferme personne.

Sessions
  Cookie inaccessible au script, la base ne gardant que l'empreinte du jeton :
  une lecture de la base ne rejoue aucune session. Deux durées, deux risques —
  12 h d'inactivité pour le terminal non maîtrisé, 7 jours en absolu. L'inactivité
  n'est pas plus courte à dessein : une cellule qui travaille vingt heures
  d'affilée ne doit pas se faire déconnecter à 4 h du matin. La révocation à
  distance est le vrai filet.

Audit
  Les actes d'authentification rejoignent le journal d'administration, avec ses
  garanties d'ajout seul et de chaînage. Les échecs de connexion en sont exclus :
  un journal inaltérable et sans purge que n'importe qui peut faire grossir en
  se trompant de mot de passe serait une prise offerte. Ils sont comptés dans
  `tentative`, qui se purge.

Choix assumé
  scrypt plutôt qu'Argon2id : Node l'intègre, donc l'image se construit sans
  dépendance native sur amd64 comme sur arm64. « Cloner et composer suffit »
  est une exigence produit, pas une commodité. Le format stocke ses paramètres,
  une migration reste ouverte sans invalider les empreintes existantes.

Vérification
  `pnpm verif` passe de 31 à 76 contrôles. Les 45 nouveaux couvrent l'absence
  d'énumération de comptes, le blocage du martèlement, le non-rejeu d'un code
  TOTP, l'usage unique des codes de secours et des liens, le contrôle de rôle à
  l'invitation, l'appartenance multi-organisation, et les attributs du cookie
  de session vérifiés à travers le transport HTTP réel.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 8b31cd95
Loading
Loading
Loading
Loading
+37 −5
Original line number Diff line number Diff line
@@ -12,15 +12,17 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.

| Lot | Contenu | État |
|-----|---------|------|
| **L0** | Socle, conteneurisation, compte root, migrations, sondes | 🟡 en cours — Docker, migrations et modèle root faits, authentification à venir |
| **L0** | Socle, conteneurisation, compte root, authentification | 🟢 fait — reste la console d'administration (L10) |
| **L1** | Noyau de persistance, main courante, rembobinage | 🟡 en cours — noyau et lecture à date faits, filtres et export PDF à venir |
| L2 | Dossier de préparation (annuaire, tiers, fiches réflexes) | ⚪ |
| L3+ | Décisions, chat, GED, visio, tableau blanc, mobilisation | ⚪ |

Ce qui fonctionne aujourd'hui : le journal inviolable, le chaînage d'empreintes,
le vérificateur indépendant, la reconstitution d'un état passé par rejeu, la
main courante avec rectification sans effacement, le compte root avec son
journal d'administration et le bris de glace, et la pile Docker complète.
Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
par rejeu, la main courante avec rectification sans effacement, le compte root
avec son journal d'administration et le bris de glace, l'authentification
complète (mot de passe, lien à usage unique, TOTP, codes de secours, sessions,
invitations, multi-organisation), et la pile Docker.

---

@@ -69,7 +71,13 @@ apps/api/src/
    rejeu.ts         lecture à date : le moteur du rembobinage
    integrite.ts     chaînage, export, vérificateur indépendant
    administration.ts  compte root, journal d'administration, bris de glace
  auth/           authentification — aucune fédération, par conception
    mots_de_passe.ts / jetons.ts / totp.ts / codes_secours.ts
    sessions.ts      deux durées : inactivité 12 h, absolue 7 jours
    comptes.ts       inscription, connexion, second facteur, invitations
    limites.ts       limitation de débit, en base pour survivre au redémarrage
  domaine/        opérations métier, qui n'écrivent que via le noyau
  verification/   suites de contrôle, une par domaine
  db/migrations/  le schéma fait autorité, y compris sur les garanties
packages/shared/  catalogue des événements, partagé avec le web et le mobile
docker/           Dockerfile unique et composition mono-hôte
@@ -90,6 +98,30 @@ C'est le seul défaut de ce produit qui ne se rattrape pas après coup.
séquence donnée (`etatALaSeq`) en même temps que sa lecture au temps présent.
C'est un critère de recette, pas une intention.

## Authentification

**Aucune fédération avec l'annuaire du client** (EF-106). Ce n'est pas une
lacune, c'est le produit : un SSO adossé à l'Active Directory qu'on soupçonne
compromis ne vaudrait rien le jour où il faut s'en servir.

- Connexion par mot de passe **ou** par lien à usage unique, au choix à chaque
  fois. Aucune des deux voies ne dispense du second facteur.
- TOTP uniquement, jamais de SMS : le SMS n'est pas un facteur mais un canal,
  interceptable et indisponible précisément quand l'outil doit servir. Il reste
  réservé à la mobilisation, où sa faiblesse est sans conséquence.
- Codes de secours imprimables — la « carte de crise ». 80 bits d'entropie,
  alphabet sans caractères ambigus, parce qu'ils se lisent sur papier, la nuit.
- Sessions : 12 h d'inactivité, 7 jours en absolu, révocables à distance depuis
  n'importe quel appareil.
- Aucune réponse ne révèle si une adresse est connue. La liste des membres
  d'une cellule de crise est un renseignement, pas un détail.

Mots de passe hachés avec scrypt plutôt qu'Argon2id : Node l'intègre, donc
l'image se construit sans dépendance native sur amd64 comme sur arm64. La
promesse « cloner et composer suffit » est une exigence produit. Le format
stocke ses paramètres, une migration reste possible sans invalider les
empreintes.

## Administration de l'instance

Un compte **root** administre l'instance : organisations, comptes, quotas,
+1 −0
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@
    "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\""
  },
  "dependencies": {
    "@fastify/cookie": "^11.1.2",
    "@kastell/shared": "workspace:*",
    "fastify": "^5.2.0",
    "postgres": "^3.4.5",
+49 −0
Original line number Diff line number Diff line
/**
 * Adaptateur d'envoi — courriel aujourd'hui, notification et SMS au lot L8.
 *
 * Comme pour `media/` et `board/`, le reste de l'application ne connaît que
 * cette interface : changer de fournisseur revient à écrire un transport.
 */

export interface Message {
  destinataire: string;
  sujet: string;
  texte: string;
}

export interface Transport {
  readonly nom: string;
  envoyer(message: Message): Promise<void>;
}

/**
 * Transport de développement : le message est écrit dans les traces plutôt
 * qu'envoyé. Le lien de connexion apparaît en clair dans la console, ce qui
 * permet de dérouler tout le parcours sans serveur de messagerie.
 */
export const transportConsole: Transport = {
  nom: "console",
  async envoyer(message) {
    console.log(
      `\n┌─ courriel (non envoyé, transport « console »)\n` +
      `│ à      : ${message.destinataire}\n` +
      `│ sujet  : ${message.sujet}\n` +
      `${message.texte.split("\n").map((l) => `│ ${l}`).join("\n")}\n` +
      `└─\n`,
    );
  },
};

let transport: Transport = transportConsole;

export function definirTransport(t: Transport): void {
  transport = t;
}

export function transportActuel(): Transport {
  return transport;
}

export async function envoyer(message: Message): Promise<void> {
  await transport.envoyer(message);
}
+45 −0
Original line number Diff line number Diff line
import { createHash, randomInt } from "node:crypto";

/**
 * Codes de secours — la « carte de crise » (EF-105).
 *
 * Le scénario visé est précis : le téléphone qui portait le second facteur est
 * dans le sac resté au bureau, ou son contenu a été chiffré avec le reste. Le
 * seul recours qui tienne est une feuille de papier rangée ailleurs.
 *
 * Alphabet sans caractères ambigus : ces codes se lisent sur du papier, la
 * nuit, sous pression. Confondre O et 0 ne doit pas être possible.
 */

const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ni I, ni O, ni 0, ni 1
const GROUPES = 4;
const PAR_GROUPE = 4;
export const NOMBRE = 10;

/** 16 caractères sur 32 possibles = 80 bits : hors de portée d'une attaque hors ligne. */
export function engendrer(): string {
  const chars = Array.from(
    { length: GROUPES * PAR_GROUPE },
    () => ALPHABET[randomInt(ALPHABET.length)]!,
  );
  return Array.from({ length: GROUPES }, (_, g) =>
    chars.slice(g * PAR_GROUPE, (g + 1) * PAR_GROUPE).join("")).join("-");
}

export function engendrerLot(nombre = NOMBRE): string[] {
  return Array.from({ length: nombre }, () => engendrer());
}

/**
 * Tirets et espaces ignorés, casse indifférente. Aucune substitution de
 * caractères ambigus : ils sont absents de l'alphabet, donc un O ou un 1 saisi
 * est une erreur de lecture sans interprétation correcte possible.
 */
export function normaliser(code: string): string {
  return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
}

/** 80 bits d'entropie : SHA-256 suffit, une dérivation coûteuse n'apporterait rien. */
export function empreinte(code: string): Buffer {
  return createHash("sha256").update(normaliser(code), "utf8").digest();
}
+552 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading