Guide pratique

Données de santé

Scénario réel : une application de santé stocke des dossiers patients (nom, NIR, diagnostics, prescriptions). Ce guide couvre le chiffrement des données de santé, la journalisation des accès et le crypto-shredding pour la conformité RGPD Art.9 et HDS.

RGPD Art.9 & HDS

Les données de santé sont des données sensibles au sens du RGPD Art.9. En France, leur hébergement requiert la certification HDS (Hébergeur de Données de Santé). Ce pattern implémente les mesures techniques requises : chiffrement, clés par établissement, journalisation des accès.

1. Architecture des clés par établissement

Chaque établissement de santé dispose de clés séparées. Une clé PHT (identité) et une clé PHI (données médicales) permettent de contrôler finement les accès : un agent administratif peut accéder à l'identité sans voir les diagnostics.

key-pht-{establishmentId}   // PHT : nom, prénom, date de naissance, NIR
key-phi-{establishmentId}   // PHI : diagnostics, prescriptions, résultats

// Clé API administrative → accès à key-pht-* uniquement
// Clé API médecin       → accès à key-pht-* et key-phi-*
// Clé API DPO/audit     → lecture seule des journaux

2. Schéma de la base de données

-- Schéma PostgreSQL : dossiers patients chiffrés
CREATE TABLE patients (
  id              SERIAL PRIMARY KEY,
  establishment_id INTEGER     NOT NULL,  -- clé par établissement
  -- Données d'identité (chiffrées par établissement)
  last_name        JSONB       NOT NULL,  -- clé: key-pht-{establishment_id}
  first_name       JSONB       NOT NULL,
  birthdate        JSONB       NOT NULL,
  nss              JSONB       NOT NULL,  -- N° de sécurité sociale (NIR)
  -- Données médicales (clé séparée plus restrictive)
  diagnosis        JSONB,                -- clé: key-phi-{establishment_id}
  prescriptions    JSONB,
  -- Recherche sans déchiffrement
  nss_hash         TEXT        NOT NULL UNIQUE,  -- hash déterministe du NIR
  created_at       TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE patient_access_logs (
  id              SERIAL PRIMARY KEY,
  patient_id      INTEGER     NOT NULL REFERENCES patients(id),
  practitioner_id INTEGER     NOT NULL,
  accessed_fields TEXT[]      NOT NULL,  -- ['diagnosis', 'prescriptions']
  reason          TEXT        NOT NULL,
  accessed_at     TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_patients_nss_hash     ON patients(nss_hash);
CREATE INDEX idx_patients_establishment ON patients(establishment_id);

3. Créer un dossier patient

import { Mafate } from '@mafate/sdk'

const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })

// Clé dédiée par établissement : isolation totale inter-établissements
function getIdentityKeyId(establishmentId: number) {
  return `key-pht-${establishmentId}`  // PHT = Protected Health Identity
}
function getMedicalKeyId(establishmentId: number) {
  return `key-phi-${establishmentId}`  // PHI = Protected Health Information
}

// POST /api/patients : créer un dossier patient
app.post('/api/patients', async (req, res) => {
  const { establishmentId, lastName, firstName, birthdate, nss, diagnosis } = req.body

  const identityKey = getIdentityKeyId(establishmentId)
  const medicalKey  = getMedicalKeyId(establishmentId)

  // Chiffrer les données d'identité
  const [encLastName, encFirstName, encBirthdate, encNss] = await Promise.all([
    mafate.encryptLocal(lastName,   identityKey),
    mafate.encryptLocal(firstName,  identityKey),
    mafate.encryptLocal(birthdate,  identityKey),
    mafate.encryptLocal(nss,        identityKey),
  ])

  // Chiffrer les données médicales avec une clé plus restrictive
  const encDiagnosis = diagnosis
    ? await mafate.encryptLocal(diagnosis, medicalKey)
    : null

  // Hash déterministe du NIR pour la recherche
  const nssHash = await mafate.hash(nss.replace(/\s/g, ''), identityKey)

  const result = await db.query(
    `INSERT INTO patients
      (establishment_id, last_name, first_name, birthdate, nss, diagnosis, nss_hash)
     VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
    [
      establishmentId,
      JSON.stringify(encLastName),  JSON.stringify(encFirstName),
      JSON.stringify(encBirthdate), JSON.stringify(encNss),
      encDiagnosis ? JSON.stringify(encDiagnosis) : null,
      nssHash,
    ]
  )

  res.json({ patientId: result.rows[0].id })
})

4. Accès avec journalisation

Chaque accès à un dossier patient doit être journalisé avec l'identité du praticien et le motif. La journalisation se fait AVANT le déchiffrement.

// Journaliser chaque accès à un dossier patient (RGPD + HDS)
async function getPatientRecord(
  patientId: number,
  practitionerId: number,
  reason: string,
  fieldsToAccess: string[]
) {
  const row = await db.query(
    'SELECT * FROM patients WHERE id = $1',
    [patientId]
  )

  // Journaliser AVANT de déchiffrer
  await db.query(
    `INSERT INTO patient_access_logs
      (patient_id, practitioner_id, accessed_fields, reason)
     VALUES ($1, $2, $3, $4)`,
    [patientId, practitionerId, fieldsToAccess, reason]
  )

  // Déchiffrer uniquement les champs demandés
  const result: Record<string, string> = {}
  const establishmentId = row.rows[0].establishment_id

  if (fieldsToAccess.includes('identity')) {
    const iKey = `key-pht-${establishmentId}`
    result.lastName   = await mafate.decryptLocalToString(JSON.parse(row.rows[0].last_name))
    result.firstName  = await mafate.decryptLocalToString(JSON.parse(row.rows[0].first_name))
    result.birthdate  = await mafate.decryptLocalToString(JSON.parse(row.rows[0].birthdate))
  }

  if (fieldsToAccess.includes('diagnosis') && row.rows[0].diagnosis) {
    const mKey = `key-phi-${establishmentId}`
    result.diagnosis = await mafate.decryptLocalToString(JSON.parse(row.rows[0].diagnosis))
  }

  return result
}

5. Suppression définitive (RGPD Art.17)

Le crypto-shredding permet de satisfaire le droit à l'effacement sans modifier chaque ligne de chaque table. En désactivant la clé, toutes les données chiffrées deviennent illisibles instantanément.

// RGPD Art.17 : droit à l'effacement d'un patient
// Le crypto-shredding rend toutes les données médicales inaccessibles
// sans avoir à parcourir et modifier chaque enregistrement

async function deletePatient(patientId: number, establishmentId: number) {
  // 1. Désactiver les clés de cet établissement pour ce patient
  //    (si clés dédiées par patient)
  const identityKeyId = `key-pht-patient-${patientId}`
  const medicalKeyId  = `key-phi-patient-${patientId}`

  await Promise.all([
    mafate.keys.disable(identityKeyId),
    mafate.keys.disable(medicalKeyId),
  ])

  // 2. Supprimer la ligne (les logs d'accès peuvent être conservés
  //    anonymisés selon obligations légales)
  await db.query(
    'UPDATE patients SET last_name=null, first_name=null, nss=null, diagnosis=null WHERE id=$1',
    [patientId]
  )

  // Résultat : aucun plaintext récupérable, conformité RGPD Art.17 satisfaite
  // Conformité HDS : traçabilité de la destruction conservée dans audit_logs
}