Esta é uma tradução informativa. A versão em inglês é a oficial e prevalece. Ler a versão em inglês

PoE selada

O envelope de criptografia do Label 309 — como um remetente sela o conteúdo para uma ou mais chaves de destinatários, enquanto a cadeia carrega apenas o hash do texto claro e os slots de chave protegidos, nunca o texto claro e nunca os destinatários.

Uma PoE selada ancora um compromisso datado sobre um texto claro, mantendo esse texto legível apenas por um público escolhido. O registro on-chain carrega o hash do texto claro — a prova de tempo, exatamente como em qualquer outro registro — somado a um envelope de criptografia (enc) que guarda o material necessário para recuperar a chave de criptografia do conteúdo. O texto cifrado em si nunca toca a cadeia; ele reside em uma URI endereçada por conteúdo (ar:// ou ipfs://). Nada na cadeia revela o texto claro, e nada revela quem são os destinatários.

Esta página especifica o envelope enc: seus dois caminhos mutuamente exclusivos de entrega de chave, os slots de chave por destinatário, o MAC do conjunto de slots, o STREAM de conteúdo segmentado e a decifragem por tentativa que um destinatário executa para descobrir e abrir uma mensagem endereçada a ele. As próprias chaves dos destinatários — os pares de chaves X25519 e X-Wing derivados da semente — são definidas em Chaves; esta página as consome. O lugar do mapa enc dentro do mapa do registro, e o transporte do corpo inteiro que o carrega na cadeia, são definidos em O registro.

Não é HPKE

Isto não é o HPKE da RFC 9180. É um design KEM-then-wrap multi-destinatário no estilo age — encapsulamento por destinatário, uma chave de criptografia de chave derivada por HKDF e uma chave de criptografia de conteúdo protegida por AEAD — com o padrão de estrofes do age v1 transposto para CBOR canônico. Não tem suite_id nem a cascata LabeledExtract/LabeledExpand; avalie-o à luz da literatura sobre ECIES e da especificação do age v1, não da análise do HPKE.

O modelo e suas propriedades de privacidade

Um remetente quer publicar um compromisso permanente e datado, provando que um texto claro específico foi selado para um público específico no instante T — ao mesmo tempo em que garante que somente esse público pode lê-lo. Uma PoE somente de hash dá a afirmação de tempo, mas nenhum vínculo com o público; uma PoE sobre texto cifrado aberto não dá confidencialidade alguma. A PoE selada faz a ponte entre as duas: o registro se compromete com o hash do texto claro (público e datado) e carrega o material de entrega de chave em enc, enquanto o texto cifrado na URI ar:// ou ipfs:// é indecifrável sem um segredo de desbloqueio correspondente.

A construção foi propositalmente projetada para que a cadeia vaze o mínimo possível sobre a mensagem e nada sobre seu público:

  • O texto claro nunca fica na cadeia. Só o seu hash e as chaves protegidas ficam. Qualquer pessoa que mais tarde obtenha o texto claro pode provar que "exatamente este texto claro foi comprometido no horário do bloco T"; ninguém mais descobre o que foi selado.
  • As chaves públicas dos destinatários nunca ficam na cadeia. A chave pública de um destinatário não aparece em lugar algum de enc. Um destinatário reconhece uma mensagem como sua apenas ao decifrar por tentativa com sucesso um slot — não existe um campo de destinatário a ser lido. Um observador sem chaves candidatas fica sabendo apenas a contagem de slots, a família de KEM (enc.kem) e a distinção entre selado e aberto. A propriedade mais forte — a de que um adversário que detém chaves públicas candidatas de destinatários ainda assim não consegue testar a qual delas (se a alguma) um slot tem como alvo — é a privacidade de chave, afirmada apenas para o caminho clássico x25519; ela não é afirmada para o caminho híbrido mlkem768x25519 (veja Anonimato e a divisão por KEM).
  • Os destinatários não descobrem nada uns sobre os outros. Cada slot por destinatário é uma chave protegida opaca. Um destinatário que abre o próprio slot não consegue derivar a chave de nenhum outro destinatário, nem identificar quem mais foi endereçado.
  • A ordem dos slots não vaza nada. A ordem em que um remetente lista os destinatários (por exemplo, "o principal primeiro") é um metadado privilegiado. O array de slots é embaralhado com um CSPRNG antes da publicação, de modo que nem mesmo a ordenação posicional carrega qualquer sinal.
  • Uma PoE selada não assinada preserva o anonimato do remetente. As assinaturas de autoria são opcionais (veja Assinaturas). Um registro selado sem sigs[] não vincula nenhuma identidade de remetente na cadeia — que é exatamente o que exigem vazamentos de denunciantes, leilões de propostas seladas e custódia de provas.

O que a cadeia de fato revela é estreito: que um registro é uma PoE selada (enc está presente), o hash do texto claro, o horário do bloco e a quantidade de slots (o comprimento do array). A quantidade é o único fato próximo aos destinatários que fica exposto, e ele revela apenas "quantos", nunca "quem". A correlação por tempo entre registros é uma preocupação de metadados que a criptografia no nível do protocolo não consegue resolver; remetentes que precisam neutralizá-la devem agrupar as publicações fora da linha do tempo sensível.

As chaves públicas dos destinatários são trocadas fora de banda. O Label 309 não prescreve nenhum mecanismo de descoberta: um destinatário pode publicar sua chave no próprio site, em um registro de DNS, em um perfil de rede social, em um QR code ou em uma autodeclaração on-chain. Um verificador toma os bytes da chave do destinatário como entrada e não faz nenhuma afirmação sobre de quem é a chave — a procedência é uma decisão de confiança do remetente, exatamente como ao enviar uma chave PGP por e-mail.

O envelope e seus dois caminhos

O mapa enc carrega campos comuns mais exatamente um de dois caminhos mutuamente exclusivos de entrega de chave. Um validador estrutural impõe essa exclusividade; um registro que carregue ambos, ou nenhum, é rejeitado.

CampoStatusSignificado
schemeOBRIGATÓRIOVersão da família de construção. A v1 define scheme = 1.
aeadOBRIGATÓRIOIdentificador do formato de conteúdo. A v1 define "chacha20-poly1305-stream64k".
nonceOBRIGATÓRIO24 bytes aleatórios — o salt único do envelope da chave de conteúdo e de cada KEK de slot.
kemapenas caminho de slotsSeletor de KEM por slot ("x25519" ou "mlkem768x25519").
slotsum dos caminhosArray de slots de chave por destinatário (multi-destinatário).
slots_macapenas caminho de slotsHMAC de 32 bytes que vincula o conjunto de slots e a alegação de hash do item à chave de conteúdo.
passphraseo outro caminhoBloco de KDF de frase secreta (chave derivada da frase secreta).
  • enc.slots — multi-destinatário. O envelope carrega N slots de chave protegidos de forma independente, um por destinatário. O texto cifrado é indecifrável sem uma chave privada que corresponda a um dos slots. Especificado em Slots e o MAC do conjunto de slots abaixo.
  • enc.passphrase — derivado de frase secreta. O envelope não carrega slots; a chave de conteúdo é derivada diretamente de uma frase secreta normalizada. Especificado em Caminho da frase secreta abaixo.

Ambos os caminhos compartilham scheme, aead e nonce. Eles diferem em qual chave está presente e, por consequência, em onde mora o compromisso da chave. No caminho de slots, o compromisso fica on-chain: slots_mac é um HMAC firmado com a chave CEK sobre uma transcrição que fixa os campos de cabeçalho, o conjunto de slots e a alegação de hash do item, de modo que um destinatário confirma a chave certa antes de buscar qualquer coisa. No caminho da frase secreta não há slots a vincular, então o compromisso é um cabeçalho de 32 bytes carregado dentro do blob de texto cifrado — testar um palpite de frase secreta exige o próprio blob, nunca apenas a cadeia pública. Cada caminho serializa sua transcrição com a mesma função canonicalEncode, e um produtor ou verificador seleciona o caminho inspecionando qual de slots / passphrase está presente. Os dois caminhos são exaustivos e mutuamente exclusivos.

enc.scheme nomeia a família de construção, independentemente do campo v do registro. Um verificador DEVE exigir enc.scheme === 1 e rejeitar qualquer outro valor. O campo está reservado para uma mudança transversal futura — um cronograma diferente para o MAC do conjunto de slots ou outro formato de conteúdo — e não para adicionar um KEM: o KEM por slot é selecionado por enc.kem, e os dois KEMs abaixo ficam sob scheme = 1 desde a primeira versão. De modo mais amplo, enc.scheme: 1 identifica a suíte criptográfica inteira, não apenas o MAC e o formato de conteúdo: as regras do canonicalEncode, o esquema de slots, o hash do HKDF, o hash do HMAC, o AEAD de envoltório por slot, o formato de conteúdo em STREAM segmentado, os esquemas de transcrição de slots e de frase secreta (incluindo o vínculo de item hashes_hash), o compromisso de frase secreta dentro do texto cifrado, a revisão fixada do X-Wing, os rótulos de separação de domínio, a versão e o perfil do Argon2id e o perfil de normalização da frase secreta são todos fixados por ele, de modo que alterar qualquer um deles exige um novo valor de enc.scheme.

A camada de conteúdo

Ambos os caminhos convergem para uma única passagem simétrica sobre o texto claro, com chave em um valor derivado de uma única chave de criptografia de conteúdo (CEK) de 32 bytes. A CEK é o que os slots entregam (cada slot a protege) ou o que o KDF de frase secreta produz; o conteúdo não é criptografado diretamente sob a CEK. Em vez disso, cada caminho deriva uma chave de conteúdo separada de 32 bytes como uma folha HKDF da CEK — com salt no enc.nonce único do envelope, sob um info específico do caminho —, de modo que a camada de entrega de chave e a camada de conteúdo nunca usem os mesmos bytes como chave da mesma primitiva:

CBOR
content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce,
                           info = <"cardano-poe-payload-v1" on the slots path,
                                   "cardano-poe-payload-passphrase-v1" on passphrase>,
                           L = 32)

