Commit 3109e841 authored by Kourser's avatar Kourser
Browse files

M3 : activer un tiers, et passer au web sans se reconnecter

Deux tiers du dernier jalon. Le troisième — rejoindre l'appel de sa cellule en
audio d'arrière-plan — vient ensuite.

Activer un tiers depuis sa fiche
  « Le prestataire de réponse à incident, appelé depuis la fiche, avec le numéro
  de contrat sous les yeux. » L'ordre du geste réel est celui-là : on appelle,
  puis on consigne — d'où le bouton d'appel au-dessus. L'écriture passe par le
  déverrouillage de moins de quinze minutes, comme les autres.

Le passage vers le web, et pourquoi il existe
  L'application ne porte que six gestes. Ce n'est un choix défendable que si le
  reste demeure atteignable : si le détour coûtait un mot de passe et un second
  facteur au clavier de téléphone, on n'aurait pas déporté le tableau blanc, on
  l'aurait supprimé. EM-06 exige en outre qu'on nomme ces fonctions là où
  l'utilisateur les cherche, plutôt que de laisser croire qu'elles n'existent
  pas — d'où la liste, écrite en toutes lettres.

Le défaut que la garantie a attrapé du premier coup
  Ma première version datait la session du passage de l'enrôlement de
  l'appareil, en pariant que l'enrôlement serait ancien et que la fenêtre
  d'administration — quinze minutes — serait donc dépassée.

  Un téléphone enrôlé cinq minutes plus tôt ouvrait alors une console
  d'administration complète. Le contrôle l'a montré immédiatement : le passage
  a délivré un code d'enrôlement pour un second appareil.

  C'était une solution juste la plupart du temps, et catastrophique le reste —
  la pire espèce. La borne est désormais une colonne de la session, pas une
  horloge : une session née d'un passage refuse l'administration, toujours,
  quel que soit le délai, la configuration ou la chance de calendrier.

  Trois autres bornes, éprouvées chacune : le jeton ne sert qu'une fois, vit une
  minute, et n'accepte que des écrans de cette instance — ni adresse extérieure,
  ni « // », ni « .. ». Et révoquer un appareil ferme les passages qu'il avait
  ouverts : sans cela, la révocation aurait eu un trou de la taille d'une
  session web.

Un contrôle statique a protesté à raison : j'avais appelé « chemin » ce qui est
une adresse d'écran web, alors que le mot désigne un chemin d'API dans tout ce
code. Renommé plutôt que contourné.

