Commit cc663d8d authored by Kourser's avatar Kourser
Browse files

« iOS et Android » redevient vrai

L'application mobile n'était pas à écrire : elle l'était. Les six gestes du
chemin critique sont livrés depuis les jalons M0 à M3, et Expo la fait tourner
sur les deux systèmes. Le tableau d'avancement disait le contraire ; il avait
tort, et cette ligne est corrigée ici aussi.

Ce qui manquait était en aval, et c'était le seul chemin qui compte
  Le relais de heurtoir ne servait qu'Apple. Un téléphone Android enrôlé
  recevait un 501 poli l'invitant à basculer sur le SMS — c'est-à-dire que la
  seule fonction qui justifie l'existence d'une application native, réveiller
  quelqu'un à trois heures du matin, ne marchait pas sur la moitié du parc.

  L'API enrôlait pourtant « plateforme = android » depuis le début, et
  l'application demandait un jeton. Tout était en place sauf la dernière porte.

Le pendant d'Apple, écrit comme lui
  Aucune bibliothèque tierce. FCM v1 tient en deux requêtes HTTPS : un JWT
  signé en RS256, échangé contre un jeton d'accès, puis l'envoi. Les deux sont
  dans la bibliothèque standard, et une dépendance de plus dans le chemin
  d'alerte serait une surface de plus à surveiller pour un gain nul.

  Le jeton d'accès se renouvelle dix minutes avant l'échéance. Un jeton qui
  expire entre la décision d'alerter et la remise coûterait la mobilisation
  entière, et l'erreur ne se verrait qu'une fois.

  La charge ne transporte rien, comme du côté Apple : un titre, une référence
  opaque, un degré d'urgence. Ni organisation, ni crise, ni personne. C'est ce
  qui rend acceptable de passer par Google, et une garantie le vérifie.

Le son n'appartient pas au message, mais au canal
  C'est la différence de fond avec iOS. L'application crée donc deux canaux,
  avant la demande d'autorisation — un canal créé après coup n'est pas
  rattrapé, et la première mobilisation arriverait muette.

  Deux, et pas un : un point d'information qui hériterait du réglage d'une
  mobilisation ferait sonner la nuit pour rien, et l'inverse serait pire — la
  personne baisserait le seul canal qui doit la réveiller pour faire taire les
  autres.

La limite qu'aucun code ne rattrape
  Apple accorde une habilitation d'alerte critique à un éditeur ; Google n'a
  pas d'équivalent. Franchir « Ne pas déranger » sur Android est une
  autorisation que la personne donne à un canal, appareil par appareil. Le
  relais demande le contournement et la priorité maximale ; le reste ne lui
  appartient pas.

  C'est dit dans le code, dans .env.example et dans la page des refus. Une
  raison de plus pour que le SMS et l'appel vocal restent les canaux de
  mobilisation de premier rang.

La sonde du relais annonce désormais iOS et Android séparément : une instance
qui ne sert qu'un des deux ne doit pas promettre de réveiller l'autre.

Éprouvé : quinze assertions exécutées, dont la vérification cryptographique de
la signature RS256 contre une paire jetable, et l'absence de tout contenu de
crise dans la charge. Onze garanties ajoutées à la suite.

Non éprouvé : aucun heurtoir n'a atteint un vrai téléphone Android. Il y
faudrait un projet Firebase, une clé de service et un appareil. Ce qui est
démontré, c'est que le jeton est bien signé et que la charge a la forme
attendue — pas que Google l'accepte.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent c4ca3374
Loading
Loading
Loading
Loading
Loading
+17 −0
Changes for .env.example: 17 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -82,3 +82,20 @@ APNS_ENVIRONNEMENT=bac_a_sable
# 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

