Commit 490d70a0 authored by Kourser's avatar Kourser
Browse files

Une adresse suffit : l'instance conduit son premier administrateur

Installer Kastell laissait l'exploitant devant trois gestes qu'il fallait
connaître, dans un ordre qu'on ne pouvait pas deviner : créer un compte au
navigateur, y activer un second facteur, puis lancer une commande d'amorçage.
Le script demande maintenant une adresse, et l'instance fait le reste.

Ce qu'elle remet dépend de ce qu'elle sait faire
  Un relais de courriel configuré : un lien de 24 heures, qui mène à poser un
  mot de passe puis un second facteur. Aucun relais : un mot de passe
  temporaire affiché dans le terminal. Le second est moins bon — il passe par
  l'épaule de qui regarde l'écran — mais c'est ce qui reste quand rien ne peut
  sortir, et le taire aurait laissé l'exploitant sans porte.

  Le mot de passe SMTP se saisit désormais à l'invite, sans écho. La règle qui
  l'en écartait reposait sur une prémisse fausse — une saisie par « read »
  n'entre pas dans l'historique du shell — et coûtait cher : il fallait ouvrir
  le .env et relancer la pile pour un secret qu'on avait sous les yeux.

Le parcours ne se contourne pas
  Tant que le laissez-passer n'est pas remplacé, ou que le second facteur
  manque, le compte ne peut rien faire d'autre que régler ces deux points.

  Le garde vit dans serveur.ts, en un seul endroit. Chacun des quatorze
  fichiers de routes réimplémente sa propre exigence de session ; y ajouter la
  vérification quatorze fois, c'est la promettre treize fois et l'oublier une.
  L'écran conduit, il ne garde pas.

Le droit root arrive au bout, et de lui-même
  La demande était qu'il soit accordé tout de suite. Il ne l'est pas, et c'est
  la seule chose que je n'ai pas faite telle quelle : la migration 0005 défend
  par un déclencheur en base qu'aucune ligne root n'existe sans second facteur
  — un droit qu'on ne peut pas exercer et dont on croira disposer le jour où
  il faudra s'en servir. Elle avait même révoqué rétroactivement les droits
  dans ce cas.

  L'amorce marque donc le compte comme destiné à root, et le droit lui vient à
  l'activation du second facteur, sans commande. Pour qui installe, la
  différence ne se voit pas : il donne une adresse et ressort administrateur.
  Pour la base, elle est entière.

Une commande, jamais une route
  Ce geste fabrique le premier administrateur. L'exposer en HTTP le mettrait à
  la portée de quiconque trouve une instance fraîche avant son exploitant — et
  une instance fraîche n'a personne pour la défendre. dist/amorce.js voyage
  dans l'image, comme dist/racine.js : l'accès à la machine reste la preuve.

Contrôlé — 18 garanties
  Qu'une session au parcours inachevé reçoive 403 sur une route ordinaire et
  200 sur celles qui servent à l'achever. Que le laissez-passer cesse d'ouvrir
  quoi que ce soit une fois remplacé. Que le compte ne soit pas root avant le
  second facteur, et le soit après, sans commande. Qu'une seconde amorce soit
  refusée. Que le lien parte, ouvre une session, et exige quand même qu'on
  pose un mot de passe — sans en réclamer un ancien que personne n'a reçu.

Deux défauts trouvés en éprouvant : la longueur minimale du mot de passe ne
vivait que dans le schéma de la route, donc pas pour qui appelle la fonction ;
et mon premier contrôle du garde visait une route inexistante, dont le 404
passait pour un refus — un preHandler ne s'exécute pas sur un chemin
introuvable.

