Verified Commit 97332f32 authored by Kourser's avatar Kourser
Browse files

Enrôlement mobile : le code est retrouvé par son empreinte, et compté

Le code était retrouvé par sa date — le dernier enrôlement ouvert de
l'instance, tous comptes confondus — puis comparé. Deux personnes qui sortent
leur téléphone la même minute se marchaient donc dessus : la première voyait
son code valide refusé, sans que rien ne l'explique. Une soirée de préparation
à plusieurs est le cas normal, pas le cas limite.

L'enrôlement est désormais retrouvé par l'empreinte de son code, comme une
session l'est par celle de son jeton. Le code fait 40 bits et vit dix minutes :
il n'y a pas de préfixe à deviner par la mesure du temps de réponse, et
l'égalité sur une empreinte de 256 bits n'en révèle aucun.

Le point d'échange est ouvert par nécessité — le téléphone n'a pas encore
d'identité. Il est donc compté comme les autres portes d'entrée : vingt
tentatives par quart d'heure, ce qui laisse passer une soirée d'enrôlements
depuis un même bureau.

Co-Authored-By: Claude (RCA)
Signed-off-by: default avatarJordan Grossemy <jordan.grossemy@rca.fr>
parent b821f305
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -22,6 +22,17 @@ export const LIMITES = {
  lien_email: { seuil: 4, fenetreSecondes: 900 },
  inscription_ip: { seuil: 6, fenetreSecondes: 3600 },
  invitation_organisation: { seuil: 40, fenetreSecondes: 3600 },
  /**
   * Échange d'un code d'enrôlement contre une identité d'appareil.
   *
   * Le point est nécessairement ouvert : le téléphone n'a pas encore
   * d'identité, il ne peut donc rien présenter. Le code fait 40 bits et vit dix
   * minutes — il ne se force pas — mais un point non authentifié qui interroge
   * la base sans compteur reste une invitation à la marteler. Le seuil est
   * large : enrôler plusieurs téléphones d'affilée depuis un même bureau est
   * une soirée de préparation ordinaire, pas une attaque.
   */
  enrolement_ip: { seuil: 20, fenetreSecondes: 900 },
} as const satisfies Record<string, Limite>;