# ── Android : le compte de service Firebase ────────────────────────────────
#
# Les trois champs viennent du fichier JSON que la console Firebase remet. Les
# trois ou aucun : un relais à moitié configuré refuserait la moitié des
# téléphones sans qu'on sache lesquels.
#
# La clé privée est en PEM, sur une seule ligne avec des \n, comme celle
# d'Apple. Sans elle, le relais démarre, sert iOS, et refuse chaque heurtoir
# Android avec un motif clair — le canal bascule alors sur le SMS.
#
# Android n'a pas d'équivalent de l'alerte critique d'Apple : franchir le mode
# « Ne pas déranger » est une autorisation que la personne accorde au canal de
# notification, appareil par appareil. Aucun réglage ici ne l'obtient.
FCM_PROJET=
FCM_COMPTE=
FCM_CLE=
+1 −1
Changes for README.md: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -24,7 +24,7 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L9** | Rembobinage complet, dossier de crise, retour d'expérience | 🟢 fait |
| **L10** | Intégration exercice, page d'état, vérificateur, CI, documentation | 🟢 fait côté serveur — application installable, accessibilité et bilinguisme suivent le client |
| **Interface** | Coque, visio flottante, rembobinage, dossier de préparation, console root, tableaux, mobilisation, clôture | 🟢 fait |
| **L11** | Applications mobiles iOS et Android | 🟡 M0 fait côté serveur — enrôlement, heurtoir, découverte d'instance ; l'application reste à écrire |
| **L11** | Applications mobiles iOS et Android | 🟢 fait — les six gestes du chemin critique (M0 à M3), sur iOS et Android ; le relais sert désormais les deux |
| L12 | Téléphonie entrante, transcription | ⚪ hors d'atteinte sans infrastructure SIP |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
+84 −7
Changes for apps/api/src/verification/relais.ts: 84 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -39,7 +39,8 @@ export async function verifierRelais(r: Rapport): Promise<void> {
    memeCle: (a: string, b: string) => boolean;
  };
  const { construireRelais } = await import(`${dossier}serveur.ts`) as {
    construireRelais: (o: { env: Record<string, unknown>; apns: unknown; version?: string })
    construireRelais: (o: { env: Record<string, unknown>; apns: unknown;
      fcm?: unknown; version?: string })
      => { inject: (o: unknown) => Promise<{ statusCode: number; json: () => unknown }> };
  };

