Questa è una traduzione a scopo informativo. Fa fede la versione inglese, che è quella normativa. Leggi la versione inglese

Guida per chi implementa lo standard

Come realizzare un'implementazione conforme a Label 309: l'architettura a livelli consigliata, il contratto di identità byte a byte tra i diversi linguaggi e i vettori di test di conformità che definiscono l'interoperabilità.

Label 309 è un formato di interscambio e un insieme di costruzioni crittografiche, non un prodotto. Possono coesistere quante implementazioni indipendenti si vuole, in TypeScript, Python, Rust, Go o un runtime mobile nativo, e un record prodotto da una di esse DEVE verificarsi correttamente con un'altra. Questa pagina si rivolge a chi sta costruendo un'implementazione del genere. Descrive l'architettura che mantiene verificabile la superficie crittografica, il contratto preciso che rende due implementazioni interoperabili e la suite di conformità che decide, in modo meccanico, se l'hai rispettato.

Due cose rendono Label 309 interoperabile tra i linguaggi. La prima è il determinismo: le costruzioni sono ancorate a standard pubblici (RFC 8949 CBOR canonico, RFC 8032 Ed25519, RFC 7748 X25519, RFC 5869 HKDF, RFC 9106 Argon2id, RFC 9052 COSE), così gli stessi input producono gli stessi byte ovunque. La seconda è la suite di conformità: un insieme di vettori di test esatti al byte che un'implementazione riproduce oppure no. La conformità è una proprietà che puoi verificare, non un'affermazione che fai.

L'architettura a livelli

Un'implementazione conforme DOVREBBE separare le primitive crittografiche dalla logica applicativa in livelli distinti, ciascuno dipendente solo da quello sottostante. I nomi qui sotto indicano ruoli, non nomi di pacchetto; scegli i tuoi.

┌─────────────────────────────────────────────────────────┐
│  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               │
└─────────────────────────────────────────────────────────┘

I confini sono portanti, non decorativi. Ogni livello ha un solo compito e una breve lista di cose che gli è vietato conoscere.

Il nucleo crittografico

Il livello più basso contiene soltanto primitive: funzioni di hash, KDF, operazioni di firma e KEM, il livello di contenuto AEAD, il CBOR canonico, COSE_Sign1, la costruzione di wrap/unwrap della PoE sigillata, le radici e le prove di Merkle e le classi di errore tipizzate che esse sollevano. Non contiene alcuna logica di dominio, nessuna chiamata HTTP, nessun accesso a database e nessun import di librerie UI o di framework lato server.

Questo livello DEVE restare privo di dipendenze legate all'applicazione o al server e DEVE essere utilizzabile in sicurezza dal browser, per tre motivi concreti:

  • Gira ovunque. Calcolare l'hash di un file, costruire una busta e, soprattutto, il verificatore in autonomia girano tutti nei browser, nei worker serverless e dalla riga di comando con la stessa facilità con cui girano su un server. Una dipendenza che funziona solo lato server (un driver di database, un framework di logging legato a un runtime, una libreria UI) romperebbe quegli ambienti e appesantirebbe ogni consumatore che include il nucleo nel proprio bundle.
  • È la superficie da sottoporre ad audit. Chi revisiona può leggere da cima a fondo un pacchetto di sole primitive confrontandolo con le RFC. Nel momento in cui vi si infiltra del codice applicativo, la superficie che chi conduce una revisione di sicurezza deve tenere a mente cresce senza limiti.
  • È ciò che le terze parti incorporano. Un verificatore indipendente, qualcuno che non si fida di alcun servizio ma solo della blockchain, importa questo livello e nulla di ciò che vi sta sopra. Mantenerlo piccolo e portabile è ciò che rende praticabile il "verifica tu stesso".

In concreto, il nucleo NON DEVE importare ORM o driver di database, framework UI, framework di logging legati al server o alcun modulo applicativo. La casualità DEVE provenire dal CSPRNG della piattaforma (Web Crypto getRandomValues, o un suo equivalente ri-esportato), mai da una sorgente disponibile solo su Node, così la stessa fonte funziona invariata in un browser.

Imponi il confine nella CI, non nella code review

La regola "zero dipendenze" si erode nel momento in cui si insinua un import comodo. Un'implementazione DOVREBBE eseguire un lint del grafo delle dipendenze che attraversi ogni import del nucleo e della libreria del formato di interscambio e faccia fallire la build a fronte di qualsiasi specificatore esterno a una allow-list definita per ciascun livello. Chi revisiona dimentica; il linter no.

La libreria del formato di interscambio