O conteúdo é então selado em um STREAM segmentado, nomeado pelo identificador de formato de conteúdo chacha20-poly1305-stream64k. Este é o layout STREAM da especificação age v1: ChaCha20-Poly1305 (RFC 8439, a variante de nonce de 12 bytes) sobre o texto claro dividido em blocos de tamanho fixo, cada um selado sob a chave de conteúdo com um nonce de contador por bloco:

CBOR
cipher       : ChaCha20-Poly1305 (RFC 8439; 12-byte nonce, 16-byte tag)
CHUNK_SIZE   : 65536 plaintext bytes per non-final chunk
chunk nonce  : uint88_be(counter) || final_flag      ; 12 bytes
               counter starts at 0, +1 per chunk;
               final_flag = 0x01 on the final chunk, 0x00 otherwise
per-chunk AAD: empty
final chunk  : 0 to 65536 plaintext bytes; every non-final chunk is exactly 65536
empty input  : exactly one final chunk of zero-length plaintext (a lone 16-byte tag)

ciphertext = seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
                                             ; each sealed chunk = plaintext length + 16 bytes

O sinalizador final separa por domínio o último bloco dos demais, e é isso que torna o truncamento detectável: um fluxo cujo último bloco não carregue o sinalizador 0x01, um sinalizador 0x01 em um bloco que não seja o último, dados após o bloco final, ou um bloco não final mais curto que CHUNK_SIZE DEVEM todos falhar na decifragem (TAMPERED_CIPHERTEXT). Como todo bloco selado tem ao menos sua tag de 16 bytes, o layout também implica um piso estrutural — um blob de texto cifrado bem formado do caminho de slots nunca tem menos de 16 bytes, a tag solitária de um bloco final vazio.

O AAD por bloco é vazio por desenho: todo o contexto é vinculado ao conteúdo transitivamente. A chave de conteúdo deriva da CEK, e a CEK está comprometida com o cabeçalho completo por slots_mac no caminho de slots (cuja transcrição cobre scheme, path, aead, kem, nonce, o conjunto de slots e a alegação de hash do item) ou pelo compromisso dentro do texto cifrado no caminho da frase secreta. Inverta qualquer campo de cabeçalho e o destinatário deriva ou aceita uma chave diferente, de modo que a decifragem falha; um AAD por bloco revincularia o mesmo contexto em cada bloco sem acrescentar segurança.

Os nonces de bloco baseados em contador são seguros porque a chave de conteúdo é de uso único: ela deriva de uma CEK nova com salt no enc.nonce único do envelope, de modo que dois fluxos nunca compartilham um par (key, nonce) e produtores sem estado — abas do navegador, execuções da CLI, workers, novas tentativas — nunca coordenam nonces entre envelopes. O contador de 88 bits admite 2^88 blocos, muito acima de qualquer carga útil realizável, de modo que o formato não impõe nenhum teto criptográfico de carga útil; um máximo prático é uma política de negação de serviço da implantação, não uma constante de transmissão.

A entrada de texto claro são os bytes originais exatos do conteúdo. A construção não acrescenta no início, no fim, nem criptografa nenhum nome de arquivo, tipo MIME, campo de tamanho ou manifesto — o fluxo decifra de volta para esses bytes e apenas esses bytes.

Os blocos liberados são provisórios até o recálculo de hash

O formato segmentado existe para que um verificador possa autenticar e liberar uma carga útil de vários GiB de forma incremental, com memória limitada. A tag de cada bloco é verificada antes que o texto claro daquele bloco seja liberado, e o truncamento é apanhado pelo sinalizador final — mas o recálculo de hash do texto claro roda sobre o texto claro inteiro, depois do último bloco. Um consumidor que faça streaming DEVE, portanto, tratar os bytes liberados como provisórios — sem efeitos colaterais, sem confirmação, sem status de "recebido" — até que essa verificação final passe.

O texto cifrado publicado é um único objeto. No caminho de slots, ele é exatamente os blocos do STREAM; no caminho da frase secreta, um cabeçalho de compromisso de chave de 32 bytes é anteposto dentro do mesmo blob (mesmo objeto, mesma URI, mesma busca — nunca um segundo objeto armazenado):

CBOR
slots path      : ciphertext blob = [ STREAM chunks ]
passphrase path : ciphertext blob = [ commitment: 32 bytes ] || [ STREAM chunks ]

O hash do texto claro em items[].hashes sempre se compromete com o texto claro, mesmo quando enc está presente. Esta é a propriedade que sustenta tudo: um verificador que não consegue decifrar ainda pode confirmar que o registro existe, que seu envelope é bem-formado e que a URI é acessível — mas só um portador de uma chave de destinatário correspondente pode decifrar o texto cifrado e confirmar ao que o compromisso se refere, recalculando o hash. Por isso, o validador NÃO DEVE decifrar para "verificar" hashes; a verificação do hash do texto claro acontece no destinatário, depois que os bytes são recuperados. Veja Conteúdo e hashing e Verificação.

Slots e o MAC do conjunto de slots

