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

Guía para implementadores

Cómo construir una implementación conforme de Label 309: la arquitectura por capas recomendada, el contrato de bytes idénticos entre lenguajes y los vectores de prueba de conformidad que definen la interoperabilidad.

Label 309 es un formato de transmisión y un conjunto de construcciones criptográficas, no un producto. Pueden coexistir tantas implementaciones independientes como se quiera (en TypeScript, Python, Rust, Go o un entorno de ejecución móvil nativo), y un registro producido por una DEBE verificarse correctamente bajo otra. Esta página está dirigida al equipo que construye una implementación de ese tipo. Describe la arquitectura que mantiene auditable la superficie criptográfica, el contrato exacto que hace interoperables a dos implementaciones y el conjunto de pruebas de conformidad que decide, de forma mecánica, si lo ha cumplido.

Dos cosas hacen que Label 309 sea interoperable entre lenguajes. La primera es el determinismo: las construcciones están ancladas a estándares públicos (RFC 8949 CBOR canónico, RFC 8032 Ed25519, RFC 7748 X25519, RFC 5869 HKDF, RFC 9106 Argon2id, RFC 9052 COSE), de modo que las mismas entradas producen los mismos bytes en cualquier entorno. La segunda es el conjunto de pruebas de conformidad: una colección de vectores de prueba exactos al byte que una implementación reproduce o no reproduce. La conformidad es una propiedad que se puede comprobar, no una afirmación que se hace.

La arquitectura por capas

Una implementación conforme DEBERÍA separar las primitivas criptográficas de la lógica de la aplicación en capas distintas, cada una dependiente únicamente de la inmediatamente inferior. Los nombres que figuran a continuación son funciones, no nombres de paquete; elija los suyos.

┌─────────────────────────────────────────────────────────┐
│  application                                            │
│  UI, routing, persistence, payments, background jobs    │
├─────────────────────────────────────────────────────────┤
│  SDK                                                    │
│  service client + standalone verifier + helpers         │
├─────────────────────────────────────────────────────────┤
│  wire-format library                                    │
│  schema · structural validator · canonical-CBOR codec   │
├─────────────────────────────────────────────────────────┤
│  cryptographic core                                     │
│  hashes · KDFs · signatures · KEM · AEAD · CBOR · COSE  │
│  no application or framework dependencies               │
└─────────────────────────────────────────────────────────┘

Las fronteras entre capas son estructurales, no estéticas. Cada capa tiene un único cometido y una lista corta de cosas que tiene prohibido conocer.

El núcleo criptográfico

La capa inferior contiene únicamente primitivas: funciones hash, KDF, operaciones de firma y de KEM, la capa de contenido AEAD, CBOR canónico, COSE_Sign1, la construcción de envoltura y desenvoltura de la PoE sellada, raíces y pruebas de Merkle, y las clases de error tipadas que estas levantan. No contiene lógica de dominio, ni HTTP, ni acceso a base de datos, ni importaciones de bibliotecas de UI o de frameworks de servidor.

Esta capa DEBE mantenerse libre de cualquier dependencia ligada a la aplicación o al servidor, y DEBE ser segura para el navegador, por tres razones concretas:

  • Se ejecuta en todas partes. Calcular el hash de un archivo, construir un sobre y, lo más importante, el verificador autónomo se ejecutan tanto en navegadores como en workers sin servidor y en la línea de comandos, con la misma facilidad que en un servidor. Una dependencia exclusiva de servidor (un controlador de base de datos, un framework de registro atado a un entorno de ejecución, una biblioteca de UI) rompería esos destinos y abultaría a todo consumidor que empaquete el núcleo.
  • Es la superficie de auditoría. Un revisor puede leer de principio a fin un paquete compuesto solo de primitivas y contrastarlo con los RFC. En cuanto se cuela código de aplicación, la superficie que un revisor de seguridad debe abarcar mentalmente crece sin límite.
  • Es lo que integran terceros. Un verificador independiente, alguien que no confía en ningún servicio, solo en la cadena, importa esta capa y nada de lo que hay por encima. Mantenerla pequeña y portable es lo que vuelve práctico el «verifíquelo usted mismo».

En concreto, el núcleo NO DEBE importar ORM ni controladores de base de datos, frameworks de UI, frameworks de registro atados al servidor, ni ningún módulo de aplicación. La aleatoriedad DEBE provenir del CSPRNG de la plataforma (Web Crypto getRandomValues, o una reexportación equivalente), nunca de una fuente exclusiva de Node, de modo que la misma fuente se ejecute sin cambios en un navegador.

Imponga la frontera en CI, no en la revisión de código

La regla de cero dependencias se degrada en cuanto se cuela una importación que viene a mano. Una implementación DEBERÍA ejecutar un análisis del grafo de dependencias que recorra cada importación del núcleo y de la biblioteca del formato de transmisión y que rompa la compilación ante cualquier especificador que quede fuera de una lista de permitidos por capa. Los revisores olvidan; el analizador no.

La biblioteca del formato de transmisión

La siguiente capa es la dueña de Label 309 en sí: el esquema del registro, el validador estructural y el codificador y decodificador de CBOR canónico. Depende del núcleo criptográfico (para el hashing, COSE y el códec CBOR) y de nada más ligado a la aplicación. Su superficie es pequeña y pura:

  • encode: produce los bytes de CBOR canónico de un registro validado.
  • decode: la inversa.
  • validate: ejecuta las comprobaciones estructurales y semánticas del estándar sobre un registro decodificado y devuelve un resultado tipado (véase Verificación).

