Verified Commit 2d944861 authored by Kourser's avatar Kourser
Browse files

Relais de heurtoir : la pièce sans laquelle rien ne sonne

Le volet mobile prévoyait ce service depuis M0 et disait déjà ce qu'il coûte :
il fait de nous un intermédiaire dans le chemin d'alerte des autres. Le voici,
avec ce qui rend cette position tenable.

Pourquoi il existe
  Les serveurs d'Apple n'acceptent que des messages signés par la clé de
  l'éditeur de l'application. Une instance auto-hébergée ne peut donc pas,
  seule, faire sonner une application publiée par quelqu'un d'autre. C'est une
  contrainte de plateforme, et aucune ruse n'en sort.

Ce qu'il ne reçoit pas, et pourquoi ce n'est pas une promesse verbale
  Un jeton d'appareil, un identifiant opaque, un degré d'urgence. Ni
  organisation, ni crise, ni personne — non pas parce qu'on s'interdit de les
  lire, mais parce qu'on ne les lui envoie pas : le type ne comporte aucun
  champ où glisser une phrase. Le titre affiché sur l'écran verrouillé est
  l'un de deux libellés fixes que le relais détient, ce qui suffit à dire
  « exercice » avant tout autre mot (EM-16) sans rien apprendre de la crise.

  Aucune base, aucun fichier, aucune trace nominative. Les traces portent une
  urgence et une issue, jamais un jeton ni une référence — un relais qui
  journaliserait les jetons deviendrait le registre de qui a été sollicité et
  quand, c'est-à-dire précisément le point de collecte que le §15 redoute.

Un défaut que ce travail a mis au jour
  L'instance annonçait « notifications : oui » dès qu'une adresse de relais
  était renseignée. Un relais joignable mais sans clé Apple ne fait sonner
  personne : la sonde aurait été vraie sur le papier et fausse à trois heures
  du matin, au moment précis où EM-76 demande à l'application de la lire avant
  d'enrôler. Elle demande maintenant au relais s'il peut réellement remettre,
  et met la réponse en cache une minute.

  Dans le même mouvement, le 410 « jeton mort » est distingué d'une panne :
  l'instance oublie le jeton au lieu de le réessayer, et le dossier de
  préparation cesse de compter joignable quelqu'un qui ne l'est plus.

Sans clé Apple, il démarre quand même et refuse chaque heurtoir avec un motif
clair. Un relais qui répondrait 204 sans rien remettre serait pire que pas de
relais du tout : l'instance croirait avoir réveillé quelqu'un.

729 garanties, dont 29 nouvelles — éprouvées sans clé Apple, sans réseau et
sans appareil : la charge composée et ce qu'elle ne contient pas, la signature
ES256 vérifiée avec la clé publique, la comparaison de clés en temps constant,
le comptage de débit, et chacune des issues du protocole.

Aucune dépendance nouvelle : APNs tient en un HTTP/2 et un jeton signé, tous
deux dans la bibliothèque standard. Une dépendance de plus dans le chemin
d'alerte serait une surface de plus à surveiller, pour un gain nul.

Ce qui reste hors de portée du code : un compte éditeur Apple, et l'instruction
de la demande d'alerte critique.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 169d447c
Loading
Loading
Loading
Loading
+26 −0
Original line number Diff line number Diff line
@@ -56,3 +56,29 @@ CLAMAV_PORT=3310

# Secrets applicatifs — générer avec: openssl rand -hex 32
SECRET_SESSION=CHANGEZ-MOI-secret-session-64-caracteres-hexadecimaux

# ── Notification mobile : le relais de heurtoir (EM-82 à EM-84) ─────────────
#
# Sans lui, l'instance reste entière : lecture, consignation, appel. Elle ne
# réveille personne, et le dit — à sa sonde d'état, et donc à l'application au
# moment où l'on choisit son instance. Le SMS et l'appel vocal restent les
# canaux de mobilisation de premier rang.
#
# Pour le lancer avec la composition : docker compose --profile mobile up -d
KASTELL_RELAIS_POUSSEE=http://relais:8081
KASTELL_RELAIS_CLE=CHANGEZ-MOI