No caminho multi-destinatário, enc.slots é um array não vazio de slots por destinatário. Todo slot protege a mesma CEK sob uma chave de criptografia de chave (KEK) por destinatário; um destinatário que abre qualquer slot recupera a única CEK que decifra o conteúdo. O remetente:

  1. Seleciona um KEM para o registro inteiro e gera a CEK (32 bytes aleatórios) e o nonce (24 bytes aleatórios).
  2. Para cada destinatário, deriva uma KEK por slot e protege a CEK sob ela (detalhes por KEM abaixo).
  3. Embaralha o array de slots com um CSPRNG (Fisher-Yates sem viés).
  4. Constrói a transcrição de slots sobre o array embaralhado, os campos de cabeçalho comuns aos KEMs e a alegação de hash do item, gera o hash dela em slots_hash e calcula slots_mac como um HMAC firmado com a chave CEK sobre esse hash.
  5. Deriva a chave de conteúdo a partir da CEK e do enc.nonce, e sela o conteúdo no STREAM segmentado acima.

A proteção por slot

Cada slot protege a CEK com ChaCha20-Poly1305 (RFC 8439, a variante de nonce de 12 bytes) sob a KEK do slot, produzindo um wrap de 48 bytes (32 bytes de texto cifrado da CEK + 16 bytes da tag Poly1305):

CBOR
wrap = ChaCha20-Poly1305_seal(
  key       = KEK,                    ; per-slot, 32 bytes
  nonce     = bytes(12, 0x00),        ; ZERO nonce
  ad        = <KEM info literal>,     ; the KEK info string for the chosen KEM
  plaintext = CEK)

O nonce de 12 bytes todo zerado é seguro justamente porque a KEK de cada slot é única por registro: portanto, uma KEK é usada em exatamente uma proteção, e assim o nonce nunca pode colidir sob uma mesma chave. Essa é uma invariante rígida — se alguma revisão chegasse a permitir que uma KEK fosse reutilizada (cache, efêmeros determinísticos, desduplicação de destinatários que reaproveita um slot), o nonce zerado teria de ser substituído por um aleatório na mesma mudança.

O MAC do conjunto de slots

O slots_mac vincula todo o conjunto de slots — junto com os campos de cabeçalho comuns aos KEMs que fixam como os slots são interpretados e a alegação de hash do texto claro do item — à CEK, frustrando adulterações por substituição de slot, remoção de slot, reordenação de slots e colagem de envelope. O vínculo é uma construção de duas etapas: uma transcrição de slots tem seu hash gerado uma vez em um slots_hash de 32 bytes, e esse hash é a mensagem de um HMAC firmado com a chave CEK.

CBOR
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))  ; 32 bytes

SLOTS_TRANSCRIPT = {                  ; closed 7-key map; keys are a set, not an order
  "scheme":      1,                   ; uint
  "path":        "slots",             ; text
  "aead":        <enc.aead>,          ; text: the content-format identifier
  "kem":         <enc.kem>,           ; "x25519" | "mlkem768x25519"
  "nonce":       <enc.nonce>,         ; bytes(24)
  "slots":       <slots>,             ; the shuffled on-wire slot array
  "hashes_hash": hashes_hash}         ; bytes(32), over this item's hashes map

slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))  ; 32 bytes
HMAC_KEY   = HKDF-SHA-256(ikm = CEK, salt = "",
                          info = "cardano-poe-slots-mac-v1", L = 32)
slots_mac  = HMAC-SHA-256(key = HMAC_KEY, msg = slots_hash)   ; 32 bytes

SLOTS_TRANSCRIPT é um mapa fechado que carrega exatamente esse conjunto de sete chaves, serializado com canonicalEncode para que os dois lados produzam bytes idênticos; sua ordem de chaves é a ordenação byte a byte da RFC 8949 §4.2.1, nunca arranjada à mão. O valor de slots é o array embaralhado de mapas de slot fechados exatamente como aparecem em trânsito ({epk, wrap} para x25519, {kem_ct, wrap} para mlkem768x25519), de modo que todo o conteúdo de transmissão por slot de cada slot fica dentro da transcrição. A transcrição adicionalmente fixa scheme, path, aead, kem e nonce: um relay que altere qualquer um desses campos de cabeçalho, deixando válidos os formatos dos slots, produz um slots_hash diferente, de modo que o MAC falha. Os prefixos SHA-256 do slots_hash e do hashes_hash (cardano-poe-slots-transcript-v1, cardano-poe-item-hashes-v1) são ASCII exato, sem terminador e sem prefixo de comprimento.

O hashes_hash é o que vincula o envelope à alegação de hash deste item: ele é um SHA-256 rotulado sobre o canonicalEncode do mapa hashes completo do item. Como o destinatário recalcula slots_mac apenas a partir dos bytes on-chain, uma correspondência de MAC confirma que o envelope foi selado para esta exata alegação — um envelope colado em um item com um mapa hashes diferente falha na etapa de correspondência on-chain, antes de qualquer busca de texto cifrado. As uris[] do item deliberadamente não são vinculadas, de modo que o texto cifrado pode ser re-hospedado em uma nova URI endereçada por conteúdo sem invalidar o envelope; um remetente para quem a lista de URIs faz parte da alegação a vincula, em vez disso, com uma assinatura no nível do registro.

Na derivação de HMAC_KEY, salt = "" é uma string de octetos de comprimento zero, a convenção de salt ausente da RFC 5869 §2.2 (o HKDF-Extract substitui por HashLen bytes zero — 32 no caso do SHA-256). Ela é fixada por um vetor de conformidade byte a byte, em vez de ser deixada a cargo de um padrão de biblioteca, de modo que uma implementação que trate mal o salt ausente falhe no vetor em vez de silenciosamente derivar uma chave diferente.

O slots_hash é calculado uma vez por registro e é constante ao longo do laço de decifragem por tentativa do destinatário — a verificação de MAC por slot redefine a chave do HMAC a partir de cada CEK candidata, mas sempre sobre o mesmo slots_hash de 32 bytes. A propriedade de compromisso é preservada porque a chave do HMAC continua sendo HKDF-SHA-256(CEK, …): gerar o hash da transcrição de antemão só muda a mensagem do HMAC, da transcrição completa para o seu SHA-256, deixando intacto o vínculo firmado com a chave CEK.

O MAC do conjunto de slots é fixado por enc.scheme: não há identificador para ele no formato de transmissão, existe exatamente uma construção por valor de scheme, e ela é idêntica para os dois KEMs. O slots_mac DEVE ter exatamente 32 bytes (ENC_SLOTS_MAC_INVALID_LENGTH em caso de comprimento errado) e DEVE ser verificado em tempo constante.

A transcrição depende dos bytes de transmissão de cada slot diretamente. Ambos os campos de slot são strings de bytes CBOR únicas — epk tem 32 bytes, kem_ct tem 1120 bytes —, de modo que não há fragmentação por campo a normalizar e nenhuma ambiguidade de fronteira de fragmento: a única fragmentação que o Label 309 realiza é o fracionamento de transporte do corpo inteiro em O registro, desfeito antes de qualquer coisa disto rodar. Uma inversão de byte em qualquer ponto de um slot muda o slots_hash e faz o MAC falhar.

A camada de conteúdo não precisa de nenhum vínculo separado, por passagem, ao conjunto de slots: a chave de conteúdo é uma folha HKDF da CEK, e a CEK já está comprometida com o cabeçalho completo — incluindo hashes_hash — por slots_mac. Editar qualquer slot ou campo de cabeçalho muda o que o destinatário deriva, de modo que o fluxo de conteúdo simplesmente não abre. O AAD por bloco é, portanto, vazio (veja A camada de conteúdo).