En esta capa viven, hechas código, las reglas de El registro: el conjunto cerrado de claves, la disciplina de reensamblaje de fragmentos, la invariante items o merkle y los requisitos de CBOR canónico. Al igual que el núcleo, se mantiene libre de clientes HTTP, controladores de base de datos e importaciones de frameworks.

El SDK y la aplicación

El SDK envuelve las capas inferiores en utilidades ergonómicas: un cliente de servicio, utilidades para construir y abrir sobres, y el verificador autónomo, la función que decodifica un registro, comprueba su estructura, verifica cualquier firma de registro contra la clave en cadena y emite un veredicto usando únicamente datos públicos. El verificador autónomo DEBE funcionar sin acceso de red a ningún servicio operado por el implementador; su única entrada externa es un explorador de la cadena de bloques público que el verificador elija. El SDK DEBERÍA ser también seguro para el navegador.

La capa de aplicación (UI, enrutamiento, persistencia, facturación, trabajos en segundo plano) es territorio libre y no acarrea obligaciones de interoperabilidad. Nada en el estándar restringe cómo la construye, solo que se sitúe por encima de la superficie criptográfica verificada en lugar de meter la mano dentro de ella.

El contrato de bytes idénticos

La interoperabilidad es una propiedad de los bytes, no de las intenciones. Dos implementaciones interoperan si y solo si las primitivas que no tienen ninguna libertad en su salida producen los mismos bytes a partir de las mismas entradas. Este es el contrato de paridad, y es el corazón de la conformidad.

El contrato se divide limpiamente en dos. Las operaciones cuya salida queda determinada por completo por sus entradas DEBEN ser idénticas al byte entre implementaciones. Las operaciones que consumen aleatoriedad no pueden ser iguales al byte de una llamada a otra; para ellas el contrato es la consumibilidad cruzada: un valor producido por una implementación DEBE ser consumible por cualquier otra (un texto cifrado sellado en un lenguaje se descifra en otro).

Primitivas idénticas al byte

Cada una de las operaciones siguientes es una función pura de sus entradas y DEBE emitir una salida idéntica al byte en toda implementación conforme:

PrimitivaAnclada aSalida que debe coincidir
Semilla → par de claves Ed25519 / X25519HKDF-SHA-256 con las constantes info registradasclaves pública y privada derivadas
HKDF-SHA-256RFC 5869material de clave de salida para una entrada fija
MAC de conjunto de ranuras HMAC-SHA-256RFC 2104bytes de las etiquetas slots_hash y slots_mac para una CEK y un conjunto de ranuras fijos
Argon2id (KDF de frase de contraseña)RFC 9106clave derivada para (m, t, p, salt, len, password) fijos
SHA-256FIPS 180-4digest
BLAKE2b-256RFC 7693digest
Codificación CBOR canónicaRFC 8949 §4.2.1bytes codificados para una entrada fija
Codificación COSE_Sign1RFC 9052bytes de la estructura para una cabecera, carga útil y firma fijas
Firma / verificación Ed25519RFC 8032 (estricta)firma; veredicto
ECDH X25519RFC 7748secreto compartido para unos escalares fijos
Envoltura / desenvoltura de PoE selladaPoE selladabytes por ranura y MAC cuando se inyectan los efímeros y la CEK
Raíz de Merkle + pruebas de inclusiónRFC 9162 §2.1.1raíz y pruebas por hoja sobre una lista de hojas ordenada

Hay dos puntos que merecen destacarse. Ed25519 es estricto: un verificador conforme DEBE aplicar las reglas de S canónica y de rechazo de puntos de orden bajo de RFC 8032 §5.1.7, de modo que dos implementaciones coincidan no solo en las firmas que aceptan, sino también en las firmas que rechazan. Argon2id cruza fronteras entre ecosistemas: cada lenguaje recurre a una biblioteca de Argon2 distinta, pero toda biblioteca conforme implementa el RFC 9106 y DEBE producir una salida idéntica para parámetros idénticos: el conjunto de parámetros, no la biblioteca, es lo que constituye el contrato.

Operaciones que consumen aleatoriedad

La generación de claves, la envoltura de PoE sellada bajo efímeros frescos por ranura y el cifrado del sobre extraen todas aleatoriedad fresca, así que su salida difiere en cada llamada y no se puede anclar al byte. El contrato para estas es la consumibilidad cruzada: la salida producida por una implementación DEBE ser consumible por cualquier otra. Un registro sellado en un lenguaje DEBE descifrarse en otro; un par de claves acuñado en uno DEBE verificarse y permitir cifrar hacia él en otro. Los conjuntos de pruebas de conformidad anclan estas operaciones con ganchos de prueba deterministas que inyectan los efímeros (lo que vuelve reproducible la envoltura) y con fixtures de ida y vuelta que cifran en un lenguaje y descifran en el otro.

Construir la construcción de PoE sellada

