Esta es una traducción a título informativo. La versión en inglés es la normativa y es la que prevalece. Leer la versión en inglés

PoE sellada

El sobre de cifrado de Label 309: cómo un remitente sella el contenido para una o varias claves de destinatario mientras la cadena solo transporta el hash del texto plano y las ranuras de clave envueltas, nunca el texto plano y nunca los destinatarios.

Una PoE sellada ancla un compromiso con marca de tiempo sobre un texto plano y, al mismo tiempo, mantiene ese texto plano legible únicamente para una audiencia elegida. El registro en la cadena transporta el hash del texto plano (la prueba de temporalidad, exactamente igual que en cualquier otro registro) más un sobre de cifrado (enc) que contiene el material necesario para recuperar la clave de cifrado del contenido. El texto cifrado en sí nunca toca la cadena: reside en un URI direccionado por contenido (ar:// o ipfs://). Nada en la cadena revela el texto plano, y nada revela quiénes son los destinatarios.

Esta página especifica el sobre enc: sus dos rutas de entrega de clave mutuamente excluyentes, las ranuras de clave por destinatario, el MAC del conjunto de ranuras, el STREAM segmentado del contenido y el descifrado de prueba que un destinatario realiza para descubrir y abrir un mensaje dirigido a él. Las claves de destinatario en sí (los pares de claves X25519 y X-Wing derivados de la semilla) se definen en Claves; esta página los consume. El lugar del mapa enc dentro del mapa del registro, y el transporte del cuerpo completo que lo lleva en la cadena, se definen en El registro.

No es HPKE

Esto no es el HPKE de RFC 9180. Es un diseño multidestinatario de tipo age, KEM-y-luego-envoltura: encapsulación por destinatario, una clave de cifrado de clave derivada con HKDF y una clave de cifrado de contenido envuelta con AEAD, con el patrón de estrofas de age v1 transpuesto a CBOR canónico. No tiene suite_id ni la cascada LabeledExtract/LabeledExpand; evalúelo frente a la literatura sobre ECIES y la especificación de age v1, no frente al análisis de HPKE.

El modelo y sus propiedades de privacidad

Un remitente quiere publicar un compromiso permanente y con marca de tiempo que demuestre que un texto plano concreto se selló para una audiencia concreta en el instante T, garantizando a la vez que solo esa audiencia pueda leerlo. Una PoE de solo hash ofrece la afirmación temporal, pero ningún vínculo con la audiencia; una PoE sobre texto cifrado abierto no ofrece confidencialidad alguna. La PoE sellada tiende un puente entre ambas: el registro se compromete con el hash del texto plano (público, con marca de tiempo) y transporta el material de entrega de clave en enc, mientras que el texto cifrado en el URI ar:// o ipfs:// no se puede descifrar sin un secreto de desbloqueo que coincida.

La construcción está diseñada deliberadamente para que la cadena filtre lo menos posible sobre el mensaje y nada sobre su audiencia:

  • El texto plano nunca está en la cadena. Solo lo están su hash y las claves envueltas. Cualquiera que obtenga después el texto plano puede demostrar «este texto plano exacto quedó comprometido en el tiempo del bloque T»; nadie más se entera de qué se selló.
  • Las claves públicas de los destinatarios nunca están en la cadena. La clave pública de un destinatario no aparece en ninguna parte de enc. Un destinatario reconoce un mensaje como suyo únicamente al descifrar de prueba con éxito una ranura: no hay ningún campo de destinatario que leer. Un observador sin claves candidatas solo se entera del número de ranuras, de la familia de KEM (enc.kem) y de la distinción entre sellado y abierto. La propiedad más fuerte (que un adversario que posea claves de destinatario candidatas aun así no pueda comprobar a cuál apunta una ranura, si es que a alguna) es la privacidad de clave, que solo se reclama para la ruta clásica x25519; no se reclama para la ruta híbrida mlkem768x25519 (consulte Anonimato y la división por KEM).
  • Los destinatarios no aprenden nada unos de otros. Cada ranura por destinatario es una clave envuelta opaca. Un destinatario que abre su propia ranura no puede derivar la clave de ningún otro destinatario, ni puede saber a quién más se dirigió el mensaje.
  • El orden de las ranuras no filtra nada. El orden en que un remitente enumera a los destinatarios (por ejemplo, «el principal primero») es metadato privilegiado. El arreglo de ranuras se baraja con un CSPRNG antes de la publicación, de modo que ni siquiera el orden posicional transmite señal alguna.
  • Una PoE sellada sin firmar preserva el anonimato del remitente. Las firmas de autoría son opcionales (consulte Firmas). Un registro sellado sin sigs[] no vincula ninguna identidad de remitente en la cadena: exactamente lo que requieren las filtraciones de denunciantes, las subastas de oferta sellada y la custodia de pruebas.

Lo que la cadena revela es estrecho: que un registro es una PoE sellada (enc está presente), el hash del texto plano, la marca de tiempo del bloque y el número de ranuras (la longitud del arreglo). El número es el único hecho adyacente a los destinatarios que queda expuesto, y revela solo «cuántos», nunca «quiénes». La correlación temporal entre registros es una cuestión de metadatos que la criptografía a nivel del cable no puede resolver; los remitentes que necesiten derrotarla deben agrupar las publicaciones fuera de la línea temporal sensible.

Las claves públicas de los destinatarios se intercambian fuera de banda. Label 309 no prescribe ningún mecanismo de descubrimiento: un destinatario puede publicar su clave en su propio sitio web, en un registro DNS, en un perfil social, en un código QR o en una autoatestación en la cadena. Un verificador toma los bytes de la clave del destinatario como entrada y no formula ninguna afirmación sobre de quién es la clave: la procedencia es una decisión de confianza del remitente, exactamente igual que al enviar por correo una clave PGP.

El sobre y sus dos rutas

El mapa enc transporta campos comunes más exactamente una de dos rutas de entrega de clave mutuamente excluyentes. Un validador estructural impone la exclusividad; un registro que transporte ambas, o ninguna, se rechaza.

CampoEstadoSignificado
schemeOBLIGATORIOVersión de la familia de construcción. v1 define scheme = 1.
aeadOBLIGATORIOIdentificador del formato de contenido. v1 define "chacha20-poly1305-stream64k".
nonceOBLIGATORIO24 bytes aleatorios: el salt único del sobre, de la clave de contenido y de cada KEK de ranura.
kemsolo ruta de ranurasSelector de KEM por ranura ("x25519" o "mlkem768x25519").
slotsuna rutaArreglo de ranuras de clave por destinatario (multidestinatario).
slots_macsolo ruta de ranurasHMAC de 32 bytes que vincula el conjunto de ranuras y la afirmación de hash del ítem a la clave de contenido.
passphrasela otra rutaBloque de KDF por frase de contraseña (clave derivada de la frase de contraseña).
  • enc.slots — multidestinatario. El sobre transporta N ranuras de clave envueltas de forma independiente, una por destinatario. El texto cifrado no se puede descifrar sin una clave privada que coincida con una de las ranuras. Se especifica más abajo en Ranuras y el MAC del conjunto de ranuras.
  • enc.passphrase — derivada de la frase de contraseña. El sobre no transporta ranuras; la clave de contenido se deriva directamente de una frase de contraseña normalizada. Se especifica más abajo en Ruta de la frase de contraseña.

Ambas rutas comparten scheme, aead y nonce. Difieren en qué clave está presente y, en consecuencia, en dónde reside el compromiso con la clave. En la ruta de ranuras el compromiso está en la cadena: slots_mac es un HMAC con clave de la CEK sobre una transcripción que fija los campos de cabecera, el conjunto de ranuras y la afirmación de hash del ítem, de modo que un destinatario confirma la clave correcta antes de recuperar nada. En la ruta de la frase de contraseña no hay ranuras que vincular, así que el compromiso es una cabecera de 32 bytes que se transporta dentro del blob de texto cifrado: probar un intento de frase de contraseña exige el propio blob, nunca solo la cadena pública. Cada ruta serializa su transcripción con la misma función canonicalEncode, y un productor o verificador selecciona la ruta inspeccionando cuál de slots / passphrase está presente. Las dos rutas son exhaustivas y mutuamente excluyentes.

enc.scheme nombra la familia de construcción, con independencia del campo v del registro. Un verificador DEBE exigir enc.scheme === 1 y rechazar cualquier otro valor. El campo se reserva para un futuro cambio transversal (una planificación distinta del MAC del conjunto de ranuras o un formato de contenido distinto), no para añadir un KEM: el KEM por ranura lo selecciona enc.kem, y ambos KEM descritos más abajo viven bajo scheme = 1 desde la primera versión. De forma más amplia, enc.scheme: 1 identifica el conjunto criptográfico completo, no solo el MAC y el formato de contenido: las reglas de canonicalEncode, el esquema de ranuras, el hash HKDF, el hash HMAC, el AEAD de envoltura por ranura, el formato de contenido STREAM-segmentado, los esquemas de transcripción de ranuras y de frase de contraseña (incluida la vinculación de ítem hashes_hash), el compromiso de frase de contraseña dentro del texto cifrado, la revisión fijada de X-Wing, las etiquetas de separación de dominio, la versión y el perfil de Argon2id, y el perfil de normalización de frase de contraseña quedan todos fijados por él, así que cambiar cualquiera de ellos exige un nuevo valor de enc.scheme.

La capa de contenido

Ambas rutas convergen en una única pasada simétrica sobre el texto plano, con clave derivada de un valor que procede de una sola clave de cifrado del contenido (CEK) de 32 bytes. La CEK es lo que entregan las ranuras (cada ranura la envuelve) o lo que produce el KDF de la frase de contraseña; el contenido no se cifra directamente bajo la CEK. En su lugar, cada ruta deriva una clave de contenido independiente de 32 bytes como hoja HKDF de la CEK (con salt del enc.nonce único del sobre, bajo un info específico de la ruta), de modo que la capa de entrega de clave y la capa de contenido nunca aplican la clave a la misma primitiva sobre los mismos bytes:

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)