938 garanties au total, aucune en échec.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 2514bff5
Loading
Loading
Loading
Loading
Loading
+2 −1
Changes for apps/api/package.json: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -8,7 +8,8 @@
    "dev": "tsx --env-file=../../.env watch src/index.ts",
    "migrate": "tsx --env-file=../../.env src/db/migrate.ts",
    "verif": "tsx --env-file=../../.env src/verif.ts",
    "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);\" && esbuild src/racine.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/racine.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\""
    "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);\" && esbuild src/racine.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/racine.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\" && esbuild src/amorce-cli.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/amorce.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\"",
    "amorcer": "node --env-file=../../.env --import tsx src/amorce-cli.ts"
  },
  "dependencies": {
    "@aws-sdk/client-s3": "^3.1116.0",
+77 −0
Changes for apps/api/src/amorce-cli.ts: 77 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { transportActuel } from "./adaptateurs/notify.js";
import { appliquerReglages } from "./administration/reglages.js";
import { AmorceRefusee, amorcerInstance } from "./auth/amorce.js";
import { ouvrirBase } from "./db/client.js";
import { chargerEnv } from "./env.js";

/**
 * Amorçage guidé du premier compte (EA-02).
 *
 *   node dist/amorce.js <adresse> "<nom>" "<organisation>"
 *
 * Pourquoi une commande, et non une route
 *   Ce geste fabrique le premier administrateur d'une instance. L'exposer en
 *   HTTP le mettrait à la portée de quiconque trouve une instance fraîche avant
 *   son exploitant — et une instance fraîche est, par construction, sans
 *   personne pour la défendre. La commande exige un accès à la machine, ce qui
 *   est exactement la preuve qu'on veut ici.
 *
 * Ce qu'elle remet, selon ce que l'instance sait faire
 *   Un relais de courriel configuré : un lien, valable 24 heures, qui conduit
 *   la personne à poser son mot de passe puis son second facteur.
 *
 *   Aucun relais : un mot de passe temporaire, affiché ici, à remplacer à la
 *   première connexion. C'est moins bien — il passe par l'épaule de qui regarde
 *   l'écran — mais c'est la seule chose qui reste quand rien ne peut sortir.
 *
 * Dans les deux cas le droit root n'est pas accordé maintenant : il l'est au
 * bout du parcours, quand le second facteur est actif. Un droit qu'on ne peut
 * pas exercer n'est pas un droit, c'est un malentendu qui attend son jour.
 */

const [email, nom = "Administrateur", organisation = "Mon organisation"] = process.argv.slice(2);

if (!email) {
  console.error('Usage : node dist/amorce.js <adresse> "<nom>" "<organisation>"');
  process.exit(2);
}

const env = chargerEnv();
const sql = ouvrirBase(env.DATABASE_URL);

try {
  // Les surcharges de la console comptent aussi ici : un relais SMTP posé
  // depuis la page de configuration doit servir à ce courriel-là comme aux
  // autres.
  await appliquerReglages(sql, env);
  const transportUtile = transportActuel().nom !== "console";

  const r = await amorcerInstance(sql, {
    email, nom, nomOrganisation: organisation, urlPublique: env.KASTELL_PUBLIC_URL,
  }, transportUtile);

  console.log(`\n✓ Compte créé pour ${email}, destiné au droit d'administration.\n`);

  if (r.parCourriel) {
    console.log("  Un lien vient de partir à cette adresse. Il est valable 24 heures et");
    console.log("  conduit à poser un mot de passe, puis un second facteur.\n");
  } else {
    console.log("  Aucun relais de courriel n'est configuré : voici un mot de passe");
    console.log("  temporaire, à remplacer dès la première connexion.\n");
    console.log(`      ${r.motDePasseTemporaire}\n`);
    console.log("  Il ne protège rien dans la durée. Connectez-vous maintenant.\n");
  }

  console.log(`  ${env.KASTELL_PUBLIC_URL}\n`);
  console.log("  Le droit d'administration sera accordé au bout du parcours, quand le");
  console.log("  second facteur sera actif — pas avant, pour qu'il ne soit jamais un");
  console.log("  droit inexerçable.\n");
} catch (e) {
  if (e instanceof AmorceRefusee) {
    console.error(`✗ ${e.message}`);
    process.exit(1);
  }
  throw e;
} finally {
  await sql.end({ timeout: 5 });
}
+214 −0
Changes for apps/api/src/auth/amorce.ts: 214 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { randomBytes } from "node:crypto";
import { envoyer } from "../adaptateurs/notify.js";
import type { Sql, Tx } from "../db/client.js";
import { amorcerRoot, journaliser } from "../noyau/administration.js";
import * as comptes from "./comptes.js";
import { empreinte, engendrer } from "./jetons.js";
import * as mdp from "./mots_de_passe.js";

/**
 * Amorçage guidé du premier compte root.
 *
 * L'installateur créait un compte, puis renvoyait l'exploitant activer un
 * second facteur et lancer l'amorçage à la main. Deux gestes qu'il fallait
 * connaître, dans un ordre qu'on ne pouvait pas deviner, sur une instance
 * qu'on venait tout juste de lever.
 *
 * Le parcours s'inverse : on donne une adresse, et l'instance conduit le reste.
 *
 * Ce que l'amorce ne fait pas
 *   Accorder root tout de suite. Un droit root sans second facteur est un droit
 *   qu'on ne peut pas exercer, et dont on croira disposer le jour où il faudra
 *   s'en servir — c'est la raison d'être du déclencheur posé en 0005, qui avait
 *   même révoqué rétroactivement les droits dans ce cas.
 *
 *   Le compte est donc marqué « destiné à root », et le droit lui vient de
 *   lui-même dès que son second facteur est actif. Pour qui installe, la
 *   différence ne se voit pas : il donne une adresse et ressort root. Pour la
 *   base, elle est entière : aucune ligne root n'existe sans 2FA utilisable.
 */

/** Le lien d'amorçage vit le temps d'une installation, pas d'une semaine. */
export const TTL_AMORCE_MS = 24 * 60 * 60 * 1000;

export interface DemandeAmorce {
  email: string;
  nom: string;
  nomOrganisation: string;
  urlPublique: string;
  motif?: string;
}

export interface ResultatAmorce {
  compteId: string;
  organisationId: string;
  /** Remis en clair à l'installateur quand aucun relais ne peut l'acheminer. */
  motDePasseTemporaire?: string;
  /** Vrai si un courriel est parti plutôt qu'un mot de passe affiché. */
  parCourriel: boolean;
}

/**
 * Un laissez-passer, pas un mot de passe : lisible à voix haute, sans
 * ambiguïté de caractères, et destiné à être remplacé à la première connexion.
 * Il ne protège rien dans la durée — sa durée est censée être de quelques
 * minutes, celles qui séparent la fin de l'installation de la première
 * connexion.
 */
export function motDePasseTemporaire(): string {
  const alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
  const octets = randomBytes(20);
  let sortie = "";
  for (let i = 0; i < 20; i += 1) {
    if (i > 0 && i % 5 === 0) sortie += "-";
    sortie += alphabet[octets[i]! % alphabet.length];
  }
  return sortie;
}

export async function rootExiste(sql: Sql | Tx): Promise<boolean> {
  const [r] = await sql`select 1 from root_actif limit 1`;
  return Boolean(r);
}

export class AmorceRefusee extends Error {
  constructor(message: string) {
    super(message);
    this.name = "AmorceRefusee";
  }
}

/**
 * Crée le compte, l'organisation, et marque le compte pour root.
 *
 * Le compte naît du parcours normal — `inscrire` — avec les mêmes contrôles et
 * le même journal. Rien ici ne crée un compte d'une autre espèce ; seule
 * s'ajoute la marque qui lui vaudra root une fois son second facteur posé.
 */
export async function amorcerInstance(
  sql: Sql, d: DemandeAmorce, transportUtile: boolean,
): Promise<ResultatAmorce> {
  if (await rootExiste(sql)) {
    throw new AmorceRefusee(
      "Cette instance a déjà un root. L'octroi passe désormais par la console, "
      + "pour laisser une trace nominative.",
    );
  }

  // Sans relais, le lien n'atteindrait personne : on remet alors un
  // laissez-passer que l'installateur affiche, et qui devra être remplacé.
  const temporaire = transportUtile ? null : motDePasseTemporaire();
  const motDePasse = temporaire ?? motDePasseTemporaire();

  const { compteId, organisationId } = await comptes.inscrire(sql, {
    email: d.email, nom: d.nom, motDePasse, nomOrganisation: d.nomOrganisation,
    urlPublique: d.urlPublique,
  });

  const motif = d.motif ?? "Amorçage de l'instance.";
  const jeton = transportUtile ? engendrer() : null;

  await sql.begin(async (tx) => {
    await tx`insert into amorce_root (compte_id, motif) values (${compteId}, ${motif})
             on conflict (compte_id) do nothing`;

    if (temporaire) {
      // Le mot de passe vient d'ailleurs : tant qu'il n'est pas remplacé, le
      // compte ne peut rien faire d'autre que le remplacer.
      await tx`update compte set mdp_a_changer = true where id = ${compteId}`;
    } else {
      // Le mot de passe tiré au sort ci-dessus n'est communiqué à personne :
      // il n'existe que pour que la colonne ne soit pas vide le temps que la
      // personne pose le sien par le lien.
      await tx`update compte set mdp_a_changer = true where id = ${compteId}`;
      // Genre « connexion » et non un genre neuf : la route qui consomme ces
      // liens existe, est éprouvée, et en écrire une seconde reviendrait à
      // doubler un chemin d'authentification pour un mot de vocabulaire.
      await tx`
        insert into jeton_usage_unique (empreinte, genre, compte_id, expire_at)
        values (${empreinte(jeton!)}, 'connexion', ${compteId},
                ${new Date(Date.now() + TTL_AMORCE_MS)})`;
    }

    await journaliser(tx, {
      type: "instance.amorce_emise", acteurId: null,
      charge: { compte_id: compteId, par_courriel: transportUtile },
    });
  });

  if (jeton) {
    await envoyer({
      destinataire: d.email,
      sujet: "Prenez la main sur votre instance Kastell",
      texte:
        `${d.urlPublique}/connexion?jeton=${jeton}\n\n`
        + `Ce lien vous fait poser votre mot de passe, puis votre second facteur.\n`
        + `Le droit d'administration vous est accordé au bout de ce parcours :\n`
        + `un administrateur sans second facteur ne pourrait pas entrer dans la\n`
        + `console, et croirait disposer d'un droit inutilisable.\n\n`
        + `Ce lien est valable 24 heures.\n\n`
        + `Préférez une adresse et un appareil indépendants du système\n`
        + `d'information de votre organisation : Kastell sert précisément les\n`
        + `situations où celui-ci n'est plus disponible ni digne de confiance.`,
    });
  }

  const r: ResultatAmorce = { compteId, organisationId, parCourriel: transportUtile };
  if (temporaire) r.motDePasseTemporaire = temporaire;
  return r;
}

/**
 * Ce qui reste à faire avant que ce compte puisse servir.
 *
 * Lu à chaque requête par le garde : c'est la seule source de vérité, et
 * l'interface n'en est que le reflet. Une vérification qui ne vivrait que dans
 * l'écran serait contournée par la première requête écrite à la main.
 */
export interface EtatAmorce {
  doitChangerMotDePasse: boolean;
  doitActiverSecondFacteur: boolean;
  /** Ce compte deviendra root au bout du parcours. */
  destineARoot: boolean;
}

export async function etatAmorce(sql: Sql | Tx, compteId: string): Promise<EtatAmorce> {
  const [c] = await sql<{ mdp_a_changer: boolean; totp_actif_at: Date | null }[]>`
    select mdp_a_changer, totp_actif_at from compte where id = ${compteId}`;
  const [a] = await sql`select 1 from amorce_root where compte_id = ${compteId}`;
  const destineARoot = Boolean(a);
  return {
    doitChangerMotDePasse: Boolean(c?.mdp_a_changer),
    // Le second facteur n'est imposé qu'aux comptes destinés à root : l'exiger
    // de tout le monde serait une autre décision, qui ne se prend pas au
    // détour d'un amorçage.
    doitActiverSecondFacteur: destineARoot && c?.totp_actif_at == null,
    destineARoot,
  };
}

/** Reste-t-il quelque chose à faire ? */
export function amorceEnCours(e: EtatAmorce): boolean {
  return e.doitChangerMotDePasse || e.doitActiverSecondFacteur;
}

/**
 * Accorde le droit root si le compte y était destiné et que tout est en place.
 *
 * Appelée après l'activation du second facteur. Silencieuse si l'amorce
 * n'existe pas, si un root est apparu entre-temps, ou si le mot de passe
 * temporaire n'a pas encore été remplacé — on ne remet pas les clés à un
 * compte qui n'a pas fini de devenir le sien.
 */
export async function accorderSiAmorce(sql: Sql, compteId: string): Promise<boolean> {
  const [a] = await sql<{ motif: string }[]>`
    select motif from amorce_root where compte_id = ${compteId}`;
  if (!a) return false;

  const e = await etatAmorce(sql, compteId);
  if (e.doitChangerMotDePasse || e.doitActiverSecondFacteur) return false;

  const accorde = await amorcerRoot(sql, compteId, a.motif);
  await sql`delete from amorce_root where compte_id = ${compteId}`;
  return accorde;
}
+45 −0
Changes for apps/api/src/auth/comptes.ts: 45 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -390,6 +390,51 @@ export async function activerTotp(
  return { codesSecours: lot };
}

/**
 * Remplacer son mot de passe.
 *
 * L'ancien est exigé même quand il est temporaire : sans cela, une session
 * volée dans la fenêtre d'installation suffirait à s'approprier le compte qui
 * va devenir root.
 */
export async function changerMotDePasse(
  sql: Sql, compteId: string, ancien: string | null, neuf: string,
): Promise<void> {
  const [compte] = await sql<{ mdp_hash: string | null; mdp_a_changer: boolean }[]>`
    select mdp_hash, mdp_a_changer from compte where id = ${compteId}`;
  if (!compte) throw new ErreurAuth("identifiants_invalides", "Compte introuvable.");
  // Défendu ici et non seulement dans le schéma de la route : une règle qui ne
  // vit qu'au bord est une règle que le prochain appelant ignorera.
  if (neuf.length < 12) {
    throw new ErreurAuth("operation_refusee", "Douze caractères au moins.");
  }

  // L'ancien mot de passe est exigé — sauf quand il n'y en a pas à connaître.
  // Un compte amorcé par lien n'en a jamais reçu : sa session vient d'un jeton
  // à usage unique envoyé à son adresse, ce qui est la même preuve qu'une
  // réinitialisation. Exiger un secret que personne ne détient n'ajouterait pas
  // de sécurité, seulement une impasse.
  if (!compte.mdp_a_changer) {
    if (ancien === null || !await mdp.verifier(ancien, compte.mdp_hash ?? null)) {
      throw new ErreurAuth("identifiants_invalides", "Mot de passe actuel incorrect.");
    }
  } else if (ancien !== null && !await mdp.verifier(ancien, compte.mdp_hash ?? null)) {
    throw new ErreurAuth("identifiants_invalides", "Mot de passe actuel incorrect.");
  }
  if (ancien !== null && neuf === ancien) {
    throw new ErreurAuth("operation_refusee", "Le nouveau mot de passe doit différer de l'ancien.");
  }
  const hash = await mdp.hacher(neuf);
  await sql.begin(async (tx) => {
    await tx`update compte set mdp_hash = ${hash}, mdp_a_changer = false
              where id = ${compteId}`;
    await journaliser(tx, {
      type: "compte.mot_de_passe_change", acteurId: compteId,
      charge: { compte_id: compteId },
    });
  });
}

export async function regenererCodesSecours(sql: Sql, compteId: string): Promise<string[]> {
  const lot = codes.engendrerLot();
  await sql.begin(async (tx) => {
+29 −0
Changes for apps/api/src/db/migrations/0028_amorce.sql: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Amorçage guidé du premier compte root (EA-02, EA-03).
--
-- Jusqu'ici l'installateur créait un compte, et il fallait ensuite activer un
-- second facteur puis lancer l'amorçage à la main. Deux gestes que l'exploitant
-- devait connaître, dans un ordre qu'il ne pouvait pas deviner.
--
-- L'amorce inverse la charge : elle marque un compte comme DESTINÉ à root, et
-- le droit lui est accordé de lui-même dès que son second facteur est actif.
--
-- Ce que cela ne change pas, et c'est délibéré : le déclencheur de la migration
-- 0005 reste en place. Aucune ligne root ne peut exister sans second facteur
-- utilisable. On ne relâche pas la garantie, on rend seulement inutile de la
-- satisfaire à la main.
create table amorce_root (
  compte_id uuid        primary key references compte(id) on delete cascade,
  motif     text        not null,
  cree_le   timestamptz not null default now()
);

comment on table amorce_root is
  'Compte destiné au premier droit root, en attente de son second facteur. La ligne disparaît quand le droit est accordé.';

-- Un mot de passe remis par un tiers — l'installateur, ici — n'est pas un mot
-- de passe : c'est un laissez-passer à usage unique. Tant qu'il n'a pas été
-- remplacé, le compte ne peut rien faire d'autre que le remplacer.
alter table compte add column mdp_a_changer boolean not null default false;

comment on column compte.mdp_a_changer is
  'Mot de passe temporaire remis à l''installation : le parcours est bloqué tant qu''il n''est pas remplacé.';
Loading