Os dois KEMs

O KEM, selecionado por registro via enc.kem, fixa o formato do slot e a derivação da KEK. Ambos estão registrados sob enc.scheme = 1 desde a primeira versão.

enc.kemKEMChave pública do destinatárioFormato do slotString de info da KEK
"x25519"X25519 (clássico)32 bytes{ epk: bstr(32), wrap: bstr(48) }"cardano-poe-kek-v1"
"mlkem768x25519"X-Wing = X25519 + ML-KEM-7681216 bytes{ kem_ct: bstr(1120), wrap: bstr(48) }"cardano-poe-kek-mlkem768x25519-v1"

Os produtores DEVERIAM usar mlkem768x25519 como padrão. O KEM híbrido é seguro tanto contra adversários clássicos quanto contra adversários quânticos do tipo "colher agora, decifrar depois", mantendo a segurança clássica do X25519 como piso — o combinador X-Wing vincula ambos os segredos compartilhados. Esse piso de "nunca abaixo da segurança clássica do X25519" é restrito a chaves de destinatário geradas validamente: ele pressupõe que a chave pública passe na verificação de validade de chave da revisão fixada do X-Wing (aplicada no encapsulamento, veja Híbrido: mlkem768x25519 abaixo). O KEM clássico x25519 permanece disponível para destinatários cuja chave publicada é apenas X25519. O identificador mlkem768x25519 é escrito de propósito sem hifens, acompanhando a grafia do ecossistema X-Wing/age.

Ambos os KEMs usam o mesmo padrão de estrofes do age — material de KEM por destinatário mais uma proteção simétrica da chave do arquivo — e o mesmo vínculo de cabeçalho (o MAC do conjunto de slots), de modo que uma única construção uniforme cobre os dois sem dependência de HPKE. O caminho clássico x25519 espelha de perto o destinatário X25519 nativo do age. O caminho híbrido mlkem768x25519 diverge de propósito da própria escolha pós-quântica do age: o age v1.3.0 traz destinatários pós-quânticos nativos (prefixo visível age1pq…) que protegem a chave do arquivo via HPKE SealBase (RFC 9180) sobre um KEM ML-KEM-768 + X25519, e não pelo padrão de estrofes. Manter a proteção por estrofe no caminho híbrido é o que permite a uma única proteção uniforme e a um único vínculo de cabeçalho uniforme cobrir os dois KEMs. A proteção híbrida, portanto, não herda a construção HPKE do age, e nenhuma afirmação de herança do age é feita para ela; a codificação de destinatário distinta age1pqc (veja Chaves) reflete que as duas codificações híbridas são independentes.

Clássico: x25519

Para cada destinatário, o remetente gera um par de chaves X25519 efêmero novo, realiza um ECDH contra a chave pública do destinatário e deriva a KEK com HKDF (RFC 5869) sob um salt de hash rotulado:

CBOR
shared   = X25519(priv_epk, pub_R)               ; per RFC 7748; reject all-zero output
kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R)  ; 32 bytes
KEK      = HKDF-SHA-256(ikm = shared,
                        salt = kek_salt,          ; binds nonce, ephemeral, recipient
                        info = "cardano-poe-kek-v1",
                        L = 32)
slot     = { "epk": pub_epk, "wrap": wrap }       ; epk = 32 bytes

A chave pública efêmera epk de 32 bytes é o único material de chave no formato de transmissão; a chave pública do destinatário nunca é publicada. O salt é um SHA-256 rotulado que vincula três valores: pub_epk mantém a KEK de cada slot única, pub_R a vincula ao destinatário específico (frustrando qualquer tentativa de reaproveitar uma epk contra um destinatário diferente) e o enc.nonce único do envelope ancora a KEK a um único envelope — de modo que uma falha de CSPRNG que repetisse a aleatoriedade de KEM entre dois envelopes só degradaria para vinculabilidade entre envelopes, nunca para um par de proteção (KEK, nonce-zero) repetido. As implementações de X25519 DEVEM rejeitar o segredo compartilhado todo zerado, conforme a RFC 7748 §6.1; as bibliotecas de uso corrente fazem isso de forma transitiva.

Híbrido: mlkem768x25519 (X-Wing)

O KEM híbrido é a construção X-Wing (draft-connolly-cfrg-xwing-kem-10), combinando ML-KEM-768 (FIPS 203) com X25519. Cada encapsulamento sorteia aleatoriedade nova de ML-KEM e uma efêmera X25519 nova, e produz um texto cifrado de 1120 bytes e um segredo compartilhado combinado de 32 bytes. A derivação da KEK vincula o destinatário por meio de um salt externo calculado sobre os próprios bytes de transmissão do slot:

CBOR
enc      = XWing.Encapsulate(pub_R)    ; named fields — MUST NOT consume positional order
kem_ct   = enc.ct                      ; 1120 bytes
shared   = enc.ss                      ; 32 bytes
kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R)  ; 32 bytes
KEK      = HKDF-SHA-256(ikm = shared,
                        salt = kek_salt,          ; binds nonce, kem_ct, recipient
                        info = "cardano-poe-kek-mlkem768x25519-v1",
                        L = 32)
wrap     = ChaCha20-Poly1305_seal(key = KEK, nonce = zeros(12),
                                  ad = "cardano-poe-kek-mlkem768x25519-v1", plaintext = CEK)
slot     = { "kem_ct": kem_ct, "wrap": wrap }     ; kem_ct = single 1120-byte byte string

Tamanhos de chave e texto cifrado do X-Wing:

ComponenteTamanhoComposição
Chave pública1216 bytesML-KEM-768 ek (1184) ‖ X25519 pk (32)
Texto cifrado1120 bytesML-KEM-768 ct (1088) ‖ X25519 ephemeral (32)
Segredo compartilhado32 bytessaída do combinador X-Wing
Chave de decapsulamento32 bytesuma semente; a chave pública é derivada dela

Um slot híbrido não carrega o campo epk — a efêmera X25519 são os últimos 32 bytes dos 1120 bytes de kem_ct. XWing.Encapsulate DEVE aplicar a verificação de validade de chave pública da revisão fixada do X-Wing a pub_R e rejeitar uma chave inválida em vez de encapsular para ela; essa é a precondição sob a qual o piso híbrido nunca cai abaixo da segurança clássica do X25519. A construção consome o X-Wing por meio de um adaptador com apenas campos nomeados: Encapsulate(pk) produz .ct (1120 B) e .ss (32 B); Decapsulate(sk, ct) produz o segredo compartilhado de 32 bytes. As implementações DEVEM mapear para a API da revisão fixada por nome e NÃO DEVEM consumir valores de retorno posicionais — a revisão fixada retorna (ss, ct) do encapsulamento e escreve o desencapsulamento como Decapsulate(ct, sk), o inverso de uma leitura ingênua da esquerda para a direita. A derivação da KEK vincula o destinatário por meio de um salt rotulado de comprimento fixo, SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R), em que kem_ct é o texto cifrado de 1120 bytes exatamente como carregado no slot e pub_R é a chave pública X-Wing do destinatário de 1216 bytes. Esta é a mesma forma de três valores que o salt clássico usa sob seu próprio rótulo — kem_ct ancora a KEK a um valor único por slot, pub_R a vincula ao destinatário específico e enc.nonce a ancora a um único envelope — expressa por um digest SHA-256, porque as entradas híbridas são grandes demais para um salt cru. Em ambos os salts, o termo pub_R é a codificação de transmissão canônica da chave do destinatário: exatamente os 32 bytes de x25519_publicKey(priv_R) para x25519, exatamente a string de bytes de 1216 bytes da chave pública X-Wing fixada para mlkem768x25519. Produtor e verificador DEVEM usar essa codificação exata e NÃO DEVEM substituí-la por qualquer equivalente não canônico ou recodificado, ou os dois lados derivam KEKs diferentes e um registro honesto falha ao abrir. De forma crucial, o vínculo é calculado fora do KEM, sobre os próprios bytes de transmissão do slot, de modo que a construção trata o X-Wing como um KEM de caixa preta: ela consome apenas a interface pública do KEM (encapsular, desencapsular, o segredo compartilhado de 32 bytes) e não faz nenhuma suposição sobre o hashing interno do combinador. O rótulo info distinto por KEM, cardano-poe-kek-mlkem768x25519-v1, garante adicionalmente que uma KEK derivada para um KEM nunca possa ser igual a uma KEK derivada para o outro, mesmo sobre um segredo compartilhado de 32 bytes idêntico. O texto cifrado de 1120 bytes é carregado como uma única string de bytes CBOR em slot.kem_ct — apenas o corpo inteiro do registro é fragmentado para transporte (veja O registro), nunca um campo individual.