El contenido se sella entonces en un STREAM segmentado, nombrado por el identificador de formato de contenido chacha20-poly1305-stream64k. Es la disposición STREAM de la especificación de age v1: ChaCha20-Poly1305 (RFC 8439, la variante de nonce de 12 bytes) sobre el texto plano dividido en segmentos de tamaño fijo, cada uno sellado bajo la clave de contenido con un nonce de contador por segmento:

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

La marca final separa por dominio el último segmento del resto, que es lo que hace detectable el truncamiento: un flujo cuyo último segmento no lleve la marca 0x01, una marca 0x01 en un segmento que no sea el último, datos a continuación del segmento final, o un segmento no final más corto que CHUNK_SIZE DEBEN fallar todos el descifrado (TAMPERED_CIPHERTEXT). Como cada segmento sellado mide al menos sus 16 bytes de etiqueta, la disposición también implica un suelo estructural: un blob de texto cifrado bien formado de la ruta de ranuras nunca mide menos de 16 bytes, la etiqueta solitaria de un segmento final vacío.

El AAD por segmento es vacío por diseño: todo el contexto se vincula al contenido de forma transitiva. La clave de contenido se deriva de la CEK, y la CEK queda comprometida con la cabecera completa mediante slots_mac en la ruta de ranuras (cuya transcripción cubre scheme, path, aead, kem, nonce, el conjunto de ranuras y la afirmación de hash del ítem) o mediante el compromiso dentro del texto cifrado en la ruta de la frase de contraseña. Altere cualquier campo de cabecera y el destinatario deriva o acepta una clave distinta, así que el descifrado falla; un AAD por segmento volvería a vincular el mismo contexto en cada segmento sin añadir seguridad.

Los nonce de contador por segmento son seguros porque la clave de contenido es de un solo uso: se deriva de una CEK fresca con salt del enc.nonce único del sobre, así que dos flujos nunca comparten un par (key, nonce) y los productores sin estado (pestañas del navegador, ejecuciones de la CLI, trabajadores, reintentos) nunca coordinan nonces entre sobres. El contador de 88 bits admite 2^88 segmentos, muy por encima de cualquier carga útil realizable, así que el formato no impone ningún techo criptográfico a la carga útil; un máximo práctico es una política de denegación de servicio del despliegue, no una constante de transmisión.

La entrada de texto plano son los bytes originales exactos del contenido. La construcción no antepone, agrega ni cifra ningún nombre de archivo, tipo MIME, campo de tamaño ni manifiesto: el flujo se descifra de vuelta a esos bytes y solo a esos bytes.

Los segmentos liberados son tentativos hasta la recomprobación del hash

El formato segmentado existe para que un verificador pueda autenticar y liberar de forma incremental una carga útil de varios GiB con memoria acotada. La etiqueta de cada segmento se verifica antes de liberar el texto plano de ese segmento, y el truncamiento se detecta con la marca final; pero la recomprobación del hash del texto plano corre sobre el texto plano completo, tras el último segmento. Por tanto, un consumidor en flujo DEBE tratar los bytes liberados como tentativos (sin efectos secundarios, sin acuse de recibo, sin estado «recibido») hasta que esa comprobación final pase.