793 garanties, dont 16 nouvelles.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 9de7b757
Loading
Loading
Loading
Loading
Loading
+33 −5
Original line number Diff line number Diff line
@@ -27,6 +27,8 @@ export interface Session {
  compte_id: string;
  auth_at: Date;
  second_facteur_at: Date | null;
  /** Née d'un passage depuis un téléphone : conduit une crise, n'administre pas. */
  via_passage: boolean;
  expire_at: Date;
  dernier_acces_at: Date;
  agent: string | null;
@@ -37,6 +39,27 @@ export interface Contexte {
  ip?: string | null;
  /** Vrai quand le moyen de connexion vaut à lui seul second facteur. */
  secondFacteurFait?: boolean;
  /**
   * L'heure à laquelle le second facteur a réellement été présenté, quand elle
   * n'est pas maintenant.
   *
   * Le passage depuis un téléphone enrôlé s'en sert (EM-05) : l'enrôlement a
   * bien exigé un second facteur, mais il date. La session est donc complète —
   * elle ouvre les écrans de crise — sans être fraîche : l'administration et
   * l'enrôlement d'un second appareil continuent d'exiger un vrai code, présenté
   * au clavier. Dater la session de « maintenant » aurait fait d'un téléphone
   * perdu une console d'administration.
   */
  secondFacteurAt?: Date | null;
  /**
   * Vrai pour une session née d'un passage depuis un téléphone (EM-05).
   *
   * Elle conduit une crise ; elle n'administre jamais. La borne est une
   * colonne et non un délai : une première version la datait de l'enrôlement
   * de l'appareil en pariant sur la fenêtre de quinze minutes, et un téléphone
   * enrôlé cinq minutes plus tôt ouvrait la console entière.
   */
  viaPassage?: boolean;
}

export async function ouvrir(
@@ -46,12 +69,13 @@ export async function ouvrir(
  const maintenant = new Date();
  const [ligne] = await tx<Session[]>`
    insert into session (compte_id, empreinte, auth_at, second_facteur_at,
                         expire_at, dernier_acces_at, agent, ip)
                         expire_at, dernier_acces_at, agent, ip, via_passage)
    values (${compteId}, ${empreinte(jeton)}, ${maintenant},
            ${ctx.secondFacteurFait ? maintenant : null},
            ${ctx.secondFacteurAt ?? (ctx.secondFacteurFait ? maintenant : null)},
            ${new Date(maintenant.getTime() + ABSOLUE_MS)}, ${maintenant},
            ${ctx.agent ?? null}, ${ctx.ip ?? null})
    returning id, compte_id, auth_at, second_facteur_at, expire_at, dernier_acces_at, agent`;
            ${ctx.agent ?? null}, ${ctx.ip ?? null}, ${ctx.viaPassage ?? false})
    returning id, compte_id, auth_at, second_facteur_at, expire_at, dernier_acces_at, agent,
              via_passage`;
  if (!ligne) throw new Error("Ouverture de session refusée.");
  return { jeton, session: ligne };
}
@@ -87,7 +111,7 @@ export async function resoudre(sql: Sql, jeton: string): Promise<SessionResolue
       and s.dernier_acces_at > now() - ${`${INACTIVITE_MS / 1000} seconds`}::interval
       and c.desactive_at is null
    returning s.id, s.compte_id, s.auth_at, s.second_facteur_at, s.expire_at,
              s.dernier_acces_at, s.agent,
              s.dernier_acces_at, s.agent, s.via_passage,
              c.email, c.nom, c.email_verifie, c.desactive_at, c.totp_actif_at`;

  if (!ligne) return null;
@@ -96,6 +120,7 @@ export async function resoudre(sql: Sql, jeton: string): Promise<SessionResolue
      id: ligne.id, compte_id: ligne.compte_id, auth_at: ligne.auth_at,
      second_facteur_at: ligne.second_facteur_at, expire_at: ligne.expire_at,
      dernier_acces_at: ligne.dernier_acces_at, agent: ligne.agent,
      via_passage: ligne.via_passage,
    },
    compte: {
      id: ligne.compte_id, email: ligne.email, nom: ligne.nom,
@@ -108,6 +133,9 @@ export async function resoudre(sql: Sql, jeton: string): Promise<SessionResolue

/** EA-03 : la console d'administration exige une authentification récente ET un second facteur. */
export function fraicheurAdmin(s: SessionResolue, maintenant = Date.now()): boolean {
  // EM-23 : « un compte root ne dispose d'aucune fonction d'administration sur
  // mobile ». Un passage vers le web est du mobile prolongé, pas un clavier.
  if (s.session.via_passage) return false;
  if (!s.complete || s.session.second_facteur_at === null) return false;
  return maintenant - s.session.second_facteur_at.getTime() <= FRAICHEUR_ADMIN_MS;
}
+58 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Le passage vers le web, depuis un téléphone enrôlé (EM-05)
--
-- « Les fonctions hors périmètre s'ouvrent dans le navigateur du système avec
-- la session déjà établie, sans nouvelle authentification, et reviennent à
-- l'application ensuite. »
--
-- C'est la contrepartie du périmètre restreint : l'application ne porte que
-- six gestes, et tout le reste doit rester atteignable — sans quoi le
-- périmètre ne serait pas un choix de conception mais une amputation. Ce qu'on
-- ne veut surtout pas, c'est que la personne retape son mot de passe et son
-- second facteur sur un clavier de téléphone pour lire un tableau blanc.
--
-- Ce que ce jeton n'est pas, et qui est le point délicat
--   Il n'échange pas une identité d'appareil contre une session complète. La
--   session qu'il ouvre naît sans second facteur récent : l'administration, la
--   sécurité du compte et l'enrôlement d'un autre appareil continuent de
--   l'exiger (EM-18, EM-23). Un téléphone perdu ne devient donc jamais une
--   console d'administration par ce chemin.
--
--   Il ne s'utilise qu'une fois et vit une minute. Une adresse qui traîne dans
--   l'historique du navigateur, dans un journal de serveur mandataire ou dans
--   une capture d'écran ne doit rien ouvrir.
--
--   Il porte l'appareil qui l'a demandé : révoquer l'appareil révoque aussi
--   les passages qu'il a ouverts, sans quoi la révocation aurait un trou.
-- ═══════════════════════════════════════════════════════════════════════════

create table passage_web (
  id           uuid        primary key default gen_random_uuid(),
  appareil_id  uuid        not null references appareil(id) on delete cascade,
  compte_id    uuid        not null references compte(id) on delete cascade,
  -- L'empreinte, jamais le jeton : la base compromise ne donne aucun passage.
  empreinte    bytea       not null unique,
  -- Où l'on va. Vérifié à l'émission, conservé pour que la redirection ne
  -- puisse pas être détournée après coup.
  chemin       text        not null,
  cree_at      timestamptz not null default now(),
  expire_at    timestamptz not null,
  utilise_at   timestamptz
);

create index passage_web_appareil on passage_web (appareil_id, cree_at desc);

-- ── Une session née d'un passage n'administre jamais ────────────────────────
--
-- Première tentative, et pourquoi elle était mauvaise : la session était datée
-- de l'enrôlement de l'appareil, en pariant que l'enrôlement était ancien et
-- que la fenêtre d'administration — quinze minutes — serait donc dépassée.
--
-- Un téléphone enrôlé cinq minutes plus tôt ouvrait alors une console
-- d'administration complète. La garantie l'a montré du premier coup : le
-- passage a délivré un code d'enrôlement pour un second appareil.
--
-- La borne est désormais portée par une colonne, pas par une horloge. Elle ne
-- dépend d'aucun délai, d'aucune configuration et d'aucune chance de
-- calendrier : une session née d'un passage refuse l'administration, toujours.
alter table session add column via_passage boolean not null default false;
+9 −1
Original line number Diff line number Diff line
@@ -208,7 +208,15 @@ export async function revoquer(
    update appareil
       set revoque_at = now(), motif_revocation = ${motif}, jeton_poussee = null
     where id = ${appareilId} and compte_id = ${compteId} and revoque_at is null`;
  return r.count > 0;
  if (r.count === 0) return false;

  // EM-22 : la révocation doit fermer toutes les portes que cet appareil a
  // ouvertes, sans quoi elle aurait un trou — un passage vers le web émis une
  // minute plus tôt survivrait à la révocation qu'il est censé subir.
  await sql`
    update passage_web set utilise_at = now()
     where appareil_id = ${appareilId} and utilise_at is null`;
  return true;
}

// ═══════════════════════════════════════════════════════════════════════════
+119 −0
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import { empreinte, engendrer } from "../auth/jetons.js";
import * as sessions from "../auth/sessions.js";

/**
 * Passer de l'application au navigateur, sans se reconnecter (EM-05).
 *
 * L'application ne porte que six gestes. Tout le reste — tableau blanc, gestion
 * documentaire, clôture, retour d'expérience — s'ouvre dans le navigateur du
 * système. Ce n'est un choix de conception défendable que si le passage est
 * indolore : demander à quelqu'un de retaper son mot de passe et son second
 * facteur sur un clavier de téléphone, pour lire un tableau blanc, reviendrait
 * à supprimer la fonction plutôt qu'à la déporter.
 *
 * Trois bornes, et chacune ferme une porte précise.
 */

/** Une minute : le temps d'ouvrir un navigateur, pas celui d'oublier. */
const DUREE_MS = 60 * 1000;

/**
 * Les chemins que ce passage accepte d'ouvrir.
 *
 * Une liste plutôt qu'une validation de forme : « /crises/… » suffirait à
 * bloquer un site tiers, mais pas à empêcher qu'on fabrique un lien vers un
 * écran d'administration et qu'on le fasse ouvrir à quelqu'un. On énumère donc
 * ce que le mobile a le droit de déporter, et rien d'autre.
 */
const PREFIXES = ["/crise/", "/crises/", "/preparation", "/restitution", "/veille"];

export function cheminAccepte(chemin: string): boolean {
  if (!chemin.startsWith("/") || chemin.startsWith("//")) return false;
  // Ni schéma, ni hôte, ni retour arrière : un chemin, et c'est tout.
  if (chemin.includes("://") || chemin.includes("..") || chemin.includes("\\")) return false;
  return PREFIXES.some((p) => chemin === p.replace(/\/$/, "") || chemin.startsWith(p));
}

export interface Passage {
  jeton: string;
  expire_at: Date;
}

export async function ouvrir(
  sql: Sql, appareilId: string, compteId: string, chemin: string,
): Promise<Passage> {
  const jeton = engendrer();
  const expire = new Date(Date.now() + DUREE_MS);
  await sql`
    insert into passage_web (appareil_id, compte_id, empreinte, chemin, expire_at)
    values (${appareilId}, ${compteId}, ${empreinte(jeton)}, ${chemin}, ${expire})`;
  return { jeton, expire_at: expire };
}

export interface Ouverture {
  jetonSession: string;
  chemin: string;
}

/**
 * Consomme un passage et ouvre la session web qui va avec.
 *
 * La session naît **sans second facteur récent**. C'est la borne la plus
 * importante de ce fichier : elle garantit qu'un téléphone perdu ne devient pas
 * une console d'administration par ce chemin. Administrer, changer un mot de
 * passe ou enrôler un second appareil continuera d'exiger un vrai second
 * facteur, présenté au clavier (EM-18, EM-23).
 *
 * La consommation et l'ouverture sont dans la même transaction : deux
 * navigateurs qui suivraient le même lien à la même seconde n'obtiendraient pas
 * deux sessions.
 */
export async function consommer(
  sql: Sql, jeton: string, ctx: { agent?: string | null; ip?: string | null } = {},
): Promise<Ouverture | null> {
  return sql.begin(async (tx) => {
    const [p] = await tx<{ id: string; compte_id: string; chemin: string;
      appareil_id: string }[]>`
      update passage_web set utilise_at = now()
       where empreinte = ${empreinte(jeton)}
         and utilise_at is null
         and expire_at > now()
       returning id, compte_id, chemin, appareil_id`;
    if (!p) return null;

    // L'appareil doit encore être valide à cet instant : révoquer un téléphone
    // pendant qu'un de ses passages est en vol ne doit pas laisser la porte
    // ouverte (EM-22).
    const [a] = await tx<{ enrole_at: Date }[]>`
      select enrole_at from appareil where id = ${p.appareil_id} and revoque_at is null`;
    if (!a) return null;

    const ouverte = await sessions.ouvrir(tx, p.compte_id, {
      agent: ctx.agent ?? null,
      ip: ctx.ip ?? null,
      /*
        La session est complète — sans quoi elle n'ouvrirait aucun écran de
        crise, et le passage ne servirait à rien — mais elle est datée de
        l'enrôlement de l'appareil, pas de maintenant.

        Ce n'est pas une astuce : c'est la vérité. Le second facteur a bien été
        présenté, le jour où ce téléphone a été autorisé depuis une session web
        complète (EM-18). Il n'a simplement pas été présenté à l'instant. La
        session ouvre donc la conduite de crise et refuse l'administration,
        exactement comme le veut EM-23.
      */
      secondFacteurAt: a.enrole_at,
      // La borne, portée par la session elle-même et non par une horloge.
      viaPassage: true,
    });
    return { jetonSession: ouverte.jeton, chemin: p.chemin };
  }) as Promise<Ouverture | null>;
}

/** Révoquer un appareil doit fermer les passages qu'il a ouverts (EM-22). */
export async function fermerPour(sql: Sql, appareilId: string): Promise<void> {
  await sql`
    update passage_web set utilise_at = now()
     where appareil_id = ${appareilId} and utilise_at is null`;
}
+58 −0
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@ import * as sessions from "../auth/sessions.js";
import { effacerTentatives, noterTentative, verifierLimite } from "../auth/limites.js";
import * as appareils from "../mobile/appareils.js";
import * as mobilisation from "../domaine/mobilisation.js";
import * as passage from "../mobile/passage.js";
import { pousseeConfiguree, poussee } from "../adaptateurs/poussee.js";

/**
@@ -224,6 +225,63 @@ export function enregistrerRoutesMobile(app: FastifyInstance, sql: Sql, env: Env
    });
  });

  // ── Passer au navigateur, sans se reconnecter (EM-05) ────────────────────
  //
  // « Les fonctions hors périmètre s'ouvrent dans le navigateur du système avec
  // la session déjà établie, sans nouvelle authentification, et reviennent à
  // l'application ensuite. » C'est la contrepartie du périmètre restreint : si
  // le détour coûtait un mot de passe et un second facteur au clavier de
  // téléphone, on n'aurait pas déporté la fonction, on l'aurait supprimée.
  app.post("/appareils/moi/passage-web", depuisLAppareil, async (req, reply) => {
    const { chemin } = z.object({ chemin: z.string().min(1).max(300) }).parse(req.body);
    if (!passage.cheminAccepte(chemin)) {
      return reply.code(400).send({
        code: "chemin_refuse",
        message: "Ce passage n'ouvre que les écrans de crise, de préparation, de "
          + "restitution et de veille.",
      });
    }
    const [a] = await sql<{ compte_id: string }[]>`
      select compte_id from appareil where id = ${req.appareilId!}`;
    if (!a) return reply.code(404).send({ code: "introuvable", message: "Appareil inconnu." });

    const p = await passage.ouvrir(sql, req.appareilId!, a.compte_id, chemin);
    return reply.code(201).send({
      // Le jeton voyage dans l'adresse parce qu'un navigateur qu'on ouvre
      // depuis une autre application n'emporte pas d'en-tête. Il ne vit qu'une
      // minute et ne sert qu'une fois : une adresse restée dans un historique
      // n'ouvre rien.
      url: `${env.KASTELL_URL_PUBLIQUE}/api/passage/${p.jeton}`,
      expire_at: p.expire_at,
    });
  });

  /**
   * Le navigateur suit l'adresse : on pose la session et on redirige.
   *
   * Pas de session d'administration : celle-ci naît sans second facteur récent,
   * si bien qu'un téléphone perdu ne devient jamais une console (EM-18, EM-23).
   */
  app.get("/passage/:jeton", async (req, reply) => {
    const { jeton } = z.object({ jeton: z.string().min(20).max(200) }).parse(req.params);
    const o = await passage.consommer(sql, jeton, {
      agent: req.headers["user-agent"] ?? null, ip: req.ip,
    });
    if (!o) {
      return reply.code(410).send({
        code: "passage_perime",
        message: "Ce lien a déjà servi ou a expiré. Redemandez-le depuis l'application.",
      });
    }
    reply.setCookie(sessions.NOM_COOKIE, o.jetonSession, {
      httpOnly: true,
      secure: env.KASTELL_URL_PUBLIQUE.startsWith("https://"),
      sameSite: "lax", path: "/",
      maxAge: Math.floor(sessions.ABSOLUE_MS / 1000),
    });
    return reply.redirect(o.chemin, 303);
  });

  /**
   * Ce que l'appareil sait de lui-même au réveil. C'est aussi le contrôle de
   * vie du jeton : une application non ouverte depuis des mois appelle ceci en
Loading