Derivación KEK de passkey: deriveBits vs deriveKey

El inicio de sesión con passkey de PayCal depende de una key encryption key (KEK) derivada dentro de un Web Worker mediante la WebCrypto API. En mayo de 2026 cambiamos cómo se realiza esa derivación, pasando de deriveKey() a deriveBits() + importKey(), para evitar una ruta de bloqueo conocida en Safari / WebKit. Este artículo explica el cambio, por qué ambas rutas son criptográficamente idénticas y cómo verificamos esa afirmación.

Resumen

Fecha del cambio 18 de mayo de 2026
Archivo modificado html/js/calendar/crypto-worker.js
Función afectada derivePasskeyKEK()
Algoritmo HKDF-SHA-256 → AES-GCM-256 (sin cambios)
Motivo Evitar la ruta de bloqueo conocida de deriveKey() en Workers de Safari / WebKit
Impacto criptográfico Ninguno. Ambas rutas producen el mismo material de clave.
Verificación La prueba de paridad tests/crypto/hkdf-equivalence.mjs confirma comportamiento de descifrado idéntico

Contexto: arquitectura KEK de passkeys

PayCal protege los datos por usuario con una data encryption key (DEK) que se almacena cifrada, nunca en texto plano. Para descifrarla al iniciar sesión, PayCal deriva una key encryption key (KEK) desde la credencial passkey del usuario.

La derivación se ejecuta dentro de un Web Worker, un hilo de fondo aislado que maneja operaciones criptográficas separado del hilo principal del navegador. El Worker llama a la WebCrypto API del navegador (crypto.subtle) para ejecutar el paso de derivación HKDF.

La entrada de la derivación es el credentialId estable de la credencial passkey WebAuthn, no la firma. Las firmas ECDSA no son deterministas, por lo que cualquier KEK derivada de una firma cambiaría en cada inicio de sesión y no podría desenvolver la DEK. Usar el credentialId garantiza que se produzca la misma KEK cada vez que el usuario se autentica con el mismo dispositivo.

Los parámetros HKDF son:

  • Algoritmo: HKDF-SHA-256
  • IKM: codificación UTF-8 de credentialId
  • Salt: valor aleatorio de 32 bytes por usuario almacenado en el servidor
  • Info: cadena de dominio fija paycal-passkey-kek
  • Salida: clave AES-GCM de 256 bits (no extraíble)

La WebCrypto API: dos rutas hacia la misma clave

La WebCrypto API expone dos formas de producir una clave derivada desde una entrada HKDF:

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
Ejecuta la derivación HKDF y devuelve directamente un objeto CryptoKey. Es la llamada más compacta: quien llama especifica el algoritmo de clave de salida, como AES-GCM-256, en un solo paso.
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
Ejecuta la derivación HKDF y devuelve primero bytes sin procesar. Luego esos bytes se importan como CryptoKey mediante una segunda llamada importKey. Es un equivalente de dos pasos: el objeto de clave resultante es idéntico en tipo, longitud, capacidad de extracción y usos permitidos.

Ambas rutas invocan la misma función HKDF-SHA-256, consumen los mismos parámetros IKM, salt e info, y producen los mismos 256 bits de material de clave. La única diferencia es cómo el runtime del Web Worker procesa internamente la llamada.

El problema: bloqueo observado de deriveKey() en Web Workers de Safari / WebKit

Existe una ruta de bloqueo de deriveKey() observada operativamente en Safari / WebKit cuando la llamada se hace desde un Web Worker. Los usuarios de Safari podían encontrar un flujo de inicio de sesión que simplemente se detenía, sin error, sin timeout y sin feedback visible, mientras esperaba que el Worker completara la derivación.

La llamada deriveBits(), usada para producir la misma salida, no muestra este bloqueo. El problema es específico de la ruta de código deriveKey() dentro del contexto de ejecución del Worker; no afecta al algoritmo HKDF subyacente.

deriveKey() y deriveBits() son APIs WebCrypto de primera clase documentadas por Apple y especificadas por el W3C. Sus semánticas son idénticas para este caso de uso. El bloqueo es una regresión de runtime, no una diferencia de diseño entre las dos llamadas.

No hay un ticket público canónico de Apple indexado para esta combinación exacta de Worker + HKDF + deriveKey(). Esto coincide con una clase de regresiones criptográficas de borde en Safari/WebKit que se descubren operativamente, se rodean en producción y no quedan formalmente rastreadas en un ticket público. Nuestra decisión de ingeniería es evitar por completo la ruta problemática, sin depender de detectar el estado del navegador en runtime.

Qué cambiamos

La función derivePasskeyKEK() en html/js/calendar/crypto-worker.js se actualizó para usar deriveBits() + importKey() como única ruta de derivación. La llamada deriveKey() se eliminó por completo.

La nueva secuencia de derivación es:

  1. Importar bytes IKM como clave base HKDF mediante importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits'])
  2. Derivar 256 bits sin procesar mediante deriveBits(hkdfParams, ikm, 256)
  3. Importar esos bits como clave AES-GCM-256 mediante importKey('raw', keyBits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])

Los parámetros HKDF (hash SHA-256, salt por usuario y cadena info fija paycal-passkey-kek) no cambian. El tipo de clave de salida, la longitud y la marca no extraíble no cambian. Las DEK envueltas existentes de usuarios que usaban la ruta antigua son totalmente compatibles: no se requiere re-wrapping ni nuevo registro.

Prueba de equivalencia criptográfica

No afirmamos equivalencia solo por razonamiento. El archivo tests/crypto/hkdf-equivalence.mjs contiene una prueba de paridad que:

  1. Deriva una clave por la Ruta A (deriveKey()) usando los mismos parámetros HKDF
  2. Deriva una clave por la Ruta B (deriveBits() + importKey()) usando los mismos parámetros HKDF
  3. Cifra una carga de prueba con la clave de la Ruta A
  4. Descifra ese ciphertext con la clave de la Ruta B
  5. Afirma que los bytes descifrados coinciden exactamente con el texto plano original
  6. Repite en sentido inverso: cifra con la Ruta B y descifra con la Ruta A

Si ambos descifrados tienen éxito y producen los mismos bytes, las claves son operacionalmente idénticas. La prueba pasa en Node.js 18+ y se referencia en nuestro SOC 2 false-positive adjudication ledger como evidencia CRYPTO-001.

Qué no cambió

  • El algoritmo de derivación de clave sigue siendo HKDF-SHA-256
  • La clave de salida sigue siendo AES-GCM de 256 bits
  • La clave de salida sigue siendo no extraíble desde el contexto del Worker
  • La fuente IKM sigue siendo el credentialId estable (no la firma ECDSA)
  • La cadena info HKDF sigue siendo paycal-passkey-kek
  • Los salts por usuario no cambian
  • No se requiere acción del usuario, nuevo registro ni rotación de credenciales
  • La data encryption key (DEK) envuelta bajo la KEK antigua es totalmente compatible con la nueva KEK: los bytes son la misma clave

Por qué publicamos esto

Este cambio no afecta la seguridad. Sí afecta cómo se comporta nuestro código en un motor de navegador específico y toca la parte más sensible de nuestra pila criptográfica del lado del cliente.

Lo publicamos porque nuestra política de transparencia exige que cualquier cambio en la lógica de derivación de claves, incluso uno neutral en comportamiento, se documente públicamente con la razón técnica y la evidencia de verificación disponibles para usuarios y auditores.

Los cambios criptográficos nunca deberían ser invisibles, incluso cuando son intencionalmente inertes.

Referencias