Il livello immediatamente superiore possiede Label 309 vero e proprio: lo schema del record, il validatore strutturale e il codificatore e decodificatore CBOR canonico. Dipende dal nucleo crittografico (per l'hashing, COSE e il codec CBOR) e da nient'altro legato all'applicazione. La sua superficie è ristretta e pura:

  • encode: produce i byte CBOR canonici per un record validato.
  • decode: l'operazione inversa.
  • validate: esegue i controlli strutturali e semantici dello standard su un record decodificato e restituisce un risultato tipizzato (vedi Verifica).

In questo livello vivono come codice le regole de Il record: il set chiuso di chiavi, la disciplina di riassemblaggio dei chunk, l'invariante items-oppure-merkle, i requisiti del CBOR canonico. Come il nucleo, resta privo di client HTTP, driver di database e import di framework.

L'SDK e l'applicazione

L'SDK racchiude i livelli inferiori in helper ergonomici: un client di servizio, helper per costruire e sbloccare la busta e il verificatore in autonomia, la funzione che decodifica un record, ne controlla la struttura, verifica eventuali firme del record rispetto alla chiave on-chain e produce un verdetto usando solo dati pubblici. Il verificatore in autonomia DEVE funzionare senza alcun accesso di rete a un servizio gestito da chi implementa; il suo unico input esterno è un explorer pubblico della blockchain scelto dal verificatore stesso. Anche l'SDK DOVREBBE restare utilizzabile in sicurezza dal browser.

Il livello applicativo (UI, routing, persistenza, fatturazione, job in background) è greenfield e non porta con sé alcun obbligo di interoperabilità. Nulla nello standard vincola il modo in cui lo costruisci, se non il fatto che si collochi al di sopra della superficie crittografica verificata, anziché intromettervisi.

Il contratto di identità byte a byte

L'interoperabilità è una proprietà dei byte, non delle intenzioni. Due implementazioni interoperano se e solo se le primitive che non hanno alcuna libertà nel proprio output producono gli stessi byte a partire dagli stessi input. Questo è il contratto di parità, ed è il cuore della conformità.

Il contratto si divide nettamente in due. Le operazioni il cui output è interamente determinato dai loro input DEVONO essere identiche al byte tra le implementazioni. Le operazioni che consumano casualità non possono dare risultati uguali al byte da una chiamata all'altra; per queste il contratto è la consumabilità incrociata: un valore prodotto da un'implementazione DEVE essere consumabile da qualsiasi altra (un testo cifrato sigillato in un linguaggio si decifra in un altro).

Primitive identiche al byte

Ognuna delle operazioni qui sotto è una funzione pura dei propri input e DEVE emettere un output identico al byte in ogni implementazione conforme:

PrimitivaAncorata aOutput che deve coincidere
Seed → coppia di chiavi Ed25519 / X25519HKDF-SHA-256 con le costanti info registratechiavi pubblica e privata derivate
HKDF-SHA-256RFC 5869materiale di chiave in output per input fisso
MAC dell'insieme di slot HMAC-SHA-256RFC 2104byte di slots_hash e del tag slots_mac per un CEK e un insieme di slot fissi
Argon2id (KDF della passphrase)RFC 9106chiave derivata per (m, t, p, salt, len, password) fissi
SHA-256FIPS 180-4digest
BLAKE2b-256RFC 7693digest
Codifica CBOR canonicaRFC 8949 §4.2.1byte codificati per input fisso
Codifica COSE_Sign1RFC 9052byte della struttura per header, payload e firma fissi
Firma / verifica Ed25519RFC 8032 (strict)firma; verdetto
ECDH X25519RFC 7748segreto condiviso per scalari fissi
Wrap / unwrap della PoE sigillataPoE sigillatabyte per slot e MAC quando si iniettano gli effimeri e il CEK
Radice di Merkle + prove di inclusioneRFC 9162 §2.1.1radice e prove per foglia su un elenco ordinato di foglie

Due punti meritano enfasi. Ed25519 è strict: un verificatore conforme DEVE applicare le regole della S canonica e del rifiuto dei punti di ordine basso di RFC 8032 §5.1.7, così due implementazioni concordano non solo sulle firme che accettano ma anche su quelle che rifiutano. Argon2id attraversa i confini tra ecosistemi: linguaggi diversi ricorrono a librerie Argon2 diverse, ma ogni libreria conforme implementa RFC 9106 e DEVE produrre un output identico per parametri identici: il contratto è il set di parametri, non la libreria.

Operazioni che consumano casualità

La generazione di chiavi, il wrap della PoE sigillata sotto effimeri freschi generati per ogni slot e la cifratura della busta attingono tutti a casualità fresca, perciò il loro output cambia a ogni chiamata e non può essere fissato al byte. Per queste il contratto è la consumabilità incrociata: l'output prodotto da un'implementazione DEVE essere consumabile da ogni altra. Un record sigillato in un linguaggio DEVE decifrarsi in un altro; una coppia di chiavi coniata in uno DEVE verificarsi e fungere da destinatario di cifratura in un altro. Le suite di conformità fissano questi casi con hook di test deterministici che iniettano gli effimeri, rendendo il wrap riproducibile, e con fixture di round-trip che cifrano in un linguaggio e decifrano nell'altro.

Costruire la PoE sigillata

La PoE sigillata è la parte più densa del formato wire, e quella in cui un singolo byte sbagliato (una chiave di mappa fuori ordine, un'etichetta sbagliata di un solo carattere, una suddivisione a blocchi non canonica) produce una busta che si apre nella tua implementazione ma in nessun'altra. Questa sezione è la checklist di build: le ricette esatte, i dati autenticati aggiuntivi che ciascun AEAD copre, il ciclo di decifratura per tentativi e le protezioni che ogni produttore e ogni verificatore DEVONO imporre. Il riferimento di costruzione su PoE sigillata è la prosa; questo è come la cabli affinché il gate di parità diventi verde. Fissa esattamente questi draft esterni, perché i loro interni fissano byte che devi riprodurre:

  • chacha20-poly1305-stream64k, il formato di contenuto, è ChaCha20-Poly1305 (RFC 8439) nel layout STREAM segmentato da 64 KiB della specifica age v1. Fissa esattamente la dimensione del chunk (65536), il nonce per chunk da 12 byte uint88_be(counter) ‖ final_flag, l'AAD per chunk vuoto e la regola del flag finale: fissano byte che devi riprodurre.
  • X-Wing (il KEM mlkem768x25519) è draft-connolly-cfrg-xwing-kem-10. Trattalo come un KEM a scatola nera: la costruzione lega la chiave pubblica del destinatario e il testo cifrato nel passo stesso di derivazione della chiave, perciò non fa affidamento su alcuna proprietà dell'hashing interno del combiner. XWing.Encapsulate DEVE applicare il controllo di validità della chiave pubblica della revisione fissata e rifiutare di incapsulare verso una chiave che non lo supera; il pavimento «mai al di sotto della sicurezza classica di X25519» è circoscritto alle chiavi generate validamente, e saltare il controllo lo rinuncia per quel destinatario. I vettori KEM di conformità fissano l'encapsulation a fronte di draft-10, perciò una discordanza di revisione del draft emerge subito.

Una CEK, due percorsi di consegna della chiave

Un record sigillato cifra il testo in chiaro una sola volta sotto un'unica chiave di cifratura del contenuto (CEK), poi consegna quella CEK tramite uno di due percorsi mutuamente esclusivi, discriminati dalla presenza dei campi: non esiste alcun tag di modalità.

  • percorso slots: la CEK è avvolta in modo indipendente verso ciascun destinatario sotto una chiave di cifratura della chiave per slot. enc porta slots (oltre a kem, slots_mac).
  • percorso passphrase: la CEK è derivata direttamente da una passphrase normalizzata tramite Argon2id. enc porta passphrase; non porta kem, slotsslots_mac.

Entrambi i percorsi condividono enc.scheme (sempre 1; rifiuta qualsiasi altro valore), enc.aead (chacha20-poly1305-stream64k) ed enc.nonce (24 byte). Differiscono per dove risiede l'impegno della chiave: on-chain in slots_mac per il percorso slots, in un'intestazione di 32 byte all'interno del blob di testo cifrato per il percorso passphrase. Entrambi vincolano la rivendicazione dell'hash dell'elemento nella propria trascrizione, ed entrambi sigillano il contenuto nello stesso STREAM segmentato; la differenza è la consegna della chiave e l'impegno, non il livello del contenuto.

Avvolgimento per slot (percorso slots)

Scegli un KEM per l'intero record: non mescolare mai i KEM all'interno di un solo slots[]. Per ciascuno degli N destinatari, deriva una nuova chiave di cifratura della chiave per slot e avvolgi la stessa CEK sotto di essa con ChaCha20-Poly1305 a un nonce azzerato di 12 byte, con AAD impostata al literale dell'etichetta info di quel KEM (mai AAD vuota), producendo esattamente 48 byte (32 byte di testo cifrato della CEK + 16 byte di tag). Il nonce azzerato è sicuro solo perché la chiave di cifratura della chiave è per slot; vedi la protezione sull'unicità più sotto.

x25519 (classico). Nuova coppia di chiavi effimere X25519 per ogni slot:

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 (ibrido; X-Wing). Nuova encapsulation X-Wing per ogni slot:

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

Entrambi i salt hanno un'unica forma — SHA-256(label || enc.nonce || <materiale KEM dello slot> || pub_R) — che porta la chiave effimera pub_epk da 32 byte sul percorso classico e il testo cifrato X-Wing kem_ct da 1120 byte sul percorso ibrido; || è concatenazione di byte, e ogni literale di prefisso del salt è ASCII esatto senza terminatore né prefisso di lunghezza. pub_R è la chiave wire canonica del destinatario (32 B per x25519, i 1216 B fissati per mlkem768x25519). Lo slot ibrido non porta un epk separato — l'effimera X25519 è costituita dagli ultimi 32 byte di kem_ct — e kem_ct è un'unica stringa di byte CBOR di esattamente 1120 byte: solo l'intero corpo del record è suddiviso in chunk per il trasporto, mai un singolo campo.

Il salt lega tre valori: il materiale KEM dello slot (KEK unica per slot), pub_R (sventando il relay confused-deputy contro un destinatario diverso) e enc.nonce (ancorando la KEK a un'unica busta, così che una casualità KEM ripetuta degradi soltanto a una correlabilità tra buste). Le etichette info distinte danno separazione di dominio tra KEM, così che nessuna KEK derivata sotto un KEM possa coincidere con una derivata sotto l'altro a parità di segreto condiviso. Usa ciascuna delle undici etichette interne byte per 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. Nessuna viene mai serializzata sul wire; sono costanti fisse, non selezionabili da registro. Un solo byte divergente produce uno slots_mac, un impegno o un tag AEAD che il produttore onesto non è in grado di riprodurre.

Mescola prima di calcolare il MAC. L'ordine di input ("prima il destinatario principale") è un metadato privilegiato; pubblicare gli slot nell'ordine di input lo fa trapelare. Mescola slots[] con un CSPRNG usando una permutazione di Fisher-Yates non distorta: una semplice estrazione di indice u32 % m tende a favorire i residui bassi e deve essere campionata per rifiuto fino a un indice uniforme, prima di calcolare il MAC dell'insieme di slot, che vincola l'ordine sul wire mescolato.

MAC dell'insieme di slot: comprimi la trascrizione con un hash, poi HMAC sotto la CEK

Il MAC dell'insieme di slot vincola l'intero insieme di slot, più i campi dell'intestazione che fissano il modo in cui gli slot vengono letti, alla CEK. Costruiscilo in due passi: comprimi con un hash una trascrizione chiusa una sola volta, poi calcola l'HMAC di quell'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

Tre cose fanno la differenza per la parità qui:

  • La trascrizione è una mappa chiusa serializzata da canonicalEncode. Il suo ordine delle chiavi è l'ordinamento di RFC 8949 §4.2.1, mai disposto a mano. Fissare scheme, path, aead, kem e nonce accanto agli slot significa che un relay che ribalti un qualsiasi campo dell'intestazione, anche lasciando valide le forme degli slot, cambia slots_hash e rompe il MAC.
  • La trascrizione vincola la rivendicazione dell'hash dell'elemento. hashes_hash è uno SHA-256 etichettato sul canonicalEncode della mappa hashes completa dell'elemento. Poiché il destinatario ricalcola slots_mac dai soli byte on-chain, una corrispondenza del MAC conferma che la busta è stata sigillata per questa esatta rivendicazione dell'hash: una busta innestata su un elemento con una mappa hashes diversa fallisce il passo di corrispondenza on-chain, prima di recuperare qualsiasi testo cifrato. Il valore slots è l'array mescolato di mappe di slot sul wire direttamente: ogni campo di slot è un'unica stringa di byte (epk 32 B, kem_ct 1120 B), perciò non c'è alcuna suddivisione in chunk per campo da canonicalizzare.
  • slots_hash viene calcolato una sola volta e mantenuto costante lungo il ciclo di decifratura per tentativi. Il controllo del MAC per slot ri-deriva la chiave HMAC da ogni CEK candidata ma sempre sullo stesso slots_hash di 32 byte. La compressione con pre-hash lascia intatto il commitment con chiave derivata dalla CEK: cambia il messaggio dell'HMAC dalla trascrizione completa al suo SHA-256, nulla di più.

L'algoritmo del MAC, la sua derivazione di chiave e lo schema della trascrizione sono tutti fissati da enc.scheme = 1 e identici per entrambi i KEM; non esiste alcun identificatore di MAC sul wire. slots_mac è esattamente di 32 byte ed è verificato a tempo costante.

Cifratura del contenuto: lo STREAM segmentato

Cifra il testo in chiaro una sola volta nello STREAM segmentato sotto una chiave del contenuto derivata dalla CEK. La chiave del contenuto è una foglia HKDF separata della CEK — salata da enc.nonce, sotto un info specifico del percorso — così il livello di avvolgimento e il livello del contenuto non usano mai la stessa primitiva sugli stessi byte:

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

L'AAD per chunk è vuoto, ed è corretto, non un'omissione: la chiave del contenuto deriva dalla CEK, e la CEK è già impegnata all'intera intestazione (compreso hashes_hash) da slots_mac. Ribalta un qualsiasi campo dell'intestazione e il destinatario deriva una chiave del contenuto diversa, perciò lo stream non si apre; un AAD per chunk rivincolerebbe lo stesso contesto su ogni chunk senza aggiungere sicurezza. I nonce a contatore sono sicuri perché la chiave del contenuto è monouso (una CEK fresca salata dal enc.nonce unico per busta), perciò due stream non condividono mai una coppia (key, nonce).

Costruisci lo STREAM così che la troncatura sia rilevabile: ogni chunk non finale è esattamente di 65536 byte di testo in chiaro, il chunk finale porta final_flag = 0x01 e da 0 a 65536 byte (un testo in chiaro vuoto è un solo chunk finale di lunghezza zero, un tag isolato di 16 byte), e un verificatore DEVE fallire (TAMPERED_CIPHERTEXT) su un flag finale mancante, un flag finale su un chunk non finale, dati dopo il chunk finale o un chunk non finale troppo corto. Verifica il tag di ogni chunk prima di rilasciarne il testo in chiaro, e tratta i byte rilasciati come provvisori finché non passa il ricontrollo dell'hash dopo la decifratura.

Il testo in chiaro è l'esatta sequenza di byte del contenuto originale; la costruzione non antepone, accoda né cifra alcun nome di file, tipo MIME, campo dimensione o wrapper di metadati. Il blob di testo cifrato pubblicato è i chunk dello STREAM (sul percorso passphrase, preceduti dall'intestazione di impegno da 32 byte più sotto). La mappa enc assemblata e l'URI risultante vanno sulla catena; i byte del testo cifrato no: pubblicali in uno store indirizzato per contenuto e metti l'URI ar:// o ipfs:// in uris[] dell'elemento.

Percorso passphrase

Quando non ci sono destinatari, deriva la CEK da una passphrase normalizzata con Argon2id. Non c'è epk, nessun avvolgimento per slot, nessun MAC dell'insieme di slot e nessun ciclo di decifratura per tentativi. L'impegno della chiave che slots_mac fornisce sul percorso slots risiede invece in un'intestazione di 32 byte all'interno del blob di testo cifrato, anteposta prima dei chunk dello 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

Il PASSPHRASE_TRANSCRIPT vincola i parametri della KDF, i campi dell'intestazione e la rivendicazione dell'hash dell'elemento nell'impegno: manomettere salt, un qualsiasi valore di params, nonce, aead, oppure innestare la busta su una rivendicazione dell'hash diversa, produce un pw_hash diverso e fa fallire il controllo dell'impegno. Il valore "normalization" è una costante fissata dallo scheme fornita alla trascrizione per fissare l'esatto profilo sotto cui è stata derivata la CEK; non viene mai serializzato sul wire (il produttore emette solo { alg, salt, params }).

Sul lato di verifica, deriva la CEK candidata, leggi i primi 32 byte del blob di testo cifrato, ricalcola l'impegno e confronta a tempo costante prima di aprire qualsiasi chunk dello STREAM. Un blob più corto di 48 byte (impegno da 32 byte + STREAM minimo da 16 byte) è malformato (TAMPERED_CIPHERTEXT). In caso di discordanza — passphrase errata, salt / params / intestazione manomessi, o una busta innestata — fai emergere lo stesso unico fallimento generico e non iniziare lo streaming; una passphrase errata è indistinguibile da un record manomesso. L'impegno è deliberatamente off-chain: un impegno on-chain sarebbe un oracolo di tentativi offline gratuito per ogni record con passphrase, compresi quelli il cui testo cifrato è trattenuto.

Imponi le soglie minime dei parametri: lunghezza del salt 16-64 byte; m ≥ 65536 KiB (≈ 64 MiB), t ≥ 3, p ≥ 1. Fissa la versione di Argon2 a 0x13 (19); nessun'altra versione è ammissibile sotto enc.scheme: 1, e non esiste alcun campo di versione sul wire. Dove la piattaforma lo consente, i produttori DOVREBBERO emettere p = 4 (il secondo profilo raccomandato dalla RFC 9106 §4); i verificatori POSSONO accettare qualsiasi p ≥ 1, fatti salvi i tetti del deployment. Argon2id attraversa pulitamente i confini tra ecosistemi: è il set di parametri, non la libreria, a essere il contratto, perciò un (m, t, p, salt, len, password) fisso deve produrre un output identico al byte in ogni implementazione. Il binding tra una passphrase e la sua busta è l'impegno nel testo cifrato qui sopra; una passphrase errata e un testo cifrato manomesso emergono entrambi come un unico fallimento generico.

Limita la passphrase grezza prima della normalizzazione e di Argon2id: rifiuta qualsiasi input più lungo della soglia di riferimento MAX_PASSPHRASE_INPUT_BYTES = 4096 byte UTF-8, così che una passphrase patologica non possa provocare un denial-of-service pre-KDF. Come le soglie del percorso a slot MAX_SLOTS e della busta decodificata, questa è una costante fissata dal deployment che PUOI restringere, non un campo del wire.

Il profilo di normalizzazione è normativo

Due implementazioni DEVONO derivare una CEK identica al byte dalla stessa passphrase, e l'unico modo per garantirlo è una normalizzazione fissata. Il profilo cardano-poe-pw-norm-v1, applicato in ordine:

  1. Rifiuta i codepoint non assegnati, una passphrase che contiene un qualsiasi codepoint non assegnato in Unicode 16.0 viene rifiutata (ENC_PASSPHRASE_UNNORMALIZABLE) prima che venga eseguita qualsiasi normalizzazione. Unicode garantisce la stabilità della normalizzazione solo sui codepoint assegnati, perciò questo chiude una falla di deriva futura ed è invisibile agli utenti onesti.
  2. NFKC, Normalization Form KC secondo UAX #15 sotto Unicode 16.0.
  3. Spazi bianchi, definisci "spazio bianco" come ogni carattere che porta la proprietà Unicode White_Space sotto Unicode 16.0; collassa ogni sequenza massimale in un singolo U+0020 SPACE.
  4. Trim, rimuovi gli spazi bianchi iniziali e finali.
  5. Rifiuta la stringa vuota, se il risultato è la stringa vuota, rifiuta (ENC_PASSPHRASE_EMPTY); una passphrase composta da soli spazi bianchi vincolerebbe altrimenti il record a una CEK che chiunque può derivare.
  6. Codifica, UTF-8; quei byte sono l'input password di Argon2id.

Fissa Unicode a 16.0 letteralmente e non lasciarlo fluttuare: l'insieme della proprietà White_Space, l'insieme dei codepoint assegnati e le tabelle di mappatura NFKC dipendono tutti dalla versione, perciò risolvere il profilo a fronte di una versione di Unicode diversa può derivare una CEK diversa dalla stessa passphrase e non riuscire ad aprire un record onesto. Una revisione futura che adotti una versione di Unicode più recente lo fa sotto un identificatore di profilo nuovo, mai reinterpretando cardano-poe-pw-norm-v1.

Decifratura per tentativi: apri ogni slot, incorpora il MAC, fallisci in modo generico

Un destinatario possiede una chiave privata KEM e scopre il proprio slot provando ad aprirne ciascuno: le chiavi pubbliche dei destinatari non sono sul wire. Prima di invocare qualsiasi primitiva KEM o AEAD, esegui le soglie di risorse, poi le protezioni strutturali. Limita per primo l'uso di risorse del parser: rifiuta una busta la cui dimensione decodificata superi i 65536 byte (ENC_ENVELOPE_TOO_LARGE) o i cui slots[] superino MAX_SLOTS = 1024 (ENC_SLOTS_TOO_MANY). Entrambe le soglie di riferimento stanno ben al di sopra del tetto di ~16 KiB dei metadati delle transazioni Cardano che vincola qualsiasi record onesto; sono costanti fissate dal deployment che PUOI restringere, mai campi del wire. Poi le protezioni strutturali: scheme == 1; aead, kem registrati; nonce di 24 byte; slots_mac di 32 byte; slots non vuoto; segreto del destinatario di 32 byte; ogni wrap di 48 byte; per KEM, ogni epk esattamente di 32 byte senza kem_ct (x25519) oppure ogni kem_ct esattamente di 1120 byte senza epk (mlkem768x25519).

Rifiuta qui i duplicati di encapsulation all'interno del record, prima di qualsiasi primitiva. Tutti i valori epk devono essere distinti sul percorso classico, tutti i valori kem_ct distinti sul percorso ibrido; un duplicato solleva ENC_SLOTS_DUPLICATE_KEM_MATERIAL. Questa è la parte verificabile dell'invariante di unicità della KEK per slot su cui si fonda l'avvolgimento a nonce azzerato; il riutilizzo tra record o tra chiavi è un obbligo del produttore che nessun verificatore può rilevare. Questo rifiuto scatta solo su un epk / kem_ct ripetuto: sigillare due volte verso lo stesso destinatario con effimere per slot fresche è legittimo e non lo fa scattare (vedi la regola sulle corrispondenze multiple più sotto). unwrap-negative porta il caso del epk duplicato con riutilizzo della KEK.

Poi esegui il ciclo, ricalcolando slots_hash una sola volta prima di esso e mantenendolo costante:

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

I punti inderogabili in questo ciclo:

  • Apri in modo atomico; non rilasciare mai testo in chiaro non verificato. Entrambe le primitive *_open_or_dummy sono atomiche: in caso di fallimento del tag AEAD non restituiscono alcun testo in chiaro, e il candidato restituito (la CEK avvolta, o il testo in chiaro del contenuto) è un valore fittizio fisso o pseudocasuale indipendente dal testo cifrato fallito. È questo che permette al ciclo di portare avanti una candidate_CEK oltre un'apertura del wrap fallita senza mai esporre byte non autenticati.
  • Incorpora il controllo del valore tutto a zero in un bit kem_ok indipendente dal segreto. Calcola kem_ok = NOT constantTimeEqual(shared, 0^32) per il percorso x25519, seleziona la KEK a tempo costante tra la KEK reale e una KEK fittizia derivata da 0^32 sotto lo stesso salt e info, e incorpora kem_ok nell'accettazione (ok = kem_ok AND open_ok AND mac_ok). Non uscire in anticipo a fronte di una quota non valida: uno slot con ECDH non valido non può mai essere accettato, e il ciclo esegue comunque lavoro identico. (XWing.Decapsulate non ha un caso tutto a zero, perciò kem_ok è fissato a vero sul percorso ibrido.)
  • Incorpora il controllo di slots_mac nel ciclo. Un mittente malevolo può fabbricare uno slot che si apre con la chiave del destinatario producendo una CEK scelta dall'attaccante (non serve conoscere alcuna chiave privata). Accettare il primo successo AEAD come "il nostro" lascerebbe che quello slot contraffatto oscuri uno onesto. Esigere che la CEK candidata riproduca anche slots_mac su slots_hash sventa la sostituzione, la rimozione e il riordino degli slot. Non saltarlo mai.
  • Ammetti corrispondenze multiple; rifiuta solo un conflitto di CEK. Una chiave del destinatario PUÒ legittimamente corrispondere a più di uno slot: sigillare la stessa CEK verso lo stesso destinatario in più slot, ciascuno con effimere fresche, è un valido riempimento del conteggio dei destinatari e non fa scattare il rifiuto del epk/kem_ct duplicato. Seleziona la CEK della prima corrispondenza e non rifiutare solo perché più di uno slot ha corrisposto. L'unica anomalia da rifiutare è costituita da due slot corrispondenti che recuperano CEK diverse (confronto a tempo costante): traccia un bit cek_conflict ed emetti l'unico fallimento generico se è impostato. Questa è difesa in profondità: sotto il commitment dell'insieme di slot una corrispondenza con CEK distinta è già infattibile, perciò fallisce in modo chiuso contro un'implementazione difettosa.
  • Itera tutti gli slot nel passaggio di una singola chiave privata, un numero costante di operazioni per slot per ogni chiave, senza uscita anticipata, così che un osservatore dei tempi non possa dedurre quale slot abbia corrisposto. Guida il rifiuto del valore tutto a zero attraverso kem_ok e lavoro fittizio anziché uscire in anticipo. Un destinatario con più chiavi itera chiave × slot e PUÒ interrompere in anticipo tra una chiave e l'altra (facendo trapelare solo il debole segnale di "quale chiave ha corrisposto"), ma deve restare a tempo costante sugli slot di ciascuna singola chiave, e deve riderivare la metà pub_R del salt per ogni chiave, dato che entrambi i KEM legano la chiave pubblica del destinatario nel salt della KEK. Lega quel salt alla codifica wire canonica della chiave, esattamente la chiave pubblica X25519 da 32 byte, oppure esattamente i byte della chiave pubblica X-Wing fissati da 1216 byte, mai una ricodifica non canonica, altrimenti i due lati derivano KEK diverse.
  • Esponi un'unica forma di fallimento generico ai chiamanti non fidati. Internamente puoi tracciare esiti tipizzati per la diagnostica locale, WRONG_RECIPIENT_KEY (nessuno slot si è aperto), TAMPERED_HEADER (uno slot si è aperto ma nessuna CEK candidata ha riprodotto slots_mac), TAMPERED_CIPHERTEXT (l'AEAD del contenuto è fallito dopo che una CEK è stata recuperata e il MAC verificato), ma un osservatore esterno NON DEVE distinguerli per forma della risposta. Sui tempi, il modello è deliberatamente circoscritto: un verificatore PUÒ ritornare al controllo if NOT found prima della decifratura del contenuto, il che separa un non destinatario da un destinatario il cui testo cifrato non si apre. Ciò rivela soltanto destinatario contro non destinatario, mai quale slot né alcun materiale di chiave; tempi uniformi tra quei due casi non sono richiesti e un'apertura fittizia del contenuto NON DEVE essere imposta. La garanzia a tempo costante che vale è l'invariante tra gli slot sopra.
  • Ricalcola e confronta l'hash del testo in chiaro dopo la decifratura. La mappa hashes on-chain si impegna sul testo in chiaro, non sul testo cifrato, perciò il destinatario (a livello applicativo) deve ricalcolare il digest e confrontarlo: la voce sha2-256 deve corrispondere, e blake2b-256 se presente. Una mancata corrispondenza significa che la rivendicazione di hash del record non coincide con i byte decifrati: rifiutati di agire sul testo in chiaro. Il validatore strutturale non decifra mai.

Limita il payload su entrambi i lati

Lo STREAM segmentato non impone alcun tetto crittografico al payload: il contatore per chunk a 88 bit ammette 2^88 chunk, e ogni chunk è sigillato sotto una coppia (content_key, nonce) distinta ben entro il limite a invocazione singola di RFC 8439, perciò non c'è alcun rischio di overflow del contatore da cui difendersi. Il massimo che un produttore o un verificatore impone è quindi una policy contro il denial-of-service del deployment, non una costante del wire: imponilo in modo incrementale man mano che lo stream viene scritto o letto, e interrompi prima di bufferizzare un payload sovradimensionato. La troncatura viene colta strutturalmente dal flag finale anziché da un tetto sulla dimensione. Lo stesso comportamento si applica sia sul percorso slots sia sul percorso passphrase.

Fixture di conformità della PoE sigillata

L'angolo della PoE sigillata nel corpus è dove emergono la maggior parte dei bug tra linguaggi. Fai passare la tua implementazione attraverso tutto. Le fixture positive fissano l'avvolgimento deterministico e il ciclo di decifratura per tentativi per entrambi i KEM — singolo e multi-destinatario, N misti e il caso peggiore a più chiavi private — più il caso legittimo di un destinatario che corrisponde a due slot (effimere fresche, stessa CEK, DEVE decifrare, così che un'implementazione che rifiuta le corrispondenze multiple fallisca qui) e il percorso passphrase (intestazione di impegno più i chunk dello STREAM in un unico blob). Un insieme dedicato alla disposizione STREAM fissa un testo in chiaro vuoto (un solo chunk finale di lunghezza zero), un payload a chunk singolo e un payload a più chunk che attraversa il confine dei 65536 byte. KAT mirati fissano entrambi i salt del KEK (SHA-256(label ‖ enc.nonce ‖ <materiale KEM> ‖ pub_R)), hashes_hash e la sua collocazione in entrambe le trascrizioni, l'encapsulation X-Wing a fronte di draft-10, l'estrazione HKDF con salt di lunghezza zero (la convenzione del salt assente della RFC 5869 §2.2, che rispecchia la derivazione della chiave di slots_mac), le codifiche Bech32 del destinatario e del segreto, e la codifica con checksum del seed d'identità.

Le fixture negative fissano i codici di rifiuto: uno slot ombra contraffatto prima di uno slot onesto (il record DEVE comunque decifrarsi sotto la CEK onesta); un ribaltamento dell'intestazione (kem/aead/scheme) che lascia valide le forme degli slot; un innesto di hashes su un elemento con una rivendicazione dell'hash diversa; i fallimenti dell'impegno con passphrase (passphrase errata, salt/params manomessi, intestazione manomessa, tutti che falliscono prima che si apra qualsiasi chunk); i rifiuti di normalizzazione della passphrase (un input con un codepoint non assegnato e un input di soli spazi bianchi); il segreto condiviso X25519 tutto a zero; lo slot duplicato all'interno del record; e i casi di manomissione dello STREAM (tag di chunk ribaltato, stream troncato, dati in coda, chunk non finale troppo corto). Due proprietà non hanno alcun vettore di byte e sono asserite comportamentalmente: il rifiuto del conflitto di CEK (costruirne uno è esattamente la collisione del commitment multi-chiave che lo standard assume infattibile) e la garanzia a tempo costante tra gli slot. Riproduci ogni stringa di byte fissata ed emetti il codice esatto per ogni caso negativo.

Una proprietà della PoE sigillata non ha alcun vettore di byte: il rifiuto del conflitto di CEK, due slot corrispondenti che recuperano CEK diverse, non può essere costruito come fixture, perché costruirne uno è esattamente la collisione del commitment multi-chiave che lo standard assume infattibile. Fissalo invece con un test comportamentale a livello di implementazione che asserisca che il tuo ciclo di decifratura per tentativi fallisce in modo chiuso a fronte di un conflitto forzato, allo stesso modo in cui la proprietà a tempo costante tra gli slot è asserita comportamentalmente anziché come stringa di byte.

Conformità e vettori di test

I vettori di test normativi sono il contratto di interoperabilità. Un'implementazione è conforme se e solo se riproduce ogni stringa di byte fissata nella suite di conformità a partire dagli stessi input, ed emette il corretto codice di errore tipizzato per ogni fixture negativa. Non c'è credito parziale né appello: se un confronto fallisce, l'implementazione è sbagliata, mai il vettore.

I vettori risiedono nella suite di conformità dello standard, organizzati per classe di primitiva: fixture di record, wrap/unwrap della PoE sigillata, firme COSE_Sign1, HKDF, derivazione del seed, Argon2id e CBOR canonico. Ciascuno fissa input in hex minuscolo e gli output attesi. Per usarli: dai gli input in pasto alla tua implementazione, confronta byte per byte ogni output indicato e correggi il codice a ogni discrepanza.

Tre obblighi che ogni implementazione deve soddisfare

Riprodurre i vettori positivi. Per ogni fixture di record devono valere entrambe le metà di encode(record) == expected_cbor E il round-trip encode(decode(expected_cbor)) == expected_cbor. Il round-trip si generalizza oltre le fixture: per qualsiasi input ben formato, encode(decode(x)) == x. Un decodificatore che perde o riordina informazioni, o un codificatore che non è canonico, infrange tutto questo e non supera la conformità.

Emettere i codici di rifiuto corretti. Le fixture negative abbinano un record deliberatamente malformato all'esatto codice di errore tipizzato che un validatore strutturale DEVE sollevare. Riprodurre i byte dei record validi è metà del contratto; rifiutare quelli non validi con il codice corretto è l'altra metà. Un validatore che rifiuta un record non valido per la ragione sbagliata, o che lo accetta, non è conforme. Le fixture negative sono l'unica fonte di verità per la parità di rifiuto tra i linguaggi: lo stesso input malformato DEVE sollevare lo stesso codice in ogni implementazione. Il catalogo completo dei codici e del loro significato si trova in Verifica.

Coincidere con i registri. Gli identificatori di algoritmo sono stringhe con un nome, attinte dai registri in Registri degli algoritmi. Un identificatore non riconosciuto DEVE far emergere il preciso codice di algoritmo non supportato, mai un'accettazione silenziosa o un panic.

Correggi l'implementazione, mai il vettore

I vettori sono ancorati alle RFC a monte e alle costruzioni deterministiche di questo standard. Quando un confronto fallisce, il difetto è nell'implementazione sotto esame. Modificare un vettore per far passare una suite trasforma un reale guasto di interoperabilità in uno latente, che si manifesta solo quando un record attraversa più implementazioni on-chain (sulla blockchain), cioè nel momento peggiore in cui scoprirlo.

Esegui la parità a ogni modifica

Un'implementazione che rilascia più di un linguaggio, o che vuole dimostrare l'interoperabilità con un'altra, DOVREBBE eseguire un unico job di integrazione continua che costruisce ogni pacchetto, esegue la suite di test di ciascun linguaggio contro le fixture condivise, impone il lint del grafo delle dipendenze e verifica che l'insieme di fixture sia identico su entrambi i lati. Una fixture aggiunta da un lato ma non dall'altro fa fallire il controllo: le due implementazioni hanno divergito silenziosamente, e la build lo coglie prima che lo faccia un record reale. Le fixture sono la fonte canonica; ciascun linguaggio ne conserva un mirror identico al byte, e il controllo verifica che il mirror sia completo ed esatto.

Convenzioni di denominazione e di formato

Alcune convenzioni mantengono leggibile un'implementazione e stabile il formato di interscambio:

  • I nomi dei campi sul wire sono in snake_case: leaf_count, cose_sign1, slots_mac. Vale per tutti i linguaggi: anche dove un linguaggio usa per consuetudine il camelCase per la propria API in memoria, il record codificato usa chiavi in snake_case, perché le chiavi fanno parte dei byte canonici che una firma copre.
  • Gli identificatori sono stringhe di registro, non enum incise nel codice. Hash, AEAD, KEM, KDF e firme fanno tutti riferimento a identificatori con un nome; aggiungere un algoritmo (un KEM post-quantistico, ad esempio) è una voce di registro aggiuntiva, mai una rottura del formato di interscambio.
  • I nomi dei metodi rispecchiano la semantica tra i linguaggi. Una funzione in un linguaggio ha una controparte dallo stesso nome in un altro (encode_canonical_cborencodeCanonicalCbor), così chi padroneggia l'uno o l'altro può mappare una superficie sull'altra e ragionare sulla parità a colpo d'occhio.
  • Avvia prima i livelli crittografici. Metti in piedi il nucleo crittografico e la libreria del formato di interscambio contro i vettori e porta al verde il controllo di parità prima di scrivere una riga di codice applicativo. Il verificatore in autonomia è la più piccola superficie vicina all'applicazione ed è la cosa successiva da costruire; tutto il resto poggia su un livello crittografico che hai già dimostrato corretto.

Pagine correlate

  • Il record: il formato di interscambio che il validatore e il codificatore implementano.
  • PoE sigillata: il riferimento di costruzione dietro le ricette di build qui.
  • Registri degli algoritmi: gli identificatori con un nome che un'implementazione risolve.
  • Verifica: la pipeline di validazione, il verificatore in autonomia e il catalogo dei codici di errore.