El texto cifrado publicado es un único objeto. En la ruta de ranuras son exactamente los segmentos del STREAM; en la ruta de la frase de contraseña se antepone una cabecera de compromiso de clave de 32 bytes dentro del mismo blob (mismo objeto, mismo URI, misma recuperación, nunca un segundo objeto almacenado):

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

El hash del texto plano en items[].hashes siempre se compromete con el texto plano, incluso cuando enc está presente. Esta es la propiedad que sostiene todo el peso: un verificador que no pueda descifrar aún puede confirmar que el registro existe, que su sobre está bien formado y que el URI se puede recuperar, pero solo quien posea una clave de destinatario que coincida puede descifrar el texto cifrado y confirmar con qué se compromete, recalculando el hash. Por tanto, el validador NO DEBE descifrar para «verificar» hashes; la verificación del hash del texto plano ocurre en el destinatario, después de recuperar los bytes. Consulte Contenido y hashing y Verificación.

Ranuras y el MAC del conjunto de ranuras

En la ruta multidestinatario, enc.slots es un arreglo no vacío de ranuras por destinatario. Cada ranura envuelve la misma CEK bajo una clave de cifrado de clave (KEK) por destinatario; un destinatario que abre cualquier ranura recupera la única CEK que descifra el contenido. El remitente:

  1. Selecciona un KEM para todo el registro y genera la CEK (32 bytes aleatorios) y el nonce (24 bytes aleatorios).
  2. Para cada destinatario, deriva una KEK por ranura y envuelve la CEK bajo ella (los detalles por KEM se dan más abajo).
  3. Baraja el arreglo de ranuras con un CSPRNG (Fisher-Yates insesgado).
  4. Construye la transcripción de ranuras sobre el arreglo barajado, los campos de cabecera comunes a los KEM y la afirmación de hash del ítem, la hashea en slots_hash y calcula slots_mac como un HMAC con clave de la CEK sobre ese hash.
  5. Deriva la clave de contenido de la CEK y enc.nonce, y sella el contenido en el STREAM segmentado de más arriba.

La envoltura por ranura

Cada ranura envuelve la CEK con ChaCha20-Poly1305 (RFC 8439, la variante de nonce de 12 bytes) bajo la KEK por ranura, produciendo un wrap de 48 bytes (32 bytes de texto cifrado de la CEK + 16 bytes de la etiqueta 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)

El nonce de 12 bytes todo a cero es seguro precisamente porque la KEK de cada ranura es única por registro: una KEK se usa, por tanto, para exactamente una envoltura, así que el nonce nunca puede colisionar bajo ninguna clave individual. Este es un invariante estricto: si alguna revisión llegara a permitir que se reutilizara una KEK (caché, valores efímeros deterministas, deduplicación de destinatarios que reutilice una ranura), el nonce cero tendría que reemplazarse por uno aleatorio en ese mismo cambio.

El MAC del conjunto de ranuras

slots_mac vincula a la CEK todo el conjunto de ranuras, junto con los campos de cabecera comunes a los KEM que fijan cómo se interpretan las ranuras, y la afirmación de hash del texto plano del ítem, derrotando las manipulaciones por sustitución, eliminación y reordenamiento de ranuras y por empalme del sobre. La vinculación es una construcción en dos pasos: una transcripción de ranuras se hashea una vez en un slots_hash de 32 bytes, y ese hash es el mensaje de un HMAC con clave de la 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 es un mapa cerrado que transporta exactamente ese conjunto de siete claves, serializado con canonicalEncode para que ambos lados produzcan bytes idénticos; su orden de claves es la ordenación bytewise de RFC 8949 §4.2.1, nunca dispuesto a mano. El valor slots es el arreglo barajado de mapas de ranura cerrados exactamente como aparecen en el cable ({epk, wrap} para x25519, {kem_ct, wrap} para mlkem768x25519), así que el contenido íntegro en el cable de cada ranura queda dentro de la transcripción. La transcripción fija además scheme, path, aead, kem y nonce: un reenvío que altere cualquiera de esos campos de cabecera dejando válidas las formas de las ranuras produce un slots_hash distinto, así que el MAC falla. Los prefijos SHA-256 de slots_hash y hashes_hash (cardano-poe-slots-transcript-v1, cardano-poe-item-hashes-v1) son ASCII exacto sin terminador ni prefijo de longitud.

hashes_hash es lo que vincula el sobre con la afirmación de hash de este ítem: es un SHA-256 etiquetado sobre el canonicalEncode del mapa hashes completo del ítem. Como el destinatario recalcula slots_mac solo a partir de los bytes en la cadena, una coincidencia del MAC confirma que el sobre se selló para esta afirmación exacta: un sobre empalmado en un ítem con un mapa hashes distinto falla el paso de coincidencia en la cadena, antes de recuperar ningún texto cifrado. Las uris[] del ítem deliberadamente no se vinculan, de modo que el texto cifrado puede realojarse en un nuevo URI direccionado por contenido sin invalidar el sobre; un remitente para quien la lista de URI forma parte de la afirmación la vincula en su lugar con una firma a nivel de registro.

En la derivación de HMAC_KEY, salt = "" es una cadena de octetos de longitud cero, la convención de salt ausente de RFC 5869 §2.2 (HKDF-Extract sustituye HashLen bytes a cero, 32 para SHA-256). Queda fijada por un vector de conformidad byte a byte en lugar de dejarse al valor por defecto de una biblioteca, de modo que una implementación que maneje mal el salt ausente falla el vector en lugar de derivar en silencio una clave distinta.

slots_hash se calcula una vez por registro y es constante a lo largo del bucle de descifrado de prueba del destinatario: la comprobación del MAC por ranura vuelve a aplicar la clave a HMAC con cada CEK candidata, pero siempre sobre el mismo slots_hash de 32 bytes. La propiedad de compromiso se preserva porque la clave del HMAC sigue siendo HKDF-SHA-256(CEK, …): precalcular el hash de la transcripción solo cambia el mensaje del HMAC, de la transcripción completa a su SHA-256, dejando intacta la vinculación con clave de la CEK.

El MAC del conjunto de ranuras queda fijado por enc.scheme: no hay identificador en el cable para él, existe exactamente una construcción por valor de scheme y es idéntico para ambos KEM. slots_mac DEBE medir exactamente 32 bytes (ENC_SLOTS_MAC_INVALID_LENGTH si la longitud es incorrecta) y DEBE verificarse en tiempo constante.

La transcripción depende directamente de los bytes en el cable de cada ranura. Ambos campos de ranura son cadenas de bytes CBOR únicas (epk mide 32 bytes, kem_ct mide 1120 bytes), así que no hay fragmentación por campo que normalizar ni ambigüedad en los límites de fragmento: el único fragmentado que realiza Label 309 es la división del cuerpo completo para el transporte en El registro, deshecha antes de que nada de esto se ejecute. Un byte alterado en cualquier lugar de una ranura cambia slots_hash y hace fallar el MAC.

