Dérivation KEK par passkey : deriveBits vs deriveKey

La connexion par passkey de PayCal repose sur une key encryption key (KEK) dérivée dans un Web Worker avec la WebCrypto API. En mai 2026, nous avons changé cette dérivation, en passant de deriveKey() à deriveBits() + importKey(), afin d’éviter un chemin de blocage connu dans Safari / WebKit. Cet article explique le changement, pourquoi les deux chemins sont cryptographiquement identiques et comment nous l’avons vérifié.

Résumé

Date du changement 18 mai 2026
Fichier modifié html/js/calendar/crypto-worker.js
Fonction concernée derivePasskeyKEK()
Algorithme HKDF-SHA-256 → AES-GCM-256 (inchangé)
Raison Éviter le chemin de blocage connu de deriveKey() dans les Workers Safari / WebKit
Impact cryptographique Aucun. Les deux chemins produisent le même matériau de clé.
Vérification Le test de parité tests/crypto/hkdf-equivalence.mjs confirme un comportement de déchiffrement identique

Contexte : architecture KEK des passkeys

PayCal protège les données par utilisateur avec une data encryption key (DEK) stockée chiffrée, jamais en clair. Pour la déchiffrer à la connexion, PayCal dérive une key encryption key (KEK) depuis la credential passkey de l’utilisateur.

La dérivation s’exécute dans un Web Worker, un thread d’arrière-plan isolé qui traite les opérations cryptographiques séparément du thread principal du navigateur. Le Worker appelle la WebCrypto API du navigateur (crypto.subtle) pour effectuer l’étape de dérivation HKDF.

L’entrée de la dérivation est le credentialId stable de la credential passkey WebAuthn, pas la signature. Les signatures ECDSA ne sont pas déterministes ; une KEK dérivée d’une signature changerait à chaque connexion et ne pourrait pas déballer la DEK. L’utilisation du credentialId garantit que la même KEK est produite à chaque authentification avec le même appareil.

Les paramètres HKDF sont :

  • Algorithme : HKDF-SHA-256
  • IKM : encodage UTF-8 de credentialId
  • Salt : valeur aléatoire de 32 octets par utilisateur, stockée côté serveur
  • Info : chaîne de domaine fixe paycal-passkey-kek
  • Sortie : clé AES-GCM 256 bits (non extractible)

La WebCrypto API : deux chemins vers la même clé

La WebCrypto API expose deux façons de produire une clé dérivée depuis une entrée HKDF :

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
Effectue la dérivation HKDF et retourne directement un objet CryptoKey. C’est l’appel le plus compact : l’appelant indique l’algorithme de clé de sortie, par exemple AES-GCM-256, en une seule étape.
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
Effectue la dérivation HKDF et retourne d’abord des octets bruts. L’appelant importe ensuite ces octets en tant que CryptoKey avec un second appel importKey. C’est un équivalent en deux étapes : la clé résultante est identique en type, longueur, extractibilité et usages autorisés.

Les deux chemins invoquent la même fonction HKDF-SHA-256, consomment les mêmes paramètres IKM, salt et info, et produisent les mêmes 256 bits de matériau de clé. La seule différence est la façon dont le runtime du Web Worker traite l’appel en interne.

Le problème : blocage observé de deriveKey() dans les Web Workers Safari / WebKit

Un chemin de blocage deriveKey() a été observé opérationnellement dans Safari / WebKit lorsque l’appel est effectué depuis un Web Worker. Des utilisateurs Safari pouvaient rencontrer un flux de connexion qui s’arrêtait simplement, sans erreur, sans timeout et sans retour visible, en attendant la fin de la dérivation par le Worker.

L’appel deriveBits(), utilisé pour produire la même sortie, ne présente pas ce blocage. Le problème est propre au chemin de code deriveKey() dans le contexte d’exécution du Worker ; il n’affecte pas l’algorithme HKDF sous-jacent.

