Passkey-KEK-Ableitung: deriveBits vs deriveKey

PayCals Passkey-Login nutzt einen key encryption key (KEK), der in einem Web Worker über die WebCrypto API abgeleitet wird. Im Mai 2026 haben wir diese Ableitung von deriveKey() auf deriveBits() + importKey() umgestellt, um einen bekannten Hang-Pfad in Safari / WebKit zu vermeiden. Dieser Artikel erklärt die Änderung, warum beide Pfade kryptografisch identisch sind und wie wir diese Aussage verifiziert haben.

Zusammenfassung

Änderungsdatum 18. Mai 2026
Geänderte Datei html/js/calendar/crypto-worker.js
Betroffene Funktion derivePasskeyKEK()
Algorithmus HKDF-SHA-256 → AES-GCM-256 (unverändert)
Grund Bekannten deriveKey()-Worker-Hang-Pfad in Safari / WebKit vermeiden
Kryptografische Auswirkung Keine. Beide Pfade erzeugen dasselbe Schlüsselmaterial.
Verifikation Paritätstest tests/crypto/hkdf-equivalence.mjs bestätigt identisches Entschlüsselungsverhalten

Hintergrund: Passkey-KEK-Architektur

PayCal schützt nutzerbezogene Daten mit einem data encryption key (DEK), der verschlüsselt gespeichert wird, nie im Klartext. Um ihn beim Login zu entschlüsseln, leitet PayCal einen key encryption key (KEK) aus dem Passkey-Credential des Nutzers ab.

Die Ableitung läuft in einem Web Worker, einem sandboxed Hintergrund-Thread, der kryptografische Operationen getrennt vom Haupt-Browser-Thread ausführt. Der Worker ruft die WebCrypto API des Browsers (crypto.subtle) auf, um den HKDF-Ableitungsschritt auszuführen.

Eingabe für die Ableitung ist die stabile credentialId aus dem WebAuthn-Passkey-Credential, nicht die Signatur. ECDSA-Signaturen sind nicht deterministisch; jeder aus einer Signatur abgeleitete KEK wäre bei jedem Login anders und könnte den DEK nicht entpacken. Die Nutzung der credentialId garantiert, dass bei jeder Authentifizierung mit demselben Gerät derselbe KEK entsteht.

Die HKDF-Parameter sind:

  • Algorithmus: HKDF-SHA-256
  • IKM: UTF-8-Encoding von credentialId
  • Salt: Pro Nutzer zufälliger 32-Byte-Wert, serverseitig gespeichert
  • Info: Fester Domain-String paycal-passkey-kek
  • Output: 256-Bit-AES-GCM-Schlüssel (nicht extrahierbar)

Die WebCrypto API: Zwei Pfade zum selben Schlüssel

Die WebCrypto API stellt zwei Wege bereit, um aus einer HKDF-Eingabe einen abgeleiteten Schlüssel zu erzeugen:

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
Führt die HKDF-Ableitung aus und gibt direkt ein CryptoKey-Objekt zurück. Dies ist der kompaktere Aufruf: Der Aufrufer gibt den Ausgabeschlüsselalgorithmus, etwa AES-GCM-256, in einem Schritt an.
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
Führt die HKDF-Ableitung aus und gibt zuerst rohe Bytes zurück. Danach importiert der Aufrufer diese Bytes mit einem zweiten importKey-Aufruf als CryptoKey. Das ist zweistufig äquivalent: Der resultierende Schlüssel ist in Typ, Länge, Extrahierbarkeit und erlaubter Nutzung identisch.

Beide Pfade rufen dieselbe HKDF-SHA-256-Funktion auf, verwenden dieselben IKM-, Salt- und Info-Parameter und erzeugen dieselben 256 Bit Schlüsselmaterial. Der einzige Unterschied ist, wie die Web-Worker-Laufzeit den Aufruf intern verarbeitet.

Das Problem: Beobachteter deriveKey()-Hang in Safari / WebKit Web Workers

In Safari / WebKit gibt es einen operativ beobachteten deriveKey()-Hang-Pfad, wenn der Aufruf aus einem Web Worker erfolgt. Safari-Nutzer konnten einen Login-Flow erleben, der einfach stoppte: kein Fehler, kein Timeout und kein sichtbares Feedback, während auf den Abschluss der Ableitung gewartet wurde.

Der deriveBits()-Aufruf, der dieselbe Ausgabe erzeugt, zeigt diesen Hang nicht. Das Problem liegt spezifisch im deriveKey()-Codepfad innerhalb des Worker-Ausführungskontexts; der zugrunde liegende HKDF-Algorithmus selbst ist nicht betroffen.

