Commit 43baf73f authored by Kourser's avatar Kourser
Browse files

Gestion documentaire : les octets dehors, l'empreinte dedans

Lot L5, et les deux exigences que les lots précédents avaient dû reporter — le
kit documentaire du dossier de préparation et les pièces jointes de salon.

Le journal ne reçoit que la référence
  Les fichiers vivent dans le stockage objet ; le journal porte la clé et
  l'empreinte SHA-256, jamais les octets (EB-07). Pour un rapport de six
  mégaoctets, la charge journalisée en fait quatre cent dix-neuf : y copier les
  pièces ferait exploser une chaîne qu'on veut pouvoir vérifier des années plus
  tard.

  L'empreinte est calculée par le serveur en relisant l'objet assemblé, jamais
  annoncée par le client. C'est elle qui prouve qu'un fichier restitué est bien
  celui qui fut déposé — une empreinte déclarée ne prouverait que la bonne foi
  de qui la déclare.

Le dépôt se reprend
  Après une coupure, le client redemande l'état du dépôt, qui lui dit quelle
  partie reprendre. Réémettre une partie déjà reçue n'est pas une erreur : c'est
  ce qui arrive quand le réseau lâche au mauvais moment, et la cellule travaille
  souvent depuis un partage de connexion. Redéposer un rapport de trente
  mégaoctets depuis le début est le genre de détail qui fait renoncer.

Rien n'est écrasé
  Redéposer crée une version. Restaurer une version antérieure en crée une
  nouvelle qui pointe le même objet, sans recopier les octets — on ne revient
  jamais en arrière, on avance en reprenant. Un écrasement silencieux au milieu
  d'une crise est une perte qu'on ne découvre que trop tard.

Sans analyseur, le produit le dit
  Un document déposé sur une instance sans antivirus est marqué « non analysée »
  et non « saine ». Un faux sentiment de sécurité vaut moins que pas de sécurité
  du tout. Une pièce en quarantaine n'est ni servie ni partageable : propager
  l'infection hors de la cellule au nom de la coopération serait un beau
  paradoxe. Le profil « complet » de la composition ajoute ClamAV.

Un partage n'est pas un trou
  Chaque consultation d'un lien à durée limitée est inscrite au journal, avec
  une origine « externe » et sans acteur — le destinataire n'a pas de compte,
  mais son passage laisse une trace.

Le kit de préparation suit le même versionnement
  Une crise ancienne rouvre le PCA qui était en vigueur ce jour-là, pas celui
  d'aujourd'hui. Retirer un élément l'ôte du kit en vigueur sans troubler les
  crises antérieures (EF-908).

Défaut corrigé au passage
  `analyse` est un mot réservé de PostgreSQL : la colonne s'appelle désormais
  `verdict`. La migration ne passait pas.

Hors périmètre
  L'extraction pour la recherche porte sur les formats texte et s'arrête au
  premier mégaoctet — limite désormais énoncée dans le cahier des charges et
  éprouvée par un contrôle. Les PDF et formats bureautiques rejoindront
  l'indexation avec l'aperçu intégré.

Vérification
  `pnpm verif` passe de 263 à 307 contrôles.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 220ecc70
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -25,5 +25,11 @@ S3_BUCKET=kastell
S3_ACCES=kastell
S3_SECRET=CHANGEZ-MOI-secret-stockage

# Analyse antivirale des dépôts (profil « complet »)
# Laisser vide désactive l'analyse : les documents sont alors marqués
# « non analysée », jamais « saine ».
CLAMAV_HOTE=
CLAMAV_PORT=3310