Um KEM por registro

Um único item de PoE selada carrega exatamente um enc.kem; todo slot usa o formato e a derivação de KEK desse KEM. Um arquivo é todo clássico ou todo híbrido — slots de KEMs diferentes NÃO DEVEM aparecer no mesmo array slots, e um verificador DEVE rejeitar um registro cujos formatos de slot sejam inconsistentes com o enc.kem declarado (ENC_SLOT_INVALID_SHAPE).

O material de encapsulamento também DEVE ser distinto dentro de um mesmo array slots: para x25519, todos os valores de epk DEVEM ser diferentes; para mlkem768x25519, todos os valores de kem_ct DEVEM ser diferentes. Uma duplicata é rejeitada — antes que qualquer primitiva KEM ou AEAD seja executada — com ENC_SLOTS_DUPLICATE_KEM_MATERIAL. Esta é a fatia verificável do invariante de unicidade-de-KEK-por-slot da qual o envoltório de nonce zero depende: o reúso de KEK entre registros ou entre chaves é uma obrigação do produtor que um verificador não consegue detectar, mas uma duplicata dentro do registro é estruturalmente visível e DEVE falhar.

Decifragem por tentativa do destinatário

Um destinatário detém uma chave privada (um escalar X25519 de 32 bytes para x25519, ou uma semente de decapsulamento X-Wing de 32 bytes para mlkem768x25519 — ambas derivadas da semente; veja Chaves). Ele não sabe de antemão qual slot, se algum, é o seu, então decifra por tentativa o array. Duas propriedades moldam o laço: a verificação do MAC do conjunto de slots é incorporada a ele (um slot só é aceito quando a CEK candidata também reproduz o slots_mac presente no formato de transmissão), e o laço percorre todos os slots sem interrupção antecipada, selecionando a correspondência em tempo constante, de modo que um observador de temporização não consiga inferir qual índice de slot coincidiu.

Antes de invocar qualquer primitiva KEM ou AEAD, o verificador DEVE rodar as verificações de forma estrutural (a defesa contra oráculo de particionamento): scheme == 1, aead/kem registrados, nonce de 24 bytes, slots_mac de 32 bytes, slots não vazio, o segredo do destinatário de 32 bytes, cada slot.wrap exatamente de 48 bytes, cada epk de x25519 de exatamente 32 bytes sem kem_ct, cada kem_ct de mlkem768x25519 de exatamente 1120 bytes sem epk, e a distinção, dentro de slots, de todo o material de encapsulamento (caso contrário, ENC_SLOTS_DUPLICATE_KEM_MATERIAL).

Nessa mesma passada pré-primitiva, o verificador DEVE também limitar o uso de recursos do parser: os limites de referência são MAX_SLOTS = 1024 slots e 65536 bytes para o envelope enc decodificado. Ambos ficam muito acima do teto de ≈ 16 KiB de metadados de transação da Cardano que limita um registro honesto, de modo que um registro que exceda qualquer um deles é malformado e é rejeitado aqui — ENC_SLOTS_TOO_MANY para slots em excesso, ENC_ENVELOPE_TOO_LARGE para um envelope superdimensionado — antes de qualquer primitiva KEM ou AEAD ser executada. Esses limites são constantes impostas pelo verificador e fixadas pela implantação, não campos de transmissão; uma implantação PODE restringi-los.

; hashes_hash, SLOTS_TRANSCRIPT and slots_hash are recomputed once, before the loop, and held constant:
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))
slots_hash  = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
if kem == "x25519": pub_R = x25519_publicKey(priv_R)   ; recipient public key, 32 B
else:               pub_R = XWing.publicKey(priv_R)     ; recipient X-Wing public key, 1216 B

found        = false
cek_conflict = false
selected_CEK = zeros(32)
for slot in enc.slots:                  ; iterate ALL slots — no early break
    kem_ok = true
    if kem == "x25519":
        shared    = x25519(priv_R, slot.epk)
        kem_ok    = NOT constant_time_eq(shared, zeros(32))     ; explicit all-zero reject, secret-independent
        kek_salt  = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || slot.epk || pub_R)
        real_KEK  = HKDF-SHA-256(shared,    salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
        dummy_KEK = HKDF-SHA-256(zeros(32), salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
        KEK       = ct_select(kem_ok, real_KEK, dummy_KEK)      ; constant-time, no early exit
        ad_wrap   = "cardano-poe-kek-v1"
    else:                               ; mlkem768x25519
        shared   = XWing.Decapsulate(sk=priv_R, ct=slot.kem_ct)   ; pinned API writes Decapsulate(ct, sk)
        kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || slot.kem_ct || pub_R)
        KEK      = HKDF-SHA-256(shared, salt = kek_salt,
                               info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
        ad_wrap  = "cardano-poe-kek-mlkem768x25519-v1"

    open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), ad_wrap, slot.wrap)
    HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
    mac_ok   = constant_time_eq(HMAC-SHA-256(HMAC_KEY, slots_hash), enc.slots_mac)
    ok       = kem_ok AND open_ok AND mac_ok                    ; kem_ok folded into acceptance
    first        = ok AND NOT found                             ; first matching slot
    cek_conflict = cek_conflict OR (ok AND found AND NOT constant_time_eq(candidate_CEK, selected_CEK))
    selected_CEK = ct_select(first, candidate_CEK, selected_CEK)   ; constant-time
    found        = found OR ok

if NOT found:    reject (single generic failure)   ; WRONG_RECIPIENT_KEY / TAMPERED_HEADER
if cek_conflict: reject (single generic failure)   ; cek_conflict

content_key = HKDF-SHA-256(selected_CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)
plaintext   = STREAM_open(content_key, ciphertext)   ; per-chunk authenticated release
if STREAM_open fails at any chunk: reject (single generic failure)   ; TAMPERED_CIPHERTEXT

