ApiBorne
Sommaire du guide

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.

Sans clé configurée, rien ne change : les échanges restent en clair (TLS seul) et vous n'avez rien à implémenter. N'implémentez cette page que si un établissement demande le chiffrement.

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)

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 »).