# Secrets applicatifs — générer avec: openssl rand -hex 32
SECRET_SESSION=CHANGEZ-MOI-secret-session-64-caracteres-hexadecimaux
+44 −8
Original line number Diff line number Diff line
@@ -14,10 +14,11 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
|-----|---------|------|
| **L0** | Socle, conteneurisation, authentification, console root | 🟢 fait |
| **L1** | Noyau de persistance, main courante, rembobinage | 🟡 en cours — noyau, lecture à date, filtres et routes faits ; reste l'export PDF paginé de la main courante (EF-410) et la vue frise, qui suppose une interface |
| **L2** | Dossier de préparation (annuaire, tiers, fiches réflexes) | 🟢 fait — sauf le kit documentaire, qui suit le stockage objet (L5) |
| **L2** | Dossier de préparation (annuaire, tiers, fiches réflexes, kit) | 🟢 fait |
| **L3** | Décisions, actions, points de situation, cockpit | 🟢 fait |
| **L4** | Chat de crise, flux temps réel | 🟢 fait — sauf les pièces jointes, qui suivent le stockage objet (L5) |
| L5+ | GED, visio, tableau blanc, mobilisation | ⚪ |
| **L4** | Chat de crise, flux temps réel | 🟢 fait |
| **L5** | Gestion documentaire, stockage objet | 🟢 fait — indexation des PDF à venir avec l'aperçu |
| L6+ | Visio et enregistrement, tableau blanc, mobilisation | ⚪ |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
@@ -27,7 +28,8 @@ complète (mot de passe, lien à usage unique, TOTP, codes de secours, sessions,
invitations, multi-organisation), la console d'administration avec ses quotas,
le dossier de préparation versionné avec son export papier, la boucle
décisionnelle complète (décisions, arbitrages, actions, points de situation,
cockpit), le chat de crise avec son flux temps réel, et la pile Docker.
cockpit), le chat de crise avec son flux temps réel, la gestion documentaire avec dépôts
reprenables et partages tracés, et la pile Docker.

---

@@ -101,6 +103,9 @@ apps/api/src/
    indicateur.ts    ce qui manque, et le geste qui le comble
    campagne.ts      vérification semestrielle des coordonnées
    export.ts        tirage papier et tableur
  domaine/documents.ts  dépôt reprenable, versions, partages à durée limitée
  adaptateurs/stockage.ts   S3 : MinIO en auto-hébergement, hébergeur en SaaS
  adaptateurs/antivirus.ts  ClamAV, ou rien — mais alors le produit le dit
  domaine/chat.ts     salons, messages, épingle, réactions, promotion
  domaine/directs.ts  messages directs — hors journal, à dessein
  adaptateurs/diffusion.ts  temps réel : mémoire en mono-hôte, Redis sinon
@@ -153,6 +158,34 @@ promesse « cloner et composer suffit » est une exigence produit. Le format
stocke ses paramètres, une migration reste possible sans invalider les
empreintes.

## Documents : les octets dehors, l'empreinte dedans

Les fichiers vivent dans le stockage objet ; le journal ne reçoit que la
référence et l'empreinte SHA-256 (EB-07). Y copier les pièces ferait exploser
une chaîne qu'on veut pouvoir vérifier des années plus tard — pour un rapport
de 6 Mo, la charge journalisée fait 419 octets.

L'empreinte est calculée **par le serveur en relisant l'objet assemblé**, jamais
annoncée par le client. C'est elle qui prouve qu'un fichier restitué est bien
celui qui fut déposé.

Le **dépôt est reprenable** : après une coupure, le client redemande l'état du
dépôt, qui lui dit quelle partie reprendre. Réémettre une partie déjà reçue
n'est pas une erreur — c'est ce qui arrive quand le réseau lâche au mauvais
moment. La cellule travaille souvent depuis un partage de connexion.

**Rien n'est écrasé.** Redéposer crée une version ; restaurer une version
antérieure en crée une nouvelle qui pointe le même objet, sans recopier les
octets.

Sans analyseur antivirus configuré, un document est marqué **« non analysée »**,
jamais « saine » : un faux sentiment de sécurité vaut moins que pas de sécurité
du tout. Une pièce en quarantaine n'est ni servie ni partageable.

Chaque consultation d'un **partage à durée limitée** est inscrite au journal,
avec une origine « externe » et sans acteur : un partage n'est pas un trou dans
la traçabilité.

## Chat : ce qui entre au journal, et ce qui n'y entre pas

Les **messages de salon sont dans le journal**. Un échange de cellule est la
@@ -222,10 +255,13 @@ L'**indicateur de préparation** ne note pas pour noter : chaque point manquant
nomme le geste qui le comble. Un tableau de bord qui affiche 62 % sans dire quoi
faire ne fait que culpabiliser.

Deux exigences attendent leur lot : le kit documentaire (EF-208) suit le
stockage objet en L5, la consultation hors ligne (EF-212) suit l'application
installable en L10. D'ici là, l'export papier tient ce rôle — et le tient mieux,
puisqu'il survit à une panne d'appareil.
Le **kit documentaire** (PCA, cartographie, modèles) suit le même versionnement :
une crise ancienne rouvre le PCA qui était en vigueur ce jour-là, pas celui
d'aujourd'hui.

