Passkey KEK Derivation: deriveBits vs deriveKey

PayCal's passkey login relies on a key encryption key (KEK) derived inside a Web Worker using the WebCrypto API. In May 2026 we changed how that derivation is performed, switching from deriveKey() to deriveBits() + importKey(), to avoid a known hang path in Safari / WebKit. This article explains the change, why the two paths are cryptographically identical, and how we verified that claim.

Summary

Change date May 18, 2026
File changed html/js/calendar/crypto-worker.js
Function affected derivePasskeyKEK()
Algorithm HKDF-SHA-256 → AES-GCM-256 (unchanged)
Reason Avoid known deriveKey() Worker hang path in Safari / WebKit
Cryptographic impact None. Both paths produce the same key material.
Verification Parity test tests/crypto/hkdf-equivalence.mjs confirms identical decrypt behavior

Background: Passkey KEK Architecture

PayCal protects per-user data with a data encryption key (DEK) that is stored encrypted, never in plain text. To decrypt it at login time, PayCal derives a key encryption key (KEK) from the user's passkey credential.

The derivation runs inside a Web Worker, a sandboxed background thread that handles cryptographic operations in isolation from the main browser thread. The Worker calls into the browser's WebCrypto API (crypto.subtle) to perform the HKDF key derivation step.

The input to the derivation is the stable credentialId from the WebAuthn passkey credential, not the signature. ECDSA signatures are non-deterministic, so any KEK derived from a signature would differ on every login and fail to unwrap the DEK. Using the credentialId guarantees the same KEK is produced every time the user authenticates with the same device.

The HKDF parameters are:

  • Algorithm: HKDF-SHA-256
  • IKM: UTF-8 encoding of credentialId
  • Salt: Per-user random 32-byte value stored server-side
  • Info: Fixed domain string paycal-passkey-kek
  • Output: 256-bit AES-GCM key (non-extractable)

The WebCrypto API: Two Paths to the Same Key

The WebCrypto API exposes two ways to produce a derived key from an HKDF input:

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
Performs HKDF derivation and returns a CryptoKey object directly. This is the more compact call: the caller specifies the output key algorithm, such as AES-GCM-256, in a single step.
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
Performs HKDF derivation and returns raw bytes first. The caller then imports those bytes as a CryptoKey using a second importKey call. This is a two-step equivalent: the resulting key object is identical in type, length, extractability, and permitted usages.

Both paths invoke the same HKDF-SHA-256 function, consume the same IKM, salt, and info parameters, and produce the same 256 bits of key material. The only difference is how the Web Worker runtime processes the call internally.

The Problem: Observed deriveKey() Hang in Safari / WebKit Web Workers

There is an operationally observed deriveKey() hang path in Safari / WebKit when the call is made from inside a Web Worker. Users on Safari could encounter a login flow that simply stopped, with no error, no timeout, and no visible feedback, while waiting for the Worker to complete the derivation.

The deriveBits() call, used to produce the same output, does not exhibit this hang. The issue is specific to the deriveKey() code path within the Worker execution context; it does not affect the underlying HKDF algorithm itself.

Both deriveKey() and deriveBits() are first-class WebCrypto APIs documented by Apple and specified by the W3C. Their semantics are identical for this use case. The hang is a runtime regression, not a design difference between the two calls.

No canonical Apple bug ticket for this exact Worker + HKDF + deriveKey() combination is publicly indexed. This is consistent with a class of Safari/WebKit edge-case crypto regressions that are discovered operationally, worked around in production, and not formally tracked in a publicly visible ticket. Our engineering decision is to avoid the problematic call path entirely, regardless of WebKit version, rather than attempting to detect browser state at runtime.

What We Changed

The derivePasskeyKEK() function in html/js/calendar/crypto-worker.js was updated to use deriveBits() + importKey() as the sole derivation path. The deriveKey() call was removed entirely.

The new derivation sequence is:

  1. Import IKM bytes as an HKDF base key via importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits'])
  2. Derive 256 raw bits via deriveBits(hkdfParams, ikm, 256)
  3. Import those bits as an AES-GCM-256 key via importKey('raw', keyBits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])

The HKDF parameters (SHA-256 hash, per-user salt, fixed info string paycal-passkey-kek) are unchanged. The output key type, length, and non-extractable flag are unchanged. Existing wrapped DEKs stored for users who previously used the old code path are fully compatible: no re-wrapping or re-enrollment is required.

Cryptographic Equivalence Proof

We do not assert equivalence by reasoning alone. The file tests/crypto/hkdf-equivalence.mjs contains a parity test that:

  1. Derives a key via Path A (deriveKey()) using the same HKDF parameters
  2. Derives a key via Path B (deriveBits() + importKey()) using the same HKDF parameters
  3. Encrypts a test payload with the Path A key
  4. Decrypts that ciphertext with the Path B key
  5. Asserts that the decrypted bytes match the original plaintext exactly
  6. Repeats in reverse: encrypts with Path B, decrypts with Path A

If both decryptions succeed and produce the same bytes, the keys are operationally identical. The test passes in Node.js 18+ and is referenced in our SOC 2 false-positive adjudication ledger as evidence item CRYPTO-001.

What Did Not Change

  • The key derivation algorithm is still HKDF-SHA-256
  • The output key is still 256-bit AES-GCM
  • The output key is still non-extractable from the Worker context
  • The IKM source is still the stable credentialId (not the ECDSA signature)
  • The HKDF info string is still paycal-passkey-kek
  • Per-user salts are unchanged
  • No user action, re-enrollment, or credential rotation is needed
  • The data encryption key (DEK) wrapped under the old KEK is fully compatible with the new KEK: the bytes are the same key

Why We Are Publishing This

This change does not affect security. It does affect how our code behaves on a specific browser engine, and it touches the most security-sensitive part of our client-side cryptography stack.

We are publishing it because our transparency policy requires that any change to key derivation logic, even a behaviorally neutral one, is documented publicly, with the technical reasoning and verification evidence available for users and auditors to review.

Cryptographic changes should never be invisible, even when they are intentionally inert.

References