ApiBorne
Sommaire du guide

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.

Le chiffrement est requis pour toute intégration active. L'admin ApiBorne exige une clé publique E2E avant d'activer un éditeur : sans elle, aucune borne ne vous enverra de trafic. Vous devez donc implémenter cette page pour recevoir des appels en production.

La séquence complète

Séquence du chiffrement de bout en bout : paire de clés chez l'éditeur, clé publique transportée par la configuration, session AES-256-GCM éphémère par requête.Votre serveurclé PRIVÉE (jamais exposée)Admin + serveur ApiBornerelais aveugleBorne1 · openssl genpkey (RSA-4096)2 · clé PUBLIQUE collée dans l'adminpage Connectivité (validée : RSA ≥ 2048 bits)3 · conf borne : contractEncryptionPublicKey4 · sessionKey AES-256 + IV neufs(par requête, retry inclus)5 · requête chiffrée (transit direct, relais aveugles)X-Kiosk-Encryption-Key + { "encrypted": { v, iv, data } }6 · unwrap RSA-OAEP → AES-GCM7 · réponse 2xx : même sessionKey, IV neuf{ "encrypted": { v, iv, data } } — erreurs 4xx/5xx EN CLAIRLe serveur ApiBorne, les proxys, les WAF…ne voient que du chiffré — aucune clé chez eux
Seul l'éditeur détient la clé privée : le serveur ApiBorne transporte la clé publique dans la conf mais ne peut jamais déchiffrer (zero-knowledge).
génération des clés (côté établissement)
# 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.

Séquence du chiffrement hybride borne ↔ éditeur : RSA-OAEP transporte une fois la clé de session AES, AES-256-GCM chiffre la requête et la réponse dans les deux sens.Borneclé PUBLIQUE + clé AES (RAM)Votre serveurclé PRIVÉE (jamais exposée)1 · génère une clé AES-256 + IValéatoires, gardés en RAM (neufs par tentative)2 · wrappe la clé AES avec la clé PUBLIQUE (RSA-OAEP)3 · requête chiffrée AES-GCMX-Kiosk-Encryption-Key (clé AES wrappée RSA) + { "encrypted": { v, iv, data } }4 · déballe la clé AES avec la clé PRIVÉE (RSA)puis déchiffre la requête en AES-GCM, traite5 · chiffre la réponse avec la MÊME clé AESIV NEUF — pas de RSA au retour6 · réponse 2xx chiffrée AES-GCM{ "encrypted": { v, iv, data } } — erreurs 4xx/5xx EN CLAIR7 · déchiffre avec SA clé AES (RAM)AES est symétrique : même clé chiffre et déchiffreRSA = 1 seul emploi (transporter la clé AES à l'aller). AES = confidentialité aller ET retour.C'est le chiffrement hybride — le même principe que TLS.
RSA (asymétrique) n'intervient qu'à l'aller pour emballer la clé AES. La réponse réutilise la MÊME clé AES (symétrique) avec un IV neuf : la borne la déchiffre sans jamais toucher à RSA.

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.

La clé de session AES ne circule jamais en clair. Elle part à l'aller emballée par RSA (illisible sans la clé privée) et ne revient pas : chacun la garde de son côté le temps de l'échange. Elle est de plus éphémère — neuve à chaque requête (retry inclus).

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.

Vue de l'attaquant : avec l'accès à l'hébergement ApiBorne, à la base de configuration et au trafic réseau, il n'obtient que du chiffré et la clé publique — jamais la clé privée.Ce qu'un accès à l'hébergeur / à la base / au réseau exposeCorps du message (AES-GCM)illisible sans la clé de session AESX-Kiosk-Encryption-Keyclé AES wrappée RSA — illisible sans la clé PRIVÉEClé PUBLIQUE (base de config / Mongo)capacité 'encrypt' uniquement — ne déchiffre RIENClé PRIVÉE RSAseule capable de déballer la clé AESabsente de ce périmètrevit chez l'éditeur (serveur on-premise)Sans la clé privée, il faudrait casser RSA-4096 ou lire la RAM de la borne à l'instant T.La clé de session AES est éphémère (neuve par requête) : rien à voler durablement.Nuance intégrité : la clé publique permet de FORGER une requête chiffrée valide —c'est l'authentification (X-Kiosk-Auth-Key) qui protège l'origine, pas le chiffrement.
La clé publique ne sait que chiffrer. Sans la clé privée (chez l'éditeur), la clé de session AES reste inaccessible : le contenu ne se déchiffre pas.
  • le corps est chiffré AES-GCM : illisible sans la clé de session AES ;
  • le header X-Kiosk-Encryption-Key contient cette clé AES wrappée par RSA : illisible sans la clé privée ;
  • la clé publique en base a la capacité encrypt uniquement — 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.

Chiffrement ≠ authentification. Détenir la clé publique permet de forger une requête chiffrée que l'éditeur déchiffrera correctement (n'importe qui peut chiffrer). Ce que la clé publique ne donne pas, c'est la capacité de lire les échanges existants. La protection de l'origine repose sur l'authentification (X-Kiosk-Auth-Key et 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-in ni PUT …/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

requête chiffrée
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 dans message) ;
  • indéchiffrable → 400 DECRYPTION_FAILED en 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-Headers et exposez X-Kiosk-Encryption (Access-Control-Expose-Headers) — voir Authentification.

Implémenter le déchiffrement

javascript
// 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"));
}
javascript
// 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: v1

Et côté borne, pour comprendre exactement le format produit :

ce que fait la borne (WebCrypto)
// 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) :

bash
# 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).

Ordre de déploiement impératif : l'éditeur d'abord. Déployez la nouvelle clé privée chez vous AVANT que la nouvelle clé publique soit saisie dans l'admin ApiBorne ; retirez l'ancienne privée une fois la conf propagée aux bornes (redémarrage). Une clé publique saisie avant sa clé privée = toutes les bornes en 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 »).