Chiffrement de bout en bout
Requis pour toute intégration active : la borne chiffre le parcours patient avec une clé de session éphémère que seul VOTRE serveur peut déballer. Le serveur ApiBorne et tous les intermédiaires réseau sont mathématiquement aveugles.
Pourquoi : zero-knowledge
Les données du parcours (identité, NIR, documents scannés) circulent déjà en direct borne → éditeur, sous TLS. Le chiffrement de bout en bout ajoute une couche applicative par-dessus : l'établissement génère une paire de clés RSA, la clé privée reste sur votre serveur, la clé publique est saisie dans l'admin ApiBorne et poussée à la borne via sa configuration. Résultat : proxys, WAF, tunnels de dev (ngrok), hébergeurs — et le serveur ApiBorne lui-même — ne voient jamais une donnée patient en clair.
La séquence complète
# L'ÉTABLISSEMENT génère sa paire de clés (la privée reste chez vous) :
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out private.pem
openssl pkey -in private.pem -pubout
# → coller le bloc BEGIN PUBLIC KEY dans l'admin ApiBorne (page Connectivité)
# Avec l'implémentation de référence : npm run keys:generate
# (stocke la privée dans les réglages de la démo, affiche la publique)Chiffrement hybride : les deux sens
Le protocole combine deux algorithmes aux rôles distincts. RSA-OAEP (asymétrique) ne sert qu'une seule fois, à l'aller, pour transporter en sécurité une clé de session. AES-256-GCM (symétrique) chiffre le contenu dans les deux sens. C'est le même principe que TLS : l'asymétrique établit un secret partagé, le symétrique fait tout le reste.
Le point qui surprend souvent : la borne n'a que la clé publique, alors comment déchiffre-t-elle la réponse de l'éditeur ? Réponse : elle ne déchiffre jamais avec RSA. Elle déchiffre avec la clé de session AES qu'elle a elle-même générée (étape 1) et conservée en mémoire. L'éditeur, après avoir déballé cette clé AES avec sa clé privée, chiffre sa réponse avec la même clé AES (et un IV neuf). AES étant symétrique, la même clé sert à chiffrer et à déchiffrer : la borne lit donc la réponse sans jamais toucher à RSA.
Pourquoi la clé publique ne déchiffre pas
La clé publique est stockée côté serveur ApiBorne (base de configuration, hébergement) pour être poussée à la borne. Question légitime : si quelqu'un compromet cet hébergement (ou la base, ou le trafic réseau), peut-il déchiffrer les échanges ? Non. Une clé publique RSA ne sait que chiffrer (wrapper la clé de session) ; seule la clé privée peut faire l'inverse. Et la clé privée n'est nulle part sur ce périmètre : elle vit uniquement chez l'éditeur.
- le corps est chiffré AES-GCM : illisible sans la clé de session AES ;
- le header
X-Kiosk-Encryption-Keycontient cette clé AES wrappée par RSA : illisible sans la clé privée ; - la clé publique en base a la capacité
encryptuniquement — elle ne déballe rien.
Pour lire un message, il faudrait la clé privée, donc casser RSA-4096 (hors de portée) ou lire la RAM de la borne à l'instant précis d'une requête. Compromettre l'hébergement ApiBorne ne donne, lui, que du chiffré. C'est le sens de « zero-knowledge » : les relais ne détiennent aucun secret utile.
X-Kiosk-Device-Id), complémentaire du chiffrement.Périmètre
Le chiffrement couvre les 10 routes de communication émises par la borne (requêtes ET réponses 2xx) : identify, by-code, RDV par id, PATCH patient, prescripteur, documents (GET/POST/DELETE), check-in, notification-readiness.
- PAS les routes de configuration (
/config/*) : ce sont des référentiels sans donnée patient, consommés par le serveur ApiBorne ; - PAS
staff/sign-inniPUT …/status: émises par le serveur ApiBorne, qui est le consommateur légitime de leurs réponses — le bout-en-bout n'a pas d'objet, TLS suffit.
Le protocole v1
POST /api/apiborneIntegrationService/v1/patients/identify
X-Kiosk-Auth-Key: <clé partagée>
X-Kiosk-Device-Id: <id du device>
X-Kiosk-Encryption: v1
X-Kiosk-Encryption-Key: <sessionKey AES-256 wrappée RSA-OAEP-SHA256, base64>
Content-Type: application/json
{ "encrypted": { "v": 1, "iv": "<base64, 12 octets>", "data": "<base64, ciphertext+tag>" } }- AES-256-GCM : IV de 96 bits, tag d'authentification de 128 bits concaténé à la fin du ciphertext (convention WebCrypto) ;
- RSA-OAEP-SHA256 : wrappe la sessionKey de 32 octets (clé RSA ≥ 2048 bits, 4096 recommandé) ;
- sessionKey unique PAR TENTATIVE : chaque requête (et chaque retry) porte une clé et un IV neufs ;
- les headers sont envoyés sur toutes les méthodes, GET inclus (leurs réponses sont sensibles) ; sur GET/DELETE il n'y a pas de corps à déchiffrer, la sessionKey sert à chiffrer votre réponse ;
- réponse 2xx : même enveloppe, même sessionKey, IV neuf, plus le header
X-Kiosk-Encryption: v1; 204 : rien ; erreurs 4xx/5xx : toujours en clair (retry et diagnostic préservés — jamais de donnée patient dansmessage) ; - indéchiffrable →
400 DECRYPTION_FAILEDen clair. La borne ne retente jamais et ne bascule jamais en clair (anti-downgrade) ; - taille : la limite de 10 Mo de l'upload s'entend sur le JSON en clair ; prévoyez ~+35 % sur le corps HTTP chiffré (base64) ;
- CORS : ajoutez
X-Kiosk-Encryption, X-Kiosk-Encryption-KeyàAccess-Control-Allow-Headerset exposezX-Kiosk-Encryption(Access-Control-Expose-Headers) — voir Authentification.
Implémenter le déchiffrement
// Déchiffrer une requête (node:crypto) — extrait de l'implémentation de référence
import crypto from "node:crypto";
function unwrapSessionKey(wrappedKeyBase64, privateKeys /* rotation : la + récente d'abord */) {
const wrapped = Buffer.from(wrappedKeyBase64, "base64");
for (const key of privateKeys) {
try {
const sessionKey = crypto.privateDecrypt(
{ key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
wrapped,
);
if (sessionKey.length === 32) return sessionKey;
} catch { /* mauvaise clé (rotation) → essayer la suivante */ }
}
throw new Error("DECRYPTION_FAILED");
}
function decryptEnvelope(sessionKey, { iv, data }) {
const payload = Buffer.from(data, "base64");
const ciphertext = payload.subarray(0, payload.length - 16);
const authTag = payload.subarray(payload.length - 16); // tag GCM concaténé (WebCrypto)
const decipher = crypto.createDecipheriv("aes-256-gcm", sessionKey, Buffer.from(iv, "base64"));
decipher.setAuthTag(authTag);
return JSON.parse(Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"));
}// Chiffrer la réponse 2xx : MÊME sessionKey, IV NEUF (jamais celui de la requête)
function sealResponse(sessionKey, payload) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", sessionKey, iv);
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
return {
encrypted: {
v: 1,
iv: iv.toString("base64"),
data: Buffer.concat([ciphertext, cipher.getAuthTag()]).toString("base64"),
},
};
}
// + header de réponse : X-Kiosk-Encryption: v1Et côté borne, pour comprendre exactement le format produit :
// Ce que fait la borne (WebCrypto natif) — pour comprendre le format
const sessionKeyRaw = crypto.getRandomValues(new Uint8Array(32));
const wrapped = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" }, // hash SHA-256 fixé à l'import de la clé
publicKey, // importKey('spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, …)
sessionKeyRaw,
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( // tag de 128 bits CONCATÉNÉ à la fin
{ name: "AES-GCM", iv },
sessionKey,
new TextEncoder().encode(JSON.stringify(body)),
);Implémentation de référence complète : src/server/contract/encryption.ts (unwrap avec rotation, wrapper de routes, erreurs typées) — et l'oracle de test scripts/encrypted-curl.mjs pour valider votre implémentation depuis la ligne de commande (voir Tester) :
# L'oracle de test de l'implémentation de référence : chiffre → appelle → déchiffre
node scripts/encrypted-curl.mjs POST /patients/identify '{"criteria":{"lastName":"MARTIN"}}' \
--url https://votre-serveur.example.com/api/apiborneIntegrationService/v1 \
--public-key votre-cle-publique.pem
# Votre implémentation doit refuser un tag corrompu :
node scripts/encrypted-curl.mjs POST /patients/identify '{"criteria":{"lastName":"MARTIN"}}' --corrupt-tag
# → attendu : 400 { "error": { "code": "DECRYPTION_FAILED", … } }Côté .NET Framework 4.8 (pas d'AesGcm BCL) : utilisez BouncyCastle.Cryptography (GcmBlockCipher + OaepEncoding SHA-256) — le tag concaténé correspond au format natif de BouncyCastle (DoFinal l'ajoute en fin de sortie).
Rotation des clés
Conservez N clés privées et essayez chacune à l'unwrap : l'échec RSA-OAEP sur une mauvaise clé est immédiat, la première qui rend 32 octets est la bonne (le tag GCM authentifie ensuite l'ensemble).
DECRYPTION_FAILED.Pièges
- Tag GCM : il est concaténé au ciphertext dans
data, pas dans un champ séparé — coupez les 16 derniers octets ; - IV de réponse : toujours un IV neuf — réutiliser l'IV de la requête avec la même clé casse GCM ;
- CORS : oublier les 2 headers fait échouer le preflight silencieusement (la borne voit une erreur réseau) ;
- Erreurs : ne chiffrez jamais les 4xx/5xx, et ne mettez jamais de donnée patient dans
error.message; - Limite de corps HTTP : le transport chiffré d'un upload de 10 Mo fait ~13,5 Mo — dimensionnez serveur HTTP et reverse proxy en conséquence ;
- Pas de fallback : headers de chiffrement présents mais corps en clair = violation de protocole →
400 DECRYPTION_FAILED(jamais « on accepte quand même »).