export type NomLimite = keyof typeof LIMITES;
+14 −4
Original line number Diff line number Diff line
@@ -103,12 +103,22 @@ export async function inscrire(sql: Sql, i: Inscription): Promise<AppareilInscri
  const secret = engendrer(32);

  const resultat = await sql.begin(async (tx) => {
    // L'enrôlement est retrouvé *par* l'empreinte du code, comme une session
    // l'est par celle de son jeton (voir auth/sessions.ts).
    //
    // La requête cherchait auparavant le dernier enrôlement ouvert de
    // l'instance, tous comptes confondus, puis comparait. Deux personnes
    // enrôlant leur téléphone la même minute se marchaient donc dessus : la
    // première voyait son code valide refusé, sans que rien ne l'explique. Et
    // le contrôle ne portait jamais que sur une ligne, choisie par sa date.
    //
    // Le code fait 8 caractères sur un alphabet de 32, soit 40 bits, et vit dix
    // minutes : il n'y a pas de préfixe à deviner par la mesure du temps de
    // réponse, et l'égalité sur une empreinte de 256 bits n'en révèle aucun.
    const [en] = await tx<{ id: string; compte_id: string; empreinte_code: Buffer }[]>`
      select id, compte_id, empreinte_code from enrolement_appareil
       where utilise_at is null and expire_at > now()
       order by cree_at desc`;
    // La comparaison se fait à temps constant sur l'empreinte, jamais sur le
    // code : un code court se devine par mesure du temps de réponse.
       where empreinte_code = ${empreinte(i.code.trim().toUpperCase())}
         and utilise_at is null and expire_at > now()`;
    if (!en || !memeEmpreinte(en.empreinte_code, empreinte(i.code.trim().toUpperCase()))) {
      return null;
    }
+8 −0
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { Sql } from "../db/client.js";
import type { Env } from "../env.js";
import * as sessions from "../auth/sessions.js";
import { effacerTentatives, noterTentative, verifierLimite } from "../auth/limites.js";
import * as appareils from "../mobile/appareils.js";
import { pousseeConfiguree, poussee } from "../adaptateurs/poussee.js";

@@ -106,8 +107,14 @@ export function enregistrerRoutesMobile(app: FastifyInstance, sql: Sql, env: Env
      systeme: z.string().trim().max(40).optional(),
    }).parse(req.body);

    // Point ouvert par nécessité : le téléphone n'a pas encore d'identité. Il
    // est donc compté, comme les autres portes d'entrée (EX-06). Le refus
    // remonte en 429 par le gestionnaire d'erreurs des routes d'authentification.
    await verifierLimite(sql, "enrolement_ip", req.ip);

    try {
      const a = await appareils.inscrire(sql, c);
      await effacerTentatives(sql, "enrolement_ip", req.ip);
      return reply.code(201).send({
        appareilId: a.appareilId,
        // Montré une seule fois : il ira dans l'enclave du téléphone, et la
@@ -115,6 +122,7 @@ export function enregistrerRoutesMobile(app: FastifyInstance, sql: Sql, env: Env
        secret: a.secret,
      });
    } catch {
      await noterTentative(sql, "enrolement_ip", req.ip, false);
      return reply.code(400).send({
        code: "enrolement_invalide",
        message: "Ce code est inconnu ou périmé. Demandez-en un nouveau depuis votre session web.",
+41 −0
Original line number Diff line number Diff line
@@ -113,6 +113,47 @@ export async function verifierMobile(
  });
  r.verifie("EM-18", "le même code ne sert pas deux fois", rejoue.statusCode === 400);

  // ── Deux enrôlements de front ────────────────────────────────────────────
  //
  // Le code était retrouvé par sa date — le dernier enrôlement ouvert de
  // l'instance, tous comptes confondus — puis comparé. Deux personnes qui
  // sortent leur téléphone la même minute se marchaient donc dessus : la
  // première voyait son code valide refusé, sans que rien ne l'explique. Une
  // soirée de préparation à plusieurs est le cas normal, pas le cas limite.
  // Deux comptes neufs : le compte principal de cette suite a sa propre liste
  // d'appareils, qu'une garantie plus bas compte exactement.
  const porteurA = await comptes.inscrire(sql, {
    email: adresse("porteur-a"), nom: "Porteur A",
    motDePasse: "un-mot-de-passe-assez-long", urlPublique: "http://localhost:8080",
  });
  const porteurB = await comptes.inscrire(sql, {
    email: adresse("porteur-b"), nom: "Porteur B",
    motDePasse: "un-mot-de-passe-assez-long", urlPublique: "http://localhost:8080",
  });
  const premier = await appareils.ouvrirEnrolement(sql, porteurA.compteId);
  const second = await appareils.ouvrirEnrolement(sql, porteurB.compteId);

  const parPremier = await app.inject({
    method: "POST", url: "/api/appareils",
    payload: { code: premier.code, nom: "Téléphone du premier", plateforme: "ios" },
  });
  r.verifie("EM-18", "un code reste valable même si quelqu'un d'autre en ouvre un après",
    parPremier.statusCode === 201, parPremier.body.slice(0, 140));

  const parSecond = await app.inject({
    method: "POST", url: "/api/appareils",
    payload: { code: second.code, nom: "Téléphone du second", plateforme: "android" },
  });
  r.verifie("EM-18", "et chacun obtient bien l'identité de son propre compte",
    parSecond.statusCode === 201
      && (parSecond.json() as { appareilId: string }).appareilId
         !== (parPremier.json() as { appareilId: string }).appareilId);
  const [proprio] = await sql<{ compte_id: string }[]>`
    select compte_id from appareil
     where id = ${(parSecond.json() as { appareilId: string }).appareilId}`;
  r.verifie("EM-18", "le code d'un compte n'enrôle jamais pour un autre",
    proprio?.compte_id === porteurB.compteId);

  // Le secret n'est pas relisable : la base n'en garde que l'empreinte, comme
  // pour les sessions. Une base volée ne donne accès à aucun appareil.
  const [garde] = await sql<{ empreinte_secret: Buffer }[]>`