Passkey KEK derivation: deriveBits vs deriveKey

PayCal का passkey login एक key encryption key (KEK) पर निर्भर करता है, जो WebCrypto API का उपयोग करके Web Worker के अंदर derive होती है। मई 2026 में हमने derivation को deriveKey() से deriveBits() + importKey() में बदला ताकि Safari / WebKit में known hang path से बचा जा सके। यह लेख बदलाव, दोनों paths के cryptographically identical होने का कारण, और verification समझाता है।

सारांश

Change date 18 मई 2026
File changed html/js/calendar/crypto-worker.js
Function affected derivePasskeyKEK()
Algorithm HKDF-SHA-256 → AES-GCM-256 (unchanged)
Reason Safari / WebKit में known deriveKey() Worker hang path से बचना
Cryptographic impact कोई नहीं। दोनों paths वही key material बनाते हैं।
Verification Parity test tests/crypto/hkdf-equivalence.mjs identical decrypt behavior confirm करता है

Background: Passkey KEK architecture

PayCal per-user data को data encryption key (DEK) से protect करता है, जो encrypted रूप में stored रहती है, plaintext में कभी नहीं। Login time पर उसे decrypt करने के लिए PayCal user के passkey credential से key encryption key (KEK) derive करता है।

Derivation एक Web Worker के अंदर चलती है, जो sandboxed background thread है और cryptographic operations को main browser thread से अलग handle करता है। Worker browser की WebCrypto API (crypto.subtle) को call करके HKDF key derivation step चलाता है।

Derivation input WebAuthn passkey credential की stable credentialId है, signature नहीं। ECDSA signatures non-deterministic होते हैं, इसलिए signature से derive हुई KEK हर login पर बदलती और DEK unwrap fail करती। credentialId का उपयोग same device से हर authentication पर same KEK बनाना guarantee करता है।

HKDF parameters ये हैं:

  • Algorithm: HKDF-SHA-256
  • IKM: credentialId की UTF-8 encoding
  • Salt: प्रति user random 32-byte value, server-side stored
  • Info: fixed domain string paycal-passkey-kek
  • Output: 256-bit AES-GCM key (non-extractable)

WebCrypto API: एक ही key तक दो paths

WebCrypto API HKDF input से derived key बनाने के दो तरीके देता है:

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
HKDF derivation चलाता है और सीधे CryptoKey object लौटाता है। यह compact call है: caller output key algorithm, जैसे AES-GCM-256, एक step में देता है।
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
HKDF derivation चलाता है और पहले raw bytes लौटाता है। फिर caller उन bytes को दूसरे importKey call से CryptoKey के रूप में import करता है। यह two-step equivalent है: resulting key object type, length, extractability और permitted usages में identical है।

दोनों paths वही HKDF-SHA-256 function invoke करते हैं, वही IKM, salt और info parameters consume करते हैं, और वही 256 bits key material produce करते हैं। फर्क केवल यह है कि Web Worker runtime call को internally कैसे process करता है।

समस्या: Safari / WebKit Web Workers में observed deriveKey() hang

Safari / WebKit में Web Worker के अंदर call होने पर deriveKey() का operationally observed hang path है। Safari users को ऐसा login flow मिल सकता था जो बस रुक जाता था: कोई error नहीं, timeout नहीं, visible feedback नहीं, जबकि Worker derivation complete करने का इंतजार कर रहा होता था।

deriveBits() call, जो वही output produce करता है, यह hang नहीं दिखाता। Issue Worker execution context में deriveKey() code path तक सीमित है; underlying HKDF algorithm प्रभावित नहीं है।

deriveKey() और deriveBits() दोनों first-class WebCrypto APIs हैं, Apple द्वारा documented और W3C द्वारा specified। इस use case के लिए semantics identical हैं। Hang runtime regression है, दोनों calls के बीच design difference नहीं।

इस exact Worker + HKDF + deriveKey() combination के लिए कोई canonical public Apple bug ticket indexed नहीं है। यह Safari/WebKit edge-case crypto regressions की उस class जैसा है जो operationally discovered होती है, production में worked around होती है, और public ticket में formally tracked नहीं होती। हमारी engineering decision है कि browser state runtime पर detect करने की बजाय problematic call path को पूरी तरह avoid किया जाए।

हमने क्या बदला

html/js/calendar/crypto-worker.js में derivePasskeyKEK() function को update किया गया ताकि deriveBits() + importKey() ही sole derivation path रहे। deriveKey() call पूरी तरह हटा दिया गया।

नई derivation sequence है:

  1. IKM bytes को HKDF base key के रूप में importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits']) से import करना
  2. deriveBits(hkdfParams, ikm, 256) से 256 raw bits derive करना
  3. इन bits को AES-GCM-256 key के रूप में importKey('raw', keyBits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) से import करना

HKDF parameters (SHA-256 hash, per-user salt, fixed info string paycal-passkey-kek) unchanged हैं। Output key type, length और non-extractable flag unchanged हैं। पुराने code path से stored existing wrapped DEKs fully compatible हैं: no re-wrapping or re-enrollment required.

Cryptographic equivalence proof

हम equivalence केवल reasoning से assert नहीं करते। File tests/crypto/hkdf-equivalence.mjs में parity test है जो:

  1. Same HKDF parameters से Path A (deriveKey()) द्वारा key derive करता है
  2. Same HKDF parameters से Path B (deriveBits() + importKey()) द्वारा key derive करता है
  3. Path A key से test payload encrypt करता है
  4. Path B key से उस ciphertext को decrypt करता है
  5. Assert करता है कि decrypted bytes original plaintext से exactly match करते हैं
  6. Reverse दोहराता है: Path B से encrypt, Path A से decrypt

अगर दोनों decryptions succeed करते हैं और same bytes produce करते हैं, keys operationally identical हैं। Test Node.js 18+ में pass करता है और हमारे SOC 2 false-positive adjudication ledger में evidence item CRYPTO-001 के रूप में referenced है।

क्या नहीं बदला

  • Key derivation algorithm अब भी HKDF-SHA-256 है
  • Output key अब भी 256-bit AES-GCM है
  • Output key Worker context से अब भी non-extractable है
  • IKM source अब भी stable credentialId है (ECDSA signature नहीं)
  • HKDF info string अब भी paycal-passkey-kek है
  • Per-user salts unchanged हैं
  • कोई user action, re-enrollment, या credential rotation जरूरी नहीं
  • पुराने KEK के तहत wrapped data encryption key (DEK) नए KEK के साथ fully compatible है: bytes वही key हैं

हम इसे क्यों publish कर रहे हैं

यह बदलाव security को प्रभावित नहीं करता। लेकिन यह specific browser engine पर हमारे code के behavior को प्रभावित करता है और client-side cryptography stack के सबसे security-sensitive हिस्से को छूता है।

हम इसे publish कर रहे हैं क्योंकि हमारी transparency policy मांग करती है कि key derivation logic में कोई भी बदलाव, भले behaviorally neutral हो, publicly documented रहे, technical reasoning और verification evidence users और auditors के review के लिए उपलब्ध हो।

Cryptographic changes कभी invisible नहीं होने चाहिए, भले वे intentionally inert हों।

संदर्भ