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
CryptoKeyobject 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
CryptoKeyusing a secondimportKeycall. 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:
- Import IKM bytes as an HKDF base key via
importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits']) - Derive 256 raw bits via
deriveBits(hkdfParams, ikm, 256) - 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:
- Derives a key via Path A (
deriveKey()) using the same HKDF parameters - Derives a key via Path B (
deriveBits()+importKey()) using the same HKDF parameters - Encrypts a test payload with the Path A key
- Decrypts that ciphertext with the Path B key
- Asserts that the decrypted bytes match the original plaintext exactly
- 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
-
WebKit Blog — Update on Web Cryptography
webkit.org/blog/7790/update-on-web-cryptography/
Official Apple/WebKit announcement adding HKDF and related WebCrypto improvements. Authoritative baseline for HKDF support in WebKit. -
Apple Developer — SubtleCrypto Documentation
developer.apple.com/documentation/webkitjs/subtlecrypto
Confirms bothderiveBits()andderiveKey()are first-class APIs in WebKit. Relevant to the architectural justification for usingderiveBits(). -
MDN — SubtleCrypto.deriveBits()
developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveBits
W3C-aligned specification reference forderiveBits(), including Worker support. Confirms this call is a valid and fully supported path in Worker contexts. -
MDN — SubtleCrypto.deriveKey()
developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey
W3C-aligned specification reference forderiveKey(). Comparing this with thederiveBits()spec confirms both are specified to work in Workers; the observed Safari hang is a runtime regression, not a spec divergence. -
Stack Overflow — HKDF output length and
deriveKeyvsderiveBitssemantics
stackoverflow.com/questions/79365928/how-to-specify-desired-hkdf-output-length-with-crypto-subtle-derivekey
Practical community discussion showing why developers intentionally preferderiveBits()for deterministic control and cross-browser compatibility when deriving HKDF keys.