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 classicox25519; non è rivendicata per il percorso ibridomlkem768x25519(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.
| Campo | Stato | Significato |
|---|---|---|
scheme | OBBLIGATORIO | Versione della famiglia di costruzione. La v1 definisce scheme = 1. |
aead | OBBLIGATORIO | Identificatore del formato di contenuto. La v1 definisce "chacha20-poly1305-stream64k". |
nonce | OBBLIGATORIO | 24 byte casuali, il salt unico per busta della chiave del contenuto e di ogni KEK di slot. |
kem | solo percorso slots | Selettore del KEM per slot ("x25519" o "mlkem768x25519"). |
slots | uno dei due percorsi | Array di slot di chiave per destinatario (multi-destinatario). |
slots_mac | solo percorso slots | HMAC di 32 byte che vincola l'insieme degli slot e la rivendicazione dell'hash dell'elemento alla chiave del contenuto. |
passphrase | l'altro percorso | Blocco 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:
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:
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 bytesIl 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):
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:
- Seleziona un solo KEM per l'intero record e genera la CEK (32 byte casuali) e il
nonce(24 byte casuali). - Per ciascun destinatario, deriva una KEK per slot e vi incapsula la CEK (i dettagli per KEM più sotto).
- Mescola l'array degli slot con un CSPRNG (Fisher-Yates non distorto).
- 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_hashe calcolaslots_maccome HMAC con chiave derivata dalla CEK su quell'hash. - 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):
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.
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) ; 32 bytes
SLOTS_TRANSCRIPT = { ; closed 7-key map; keys are a set, not an order
"scheme": 1, ; uint
"path": "slots", ; text
"aead": <enc.aead>, ; text: the content-format identifier
"kem": <enc.kem>, ; "x25519" | "mlkem768x25519"
"nonce": <enc.nonce>, ; bytes(24)
"slots": <slots>, ; the shuffled on-wire slot array
"hashes_hash": hashes_hash} ; bytes(32), over this item's hashes map
slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT)) ; 32 bytes
HMAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "",
info = "cardano-poe-slots-mac-v1", L = 32)
slots_mac = HMAC-SHA-256(key = HMAC_KEY, msg = slots_hash) ; 32 bytesSLOTS_TRANSCRIPT è 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.kem | KEM | Chiave pubblica del destinatario | Forma dello slot | Stringa info della KEK |
|---|---|---|---|---|
"x25519" | X25519 (classico) | 32 byte | { epk: bstr(32), wrap: bstr(48) } | "cardano-poe-kek-v1" |
"mlkem768x25519" | X-Wing = X25519 + ML-KEM-768 | 1216 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:
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 bytesLa 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:
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 stringDimensioni di chiavi e testo cifrato di X-Wing:
| Componente | Dimensione | Composizione |
|---|---|---|
| Chiave pubblica | 1216 byte | ML-KEM-768 ek (1184) ‖ X25519 pk (32) |
| Testo cifrato | 1120 byte | ML-KEM-768 ct (1088) ‖ X25519 ephemeral (32) |
| Segreto condiviso | 32 byte | output del combinatore X-Wing |
| Chiave di decapsulation | 32 byte | un 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_CIPHERTEXTLa 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.
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 pathIl 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, è:
- Rifiutare i codepoint non assegnati. Una passphrase che contiene un qualsiasi
codepoint non assegnato in Unicode 16.0 viene rifiutata con
ENC_PASSPHRASE_UNNORMALIZABLEprima che venga eseguito qualsiasi passo di normalizzazione. - NFKC. Applicare la Normalization Form KC secondo UAX #15 sotto Unicode 16.0.
- Spazi bianchi. Definire "spazio bianco" come ogni carattere che porta la
proprietà Unicode
White_Spacesotto Unicode 16.0, e collassare ogni sequenza massimale di tali caratteri in un singolo U+0020 SPACE. - Trim. Rimuovere gli spazi bianchi iniziali e finali.
- 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. - 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 soloslot.epke dal soloslot.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 blobepk/wrapnon 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.saltfresco 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 soloenc.kemper 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
infodel 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://oipfs://: 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
encnella mappa del record e il trasporto dell'intero corpo che porta il record on-chain. - Registri degli algoritmi: gli identificatori
enc.aead,enc.keme 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.
Firme
L'array opzionale `sigs` a livello di record — una COSE_Sign1 distaccata sull'intero corpo del record, il suo payload firmato con separazione di dominio, le due modalità di trasporto della chiave del firmatario e la verifica Ed25519 rigorosa.
Verifica
I tre ruoli di verifica di Label 309, gli stati del verdetto, la profondità di finalità e il catalogo tipizzato degli errori. Come chiunque arriva alla stessa risposta partendo dalla sola infrastruttura pubblica.