La PoE sellada es la parte más densa del formato de transmisión, y aquella en la que un solo byte equivocado (una clave de mapa mal ordenada, una etiqueta con un carácter de más, un fragmentado no canónico) produce un sobre que abre en tu propia implementación pero en ninguna otra. Esta sección es la lista de comprobación para construirla: las recetas exactas, los datos autenticados adicionales que cubre cada AEAD, el bucle de descifrado por ensayo y las salvaguardas que todo productor y verificador debe imponer. La referencia de la construcción en PoE sellada es la prosa; esto es cómo conectarla para que la verificación de paridad quede en verde. Ancla estos borradores externos con exactitud, porque sus interioridades fijan bytes que debes reproducir:

  • chacha20-poly1305-stream64k, el formato de contenido, es ChaCha20-Poly1305 (RFC 8439) en la disposición STREAM segmentada de 64 KiB de la especificación de age v1. Ancla con exactitud el tamaño de segmento (65536), el nonce por segmento de 12 bytes uint88_be(counter) ‖ final_flag, el AAD por segmento vacío y la regla de la marca final: fijan bytes que debes reproducir.
  • X-Wing (el KEM mlkem768x25519) es draft-connolly-cfrg-xwing-kem-10. Trátalo como un KEM de caja negra: la construcción vincula la clave pública del destinatario y el texto cifrado al propio paso de derivación de clave, así que no depende de ninguna propiedad del hashing interno del combinador. XWing.Encapsulate DEBE aplicar la comprobación de validez de clave pública de la revisión fijada y negarse a encapsular hacia una clave que no la supere; el suelo de «nunca por debajo de la seguridad clásica de X25519» está acotado a claves generadas de forma válida, y omitir la comprobación renuncia al suelo para ese destinatario. Los vectores KEM de conformidad anclan el encapsulamiento contra el draft-10, de modo que un desajuste de revisión del borrador aflora de inmediato.

Una CEK, dos rutas de entrega de clave

Un registro sellado cifra el texto plano una sola vez bajo una única clave de cifrado de contenido (CEK) y luego entrega esa CEK por una de dos rutas mutuamente excluyentes, discriminadas por la presencia de campos: no hay etiqueta de modo:

  • ruta de ranuras: la CEK se envuelve de forma independiente para cada destinatario bajo una clave de cifrado de clave por ranura. enc lleva slots (además de kem y slots_mac).
  • ruta de frase de contraseña: la CEK se deriva directamente de una frase de contraseña normalizada mediante Argon2id. enc lleva passphrase; no lleva kem, slots ni slots_mac.

Ambas rutas comparten enc.scheme (siempre 1; rechaza cualquier otro valor), enc.aead (chacha20-poly1305-stream64k) y enc.nonce (24 bytes). Difieren en dónde reside el compromiso con la clave: en la cadena, en slots_mac para la ruta de ranuras; en una cabecera de 32 bytes dentro del blob de texto cifrado para la ruta de frase de contraseña. Ambas vinculan la afirmación de hash del ítem a su transcripción, y ambas sellan el contenido en el mismo STREAM segmentado; la diferencia es la entrega de clave y el compromiso, no la capa de contenido.

Envoltorio por ranura (ruta de ranuras)

Elige un KEM para todo el registro; nunca mezcles KEM dentro de un mismo slots[]. Para cada uno de los N destinatarios, deriva una clave de cifrado de clave (KEK) fresca por ranura y envuelve la misma CEK bajo ella con ChaCha20-Poly1305 con un nonce de 12 bytes todo a cero, con la AAD fijada al literal de la etiqueta info de ese KEM (nunca una AAD vacía), produciendo exactamente 48 bytes (texto cifrado de la CEK de 32 bytes + etiqueta de 16 bytes). El nonce a cero es seguro solo porque la clave de cifrado de clave es por ranura; consulta la salvaguarda de unicidad más abajo.

x25519 (clásico). Par de claves X25519 efímeras fresco por ranura:

priv_epk : randomBytes(32)                        ; fresh per slot
pub_epk  : x25519_publicKey(priv_epk)
shared   : x25519_sharedSecret(priv_epk, pub_R)   ; reject all-zero result
kek_salt : SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R)   ; 32 B
KEK      : HKDF-SHA-256(ikm  = shared, salt = kek_salt,
                        info = "cardano-poe-kek-v1", L = 32)
wrap     : ChaCha20-Poly1305(key = KEK, nonce = zeros(12),
                             ad = "cardano-poe-kek-v1", plaintext = CEK)   ; 48 B
slot     : { "epk": pub_epk, "wrap": wrap }

mlkem768x25519 (híbrido; X-Wing). Encapsulamiento X-Wing fresco por ranura:

enc    = XWing.Encapsulate(pub_R)       ; named fields — MUST NOT consume positional order
kem_ct = enc.ct                         ; 1120 B
shared = enc.ss                         ; 32 B
kek_salt : SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R)   ; 32 B
KEK      : HKDF-SHA-256(ikm  = shared, salt = kek_salt,
                        info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
wrap     : ChaCha20-Poly1305(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

Ambos salts tienen una sola forma, SHA-256(label || enc.nonce || <material KEM de la ranura> || pub_R), que lleva el efímero pub_epk de 32 bytes en la ruta clásica y el texto cifrado X-Wing kem_ct de 1120 bytes en la ruta híbrida; || es concatenación de bytes, y cada literal de prefijo de salt es ASCII exacto sin terminador ni prefijo de longitud. pub_R es la clave canónica del destinatario en el cable (32 B para x25519, los 1216 B fijados para mlkem768x25519). La ranura híbrida no lleva un epk aparte (el efímero X25519 son los 32 bytes finales de kem_ct), y kem_ct es una única cadena de bytes CBOR de exactamente 1120 bytes: solo el cuerpo completo del registro se fragmenta para el transporte, nunca un campo individual.

El salt vincula tres valores: el material KEM de la ranura (KEK única por ranura), pub_R (que frustra el reenvío de adjunto confundido contra un destinatario distinto) y enc.nonce (que ancla la KEK a un único sobre, de modo que repetir la aleatoriedad del KEM solo degrada a vinculabilidad entre sobres). Las etiquetas info diferenciadas dan separación de dominios entre KEM, de modo que ningún KEK derivado bajo un KEM puede igualar a uno derivado bajo el otro sobre un secreto compartido idéntico. Usa cada una de las once etiquetas internas byte a byte: cardano-poe-kek-v1, cardano-poe-kek-mlkem768x25519-v1, cardano-poe-x25519-kek-salt-v1, cardano-poe-xwing-kek-salt-v1, cardano-poe-item-hashes-v1, cardano-poe-slots-transcript-v1, cardano-poe-slots-mac-v1, cardano-poe-passphrase-transcript-v1, cardano-poe-passphrase-mac-v1, cardano-poe-payload-v1, cardano-poe-payload-passphrase-v1. Ninguna se serializa jamás en la transmisión; son constantes fijas, no seleccionables por registro. Un solo byte divergente produce un slots_mac, un compromiso o una etiqueta AEAD que el productor honesto no puede reproducir.

Baraja antes de calcular el MAC. El orden de entrada («el destinatario principal primero») es metadato privilegiado; publicar las ranuras en el orden de entrada lo filtra. Baraja slots[] con un CSPRNG usando una permutación de Fisher-Yates insesgada (un simple sorteo de índice u32 % m sesga hacia los residuos bajos y debe rechazarse por muestreo hasta obtener un índice uniforme) antes de calcular el MAC del conjunto de ranuras, que vincula el orden barajado tal como queda en la transmisión.

MAC del conjunto de ranuras: hashea la transcripción, luego aplica HMAC bajo la CEK

El MAC del conjunto de ranuras vincula a la CEK el conjunto de ranuras completo, más los campos de cabecera que fijan cómo se leen las ranuras. Constrúyelo en dos pasos: hashea una transcripción cerrada una vez y luego aplica HMAC a ese hash:

hashes_hash : SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))   ; 32 B