deriveKey() und deriveBits() sind beide erstklassige WebCrypto APIs, von Apple dokumentiert und durch das W3C spezifiziert. Für diesen Anwendungsfall sind ihre Semantiken identisch. Der Hang ist eine Runtime-Regression, kein Designunterschied zwischen den beiden Aufrufen.

Für diese exakte Kombination aus Worker + HKDF + deriveKey() ist kein kanonisches Apple-Bugticket öffentlich indexiert. Das passt zu einer Klasse von Safari/WebKit-Edge-Case-Krypto-Regressionen, die operativ entdeckt, in Produktion umgangen und nicht öffentlich sichtbar formal verfolgt werden. Unsere technische Entscheidung ist, den problematischen Aufrufpfad unabhängig von der WebKit-Version vollständig zu vermeiden, statt Browserzustand zur Laufzeit zu erkennen.

Was wir geändert haben

Die Funktion derivePasskeyKEK() in html/js/calendar/crypto-worker.js wurde so aktualisiert, dass deriveBits() + importKey() der einzige Ableitungspfad ist. Der deriveKey()-Aufruf wurde vollständig entfernt.

Die neue Ableitungssequenz ist:

  1. IKM-Bytes als HKDF-Basisschlüssel über importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits']) importieren
  2. 256 rohe Bits über deriveBits(hkdfParams, ikm, 256) ableiten
  3. Diese Bits als AES-GCM-256-Schlüssel über importKey('raw', keyBits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) importieren

Die HKDF-Parameter (SHA-256-Hash, pro Nutzer Salt, fester Info-String paycal-passkey-kek) sind unverändert. Schlüsseltyp, Länge und Nicht-Extrahierbarkeit der Ausgabe sind unverändert. Bestehende wrapped DEKs von Nutzern, die zuvor den alten Codepfad verwendet haben, bleiben vollständig kompatibel: kein Re-Wrapping und keine erneute Registrierung erforderlich.

Nachweis kryptografischer Äquivalenz

Wir behaupten Äquivalenz nicht nur durch Argumentation. Die Datei tests/crypto/hkdf-equivalence.mjs enthält einen Paritätstest, der:

  1. Einen Schlüssel über Pfad A (deriveKey()) mit denselben HKDF-Parametern ableitet
  2. Einen Schlüssel über Pfad B (deriveBits() + importKey()) mit denselben HKDF-Parametern ableitet
  3. Eine Test-Payload mit dem Pfad-A-Schlüssel verschlüsselt
  4. Diesen Ciphertext mit dem Pfad-B-Schlüssel entschlüsselt
  5. Prüft, dass die entschlüsselten Bytes exakt dem ursprünglichen Plaintext entsprechen
  6. Den Ablauf umkehrt: mit Pfad B verschlüsseln, mit Pfad A entschlüsseln

Wenn beide Entschlüsselungen gelingen und dieselben Bytes erzeugen, sind die Schlüssel operativ identisch. Der Test besteht in Node.js 18+ und ist in unserem SOC 2 false-positive adjudication ledger als Evidenzobjekt CRYPTO-001 referenziert.

Was sich nicht geändert hat

  • Der Schlüsselableitungsalgorithmus bleibt HKDF-SHA-256
  • Der Ausgabeschlüssel bleibt 256-Bit-AES-GCM
  • Der Ausgabeschlüssel bleibt aus dem Worker-Kontext nicht extrahierbar
  • Die IKM-Quelle bleibt die stabile credentialId (nicht die ECDSA-Signatur)
  • Der HKDF-Info-String bleibt paycal-passkey-kek
  • Pro-Nutzer-Salts bleiben unverändert
  • Keine Nutzeraktion, erneute Registrierung oder Credential-Rotation ist nötig
  • Der data encryption key (DEK), der unter dem alten KEK gewrappt wurde, ist vollständig mit dem neuen KEK kompatibel: Die Bytes sind derselbe Schlüssel

Warum wir das veröffentlichen

Diese Änderung wirkt sich nicht auf die Sicherheit aus. Sie beeinflusst aber, wie unser Code auf einer bestimmten Browser-Engine läuft, und berührt den sicherheitssensibelsten Teil unseres clientseitigen Kryptografie-Stacks.

Wir veröffentlichen sie, weil unsere Transparenzrichtlinie verlangt, dass jede Änderung an Schlüsselableitungslogik, auch eine verhaltensneutrale, öffentlich dokumentiert wird, inklusive technischer Begründung und Verifikationsevidenz für Nutzer und Auditoren.

Kryptografische Änderungen sollten nie unsichtbar sein, selbst wenn sie absichtlich inert sind.

Referenzen