Derivazione KEK passkey: deriveBits vs deriveKey

Il login passkey di PayCal si basa su una key encryption key (KEK) derivata dentro un Web Worker tramite la WebCrypto API. A maggio 2026 abbiamo cambiato questa derivazione, passando da deriveKey() a deriveBits() + importKey(), per evitare un percorso di blocco noto in Safari / WebKit. Questo articolo spiega la modifica, perché i due percorsi sono crittograficamente identici e come lo abbiamo verificato.

Riepilogo

Data modifica 18 maggio 2026
File modificato html/js/calendar/crypto-worker.js
Funzione interessata derivePasskeyKEK()
Algoritmo HKDF-SHA-256 → AES-GCM-256 (invariato)
Motivo Evitare il percorso di blocco noto di deriveKey() nei Worker Safari / WebKit
Impatto crittografico Nessuno. Entrambi i percorsi producono lo stesso materiale di chiave.
Verifica Il test di parità tests/crypto/hkdf-equivalence.mjs conferma un comportamento di decrittazione identico

Contesto: architettura KEK passkey

PayCal protegge i dati per utente con una data encryption key (DEK) conservata cifrata, mai in chiaro. Per decifrarla al login, PayCal deriva una key encryption key (KEK) dalla credenziale passkey dell’utente.

La derivazione avviene dentro un Web Worker, un thread di background isolato che gestisce operazioni crittografiche separato dal thread principale del browser. Il Worker chiama la WebCrypto API del browser (crypto.subtle) per eseguire lo step di derivazione HKDF.

L’input della derivazione è il credentialId stabile della credenziale passkey WebAuthn, non la firma. Le firme ECDSA sono non deterministiche, quindi una KEK derivata da una firma cambierebbe a ogni login e non riuscirebbe a unwrap della DEK. Usare il credentialId garantisce che venga prodotta la stessa KEK ogni volta che l’utente si autentica con lo stesso dispositivo.

I parametri HKDF sono:

  • Algoritmo: HKDF-SHA-256
  • IKM: codifica UTF-8 di credentialId
  • Salt: valore casuale di 32 byte per utente conservato server-side
  • Info: stringa di dominio fissa paycal-passkey-kek
  • Output: chiave AES-GCM a 256 bit (non estraibile)

La WebCrypto API: due percorsi verso la stessa chiave

La WebCrypto API espone due modi per produrre una chiave derivata da un input HKDF:

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
Esegue la derivazione HKDF e restituisce direttamente un oggetto CryptoKey. È la chiamata più compatta: il chiamante specifica in un solo step l’algoritmo della chiave di output, come AES-GCM-256.
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
Esegue la derivazione HKDF e restituisce prima byte grezzi. Il chiamante poi importa quei byte come CryptoKey con una seconda chiamata importKey. È un equivalente in due step: la chiave risultante è identica per tipo, lunghezza, estraibilità e usi consentiti.

Entrambi i percorsi invocano la stessa funzione HKDF-SHA-256, consumano gli stessi parametri IKM, salt e info, e producono gli stessi 256 bit di materiale di chiave. L’unica differenza è come il runtime del Web Worker elabora internamente la chiamata.

Il problema: blocco osservato di deriveKey() nei Web Worker Safari / WebKit

In Safari / WebKit è stato osservato operativamente un percorso di blocco di deriveKey() quando la chiamata viene effettuata da un Web Worker. Gli utenti Safari potevano incontrare un flusso di login che si fermava semplicemente, senza errore, timeout o feedback visibile, mentre attendeva la derivazione del Worker.

La chiamata deriveBits(), usata per produrre lo stesso output, non mostra questo blocco. Il problema è specifico del percorso di codice deriveKey() nel contesto di esecuzione del Worker; non riguarda l’algoritmo HKDF sottostante.

deriveKey() e deriveBits() sono entrambe API WebCrypto di prima classe, documentate da Apple e specificate dal W3C. Le loro semantiche sono identiche per questo caso d’uso. Il blocco è una regressione runtime, non una differenza di design fra le due chiamate.

Non esiste un ticket Apple pubblico canonico indicizzato per questa esatta combinazione Worker + HKDF + deriveKey(). È coerente con una classe di regressioni crypto edge-case Safari/WebKit scoperte operativamente, aggirate in produzione e non tracciate formalmente in un ticket pubblico. La nostra decisione tecnica è evitare completamente il percorso problematico, indipendentemente dalla versione WebKit, invece di rilevare lo stato del browser a runtime.

Cosa abbiamo cambiato

La funzione derivePasskeyKEK() in html/js/calendar/crypto-worker.js è stata aggiornata per usare deriveBits() + importKey() come unico percorso di derivazione. La chiamata deriveKey() è stata rimossa del tutto.

La nuova sequenza di derivazione è:

  1. Importare i byte IKM come chiave base HKDF tramite importKey('raw', ikmMaterial, 'HKDF', false, ['deriveBits'])
  2. Derivare 256 bit grezzi tramite deriveBits(hkdfParams, ikm, 256)
  3. Importare quei bit come chiave AES-GCM-256 tramite importKey('raw', keyBits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])

I parametri HKDF (hash SHA-256, salt per utente, stringa info fissa paycal-passkey-kek) sono invariati. Tipo di chiave di output, lunghezza e flag non estraibile sono invariati. Le DEK wrapped esistenti per utenti che avevano usato il vecchio percorso sono pienamente compatibili: non servono re-wrapping o nuova registrazione.

Prova di equivalenza crittografica

Non affermiamo equivalenza solo per ragionamento. Il file tests/crypto/hkdf-equivalence.mjs contiene un test di parità che:

  1. Deriva una chiave tramite Percorso A (deriveKey()) usando gli stessi parametri HKDF
  2. Deriva una chiave tramite Percorso B (deriveBits() + importKey()) usando gli stessi parametri HKDF
  3. Cifra un payload di test con la chiave del Percorso A
  4. Decifra quel ciphertext con la chiave del Percorso B
  5. Verifica che i byte decifrati corrispondano esattamente al plaintext originale
  6. Ripete al contrario: cifra con Percorso B, decifra con Percorso A

Se entrambe le decrittazioni riescono e producono gli stessi byte, le chiavi sono operativamente identiche. Il test passa in Node.js 18+ ed è referenziato nel nostro SOC 2 false-positive adjudication ledger come elemento di evidenza CRYPTO-001.

Cosa non è cambiato

  • L’algoritmo di derivazione della chiave resta HKDF-SHA-256
  • La chiave di output resta AES-GCM a 256 bit
  • La chiave di output resta non estraibile dal contesto del Worker
  • La sorgente IKM resta il credentialId stabile (non la firma ECDSA)
  • La stringa info HKDF resta paycal-passkey-kek
  • I salt per utente sono invariati
  • Non sono richieste azioni utente, nuova registrazione o rotazione credenziali
  • La data encryption key (DEK) wrapped sotto la vecchia KEK è pienamente compatibile con la nuova KEK: i byte sono la stessa chiave

Perché lo pubblichiamo

Questa modifica non influisce sulla sicurezza. Influisce però sul comportamento del nostro codice su uno specifico motore browser e tocca la parte più sensibile del nostro stack crittografico client-side.

La pubblichiamo perché la nostra policy di trasparenza richiede che ogni modifica alla logica di derivazione delle chiavi, anche se neutra nel comportamento, sia documentata pubblicamente con ragionamento tecnico ed evidenza di verifica disponibili per utenti e auditor.

Le modifiche crittografiche non dovrebbero mai essere invisibili, anche quando sono intenzionalmente inerti.

Riferimenti