SLOTS_TRANSCRIPT = {                          ; closed 7-key map; keys are a set, not an order
    "scheme":      1,
    "path":        "slots",
    "aead":        <enc.aead>,                ; 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
}
slots_hash : SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
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 B

Tres cosas hacen o deshacen la paridad aquí:

  • La transcripción es un mapa cerrado serializado por canonicalEncode. Su orden de claves es la ordenación de RFC 8949 §4.2.1, nunca dispuesto a mano. Fijar scheme, path, aead, kem y nonce junto a las ranuras significa que un reenvío que altere cualquier campo de cabecera (aun dejando válidas las formas de las ranuras) cambia slots_hash y rompe el MAC.
  • La transcripción vincula la afirmación de hash del ítem. hashes_hash 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 cadena, una coincidencia del MAC confirma que el sobre se selló para esta afirmación de hash 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. El valor slots es el arreglo barajado de mapas de ranura en el cable directamente: cada campo de ranura es una única cadena de bytes (epk 32 B, kem_ct 1120 B), así que no hay fragmentación por campo que canonicalizar.
  • slots_hash se calcula una vez y se mantiene constante durante el bucle de descifrado por ensayo. 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. Precalcular el hash deja intacto el compromiso con clave de la CEK: cambia el mensaje del HMAC de la transcripción completa a su SHA-256, nada más.

El algoritmo del MAC, su derivación de clave y el esquema de la transcripción están todos fijados por enc.scheme = 1 y son idénticos para ambos KEM; no hay ningún identificador de MAC en la transmisión. slots_mac mide exactamente 32 bytes y se verifica en tiempo constante.

Cifrado de contenido: el STREAM segmentado

Cifra el texto plano una sola vez en el STREAM segmentado bajo una clave de contenido derivada de la CEK. La clave de contenido es una hoja HKDF independiente de la CEK (con salt del enc.nonce, bajo un info específico de la ruta), así que la capa de envoltorio y la capa de contenido nunca aplican la clave a la misma primitiva sobre los mismos bytes:

content_key : HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)

; STREAM (chacha20-poly1305-stream64k):
CHUNK_SIZE  : 65536 plaintext bytes per non-final chunk
chunk nonce : uint88_be(counter) || final_flag   ; 12 B; counter from 0, +1 per chunk;
                                                 ; final_flag = 0x01 on the last chunk, else 0x00
per-chunk AAD : empty
ciphertext  : seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
              ; each chunk sealed with ChaCha20-Poly1305 under content_key; sealed = plaintext + 16 B

El AAD por segmento es vacío, y eso es correcto, no una omisión: la clave de contenido se deriva de la CEK, y la CEK ya queda comprometida con la cabecera completa (incluido hashes_hash) mediante slots_mac. Altera cualquier campo de cabecera y el destinatario deriva una clave de contenido distinta, así que el flujo no consigue abrir; un AAD por segmento volvería a vincular el mismo contexto en cada segmento sin añadir seguridad. Los nonce de contador son seguros porque la clave de contenido es de un solo uso (una CEK fresca con salt del enc.nonce único del sobre), así que dos flujos nunca comparten un par (key, nonce).

Construye el STREAM de modo que el truncamiento sea detectable: cada segmento no final mide exactamente 65536 bytes de texto plano, el segmento final lleva final_flag = 0x01 y de 0 a 65536 bytes (un texto plano vacío es un único segmento final de longitud cero, una etiqueta solitaria de 16 bytes), y un verificador DEBE fallar (TAMPERED_CIPHERTEXT) ante una marca final ausente, una marca final en un segmento no final, datos a continuación del segmento final o un segmento no final corto. Verifica la etiqueta de cada segmento antes de liberar su texto plano, y trata los bytes liberados como tentativos hasta que pase la recomprobación del hash posterior al descifrado.

