Chiffrement de bout en bout
Optionnel et opt-in par licence : 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)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 »).