La capa de contenido no necesita ninguna vinculación independiente con el conjunto de ranuras por pasada: la clave de contenido es una hoja HKDF de la CEK, y la CEK ya queda comprometida con la cabecera completa (incluido hashes_hash) mediante slots_mac. Editar cualquier ranura o campo de cabecera cambia lo que el destinatario deriva, así que el flujo de contenido simplemente no consigue abrir. Por eso el AAD por segmento es vacío (consulte La capa de contenido).

Los dos KEM

El KEM, seleccionado por registro mediante enc.kem, fija la forma de la ranura y la derivación de la KEK. Ambos están registrados bajo enc.scheme = 1 desde la primera versión.

enc.kemKEMClave pública del destinatarioForma de la ranuraCadena info de la KEK
"x25519"X25519 (clásico)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"

Los productores DEBERÍAN usar mlkem768x25519 por defecto. El KEM híbrido es seguro tanto frente a adversarios clásicos como frente a adversarios cuánticos del tipo «cosechar ahora, descifrar después», a la vez que mantiene la seguridad clásica de X25519 como suelo: el combinador de X-Wing vincula ambos secretos compartidos. Ese suelo de «nunca por debajo de la seguridad clásica de X25519» está acotado a claves de destinatario generadas de forma válida: presupone que la clave pública supera la comprobación de validez de clave de la revisión fijada de X-Wing (aplicada en el encapsulamiento, véase Híbrido: mlkem768x25519 más abajo). El KEM clásico x25519 sigue disponible para destinatarios cuya clave publicada es solo X25519. El identificador mlkem768x25519 se escribe deliberadamente sin guiones, coincidiendo con la grafía del ecosistema X-Wing/age.

Ambos KEM usan el mismo patrón de estrofas de age (material KEM por destinatario más una envoltura simétrica de la clave del archivo) y la misma vinculación de cabecera (el MAC del conjunto de ranuras), de modo que una única construcción uniforme cubre ambos sin ninguna dependencia de HPKE. La ruta clásica x25519 refleja de cerca el destinatario X25519 nativo de age. La ruta híbrida mlkem768x25519 diverge deliberadamente de la propia elección poscuántica de age: age v1.3.0 distribuye destinatarios poscuánticos nativos (prefijo visible age1pq…) que envuelven la clave del archivo mediante HPKE SealBase (RFC 9180) sobre un KEM ML-KEM-768 + X25519, y no el patrón de estrofas. Conservar la envoltura de estrofas para la ruta híbrida es lo que permite que una única envoltura uniforme y una única vinculación de cabecera uniforme cubran ambos KEM. La envoltura híbrida, por tanto, no hereda la construcción HPKE de age, y no se formula ninguna afirmación de herencia de age sobre ella; la codificación de destinatario age1pqc distinta (consulte Claves) refleja que las dos codificaciones híbridas son independientes.

Clásico: x25519

Para cada destinatario, el remitente genera un par de claves X25519 efímero nuevo, realiza un ECDH contra la clave pública del destinatario y deriva la KEK con HKDF (RFC 5869) bajo un salt de hash etiquetado:

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

La clave pública efímera epk de 32 bytes es el único material de clave en el cable; la clave pública del destinatario nunca se publica. El salt es un SHA-256 etiquetado que vincula tres valores: pub_epk hace única la KEK de cada ranura, pub_R la vincula al destinatario concreto (derrotando cualquier intento de reutilizar un epk contra un destinatario distinto), y el enc.nonce único del sobre ancla la KEK a un único sobre, de modo que un fallo del CSPRNG que repitiera la aleatoriedad del KEM entre dos sobres solo degrada a vinculabilidad entre sobres, nunca a un par de envoltura (KEK, nonce-cero) repetido. Las implementaciones de X25519 DEBEN rechazar el secreto compartido todo a cero, según RFC 7748 §6.1; las bibliotecas más extendidas lo hacen de forma transitiva.

Híbrido: mlkem768x25519 (X-Wing)

El KEM híbrido es la construcción X-Wing (draft-connolly-cfrg-xwing-kem-10), que combina ML-KEM-768 (FIPS 203) con X25519. Cada encapsulación extrae aleatoriedad fresca de ML-KEM y un efímero X25519 fresco, y produce un texto cifrado de 1120 bytes y un secreto compartido combinado de 32 bytes. La derivación de la KEK vincula al destinatario mediante un salt externo calculado sobre los propios bytes en el cable de la ranura:

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

Tamaños de clave y texto cifrado de X-Wing:

ComponenteTamañoComposición
Clave pública1216 bytesML-KEM-768 ek (1184) ‖ X25519 pk (32)
Texto cifrado1120 bytesML-KEM-768 ct (1088) ‖ X25519 efímero (32)
Secreto compartido32 bytessalida del combinador de X-Wing
Clave de desencapsulación32 bytesuna semilla; la clave pública se deriva de ella

Una ranura híbrida no transporta ningún campo epk: el efímero X25519 son los 32 bytes finales del kem_ct de 1120 bytes. XWing.Encapsulate DEBE aplicar la comprobación de validez de clave pública de la revisión fijada de X-Wing a pub_R y rechazar una clave inválida en lugar de encapsular hacia ella; esta es la precondición bajo la cual el suelo híbrido nunca baja de la seguridad clásica de X25519. La construcción consume X-Wing a través de un adaptador con campos nombrados exclusivamente: Encapsulate(pk) produce .ct (1120 B) y .ss (32 B); Decapsulate(sk, ct) produce el secreto compartido de 32 bytes. Las implementaciones DEBEN mapear a la API de la revisión fijada por nombre y NO DEBEN consumir valores de retorno posicionales: la revisión fijada devuelve (ss, ct) del encapsulamiento y escribe la desencapsulación como Decapsulate(ct, sk), lo inverso de una lectura ingenua de izquierda a derecha. La derivación de la KEK vincula al destinatario a través de un salt etiquetado de longitud fija, SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R), donde kem_ct es el texto cifrado de 1120 bytes exactamente como se transporta en la ranura y pub_R es la clave pública X-Wing del destinatario de 1216 bytes. Esta es la misma forma de tres valores que usa el salt clásico bajo su propia etiqueta (kem_ct ancla la KEK a un valor único por ranura, pub_R la vincula al destinatario concreto, y enc.nonce la ancla a un único sobre), expresada a través de un resumen SHA-256 porque las entradas híbridas son demasiado grandes para un salt en bruto. En ambos salts, el término pub_R es la codificación de transmisión canónica de la clave del destinatario: exactamente los 32 bytes de x25519_publicKey(priv_R) para x25519, exactamente la cadena de bytes de clave pública X-Wing fijada de 1216 bytes para mlkem768x25519. El productor y el verificador DEBEN usar esa codificación exacta y NO DEBEN sustituirla por ningún equivalente no canónico o recodificado, o ambos lados derivan KEK distintas y un registro honesto no consigue abrir. Lo crucial es que la vinculación se calcula fuera del KEM, sobre los propios bytes en el cable de la ranura, de modo que la construcción trata X-Wing como un KEM de caja negra: consume únicamente la interfaz pública del KEM (encapsular, desencapsular, el secreto compartido de 32 bytes) y no asume nada sobre el hashing interno del combinador. La etiqueta info distinta por KEM, cardano-poe-kek-mlkem768x25519-v1, garantiza además que una KEK derivada para un KEM nunca pueda ser igual a una KEK derivada para el otro, incluso sobre un secreto compartido idéntico de 32 bytes. El texto cifrado de 1120 bytes se transporta como una única cadena de bytes CBOR en slot.kem_ct: solo el cuerpo completo del registro se fragmenta para el transporte (consulte El registro), nunca un campo individual.

