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

Sealed PoE

La busta di cifratura di Label 309. Come un mittente sigilla un contenuto verso una o più chiavi di destinatario, mentre la blockchain trasporta soltanto l'hash del testo in chiaro e gli slot di chiave incapsulati, mai il testo in chiaro e mai i destinatari.

Una sealed PoE ancora un impegno datato su un testo in chiaro, mantenendolo leggibile solo da un pubblico scelto. Il record on-chain (sulla blockchain) trasporta l'hash del testo in chiaro, la prova del momento temporale esattamente come per qualsiasi altro record, più una busta di cifratura (enc) che contiene il materiale necessario a recuperare la chiave di cifratura del contenuto. Il testo cifrato non tocca mai la blockchain: risiede a un URI indirizzato per contenuto (ar:// o ipfs://). Nulla sulla blockchain rivela il testo in chiaro, e nulla rivela chi siano i destinatari.

Questa pagina specifica la busta enc: i suoi due percorsi mutuamente esclusivi per la consegna della chiave, gli slot di chiave per destinatario, il MAC dell'insieme degli slot, lo STREAM segmentato del contenuto e la decifratura per tentativi che un destinatario esegue per scoprire e aprire un messaggio indirizzato a sé. Le chiavi dei destinatari, ossia le coppie di chiavi X25519 e X-Wing derivate dal seed, sono definite in Chiavi: questa pagina le utilizza. La collocazione della mappa enc all'interno della mappa del record, e il trasporto dell'intero corpo che la porta on-chain, sono definiti in Il record.

Non è HPKE

Questo non è HPKE come da RFC 9180. È un design multi-destinatario in stile age, KEM seguito da incapsulamento: encapsulation per ciascun destinatario, una chiave di cifratura della chiave derivata con HKDF e una chiave di cifratura del contenuto incapsulata tramite AEAD, con il pattern a stanza di age v1 trasposto in CBOR canonico. Non ha né suite_id né la cascata LabeledExtract/LabeledExpand; va valutato a fronte della letteratura su ECIES e della specifica age v1, non a fronte dell'analisi di HPKE.

Il modello e le sue proprietà di riservatezza

Un mittente vuole pubblicare un impegno permanente e datato che dimostri come uno specifico testo in chiaro sia stato sigillato per un pubblico specifico all'istante T, garantendo al tempo stesso che solo quel pubblico possa leggerlo. Una PoE di solo hash fornisce la rivendicazione temporale ma nessun vincolo verso il pubblico; una PoE su testo cifrato in chiaro non offre alcuna riservatezza. La sealed PoE unisce i due aspetti: il record si impegna sull'hash del testo in chiaro (pubblico, datato) e trasporta in enc il materiale per la consegna della chiave, mentre il testo cifrato all'URI ar:// o ipfs:// è indecifrabile senza un segreto di sblocco corrispondente.

La costruzione è progettata di proposito affinché la blockchain riveli il meno possibile sul messaggio e nulla sul suo pubblico:

  • Il testo in chiaro non è mai on-chain. Lo sono solo il suo hash e le chiavi incapsulate. Chiunque ottenga in seguito il testo in chiaro può dimostrare che "questo identico testo in chiaro è stato impegnato al block time (l'orario del blocco) T"; nessun altro scopre cosa è stato sigillato.
  • Le chiavi pubbliche dei destinatari non sono mai on-chain. La chiave pubblica di un destinatario non compare da nessuna parte in enc. Un destinatario riconosce un messaggio come proprio solo tentando con successo la decifratura di uno slot: non c'è alcun campo destinatario da leggere. Un osservatore privo di chiavi candidate apprende soltanto il numero di slot, la famiglia di KEM (enc.kem) e la distinzione tra sigillato e aperto. La proprietà più forte, ovvero che un avversario che possiede chiavi pubbliche candidate dei destinatari non possa comunque verificare quale slot (se mai uno) prenda di mira, è la riservatezza sulla chiave (key-privacy), rivendicata solo per il percorso classico x25519; non è rivendicata per il percorso ibrido mlkem768x25519 (vedi Anonimato e la distinzione per KEM).
  • I destinatari non scoprono nulla l'uno dell'altro. Ogni slot per destinatario è una chiave incapsulata opaca. Un destinatario che apre il proprio slot non può derivare la chiave di alcun altro destinatario, né può sapere a chi altro fosse indirizzato il messaggio.
  • L'ordine degli slot non rivela nulla. L'ordine in cui un mittente elenca i destinatari (ad esempio "il principale per primo") è un metadato privilegiato. L'array degli slot viene mescolato con un CSPRNG prima della pubblicazione, in modo che nemmeno l'ordine posizionale trasmetta alcun segnale.
  • Una sealed PoE non firmata preserva l'anonimato del mittente. Le firme di paternità sono opzionali (vedi Firme). Un record sigillato privo di sigs[] non vincola alcuna identità del mittente sulla blockchain, esattamente ciò che richiedono le segnalazioni di whistleblower, le aste a busta chiusa e la custodia di prove.

Ciò che la blockchain rivela davvero è circoscritto: che un record è una sealed PoE (enc è presente), l'hash del testo in chiaro, il timestamp del blocco e il numero di slot (la lunghezza dell'array). Il numero è l'unico fatto vicino ai destinatari che venga esposto, e rivela soltanto "quanti", mai "chi". La correlazione temporale tra record è un problema di metadati che la crittografia a livello di wire non può risolvere; i mittenti che hanno bisogno di neutralizzarla devono accorpare le pubblicazioni al di fuori della linea temporale sensibile.

Le chiavi pubbliche dei destinatari si scambiano fuori banda. Label 309 non prescrive alcun meccanismo di scoperta: un destinatario può pubblicare la propria chiave sul proprio sito web, in un record DNS, su un profilo social, in un codice QR o in un'autocertificazione on-chain. Un verificatore prende in input i byte della chiave del destinatario e non avanza alcuna pretesa su chi ne sia il titolare: la provenienza è una decisione di fiducia del mittente, esattamente come quando si invia per email una chiave PGP.

La busta e i suoi due percorsi

La mappa enc trasporta campi comuni più esattamente uno di due percorsi mutuamente esclusivi per la consegna della chiave. Un validatore strutturale fa rispettare questa esclusività; un record che li trasporta entrambi, o nessuno dei due, viene rifiutato.

