Passkey KEK-derivatie: deriveBits vs deriveKey

PayCals passkey-login gebruikt een key encryption key (KEK) die in een Web Worker wordt afgeleid met de WebCrypto API. In mei 2026 hebben we die derivatie gewijzigd van deriveKey() naar deriveBits() + importKey(), om een bekend hang-pad in Safari / WebKit te vermijden. Dit artikel legt uit wat is veranderd, waarom beide paden cryptografisch identiek zijn en hoe we dat hebben geverifieerd.

Samenvatting

Wijzigingsdatum 18 mei 2026
Gewijzigd bestand html/js/calendar/crypto-worker.js
Betrokken functie derivePasskeyKEK()
Algoritme HKDF-SHA-256 → AES-GCM-256 (ongewijzigd)
Reden Bekend deriveKey()-Worker-hangpad in Safari / WebKit vermijden
Cryptografische impact Geen. Beide paden produceren hetzelfde sleutelmateriaal.
Verificatie Pariteitstest tests/crypto/hkdf-equivalence.mjs bevestigt identiek decryptgedrag

Achtergrond: passkey-KEK-architectuur

PayCal beschermt gegevens per gebruiker met een data encryption key (DEK) die versleuteld wordt opgeslagen, nooit als platte tekst. Om die bij het inloggen te ontsleutelen, leidt PayCal een key encryption key (KEK) af uit de passkey-credential van de gebruiker.

De derivatie draait in een Web Worker, een gesandboxte achtergrondthread die cryptografische operaties gescheiden van de hoofdthread van de browser uitvoert. De Worker roept de WebCrypto API van de browser (crypto.subtle) aan om de HKDF-derivatiestap uit te voeren.

De invoer voor de derivatie is de stabiele credentialId uit de WebAuthn-passkey-credential, niet de handtekening. ECDSA-handtekeningen zijn niet deterministisch; een KEK die uit een handtekening wordt afgeleid zou bij elke login verschillen en de DEK niet kunnen unwrapen. Gebruik van de credentialId garandeert dat dezelfde KEK wordt geproduceerd telkens wanneer de gebruiker met hetzelfde apparaat authenticeert.

De HKDF-parameters zijn:

  • Algoritme: HKDF-SHA-256
  • IKM: UTF-8-codering van credentialId
  • Salt: willekeurige 32-byte waarde per gebruiker, server-side opgeslagen
  • Info: vaste domeinstring paycal-passkey-kek
  • Output: 256-bit AES-GCM-sleutel (niet extraheerbaar)

De WebCrypto API: twee paden naar dezelfde sleutel

De WebCrypto API biedt twee manieren om uit HKDF-invoer een afgeleide sleutel te produceren:

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
Voert HKDF-derivatie uit en geeft direct een CryptoKey-object terug. Dit is de compactere call: de caller specificeert het output-key-algoritme, zoals AES-GCM-256, in één stap.
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
Voert HKDF-derivatie uit en geeft eerst ruwe bytes terug. De caller importeert die bytes daarna als CryptoKey met een tweede importKey-call. Dit is een tweestaps equivalent: het resulterende sleutelobject is identiek in type, lengte, extraheerbaarheid en toegestane usages.

Beide paden roepen dezelfde HKDF-SHA-256-functie aan, gebruiken dezelfde IKM-, salt- en info-parameters en produceren dezelfde 256 bits sleutelmateriaal. Het enige verschil is hoe de Web Worker-runtime de call intern verwerkt.

Het probleem: geobserveerde deriveKey()-hang in Safari / WebKit Web Workers

Er is een operationeel geobserveerd deriveKey()-hangpad in Safari / WebKit wanneer de call vanuit een Web Worker wordt gedaan. Safari-gebruikers konden een loginflow ervaren die simpelweg stopte, zonder fout, timeout of zichtbaar feedback, terwijl werd gewacht tot de Worker de derivatie afrondde.

De deriveBits()-call, gebruikt om dezelfde output te produceren, vertoont deze hang niet. Het probleem is specifiek voor het deriveKey()-codepad binnen de Worker-uitvoeringscontext; het raakt het onderliggende HKDF-algoritme zelf niet.