# La clé .p8 remise par Apple, en PEM, sur une seule ligne avec des \n.
# Sans elle, le relais démarre et refuse chaque heurtoir avec un motif clair —
# plutôt que d'accepter en silence et de laisser croire qu'un téléphone sonnera.
APNS_CLE=
APNS_CLE_ID=
APNS_EQUIPE_ID=
APNS_PAQUET=bzh.kourser.kastell
APNS_ENVIRONNEMENT=bac_a_sable

# EM-12 : à laisser sur « false » tant qu'Apple n'a pas instruit la demande
# d'habilitation d'alerte critique. Son obtention n'est jamais présumée : sans
# elle, la notification ne franchit pas le mode silencieux, et l'application le
# dit à l'installation plutôt que de le laisser découvrir la nuit venue.
APNS_ALERTE_CRITIQUE=false
+39 −4
Original line number Diff line number Diff line
@@ -45,6 +45,21 @@ export interface Heurtoir {
  exercice: boolean;
}

/**
 * Un jeton que le système de notification ne reconnaît plus (EM-15, EM-51).
 *
 * À distinguer d'une panne : une panne se réessaie, un jeton mort ne se
 * réessaiera jamais. Le garder en base ferait croire à un canal qui n'existe
 * plus, et le dossier de préparation compterait comme joignable quelqu'un qui
 * ne l'est pas — exactement le mensonge qu'EM-13 cherche à empêcher.
 */
export class JetonMort extends Error {
  constructor(readonly motif: string) {
    super(`Jeton de notification rejeté par le service (${motif}).`);
    this.name = "JetonMort";
  }
}