@@ -109,10 +110,10 @@ export async function verifierRelais(r: Rapport): Promise<void> {
  const clePem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
  const jwt = signerJeton({ clePem, cleId: "ABCDE12345", equipeId: "TEAM123456" }, 1_790_000_000);
  const [enteteB64, chargeB64, sigB64] = jwt.split(".");
  const enteteJwt = JSON.parse(Buffer.from(enteteB64!, "base64url").toString()) as
  const enteteApple = JSON.parse(Buffer.from(enteteB64!, "base64url").toString()) as
    { alg: string; kid: string };
  r.verifie("EM-84", "le jeton d'autorisation est signé en ES256 et porte son identifiant",
    enteteJwt.alg === "ES256" && enteteJwt.kid === "ABCDE12345");
    enteteApple.alg === "ES256" && enteteApple.kid === "ABCDE12345");
  r.verifie("EM-84", "et la signature est vérifiable au format brut qu'exige Apple",
    createVerify("SHA256").update(`${enteteB64}.${chargeB64}`)
      .verify({ key: publicKey, dsaEncoding: "ieee-p1363" },
@@ -139,7 +140,7 @@ export async function verifierRelais(r: Rapport): Promise<void> {
    KASTELL_SOURCE_URL: "https://git.cythin.eu/cythin/kastell",
    KASTELL_REVISION: "",
  };
  const app = construireRelais({ env, apns: apnsEssai });
  const app = construireRelais({ env, apns: apnsEssai, fcm: null });

  const frapper = (cle: string | null, corps: unknown) => app.inject({
    method: "POST", url: "/heurtoir",
@@ -186,7 +187,8 @@ export async function verifierRelais(r: Rapport): Promise<void> {
  // Une requête refusée compte aussi : sans cela, un appelant pourrait sonder
  // le relais indéfiniment avec des charges malformées, gratuitement.
  issue = { etat: "remis" };
  const etroit = construireRelais({ env: { ...env, DEBIT_PAR_MINUTE: 3 }, apns: apnsEssai });
  const etroit = construireRelais(
    { env: { ...env, DEBIT_PAR_MINUTE: 3 }, apns: apnsEssai, fcm: null });
  const depassements: number[] = [];
  for (let i = 0; i < 6; i += 1) {
    depassements.push((await etroit.inject({
@@ -198,7 +200,8 @@ export async function verifierRelais(r: Rapport): Promise<void> {
    depassements.filter((s) => s === 429).length === 3
      && depassements.filter((s) => s === 204).length === 3);
  const malformees = [0, 1, 2, 3].map(() => ({ jeton: "x" }));
  const etroit2 = construireRelais({ env: { ...env, DEBIT_PAR_MINUTE: 3 }, apns: apnsEssai });
  const etroit2 = construireRelais(
    { env: { ...env, DEBIT_PAR_MINUTE: 3 }, apns: apnsEssai, fcm: null });
  const sondage: number[] = [];
  for (const m of malformees) {
    sondage.push((await etroit2.inject({
@@ -209,7 +212,7 @@ export async function verifierRelais(r: Rapport): Promise<void> {
  r.verifie("EM-83", "sonder le relais avec des charges illisibles coûte aussi du quota",
    sondage.at(-1) === 429);

  const sansApns = construireRelais({ env, apns: null });
  const sansApns = construireRelais({ env, apns: null, fcm: null });
  r.verifie("EM-82", "un relais sans clé Apple refuse plutôt que d'accepter en silence",
    (await sansApns.inject({
      method: "POST", url: "/heurtoir",
@@ -219,4 +222,78 @@ export async function verifierRelais(r: Rapport): Promise<void> {
    ((await sansApns.inject({ method: "GET", url: "/etat" })) as {
      json: () => { remise: boolean };
    }).json().remise === false);

  // ── Android (EM-82, EM-09) ───────────────────────────────────────────────
  //
  // « iOS et Android » cessait d'être vrai à mi-chemin : l'application tournait
  // sur les deux, le relais renvoyait un 501 aux téléphones Android.
  r.titre("Relais — Android");

  const { signerJwt } = await import(`${dossier}fcm.ts`) as {
    signerJwt: (id: { projet: string; compte: string; clePemFcm: string },
      maintenantS?: number) => string;
  };
  const { composerFcm, CANAUX_ANDROID } = await import(`${dossier}chargeJwt.ts`) as {
    composerFcm: (h: Record<string, unknown>, projet: string) =>
      { chemin: string; corps: string };
    CANAUX_ANDROID: { mobAndroid: string; infoAndroid: string };
  };

  // Une paireFcm jetable : aucune clé de service ne traverse une vérification.
  const paireFcm = generateKeyPairSync("rsa", { modulusLength: 2048 });
  const clePemFcm = paireFcm.privateKey.export({ type: "pkcs8", format: "pem" }).toString();
  const idFcm = { projet: "kastell-verif",
    compte: "relais@kastell-verif.iam.gserviceaccount.com", clePemFcm };

  const jwtFcm = signerJwt(idFcm, 1_800_000_000);
  const [enteteJwt, chargeJwt, signatureJwt] = jwtFcm.split(".");
  const lirePart = (x: string) => JSON.parse(Buffer.from(x, "base64url").toString()) as
    Record<string, unknown>;

  r.verifie("EM-82", "le jeton présenté à Google est signé en RS256",
    lirePart(enteteJwt!).alg === "RS256");
  r.verifie("EM-82", "il demande la seule portée dont le relais ait besoin",
    lirePart(chargeJwt!).scope === "https://www.googleapis.com/auth/firebase.messaging");
  r.verifie("EM-82", "et sa signature se vérifie avec la clé publique du compte",
    createVerify("RSA-SHA256").update(`${enteteJwt}.${chargeJwt}`)
      .verify(paireFcm.publicKey, Buffer.from(signatureJwt!, "base64url")));

  const mobAndroid = (JSON.parse(composerFcm({
    jeton: "jeton-appareil", plateforme: "android", reference: "reference-verif",
    urgence: "mobAndroid", reveiller: true, exercice: false,
  }, "kastell-verif").corps) as { message: Record<string, never> }).message as
    Record<string, never>;
  const android = mobAndroid.android as unknown as Record<string, unknown>;
  const notifAndroid = android.notification as Record<string, unknown>;

  r.verifie("EM-11", "une mobAndroid part en priorité haute", android.priority === "high");
  r.verifie("EM-11", "sur le canal qui doit rester sonore",
    notifAndroid.channel_id === CANAUX_ANDROID.mobAndroid);
  r.verifie("EM-10", "et expire avec la mobAndroid, pas des heures après",
    android.ttl === "7200s");

  const infoAndroid = (JSON.parse(composerFcm({
    jeton: "j", plateforme: "android", reference: "r", urgence: "infoAndroid",
    reveiller: true, exercice: true,
  }, "p").corps) as { message: Record<string, never> }).message as Record<string, never>;
  const androidInfo = infoAndroid.android as unknown as Record<string, unknown>;

  r.verifie("EM-10", "une infoAndroid ne réveille pas", androidInfo.priority === "normal");
  r.verifie("EM-16", "un exercice se nomme exercice, jusque sur l'écran verrouillé",
    String((infoAndroid.notification as unknown as { title: string }).title)
      .includes("EXERCICE"));

  // EM-09 : la chargeJwt ne transporte rien. C'est ce qui rend acceptable de
  // passer par Google, comme par Apple.
  r.verifie("EM-09", "la chargeJwt Android ne porte ni organisation, ni crise, ni personne",
    (infoAndroid.notification as unknown as { title: string }).title
      .startsWith("Kastell")
    && Object.values(infoAndroid.data as unknown as Record<string, string>)
      .every((v) => typeof v === "string"));

  const sansRien = construireRelais({ env, apns: null, fcm: null });
  r.verifie("EM-82", "sans aucune des deux clés, la sonde n'annonce ni iOS ni Android",
    ((await sansRien.inject({ method: "GET", url: "/etat" })) as {
      json: () => { remise_ios: boolean; remise_android: boolean };
    }).json().remise_android === false);
}
+51 −0
Changes for apps/mobile/src/appareil/poussee.ts: 51 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -132,9 +132,60 @@ function traduire(p: Notifications.NotificationPermissionsStatus): Autorisation
 * Celle-ci se demande à la fin de l'enrôlement, quand la personne vient
 * précisément de dire qu'elle veut être jointe.
 */
/**
 * Les deux canaux Android, créés avant toute demande d'autorisation.
 *
 * Sur Android, le son et l'importance n'appartiennent pas au message : ils
 * appartiennent au canal, et l'utilisateur peut les régler ensuite. Sans canal
 * créé, une notification de mobilisation arrive muette — la promesse centrale
 * du produit tombe, et rien ne le signale.
 *
 * Deux canaux, parce qu'un point d'information n'a pas à hériter du réglage
 * d'une mobilisation, ni l'inverse — qui serait pire : la personne baisserait
 * le seul canal qui doit la réveiller pour faire taire les autres.
 *
 * Les identifiants sont ceux que le relais inscrit dans la charge FCM. Ils
 * doivent correspondre exactement ; c'est pourquoi ils sont écrits en clair
 * des deux côtés.
 */
export const CANAUX_ANDROID = {
  mobilisation: "kastell-mobilisation",
  information: "kastell-information",
} as const;

export async function preparerCanauxAndroid(): Promise<void> {
  if (Platform.OS !== "android") return;

  await Notifications.setNotificationChannelAsync(CANAUX_ANDROID.mobilisation, {
    name: "Mobilisation de crise",
    description: "Vous êtes appelé en cellule. Ce canal doit rester sonore.",
    importance: Notifications.AndroidImportance.MAX,
    sound: "default",
    enableVibrate: true,
    // Android n'a pas d'équivalent de l'alerte critique d'Apple : franchir
    // « Ne pas déranger » est une autorisation que la personne accorde à ce
    // canal, appareil par appareil. On demande le contournement ; il n'est
    // accordé que si elle l'a permis, et l'écran d'appareil le dit.
    bypassDnd: true,
    lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
  });

  await Notifications.setNotificationChannelAsync(CANAUX_ANDROID.information, {
    name: "Information de crise",
    description: "Points de situation et arbitrages. Ne réveille pas.",
    importance: Notifications.AndroidImportance.DEFAULT,
    enableVibrate: false,
    lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
  });
}

export async function demander(): Promise<EtatCanal> {
  const reel = Device.isDevice;

  // Avant la demande d'autorisation : un canal créé après coup n'est pas
  // rattrapé, et la première mobilisation arriverait muette.
  await preparerCanauxAndroid();

  const existante = await Notifications.getPermissionsAsync();
  const p = existante.status === "granted" ? existante : await Notifications.requestPermissionsAsync({
    ios: {
+83 −0
Changes for apps/relais/src/charge.ts: 83 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -119,6 +119,89 @@ export function composer(h: Heurtoir, r: Reglages, maintenantS = Math.floor(Date
  };
}

/**
 * Le canal Android sur lequel la notification arrive.
 *
 * Sur Android, ce n'est pas le message qui porte le son et l'importance : c'est
 * le canal, créé par l'application, et que l'utilisateur peut ensuite régler
 * lui-même. Deux canaux, donc, parce qu'on ne veut pas qu'un point
 * d'information hérite du réglage d'une mobilisation — ni l'inverse, qui serait
 * pire.
 *
 * Ces identifiants doivent exister à l'identique dans l'application ; ils sont
 * ici en clair pour qu'on puisse le vérifier des deux côtés.
 */
export const CANAUX_ANDROID = {
  mobilisation: "kastell-mobilisation",
  information: "kastell-information",
} as const;

export interface ChargeFcm {
  chemin: string;
  corps: string;
}

/**
 * La charge pour Android (FCM HTTP v1).
 *
 * Deux différences avec Apple, et aucune n'est un détail.
 *
 * Le son et l'importance ne voyagent pas dans le message : ils appartiennent au
 * canal. On choisit donc un canal selon l'urgence, et c'est tout ce qu'on peut
 * faire — le reste appartient à l'utilisateur, qui peut baisser un canal sans
 * nous le dire.
 *
 * Et il n'existe aucun équivalent de l'alerte critique d'Apple. Franchir le
 * mode « Ne pas déranger » sur Android est une autorisation que la personne
 * accorde à un canal, appareil par appareil ; aucune habilitation d'éditeur ne
 * l'obtient. C'est une limite du système, pas un réglage qu'on aurait oublié —
 * et c'est une raison de plus pour que le SMS et l'appel vocal restent les
 * canaux de mobilisation de premier rang.
 */
export function composerFcm(
  h: Heurtoir, projet: string, maintenantS = Math.floor(Date.now() / 1000),
): ChargeFcm {
  void maintenantS;
  const reveiller = h.reveiller && h.urgence === "mobilisation";

  return {
    chemin: `/v1/projects/${projet}/messages:send`,
    corps: JSON.stringify({
      message: {
        token: h.jeton,
        notification: {
          title: h.exercice ? LIBELLES.exercice : LIBELLES.reel,
        },
        android: {
          // « high » réveille l'appareil en veille profonde ; « normal » le
          // laisse grouper et différer, ce qu'on veut d'une information et
          // qu'on ne veut surtout pas d'une mobilisation.
          priority: reveiller ? "high" : "normal",
          ttl: `${DUREES_S[h.urgence]}s`,
          // Même rôle que « apns-collapse-id » : une relance ou une reprise
          // réseau ne doit produire qu'une bannière.
          collapse_key: h.reference.slice(0, 64),
          notification: {
            channel_id: h.urgence === "mobilisation"
              ? CANAUX_ANDROID.mobilisation
              : CANAUX_ANDROID.information,
            // La catégorie fait apparaître les réponses sans ouvrir
            // l'application, comme la catégorie APNs côté Apple.
            click_action: h.urgence === "mobilisation" ? "MOBILISATION" : undefined,
          },
        },
        // Les données sont des chaînes chez Google, sans exception : un booléen
        // envoyé tel quel fait refuser tout le message.
        data: {
          reference: h.reference,
          urgence: h.urgence,
          exercice: String(h.exercice),
        },
      },
    }),
  };
}

/**
 * Ce qu'on a le droit d'écrire dans les traces.
 *
Loading