deriveKey() et deriveBits() sont deux APIs WebCrypto de premier ordre, documentées par Apple et spécifiées par le W3C. Leurs sémantiques sont identiques pour ce cas d’usage. Le blocage est une régression runtime, pas une différence de conception entre les deux appels.

Aucun ticket Apple public canonique n’est indexé pour cette combinaison exacte Worker + HKDF + deriveKey(). Cela correspond à une classe de régressions crypto edge-case Safari/WebKit découvertes opérationnellement, contournées en production et non suivies dans un ticket public visible. Notre décision d’ingénierie est d’éviter entièrement le chemin problématique, quelle que soit la version WebKit, plutôt que de détecter l’état du navigateur au runtime.

Ce que nous avons changé

La fonction derivePasskeyKEK() dans html/js/calendar/crypto-worker.js a été mise à jour pour utiliser deriveBits() + importKey() comme seul chemin de dérivation. L’appel deriveKey() a été entièrement supprimé.

La nouvelle séquence de dérivation est :

  1. Importer les octets IKM comme clé de base HKDF via importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits'])
  2. Dériver 256 bits bruts via deriveBits(hkdfParams, ikm, 256)
  3. Importer ces bits comme clé AES-GCM-256 via importKey('raw', keyBits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])

Les paramètres HKDF (hash SHA-256, salt par utilisateur, chaîne info fixe paycal-passkey-kek) sont inchangés. Le type de clé de sortie, sa longueur et son indicateur non extractible sont inchangés. Les DEK déjà wrapped pour les utilisateurs ayant utilisé l’ancien chemin restent entièrement compatibles : aucun re-wrapping ni réenrôlement n’est requis.

Preuve d’équivalence cryptographique

Nous n’affirmons pas l’équivalence par raisonnement seul. Le fichier tests/crypto/hkdf-equivalence.mjs contient un test de parité qui :

  1. Dérive une clé via le chemin A (deriveKey()) avec les mêmes paramètres HKDF
  2. Dérive une clé via le chemin B (deriveBits() + importKey()) avec les mêmes paramètres HKDF
  3. Chiffre une charge de test avec la clé du chemin A
  4. Déchiffre ce ciphertext avec la clé du chemin B
  5. Vérifie que les octets déchiffrés correspondent exactement au plaintext d’origine
  6. Répète en sens inverse : chiffrement avec le chemin B, déchiffrement avec le chemin A

Si les deux déchiffrements réussissent et produisent les mêmes octets, les clés sont opérationnellement identiques. Le test passe sous Node.js 18+ et est référencé dans notre SOC 2 false-positive adjudication ledger comme élément de preuve CRYPTO-001.

Ce qui n’a pas changé

  • L’algorithme de dérivation de clé reste HKDF-SHA-256
  • La clé de sortie reste AES-GCM 256 bits
  • La clé de sortie reste non extractible depuis le contexte du Worker
  • La source IKM reste le credentialId stable (pas la signature ECDSA)
  • La chaîne info HKDF reste paycal-passkey-kek
  • Les salts par utilisateur sont inchangés
  • Aucune action utilisateur, aucun réenrôlement et aucune rotation de credential n’est nécessaire
  • La data encryption key (DEK) wrapped sous l’ancienne KEK est entièrement compatible avec la nouvelle KEK : les octets sont la même clé

Pourquoi nous publions cela

Ce changement n’affecte pas la sécurité. Il affecte en revanche le comportement de notre code sur un moteur de navigateur spécifique et touche la partie la plus sensible de notre pile cryptographique côté client.

Nous le publions parce que notre politique de transparence exige que tout changement de logique de dérivation de clés, même neutre en comportement, soit documenté publiquement, avec le raisonnement technique et les preuves de vérification disponibles pour les utilisateurs et les auditeurs.

Les changements cryptographiques ne devraient jamais être invisibles, même lorsqu’ils sont intentionnellement inertes.

Références