CampoStatoSignificato
schemeOBBLIGATORIOVersione della famiglia di costruzione. La v1 definisce scheme = 1.
aeadOBBLIGATORIOIdentificatore del formato di contenuto. La v1 definisce "chacha20-poly1305-stream64k".
nonceOBBLIGATORIO24 byte casuali, il salt unico per busta della chiave del contenuto e di ogni KEK di slot.
kemsolo percorso slotsSelettore del KEM per slot ("x25519" o "mlkem768x25519").
slotsuno dei due percorsiArray di slot di chiave per destinatario (multi-destinatario).
slots_macsolo percorso slotsHMAC di 32 byte che vincola l'insieme degli slot e la rivendicazione dell'hash dell'elemento alla chiave del contenuto.
passphrasel'altro percorsoBlocco passphrase-KDF (chiave derivata da passphrase).
  • enc.slots, multi-destinatario. La busta trasporta N slot di chiave incapsulati in modo indipendente, uno per destinatario. Il testo cifrato è indecifrabile senza una chiave privata corrispondente a uno degli slot. Specificato in Slot e MAC dell'insieme degli slot qui sotto.
  • enc.passphrase, derivata da passphrase. La busta non trasporta slot; la chiave del contenuto è derivata direttamente da una passphrase normalizzata. Specificato in Percorso passphrase qui sotto.

Entrambi i percorsi condividono scheme, aead e nonce. Differiscono per quale chiave è presente e, di conseguenza, per dove risiede l'impegno della chiave. Nel percorso slots l'impegno è on-chain: slots_mac è un HMAC con chiave derivata dalla CEK su una trascrizione che fissa i campi dell'intestazione, l'insieme di slot e la rivendicazione dell'hash dell'elemento, perciò un destinatario conferma la chiave giusta prima di recuperare alcunché. Nel percorso passphrase non ci sono slot da vincolare, perciò l'impegno è un'intestazione di 32 byte trasportata all'interno del blob di testo cifrato: testare un tentativo di passphrase richiede il blob stesso, mai la sola blockchain pubblica. Ogni percorso serializza la propria trascrizione con la stessa funzione canonicalEncode, e un produttore o un verificatore seleziona il percorso ispezionando quale tra slots e passphrase sia presente. I due percorsi sono esaustivi e mutuamente esclusivi.