Un KEM por registro

Un único elemento de PoE sellada transporta exactamente un enc.kem; cada ranura usa la forma y la derivación de KEK de ese KEM. Un archivo es íntegramente clásico o íntegramente híbrido: ranuras de KEM distintos NO DEBEN aparecer en el mismo arreglo slots, y un verificador DEBE rechazar un registro cuyas formas de ranura sean incompatibles con el enc.kem declarado (ENC_SLOT_INVALID_SHAPE).

El material de encapsulamiento también DEBE ser distinto dentro de un mismo arreglo slots: para x25519, todos los valores epk DEBEN diferir; para mlkem768x25519, todos los valores kem_ct DEBEN diferir. Un duplicado se rechaza (antes de ejecutar ninguna primitiva KEM o AEAD) con ENC_SLOTS_DUPLICATE_KEM_MATERIAL. Esta es la porción verificable de la invariante de unicidad de la KEK por ranura de la que depende el envoltorio con nonce a cero: la reutilización de KEK entre registros o entre claves es una obligación del productor que un verificador no puede detectar, pero un duplicado dentro del registro es estructuralmente visible y DEBE fallar.

Descifrado de prueba del destinatario

Un destinatario posee una clave privada (un escalar X25519 de 32 bytes para x25519, o una semilla de desencapsulación X-Wing de 32 bytes para mlkem768x25519, ambas derivadas de la semilla; consulte Claves). No sabe de antemano cuál ranura, si la hay, es la suya, así que descifra de prueba el arreglo. Dos propiedades dan forma al bucle: la comprobación del MAC del conjunto de ranuras va plegada dentro (una ranura se acepta solo cuando su CEK candidata también reproduce el slots_mac que está en el cable), y el bucle recorre todas las ranuras sin salida anticipada, seleccionando la coincidencia en tiempo constante para que un observador de tiempos no pueda inferir qué índice de ranura coincidió.

Antes de invocar cualquier primitiva KEM o AEAD, el verificador DEBE ejecutar las comprobaciones de forma estructurales (la defensa contra oráculos de partición): scheme == 1, aead/kem registrados, nonce de 24 bytes, slots_mac de 32 bytes, slots no vacío, el secreto del destinatario de 32 bytes, cada slot.wrap de exactamente 48 bytes, cada epk de x25519 de exactamente 32 bytes sin kem_ct, cada kem_ct de mlkem768x25519 de exactamente 1120 bytes sin epk, y la distinción dentro de slots de todo el material de encapsulamiento (de lo contrario, ENC_SLOTS_DUPLICATE_KEM_MATERIAL).

En esa misma pasada previa a las primitivas, el verificador DEBE acotar también el uso de recursos del analizador: los límites de referencia son MAX_SLOTS = 1024 ranuras y 65536 bytes para el sobre enc decodificado. Ambos quedan muy por encima del techo de ≈ 16 KiB de los metadatos de transacción de Cardano que acota un registro honesto, así que un registro que supere cualquiera de ellos está mal formado y se rechaza aquí (ENC_SLOTS_TOO_MANY por demasiadas ranuras, ENC_ENVELOPE_TOO_LARGE por un sobre sobredimensionado) antes de ejecutar ninguna primitiva KEM o AEAD. Estas cotas son constantes impuestas por el verificador y fijadas por despliegue, no campos de transmisión; un despliegue PUEDE endurecerlas.

; 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

La derivación de la KEK se bifurca según enc.kem: para x25519, el destinatario realiza un ECDH contra slot.epk y vuelve a derivar el mismo salt etiquetado sobre enc.nonce || slot.epk || pub_R; para mlkem768x25519, desencapsula con X-Wing slot.kem_ct directamente (una única cadena de bytes de 1120 bytes) y recalcula el mismo salt etiquetado sobre enc.nonce || slot.kem_ct || pub_R, donde pub_R es su propia clave pública X-Wing de 1216 bytes derivada de la semilla que posee. El rechazo del secreto compartido X25519 todo a cero es explícito aquí en lugar de confiarse de forma transitiva: una ranura fabricada para llevar el secreto compartido a zeros(32) (RFC 7748 §6.1) pone el bit de validez independiente del secreto kem_ok en falso, la KEK se selecciona en tiempo constante hacia una dummy_KEK derivada de zeros(32) bajo el mismo salt e info para que el bucle realice un trabajo idéntico, y kem_ok se pliega en ok, de modo que una ranura con ECDH inválido nunca puede aceptarse, sea cual sea el resultado de la envoltura o del MAC, y el registro aflora el único fallo genérico si no coincide nada más. Todo lo que viene después de abrir la envoltura (la comprobación del MAC del conjunto de ranuras, la derivación de la clave de contenido y el descifrado del contenido) es independiente del KEM.

Ambas primitivas AEAD *_open_or_dummy son atómicas: ante un fallo de verificación del tag no devuelven texto plano, y el candidato devuelto (candidate_CEK para la apertura de la envoltura, el plaintext para la apertura del contenido) es un valor de relleno fijo o pseudoaleatorio que es independiente del texto cifrado que falló. Nunca se libera texto plano sin verificar al llamante, así que una apertura fallida no puede convertirse en un oráculo de descifrado.

Por qué la comprobación del MAC vive dentro del bucle