A derivação da KEK ramifica conforme enc.kem: para x25519, o destinatário realiza um ECDH contra slot.epk e rederiva o mesmo salt rotulado sobre enc.nonce || slot.epk || pub_R; para mlkem768x25519, ele desencapsula slot.kem_ct diretamente com X-Wing (uma única string de bytes de 1120 bytes) e recalcula o mesmo salt rotulado sobre enc.nonce || slot.kem_ct || pub_R, em que pub_R é sua própria chave pública X-Wing de 1216 bytes derivada da semente que detém. A rejeição do segredo compartilhado X25519 todo zerado é explícita aqui, em vez de apoiada de forma transitiva: um slot forjado para levar o segredo compartilhado a zeros(32) (RFC 7748 §6.1) zera o bit de validade independente do segredo kem_ok, a KEK é selecionada em tempo constante para um dummy_KEK derivado de zeros(32) sob o mesmo salt e o mesmo info, de modo que o laço realize trabalho idêntico, e kem_ok é incorporado a ok — de modo que um slot com ECDH inválido nunca possa ser aceito, independentemente do resultado do envoltório ou do MAC, e o registro expõe a única falha genérica se nada mais coincidir. Tudo o que vem depois da abertura da proteção — a verificação do MAC do conjunto de slots, a derivação da chave de conteúdo e a decifragem do conteúdo — independe do KEM.

Ambas as primitivas AEAD *_open_or_dummy são atômicas: em uma falha de verificação de tag, elas não retornam texto em claro, e o candidato retornado (candidate_CEK para a abertura do envoltório, o plaintext para a abertura do conteúdo) é um valor fictício, fixo ou pseudoaleatório, independente do texto cifrado que falhou. Nenhum texto em claro não verificado é jamais liberado ao chamador, de modo que uma abertura malsucedida não pode se tornar um oráculo de decifragem.

Por que a verificação do MAC fica dentro do laço

Um remetente malicioso pode forjar um slot que abre sob a chave de um destinatário, mas produz uma CEK escolhida pelo atacante (encapsular para a chave pública do destinatário não exige nenhuma chave privada). Se um destinatário aceitasse o primeiro sucesso de AEAD como "o seu", esse slot forjado ofuscaria um honesto mais adiante no array. Incorporar a verificação do slots_mac ao laço significa que um slot só é aceito quando sua CEK candidata reproduz o MAC sobre slots_hash — então um slot forjado é ignorado e a varredura continua. O comprimento de slot.wrap DEVE ser verificado como 48 bytes antes de qualquer chamada de AEAD, uma defesa contra oráculo de particionamento que o age v1 também aplica.

Múltiplos slots correspondentes: a duplicação é permitida, um conflito de CEK não. A chave privada de um destinatário PODE legitimamente corresponder a mais de um slot. Um produtor pode selar a mesma CEK para o mesmo destinatário em vários slots — cada um com sua própria efêmera por slot fresca — para preencher a contagem aparente de destinatários, uma técnica de privacidade válida. O verificador seleciona a CEK do primeiro slot correspondente e NÃO DEVE rejeitar apenas porque mais de um slot correspondeu. Isso é distinto da rejeição de material de encapsulamento duplicado dentro do registro (ENC_SLOTS_DUPLICATE_KEM_MATERIAL), que dispara em um epk ou kem_ct repetido: a duplicação honesta sorteia aleatoriedade de KEM por slot fresca para cada aparição, de modo que seus epk / kem_ct diferem e ela nunca colide com essa verificação. A única anomalia que o verificador DEVE rejeitar são dois slots correspondentes que recuperam CEKs diferentes (comparadas em tempo constante): o laço carrega um bit cek_conflict ao longo de todos os slots e, se qualquer correspondência posterior recuperar uma CEK que difira da selecionada, expõe a única falha genérica. Isso é defesa em profundidade — sob a propriedade de compromisso que a CEK recuperada fornece (o MAC do conjunto de slots vincula a CEK a uma única transcrição de slots; veja Anonimato e a divisão por KEM), uma correspondência com CEK distinta já é inviável, sendo exatamente a colisão multi-chave que o compromisso exclui, de modo que a verificação falha de forma fechada contra uma implementação quebrada ou um enfraquecimento futuro dessa suposição.

Uma única forma de falha genérica, tempo constante ao longo dos slots

Um chamador não confiável DEVE receber exatamente uma forma de falha genérica, qualquer que tenha sido o motivo da falha de decifragem — nenhum slot abriu, o conjunto de slots foi adulterado ou o AEAD de conteúdo falhou — e a resposta NÃO DEVE distinguir esses casos, nem revelar qual slot coincidiu. Uma implementação PODE expor códigos tipados internos — WRONG_RECIPIENT_KEY (nenhum slot abre), TAMPERED_HEADER (um slot abre, mas nenhuma CEK candidata reproduz o slots_mac sobre slots_hash), TAMPERED_CIPHERTEXT (o AEAD de conteúdo falha depois de uma CEK ser recuperada) — a um chamador local confiável, para diagnóstico, mas esses códigos NÃO DEVEM vazar para um observador externo por uma resposta distinguível.

Quanto à temporização, o verificador PODE retornar na verificação de não correspondência (if NOT found) antes da decifragem do conteúdo. Esse retorno antecipado revela apenas destinatário versus não destinatário — nunca qual slot coincidiu e nenhum material de chave — porque o laço entre slots acima já rodou até o fim quando a verificação é alcançada. Uma temporização uniforme entre o caso do não destinatário e um destinatário cujo texto cifrado falha ao abrir NÃO é exigida, e uma abertura de conteúdo fictícia NÃO DEVE ser obrigatória: forçar todo não destinatário a pagar o custo integral da decifragem de conteúdo não compra nenhuma privacidade que o laço já não forneça. A garantia de tempo constante que de fato vale é a invariante entre slots — o laço processa um número constante de operações de slot por chave privada, sem interrupção antecipada, de modo que um observador de nível de rede fica sabendo apenas a contagem de slots, nunca qual slot (se algum) a chave desenvolve. Um destinatário que detém várias chaves (por exemplo, chaves arquivadas ao longo de uma rotação de identidade) itera chave privada × slot, rederivando a metade pub_R do salt a partir da chave atual; ele PODE curto-circuitar entre chaves (vazando apenas o sinal fraco de "qual chave coincidiu"), mas DEVE permanecer em tempo constante ao longo dos slots de qualquer chave isolada.

Depois de recuperar o texto claro, o destinatário — na camada de aplicação, não na função de decifragem — recalcula o hash do texto claro e o confere contra items[].hashes. Uma divergência significa que o compromisso on-chain do registro não corresponde aos bytes decifrados, e o destinatário DEVE se recusar a agir sobre esse texto claro. É este o passo que fecha o ciclo: a cadeia testemunhou um compromisso no instante T, e o destinatário confirma que ele é um compromisso com exatamente esses bytes.

Caminho da frase secreta

O caminho alternativo de entrega de chave substitui os slots de destinatário por uma frase secreta. Não há array slots, nem slots_mac, nem efêmera por slot, nem laço de decifragem por tentativa: a CEK é derivada diretamente de uma frase secreta normalizada via Argon2id (RFC 9106) sobre um salt e parâmetros on-chain. O compromisso de chave que slots_mac fornece no caminho de slots mora, em vez disso, em um cabeçalho de 32 bytes dentro do blob de texto cifrado, anteposto antes dos blocos do STREAM — mesmo objeto, mesma URI, mesma busca.

CBOR
passphrase_bytes = utf8(normalize(passphrase))   ; cardano-poe-pw-norm-v1 (see below)
CEK              = argon2id(passphrase_bytes,
                           salt   = enc.passphrase.salt,    ; 16–64 bytes, on chain
                           params = enc.passphrase.params,  ; { m, t, p }, on chain
                           L      = 32)

hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))

