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ássicox25519; ela não é afirmada para o caminho híbridomlkem768x25519(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.
| Campo | Status | Significado |
|---|---|---|
scheme | OBRIGATÓRIO | Versão da família de construção. A v1 define scheme = 1. |
aead | OBRIGATÓRIO | Identificador do formato de conteúdo. A v1 define "chacha20-poly1305-stream64k". |
nonce | OBRIGATÓRIO | 24 bytes aleatórios — o salt único do envelope da chave de conteúdo e de cada KEK de slot. |
kem | apenas caminho de slots | Seletor de KEM por slot ("x25519" ou "mlkem768x25519"). |
slots | um dos caminhos | Array de slots de chave por destinatário (multi-destinatário). |
slots_mac | apenas caminho de slots | HMAC de 32 bytes que vincula o conjunto de slots e a alegação de hash do item à chave de conteúdo. |
passphrase | o outro caminho | Bloco 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:
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:
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 bytesO 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):
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:
- Seleciona um KEM para o registro inteiro e gera a CEK (32 bytes aleatórios) e o
nonce(24 bytes aleatórios). - Para cada destinatário, deriva uma KEK por slot e protege a CEK sob ela (detalhes por KEM abaixo).
- Embaralha o array de slots com um CSPRNG (Fisher-Yates sem viés).
- 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_hashe calculaslots_maccomo um HMAC firmado com a chave CEK sobre esse hash. - 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):
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.
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 bytesSLOTS_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.kem | KEM | Chave pública do destinatário | Formato do slot | String 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-768 | 1216 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:
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 bytesA 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:
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 stringTamanhos de chave e texto cifrado do X-Wing:
| Componente | Tamanho | Composição |
|---|---|---|
| Chave pública | 1216 bytes | ML-KEM-768 ek (1184) ‖ X25519 pk (32) |
| Texto cifrado | 1120 bytes | ML-KEM-768 ct (1088) ‖ X25519 ephemeral (32) |
| Segredo compartilhado | 32 bytes | saída do combinador X-Wing |
| Chave de decapsulamento | 32 bytes | uma 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_CIPHERTEXTA 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.
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 pathO 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, é:
- 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_UNNORMALIZABLEantes de qualquer etapa de normalização rodar. - NFKC. Aplique a Forma de Normalização KC conforme a UAX #15 sob Unicode 16.0.
- Espaço em branco. Defina "espaço em branco" como todo caractere que carrega a
propriedade Unicode
White_Spacesob Unicode 16.0, e colapse toda sequência máxima desses caracteres em um único U+0020 SPACE. - Trim. Remova o espaço em branco inicial e final.
- 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. - 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ó comslot.epkeslot.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 blobsepk/wrapsem 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.saltnovo 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(umenc.kempor 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
infodo 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://ouipfs://— 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
encfica 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.keme 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.
Assinaturas
O array opcional `sigs`, no nível do registro — um COSE_Sign1 destacado sobre todo o corpo do registro, sua carga útil assinada com separação de domínio, os dois caminhos para a chave do signatário e a verificação estrita de Ed25519.
Verificação
Os três papéis de verificador do Label 309, os estados de veredito, a profundidade de finalidade e o catálogo tipado de erros — como qualquer pessoa chega à mesma resposta usando apenas infraestrutura pública.