Un remitente malicioso puede fabricar una ranura que se abra con la clave de un destinatario pero produzca una CEK elegida por el atacante (encapsular hacia la clave pública del destinatario no necesita ninguna clave privada). Si un destinatario aceptara el primer éxito de AEAD como «suyo», esa ranura falsificada eclipsaría a una honesta situada más adelante en el arreglo. Plegar la comprobación de slots_mac dentro del bucle significa que una ranura se acepta solo cuando su CEK candidata reproduce el MAC sobre slots_hash: así, una ranura falsificada se omite y el escaneo continúa. La longitud de slot.wrap DEBE comprobarse que es de 48 bytes antes de cualquier llamada al AEAD, una defensa contra oráculos de partición que age v1 también aplica.

Múltiples ranuras coincidentes: la duplicación está permitida, un conflicto de CEK no. La clave privada de un destinatario PUEDE coincidir legítimamente con más de una ranura. Un productor puede sellar la misma CEK para el mismo destinatario en varias ranuras (cada una con su propio efímero fresco por ranura) para acolchar el número aparente de destinatarios, una técnica de privacidad válida. El verificador selecciona la CEK de la primera coincidencia y NO DEBE rechazar solo porque coincidiera más de una ranura. Esto es distinto del rechazo de material-de-encapsulamiento-duplicado dentro del registro (ENC_SLOTS_DUPLICATE_KEM_MATERIAL), que se dispara ante un epk o kem_ct repetido: la duplicación honesta extrae aleatoriedad KEM fresca por ranura en cada aparición, así que sus epk / kem_ct difieren y nunca colisiona con esa comprobación. La única anomalía que el verificador DEBE rechazar son dos ranuras coincidentes que recuperan CEK distintas (comparadas en tiempo constante): el bucle arrastra un bit cek_conflict a lo largo de todas las ranuras y, si cualquier coincidencia posterior recupera una CEK que difiera de la seleccionada, aflora el único fallo genérico. Esto es defensa en profundidad: bajo la propiedad de compromiso que aporta la CEK recuperada (el MAC del conjunto de ranuras vincula la CEK a una única transcripción de ranuras; véase Anonimato y la división por KEM), una coincidencia con CEK distinta ya es inviable, siendo exactamente la colisión multiclave que el compromiso descarta, así que la comprobación falla de forma segura frente a una implementación defectuosa o un debilitamiento futuro de esa suposición.

Una forma de fallo genérico, tiempo constante entre ranuras

Un llamante no confiable DEBE recibir exactamente una forma de fallo genérico con independencia de por qué falló el descifrado (ninguna ranura abrió, el conjunto de ranuras fue manipulado o el AEAD de contenido falló), y la respuesta NO DEBE distinguirlos, ni revelar qué ranura coincidió. Una implementación PUEDE exponer códigos tipados internos (WRONG_RECIPIENT_KEY (ninguna ranura abre), TAMPERED_HEADER (una ranura abre pero ninguna CEK candidata reproduce el slots_mac sobre slots_hash), TAMPERED_CIPHERTEXT (el AEAD de contenido falla tras recuperar una CEK)) a un llamante local de confianza para diagnóstico, pero esos códigos NO DEBEN filtrarse a un observador externo a través de una respuesta distinguible.

En cuanto al tiempo, el verificador PUEDE retornar en la comprobación de no-coincidencia (if NOT found) antes del descifrado del contenido. Ese retorno anticipado revela solo destinatario frente a no destinatario (nunca qué ranura coincidió ni material de clave alguno) porque el bucle entre ranuras de más arriba ya ha corrido hasta el final cuando se alcanza la comprobación. No se exige un tiempo uniforme entre el caso de no destinatario y el de un destinatario cuyo texto cifrado no consigue abrir, y NO DEBE imponerse una apertura de contenido ficticia: forzar a todo no destinatario a pagar el coste completo del descifrado del contenido no compra ninguna privacidad que el bucle no proporcione ya. La garantía de tiempo constante que se mantiene es la invariante entre ranuras: el bucle procesa un número constante de operaciones de ranura por clave privada sin salida anticipada, de modo que un observador a nivel de red solo se entera del número de ranuras, nunca de cuál ranura (si alguna) desenvuelve la clave. Un destinatario que posee varias claves (por ejemplo, claves archivadas tras una rotación de identidad) itera clave privada × ranura, volviendo a derivar la mitad pub_R del salt a partir de la clave actual; PUEDE cortocircuitar entre claves (filtrando solo la señal débil de «qué clave coincidió»), pero DEBE mantenerse en tiempo constante a lo largo de las ranuras de cualquier clave.

Tras recuperar el texto plano, el destinatario (en la capa de aplicación, no en la función de descifrado) recalcula el hash del texto plano y lo compara con items[].hashes. Una discordancia significa que el compromiso del registro en la cadena no coincide con los bytes descifrados, y el destinatario DEBE negarse a actuar sobre el texto plano. Este es el paso que cierra el círculo: la cadena fue testigo de un compromiso en el tiempo T, y el destinatario confirma que es un compromiso con exactamente estos bytes.

Ruta de la frase de contraseña

La ruta alternativa de entrega de clave reemplaza las ranuras de destinatario por una frase de contraseña. No hay arreglo slots, ni slots_mac, ni efímero por ranura, ni bucle de descifrado de prueba: la CEK se deriva directamente de una frase de contraseña normalizada mediante Argon2id (RFC 9106) sobre un salt y unos parámetros que están en la cadena. El compromiso con la clave que slots_mac aporta en la ruta de ranuras reside en su lugar en una cabecera de 32 bytes dentro del blob de texto cifrado, antepuesta antes de los segmentos del STREAM: mismo objeto, mismo URI, misma recuperación.

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

El bloque enc.passphrase en el cable es { alg, salt, params }: nombra el KDF ("argon2id"), el salt y los parámetros. Label 309 fija un suelo de parámetros de m ≥ 65536 KiB (64 MiB), t ≥ 3, p ≥ 1; el productor elige valores iguales o superiores al suelo, y el salt es de 16 a 64 bytes inclusive (el techo de 64 bytes es el límite de cadena de bytes del metadato). Cuando la plataforma lo permita, los productores DEBERÍAN usar p = 4 (el segundo perfil recomendado de RFC 9106 §4); los verificadores PUEDEN aceptar cualquier p ≥ 1, sujeto a los topes del despliegue de más abajo.

La PASSPHRASE_TRANSCRIPT vincula al compromiso los parámetros del KDF, los campos de cabecera y la afirmación de hash del ítem: el verificador recalcula la transcripción a partir del mapa enc recibido y las hashes del ítem, de modo que manipular salt, cualquier valor de params, nonce, aead, o empalmar el sobre en una afirmación de hash distinta produce un pw_hash distinto y la comprobación del compromiso falla. El contenido se sella entonces en el mismo STREAM segmentado que la ruta de ranuras, bajo la clave de contenido de la ruta de la frase de contraseña. El valor de "normalization" es una constante fijada por el esquema que se alimenta a la transcripción para fijar el perfil exacto bajo el que se derivó la CEK; nunca se serializa en el cable.