PASSPHRASE_TRANSCRIPT = {              ; closed 6-key map; keys are a set, not an order
  "scheme":      1,                    ; uint
  "path":        "passphrase",         ; text
  "aead":        <enc.aead>,           ; text: the content-format identifier
  "nonce":       <enc.nonce>,          ; bytes(24)
  "hashes_hash": hashes_hash,          ; bytes(32), over this item's hashes
  "passphrase": {                      ; closed sub-map
    "alg":           "argon2id",       ; text
    "salt":          enc.passphrase.salt,         ; bytes
    "params":        { "m": m, "t": t, "p": p },  ; closed map of uints
    "normalization": "cardano-poe-pw-norm-v1"}}   ; scheme-fixed constant, NOT on the wire

pw_hash     = SHA-256("cardano-poe-passphrase-transcript-v1" || canonicalEncode(PASSPHRASE_TRANSCRIPT))
PW_MAC_KEY  = HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-passphrase-mac-v1", L = 32)
commitment  = HMAC-SHA-256(key = PW_MAC_KEY, msg = pw_hash)   ; 32 bytes

content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce,
                           info = "cardano-poe-payload-passphrase-v1", L = 32)
ciphertext blob = commitment || STREAM chunks                 ; STREAM under content_key, as on the slots path

O bloco enc.passphrase no formato de transmissão é { alg, salt, params } — ele nomeia o KDF ("argon2id"), o salt e os parâmetros. O Label 309 fixa um piso de parâmetros de m ≥ 65536 KiB (64 MiB), t ≥ 3, p ≥ 1; o produtor escolhe valores no piso ou acima dele, e o salt tem de 16 a 64 bytes, inclusive (o teto de 64 bytes é o limite de cadeia de bytes do metadado). Onde a plataforma der suporte, os produtores DEVERIAM usar p = 4 (o segundo perfil recomendado da RFC 9106 §4); os verificadores PODEM aceitar qualquer p ≥ 1, sujeito aos tetos da implantação abaixo.

A PASSPHRASE_TRANSCRIPT vincula os parâmetros do KDF, os campos de cabeçalho e a alegação de hash do item ao compromisso: o verificador recalcula a transcrição a partir do mapa enc recebido e do hashes do item, de modo que adulterar salt, qualquer valor de params, nonce, aead, ou colar o envelope em uma alegação de hash diferente produz um pw_hash diferente e a verificação do compromisso falha. O conteúdo é então selado no mesmo STREAM segmentado do caminho de slots, sob a chave de conteúdo do caminho de frase secreta. O valor de "normalization" é uma constante fixada pelo esquema alimentada à transcrição para fixar o perfil exato sob o qual a CEK foi derivada; ele nunca é serializado no formato de transmissão.

Ordem de verificação. O verificador deriva a CEK candidata a partir da frase secreta digitada, lê os 32 bytes iniciais do blob de texto cifrado, recalcula o compromisso e o compara em tempo constante — antes de abrir qualquer bloco do STREAM. Um blob do caminho de frase secreta com menos de 48 bytes — o cabeçalho de compromisso de 32 bytes mais o STREAM mínimo de 16 bytes — não pode ser bem formado e é texto cifrado malformado (TAMPERED_CIPHERTEXT). Em caso de divergência — frase secreta errada, salt / params adulterados, cabeçalho adulterado, ou um envelope colado — o verificador expõe a mesma falha genérica única de qualquer outra falha de decifragem e NÃO DEVE começar a transmitir. Uma frase secreta errada é, portanto, indistinguível de um registro adulterado.

Antes da normalização e do Argon2id, uma implementação DEVE limitar o comprimento da entrada bruta da frase secreta, de modo que uma frase secreta superdimensionada não possa provocar uma negação de serviço pré-KDF: o limite de referência é de 4096 bytes UTF-8 de entrada bruta, rejeitada antes de qualquer trabalho de normalização ou de hashing. Como os limites de MAX_SLOTS e do envelope enc decodificado que o caminho de slots impõe, este é uma constante imposta pelo verificador e fixada pela implantação — não um campo de transmissão — e uma implantação PODE restringi-lo. Além do piso de parâmetros, as implementações DEVERIAM também impor limites superiores sobre m, t e p contra a negação de serviço do lado do verificador; esses tetos não são normativos (dependem do hardware) e NÃO DEVEM ser confundidos com o piso.

Por que o compromisso é off-chain

Um compromisso de frase secreta on-chain entregaria a todo observador um oráculo de teste offline gratuito — derivar uma CEK candidata de uma frase secreta adivinhada, conferi-la contra a cadeia — para todo registro de frase secreta, para sempre, inclusive registros cujo texto cifrado é retido. Carregar o compromisso dentro do blob de texto cifrado significa que testar um palpite exige o próprio blob: um registro com texto cifrado retido não expõe nenhum material adivinhável da frase secreta no ledger permanente, e um destinatário legítimo que já detém o blob não paga nada para ler primeiro um cabeçalho de 32 bytes.

O perfil de normalização

A normalização aplicada à frase secreta antes do Argon2id é o perfil fixo cardano-poe-pw-norm-v1. Ele é normativo: duas implementações DEVEM derivar uma CEK idêntica byte a byte a partir da mesma frase secreta, e o único jeito de garantir isso é uma normalização fixada. O perfil, aplicado em ordem, é:

  1. Rejeite codepoints não atribuídos. Uma frase secreta que contenha qualquer codepoint não atribuído no Unicode 16.0 é rejeitada com ENC_PASSPHRASE_UNNORMALIZABLE antes de qualquer etapa de normalização rodar.
  2. NFKC. Aplique a Forma de Normalização KC conforme a UAX #15 sob Unicode 16.0.
  3. Espaço em branco. Defina "espaço em branco" como todo caractere que carrega a propriedade Unicode White_Space sob Unicode 16.0, e colapse toda sequência máxima desses caracteres em um único U+0020 SPACE.
  4. Trim. Remova o espaço em branco inicial e final.
  5. Rejeite vazio. Se o resultado for a string vazia, rejeite com ENC_PASSPHRASE_EMPTY: uma frase secreta só de espaços em branco ou de outro modo vazia normaliza para zero bytes, o que o Argon2id aceitaria silenciosamente — fixando o registro a uma CEK que qualquer parte pode derivar.
  6. Codifique. Codifique o resultado como UTF-8; esses bytes são a entrada de senha do Argon2id.

O passo 1 é o que torna o perfil determinístico entre implementações e ao longo do tempo. A Política de Estabilidade de Normalização do Unicode garante que a normalização de uma string seja estável entre versões futuras do Unicode apenas quando todo codepoint nela é atribuído na versão em que foi normalizada; um codepoint não atribuído pode adquirir uma decomposição mais tarde e mudar silenciosamente a CEK derivada. Rejeitar codepoints não atribuídos fecha essa brecha por completo, e é invisível para usuários honestos — todo caractere em uso escrito real é atribuído.

A versão Unicode é fixada em Unicode 16.0 literalmente e NÃO DEVE flutuar: o conjunto da propriedade White_Space, o conjunto de codepoints atribuídos e as tabelas de mapeamento NFKC dependem todos da versão, e um verificador que resolva o perfil contra uma versão Unicode diferente poderia derivar uma CEK diferente a partir da mesma frase secreta e não conseguir decifrar um registro honesto. Uma revisão futura que adote uma versão Unicode mais nova o faz sob um novo identificador de perfil, não reinterpretando cardano-poe-pw-norm-v1.

A entropia da frase secreta é a única barreira

O salt e os parâmetros do Argon2id ficam públicos na cadeia para sempre, então um atacante tem tempo offline ilimitado para forçar a frase secreta contra eles. A entropia da frase secreta é a única margem de segurança neste caminho. Os produtores DEVERIAM usar uma frase secreta diceware gerada por CSPRNG em vez de uma escolhida por uma pessoa, e DEVERIAM exibir um aviso visível ao aceitar frases secretas digitadas, alertando que o texto cifrado on-chain ficará permanentemente sujeito a ataque offline.