La consultation hors ligne (EF-212) attend l'application installable en L10.
D'ici là, l'export papier tient ce rôle — et le tient mieux, puisqu'il survit à
une panne d'appareil.

## Administration de l'instance

+2 −0
Original line number Diff line number Diff line
@@ -10,6 +10,8 @@
    "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);\""
  },
  "dependencies": {
    "@aws-sdk/client-s3": "^3.1116.0",
    "@aws-sdk/s3-request-presigner": "^3.1116.0",
    "@fastify/cookie": "^11.1.2",
    "@fastify/websocket": "^11.3.0",
    "@kastell/shared": "workspace:*",
+104 −0
Original line number Diff line number Diff line
import { connect } from "node:net";
import type { Readable } from "node:stream";

/**
 * Analyse antivirale des dépôts (EF-905).
 *
 * Le cas d'usage n'est pas théorique : on manipule des pièces issues d'un
 * système d'information compromis, échangées entre des gens pressés. Une
 * cellule de crise qui se réinfecte en se transmettant la preuve du chiffrement
 * est un scénario documenté.
 *
 * Quand aucun analyseur n'est configuré, le produit le dit — le document est
 * marqué « non analysée » plutôt que « saine ». Un faux sentiment de sécurité
 * vaut moins que pas de sécurité du tout.
 */

export type Verdict = "saine" | "infectee" | "non_analysee";

export interface Resultat {
  verdict: Verdict;
  detail: string | null;
}

export interface Antivirus {
  readonly nom: string;
  analyser(flux: Readable): Promise<Resultat>;
}

export const antivirusInactif: Antivirus = {
  nom: "inactif",
  async analyser(flux) {
    flux.resume(); // on consomme, sinon le flux reste ouvert
    return {
      verdict: "non_analysee",
      detail: "Aucun analyseur configuré sur cette instance.",
    };
  },
};

/**
 * ClamAV par le protocole INSTREAM : le flux est envoyé par blocs préfixés de
 * leur longueur, un bloc vide clôt l'envoi. Rien à installer côté application.
 */
export function antivirusClamav(hote: string, port = 3310, delaiMs = 60_000): Antivirus {
  return {
    nom: `clamav(${hote}:${port})`,
    analyser(flux) {
      return new Promise<Resultat>((resoudre) => {
        const prise = connect({ host: hote, port, timeout: delaiMs });
        let reponse = "";
        let clos = false;

        const finir = (r: Resultat) => {
          if (clos) return;
          clos = true;
          prise.destroy();
          flux.destroy();
          resoudre(r);
        };

        prise.on("error", () => finir({
          verdict: "non_analysee", detail: "Analyseur injoignable.",
        }));
        prise.on("timeout", () => finir({
          verdict: "non_analysee", detail: "Analyseur hors délai.",
        }));
        prise.on("data", (bloc) => { reponse += bloc.toString(); });

        prise.on("close", () => {
          if (clos) return;
          const texte = reponse.trim();
          if (texte.endsWith("OK")) finir({ verdict: "saine", detail: null });
          else if (texte.includes("FOUND")) {
            finir({ verdict: "infectee", detail: texte.replace(/^stream:\s*/, "") });
          } else finir({ verdict: "non_analysee", detail: texte || "Réponse illisible." });
        });

        prise.on("connect", () => {
          prise.write("zINSTREAM\0");
          flux.on("data", (bloc: Buffer) => {
            const taille = Buffer.alloc(4);
            taille.writeUInt32BE(bloc.length);
            prise.write(taille);
            prise.write(bloc);
          });
          flux.on("end", () => prise.write(Buffer.alloc(4)));
          flux.on("error", () => finir({
            verdict: "non_analysee", detail: "Lecture du contenu interrompue.",
          }));
        });
      });
    },
  };
}

let courant: Antivirus = antivirusInactif;

export function definirAntivirus(a: Antivirus): void {
  courant = a;
}

export function antivirus(): Antivirus {
  return courant;
}
+145 −0
Original line number Diff line number Diff line
import { Readable } from "node:stream";
import {
  AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateBucketCommand,
  CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand,
  HeadObjectCommand, PutObjectCommand, S3Client, UploadPartCommand,
} from "@aws-sdk/client-s3";

/**
 * Stockage objet.
 *
 * Adaptateur au même titre que les autres : l'application ne connaît que cette
 * interface. MinIO en auto-hébergement, un hébergeur européen en SaaS, et rien
 * d'autre ne change — c'est le seul protocole de la pile qui soit
 * interchangeable par construction.
 */

export interface Partie {
  numero: number;
  etag: string;
  taille: number;
}

export interface Stockage {
  readonly nom: string;
  preparer(): Promise<void>;
  deposer(cle: string, corps: Buffer, typeMime: string): Promise<void>;
  ouvrirDepot(cle: string, typeMime: string): Promise<string>;
  deposerPartie(cle: string, televersementId: string, numero: number, corps: Buffer): Promise<string>;
  acheverDepot(cle: string, televersementId: string, parties: Partie[]): Promise<void>;
  abandonnerDepot(cle: string, televersementId: string): Promise<void>;
  lire(cle: string, plage?: { debut: number; fin?: number }): Promise<Readable>;
  taille(cle: string): Promise<number | null>;
  supprimer(cle: string): Promise<void>;
}

export interface ConfigurationS3 {
  endpoint?: string | undefined;
  region: string;
  seau: string;
  acces: string;
  secret: string;
}

export function stockageS3(c: ConfigurationS3): Stockage {
  const client = new S3Client({
    ...(c.endpoint ? { endpoint: c.endpoint } : {}),
    region: c.region,
    credentials: { accessKeyId: c.acces, secretAccessKey: c.secret },
    // MinIO n'expose pas de sous-domaines par seau.
    forcePathStyle: true,
  });

  return {
    nom: `s3(${c.seau})`,

    /** EX-29 : l'instance ne se déclare prête qu'une fois le seau joignable. */
    async preparer() {
      try {
        await client.send(new HeadBucketCommand({ Bucket: c.seau }));
      } catch {
        await client.send(new CreateBucketCommand({ Bucket: c.seau }));
      }
    },

    async deposer(cle, corps, typeMime) {
      await client.send(new PutObjectCommand({
        Bucket: c.seau, Key: cle, Body: corps, ContentType: typeMime,
      }));
    },

    async ouvrirDepot(cle, typeMime) {
      const r = await client.send(new CreateMultipartUploadCommand({
        Bucket: c.seau, Key: cle, ContentType: typeMime,
      }));
      if (!r.UploadId) throw new Error("Le stockage n'a pas ouvert de dépôt.");
      return r.UploadId;
    },

    async deposerPartie(cle, televersementId, numero, corps) {
      const r = await client.send(new UploadPartCommand({
        Bucket: c.seau, Key: cle, UploadId: televersementId, PartNumber: numero, Body: corps,
      }));
      if (!r.ETag) throw new Error("Partie refusée par le stockage.");
      return r.ETag;
    },

    async acheverDepot(cle, televersementId, parties) {
      await client.send(new CompleteMultipartUploadCommand({
        Bucket: c.seau, Key: cle, UploadId: televersementId,
        MultipartUpload: {
          Parts: [...parties].sort((a, b) => a.numero - b.numero)
            .map((p) => ({ PartNumber: p.numero, ETag: p.etag })),
        },
      }));
    },

    async abandonnerDepot(cle, televersementId) {
      await client.send(new AbortMultipartUploadCommand({
        Bucket: c.seau, Key: cle, UploadId: televersementId,
      })).catch(() => undefined);
    },

    async lire(cle, plage) {
      const r = await client.send(new GetObjectCommand({
        Bucket: c.seau, Key: cle,
        ...(plage ? { Range: `bytes=${plage.debut}-${plage.fin ?? ""}` } : {}),
      }));
      if (!r.Body) throw new Error("Objet introuvable.");
      return r.Body as Readable;
    },

    async taille(cle) {
      try {
        const r = await client.send(new HeadObjectCommand({ Bucket: c.seau, Key: cle }));
        return r.ContentLength ?? null;
      } catch {
        return null;
      }
    },

    async supprimer(cle) {
      await client.send(new DeleteObjectCommand({ Bucket: c.seau, Key: cle }));
    },
  };
}

let courant: Stockage | null = null;

export function definirStockage(s: Stockage): void {
  courant = s;
}

export function stockage(): Stockage {
  if (!courant) {
    throw new Error(
      "Aucun stockage objet configuré. Renseignez S3_ENDPOINT, S3_BUCKET, "
      + "S3_ACCES et S3_SECRET — la gestion documentaire en dépend.",
    );
  }
  return courant;
}

export function stockageConfigure(): boolean {
  return courant !== null;
}
Loading