diff --git a/__tests__/lib/document-seal.test.ts b/__tests__/lib/document-seal.test.ts new file mode 100644 index 0000000..e0bc95d --- /dev/null +++ b/__tests__/lib/document-seal.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { + buildDocumentFingerprint, + computeDocumentSeal, + verifyDocumentSeal, + formatSealReference, + type SealableDocument, +} from "@/lib/document-seal"; + +const BASE_DOCUMENT: SealableDocument = { + document_type: "prescription", + patient_full_name: "Amina Diallo", + patient_age_years: 42, + patient_sex: "female", + patient_weight_kg: 68, + patient_blood_group: "O+", + validated_diagnosis_code: "BA00", + validated_diagnosis_name: "Hypertension essentielle", + chief_complaint: "Céphalées répétées", + clinical_notes: "Tension élevée sur trois mesures.", + treatments: [{ drug_name: "Lisinopril", dosage_mg: "10 mg", duration_days: 30 }], + recommendations: ["Réduire le sel"], + follow_up_delay_days: 30, + follow_up_tests: ["Ionogramme"], + practitioner_name: "Martin", + practitioner_title: "Dr.", + practitioner_rpps: "10101010101", + validated_by: "Dr. Martin", + validated_by_user_id: "0f7a1c9e-3c5b-4c1a-9f2d-8e5b6a4c1d2f", + validated_at: "2026-08-02T09:30:00.000Z", +}; + +function withChange(change: Partial): SealableDocument { + return { ...BASE_DOCUMENT, ...change }; +} + +describe("computeDocumentSeal", () => { + it("produit la même empreinte pour un contenu identique", () => { + expect(computeDocumentSeal(BASE_DOCUMENT)).toBe(computeDocumentSeal({ ...BASE_DOCUMENT })); + }); + + it("ne dépend pas de l'ordre d'insertion des clés", () => { + // Un objet reconstruit dans un autre ordre décrit le même document : il ne + // doit pas déclencher une fausse alerte de falsification. + const reordered = Object.fromEntries( + Object.entries(BASE_DOCUMENT).reverse() + ) as unknown as SealableDocument; + + expect(computeDocumentSeal(reordered)).toBe(computeDocumentSeal(BASE_DOCUMENT)); + }); + + it("produit une empreinte SHA-256 hexadécimale", () => { + expect(computeDocumentSeal(BASE_DOCUMENT)).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe("détection des modifications", () => { + const scenarios: Array<[string, Partial]> = [ + ["la posologie d'un traitement", { + treatments: [{ drug_name: "Lisinopril", dosage_mg: "40 mg", duration_days: 30 }], + }], + ["l'ajout d'un traitement", { + treatments: [ + { drug_name: "Lisinopril", dosage_mg: "10 mg", duration_days: 30 }, + { drug_name: "Tramadol", dosage_mg: "50 mg", duration_days: 7 }, + ], + }], + ["le diagnostic retenu", { validated_diagnosis_code: "5A11" }], + ["l'identité du patient", { patient_full_name: "Amina Diallo-Sy" }], + ["le numéro RPPS du praticien", { practitioner_rpps: "20202020202" }], + ["le nom du validateur", { validated_by: "Dr. Autre" }], + ["une recommandation", { recommendations: ["Réduire le sel", "Arrêter le tabac"] }], + ["les notes cliniques", { clinical_notes: "Tension normale." }], + ]; + + for (const [label, change] of scenarios) { + it(`rompt le sceau si l'on modifie ${label}`, () => { + const seal = computeDocumentSeal(BASE_DOCUMENT); + expect(verifyDocumentSeal(withChange(change), seal)).toBe("tampered"); + }); + } + + it("confirme un document intact", () => { + const seal = computeDocumentSeal(BASE_DOCUMENT); + expect(verifyDocumentSeal(BASE_DOCUMENT, seal)).toBe("sealed"); + }); + + it("distingue un document jamais scellé d'un document altéré", () => { + expect(verifyDocumentSeal(BASE_DOCUMENT, null)).toBe("unsealed"); + expect(verifyDocumentSeal(BASE_DOCUMENT, "")).toBe("unsealed"); + }); +}); + +describe("buildDocumentFingerprint", () => { + it("distingue null d'une chaîne vide", () => { + const withNull = buildDocumentFingerprint(withChange({ clinical_notes: null })); + const withEmpty = buildDocumentFingerprint(withChange({ clinical_notes: "" })); + + expect(withNull).not.toBe(withEmpty); + }); + + it("tient compte de l'ordre des traitements", () => { + // Deux ordonnances listant les mêmes molécules dans un ordre différent sont + // deux documents différents à l'impression : le sceau doit le refléter. + const first = buildDocumentFingerprint( + withChange({ treatments: [{ drug_name: "A" }, { drug_name: "B" }] }) + ); + const second = buildDocumentFingerprint( + withChange({ treatments: [{ drug_name: "B" }, { drug_name: "A" }] }) + ); + + expect(first).not.toBe(second); + }); +}); + +describe("formatSealReference", () => { + it("met en forme un préfixe lisible pour impression", () => { + expect(formatSealReference("a1b2c3d4e5f60718293a4b5c6d7e8f90")).toBe("A1B2-C3D4-E5F6-0718"); + }); +}); diff --git a/actions/diagnostics.ts b/actions/diagnostics.ts index 57b5fdb..9893948 100644 --- a/actions/diagnostics.ts +++ b/actions/diagnostics.ts @@ -1,6 +1,7 @@ "use server"; import { createClient } from "@/lib/supabase/server"; +import { computeDocumentSeal } from "@/lib/document-seal"; import type { DiagnosticRecord, PatientProfileInput, @@ -313,7 +314,39 @@ export async function updateDiagnosticPrescription( return { success: false, error: "Le diagnostic doit d'abord être validé par un médecin." }; } - const { error } = await (supabase as any) + // Un seul cast pour les deux requêtes de cette fonction, plutôt qu'un par appel. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any; + + // Champs du document qui ne figurent pas dans le formulaire de prescription : + // ils entrent pourtant dans l'empreinte, puisqu'ils sont imprimés. + const { data: existing } = await db + .from("diagnostics") + .select( + "patient_full_name, patient_age_years, patient_sex, patient_weight_kg, patient_blood_group, validated_diagnosis_code, validated_diagnosis_name, chief_complaint, clinical_notes, validated_by, validated_by_user_id, validated_at" + ) + .eq("id", diagnosticId) + .eq("clinic_id", clinicId) + .maybeSingle(); + + if (!existing) return { success: false, error: "Diagnostic introuvable" }; + + // Le sceau est calculé sur le contenu tel qu'il sera enregistré, jamais sur ce + // que le client prétend avoir produit : une empreinte fournie par l'appelant + // ne prouverait rien. + const documentSeal = computeDocumentSeal({ + ...existing, + document_type: prescription.document_type, + treatments: prescription.treatments, + recommendations: prescription.recommendations, + follow_up_delay_days: prescription.follow_up_delay_days, + follow_up_tests: prescription.follow_up_tests, + practitioner_name: prescription.practitioner_name, + practitioner_title: prescription.practitioner_title, + practitioner_rpps: prescription.practitioner_rpps, + }); + + const { error } = await db .from("diagnostics") .update({ document_type: prescription.document_type, @@ -328,6 +361,9 @@ export async function updateDiagnosticPrescription( // désormais imputable à un compte réel et vérifiable. prescribed_by_user_id: userId, icf_codes: prescription.icf_codes ?? [], + document_seal: documentSeal, + document_sealed_at: new Date().toISOString(), + document_sealed_by_user_id: userId, current_step: nextStep(state.current_step, 6), }) .eq("id", diagnosticId) diff --git a/app/(app)/app/diagnostics/[id]/page.tsx b/app/(app)/app/diagnostics/[id]/page.tsx index 88ae11f..7299c9b 100644 --- a/app/(app)/app/diagnostics/[id]/page.tsx +++ b/app/(app)/app/diagnostics/[id]/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { ArrowLeft, FileEdit, Stethoscope } from "lucide-react"; import { getDiagnosticById } from "@/actions/diagnostics"; import { getSignatureForValidatedDiagnostic } from "@/actions/doctor-signature"; +import { verifyDocumentSeal, formatSealReference } from "@/lib/document-seal"; import { PrescriptionPrintDocument } from "@/components/diagnostics/prescription-print-document"; import { DiagnosticValidationPanel } from "@/components/diagnostics/diagnostic-validation-panel"; import { ComorbiditiesPanel } from "@/components/diagnostics/comorbidities-panel"; @@ -24,6 +25,11 @@ export default async function DiagnosticDetailPage({ params }: DiagnosticDetailP // diagnostic n'est pas validé. const signature = await getSignatureForValidatedDiagnostic(diagnostic.id); + // L'empreinte est recalculée à chaque affichage : une modification du contenu + // postérieure à la production du document devient visible, au lieu de passer + // sous une signature qui ne l'engage plus. + const sealStatus = verifyDocumentSeal(diagnostic, diagnostic.document_seal); + const STATUS_STYLES: Record = { draft: "bg-gray-100 text-gray-600", pending_validation: "bg-amber-100 text-amber-700", @@ -107,6 +113,10 @@ export default async function DiagnosticDetailPage({ params }: DiagnosticDetailP diff --git a/components/diagnostics/prescription-print-document.tsx b/components/diagnostics/prescription-print-document.tsx index 05e32c7..6193098 100644 --- a/components/diagnostics/prescription-print-document.tsx +++ b/components/diagnostics/prescription-print-document.tsx @@ -2,10 +2,13 @@ import { format, parseISO } from "date-fns"; import { fr } from "date-fns/locale"; -import { Printer } from "lucide-react"; +import { Printer, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; import type { DiagnosticRecord } from "@/types"; +/** Statut du sceau, calculé côté serveur : ce composant ne recalcule rien. */ +type DocumentSealStatus = "sealed" | "unsealed" | "tampered"; + const DOCUMENT_TYPE_LABELS: Record = { consultation: "Compte rendu de consultation", prescription: "Ordonnance médicale", @@ -36,6 +39,10 @@ interface PrescriptionPrintDocumentProps { clinicName?: string; clinicAddress?: string; signatureDataUrl?: string; + /** Résultat de la vérification du sceau, calculée côté serveur. */ + sealStatus?: DocumentSealStatus; + /** Référence courte imprimable du sceau, mise en forme côté serveur. */ + sealReference?: string; } export function PrescriptionPrintDocument({ @@ -43,6 +50,8 @@ export function PrescriptionPrintDocument({ clinicName = "Cabinet médical", clinicAddress, signatureDataUrl, + sealStatus = "unsealed", + sealReference, }: PrescriptionPrintDocumentProps) { const documentTitle = (diagnostic.document_type ? DOCUMENT_TYPE_LABELS[diagnostic.document_type] : null) ?? "Document médical"; const formattedDate = format(parseISO(diagnostic.created_at), "d MMMM yyyy", { locale: fr }); @@ -56,6 +65,20 @@ export function PrescriptionPrintDocument({ + {sealStatus === "tampered" && ( +
+ +
+

Document modifié après signature

+

+ Le contenu ne correspond plus à celui scellé lors de la production du document. La + signature affichée ne l'engage pas. Régénérez le document avant toute remise au + patient. +

+
+
+ )} + {/* Document */}
@@ -266,6 +289,9 @@ export function PrescriptionPrintDocument({

Document généré le {formattedDate}

Réf: {diagnostic.id.slice(0, 8).toUpperCase()}

+ {sealStatus === "sealed" && sealReference && ( +

Sceau: {sealReference}

+ )}

Signature et cachet

diff --git a/lib/document-seal.ts b/lib/document-seal.ts new file mode 100644 index 0000000..6ec8762 --- /dev/null +++ b/lib/document-seal.ts @@ -0,0 +1,124 @@ +import { createHash } from "node:crypto"; + +/** + * Scellement des documents médicaux. + * + * La signature apposée sur une ordonnance est une image : elle ne dit rien du + * contenu qu'elle accompagne. Modifier un traitement après validation + * réaffichait jusqu'ici la même signature, sans que rien ne le signale. + * + * On calcule donc une empreinte du contenu au moment où le document est produit, + * et on la conserve. À l'affichage, l'empreinte est recalculée : toute + * divergence prouve une modification postérieure au scellement. + * + * PORTÉE : c'est un contrôle d'intégrité, pas une signature électronique au sens + * eIDAS. Il détecte une modification du contenu après coup ; il ne prouve pas + * l'identité du signataire par un certificat, et l'horodatage est celui du + * serveur, non celui d'une autorité de temps. L'imputabilité repose sur + * `sealed_by_user_id`, complétée par `validated_by_user_id`. + */ + +/** Champs du diagnostic qui figurent sur le document imprimé. */ +export interface SealableDocument { + document_type: string | null; + patient_full_name: string | null; + patient_age_years: number | null; + patient_sex: string | null; + patient_weight_kg: number | null; + patient_blood_group: string | null; + validated_diagnosis_code: string | null; + validated_diagnosis_name: string | null; + chief_complaint: string | null; + clinical_notes: string | null; + treatments: unknown[]; + recommendations: string[]; + follow_up_delay_days: number | null; + follow_up_tests: string[]; + practitioner_name: string | null; + practitioner_title: string | null; + practitioner_rpps: string | null; + validated_by: string | null; + validated_by_user_id: string | null; + validated_at: string | null; +} + +/** + * Sérialise une valeur de façon déterministe. + * + * `JSON.stringify` conserve l'ordre d'insertion des clés : deux objets + * identiques mais construits différemment produiraient deux empreintes + * distinctes, et donc une fausse alerte de falsification. On trie donc les clés + * à tous les niveaux. + */ +function canonicalize(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalize(entryValue)}`); + return `{${entries.join(",")}}`; + } + + return JSON.stringify(value); +} + +/** + * Représentation canonique du document, dans un ordre de champs figé. + * + * Exportée pour les tests : c'est elle qui détermine ce qu'une modification + * rend détectable. Tout champ absent d'ici pourra être modifié sans rompre le + * sceau. + */ +export function buildDocumentFingerprint(document: SealableDocument): string { + return canonicalize({ + document_type: document.document_type, + patient_full_name: document.patient_full_name, + patient_age_years: document.patient_age_years, + patient_sex: document.patient_sex, + patient_weight_kg: document.patient_weight_kg, + patient_blood_group: document.patient_blood_group, + validated_diagnosis_code: document.validated_diagnosis_code, + validated_diagnosis_name: document.validated_diagnosis_name, + chief_complaint: document.chief_complaint, + clinical_notes: document.clinical_notes, + treatments: document.treatments, + recommendations: document.recommendations, + follow_up_delay_days: document.follow_up_delay_days, + follow_up_tests: document.follow_up_tests, + practitioner_name: document.practitioner_name, + practitioner_title: document.practitioner_title, + practitioner_rpps: document.practitioner_rpps, + validated_by: document.validated_by, + validated_by_user_id: document.validated_by_user_id, + validated_at: document.validated_at, + }); +} + +/** Empreinte SHA-256 du document, en hexadécimal minuscule. */ +export function computeDocumentSeal(document: SealableDocument): string { + return createHash("sha256").update(buildDocumentFingerprint(document), "utf8").digest("hex"); +} + +export type DocumentSealStatus = "sealed" | "unsealed" | "tampered"; + +/** + * Compare l'empreinte conservée au contenu actuel. + * + * `unsealed` couvre les documents produits avant la mise en place du scellement : + * ils ne sont pas suspects, ils sont simplement non vérifiables. + */ +export function verifyDocumentSeal( + document: SealableDocument, + storedSeal: string | null | undefined +): DocumentSealStatus { + if (!storedSeal) return "unsealed"; + return computeDocumentSeal(document) === storedSeal ? "sealed" : "tampered"; +} + +/** Référence courte imprimable, pour rapprocher un document papier de son sceau. */ +export function formatSealReference(seal: string): string { + return seal.slice(0, 16).toUpperCase().replace(/(.{4})(?=.)/g, "$1-"); +} diff --git a/supabase/migrations/015_scellement_documents_medicaux.sql b/supabase/migrations/015_scellement_documents_medicaux.sql new file mode 100644 index 0000000..b0280cb --- /dev/null +++ b/supabase/migrations/015_scellement_documents_medicaux.sql @@ -0,0 +1,31 @@ +-- ═══════════════════════════════════════════════════════════════════════════════ +-- 015 — Scellement des documents médicaux +-- (audit signature / carnet / ordonnance 2026-08-02) +-- +-- La signature apposée sur une ordonnance est une image : elle n'est liée à +-- aucun contenu. Modifier une posologie après validation réaffichait la même +-- signature, sans que rien ne le signale — le document restait crédible alors +-- qu'il ne correspondait plus à ce que le médecin avait validé. +-- +-- On conserve donc une empreinte SHA-256 du contenu au moment où le document est +-- produit. À l'affichage, l'empreinte est recalculée et comparée : toute +-- divergence prouve une modification postérieure. +-- +-- PORTÉE : contrôle d'intégrité, pas signature électronique au sens eIDAS. Il +-- détecte une modification après coup ; il ne prouve pas l'identité du signataire +-- par un certificat, et l'horodatage est celui du serveur, non celui d'une +-- autorité de temps. Ces limites sont assumées et documentées dans +-- lib/document-seal.ts. +-- ═══════════════════════════════════════════════════════════════════════════════ + +ALTER TABLE diagnostics ADD COLUMN IF NOT EXISTS document_seal TEXT; +ALTER TABLE diagnostics ADD COLUMN IF NOT EXISTS document_sealed_at TIMESTAMPTZ; +ALTER TABLE diagnostics ADD COLUMN IF NOT EXISTS document_sealed_by_user_id UUID + REFERENCES users(id) ON DELETE SET NULL; + +COMMENT ON COLUMN diagnostics.document_seal IS + 'Empreinte SHA-256 du contenu du document au moment de sa production. Calculée et écrite côté serveur uniquement (lib/document-seal.ts).'; + +-- Les documents produits avant cette migration restent sans sceau : ils ne sont +-- pas suspects, ils sont simplement non vérifiables. L'interface les affiche +-- comme « non scellé », distinct de « altéré ». diff --git a/types/index.ts b/types/index.ts index a478cc1..5aae53f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -195,6 +195,12 @@ export interface DiagnosticRecord { practitioner_title: string | null; practitioner_rpps: string | null; + // Scellement (migration 015) + /** Empreinte SHA-256 du contenu au moment de la production du document. */ + document_seal: string | null; + document_sealed_at: string | null; + document_sealed_by_user_id: string | null; + created_at: string; updated_at: string; }