El texto plano son los bytes de contenido originales exactos; la construcción no antepone, no añade ni cifra ningún nombre de archivo, tipo MIME, campo de tamaño ni envoltorio de metadatos. El blob de texto cifrado publicado son los segmentos del STREAM (en la ruta de frase de contraseña, precedidos de la cabecera de compromiso de 32 bytes de más abajo). El mapa enc ensamblado y la URI resultante van en la cadena; los bytes del texto cifrado no, publícalos en un almacén direccionado por contenido y pon la URI ar:// o ipfs:// en el uris[] del elemento.

Ruta de frase de contraseña

Cuando no hay destinatarios, deriva la CEK de una frase de contraseña normalizada con Argon2id. No hay epk, ni envoltorio por ranura, ni MAC del conjunto de ranuras, ni bucle de descifrado por ensayo. 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:

passphrase_bytes = utf8(normalize(passphrase))   ; cardano-poe-pw-norm-v1
CEK = argon2id(passphrase_bytes, salt = enc.passphrase.salt,
               params = enc.passphrase.params, 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,
    "path":        "passphrase",
    "aead":        <enc.aead>,
    "nonce":       <enc.nonce>,               ; bytes(24)
    "hashes_hash": hashes_hash,               ; bytes(32), over this item's hashes
    "passphrase": {                           ; closed sub-map
        "alg":           "argon2id",
        "salt":          enc.passphrase.salt,
        "params":        { "m": m, "t": t, "p": p },
        "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 B

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

La PASSPHRASE_TRANSCRIPT vincula al compromiso los parámetros del KDF, los campos de cabecera y la afirmación de hash del ítem: 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 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 la transmisión (el productor emite solo { alg, salt, params }).

En el lado de la verificación, deriva la CEK candidata, lee los 32 bytes iniciales del blob de texto cifrado, recalcula el compromiso y compáralo en tiempo constante antes de abrir ningún segmento del STREAM. Un blob más corto que 48 bytes (compromiso de 32 bytes + STREAM mínimo de 16 bytes) está mal formado (TAMPERED_CIPHERTEXT). Ante una discordancia (frase de contraseña equivocada, salt / params / cabecera manipulados, o un sobre empalmado) aflora el mismo único fallo genérico y no empieces a transmitir en flujo; una frase de contraseña equivocada es indistinguible de un registro manipulado. El compromiso está deliberadamente fuera de la cadena: un compromiso en la cadena sería un oráculo de adivinación fuera de línea sin coste para cada registro por frase de contraseña, incluidos aquellos cuyo texto cifrado se retiene.

Impón los mínimos de los parámetros: longitud de salt de 16 a 64 bytes; m ≥ 65536 KiB (≈ 64 MiB), t ≥ 3, p ≥ 1. Fija la versión de Argon2 en 0x13 (19); ninguna otra versión es admisible bajo enc.scheme: 1, y no hay ningún campo de versión en la transmisión. Cuando la plataforma lo permita, los productores DEBERÍAN emitir p = 4 (el segundo perfil recomendado de RFC 9106 §4); los verificadores PUEDEN aceptar cualquier p ≥ 1, sujeto a los topes del despliegue. Argon2id cruza limpiamente las fronteras entre ecosistemas (el conjunto de parámetros, no la biblioteca, es el contrato), así que unos (m, t, p, salt, len, password) fijos deben producir una salida idéntica al byte en toda implementación. La vinculación entre una frase de contraseña y su sobre es el compromiso dentro del texto cifrado de más arriba; una frase de contraseña equivocada y un texto cifrado manipulado afloran ambos como un único fallo genérico.

Acota la frase de contraseña en bruto antes de la normalización y de Argon2id: rechaza cualquier entrada de más de los MAX_PASSPHRASE_INPUT_BYTES = 4096 bytes UTF-8 de referencia, de modo que una frase de contraseña patológica no pueda provocar una denegación de servicio previa al KDF. Como las cotas de MAX_SLOTS y del sobre decodificado de la ruta de ranuras, esta es una constante fijada por despliegue que PUEDES endurecer, no un campo de transmisión.

El perfil de normalización 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 cardano-poe-pw-norm-v1, aplicado en orden:

  1. Rechaza los 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 (ENC_PASSPHRASE_UNNORMALIZABLE) antes de ejecutar cualquier normalización. Unicode garantiza la estabilidad de la normalización solo sobre puntos de código asignados, así que esto cierra un hueco de deriva futura y es invisible para los usuarios honestos.
  2. NFKC: la forma de normalización KC según UAX #15 bajo Unicode 16.0.
  3. Espacios en blanco: define como espacio en blanco todo carácter que lleve la propiedad Unicode White_Space bajo Unicode 16.0; colapsa cada secuencia máxima a un único U+0020 SPACE.
  4. Recorte: elimina los espacios en blanco iniciales y finales.
  5. Rechaza vacío: si el resultado es la cadena vacía, rechaza (ENC_PASSPHRASE_EMPTY); una frase de contraseña compuesta solo de espacios en blanco dejaría el registro con clave de una CEK que cualquier parte puede derivar.
  6. Codificación: UTF-8; esos bytes son la entrada de contraseña de Argon2id.

Ancla Unicode en 16.0 de forma literal y no dejes que flote: 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, así que resolver el perfil contra una versión distinta de Unicode puede derivar una CEK diferente de la misma frase de contraseña y no abrir 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, nunca reinterpretando cardano-poe-pw-norm-v1.

Descifrado por ensayo: abre cada ranura, integra el MAC, falla de forma genérica

Un destinatario posee una clave privada KEM y descubre su ranura intentando abrir cada una; las claves públicas de los destinatarios no están en la transmisión. Antes de invocar cualquier primitiva KEM o AEAD, ejecuta las cotas de recursos y luego las salvaguardas estructurales. Acota primero el uso de recursos del analizador: rechaza un sobre cuyo tamaño decodificado supere los 65536 bytes (ENC_ENVELOPE_TOO_LARGE) o cuyo slots[] supere MAX_SLOTS = 1024 (ENC_SLOTS_TOO_MANY). Ambas cotas de referencia quedan muy por encima del techo de ~16 KiB de los metadatos de transacción de Cardano que acota un registro honesto; son constantes fijadas por despliegue que PUEDES endurecer, nunca campos de transmisión. Luego las salvaguardas estructurales: scheme == 1; aead, kem registrados; nonce de 24 bytes; slots_mac de 32 bytes; slots no vacío; secreto del destinatario de 32 bytes; cada wrap de 48 bytes; por KEM, cada epk de exactamente 32 bytes sin kem_ct (x25519) o cada kem_ct de exactamente 1120 bytes sin epk (mlkem768x25519).

Rechaza aquí los encapsulamientos duplicados dentro del registro, antes de cualquier primitiva. Todos los valores epk deben ser distintos en la ruta clásica, todos los valores kem_ct distintos en la ruta híbrida; un duplicado levanta ENC_SLOTS_DUPLICATE_KEM_MATERIAL. Esta es la porción comprobable por el verificador de la invariante de unicidad del KEK por ranura de la que depende el envoltorio con nonce a cero; la reutilización entre registros o entre claves es una obligación del productor que ningún verificador puede detectar. Este rechazo se dispara solo ante un epk / kem_ct repetido: sellar dos veces para el mismo destinatario con efímeros frescos por ranura es legítimo y no lo dispara (véase la regla de múltiples coincidencias más abajo). unwrap-negative lleva el caso de epk duplicado con reutilización de KEK.

Luego ejecuta el bucle, recalculando slots_hash una vez antes de él y manteniéndolo constante:

found        = false
cek_conflict = false
selected_CEK = 0^32
for slot in slots:                            ; iterate ALL slots — no early break
    ; derive KEK per-KEM, as in the wrap recipe. For x25519 the all-zero shared
    ; secret is rejected via a secret-independent bit, not an early branch:
    ;   kem_ok = NOT constantTimeEqual(shared, 0^32)
    ;   KEK    = ct_select(kem_ok, real_KEK, dummy_KEK)   ; dummy_KEK from ikm=0^32, same salt/info
    ; (XWing.Decapsulate has no all-zero case; kem_ok stays true on the hybrid path.)
    open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), kem_info_label, slot.wrap)
    HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
    mac_ok   = constantTimeEqual(HMAC-SHA-256(HMAC_KEY, slots_hash), 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 constantTimeEqual(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)
if cek_conflict: reject (single generic failure)
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

Los innegociables en este bucle:

  • Abre de forma atómica; nunca liberes texto plano sin verificar. Ambas primitivas *_open_or_dummy son atómicas: ante un fallo de verificación del tag AEAD no devuelven texto plano, y el candidato devuelto (la CEK envuelta o el texto plano del contenido) es un valor de relleno fijo o pseudoaleatorio independiente del texto cifrado que falló. Esto es lo que permite al bucle arrastrar una candidate_CEK más allá de una apertura de envoltura fallida sin exponer jamás bytes no autenticados.
  • Integra la comprobación de todo a cero en un bit kem_ok independiente del secreto. Calcula kem_ok = NOT constantTimeEqual(shared, 0^32) para la ruta x25519, selecciona la KEK en tiempo constante entre la KEK real y una KEK ficticia derivada de 0^32 bajo el mismo salt e info, y pliega kem_ok en la aceptación (ok = kem_ok AND open_ok AND mac_ok). No te ramifiques fuera de forma anticipada ante un share inválido: una ranura con ECDH inválido nunca puede aceptarse, y el bucle sigue haciendo un trabajo idéntico. (XWing.Decapsulate no tiene caso de todo a cero, así que kem_ok queda fijo en verdadero en la ruta híbrida.)
  • Integra la comprobación de slots_mac en el bucle. Un emisor malicioso puede fabricar una ranura que abra bajo la clave del destinatario con una CEK elegida por el atacante (sin necesidad de conocer la clave privada). Aceptar el primer éxito de AEAD como «la nuestra» dejaría que esa ranura falsificada eclipsara a una honesta. Exigir que la CEK candidata también reproduzca slots_mac sobre slots_hash frustra la sustitución, la eliminación y la reordenación de ranuras. Nunca lo omitas.
  • Permite múltiples coincidencias; rechaza solo un conflicto de CEK. Una clave de destinatario PUEDE coincidir legítimamente con más de una ranura: sellar la misma CEK para el mismo destinatario en varias ranuras, cada una con efímeros frescos, es un acolchado válido del número de destinatarios y no dispara el rechazo de epk/kem_ct duplicado. Selecciona la CEK de la primera coincidencia y no rechaces solo porque coincidiera más de una ranura. La única anomalía a rechazar son dos ranuras coincidentes que recuperan CEK distintas (comparación en tiempo constante): rastrea un bit cek_conflict y aflora el único fallo genérico si está activo. Esto es defensa en profundidad: bajo el compromiso del conjunto de ranuras, una coincidencia con CEK distinta ya es inviable, así que falla de forma segura frente a una implementación defectuosa.
  • Itera todas las ranuras dentro de la pasada de una sola clave privada (un número constante de operaciones de ranura por clave, sin salida anticipada), de modo que un observador de tiempos no pueda inferir qué ranura coincidió. Conduce el rechazo de todo a cero a través de kem_ok y trabajo ficticio en lugar de salir antes. Un destinatario con varias claves itera clave × ranura y 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, y debe volver a derivar la mitad pub_R del salt por clave, ya que ambos KEM vinculan la propia clave pública del destinatario al salt del KEK. Vincula ese salt a la codificación de transmisión canónica de la clave (exactamente la clave pública X25519 de 32 bytes, o exactamente los bytes de clave pública X-Wing fijados de 1216 bytes), nunca una recodificación no canónica, o ambos lados derivan KEK distintas.
  • Expón una única forma de fallo genérico a los llamantes no confiables. Internamente puedes rastrear resultados tipados para diagnóstico local (WRONG_RECIPIENT_KEY (no abrió ninguna ranura), TAMPERED_HEADER (una ranura abrió pero ninguna CEK candidata reprodujo slots_mac), TAMPERED_CIPHERTEXT (el AEAD de contenido falló tras recuperar una CEK y verificar el MAC)), pero un observador externo NO DEBE distinguirlos por la forma de la respuesta. En cuanto al tiempo, el modelo está deliberadamente acotado: un verificador PUEDE retornar en la comprobación if NOT found antes del descifrado del contenido, lo que separa a un no destinatario de un destinatario cuyo texto cifrado no consigue abrir. Eso revela solo destinatario frente a no destinatario, nunca qué ranura ni material de clave alguno; no se exige un tiempo uniforme entre esos dos casos y NO DEBE imponerse una apertura de contenido ficticia. La garantía de tiempo constante que se mantiene es la invariante entre ranuras de más arriba.
  • Recalcula y compara el hash del texto plano tras el descifrado. El mapa hashes en la cadena se compromete con el texto plano, no con el texto cifrado, así que el destinatario (en la capa de aplicación) debe recalcular el resumen y compararlo: la entrada sha2-256 debe coincidir, y blake2b-256 si está presente. Una discrepancia significa que la afirmación de hash del registro no coincide con los bytes descifrados: niégate a actuar sobre el texto plano. El validador estructural nunca descifra.

Acota la carga útil en ambos lados

El STREAM segmentado no impone ningún techo criptográfico a la carga útil: el contador de 88 bits por segmento admite 2^88 segmentos, y cada segmento se sella bajo un par (content_key, nonce) distinto, holgadamente dentro del límite de invocación única de RFC 8439, así que no hay ningún riesgo de desbordamiento del contador contra el que protegerse. El máximo que impone un productor o un verificador es, por tanto, una política de denegación de servicio del despliegue, no una constante de transmisión: imponlo de forma incremental a medida que el flujo se escribe o se lee, y aborta antes de almacenar en memoria una carga útil sobredimensionada. El truncamiento se detecta estructuralmente mediante la marca final, no mediante un tope de tamaño. La misma postura se aplica tanto en la ruta de ranuras como en la de frase de contraseña.

Fixtures de conformidad de la PoE sellada

El rincón de la PoE sellada del corpus es donde aflora la mayoría de los errores entre lenguajes. Lleva tu implementación a través de todo él. Los fixtures positivos anclan el envoltorio determinista y el bucle de descifrado por ensayo para ambos KEM (de uno y de varios destinatarios, con N mixta y el peor caso de múltiples claves privadas), más el caso legítimo de un destinatario que coincide con dos ranuras (efímeros frescos, la misma CEK, DEBE descifrar, de modo que una implementación que rechace múltiples coincidencias falla aquí) y la ruta de frase de contraseña (cabecera de compromiso más segmentos del STREAM en un solo blob). Un conjunto dedicado de disposición del STREAM ancla un texto plano vacío (un único segmento final de longitud cero), una carga útil de un solo segmento y una carga útil de varios segmentos que cruza el límite de 65536 bytes. KATs específicos anclan ambos salts de KEK (SHA-256(label ‖ enc.nonce ‖ <material KEM> ‖ pub_R)), el hashes_hash y su lugar en ambas transcripciones, el encapsulamiento de X-Wing contra el draft-10, la extracción HKDF con salt de longitud cero (la convención de salt ausente de RFC 5869 §2.2, reflejando la derivación de la clave de slots_mac), las codificaciones Bech32 de destinatario y de secreto, y la codificación con suma de verificación de la semilla de identidad.

Los fixtures negativos anclan los códigos de rechazo: una ranura sombra falsificada antes de una ranura honesta (el registro DEBE seguir descifrando bajo la CEK honesta); un cambio de cabecera (kem/aead/scheme) que deja válidas las formas de las ranuras; un empalme de hashes en un ítem con una afirmación de hash distinta; los fallos del compromiso de frase de contraseña (frase de contraseña equivocada, salt/params manipulados, cabecera manipulada, todos fallando antes de abrir ningún segmento); los rechazos de normalización de frase de contraseña (una entrada con un punto de código no asignado y una entrada compuesta solo de espacios en blanco); el secreto compartido X25519 todo a cero; la ranura duplicada dentro del registro; y los casos de manipulación del STREAM (etiqueta de segmento alterada, flujo truncado, datos sobrantes, segmento no final corto). Dos propiedades no tienen vector de bytes y se afirman de forma conductual en su lugar: el rechazo por conflicto de CEK (construir uno es exactamente la colisión multiclave del compromiso que el estándar supone inviable) y la garantía de tiempo constante entre ranuras. Reproduce cada cadena de bytes anclada y emite el código exacto para cada caso negativo.

Una propiedad de la PoE sellada no tiene vector de bytes: el rechazo por conflicto de CEK (dos ranuras coincidentes que recuperan CEK distintas) no puede construirse como fixture, porque construir uno es exactamente la colisión multiclave del compromiso que el estándar supone inviable. Ánclala en cambio con una prueba de comportamiento a nivel de implementación que afirme que tu bucle de descifrado por ensayo falla de forma segura ante un conflicto forzado, igual que la propiedad de tiempo constante entre ranuras se afirma de forma conductual en lugar de como una cadena de bytes.

Conformidad y vectores de prueba

Los vectores de prueba normativos son el contrato de interoperabilidad. Una implementación es conforme si y solo si reproduce cada cadena de bytes anclada del conjunto de pruebas de conformidad a partir de las mismas entradas, y emite el código de error tipado correcto para cada fixture negativo. No hay créditos parciales ni apelación posible: si una comparación falla, la equivocada es la implementación, nunca el vector.

Los vectores residen en el conjunto de pruebas de conformidad del estándar, organizados por clase de primitiva: fixtures de registros, envoltura y desenvoltura de PoE sellada, firmas COSE_Sign1, HKDF, derivación de semilla, Argon2id y CBOR canónico. Cada uno ancla entradas en hexadecimal en minúsculas y las salidas esperadas. Para usarlos: alimente las entradas en su implementación, compare cada salida con nombre byte a byte y corrija su código ante cualquier discrepancia.

Tres obligaciones que toda implementación debe cumplir

Reproducir los vectores positivos. Para cada fixture de registro, deben cumplirse ambas mitades de encode(record) == expected_cbor Y la ida y vuelta encode(decode(expected_cbor)) == expected_cbor. La ida y vuelta se generaliza más allá de los fixtures: para una entrada arbitraria bien formada, encode(decode(x)) == x. Un decodificador que pierde o reordena información, o un codificador que no es canónico, rompe esto y no supera la conformidad.

Emitir los códigos de rechazo correctos. Los fixtures negativos emparejan un registro deliberadamente mal formado con el código de error tipado exacto que un validador estructural DEBE levantar. Reproducir los bytes de los registros válidos es la mitad del contrato; rechazar los inválidos con el código correcto es la otra mitad. Un validador que rechaza un registro defectuoso por la razón equivocada, o que lo acepta, no es conforme. Los fixtures negativos son la única fuente de verdad para la paridad de rechazo entre lenguajes: la misma entrada mal formada DEBE levantar el mismo código en toda implementación. El catálogo completo de códigos y su significado está en Verificación.

Coincidir con los registros de algoritmos. Los identificadores de algoritmo son cadenas con nombre extraídas de los registros de algoritmos de Registros de algoritmos. Un identificador no reconocido DEBE aflorar el código preciso de algoritmo no admitido, nunca una aceptación silenciosa ni un fallo catastrófico.

Corrija la implementación, nunca el vector

Los vectores están anclados a los RFC originales y a las construcciones deterministas de este estándar. Cuando una comparación falla, el error está en la implementación que se está probando. Editar un vector para que un conjunto de pruebas pase convierte un fallo real de interoperabilidad en uno latente que aflora solo cuando un registro cruza entre implementaciones en la cadena: el peor momento posible para descubrirlo.

Ejecute la paridad en cada cambio

Una implementación que distribuye más de un lenguaje, o que quiere demostrar interoperabilidad con otra, DEBERÍA ejecutar un único trabajo de integración continua que compile todos los paquetes, ejecute el conjunto de pruebas de cada lenguaje contra los fixtures compartidos, imponga el análisis del grafo de dependencias y compruebe que el conjunto de fixtures es idéntico en ambos lados. Un fixture añadido en un lado pero no en el otro hace fallar la verificación: las dos implementaciones han divergido en silencio, y la compilación lo detecta antes de que lo haga un registro real. Los fixtures son la fuente canónica; cada lenguaje mantiene un reflejo idéntico al byte, y la verificación afirma que el reflejo es completo y exacto.

Convenciones de nombres y de transmisión

Unas pocas convenciones mantienen legible una implementación y estable el formato de transmisión:

  • Los nombres de campo de transmisión van en snake_case: leaf_count, cose_sign1, slots_mac. Esto se mantiene entre lenguajes: incluso allí donde un lenguaje usa idiomáticamente camelCase para su API en memoria, el registro codificado emplea claves en snake_case, porque las claves forman parte de los bytes canónicos que cubre una firma.
  • Los identificadores son cadenas del registro de algoritmos, no enumeraciones incrustadas en el código. Los hashes, AEAD, KEM, KDF y firmas referencian todos identificadores con nombre; añadir un algoritmo (un KEM poscuántico, por ejemplo) es una entrada aditiva en el registro de algoritmos, nunca una ruptura del formato de transmisión.
  • Los nombres de método entre lenguajes se reflejan semánticamente. Una función en un lenguaje tiene una contraparte con el mismo nombre en otro (encode_canonical_cborencodeCanonicalCbor), de modo que quien domine cualquiera de los dos puede trazar una superficie sobre la otra y razonar sobre la paridad con solo inspeccionarla.
  • Ponga en marcha primero las capas criptográficas. Levante el núcleo criptográfico y la biblioteca del formato de transmisión contra los vectores y deje en verde la verificación de paridad antes de escribir una sola línea de código de aplicación. El verificador autónomo es la superficie más pequeña cercana a la aplicación y lo siguiente que conviene construir; todo lo demás se asienta sobre una capa criptográfica que ya ha demostrado correcta.

Páginas relacionadas

  • El registro: el formato de transmisión que implementan el validador y el codificador.
  • PoE sellada: la referencia de la construcción que respalda las recetas de construcción de aquí.
  • Registros de algoritmos: los identificadores con nombre que resuelve una implementación.
  • Verificación: la canalización de validación, el verificador autónomo y el catálogo de códigos de error.