enc.scheme nomina la famiglia di costruzione, in modo indipendente dal campo v del record. Un verificatore DEVE esigere enc.scheme === 1 e rifiutare qualsiasi altro valore. Il campo è riservato a un futuro cambiamento trasversale (una diversa pianificazione del MAC dell'insieme degli slot o un diverso formato di contenuto), non all'aggiunta di un KEM: il KEM per slot è selezionato da enc.kem, ed entrambi i KEM qui sotto vivono sotto scheme = 1 fin dalla prima release. Più in generale, enc.scheme: 1 identifica l'intera suite crittografica, non soltanto il MAC e il formato di contenuto: le regole di canonicalEncode, lo schema degli slot, l'hash di HKDF, l'hash di HMAC, l'AEAD di avvolgimento per slot, il formato di contenuto a STREAM segmentato, gli schemi di trascrizione di slot e passphrase (compreso il vincolo hashes_hash dell'elemento), l'impegno della passphrase nel testo cifrato, la revisione di X-Wing fissata, le label di separazione di dominio, la versione e il profilo di Argon2id e il profilo di normalizzazione della passphrase sono tutti fissati da esso, perciò cambiare uno qualsiasi di essi richiede un nuovo valore di enc.scheme.

Il livello del contenuto

Entrambi i percorsi convergono su un unico passaggio simmetrico sul testo in chiaro, con chiave derivata da un'unica chiave di cifratura del contenuto (CEK) di 32 byte. La CEK è ciò che gli slot consegnano (ogni slot la incapsula) oppure ciò che la KDF della passphrase produce; il contenuto non viene cifrato direttamente sotto la CEK. Ogni percorso deriva invece una chiave del contenuto separata di 32 byte come foglia HKDF della CEK, salata dal enc.nonce unico per busta e sotto un info specifico del percorso, così che il livello di consegna della chiave e il livello del contenuto non usino mai la stessa primitiva sugli stessi byte:

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

Il contenuto viene poi sigillato in uno STREAM segmentato, nominato dall'identificatore di formato di contenuto chacha20-poly1305-stream64k. È il layout STREAM della specifica age v1: ChaCha20-Poly1305 (RFC 8439, la variante a nonce da 12 byte) sul testo in chiaro suddiviso in chunk di dimensione fissa, ciascuno sigillato sotto la chiave del contenuto con un nonce a contatore per chunk:

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

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

Il flag finale separa per dominio l'ultimo chunk dagli altri, ed è ciò che rende rilevabile la troncatura: uno stream il cui ultimo chunk non porta il flag 0x01, un flag 0x01 su un chunk che non è l'ultimo, dati che seguono il chunk finale, o un chunk non finale più corto di CHUNK_SIZE DEVONO tutti far fallire la decifratura (TAMPERED_CIPHERTEXT). Poiché ogni chunk sigillato è almeno il suo tag da 16 byte, il layout implica anche una soglia minima strutturale: un blob di testo cifrato ben formato del percorso slots non è mai più corto di 16 byte, il solo tag di un chunk finale vuoto.

L'AAD per chunk è vuoto per scelta progettuale: tutto il contesto è legato al contenuto in modo transitivo. La chiave del contenuto deriva dalla CEK, e la CEK è impegnata all'intera intestazione da slots_mac sul percorso slots (la cui trascrizione copre scheme, path, aead, kem, nonce, l'insieme di slot e la rivendicazione dell'hash dell'elemento) o dall'impegno nel testo cifrato sul percorso passphrase. Ribalta un qualsiasi campo dell'intestazione e il destinatario deriva o accetta una chiave diversa, perciò la decifratura fallisce; un AAD per chunk rivincolerebbe lo stesso contesto su ogni chunk senza aggiungere sicurezza.

I nonce a contatore dei chunk sono sicuri perché la chiave del contenuto è monouso: deriva da una CEK fresca salata dal enc.nonce unico per busta, perciò due stream non condividono mai una coppia (key, nonce) e i produttori senza stato — schede del browser, esecuzioni della CLI, worker, tentativi ripetuti — non coordinano mai i nonce tra le buste. Il contatore a 88 bit ammette 2^88 chunk, ben oltre qualsiasi payload realizzabile, perciò il formato non impone alcun tetto crittografico al payload; un massimo pratico è una policy contro il denial-of-service del deployment, non una costante del wire.

L'input 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 manifesto: lo stream si decifra restituendo quei byte e soltanto quelli.

I chunk rilasciati sono provvisori fino al ricontrollo dell'hash

Il formato segmentato esiste affinché un verificatore possa autenticare e rilasciare un payload di più GiB in modo incrementale con memoria limitata. Il tag di ogni chunk è verificato prima che il testo in chiaro di quel chunk venga rilasciato, e la troncatura è colta dal flag finale, ma il ricontrollo dell'hash del testo in chiaro viene eseguito sull'intero testo in chiaro, dopo l'ultimo chunk. Un consumatore in streaming DEVE perciò trattare i byte rilasciati come provvisori — nessun effetto collaterale, nessun riscontro, nessuno stato "ricevuto" — finché quel controllo finale non passa.

Il testo cifrato pubblicato è un unico oggetto. Sul percorso slots è esattamente i chunk dello STREAM; sul percorso passphrase un'intestazione di impegno della chiave da 32 byte viene anteposta all'interno dello stesso blob (stesso oggetto, stesso URI, stesso recupero, mai un secondo oggetto memorizzato):

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

L'hash del testo in chiaro in items[].hashes si impegna sempre sul testo in chiaro, anche quando enc è presente. Questa è la proprietà portante: un verificatore che non può decifrare può comunque confermare che il record esiste, che la sua busta è ben formata e che l'URI è recuperabile, ma solo chi possiede una chiave di destinatario corrispondente può decifrare il testo cifrato e confermare a cosa si riferisca l'impegno ricalcolando l'hash. Il validatore pertanto NON DEVE decifrare per "verificare" gli hash; la verifica dell'hash del testo in chiaro avviene presso il destinatario, dopo che i byte sono stati recuperati. Vedi Contenuto e hashing e Verifica.

Slot e MAC dell'insieme degli slot

Nel percorso multi-destinatario, enc.slots è un array non vuoto di slot per destinatario. Ogni slot incapsula la stessa CEK sotto una chiave di cifratura della chiave (KEK) specifica per destinatario; un destinatario che apre un qualsiasi slot recupera l'unica CEK che decifra il contenuto. Il mittente:

  1. Seleziona un solo KEM per l'intero record e genera la CEK (32 byte casuali) e il nonce (24 byte casuali).
  2. Per ciascun destinatario, deriva una KEK per slot e vi incapsula la CEK (i dettagli per KEM più sotto).
  3. Mescola l'array degli slot con un CSPRNG (Fisher-Yates non distorto).
  4. Costruisce la trascrizione degli slot sull'array mescolato, sui campi dell'intestazione comuni ai KEM e sulla rivendicazione dell'hash dell'elemento, ne calcola l'hash in slots_hash e calcola slots_mac come HMAC con chiave derivata dalla CEK su quell'hash.
  5. Deriva la chiave del contenuto dalla CEK e da enc.nonce, e sigilla il contenuto nello STREAM segmentato qui sopra.

L'incapsulamento per slot

Ogni slot incapsula la CEK con ChaCha20-Poly1305 (RFC 8439, la variante con nonce di 12 byte) sotto la KEK dello slot, producendo un wrap di 48 byte (32 byte di testo cifrato della CEK + 16 byte di tag Poly1305):

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

Il nonce di 12 byte tutto a zero è sicuro proprio perché la KEK di ciascuno slot è unica per record: una KEK viene quindi usata per esattamente un incapsulamento, così il nonce non può mai entrare in collisione sotto una singola chiave. Questo è un invariante rigido: se una qualsiasi revisione consentisse mai di riutilizzare una KEK (caching, effimere deterministiche, deduplicazione dei destinatari che riusa uno slot), nello stesso cambiamento il nonce a zero dovrebbe essere sostituito con uno casuale.

Il MAC dell'insieme degli slot

slots_mac vincola l'intero insieme degli slot, insieme ai campi dell'intestazione comuni ai KEM che fissano il modo in cui gli slot vengono interpretati, e alla rivendicazione dell'hash del testo in chiaro dell'elemento, alla CEK, neutralizzando le manomissioni per sostituzione, rimozione e riordino degli slot e per innesto della busta. Il binding è una costruzione a due passi: una trascrizione degli slot viene prima ridotta con un hash a un slots_hash di 32 byte, e quell'hash è il messaggio di un HMAC con chiave derivata dalla CEK.

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

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

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

SLOTS_TRANSCRIPT è una mappa chiusa che porta esattamente quell'insieme di sette chiavi, serializzata con canonicalEncode così che entrambi i lati producano byte identici; il suo ordine delle chiavi è l'ordinamento byte per byte di RFC 8949 §4.2.1, mai disposto a mano. Il valore slots è l'array mescolato di mappe di slot chiuse esattamente come compaiono sul wire ({epk, wrap} per x25519, {kem_ct, wrap} per mlkem768x25519), così l'intero contenuto sul wire per slot di ogni slot è dentro la trascrizione. La trascrizione fissa inoltre scheme, path, aead, kem e nonce: un relay che ribalti uno qualsiasi di quei campi dell'intestazione lasciando valide le forme degli slot produce un slots_hash diverso, perciò il MAC fallisce. I prefissi SHA-256 di slots_hash e hashes_hash (cardano-poe-slots-transcript-v1, cardano-poe-item-hashes-v1) sono ASCII esatto senza terminatore né prefisso di lunghezza.

hashes_hash è ciò che vincola la busta alla rivendicazione dell'hash di questo elemento: è 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: una busta innestata su un elemento con una mappa hashes diversa fallisce il passo di corrispondenza on-chain, prima di recuperare qualsiasi testo cifrato. Gli uris[] dell'elemento sono deliberatamente non vincolati, così che il testo cifrato possa essere riospitato a un nuovo URI indirizzato per contenuto senza invalidare la busta; un mittente per cui l'elenco degli URI fa parte della rivendicazione lo vincola invece con una firma a livello di record.

Nella derivazione di HMAC_KEY, salt = "" è una stringa di byte di lunghezza zero, la convenzione del salt assente della RFC 5869 §2.2 (HKDF-Extract sostituisce HashLen byte a zero, 32 per SHA-256). È fissata da un vettore di conformità byte per byte anziché lasciata a un default di libreria, così che un'implementazione che gestisce male il salt assente fallisca il vettore invece di derivare in silenzio una chiave diversa.

slots_hash viene calcolato una sola volta per record ed è costante lungo il ciclo di decifratura per tentativi del destinatario: 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 proprietà di commitment è preservata perché la chiave HMAC resta HKDF-SHA-256(CEK, …): comprimere con un pre-hash la trascrizione cambia soltanto il messaggio dell'HMAC, dalla trascrizione completa al suo SHA-256, lasciando intatto il binding con chiave derivata dalla CEK.

Il MAC dell'insieme degli slot è fissato da enc.scheme: non esiste alcun identificatore sul wire, per ciascun valore di scheme esiste esattamente una costruzione, ed è identico per entrambi i KEM. slots_mac DEVE essere esattamente di 32 byte (ENC_SLOTS_MAC_INVALID_LENGTH in caso di lunghezza errata) e DEVE essere verificato a tempo costante.

La trascrizione dipende direttamente dai byte sul wire di ciascuno slot. Entrambi i campi di slot sono singole stringhe di byte CBOR — epk è di 32 byte, kem_ct è di 1120 byte — perciò non c'è alcuna suddivisione in chunk per campo da normalizzare né alcuna ambiguità sui confini dei chunk: l'unica suddivisione in chunk che Label 309 esegue è la suddivisione di trasporto dell'intero corpo su Il record, disfatta prima che tutto questo venga eseguito. Una inversione di byte ovunque in uno slot cambia slots_hash e fa fallire il MAC.

Il livello del contenuto non ha bisogno di alcun binding separato per passaggio all'insieme degli slot: la chiave del contenuto è una foglia HKDF della CEK, e la CEK è già impegnata all'intera intestazione — compreso hashes_hash — da slots_mac. Modificare un qualsiasi slot o campo dell'intestazione cambia ciò che il destinatario deriva, perciò lo stream del contenuto semplicemente non si apre. L'AAD per chunk è pertanto vuoto (vedi Il livello del contenuto).

I due KEM

Il KEM, selezionato per ciascun record da enc.kem, fissa la forma dello slot e la derivazione della KEK. Entrambi sono registrati sotto enc.scheme = 1 fin dalla prima release.

enc.kemKEMChiave pubblica del destinatarioForma dello slotStringa info della KEK
"x25519"X25519 (classico)32 byte{ epk: bstr(32), wrap: bstr(48) }"cardano-poe-kek-v1"
"mlkem768x25519"X-Wing = X25519 + ML-KEM-7681216 byte{ kem_ct: bstr(1120), wrap: bstr(48) }"cardano-poe-kek-mlkem768x25519-v1"

I produttori DOVREBBERO usare mlkem768x25519 come predefinito. Il KEM ibrido è sicuro sia contro avversari classici sia contro avversari quantistici che applicano la strategia harvest-now-decrypt-later, mantenendo al contempo la sicurezza classica di X25519 come soglia minima: il combiner X-Wing lega entrambi i segreti condivisi. Quella soglia minima «mai al di sotto della sicurezza classica di X25519» è circoscritta alle chiavi del destinatario generate validamente: presuppone che la chiave pubblica superi il controllo di validità della chiave della revisione di X-Wing fissata (applicato all'encapsulation, vedi Ibrido: mlkem768x25519 più sotto). Il KEM classico x25519 resta disponibile per i destinatari la cui chiave pubblicata è solo X25519. L'identificatore mlkem768x25519 è scritto di proposito senza trattini, in linea con la grafia dell'ecosistema X-Wing/age.

Entrambi i KEM usano lo stesso pattern a stanza di age, ovvero materiale KEM per destinatario più un avvolgimento simmetrico della chiave del file, e lo stesso binding dell'intestazione (il MAC dell'insieme degli slot), così un'unica costruzione uniforme copre entrambi senza alcuna dipendenza da HPKE. Il percorso classico x25519 rispecchia da vicino il destinatario X25519 nativo di age. Il percorso ibrido mlkem768x25519 diverge di proposito dalla scelta post-quantistica di age stessa: age v1.3.0 mette in campo destinatari post-quantistici nativi (prefisso visibile age1pq…) che avvolgono la chiave del file tramite HPKE SealBase (RFC 9180) su un KEM ML-KEM-768 + X25519, non il pattern a stanza. Mantenere l'avvolgimento a stanza per il percorso ibrido è ciò che permette a un unico avvolgimento uniforme e a un unico binding dell'intestazione uniforme di coprire entrambi i KEM. L'avvolgimento ibrido quindi non eredita la costruzione HPKE di age, e per esso non viene avanzata alcuna pretesa di eredità da age; la codifica distinta del destinatario age1pqc (vedi Chiavi) riflette il fatto che le due codifiche ibride sono indipendenti.

Classico: x25519

Per ciascun destinatario il mittente genera una nuova coppia di chiavi effimere X25519, esegue un ECDH contro la chiave pubblica del destinatario e deriva la KEK con HKDF (RFC 5869) sotto un salt a hash etichettato:

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

La chiave pubblica effimera epk di 32 byte è l'unico materiale di chiave sul wire; la chiave pubblica del destinatario non viene mai pubblicata. Il salt è uno SHA-256 etichettato che lega tre valori: pub_epk mantiene unica la KEK di ogni slot, pub_R la vincola allo specifico destinatario (sventando ogni tentativo di riutilizzare un epk contro un destinatario diverso) e il enc.nonce unico per busta ancora la KEK a un'unica busta, così che un guasto del CSPRNG che ripetesse la casualità KEM tra due buste degradi soltanto a una correlabilità tra buste, mai a una coppia di avvolgimento (KEK, nonce-azzerato) ripetuta. Le implementazioni di X25519 DEVONO rifiutare il segreto condiviso tutto a zero secondo RFC 7748 §6.1; le librerie più diffuse lo fanno in modo transitivo.

Ibrido: mlkem768x25519 (X-Wing)

Il KEM ibrido è la costruzione X-Wing (draft-connolly-cfrg-xwing-kem-10), che combina ML-KEM-768 (FIPS 203) con X25519. Ogni encapsulation estrae nuova casualità ML-KEM e una nuova effimera X25519 e produce un testo cifrato di 1120 byte e un segreto condiviso combinato di 32 byte. La derivazione della KEK vincola il destinatario tramite un salt esterno calcolato sui byte wire dello slot stesso:

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

Dimensioni di chiavi e testo cifrato di X-Wing:

ComponenteDimensioneComposizione
Chiave pubblica1216 byteML-KEM-768 ek (1184) ‖ X25519 pk (32)
Testo cifrato1120 byteML-KEM-768 ct (1088) ‖ X25519 ephemeral (32)
Segreto condiviso32 byteoutput del combinatore X-Wing
Chiave di decapsulation32 byteun seed; la chiave pubblica ne è derivata

Uno slot ibrido non porta alcun campo epk: l'effimera X25519 è costituita dagli ultimi 32 byte dei 1120 byte di kem_ct. XWing.Encapsulate DEVE applicare il controllo di validità della chiave pubblica della revisione di X-Wing fissata a pub_R e rifiutare una chiave non valida anziché incapsulare verso di essa; è questa la precondizione sotto cui la soglia minima ibrida non scende mai al di sotto della sicurezza classica di X25519. La costruzione consuma X-Wing attraverso un adattatore con soli campi nominati: Encapsulate(pk) produce .ct (1120 B) e .ss (32 B); Decapsulate(sk, ct) produce il segreto condiviso di 32 byte. Le implementazioni DEVONO mappare l'API della revisione fissata per nome e NON DEVONO consumare valori di ritorno posizionali: la revisione fissata restituisce (ss, ct) dall'encapsulation e scrive la decapsulation come Decapsulate(ct, sk), l'inverso di una lettura ingenua da sinistra a destra. La derivazione della KEK vincola il destinatario tramite un salt etichettato a lunghezza fissa, SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R), dove kem_ct è il testo cifrato di 1120 byte esattamente come trasportato nello slot e pub_R è la chiave pubblica X-Wing del destinatario di 1216 byte. È la stessa forma a tre valori che usa il salt classico sotto la propria etichetta: kem_ct àncora la KEK a un valore unico per slot, pub_R la vincola allo specifico destinatario e enc.nonce la àncora a un'unica busta, espressi attraverso un digest SHA-256 perché gli input ibridi sono sovradimensionati per un salt grezzo. In entrambi i salt il termine pub_R è la codifica wire canonica della chiave del destinatario: esattamente i 32 byte di x25519_publicKey(priv_R) per x25519, esattamente la stringa di byte della chiave pubblica X-Wing fissata da 1216 byte per mlkem768x25519. Il produttore e il verificatore DEVONO usare quella codifica esatta e NON DEVONO sostituirla con alcun equivalente non canonico o ricodificato, altrimenti i due lati derivano KEK diverse e un record onesto non si apre. Fondamentale: il binding è calcolato all'esterno del KEM, sui byte wire dello slot stesso, perciò la costruzione tratta X-Wing come un KEM a scatola nera: consuma solo l'interfaccia KEM pubblica (encapsulate, decapsulate, il segreto condiviso di 32 byte) e non fa alcuna assunzione sull'hashing interno del combiner. L'etichetta info distinta per KEM cardano-poe-kek-mlkem768x25519-v1 garantisce in più che una KEK derivata per un KEM non possa mai coincidere con una KEK derivata per l'altro, anche a parità di un identico segreto condiviso di 32 byte. Il testo cifrato di 1120 byte è trasportato come un'unica stringa di byte CBOR in slot.kem_ct: solo l'intero corpo del record è suddiviso in chunk per il trasporto (vedi Il record), mai un singolo campo.

Un solo KEM per record

Un singolo elemento di sealed PoE porta esattamente un enc.kem; ogni slot usa la forma e la derivazione della KEK di quel KEM. Un file è tutto classico o tutto ibrido: slot di KEM differenti NON DEVONO comparire nello stesso array slots, e un verificatore DEVE rifiutare un record le cui forme degli slot sono incoerenti con il enc.kem dichiarato (ENC_SLOT_INVALID_SHAPE).

Anche il materiale di incapsulamento DEVE essere distinto all'interno di un solo array slots: per x25519 tutti i valori epk DEVONO differire, per mlkem768x25519 tutti i valori kem_ct DEVONO differire. Un duplicato viene rifiutato, prima dell'esecuzione di qualsiasi primitiva KEM o AEAD, con 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 della KEK tra record o tra chiavi è un obbligo del produttore che un verificatore non può rilevare, ma un duplicato all'interno del record è strutturalmente visibile e DEVE fallire.

Decifratura per tentativi del destinatario

Un destinatario possiede una chiave privata (uno scalare X25519 di 32 byte per x25519, o un seed di decapsulation X-Wing di 32 byte per mlkem768x25519, entrambi derivati dal seed; vedi Chiavi). Non sa in anticipo quale slot, se ce n'è uno, sia il suo, perciò decifra per tentativi l'array. Due proprietà danno forma al ciclo: il controllo del MAC dell'insieme degli slot è incorporato al suo interno (uno slot è accettato solo quando la sua CEK candidata riproduce anche il slots_mac presente sul wire), e il ciclo scorre tutti gli slot senza alcuna uscita anticipata, selezionando la corrispondenza a tempo costante così che un osservatore dei tempi non possa dedurre quale indice di slot abbia corrisposto.

Prima di invocare qualsiasi primitiva KEM o AEAD, il verificatore DEVE eseguire i controlli strutturali di forma (la difesa contro l'oracolo di partizione): scheme == 1, aead/kem registrati, nonce di 24 byte, slots_mac di 32 byte, slots non vuoto, il segreto del destinatario di 32 byte, ogni slot.wrap esattamente di 48 byte, ogni epk x25519 esattamente di 32 byte senza kem_ct, ogni kem_ct mlkem768x25519 esattamente di 1120 byte senza epk, e la distinzione, dentro slots, di tutto il materiale di incapsulamento (altrimenti ENC_SLOTS_DUPLICATE_KEM_MATERIAL).

Nello stesso passaggio pre-primitiva il verificatore DEVE inoltre limitare l'uso di risorse del parser: le soglie di riferimento sono MAX_SLOTS = 1024 slot e 65536 byte per la busta enc decodificata. Entrambe stanno ben al di sopra del tetto di ≈ 16 KiB dei metadati delle transazioni Cardano che vincola un record onesto, perciò un record che superi l'una o l'altra è malformato e viene rifiutato qui: ENC_SLOTS_TOO_MANY per troppi slot, ENC_ENVELOPE_TOO_LARGE per una busta sovradimensionata, prima dell'esecuzione di qualsiasi primitiva KEM o AEAD. Queste soglie sono costanti applicate dal verificatore e fissate dal deployment, non campi del wire; un deployment PUÒ restringerle.

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

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

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

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

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

La derivazione della KEK si dirama in base a enc.kem: per x25519 il destinatario esegue un ECDH contro slot.epk e rideriva lo stesso salt etichettato su enc.nonce || slot.epk || pub_R; per mlkem768x25519 esegue la decapsulation X-Wing di slot.kem_ct direttamente (un'unica stringa di byte di 1120 byte) e ricalcola lo stesso salt etichettato su enc.nonce || slot.kem_ct || pub_R, dove pub_R è la sua stessa chiave pubblica X-Wing di 1216 byte derivata dal seed posseduto. Il rifiuto del segreto condiviso X25519 tutto a zero è qui esplicito anziché affidato a un rifiuto transitivo: uno slot confezionato per portare il segreto condiviso a zeros(32) (RFC 7748 §6.1) imposta a falso il bit di validità kem_ok indipendente dal segreto, la KEK viene selezionata a tempo costante verso una dummy_KEK derivata da zeros(32) sotto lo stesso salt e info così che il ciclo esegua lavoro identico, e kem_ok è incorporato in ok, perciò uno slot con ECDH non valido non può mai essere accettato a prescindere dall'esito del wrap o del MAC, e il record fa emergere l'unico fallimento generico se nient'altro corrisponde. Tutto ciò che segue l'apertura del wrap, il controllo del MAC dell'insieme degli slot, la derivazione della chiave del contenuto e la decifratura del contenuto, è indipendente dal KEM.

Entrambe le primitive AEAD *_open_or_dummy sono atomiche: in caso di fallimento della verifica del tag non restituiscono alcun testo in chiaro, e il candidato restituito (candidate_CEK per l'apertura del wrap, il plaintext per l'apertura del contenuto) è un valore fittizio fisso o pseudocasuale che è indipendente dal testo cifrato fallito. Nessun testo in chiaro non verificato viene mai rilasciato al chiamante, perciò un'apertura fallita non può diventare un oracolo di decifratura.

Perché il controllo del MAC vive dentro il ciclo

Un mittente malevolo può fabbricare uno slot che si apre con la chiave di un destinatario ma produce una CEK scelta dall'attaccante (incapsulare verso la chiave pubblica del destinatario non richiede alcuna chiave privata). Se un destinatario accettasse il primo successo AEAD come "il proprio", quello slot contraffatto oscurerebbe uno slot onesto più avanti nell'array. Incorporare il controllo di slots_mac nel ciclo significa che uno slot è accettato solo quando la sua CEK candidata riproduce il MAC su slots_hash, perciò uno slot contraffatto viene saltato e la scansione prosegue. La lunghezza di slot.wrap DEVE essere verificata pari a 48 byte prima di qualsiasi chiamata AEAD, una difesa contro gli oracoli di partizione che anche age v1 applica.

Corrispondenze multiple di slot: la duplicazione è ammessa, un conflitto di CEK no. Una chiave privata del destinatario PUÒ legittimamente corrispondere a più di uno slot. Un produttore può sigillare la stessa CEK verso lo stesso destinatario in più slot, ciascuno con la propria effimera per slot fresca, per gonfiare il conteggio apparente dei destinatari: una valida tecnica di privacy. Il verificatore seleziona la CEK della prima corrispondenza e NON DEVE rifiutare solo perché più di uno slot ha corrisposto. Ciò è distinto dal rifiuto, all'interno del record, del materiale-di-encapsulation-duplicato (ENC_SLOTS_DUPLICATE_KEM_MATERIAL), che scatta su un epk o un kem_ct ripetuto: la duplicazione onesta estrae nuova casualità KEM per slot per ogni comparsa, perciò il suo epk / kem_ct differisce e non collide mai con quel controllo. L'unica anomalia che il verificatore DEVE rifiutare è costituita da due slot corrispondenti che recuperano CEK diverse (confrontate a tempo costante): il ciclo porta un bit cek_conflict attraverso tutti gli slot e, se una qualsiasi corrispondenza successiva recupera una CEK che differisce da quella selezionata, fa emergere l'unico fallimento generico. Questa è difesa in profondità: sotto la proprietà di commitment che la CEK recuperata fornisce (il MAC dell'insieme degli slot lega la CEK a un'unica trascrizione di slot; vedi Anonimato e la distinzione per KEM), una corrispondenza con CEK distinta è già infattibile, essendo esattamente la collisione multi-chiave che il commitment esclude, perciò il controllo fallisce in modo chiuso contro un'implementazione difettosa o un futuro indebolimento di quell'assunzione.

Un'unica forma di fallimento generico, a tempo costante su tutti gli slot

Un chiamante non fidato DEVE ricevere esattamente un'unica forma di fallimento generico a prescindere dal motivo per cui la decifratura è fallita (nessuno slot aperto, insieme di slot manomesso, o AEAD del contenuto fallito), e la risposta NON DEVE distinguere questi casi, né rivelare quale slot abbia corrisposto. Un'implementazione PUÒ esporre codici interni tipizzati, WRONG_RECIPIENT_KEY (nessuno slot si apre), TAMPERED_HEADER (uno slot si apre ma nessuna CEK candidata riproduce lo slots_mac su slots_hash), TAMPERED_CIPHERTEXT (l'AEAD del contenuto fallisce dopo che una CEK è stata recuperata), a un chiamante locale fidato per la diagnostica, ma quei codici NON DEVONO trapelare a un osservatore esterno tramite una risposta distinguibile.

Sui tempi, il verificatore PUÒ ritornare al controllo di mancata corrispondenza (if NOT found) prima della decifratura del contenuto. Quel ritorno anticipato rivela soltanto destinatario contro non destinatario, mai quale slot abbia corrisposto né alcun materiale di chiave, perché il ciclo tra gli slot sopra è già stato eseguito fino in fondo quando si raggiunge il controllo. Tempi uniformi tra il caso del non destinatario e quello di un destinatario il cui testo cifrato non si apre non sono richiesti, e un'apertura fittizia del contenuto NON DEVE essere imposta: imporre a ogni non destinatario di pagare l'intero costo della decifratura del contenuto non acquista alcuna privacy che il ciclo non fornisca già. La garanzia a tempo costante che vale è l'invariante tra gli slot: il ciclo esegue un numero costante di operazioni per slot per ogni chiave privata senza alcuna uscita anticipata, perciò un osservatore a livello di rete apprende solo il numero di slot, mai quale slot (se mai uno) la chiave apra. Un destinatario che detiene più chiavi (per esempio chiavi archiviate dopo una rotazione d'identità) itera chiave privata × slot, riderivando la metà del salt pub_R dalla chiave corrente; 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.

Dopo aver recuperato il testo in chiaro, il destinatario, nel livello applicativo e non nella funzione di decifratura, ricalcola l'hash del testo in chiaro e lo confronta con items[].hashes. Una mancata corrispondenza significa che l'impegno on-chain del record non coincide con i byte decifrati, e il destinatario DEVE rifiutarsi di agire sul testo in chiaro. È il passo che chiude il cerchio: la blockchain ha testimoniato un impegno all'istante T, e il destinatario conferma che si tratta di un impegno esattamente su questi byte.

Percorso passphrase

Il percorso alternativo per la consegna della chiave sostituisce gli slot dei destinatari con una passphrase. Non c'è alcun array slots, nessun slots_mac, nessuna effimera per slot e nessun ciclo di decifratura per tentativi: la CEK è derivata direttamente da una passphrase normalizzata tramite Argon2id (RFC 9106) su un salt e parametri presenti sulla blockchain. 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: stesso oggetto, stesso URI, stesso recupero.

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

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

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

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

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

Il blocco enc.passphrase sul wire è { alg, salt, params }: nomina la KDF ("argon2id"), il salt e i parametri. Label 309 fissa una soglia minima dei parametri di m ≥ 65536 KiB (64 MiB), t ≥ 3, p ≥ 1; il produttore sceglie valori pari o superiori alla soglia e il salt è compreso tra 16 e 64 byte inclusi (il limite massimo di 64 byte è il tetto per la stringa di byte del metadatum). Dove la piattaforma lo consente, i produttori DOVREBBERO usare p = 4 (il secondo profilo raccomandato dalla RFC 9106 §4); i verificatori POSSONO accettare qualsiasi p ≥ 1, fatti salvi i tetti del deployment più sotto.

Il PASSPHRASE_TRANSCRIPT vincola i parametri della KDF, i campi dell'intestazione e la rivendicazione dell'hash dell'elemento nell'impegno: il verificatore ricalcola la trascrizione dalla mappa enc ricevuta e dagli hashes dell'elemento, perciò 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 contenuto viene poi sigillato nello stesso STREAM segmentato del percorso slots, sotto la chiave del contenuto del percorso passphrase. 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.

Ordine di verifica. Il verificatore deriva la CEK candidata dalla passphrase inserita, legge i primi 32 byte del blob di testo cifrato, ricalcola l'impegno e lo confronta a tempo costante, prima di aprire qualsiasi chunk dello STREAM. Un blob del percorso passphrase più corto di 48 byte — l'intestazione di impegno da 32 byte più lo STREAM minimo da 16 byte — non può essere ben formato ed è testo cifrato malformato (TAMPERED_CIPHERTEXT). In caso di discordanza — passphrase errata, salt / params manomessi, intestazione manomessa o una busta innestata — il verificatore fa emergere lo stesso unico fallimento generico di qualsiasi altro fallimento di decifratura e NON DEVE iniziare lo streaming. Una passphrase errata è quindi indistinguibile da un record manomesso.

Prima della normalizzazione e di Argon2id, un'implementazione DEVE limitare la lunghezza dell'input grezzo della passphrase, così che una passphrase sovradimensionata non possa provocare un denial-of-service pre-KDF: la soglia di riferimento è 4096 byte UTF-8 di input grezzo, rifiutato prima di qualsiasi lavoro di normalizzazione o hashing. Come le soglie su MAX_SLOTS e sulla busta enc decodificata imposte dal percorso slots, questa è una costante applicata dal verificatore e fissata dal deployment, non un campo del wire, e un deployment PUÒ restringerla. Oltre alla soglia minima dei parametri, le implementazioni DOVREBBERO inoltre imporre soglie superiori su m, t e p contro un DoS lato verificatore; quei tetti sono non normativi (dipendono dall'hardware) e NON DEVONO essere confusi con la soglia minima.

Perché l'impegno è off-chain

Un impegno della passphrase on-chain consegnerebbe a ogni osservatore un oracolo di test offline gratuito — derivare una CEK candidata da una passphrase indovinata, confrontarla con la blockchain — per ogni record con passphrase, per sempre, compresi i record il cui testo cifrato è trattenuto. Trasportare l'impegno all'interno del blob di testo cifrato significa che testare un tentativo richiede il blob stesso: un record a testo cifrato trattenuto non espone alcun materiale indovinabile tramite passphrase sul ledger permanente, e un destinatario legittimo che possiede già il blob non paga nulla per leggere prima un'intestazione di 32 byte.

Il profilo di normalizzazione

La normalizzazione applicata alla passphrase prima di Argon2id è il profilo fisso cardano-poe-pw-norm-v1. È normativo: due implementazioni DEVONO derivare una CEK identica byte per byte dalla stessa passphrase, e l'unico modo per garantirlo è una normalizzazione fissata. Il profilo, applicato in ordine, è:

  1. Rifiutare i codepoint non assegnati. Una passphrase che contiene un qualsiasi codepoint non assegnato in Unicode 16.0 viene rifiutata con ENC_PASSPHRASE_UNNORMALIZABLE prima che venga eseguito qualsiasi passo di normalizzazione.
  2. NFKC. Applicare la Normalization Form KC secondo UAX #15 sotto Unicode 16.0.
  3. Spazi bianchi. Definire "spazio bianco" come ogni carattere che porta la proprietà Unicode White_Space sotto Unicode 16.0, e collassare ogni sequenza massimale di tali caratteri in un singolo U+0020 SPACE.
  4. Trim. Rimuovere gli spazi bianchi iniziali e finali.
  5. Rifiutare la stringa vuota. Se il risultato è la stringa vuota, rifiutare con ENC_PASSPHRASE_EMPTY: una passphrase composta da soli spazi bianchi o altrimenti vacua si normalizza a zero byte, che Argon2id accetterebbe in silenzio, vincolando il record a una CEK che chiunque può derivare.
  6. Codifica. Codificare il risultato in UTF-8; quei byte sono l'input password di Argon2id.

Il passo 1 è ciò che rende il profilo deterministico tra le implementazioni e nel tempo. La Unicode Normalization Stability Policy garantisce che la normalizzazione di una stringa sia stabile nelle versioni future di Unicode solo quando ogni codepoint in essa è assegnato nella versione in cui è stata normalizzata; un codepoint non assegnato potrebbe acquisire una decomposizione in seguito e cambiare in silenzio la CEK derivata. Rifiutare i codepoint non assegnati chiude del tutto quella falla, ed è invisibile agli utenti onesti: ogni carattere effettivamente in uso scritto è assegnato.

La versione di Unicode è fissata a Unicode 16.0 letteralmente e NON DEVE fluttuare: l'insieme della proprietà White_Space, l'insieme dei codepoint assegnati e le tabelle di mappatura NFKC dipendono tutti dalla versione, e un verificatore che risolvesse il profilo a fronte di una versione di Unicode diversa potrebbe derivare una CEK diversa dalla stessa passphrase e non riuscire a decifrare un record onesto. Una revisione futura che adotti una versione di Unicode più recente lo fa sotto un nuovo identificatore di profilo, non reinterpretando cardano-poe-pw-norm-v1.

L'entropia della passphrase è l'unica barriera

Il salt e i parametri Argon2id sono pubblici sulla blockchain per sempre, perciò un attaccante ha tempo offline illimitato per forzare la passphrase a fronte di essi. L'entropia della passphrase è l'unico margine di sicurezza su questo percorso. I produttori DOVREBBERO usare una passphrase diceware generata da CSPRNG anziché una scelta da un essere umano, e DOVREBBERO mostrare un avviso visibile quando accettano passphrase digitate, dato che il testo cifrato on-chain sarà soggetto in modo permanente ad attacchi offline.

Forward secrecy e indipendenza tra gli slot

La costruzione a slot usa un ECDH ephemeral-static (o una nuova encapsulation X-Wing) con un'effimera nuova per ogni slot, il che assicura due proprietà che un design static-static o a effimera condivisa perderebbe:

  • Forward secrecy contro la compromissione del mittente. Il mittente non conserva alcuna chiave di lungo termine nella costruzione; l'effimera è azzerata dopo la sigillatura. Compromettere in seguito lo stato del mittente non può decifrare i record pubblicati prima della compromissione.
  • Indipendenza tra gli slot. Destinatari diversi ricevono effimere diverse, quindi segreti condivisi e KEK diversi. Un destinatario che lascia trapelare la propria CEK incapsulata rivela la CEK (inevitabile, è la chiave del file) ma mai la KEK di un altro destinatario.

La sealed PoE non ha forward secrecy per il destinatario, per scelta progettuale: una volta che un record è sigillato verso una chiave di destinatario a lungo termine, chi possiede la chiave privata corrispondente può decifrarlo per sempre. È una proprietà della cifratura a chiave pubblica verso una chiave a lungo termine, non un difetto.

Anonimato e la distinzione per KEM

Quando un record di sealed PoE non porta alcun sigs, i suoi byte sul wire sono indipendenti dall'identità del mittente: ogni slot porta solo materiale KEM effimero specifico per record e per slot (l'effimera X25519 in slot.epk, o il testo cifrato X-Wing in slot.kem_ct), le chiavi a lungo termine del mittente non compaiono mai, gli slot sono mescolati con un CSPRNG, nessuna chiave pubblica del destinatario è sul wire e nessun campo descrittivo (nome del file, tipo MIME, dimensione) è presente. Un record sigillato non firmato non vincola quindi alcuna identità del mittente sulla catena, esattamente ciò che richiedono le segnalazioni di whistleblower, le aste a busta chiusa e la custodia di prove.

Per entrambi i KEM le fughe oneste sono identiche e inevitabili: il numero di slot, la distinzione tra sigillato e aperto e la famiglia di KEM classico-contro-ibrido (enc.kem) sono visibili a qualsiasi osservatore; nulla di più sui destinatari lo è.

La rivendicazione più forte, ovvero che un avversario che possiede un insieme di chiavi pubbliche candidate dei destinatari non possa verificare se un dato slot sia indirizzato a una di esse (riservatezza sulla chiave / anonimato del destinatario), è una proprietà specifica del KEM:

  • x25519, riservato sulla chiave. L'incapsulamento per slot è una chiave pubblica effimera fresca, statisticamente indipendente dalla chiave del destinatario. Un avversario in possesso di chiavi pubbliche candidate dei destinatari non può, a partire dal solo slot.epk e dal solo slot.wrap, decidere quale candidato (se mai uno) lo slot prenda di mira, senza la chiave privata corrispondente. Il percorso classico è quindi riservato sulla chiave, il che dà anche l'impossibilità di collegamento tra record: due sealed PoE verso lo stesso destinatario appaiono come blob epk/wrap non correlati.
  • mlkem768x25519, non rivendicato. L'anonimato del destinatario contro un avversario in possesso di chiavi candidate dei destinatari è una proprietà a sé non implicata dalla sicurezza IND-CCA del KEM ibrido. Label 309 non la rivendica per il percorso X-Wing fino a quando non sia giustificata in modo indipendente per X-Wing. Un deployment il cui modello di minaccia richiede l'anonimato del destinatario contro un avversario in possesso delle chiavi NON DEVE affidarsi al percorso ibrido per questa proprietà.

I mittenti preoccupati per la correlazione temporale tra record DEVONO accorpare le pubblicazioni al di fuori della linea temporale critica; la crittografia a livello di wire non può risolvere gli attacchi sui metadati basati sui tempi.

Il MAC dell'insieme degli slot è il commitment; l'avvolgimento non deve esserlo

La CEK recuperata è un commitment all'insieme di slot a cui il destinatario ha corrisposto: un mittente malevolo non può costruire due insiemi di slot distinti che un singolo destinatario accetti entrambi come propri. La proprietà richiesta qui è il commitment di chiave ristretto per la CEK della busta nel senso della RFC 9771: la CEK recuperata si lega a un'unica trascrizione di slot, non un AEAD committing completo su input arbitrari. Poggia sulla resistenza alle collisioni multi-chiave di CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) per CEK e trascrizioni scelte dall'avversario, un margine di collisione generica da ~128 bit (il limite di birthday su un output da 256 bit), adeguato al modello di minaccia. La resistenza alla manomissione della trascrizione stessa eredita il limite di collisione da ~2^128 di SHA-256: qualsiasi modifica ai campi dell'intestazione impegnati o ai byte degli slot altera slots_hash, e forgiare uno slots_hash invariato su una trascrizione diversa è esattamente quella ricerca di collisione da ~2^128. Poiché il commitment è fornito da slots_mac, l'AEAD wrap per slot non deve essere un AEAD committing; il ChaCha20-Poly1305 non committing predefinito è qui sicuro.

Pattern vietati

Un'implementazione conforme NON DEVE:

  • Riutilizzare un'effimera per slot tra slot o tra record, né in alcun altro modo lasciare che una KEK si ripeta: l'incapsulamento a nonce zero dipende dall'unicità della KEK per slot.
  • Riutilizzare una CEK tra buste: una CEK fresca da CSPRNG per ogni elemento che porta enc, sia all'interno di un record sia tra record diversi.
  • Riutilizzare un salt di passphrase: generare un enc.passphrase.salt fresco da CSPRNG per ogni busta con passphrase; il salt è l'unico separatore tra record per una passphrase riutilizzata.
  • Mescolare i KEM all'interno di un solo array slots (un solo enc.kem per record).
  • Pubblicare gli slot nell'ordine di input: il mescolamento con CSPRNG è obbligatorio.
  • Avvolgere la CEK con un nonce diverso dal nonce azzerato di 12 byte, o con un'AAD del wrap-AEAD vuota: l'AAD del wrap è il literale dell'etichetta info del KEM.
  • Mettere una chiave pubblica del destinatario sul wire: il design a decifratura per tentativi è la caratteristica di riservatezza; pubblicare le chiavi pubbliche la vanifica.
  • Saltare la verifica di slots_mac: senza di essa, la sostituzione di slot ha successo.
  • Memorizzare il testo in chiaro all'URI ar:///ipfs://: viene pubblicato solo il testo cifrato; il testo in chiaro è consegnato fuori banda o trattenuto dal mittente.
  • Riferire il testo cifrato tramite uno schema diverso da ar:// o ipfs://: gli schemi indirizzati per contenuto vincolano l'URI ai byte; un URL servito da un host richiederebbe un impegno on-chain separato sul testo cifrato che la sealed PoE non trasporta.
  • Registrare in log o persistere la CEK, una qualsiasi KEK, la chiave HMAC dell'insieme degli slot, la chiave MAC della passphrase, la chiave del contenuto, un segreto condiviso ECDH, una chiave privata effimera o una chiave privata di destinatario.

Pagine correlate

  • Chiavi: le coppie di chiavi X25519 e X-Wing derivate dal seed che forniscono il materiale di chiave del destinatario e del mittente.
  • Il record: dove si colloca enc nella mappa del record e il trasporto dell'intero corpo che porta il record on-chain.
  • Registri degli algoritmi: gli identificatori enc.aead, enc.kem e della passphrase-KDF e le primitive che li supportano.
  • Contenuto e hashing: l'impegno sull'hash del testo in chiaro che ogni record sigillato trasporta.
  • Verifica: la pipeline di validazione, il motivo per cui il validatore non decifra mai, e il catalogo degli errori.