export interface Poussee {
  readonly nom: string;
  /** Vrai si l'instance peut réellement faire sonner un téléphone (EM-76). */
@@ -107,12 +122,21 @@ export function pousseeRelais(url: string, cle: string, delaiMs = 8000): Poussee
  return {
    nom: `relais(${new URL(base).host})`,

    /**
     * « Joignable » ne suffit pas : un relais qui répond mais n'a pas de clé
     * Apple ne fera sonner aucun téléphone. Répondre vrai ici ferait dire à la
     * sonde d'état que cette instance peut réveiller quelqu'un — c'est
     * exactement ce qu'EM-76 demande à l'application de lire *avant* tout
     * enrôlement, et exactement ce qu'il ne faut pas lui faire croire.
     */
    async disponible() {
      try {
        const r = await fetch(`${base}/etat`, {
          signal: AbortSignal.timeout(delaiMs),
        });
        return r.ok;
        const r = await fetch(`${base}/etat`, { signal: AbortSignal.timeout(delaiMs) });
        if (!r.ok) return false;
        const e = await r.json() as { remise?: unknown };
        // Un relais d'une version antérieure n'annonce pas ce champ. On ne
        // suppose pas : sans réponse claire, on considère qu'il ne remet pas.
        return e.remise === true;
      } catch {
        return false;
      }
@@ -136,6 +160,17 @@ export function pousseeRelais(url: string, cle: string, delaiMs = 8000): Poussee
        // Un jeton mort est une information utile : il vaut mieux le savoir
        // avant la crise que pendant (EM-15, EM-51).
        const detail = await r.text().catch(() => "");
        // 410 : le relais a obtenu d'Apple que ce jeton ne désignera plus
        // jamais rien. C'est le seul cas où l'instance doit oublier le jeton
        // plutôt que de réessayer.
        if (r.status === 410) {
          let motif = "jeton inconnu du service";
          try {
            const j = JSON.parse(detail) as { message?: string };
            if (j.message) motif = j.message;
          } catch { /* le relais peut répondre sans corps */ }
          throw new JetonMort(motif);
        }
        throw new Error(`Le relais a refusé le heurtoir (${r.status}) ${detail.slice(0, 120)}`);
      }
    },
+10 −1
Original line number Diff line number Diff line
import { randomInt } from "node:crypto";
import type { Sql } from "../db/client.js";
import { empreinte, engendrer, memeEmpreinte } from "../auth/jetons.js";
import { poussee, pousseeConfiguree, type Urgence } from "../adaptateurs/poussee.js";
import { JetonMort, poussee, pousseeConfiguree, type Urgence } from "../adaptateurs/poussee.js";

/**
 * Appareils mobiles : enrôlement, révocation, heurtoir (EM-17 à EM-23).
@@ -257,6 +257,15 @@ export async function frapper(
    } catch (e) {
      erreur = e instanceof Error ? e.message : "remise refusée";
      echecs.push({ appareil_id: a.id, motif: erreur });
      // EM-15 : un jeton mort est traité comme une panne de canal — il bascule
      // sur le SMS, déjà tenté par l'appelant, et il disparaît de la base.
      // L'appareil le redéclarera à sa prochaine ouverture (EM-51) ; d'ici là,
      // le dossier de préparation le comptera comme injoignable, ce qu'il est.
      if (e instanceof JetonMort) {
        await sql`
          update appareil set jeton_poussee = null, jeton_poussee_at = null
           where id = ${a.id}`;
      }
    }
    await sql`
      insert into heurtoir (appareil_id, reference, urgence, remis, motif_echec)
+35 −1
Original line number Diff line number Diff line
@@ -20,6 +20,35 @@ import { PROTOCOLE, PROTOCOLE_MINIMAL } from "../version.js";
 * d'état servie par le service qu'elle surveille ne dit rien le jour où celui-ci
 * tombe. Cet endpoint est la sonde qu'elle interroge.
 */
/**
 * La disponibilité du relais, mise en cache une minute.
 *
 * La sonde est publique et interrogée par des supervisions : demander au
 * relais à chaque appel en ferait un amplificateur de charge, et une panne du
 * relais ralentirait la page qui doit dire qu'il est en panne. Une minute est
 * assez court pour qu'une coupure se voie, assez long pour que la sonde reste
 * une sonde.
 */
let relaisVu: { valeur: boolean; mesure: number } | null = null;

async function relaisRemet(): Promise<boolean> {
  if (!pousseeConfiguree()) return false;
  if (relaisVu && Date.now() - relaisVu.mesure < 60_000) return relaisVu.valeur;
  let valeur = false;
  try {
    valeur = await poussee().disponible();
  } catch {
    valeur = false;
  }
  relaisVu = { valeur, mesure: Date.now() };
  return valeur;
}

/** Pour les vérifications, qui changent d'adaptateur entre deux contrôles. */
export function oublierRelaisVu(): void {
  relaisVu = null;
}

export function enregistrerRoutesEtat(app: FastifyInstance, sql: Sql, env: Env): void {
  app.get("/etat", async (_req, reply) => {
    const debut = Date.now();
@@ -60,7 +89,12 @@ export function enregistrerRoutesEtat(app: FastifyInstance, sql: Sql, env: Env):
        // serveur peut faire sonner ce téléphone ? Une organisation dont
        // l'instance ne relaie pas doit l'apprendre à l'installation, pas la
        // nuit où personne ne se réveille.
        notifications: pousseeConfiguree(),
        //
        // Un relais déclaré ne suffit pas : on lui demande s'il peut
        // réellement remettre. Répondre « oui » parce qu'une adresse est
        // renseignée serait la pire des réponses — vraie sur le papier, fausse
        // à trois heures du matin.
        notifications: await relaisRemet(),
        relais: pousseeConfiguree() ? poussee().nom : null,
      },
      latence_base_ms: Date.now() - debut,
+4 −0
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import { verifierInterface } from "./verification/interface.js";
import { verifierAccessibilite } from "./verification/accessibilite.js";
import { verifierMobile } from "./verification/mobile.js";
import { verifierApplicationMobile } from "./verification/application-mobile.js";
import { verifierRelais } from "./verification/relais.js";
import { verifierCanaux } from "./verification/canaux.js";
import { verifierContinuite } from "./verification/continuite.js";
import { verifierVeille } from "./verification/veille.js";
@@ -321,6 +322,9 @@ await verifierMobile(sql, app, rapport);
// ── L'application elle-même, relue sans simulateur (EM-41) ────────────────
await verifierApplicationMobile(rapport);

// ── Le relais de heurtoir (EM-82 à EM-85) ─────────────────────────────────
await verifierRelais(rapport);

// ── Canaux de diffusion et envois de masse (§6.11) ────────────────────────
await verifierCanaux(sql, app, rapport);

Loading