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 journaux2. 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
}