Orden de verificación. El verificador deriva la CEK candidata de la frase de contraseña introducida, lee los 32 bytes iniciales del blob de texto cifrado, recalcula el compromiso y lo compara en tiempo constante, antes de abrir ningún segmento del STREAM. Un blob de la ruta de la frase de contraseña más corto que 48 bytes (la cabecera de compromiso de 32 bytes más el STREAM mínimo de 16 bytes) no puede estar bien formado y es texto cifrado mal formado (TAMPERED_CIPHERTEXT). Ante una discordancia (frase de contraseña equivocada, salt / params manipulados, cabecera manipulada o un sobre empalmado), el verificador aflora el mismo único fallo genérico que cualquier otro fallo de descifrado y NO DEBE empezar a transmitir en flujo. Una frase de contraseña equivocada es, por tanto, indistinguible de un registro manipulado.

Antes de la normalización y de Argon2id, una implementación DEBE acotar la longitud de la entrada de frase de contraseña en bruto para que una frase de contraseña sobredimensionada no pueda provocar una denegación de servicio previa al KDF: la cota de referencia es de 4096 bytes UTF-8 de entrada en bruto, rechazados antes de cualquier trabajo de normalización o hashing. Como las cotas de MAX_SLOTS y del sobre enc decodificado que impone la ruta de ranuras, esta es una constante impuesta por el verificador y fijada por despliegue, no un campo de transmisión, y un despliegue PUEDE endurecerla. Más allá del suelo de parámetros, las implementaciones DEBERÍAN imponer también cotas superiores sobre m, t y p frente a una denegación de servicio del lado del verificador; esos topes no son normativos (dependen del hardware) y NO DEBEN confundirse con el suelo.

Por qué el compromiso está fuera de la cadena

Un compromiso de frase de contraseña en la cadena le entregaría a todo observador un oráculo de prueba sin coste y fuera de línea (derivar una CEK candidata de una frase de contraseña adivinada y cotejarla contra la cadena) para cada registro por frase de contraseña, para siempre, incluidos los registros cuyo texto cifrado se retiene. Transportar el compromiso dentro del blob de texto cifrado significa que probar un intento exige el propio blob: un registro con el texto cifrado retenido no expone ningún material adivinable por frase de contraseña en el ledger permanente, y un destinatario legítimo que ya posee el blob no paga nada por leer primero una cabecera de 32 bytes.

El perfil de normalización

La normalización que se aplica a la frase de contraseña antes de Argon2id es el perfil fijo cardano-poe-pw-norm-v1. Es normativo: dos implementaciones DEBEN derivar una CEK idéntica al byte de la misma frase de contraseña, y la única forma de garantizarlo es una normalización anclada. El perfil, aplicado en orden, es:

  1. Rechazar puntos de código no asignados. Una frase de contraseña que contenga cualquier punto de código no asignado en Unicode 16.0 se rechaza con ENC_PASSPHRASE_UNNORMALIZABLE antes de ejecutar cualquier paso de normalización.
  2. NFKC. Aplica la forma de normalización KC según UAX #15 bajo Unicode 16.0.
  3. Espacios en blanco. Define «espacio en blanco» como todo carácter que lleve la propiedad Unicode White_Space bajo Unicode 16.0, y colapsa cada secuencia máxima de tales caracteres a un único U+0020 SPACE.
  4. Recorte. Elimina los espacios en blanco iniciales y finales.
  5. Rechazar vacío. Si el resultado es la cadena vacía, rechaza con ENC_PASSPHRASE_EMPTY: una frase de contraseña compuesta solo de espacios en blanco o vacía de cualquier otro modo se normaliza a cero bytes, lo que Argon2id aceptaría en silencio, dejando el registro con clave de una CEK que cualquier parte puede derivar.
  6. Codificación. Codifica el resultado como UTF-8; esos bytes son la entrada de contraseña de Argon2id.

El paso 1 es lo que hace el perfil determinista entre implementaciones y a lo largo del tiempo. La política de estabilidad de normalización de Unicode garantiza que la normalización de una cadena es estable entre versiones futuras de Unicode únicamente cuando todo punto de código de ella está asignado en la versión en la que se normalizó; un punto de código no asignado podría adquirir más tarde una descomposición y cambiar en silencio la CEK derivada. Rechazar los puntos de código no asignados cierra ese hueco por completo, y es invisible para los usuarios honestos: todo carácter de uso escrito real está asignado.

La versión de Unicode se ancla en Unicode 16.0 de forma literal y NO DEBE flotar: el conjunto de la propiedad White_Space, el conjunto de puntos de código asignados y las tablas de mapeo de NFKC dependen todos de la versión, y un verificador que resuelva el perfil contra una versión distinta de Unicode podría derivar una CEK diferente de la misma frase de contraseña y no descifrar un registro honesto. Una revisión futura que adopte una versión más reciente de Unicode lo hace bajo un nuevo identificador de perfil, no reinterpretando cardano-poe-pw-norm-v1.

La entropía de la frase de contraseña es la única barrera

El salt y los parámetros de Argon2id son públicos en la cadena para siempre, así que un atacante dispone de tiempo ilimitado fuera de línea para forzar la frase de contraseña por fuerza bruta frente a ellos. La entropía de la frase de contraseña es el único margen de seguridad en esta ruta. Los productores DEBERÍAN usar una frase de contraseña de tipo diceware generada con un CSPRNG en lugar de una elegida por una persona, y DEBERÍAN mostrar una advertencia visible al aceptar frases de contraseña tecleadas, ya que el texto cifrado en la cadena quedará permanentemente sujeto a ataques fuera de línea.

Secreto hacia adelante e independencia por ranura

La construcción de ranuras usa ECDH efímero-estático (o encapsulación X-Wing fresca) con un efímero nuevo por ranura, lo que aporta dos propiedades que un diseño estático-estático o de efímero compartido perdería:

  • Secreto hacia adelante frente al compromiso del remitente. El remitente no conserva ninguna clave de largo plazo en la construcción; el efímero se pone a cero tras el sellado. Comprometer después el estado del remitente no puede descifrar registros publicados antes del compromiso.
  • Independencia por ranura. Distintos destinatarios obtienen distintos efímeros y, por tanto, distintos secretos compartidos y KEK. Que un destinatario filtre su CEK envuelta revela la CEK (inevitable: es la clave del archivo) pero nunca la KEK de otro destinatario.

