Derivação KEK de passkey: deriveBits vs deriveKey

O login por passkey da PayCal depende de uma key encryption key (KEK) derivada dentro de um Web Worker usando a WebCrypto API. Em maio de 2026 mudamos como essa derivação é feita, de deriveKey() para deriveBits() + importKey(), para evitar um caminho conhecido de travamento no Safari / WebKit. Este artigo explica a mudança, por que os dois caminhos são criptograficamente idênticos e como verificamos essa afirmação.

Resumo

Data da mudança 18 de maio de 2026
Arquivo alterado html/js/calendar/crypto-worker.js
Função afetada derivePasskeyKEK()
Algoritmo HKDF-SHA-256 → AES-GCM-256 (inalterado)
Motivo Evitar o caminho conhecido de travamento de deriveKey() em Workers Safari / WebKit
Impacto criptográfico Nenhum. Os dois caminhos produzem o mesmo material de chave.
Verificação O teste de paridade tests/crypto/hkdf-equivalence.mjs confirma comportamento de descriptografia idêntico

Contexto: arquitetura KEK de passkey

A PayCal protege dados por usuário com uma data encryption key (DEK) armazenada criptografada, nunca em texto claro. Para descriptografá-la no login, a PayCal deriva uma key encryption key (KEK) da credencial passkey do usuário.

A derivação roda dentro de um Web Worker, uma thread de background isolada que trata operações criptográficas separada da thread principal do navegador. O Worker chama a WebCrypto API do navegador (crypto.subtle) para executar a etapa de derivação HKDF.

A entrada da derivação é o credentialId estável da credencial passkey WebAuthn, não a assinatura. Assinaturas ECDSA não são determinísticas; uma KEK derivada de assinatura mudaria a cada login e falharia ao desembrulhar a DEK. Usar o credentialId garante que a mesma KEK seja produzida toda vez que o usuário autentica com o mesmo dispositivo.

Os parâmetros HKDF são:

  • Algoritmo: HKDF-SHA-256
  • IKM: codificação UTF-8 de credentialId
  • Salt: valor aleatório de 32 bytes por usuário armazenado no servidor
  • Info: string de domínio fixa paycal-passkey-kek
  • Saída: chave AES-GCM de 256 bits (não extraível)

A WebCrypto API: dois caminhos para a mesma chave

A WebCrypto API expõe duas formas de produzir uma chave derivada a partir de uma entrada HKDF:

crypto.subtle.deriveKey(algorithm, baseKey, derivedKeyType, extractable, usages)
Executa a derivação HKDF e retorna diretamente um objeto CryptoKey. É a chamada mais compacta: quem chama especifica o algoritmo da chave de saída, como AES-GCM-256, em uma única etapa.
crypto.subtle.deriveBits(algorithm, baseKey, length) + crypto.subtle.importKey(format, keyData, algorithm, extractable, usages)
Executa a derivação HKDF e retorna primeiro bytes brutos. Depois esses bytes são importados como CryptoKey com uma segunda chamada importKey. É um equivalente em duas etapas: o objeto de chave resultante é idêntico em tipo, comprimento, extraibilidade e usos permitidos.

Os dois caminhos invocam a mesma função HKDF-SHA-256, consomem os mesmos parâmetros IKM, salt e info, e produzem os mesmos 256 bits de material de chave. A única diferença é como o runtime do Web Worker processa internamente a chamada.

O problema: travamento observado de deriveKey() em Web Workers Safari / WebKit

Há um caminho de travamento de deriveKey() observado operacionalmente no Safari / WebKit quando a chamada é feita dentro de um Web Worker. Usuários do Safari podiam encontrar um fluxo de login que simplesmente parava, sem erro, sem timeout e sem feedback visível, enquanto aguardava o Worker concluir a derivação.

A chamada deriveBits(), usada para produzir a mesma saída, não apresenta esse travamento. O problema é específico do caminho de código deriveKey() no contexto de execução do Worker; ele não afeta o algoritmo HKDF subjacente.

deriveKey() e deriveBits() são APIs WebCrypto de primeira classe, documentadas pela Apple e especificadas pelo W3C. Suas semânticas são idênticas para este caso de uso. O travamento é uma regressão de runtime, não uma diferença de desenho entre as duas chamadas.

Não há um ticket público canônico da Apple indexado para esta combinação exata Worker + HKDF + deriveKey(). Isso é consistente com uma classe de regressões criptográficas edge-case do Safari/WebKit descobertas operacionalmente, contornadas em produção e não formalmente rastreadas em um ticket público. Nossa decisão de engenharia é evitar totalmente o caminho problemático, independentemente da versão WebKit, em vez de tentar detectar o estado do navegador em runtime.

O que mudamos

A função derivePasskeyKEK() em html/js/calendar/crypto-worker.js foi atualizada para usar deriveBits() + importKey() como o único caminho de derivação. A chamada deriveKey() foi removida por completo.

A nova sequência de derivação é:

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

Os parâmetros HKDF (hash SHA-256, salt por usuário, string info fixa paycal-passkey-kek) não mudaram. O tipo de chave de saída, o comprimento e o flag não extraível não mudaram. DEKs wrapped existentes para usuários que usavam o caminho antigo continuam totalmente compatíveis: não é necessário re-wrapping nem novo registro.

Prova de equivalência criptográfica

Não afirmamos equivalência apenas por raciocínio. O arquivo tests/crypto/hkdf-equivalence.mjs contém um teste de paridade que:

  1. Deriva uma chave pela Rota A (deriveKey()) usando os mesmos parâmetros HKDF
  2. Deriva uma chave pela Rota B (deriveBits() + importKey()) usando os mesmos parâmetros HKDF
  3. Criptografa uma carga de teste com a chave da Rota A
  4. Descriptografa esse ciphertext com a chave da Rota B
  5. Confirma que os bytes descriptografados correspondem exatamente ao plaintext original
  6. Repete ao contrário: criptografa com a Rota B e descriptografa com a Rota A

Se ambas as descriptografias têm sucesso e produzem os mesmos bytes, as chaves são operacionalmente idênticas. O teste passa em Node.js 18+ e é referenciado no nosso SOC 2 false-positive adjudication ledger como item de evidência CRYPTO-001.

O que não mudou

  • O algoritmo de derivação de chave continua HKDF-SHA-256
  • A chave de saída continua AES-GCM de 256 bits
  • A chave de saída continua não extraível do contexto do Worker
  • A fonte IKM continua sendo o credentialId estável (não a assinatura ECDSA)
  • A string info HKDF continua paycal-passkey-kek
  • Salts por usuário permanecem inalterados
  • Nenhuma ação do usuário, novo registro ou rotação de credencial é necessária
  • A data encryption key (DEK) wrapped sob a KEK antiga é totalmente compatível com a nova KEK: os bytes são a mesma chave

Por que estamos publicando isto

Esta mudança não afeta a segurança. Ela afeta como nosso código se comporta em um mecanismo de navegador específico e toca a parte mais sensível da nossa pilha criptográfica client-side.

Estamos publicando porque nossa política de transparência exige que qualquer mudança na lógica de derivação de chaves, mesmo uma mudança neutra em comportamento, seja documentada publicamente com a razão técnica e a evidência de verificação disponíveis para usuários e auditores.

Mudanças criptográficas nunca deveriam ser invisíveis, mesmo quando são intencionalmente inertes.

Referências