deriveKey() en deriveBits() zijn beide first-class WebCrypto APIs, gedocumenteerd door Apple en gespecificeerd door W3C. Hun semantiek is identiek voor deze use case. De hang is een runtime-regressie, geen ontwerpverschil tussen de twee calls.

Voor deze exacte combinatie van Worker + HKDF + deriveKey() is geen canoniek openbaar Apple-bugticket geïndexeerd. Dit past bij een klasse Safari/WebKit edge-case crypto-regressies die operationeel worden ontdekt, in productie worden omzeild en niet formeel in een openbaar ticket worden gevolgd. Onze engineeringbeslissing is het problematische callpad volledig te vermijden, ongeacht WebKit-versie, in plaats van browserstatus runtime te detecteren.

Wat we hebben gewijzigd

De functie derivePasskeyKEK() in html/js/calendar/crypto-worker.js is bijgewerkt om deriveBits() + importKey() als enige derivatiepad te gebruiken. De deriveKey()-call is volledig verwijderd.

De nieuwe derivatiereeks is:

  1. IKM-bytes als HKDF-basissleutel importeren via importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits'])
  2. 256 ruwe bits afleiden via deriveBits(hkdfParams, ikm, 256)
  3. Die bits als AES-GCM-256-sleutel importeren via importKey('raw', keyBits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])

De HKDF-parameters (SHA-256-hash, salt per gebruiker, vaste info-string paycal-passkey-kek) zijn ongewijzigd. Het output-sleuteltype, de lengte en de niet-extraheerbare vlag zijn ongewijzigd. Bestaande wrapped DEKs van gebruikers die eerder het oude codepad gebruikten blijven volledig compatibel: geen re-wrapping of herinschrijving nodig.

Bewijs van cryptografische equivalentie

We stellen equivalentie niet alleen op basis van redenering. Het bestand tests/crypto/hkdf-equivalence.mjs bevat een pariteitstest die:

  1. Een sleutel afleidt via Pad A (deriveKey()) met dezelfde HKDF-parameters
  2. Een sleutel afleidt via Pad B (deriveBits() + importKey()) met dezelfde HKDF-parameters
  3. Een testpayload versleutelt met de Pad A-sleutel
  4. Die ciphertext ontsleutelt met de Pad B-sleutel
  5. Controleert dat de ontsleutelde bytes exact overeenkomen met de oorspronkelijke plaintext
  6. Dit omgekeerd herhaalt: versleutelen met Pad B, ontsleutelen met Pad A

Als beide ontsleutelingen slagen en dezelfde bytes produceren, zijn de sleutels operationeel identiek. De test slaagt in Node.js 18+ en wordt in ons SOC 2 false-positive adjudication ledger genoemd als bewijsitem CRYPTO-001.

Wat niet is veranderd

  • Het sleutelderivatiealgoritme blijft HKDF-SHA-256
  • De outputsleutel blijft 256-bit AES-GCM
  • De outputsleutel blijft niet extraheerbaar uit de Worker-context
  • De IKM-bron blijft de stabiele credentialId (niet de ECDSA-handtekening)
  • De HKDF-info-string blijft paycal-passkey-kek
  • Salts per gebruiker zijn ongewijzigd
  • Geen gebruikersactie, herinschrijving of credentialrotatie is nodig
  • De data encryption key (DEK) die onder de oude KEK is gewrapped blijft volledig compatibel met de nieuwe KEK: de bytes zijn dezelfde sleutel

Waarom we dit publiceren

Deze wijziging heeft geen invloed op de beveiliging. Ze beïnvloedt wel hoe onze code zich gedraagt op een specifieke browserengine en raakt het meest beveiligingsgevoelige deel van onze client-side cryptografiestack.

We publiceren dit omdat ons transparantiebeleid vereist dat elke wijziging in sleutelderivatielogica, zelfs een gedragsneutrale wijziging, openbaar wordt gedocumenteerd met technische onderbouwing en verificatiebewijs voor gebruikers en auditors.

Cryptografische wijzigingen zouden nooit onzichtbaar moeten zijn, zelfs wanneer ze bewust inert zijn.

Referenties