La PoE sellada no tiene secreto hacia adelante respecto al destinatario, por diseño: una vez que un registro se sella hacia una clave de destinatario de largo plazo, quien posea la clave privada que coincide puede descifrarlo para siempre. Esa es una propiedad del cifrado de clave pública hacia una clave de largo plazo, no un defecto.

Anonimato y la división por KEM

Cuando un registro de PoE sellada no transporta ningún sigs, sus bytes en el cable son independientes de la identidad del remitente: cada ranura transporta solo material KEM efímero por registro y por ranura (el efímero X25519 en slot.epk, o el texto cifrado X-Wing en slot.kem_ct), las claves de largo plazo del remitente nunca aparecen, las ranuras se barajan con un CSPRNG, ninguna clave pública de destinatario está en el cable, y no hay ningún campo descriptivo (nombre de archivo, tipo MIME, tamaño) presente. Por tanto, un registro sellado sin firmar no vincula ninguna identidad de remitente en la cadena: exactamente lo que requieren las filtraciones de denunciantes, las subastas de oferta sellada y la custodia de pruebas.

Para ambos KEM, las filtraciones honestas son idénticas e inevitables: el número de ranuras, la distinción entre sellado y abierto y la familia de KEM clásica frente a híbrida (enc.kem) son visibles para cualquier observador; nada más sobre los destinatarios lo es.

La afirmación más fuerte (que un adversario que posea un conjunto de claves públicas de destinatario candidatas no pueda comprobar si una ranura dada está dirigida a una de ellas, la privacidad de clave / anonimato del destinatario) es una propiedad por KEM:

  • x25519: con privacidad de clave. El encapsulamiento por ranura es una clave pública efímera fresca, estadísticamente independiente de la clave del destinatario. Un adversario que posea claves públicas de destinatario candidatas no puede, solo a partir de slot.epk y slot.wrap, decidir a qué candidata (si a alguna) apunta la ranura sin la clave privada que coincide. La ruta clásica es, por tanto, con privacidad de clave, lo que también da imposibilidad de vincular registros entre sí: dos PoE selladas para el mismo destinatario parecen fragmentos epk/wrap sin relación.
  • mlkem768x25519: no se reclama. El anonimato del destinatario frente a un adversario que posea claves de destinatario candidatas es una propiedad independiente que no implica la seguridad IND-CCA del KEM híbrido. Label 309 no la reclama para la ruta X-Wing mientras no se justifique de forma independiente para X-Wing. Un despliegue cuyo modelo de amenaza exija anonimato del destinatario frente a un adversario que posea las claves NO DEBE depender de la ruta híbrida para esa propiedad.

Los remitentes que se preocupen por la correlación temporal entre registros DEBEN agrupar las publicaciones fuera de la línea temporal crítica; la criptografía a nivel del cable no puede resolver los ataques de tiempos sobre metadatos.

El MAC del conjunto de ranuras es el compromiso; la envoltura no tiene por qué serlo

La CEK recuperada es un compromiso con el conjunto de ranuras que coincidió el destinatario: un remitente malicioso no puede construir dos conjuntos de ranuras distintos que un único destinatario acepte como suyos. La propiedad requerida aquí es un compromiso de clave restringido para la CEK del sobre en el sentido de RFC 9771 (la CEK recuperada se vincula a una única transcripción de ranuras), no un AEAD plenamente comprometedor sobre entradas arbitrarias. Se apoya en la resistencia a colisiones multiclave de CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) para CEK y transcripciones elegidas de forma adversaria, un margen de colisión genérica de ~128 bits (la cota del cumpleaños sobre una salida de 256 bits), adecuado para el modelo de amenaza. La evidencia de manipulación de la propia transcripción hereda la cota de colisión de ~2^128 de SHA-256: cualquier cambio en los campos de cabecera comprometidos o en los bytes de las ranuras altera slots_hash, y forjar un slots_hash inalterado sobre una transcripción distinta es exactamente esa búsqueda de colisión de ~2^128. Como el compromiso lo aporta slots_mac, el AEAD de envoltura wrap por ranura no tiene por qué ser un AEAD que comprometa; el ChaCha20-Poly1305 no comprometedor por defecto es sólido aquí.

Patrones prohibidos

Una implementación conforme NO DEBE:

  • Reutilizar un efímero por ranura entre ranuras o registros, ni permitir de otro modo que una KEK se repita: la envoltura de nonce cero depende de la unicidad de la KEK por ranura.
  • Reutilizar una CEK entre sobres: una CEK fresca de CSPRNG por cada ítem que lleve enc, tanto dentro de un registro como entre registros.
  • Reutilizar un salt de frase de contraseña: genere un enc.passphrase.salt fresco de CSPRNG para cada sobre de frase de contraseña; el salt es el único separador entre registros para una frase de contraseña reutilizada.
  • Mezclar KEM dentro de un mismo arreglo slots (un enc.kem por registro).
  • Publicar las ranuras en el orden de entrada: el barajado con CSPRNG es obligatorio.
  • Envolver la CEK con cualquier nonce que no sea el nonce de 12 bytes todo a cero, o con un AAD vacío en el AEAD de envoltura: el AAD de la envoltura es el literal de la etiqueta info del KEM.
  • Poner una clave pública de destinatario en el cable: el diseño de descifrado de prueba es la característica de privacidad; publicar claves públicas la derrota.
  • Omitir la verificación de slots_mac: sin ella, la sustitución de ranuras tiene éxito.
  • Almacenar el texto plano en el URI ar:///ipfs://: solo se publica el texto cifrado; el texto plano se entrega fuera de banda o lo conserva el remitente.
  • Referenciar el texto cifrado mediante cualquier esquema que no sea ar:// o ipfs://: los esquemas direccionados por contenido vinculan el URI a los bytes; una URL servida por un host requeriría un compromiso en la cadena con el texto cifrado por separado, que la PoE sellada no transporta.
  • Registrar en logs ni persistir la CEK, ninguna KEK, la clave HMAC del conjunto de ranuras, la clave MAC de la frase de contraseña, la clave de contenido, un secreto compartido de ECDH, una clave privada efímera ni una clave privada de destinatario.

Páginas relacionadas

  • Claves: los pares de claves X25519 y X-Wing derivados de la semilla que aportan el material de clave del destinatario y del remitente.
  • El registro: dónde se ubica enc en el mapa del registro y el transporte del cuerpo completo que lleva el registro en la cadena.
  • Registros de algoritmos: los identificadores enc.aead, enc.kem y de KDF por frase de contraseña, y sus primitivas subyacentes.
  • Contenido y hashing: el compromiso con el hash del texto plano que transporta todo registro sellado.
  • Verificación: la canalización de validación, por qué el validador nunca descifra y el catálogo de errores.