Sigilo futuro e independência entre slots

A construção de slots usa ECDH efêmero-estático (ou encapsulamento X-Wing novo) com uma efêmera nova por slot, o que rende duas propriedades que um design estático-estático ou de efêmera compartilhada perderia:

  • Sigilo futuro contra comprometimento do remetente. O remetente não detém nenhuma chave de longo prazo na construção; a efêmera é zerada após o selamento. Comprometer o estado do remetente mais tarde não consegue decifrar registros publicados antes do comprometimento.
  • Independência entre slots. Destinatários diferentes recebem efêmeras diferentes e, portanto, segredos compartilhados e KEKs diferentes. Um destinatário que vaze a sua CEK protegida revela a CEK (inevitável — ela é a chave do arquivo), mas nunca a KEK de outro destinatário.

A PoE selada não tem sigilo futuro para o destinatário, por projeto: uma vez que um registro é selado para uma chave de destinatário de longo prazo, quem detém a chave privada correspondente pode decifrá-lo para sempre. Isso é uma propriedade da criptografia de chave pública para uma chave de longo prazo, não um defeito.

Anonimato e a divisão por KEM

Quando um registro de PoE selada carrega nenhum sigs, seus bytes no formato de transmissão são independentes da identidade do remetente: cada slot carrega apenas material de KEM efêmero, específico do registro e do slot (a efêmera X25519 em slot.epk, ou o texto cifrado X-Wing em slot.kem_ct), as chaves de longo prazo do remetente nunca aparecem, os slots são embaralhados por CSPRNG, nenhuma chave pública de destinatário fica no formato de transmissão, e nenhum campo descritivo (nome de arquivo, tipo MIME, tamanho) está presente. Um registro selado não assinado, portanto, não vincula nenhuma identidade de remetente na cadeia — que é exatamente o que exigem vazamentos de denunciantes, leilões de propostas seladas e custódia de provas.

Para ambos os KEMs, os vazamentos honestos são idênticos e inevitáveis: a contagem de slots, a distinção entre selado e aberto e a família de KEM clássica versus híbrida (enc.kem) ficam visíveis a qualquer observador; nada além disso sobre os destinatários fica.

A afirmação mais forte — a de que um adversário que detém um conjunto de chaves públicas candidatas de destinatários não consegue testar se um dado slot é endereçado a uma delas (privacidade de chave / anonimato do destinatário) — é uma propriedade por KEM:

  • x25519 — privado quanto à chave. O encapsulamento por slot é uma chave pública efêmera nova, estatisticamente independente da chave do destinatário. Um adversário de posse de chaves públicas candidatas de destinatários não consegue, só com slot.epk e slot.wrap, decidir qual candidata (se alguma) o slot tem como alvo sem a chave privada correspondente. O caminho clássico é, portanto, privado quanto à chave, o que também dá impossibilidade de vínculo entre registros: duas PoEs seladas para o mesmo destinatário parecem blobs epk/wrap sem relação entre si.
  • mlkem768x25519 — não afirmado. O anonimato do destinatário diante de um adversário de posse de chaves públicas candidatas de destinatários é uma propriedade separada, não implicada pela segurança IND-CCA do KEM híbrido. O Label 309 não a afirma para o caminho X-Wing a menos que e até que seja justificada de forma independente para o X-Wing. Uma implantação cujo modelo de ameaças exige anonimato do destinatário diante de um adversário de posse de chaves NÃO DEVE apoiar-se no caminho híbrido para essa propriedade.

Remetentes preocupados com a correlação por tempo entre registros DEVEM agrupar as publicações fora da linha do tempo crítica; a criptografia no nível do protocolo não consegue resolver ataques de temporização sobre metadados.

O MAC do conjunto de slots é o compromisso; o envoltório não precisa ser

A CEK recuperada é um compromisso com o conjunto de slots que o destinatário casou: um remetente malicioso não consegue construir dois conjuntos de slots distintos que um único destinatário aceite como seus. A propriedade exigida aqui é o compromisso restrito de chave para a CEK do envelope no sentido da RFC 9771 — a CEK recuperada se vincula a uma única transcrição de slots — e não um AEAD comprometedor pleno sobre entradas arbitrárias. Isso repousa sobre a resistência a colisões multi-chave de CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) para CEKs e transcrições escolhidas adversarialmente — uma margem de colisão genérica de ~128 bits (o limite de aniversário sobre uma saída de 256 bits), adequada ao modelo de ameaças. A evidência de adulteração da própria transcrição herda o limite de colisão de ~2^128 do SHA-256: qualquer alteração dos campos de cabeçalho comprometidos ou dos bytes de slot altera slots_hash, e forjar um slots_hash inalterado sobre uma transcrição diferente é exatamente aquela busca de colisão de ~2^128. Como o compromisso é fornecido pelo slots_mac, o AEAD do wrap por slot não precisa ser um AEAD comprometedor; o ChaCha20-Poly1305 padrão, não comprometedor, é sólido aqui.

Padrões proibidos

Uma implementação em conformidade NÃO DEVE:

  • Reutilizar uma efêmera por slot entre slots ou registros, ou de qualquer outra forma deixar uma KEK se repetir — a proteção com nonce zerado depende da unicidade da KEK por slot.
  • Reutilizar uma CEK entre envelopes — uma CEK nova de CSPRNG por item portador de enc, tanto dentro de um registro quanto entre registros.
  • Reutilizar um salt de frase secreta — gere um enc.passphrase.salt novo de CSPRNG para todo envelope de frase secreta; o salt é o único separador entre registros para uma frase secreta reutilizada.
  • Misturar KEMs dentro de um mesmo array slots (um enc.kem por registro).
  • Publicar os slots na ordem de entrada — o embaralhamento por CSPRNG é obrigatório.
  • Proteger a CEK com qualquer nonce que não seja o nonce zero de 12 bytes, ou com AAD vazio no AEAD do envoltório — o AAD do envoltório é o literal do rótulo info do KEM.
  • Colocar uma chave pública de destinatário no formato de transmissão — o design de decifragem por tentativa é o recurso de privacidade; publicar as chaves públicas o anula.
  • Pular a verificação do slots_mac — sem ela, a substituição de slot tem êxito.
  • Armazenar o texto claro na URI ar:///ipfs:// — apenas o texto cifrado é publicado; o texto claro é entregue fora de banda ou retido pelo remetente.
  • Referenciar o texto cifrado por qualquer esquema que não seja ar:// ou ipfs:// — os esquemas endereçados por conteúdo vinculam a URI aos bytes; uma URL servida por um host exigiria um compromisso on-chain separado sobre o texto cifrado, que a PoE selada não carrega.
  • Registrar em log ou persistir a CEK, qualquer KEK, a chave HMAC do conjunto de slots, a chave do MAC de frase secreta, a chave de conteúdo, um segredo compartilhado de ECDH, uma chave privada efêmera ou uma chave privada de destinatário.

Páginas relacionadas

  • Chaves — os pares de chaves X25519 e X-Wing derivados da semente que fornecem o material de chave do destinatário e do remetente.
  • O registro — onde enc fica no mapa do registro e o transporte do corpo inteiro que carrega o registro na cadeia.
  • Registros de identificadores de algoritmos — os identificadores enc.aead, enc.kem e do KDF de frase secreta e suas primitivas de apoio.
  • Conteúdo e hashing — o compromisso com o hash do texto claro que todo registro selado carrega.
  • Verificação — o pipeline de validação, por que o validador nunca decifra e o catálogo de erros.