Versiegelte PoE
Der Verschlüsselungsumschlag von Label 309 — wie ein Absender Inhalte für einen oder mehrere Empfängerschlüssel versiegelt, während die Blockchain nur den Klartext-Hash und die gewickelten Schlüssel-Slots trägt, niemals den Klartext und niemals die Empfänger.
Eine versiegelte PoE verankert einen zeitgestempelten Commitment auf einen
Klartext und hält diesen Klartext zugleich nur für ein ausgewähltes Publikum
lesbar. Der On-Chain-Datensatz trägt den Klartext-Hash, also den Nachweis des
Zeitpunkts, genau wie bei jedem anderen Datensatz, ergänzt um einen
Verschlüsselungsumschlag (enc), der das Material zur Wiederherstellung des
Inhaltsverschlüsselungsschlüssels enthält. Der Chiffretext selbst gelangt nie in
die Blockchain; er liegt unter einer inhaltsadressierten URI (ar:// oder
ipfs://). Nichts in der Blockchain gibt den Klartext preis, und nichts verrät,
wer die Empfänger sind.
Diese Seite spezifiziert den enc-Umschlag: seine zwei sich gegenseitig
ausschließenden Pfade zur Schlüsselzustellung, die einzelnen Empfänger-Slots,
den MAC über die Slot-Menge, den segmentierten Inhalts-STREAM und die
Probeentschlüsselung, die ein Empfänger durchführt, um eine an ihn gerichtete
Nachricht zu erkennen und zu öffnen. Die Empfängerschlüssel selbst, also die aus
dem Seed abgeleiteten X25519- und X-Wing-Schlüsselpaare, sind unter
Schlüssel definiert; diese Seite verwendet sie. Der Platz der
enc-Map innerhalb der Datensatz-Map sowie der Transport über den gesamten Körper,
der sie on-chain trägt, sind unter Der Datensatz definiert.
Kein HPKE
Dies ist nicht RFC 9180 HPKE. Es ist ein
Mehrempfänger-Verfahren im Stil von age, das nach dem Muster KEM-dann-Wickeln arbeitet: eine
Kapselung pro Empfänger, ein per HKDF abgeleiteter Schlüssel zur Schlüsselverschlüsselung und ein
per AEAD gewickelter Schlüssel zur Inhaltsverschlüsselung, wobei das Stanza-Muster von age
v1 auf kanonisches CBOR übertragen wurde. Es hat keine suite_id
und keine LabeledExtract/LabeledExpand-Kaskade; beurteilen Sie es anhand der ECIES-Literatur
und der age-v1-Spezifikation, nicht anhand der
Analyse von HPKE.
Das Modell und seine Datenschutzeigenschaften
Ein Absender möchte einen dauerhaften, zeitgestempelten Commitment
veröffentlichen, der belegt, dass ein bestimmter Klartext zum Zeitpunkt T für ein
bestimmtes Publikum versiegelt wurde, und zugleich sicherstellen, dass nur dieses
Publikum ihn lesen kann. Eine reine Hash-PoE liefert die Zeitaussage, aber keine
Bindung an ein Publikum; eine PoE über offenen Chiffretext bietet überhaupt keine
Vertraulichkeit. Die versiegelte PoE verbindet beides: Der Datensatz committet
auf den Klartext-Hash (öffentlich, zeitgestempelt) und trägt das Material zur
Schlüsselzustellung in enc, während der Chiffretext unter der ar://- oder
ipfs://-URI ohne ein passendes Entsperrgeheimnis nicht entschlüsselbar ist.
Die Konstruktion ist bewusst so gestaltet, dass die Blockchain so wenig wie möglich über die Nachricht und nichts über ihr Publikum preisgibt:
- Der Klartext liegt nie in der Blockchain. Dort liegen nur sein Hash und die gewickelten Schlüssel. Wer später den Klartext erhält, kann beweisen: „Genau dieser Klartext wurde zur Blockzeit T committet"; niemand sonst erfährt, was versiegelt wurde.
- Öffentliche Empfängerschlüssel liegen nie in der Blockchain. Der öffentliche
Schlüssel eines Empfängers taucht nirgends in
encauf. Ein Empfänger erkennt eine Nachricht nur als seine, indem er einen Slot erfolgreich probeweise entschlüsselt; es gibt kein Adressatenfeld zum Auslesen. Ein Beobachter ohne Kandidatenschlüssel erfährt nur die Anzahl der Slots, die KEM-Familie (enc.kem) und die Unterscheidung versiegelt gegenüber offen. Die stärkere Eigenschaft, dass nämlich ein Angreifer, der selbst Kandidaten-Empfängerschlüssel hält, dennoch nicht prüfen kann, an welchen (falls überhaupt) ein Slot gerichtet ist, ist die Schlüsselprivatheit, die nur für den klassischenx25519-Pfad beansprucht wird; für den hybridenmlkem768x25519-Pfad wird sie nicht beansprucht (siehe Anonymität und die Aufteilung pro KEM). - Empfänger erfahren nichts voneinander. Jeder einzelne Empfänger-Slot ist ein undurchsichtiger gewickelter Schlüssel. Wer seinen eigenen Slot öffnet, kann daraus den Schlüssel keines anderen Empfängers ableiten und nicht erkennen, wer sonst noch adressiert wurde.
- Die Reihenfolge der Slots verrät nichts. Die Reihenfolge, in der ein Absender Empfänger auflistet (etwa „der wichtigste zuerst"), ist privilegierte Information. Das Slot-Array wird vor der Veröffentlichung mit einem CSPRNG durchmischt, sodass selbst die Positionsanordnung kein Signal trägt.
- Eine unsignierte versiegelte PoE wahrt die Anonymität des Absenders.
Urheberschaftssignaturen sind optional (siehe Signaturen).
Ein versiegelter Datensatz ohne
sigs[]bindet keine Absenderidentität in der Blockchain, genau das, was Whistleblower-Übergaben, Auktionen mit verdeckten Geboten und die treuhänderische Sicherung von Beweismitteln erfordern.
Was die Blockchain tatsächlich preisgibt, ist eng begrenzt: dass ein Datensatz
eine versiegelte PoE ist (enc ist vorhanden), der Klartext-Hash, der
Block-Zeitstempel und die Anzahl der Slots (die Array-Länge). Die Anzahl ist
die einzige empfängernahe Tatsache, die offenliegt, und sie verrät nur „wie
viele", niemals „wer". Eine Korrelation über Zeitpunkte hinweg ist eine
Metadaten-Frage, die Kryptografie auf Protokollebene nicht lösen kann; wer sie
ausschließen muss, muss seine Veröffentlichungen bündeln und von der sensiblen
Zeitachse entkoppeln.
Öffentliche Empfängerschlüssel werden out of band ausgetauscht. Label 309 schreibt keinen Mechanismus zur Auffindung vor: Ein Empfänger kann seinen Schlüssel auf der eigenen Website, in einem DNS-Eintrag, in einem sozialen Profil, als QR-Code oder als On-Chain-Selbstbestätigung veröffentlichen. Ein Verifizierer nimmt die Bytes des Empfängerschlüssels als Eingabe entgegen und trifft keine Aussage darüber, wessen Schlüssel sie sind; die Herkunft ist eine Vertrauensentscheidung des Absenders, genau wie beim Versenden eines PGP-Schlüssels per E-Mail.
Der Umschlag und seine zwei Pfade
Die enc-Map trägt gemeinsame Felder sowie genau einen von zwei sich
gegenseitig ausschließenden Pfaden zur Schlüsselzustellung. Ein struktureller
Validator erzwingt diese Ausschließlichkeit; ein Datensatz, der beide oder keinen
führt, wird abgelehnt.
| Feld | Status | Bedeutung |
|---|---|---|
scheme | ERFORDERLICH | Version der Konstruktionsfamilie. v1 definiert scheme = 1. |
aead | ERFORDERLICH | Kennung des Inhaltsformats. v1 definiert "chacha20-poly1305-stream64k". |
nonce | ERFORDERLICH | 24 zufällige Bytes — das umschlageindeutige Salt des Inhaltsschlüssels und jedes Slot-KEK. |
kem | nur Slots-Pfad | KEM-Auswahl pro Slot ("x25519" oder "mlkem768x25519"). |
slots | ein Pfad | Array von Empfänger-Slots (mehrere Empfänger). |
slots_mac | nur Slots-Pfad | 32-Byte-HMAC, der die Slot-Menge und den Hash-Anspruch des Elements an den Inhaltsschlüssel bindet. |
passphrase | der andere Pfad | Passphrase-KDF-Block (aus der Passphrase abgeleiteter Schlüssel). |
enc.slots— mehrere Empfänger. Der Umschlag trägt N unabhängig gewickelte Schlüssel-Slots, einen pro Empfänger. Der Chiffretext ist ohne einen privaten Schlüssel, der zu einem der Slots passt, nicht entschlüsselbar. Spezifiziert unter Slots und der MAC über die Slot-Menge.enc.passphrase— aus der Passphrase abgeleitet. Der Umschlag trägt keine Slots; der Inhaltsschlüssel wird direkt aus einer normalisierten Passphrase abgeleitet. Spezifiziert unter Passphrase-Pfad.
Beide Pfade teilen sich scheme, aead und nonce. Sie unterscheiden sich
darin, welcher Schlüssel vorhanden ist, und folglich darin, wo das
Schlüssel-Commitment liegt. Auf dem Slots-Pfad steht das Commitment on-chain:
slots_mac ist ein CEK-geschlüsselter HMAC über ein Transkript, das die
Header-Felder, die Slot-Menge und den Hash-Anspruch des Elements festschreibt, sodass
ein Empfänger den richtigen Schlüssel bestätigt, bevor er irgendetwas abruft. Auf dem
Passphrase-Pfad gibt es keine Slots zu binden, daher ist das Commitment ein
32-Byte-Header, der innerhalb des Chiffretext-Blobs mitgeführt wird – einen
Passphrase-Versuch zu prüfen, erfordert den Blob selbst, nie nur die öffentliche
Chain. Jeder Pfad serialisiert sein Transkript mit derselben
canonicalEncode-Funktion, und wer erzeugt oder verifiziert, wählt den Pfad, indem er
prüft, welches von slots / passphrase vorhanden ist. Die beiden Pfade sind
erschöpfend und schließen einander aus.
enc.scheme benennt die Familie der Konstruktion, unabhängig vom v-Feld des
Datensatzes. Ein Verifizierer MUSS enc.scheme === 1 verlangen und jeden anderen
Wert ablehnen. Das Feld ist für eine künftige übergreifende Änderung reserviert,
etwa einen anderen Ablauf für den MAC über die Slot-Menge oder ein anderes
Inhaltsformat, nicht für das Hinzufügen eines KEM: Das KEM pro Slot wird durch
enc.kem ausgewählt, und beide unten beschriebenen KEMs leben ab dem ersten
Release unter scheme = 1. Allgemeiner benennt enc.scheme: 1 die gesamte
kryptografische Suite, nicht nur den MAC und das Inhaltsformat: Die
canonicalEncode-Regeln, das Slot-Schema, der HKDF-Hash, der HMAC-Hash, die Wrap-AEAD
pro Slot, das segmentierte STREAM-Inhaltsformat, die Transkript-Schemata für Slots und
Passphrase (einschließlich der hashes_hash-Element-Bindung), das
Passphrase-Commitment im Chiffretext, die festgelegte X-Wing-Revision, die
Domain-Separation-Labels, die Argon2id-Version und das Argon2id-Profil sowie das
Passphrasen-Normalisierungsprofil werden allesamt durch ihn fixiert, sodass eine
Änderung an irgendeinem davon einen neuen enc.scheme-Wert erfordert.
Die Inhaltsschicht
Beide Pfade laufen in einem einzigen symmetrischen Durchlauf über den Klartext
zusammen, geschlüsselt durch einen aus einem einzigen 32-Byte-Inhaltsverschlüsselungsschlüssel
(Content Encryption Key, CEK) abgeleiteten Wert. Den CEK liefern die Slots
(jeder Slot wickelt ihn) oder die Passphrase-KDF erzeugt ihn; der Inhalt wird
nicht direkt unter dem CEK verschlüsselt. Stattdessen leitet jeder Pfad einen
eigenen 32-Byte-Inhaltsschlüssel als HKDF-Blatt des CEK ab – gesalzen mit der
umschlageindeutigen enc.nonce, unter einem pfadspezifischen info –, sodass die
Schicht der Schlüsselzustellung und die Inhaltsschicht niemals dasselbe Primitiv
auf denselben Bytes schlüsseln:
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)Der Inhalt wird dann in einem segmentierten STREAM versiegelt, benannt durch den
Inhaltsformat-Bezeichner chacha20-poly1305-stream64k.
Dies ist das STREAM-Layout der
age-v1-Spezifikation:
ChaCha20-Poly1305 (RFC 8439, die
Variante mit 12-Byte-Nonce) über den in feste Chunks zerlegten Klartext, jeder
unter dem Inhaltsschlüssel mit einer Zähler-Nonce pro Chunk versiegelt:
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 bytesDas Final-Flag trennt den letzten Chunk per Domäne vom Rest, und genau das macht
eine Abschneidung erkennbar: Ein Stream, dessen letzter Chunk nicht das 0x01-Flag
trägt, ein 0x01-Flag auf einem Chunk, der nicht der letzte ist, Daten nach dem
finalen Chunk oder ein nicht-finaler Chunk, der kürzer als CHUNK_SIZE ist, MÜSSEN
allesamt bei der Entschlüsselung scheitern (TAMPERED_CIPHERTEXT). Da jeder
versiegelte Chunk mindestens seinen 16-Byte-Tag umfasst, impliziert das Layout zudem
eine strukturelle Untergrenze – ein wohlgeformter Chiffretext-Blob auf dem Slot-Pfad
ist nie kürzer als 16 Byte, der einzelne Tag eines leeren finalen Chunks.
Die AAD pro Chunk ist konstruktionsbedingt leer: Der gesamte Kontext ist
transitiv an den Inhalt gebunden. Der Inhaltsschlüssel leitet sich aus dem CEK ab,
und der CEK ist auf dem Slot-Pfad durch slots_mac an den vollständigen Header
festgelegt (dessen Transkript scheme, path, aead, kem, nonce, die Slot-Menge
und den Hash-Anspruch des Elements abdeckt) oder auf dem Passphrase-Pfad durch das
Commitment im Chiffretext. Verändert man irgendein Header-Feld, leitet der Empfänger
einen anderen Schlüssel ab oder akzeptiert ihn, sodass die Entschlüsselung scheitert;
eine AAD pro Chunk würde denselben Kontext bei jedem Chunk erneut binden, ohne
Sicherheit hinzuzufügen.
Die zählerbasierten Chunk-Nonces sind sicher, weil der Inhaltsschlüssel einmalig
ist: Er leitet sich aus einem frischen CEK ab, gesalzen mit der umschlageindeutigen
enc.nonce, sodass nie zwei Streams ein (key, nonce)-Paar teilen und zustandslose
Erzeuger – Browser-Tabs, CLI-Läufe, Worker, Wiederholungen – Nonces nie über
Umschläge hinweg abstimmen. Der 88-Bit-Zähler lässt 2^88 Chunks zu, weit über jeder
realisierbaren Nutzlast, sodass das Format keine kryptografische Nutzlast-Obergrenze
auferlegt; ein praktisches Maximum ist eine Denial-of-Service-Richtlinie des
Deployments, keine Wire-Konstante.
Die Klartext-Eingabe sind die exakten ursprünglichen Inhalts-Bytes. Die Konstruktion stellt keinen Dateinamen, MIME-Typ, kein Größenfeld und kein Manifest voran, hängt nichts dergleichen an und verschlüsselt nichts dergleichen mit; der Stream entschlüsselt sich genau zu diesen Bytes und nur zu diesen Bytes.
Freigegebene Chunks sind vorläufig bis zur erneuten Hash-Prüfung
Das segmentierte Format existiert, damit ein Verifizierer eine mehrere GiB große Nutzlast inkrementell und mit begrenztem Speicher authentifizieren und freigeben kann. Der Tag jedes Chunks wird verifiziert, bevor der Klartext dieses Chunks freigegeben wird, und eine Abschneidung wird durch das Final-Flag erkannt – doch die erneute Klartext-Hash-Prüfung läuft über den gesamten Klartext, nach dem letzten Chunk. Wer als Streaming-Konsument liest, MUSS freigegebene Bytes daher als vorläufig behandeln – keine Seiteneffekte, keine Bestätigung, kein „empfangen"-Status –, bis diese abschließende Prüfung besteht.
Der veröffentlichte Chiffretext ist ein einziges Objekt. Auf dem Slot-Pfad sind es genau die STREAM-Chunks; auf dem Passphrase-Pfad wird ein 32-Byte-Schlüssel-Commitment-Header innerhalb desselben Blobs vorangestellt (dasselbe Objekt, dieselbe URI, derselbe Abruf – niemals ein zweites gespeichertes Objekt):
slots path : ciphertext blob = [ STREAM chunks ]
passphrase path : ciphertext blob = [ commitment: 32 bytes ] || [ STREAM chunks ]Der Klartext-Hash in items[].hashes committet stets auf den Klartext, auch
wenn enc vorhanden ist. Das ist die tragende Eigenschaft: Wer nicht
entschlüsseln kann, kann dennoch bestätigen, dass der Datensatz existiert, dass
sein Umschlag wohlgeformt ist und dass die URI abrufbar ist, aber nur wer einen
passenden Empfängerschlüssel besitzt, kann den Chiffretext entschlüsseln und durch
erneutes Berechnen des Hash bestätigen, worauf der Commitment lautet. Der
Validator DARF deshalb NICHT entschlüsseln, um Hashes zu „verifizieren"; die
Verifizierung des Klartext-Hash findet beim Empfänger statt, nachdem die Bytes
wiederhergestellt sind. Siehe Inhalt und Hashing und
Verifizierung.
Slots und der MAC über die Slot-Menge
Auf dem Mehrempfänger-Pfad ist enc.slots ein nicht leeres Array von Slots, einer
pro Empfänger. Jeder Slot wickelt denselben CEK unter einem
Schlüsselverschlüsselungsschlüssel (Key Encryption Key, KEK) pro Empfänger; wer
einen beliebigen Slot öffnet, erhält den einen CEK, der den Inhalt entschlüsselt.
Der Absender:
- Wählt ein KEM für den gesamten Datensatz und erzeugt den CEK (32 zufällige
Bytes) und die
nonce(24 zufällige Bytes). - Leitet für jeden Empfänger einen KEK pro Slot ab und wickelt den CEK darunter (KEM-Details siehe unten).
- Durchmischt das Slot-Array mit einem CSPRNG (unverzerrtes Fisher-Yates).
- Bildet das Slots-Transkript über das durchmischte Array, die KEM-übergreifenden
Header-Felder und den Hash-Anspruch des Elements, hasht es zu
slots_hashund berechnetslots_macals CEK-geschlüsselten HMAC über diesen Hash. - Leitet den Inhaltsschlüssel aus dem CEK und
enc.nonceab und versiegelt den Inhalt im oben beschriebenen segmentierten STREAM.
Das Wickeln pro Slot
Jeder Slot wickelt den CEK mit ChaCha20-Poly1305
(RFC 8439, die Variante mit
12-Byte-Nonce) unter dem KEK des Slots und erzeugt ein 48-Byte-wrap
(32-Byte-CEK-Chiffretext + 16-Byte-Poly1305-Tag):
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)Die 12-Byte-Nonce aus lauter Nullen ist genau deshalb sicher, weil der KEK jedes Slots pro Datensatz eindeutig ist: Ein KEK wird daher für genau ein Wickeln verwendet, sodass die Nonce unter ein und demselben Schlüssel niemals kollidieren kann. Das ist eine harte Invariante: Würde eine Überarbeitung jemals zulassen, dass ein KEK wiederverwendet wird (Caching, deterministische Ephemerals, eine Empfänger-Deduplizierung, die einen Slot wiederverwendet), müsste in derselben Änderung die Null-Nonce durch eine zufällige ersetzt werden.
Der MAC über die Slot-Menge
slots_mac bindet die gesamte Slot-Menge – zusammen mit den KEM-übergreifenden
Header-Feldern, die festlegen, wie die Slots interpretiert werden, und dem
Klartext-Hash-Anspruch des Elements – an den CEK und vereitelt damit das
Austauschen, Entfernen und Umordnen von Slots sowie das Einspleißen von Umschlägen.
Die Bindung ist eine zweistufige Konstruktion: Ein Slots-Transkript wird
einmal zu einem 32-Byte-slots_hash gehasht, und dieser Hash ist die Nachricht
eines CEK-geschlüsselten HMAC.
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 ist eine geschlossene Map, die genau diese Sieben-Schlüssel-Menge
trägt, mit canonicalEncode serialisiert, sodass beide Seiten byte-identische
Bytes erzeugen; ihre Schlüsselreihenfolge ist die bytweise Sortierung nach
RFC 8949 §4.2.1, niemals von Hand angeordnet. Der slots-Wert ist das durchmischte
Array geschlossener Slot-Maps genau so, wie sie auf dem Draht erscheinen
({epk, wrap} für x25519, {kem_ct, wrap} für mlkem768x25519), sodass der
vollständige Draht-Inhalt jedes Slots innerhalb des Transkripts liegt. Das Transkript
fixiert zusätzlich scheme, path, aead, kem und nonce: Ein Relais, das
eines dieser Header-Felder umkippt, während die Slot-Formen gültig bleiben, erzeugt
einen anderen slots_hash, sodass der MAC fehlschlägt. Die SHA-256-Präfixe von
slots_hash und hashes_hash (cardano-poe-slots-transcript-v1,
cardano-poe-item-hashes-v1) sind exaktes ASCII ohne Terminator und ohne
Längenpräfix.
hashes_hash ist es, was den Umschlag an den Hash-Anspruch dieses Elements
bindet: ein markierter SHA-256 über die canonicalEncode-Form der vollständigen
hashes-Map des Elements. Da der Empfänger slots_mac allein aus On-Chain-Bytes neu
berechnet, bestätigt ein MAC-Treffer, dass der Umschlag für genau diesen Anspruch
versiegelt wurde – ein Umschlag, der auf ein Element mit einer anderen hashes-Map
gespleißt wurde, scheitert am On-Chain-Abgleich, noch vor jedem Chiffretext-Abruf. Die
uris[] des Elements werden bewusst nicht gebunden, sodass der Chiffretext unter
einer neuen inhaltsadressierten URI neu gehostet werden kann, ohne den Umschlag
ungültig zu machen; wer als Absender die URI-Liste zum Anspruch zählt, bindet sie
stattdessen mit einer Signatur auf Datensatzebene.
In der HMAC_KEY-Ableitung ist salt = "" eine Null-Längen-Oktettkette, die
Konvention für ein fehlendes Salt aus
RFC 5869 §2.2 (HKDF-Extract
setzt HashLen Nullbytes ein, 32 bei SHA-256). Sie wird durch einen byte-exakten
Konformitäts-Testvektor fixiert, statt einem Bibliotheks-Default überlassen zu werden,
sodass eine Implementierung, die das fehlende Salt falsch behandelt, am Testvektor
scheitert, statt stillschweigend einen anderen Schlüssel abzuleiten.
slots_hash wird einmal pro Datensatz berechnet und ist über die Schleife der
Probeentschlüsselung beim Empfänger hinweg konstant; die MAC-Prüfung pro Slot
schlüsselt den HMAC aus jedem Kandidaten-CEK neu, jedoch stets über denselben
32-Byte-slots_hash. Die Commitment-Eigenschaft bleibt erhalten, weil der
HMAC-Schlüssel weiterhin HKDF-SHA-256(CEK, …) ist: Das Vorhashen des Transkripts
ändert lediglich die HMAC-Nachricht vom vollständigen Transkript zu dessen
SHA-256 und lässt die CEK-geschlüsselte Bindung unberührt.
Der MAC über die Slot-Menge ist durch enc.scheme festgelegt: Es gibt keine
Kennung dafür auf dem Draht, pro Scheme-Wert existiert genau eine Konstruktion, und
sie ist für beide KEMs identisch. slots_mac MUSS exakt 32 Bytes umfassen (bei
falscher Länge ENC_SLOTS_MAC_INVALID_LENGTH) und MUSS in konstanter Zeit
verifiziert werden.
Das Transkript hängt unmittelbar von den Wire-Bytes jedes Slots ab. Beide Slot-Felder
sind einzelne CBOR-Byte-Strings – epk ist 32 Byte, kem_ct ist 1120 Byte –, sodass
es keine Stückelung pro Feld zu normalisieren und keine Mehrdeutigkeit an Stückgrenzen
gibt: Die einzige Aufteilung, die Label 309 vornimmt, ist die Transport-Aufteilung über
den gesamten Körper unter Der Datensatz, rückgängig gemacht, bevor
irgendetwas davon läuft. Eine gekippte Bitfolge irgendwo in einem Slot ändert
slots_hash und lässt den MAC scheitern.
Die Inhaltsschicht braucht keine separate Bindung pro Durchlauf an die Slot-Menge: Der
Inhaltsschlüssel ist ein HKDF-Blatt des CEK, und der CEK ist bereits an den
vollständigen Header festgelegt – einschließlich hashes_hash – durch slots_mac. Das
Verändern eines Slots oder Header-Felds ändert, was der Empfänger ableitet, sodass der
Inhalts-Stream sich schlicht nicht öffnen lässt. Die AAD pro Chunk ist daher leer (siehe
Die Inhaltsschicht).
Die zwei KEMs
Das KEM, pro Datensatz durch enc.kem ausgewählt, legt die Form des Slots und die
Ableitung des KEK fest. Beide sind ab dem ersten Release unter enc.scheme = 1
registriert.
enc.kem | KEM | Öffentlicher Empfängerschlüssel | Slot-Form | KEK-Info-String |
|---|---|---|---|---|
"x25519" | X25519 (klassisch) | 32 Bytes | { epk: bstr(32), wrap: bstr(48) } | "cardano-poe-kek-v1" |
"mlkem768x25519" | X-Wing = X25519 + ML-KEM-768 | 1216 Bytes | { kem_ct: bstr(1120), wrap: bstr(48) } | "cardano-poe-kek-mlkem768x25519-v1" |
Erzeuger SOLLTEN standardmäßig mlkem768x25519 verwenden. Das hybride KEM ist
sowohl gegen klassische Angreifer als auch gegen Quantenangreifer nach dem Muster
„jetzt sammeln, später entschlüsseln" sicher und behält dabei die klassische
Sicherheit von X25519 als Untergrenze; der X-Wing-Combiner bindet beide gemeinsamen
Geheimnisse. Diese Untergrenze „nie unter die klassische X25519-Sicherheit" ist auf
gültig erzeugte Empfängerschlüssel beschränkt: Sie setzt voraus, dass der
öffentliche Schlüssel die Gültigkeitsprüfung der festgelegten X-Wing-Revision besteht
(angewandt bei der Kapselung, siehe Hybrid:
mlkem768x25519 weiter unten). Das klassische
x25519-KEM bleibt für Empfänger verfügbar, deren veröffentlichter Schlüssel nur
X25519 ist. Die Kennung mlkem768x25519 ist bewusst ohne Bindestriche geschrieben und
folgt damit der Schreibweise des X-Wing-/age-Ökosystems.
Beide KEMs verwenden dasselbe age-Stanza-Muster, also pro Empfänger
KEM-Material plus eine symmetrische Wicklung des Dateischlüssels, und dieselbe
Header-Bindung (MAC über die Slot-Menge und Inhalts-AEAD), sodass eine einheitliche
Konstruktion beide ohne HPKE-Abhängigkeit abdeckt. Der klassische x25519-Pfad
folgt eng dem nativen X25519-Empfänger von age. Der hybride mlkem768x25519-Pfad
weicht bewusst von ages eigener Post-Quanten-Wahl ab: age v1.3.0 liefert native
Post-Quanten-Empfänger (sichtbares Präfix age1pq…), die den Dateischlüssel über
HPKE SealBase (RFC 9180) über ein
ML-KEM-768-+-X25519-KEM wickeln, nicht über das Stanza-Muster. Die Stanza-Wicklung
für den hybriden Pfad beizubehalten, ist es, was eine einheitliche Wicklung und eine
einheitliche Header-Bindung beide KEMs abdecken lässt. Die hybride Wicklung erbt
daher die HPKE-Konstruktion von age nicht, und für sie wird kein
age-Erbschaftsanspruch erhoben; die eigenständige age1pqc-Empfängerkodierung
(siehe Schlüssel) spiegelt wider, dass die beiden hybriden Kodierungen
unabhängig voneinander sind.
Klassisch: x25519
Für jeden Empfänger erzeugt der Absender ein frisches ephemeres X25519-Schlüsselpaar, führt einen ECDH gegen den öffentlichen Empfängerschlüssel durch und leitet den KEK mit HKDF (RFC 5869) unter einem Salt aus markiertem Hash ab:
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 bytesDer 32-Byte-ephemere öffentliche Schlüssel epk ist das einzige Schlüsselmaterial
auf dem Draht; der öffentliche Empfängerschlüssel wird nie veröffentlicht. Der Salt
ist ein markierter SHA-256, der drei Werte bindet: pub_epk macht den KEK jedes
Slots eindeutig, pub_R bindet ihn an den konkreten Empfänger (was jeden Versuch
vereitelt, ein epk gegen einen anderen Empfänger zweckzuentfremden), und die
umschlageindeutige enc.nonce verankert den KEK an genau einen Umschlag – sodass ein
CSPRNG-Ausfall, der KEM-Zufälligkeit über zwei Umschläge hinweg wiederholt, nur zu
datensatzübergreifender Verknüpfbarkeit degradiert, nie zu einem wiederholten
(KEK, Null-Nonce)-Wrap-Paar. X25519-Implementierungen MÜSSEN das gemeinsame
Geheimnis aus lauter Nullen gemäß
RFC 7748 §6.1 ablehnen;
verbreitete Bibliotheken tun dies transitiv.
Hybrid: mlkem768x25519 (X-Wing)
Das hybride KEM ist die X-Wing-Konstruktion (draft-connolly-cfrg-xwing-kem-10), die ML-KEM-768 (FIPS 203) mit X25519 kombiniert. Jede Kapselung zieht frisches ML-KEM-Zufallsmaterial und einen frischen X25519-Ephemeral und liefert einen 1120-Byte-Chiffretext und ein 32-Byte-kombiniertes gemeinsames Geheimnis. Die KEK-Ableitung bindet den Empfänger über ein externes Salt, das über die eigenen Wire-Bytes des Slots berechnet wird:
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 stringGrößen der X-Wing-Schlüssel und -Chiffretexte:
| Komponente | Größe | Zusammensetzung |
|---|---|---|
| Öffentlicher Schlüssel | 1216 Bytes | ML-KEM-768 ek (1184) ‖ X25519 pk (32) |
| Chiffretext | 1120 Bytes | ML-KEM-768 ct (1088) ‖ X25519-Ephemeral (32) |
| Gemeinsames Geheimnis | 32 Bytes | Ausgabe des X-Wing-Combiners |
| Dekapselungsschlüssel | 32 Bytes | ein Seed; der öffentliche Schlüssel wird daraus abgeleitet |
Ein hybrider Slot trägt kein epk-Feld; der X25519-Ephemeral sind die letzten
32 Bytes des 1120-Byte-kem_ct. XWing.Encapsulate MUSS die Gültigkeitsprüfung des
öffentlichen Schlüssels aus der festgelegten X-Wing-Revision auf pub_R anwenden und
einen ungültigen Schlüssel ablehnen, statt an ihn zu kapseln; dies ist die
Vorbedingung, unter der die hybride Untergrenze nie unter die klassische
X25519-Sicherheit fällt. Die Konstruktion nutzt X-Wing über einen Adapter mit
ausschließlich benannten Feldern: Encapsulate(pk) liefert .ct (1120 B) und
.ss (32 B); Decapsulate(sk, ct) liefert das 32-Byte-gemeinsame Geheimnis.
Implementierungen MÜSSEN namentlich auf die API der festgelegten Revision abbilden
und DÜRFEN KEINE positionellen Rückgabewerte verbrauchen: Die festgelegte Revision
gibt bei der Kapselung (ss, ct) zurück und schreibt die Entkapselung als
Decapsulate(ct, sk), die Umkehrung einer naiven Links-nach-rechts-Lesart. Die
KEK-Ableitung bindet den Empfänger über ein markiertes Salt fester Länge,
SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R), wobei
kem_ct der 1120-Byte-Chiffretext exakt so ist, wie er im Slot mitgeführt wird, und
pub_R der 1216-Byte-X-Wing-Empfängerschlüssel. Das ist dieselbe Drei-Werte-Form, die
das klassische Salt unter seinem eigenen Label verwendet: Die kem_ct-Hälfte verankert
den KEK an einem slot-eindeutigen Wert, die pub_R-Hälfte bindet ihn an den konkreten
Empfänger, und enc.nonce verankert ihn an genau einen Umschlag, ausgedrückt durch
einen SHA-256-Digest, weil die hybriden Eingaben für ein rohes Salt zu groß sind. In beiden Salts ist der pub_R-Term die
kanonische Wire-Kodierung des Empfängerschlüssels: genau der 32-Byte-Wert
x25519_publicKey(priv_R) bei x25519, genau die festgelegte
1216-Byte-X-Wing-Public-Key-Bytefolge bei mlkem768x25519. Erzeuger und Verifizierer
MÜSSEN genau diese Kodierung verwenden und DÜRFEN kein nicht-kanonisches oder neu
kodiertes Äquivalent einsetzen, sonst leiten beide Seiten unterschiedliche KEKs ab und
ein ehrlicher Datensatz lässt sich nicht öffnen. Entscheidend ist, dass die Bindung
außerhalb des KEM berechnet wird, über die eigenen Wire-Bytes des Slots, sodass
die Konstruktion X-Wing als Black-Box-KEM behandelt: Sie nutzt allein die
öffentliche KEM-Schnittstelle (Kapseln, Entkapseln, das 32-Byte-gemeinsame Geheimnis)
und trifft keine Annahme über das interne Hashen des Combiners. Das KEM-distinkte
info-Label cardano-poe-kek-mlkem768x25519-v1 garantiert zusätzlich, dass ein für
ein KEM abgeleiteter KEK niemals gleich einem für das andere abgeleiteten KEK sein
kann, selbst bei einem identischen 32-Byte-gemeinsamen Geheimnis. Der
1120-Byte-Chiffretext wird als einzelner CBOR-Byte-String in slot.kem_ct
mitgeführt – nur der gesamte Datensatzkörper wird für den Transport gestückelt (siehe
Der Datensatz), niemals ein einzelnes Feld.
Ein KEM pro Datensatz
Ein einzelnes versiegeltes PoE-Item trägt genau ein enc.kem; jeder Slot
verwendet die Form und KEK-Ableitung dieses KEM. Eine Datei ist entweder
durchgängig klassisch oder durchgängig hybrid; Slots verschiedener KEMs DÜRFEN
NICHT im selben slots-Array vorkommen, und ein Verifizierer MUSS einen Datensatz
ablehnen, dessen Slot-Formen nicht zum deklarierten enc.kem passen
(ENC_SLOT_INVALID_SHAPE).
Das Kapselungsmaterial MUSS zudem innerhalb eines einzelnen slots-Arrays
verschieden sein: für x25519 MÜSSEN alle epk-Werte voneinander abweichen, für
mlkem768x25519 alle kem_ct-Werte. Eine Dublette wird, bevor
irgendein KEM- oder AEAD-Primitive läuft, mit ENC_SLOTS_DUPLICATE_KEM_MATERIAL
abgelehnt. Das ist der verifizierbare Anteil der Eindeutigkeitsinvariante des KEK
pro Slot, von der das Wickeln mit Null-Nonce abhängt: Eine KEK-Wiederverwendung über
Datensätze oder Schlüssel hinweg ist eine Erzeugerpflicht, die ein Verifizierer
nicht erkennen kann, doch eine Dublette innerhalb des Datensatzes ist strukturell
sichtbar und MUSS fehlschlagen.
Probeentschlüsselung durch den Empfänger
Ein Empfänger besitzt einen privaten Schlüssel (einen 32-Byte-X25519-Skalar für
x25519 oder einen 32-Byte-X-Wing-Dekapselungs-Seed für mlkem768x25519, beide
aus dem Seed abgeleitet; siehe Schlüssel). Er weiß im Voraus nicht,
welcher Slot, falls überhaupt, seiner ist, und entschlüsselt das Array daher
probeweise. Zwei Eigenschaften prägen die Schleife: Die Prüfung des MAC über die
Slot-Menge ist eingebettet (ein Slot wird nur akzeptiert, wenn sein
Kandidaten-CEK auch den slots_mac auf dem Draht reproduziert), und die Schleife
läuft über alle Slots ohne vorzeitigen Abbruch und wählt den Treffer in
konstanter Zeit aus, sodass ein zeitmessender Beobachter nicht herleiten kann,
welcher Slot-Index gepasst hat.
Bevor irgendein KEM- oder AEAD-Primitive aufgerufen wird, MUSS der Verifizierer die
strukturellen Formprüfungen durchführen (die Abwehr gegen das Partitionierungs-Orakel):
scheme == 1, aead/kem registriert, nonce 24 Bytes, slots_mac 32 Bytes,
slots nicht leer, das Empfängergeheimnis 32 Bytes, jedes slot.wrap exakt 48
Bytes, jedes x25519-epk exakt 32 Bytes ohne kem_ct, jedes
mlkem768x25519-kem_ct exakt 1120 Bytes ohne epk und die
Verschiedenheit des gesamten Kapselungsmaterials innerhalb von slots (andernfalls
ENC_SLOTS_DUPLICATE_KEM_MATERIAL).
Im selben vorgelagerten Durchlauf MUSS der Verifizierer zudem den Ressourcenverbrauch
des Parsers begrenzen: Die Referenzgrenzen sind MAX_SLOTS = 1024 Slots und 65536
Bytes für den dekodierten enc-Umschlag. Beide liegen weit über der
≈ 16-KiB-Transaktionsmetadatengrenze von Cardano, die einen ehrlichen Datensatz
beschränkt, sodass ein Datensatz, der eine der beiden überschreitet, fehlgeformt ist
und hier abgelehnt wird, ENC_SLOTS_TOO_MANY bei zu vielen Slots,
ENC_ENVELOPE_TOO_LARGE bei einem zu großen Umschlag, bevor irgendein KEM- oder
AEAD-Primitive läuft. Diese Grenzen sind verifiziererseitig durchgesetzte,
deployment-festgelegte Konstanten, keine Wire-Felder; ein Deployment DARF sie
verschärfen.
; 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_CIPHERTEXTDie KEK-Ableitung verzweigt nach enc.kem: Für x25519 führt der Empfänger einen
ECDH gegen slot.epk durch und leitet dasselbe markierte Salt über
enc.nonce || slot.epk || pub_R erneut ab; für mlkem768x25519 dekapselt er
slot.kem_ct direkt per X-Wing (ein einziger 1120-Byte-Byte-String) und berechnet
dasselbe markierte Salt über enc.nonce || slot.kem_ct || pub_R neu, wobei pub_R
sein eigener 1216-Byte-X-Wing-Schlüssel ist, abgeleitet aus dem gehaltenen Seed. Die Ablehnung des X25519-Geheimnisses aus lauter Nullen ist hier ausdrücklich,
statt sich transitiv darauf zu verlassen: Ein Slot, der darauf zugeschnitten ist, das
gemeinsame Geheimnis auf zeros(32) zu treiben
(RFC 7748 §6.1), setzt das
geheimnisunabhängige Gültigkeitsbit kem_ok auf falsch, der KEK wird in konstanter
Zeit auf einen aus zeros(32) unter demselben Salt und info abgeleiteten
dummy_KEK ausgewählt, sodass die Schleife identische Arbeit leistet, und kem_ok
fließt in ok ein, sodass ein Slot mit ungültigem ECDH nie angenommen werden kann,
ungeachtet des Wrap- oder MAC-Ergebnisses, und der Datensatz den einzelnen generischen
Fehlschlag zutage fördert, wenn sonst nichts passt. Alles nach dem Öffnen des Wrap,
also die Prüfung des MAC über die Slot-Menge, die Ableitung des Inhaltsschlüssels und
die Entschlüsselung des Inhalts, ist KEM-unabhängig.
Beide *_open_or_dummy-AEAD-Primitive sind atomar: Bei einem Fehlschlag der
Tag-Verifizierung geben sie keinen Klartext zurück, und der zurückgegebene Kandidat
(candidate_CEK beim Öffnen des Wrap, der plaintext beim Öffnen des Inhalts) ist ein
fester oder pseudozufälliger Dummy, der unabhängig vom fehlgeschlagenen Chiffretext
ist. Es wird nie unverifizierter Klartext an den Aufrufer freigegeben, sodass ein
fehlgeschlagenes Öffnen nicht zum Entschlüsselungsorakel werden kann.
Warum die MAC-Prüfung innerhalb der Schleife steht
Ein bösartiger Absender kann einen Slot bauen, der sich unter dem Schlüssel eines Empfängers
öffnet, aber einen vom Angreifer gewählten CEK liefert (eine Kapselung an den öffentlichen
Schlüssel des Empfängers benötigt keinen privaten Schlüssel). Würde ein Empfänger den ersten
AEAD-Erfolg als „seinen" akzeptieren, würde dieser gefälschte Slot einen ehrlichen weiter hinten
im Array verdecken. Indem die Prüfung von slots_mac in die Schleife eingebettet ist, wird ein
Slot nur akzeptiert, wenn sein Kandidaten-CEK den MAC über slots_hash reproduziert, sodass ein
gefälschter Slot übersprungen wird und das Durchsuchen fortgesetzt wird. Die Länge von slot.wrap
MUSS vor jedem AEAD-Aufruf auf 48 Bytes geprüft werden, eine Abwehr gegen
Partitionierungs-Orakel, die auch age v1 anwendet.
Mehrere treffende Slots: Dublizieren ist erlaubt, ein CEK-Konflikt nicht. Der
private Schlüssel eines Empfängers DARF rechtmäßig mehr als einen Slot treffen. Ein
Erzeuger darf den gleichen CEK an den gleichen Empfänger über mehrere Slots
hinweg versiegeln, jeden mit seinem eigenen frischen Ephemeral pro Slot, um die
scheinbare Empfängeranzahl aufzufüllen, eine gültige Datenschutztechnik. Der Verifizierer
wählt den CEK des ersten Treffers und DARF NICHT allein deshalb ablehnen, weil mehr
als ein Slot getroffen hat. Das ist verschieden von der Ablehnung
doppelten Kapselungsmaterials innerhalb des Datensatzes
(ENC_SLOTS_DUPLICATE_KEM_MATERIAL), die bei einem wiederholten epk oder
kem_ct greift: Eine ehrliche Dublette zieht für jedes Vorkommen
frische KEM-Zufälligkeit pro Slot, sodass sich ihr epk / kem_ct unterscheidet und
sie nie mit jener Prüfung kollidiert. Die einzige Anomalie, die der Verifizierer ablehnen
MUSS, sind zwei treffende Slots, die unterschiedliche CEKs wiederherstellen (in
konstanter Zeit verglichen): Die Schleife führt über alle Slots hinweg ein
cek_conflict-Bit mit und fördert, falls irgendein späterer Treffer einen CEK
wiederherstellt, der vom ausgewählten abweicht, den einzelnen generischen Fehlschlag
zutage. Das ist Verteidigung in der Tiefe: Unter der Commitment-Eigenschaft, die der
wiederhergestellte CEK liefert (der MAC über die Slot-Menge bindet den CEK an ein
einzelnes Slot-Transkript; siehe Anonymität und die Aufteilung pro
KEM), ist ein Treffer mit abweichendem CEK bereits
unmöglich, denn er ist genau die Multi-Key-Kollision, die das Commitment ausschließt,
sodass die Prüfung gegen eine fehlerhafte Implementierung oder eine künftige
Abschwächung dieser Annahme „fail closed“ fällt.
Eine generische Fehlschlag-Form, konstante Zeit über alle Slots
Ein nicht vertrauenswürdiger Aufrufer MUSS genau eine generische Fehlschlag-Form erhalten,
unabhängig davon, warum die Entschlüsselung scheiterte, sei es, dass kein Slot sich öffnete, die
Slot-Menge manipuliert wurde oder das Inhalts-AEAD fehlschlug, und die Antwort DARF diese Fälle
NICHT unterscheiden noch verraten, welcher Slot passte. Eine Implementierung KANN interne typisierte
Codes (WRONG_RECIPIENT_KEY, kein Slot öffnet sich; TAMPERED_HEADER, ein Slot öffnet sich, aber
kein Kandidaten-CEK reproduziert den slots_mac über slots_hash; TAMPERED_CIPHERTEXT, das
Inhalts-AEAD scheitert, nachdem ein CEK wiederhergestellt wurde) einem vertrauenswürdigen lokalen
Aufrufer zur Diagnostik zutage fördern, doch diese Codes DÜRFEN über eine unterscheidbare Antwort
NICHT an einen externen Beobachter gelangen.
Zum Timing: Der Verifizierer DARF an der Kein-Treffer-Prüfung (if NOT found) vor der
Inhaltsentschlüsselung zurückkehren. Diese frühe Rückkehr verrät allein Empfänger versus
Nicht-Empfänger, nie welcher Slot passte und kein Schlüsselmaterial, denn die obige
Über-alle-Slots-Schleife ist bis zum Erreichen der Prüfung bereits vollständig durchgelaufen. Ein
einheitliches Timing zwischen dem Nicht-Empfänger-Fall und einem Empfänger, dessen Chiffretext nicht
aufgeht, ist NICHT erforderlich, und ein Dummy-Inhaltsöffnen DARF NICHT vorgeschrieben werden:
Jeden Nicht-Empfänger die vollen Kosten der Inhaltsentschlüsselung zahlen zu lassen, erkauft keine
Privatheit, die die Schleife nicht bereits bietet. Die Konstantzeit-Garantie, die gilt, ist die
Über-alle-Slots-Invariante: Die Schleife verarbeitet eine konstante Anzahl von Slot-Operationen pro
privatem Schlüssel ohne vorzeitigen Abbruch, sodass ein netzwerkseitiger Beobachter allein die
Slot-Anzahl erfährt, nie welchen Slot (falls überhaupt) der Schlüssel entpackt. Wer mehrere Schlüssel
besitzt (etwa archivierte Schlüssel über eine Identitätsrotation hinweg), iteriert privater Schlüssel
× Slot und leitet die pub_R-Salt-Hälfte aus dem aktuellen Schlüssel neu ab; er DARF über die
Schlüssel hinweg vorzeitig abbrechen (was nur das schwache Signal „welcher Schlüssel passte"
preisgibt), MUSS aber über die Slots eines einzelnen Schlüssels konstantzeitig bleiben.
Nach dem Wiederherstellen des Klartexts berechnet der Empfänger, in der
Anwendungsschicht, nicht in der Entschlüsselungsfunktion, den Klartext-Hash neu und
prüft ihn gegen items[].hashes. Eine Abweichung bedeutet, dass der
On-Chain-Commitment des Datensatzes nicht zu den entschlüsselten Bytes passt, und
der Empfänger MUSS sich weigern, auf Grundlage des Klartexts zu handeln. Das ist
der Schritt, der den Kreis schließt: Die Blockchain hat einen Commitment zum
Zeitpunkt T bezeugt, und der Empfänger bestätigt, dass es ein Commitment auf genau
diese Bytes ist.
Passphrase-Pfad
Der alternative Pfad zur Schlüsselzustellung ersetzt die Empfänger-Slots durch eine
Passphrase. Es gibt kein slots-Array, kein slots_mac, keinen Ephemeral pro Slot
und keine Probeentschlüsselungsschleife: Der CEK wird über Argon2id
(RFC 9106) direkt aus einer
normalisierten Passphrase abgeleitet, über einen On-Chain-Salt und On-Chain-Parameter.
Das Schlüssel-Commitment, das auf dem Slot-Pfad slots_mac liefert, liegt hier
stattdessen in einem 32-Byte-Header innerhalb des Chiffretext-Blobs, den
STREAM-Chunks vorangestellt – dasselbe Objekt, dieselbe URI, derselbe Abruf.
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 pathDer enc.passphrase-Block auf dem Draht ist { alg, salt, params }; er benennt die
KDF ("argon2id"), den Salt und die Parameter. Label 309 legt eine
Parameter-Untergrenze von m ≥ 65536 KiB (64 MiB), t ≥ 3, p ≥ 1 fest; der
Erzeuger wählt Werte auf oder oberhalb der Untergrenze, und der Salt umfasst 16 bis
64 Bytes einschließlich (die Obergrenze von 64 Bytes ist die
Byte-String-Obergrenze für Metadaten). Wo die Plattform es unterstützt, SOLLTEN
Erzeuger p = 4 verwenden (das zweite empfohlene Profil aus
RFC 9106 §4); Verifizierer DÜRFEN
jedes p ≥ 1 akzeptieren, vorbehaltlich der Deployment-Obergrenzen weiter unten.
Das PASSPHRASE_TRANSCRIPT bindet die KDF-Parameter, die Header-Felder und den
Hash-Anspruch des Elements in das Commitment: Der Verifizierer berechnet das Transkript
aus der empfangenen enc-Map und den hashes des Elements neu, sodass eine
Manipulation an salt, einem params-Wert, nonce oder aead – oder das Einspleißen
des Umschlags auf einen anderen Hash-Anspruch – einen anderen pw_hash ergibt und die
Commitment-Prüfung scheitern lässt. Der Inhalt wird anschließend im selben segmentierten
STREAM wie auf dem Slot-Pfad versiegelt, unter dem Inhaltsschlüssel des
Passphrase-Pfads. Der Wert "normalization" ist eine scheme-feste Konstante, die in
das Transkript eingespeist wird, um exakt das Profil festzuhalten, unter dem der CEK
abgeleitet wurde; er wird niemals auf dem Draht serialisiert.
Verifizierungsreihenfolge. Der Verifizierer leitet den Kandidaten-CEK aus der
eingegebenen Passphrase ab, liest die führenden 32 Bytes des Chiffretext-Blobs,
berechnet das Commitment neu und vergleicht es in konstanter Zeit – bevor irgendein
STREAM-Chunk geöffnet wird. Ein Passphrase-Pfad-Blob, der kürzer als 48 Bytes ist –
der 32-Byte-Commitment-Header plus das 16-Byte-Minimum des STREAM –, kann nicht
wohlgeformt sein und ist fehlgeformter Chiffretext (TAMPERED_CIPHERTEXT). Bei einem
Nichttreffer – falsche Passphrase, manipulierter salt / params, manipulierter Header
oder ein eingespleißter Umschlag – fördert der Verifizierer denselben einzelnen
generischen Fehlschlag zutage wie bei jedem anderen Entschlüsselungsfehler und DARF das
Streaming NICHT beginnen. Eine falsche Passphrase ist daher von einem manipulierten
Datensatz nicht zu unterscheiden.
Vor der Normalisierung und Argon2id MUSS eine Implementierung die Länge der rohen
Passphrasen-Eingabe begrenzen, damit eine übergroße Passphrase keine Dienstverweigerung
vor dem KDF antreiben kann: Die Referenzgrenze beträgt 4096 UTF-8-Bytes roher
Eingabe, abgelehnt vor jeder Normalisierungs- oder Hash-Arbeit. Wie die MAX_SLOTS- und
die Grenze für den dekodierten enc-Umschlag, die der Slots-Pfad durchsetzt, ist dies
eine verifiziererseitig durchgesetzte, deployment-festgelegte Konstante, kein Wire-Feld,
und ein Deployment DARF sie verschärfen. Über die Parameter-Untergrenze hinaus SOLLTEN
Implementierungen zudem obere Schranken für m, t und p gegen verifiziererseitige
Dienstverweigerung durchsetzen; jene Obergrenzen sind nicht normativ (hardwareabhängig)
und DÜRFEN NICHT mit der Untergrenze vermengt werden.
Warum das Commitment off-chain liegt
Ein On-Chain-Passphrase-Commitment würde jedem Beobachter ein kostenloses Offline-Test-Orakel in die Hand geben – einen Kandidaten-CEK aus einer geratenen Passphrase ableiten und gegen die Chain prüfen –, und das für jeden Passphrase-Datensatz, für immer, einschließlich Datensätzen, deren Chiffretext zurückgehalten wird. Das Commitment innerhalb des Chiffretext-Blobs zu führen, bedeutet, dass das Prüfen eines Versuchs den Blob selbst erfordert: Ein Datensatz mit zurückgehaltenem Chiffretext legt auf dem permanenten Ledger kein per Passphrase erratbares Material offen, und ein berechtigter Empfänger, der den Blob bereits besitzt, zahlt nichts dafür, zuerst einen 32-Byte-Header zu lesen.
Das Normalisierungsprofil
Die Normalisierung, die vor Argon2id auf die Passphrase angewendet wird, ist das
feste Profil cardano-poe-pw-norm-v1. Es ist normativ: Zwei Implementierungen
MÜSSEN aus derselben Passphrase einen byte-identischen CEK ableiten, und die einzige
Möglichkeit, das zu gewährleisten, ist eine fixierte Normalisierung. Das Profil,
in dieser Reihenfolge angewendet, lautet:
- NFKC. Normalisierungsform KC gemäß UAX #15 unter Unicode 16.0 anwenden.
- Leerraum. „Leerraum" als jedes Zeichen definieren, das unter Unicode 16.0
die Unicode-Eigenschaft
White_Spaceträgt, und jede maximale Folge solcher Zeichen zu einem einzelnen U+0020 SPACE zusammenfassen. - Trimmen. Führenden und abschließenden Leerraum entfernen.
- Kodieren. Das Ergebnis als UTF-8 kodieren; diese Bytes sind die Passwort-Eingabe für Argon2id.
Die Unicode-Version ist wörtlich auf Unicode 16.0 fixiert und DARF NICHT
schwanken: Die Menge der White_Space-Eigenschaft und die NFKC-Abbildungstabellen
sind versionsabhängig, und ein Verifizierer, der das Profil gegen eine andere
Unicode-Version auflöst, könnte aus derselben Passphrase einen anderen CEK ableiten
und einen ehrlichen Datensatz nicht entschlüsseln. Eine künftige Revision, die eine
neuere Unicode-Version übernimmt, tut dies unter einer neuen Profilkennung, nicht
durch eine Neuauslegung von cardano-poe-pw-norm-v1.
Die Entropie der Passphrase ist die einzige Hürde
Der Salt und die Argon2id-Parameter liegen für immer öffentlich in der Blockchain, sodass ein Angreifer unbegrenzt Offline-Zeit hat, um die Passphrase gegen sie per Brute-Force anzugreifen. Die Entropie der Passphrase ist die einzige Sicherheitsmarge auf diesem Pfad. Erzeuger SOLLTEN eine vom CSPRNG erzeugte Diceware-Passphrase statt einer von Menschen gewählten verwenden und SOLLTEN eine sichtbare Warnung anzeigen, wenn sie getippte Passphrasen akzeptieren, da der On-Chain-Chiffretext dauerhaft einem Offline-Angriff ausgesetzt sein wird.
Forward Secrecy und Unabhängigkeit pro Slot
Die Slots-Konstruktion verwendet ephemer-statisches ECDH (oder eine frische X-Wing-Kapselung) mit einem frischen Ephemeral pro Slot, was zwei Eigenschaften einbringt, die ein statisch-statisches oder ein Design mit gemeinsamem Ephemeral verlieren würde:
- Forward Secrecy gegen eine Kompromittierung des Absenders. Der Absender hält in der Konstruktion keinen langlebigen Schlüssel; der Ephemeral wird nach dem Versiegeln genullt. Wird der Zustand des Absenders später kompromittiert, lassen sich vor der Kompromittierung veröffentlichte Datensätze dennoch nicht entschlüsseln.
- Unabhängigkeit pro Slot. Verschiedene Empfänger erhalten verschiedene Ephemerals, also verschiedene gemeinsame Geheimnisse und KEKs. Gibt ein Empfänger seinen gewickelten CEK preis, offenbart das den CEK (unvermeidlich, denn es ist der Dateischlüssel), aber niemals den KEK eines anderen Empfängers.
Eine versiegelte PoE hat konstruktionsbedingt keine Forward Secrecy für den Empfänger: Sobald ein Datensatz für einen langlebigen Empfängerschlüssel versiegelt ist, kann der Inhaber des passenden privaten Schlüssels ihn für immer entschlüsseln. Das ist eine Eigenschaft der Public-Key-Verschlüsselung auf einen langlebigen Schlüssel, kein Defekt.
Anonymität und die Aufteilung pro KEM
Trägt ein versiegelter PoE-Datensatz keine sigs, sind seine Wire-Bytes
unabhängig von der Identität des Absenders: Jeder Slot trägt nur pro Datensatz und
pro Slot ephemeres KEM-Material (den X25519-Ephemeral in slot.epk oder den
X-Wing-Chiffretext in slot.kem_ct), die langlebigen Schlüssel des Absenders
erscheinen nie, die Slots sind CSPRNG-durchmischt, kein öffentlicher
Empfängerschlüssel liegt auf dem Draht, und kein beschreibendes Feld (Dateiname,
MIME-Typ, Größe) ist vorhanden. Ein unsignierter versiegelter Datensatz bindet
daher keine Absenderidentität in der Blockchain, genau das, was Whistleblower-Übergaben,
Auktionen mit verdeckten Geboten und die treuhänderische Sicherung von
Beweismitteln erfordern.
Für beide KEMs sind die ehrlichen Leckagen identisch und unvermeidlich: die
Slot-Anzahl, die Unterscheidung versiegelt gegenüber offen und die KEM-Familie
klassisch gegenüber hybrid (enc.kem) sind für jeden Beobachter sichtbar; mehr
über die Empfänger nicht.
Der stärkere Anspruch, dass nämlich ein Angreifer, der eine Menge von Kandidaten-Empfängerschlüsseln hält, nicht prüfen kann, ob ein gegebener Slot an einen von ihnen gerichtet ist (Schlüsselprivatheit / Empfänger-Anonymität), ist eine Eigenschaft pro KEM:
x25519— schlüsselprivat. Die Kapselung pro Slot ist ein frischer ephemerer öffentlicher Schlüssel, statistisch unabhängig vom Empfängerschlüssel. Allein ausslot.epkundslot.wrapkann ein Angreifer, der Kandidaten-Empfängerschlüssel hält, ohne den passenden privaten Schlüssel nicht entscheiden, an welchen Kandidaten (falls überhaupt) der Slot gerichtet ist. Der klassische Pfad ist daher schlüsselprivat, was zugleich die Unverknüpfbarkeit über Datensätze hinweg liefert: Zwei versiegelte PoEs an denselben Empfänger sehen wie unzusammenhängendeepk-/wrap-Blobs aus.mlkem768x25519— nicht beansprucht. Empfänger-Anonymität gegenüber einem Angreifer, der Kandidaten-Empfängerschlüssel hält, ist eine eigenständige Eigenschaft, die nicht aus der IND-CCA-Sicherheit des hybriden KEM folgt. Label 309 beansprucht sie für den X-Wing-Pfad nicht, solange sie nicht eigenständig für X-Wing belegt ist. Eine Bereitstellung, deren Bedrohungsmodell Empfänger-Anonymität gegenüber einem schlüsselbesitzenden Angreifer verlangt, DARF sich für diese Eigenschaft NICHT auf den hybriden Pfad verlassen.
Absender, denen eine Korrelation über Zeitpunkte hinweg Sorge bereitet, MÜSSEN ihre Veröffentlichungen bündeln und von der kritischen Zeitachse entkoppeln; Kryptografie auf Protokollebene kann Metadaten-Zeitangriffe nicht lösen.
Der MAC über die Slot-Menge ist das Commitment; die Wicklung muss es nicht sein
Der wiederhergestellte CEK ist ein Commitment auf die Slot-Menge, die der Empfänger
abgeglichen hat: Ein böswilliger Absender kann keine zwei verschiedenen Slot-Mengen konstruieren,
die ein einzelner Empfänger als die seine akzeptiert. Die hier erforderliche Eigenschaft ist
eingeschränktes Schlüssel-Commitment für den Umschlag-CEK im Sinne von RFC
9771, nämlich dass der wiederhergestellte CEK an ein
einzelnes Slot-Transkript bindet, nicht eine vollständige committing AEAD über beliebige
Eingaben. Sie beruht auf der Multi-Key-Kollisionsresistenz von CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) für adversariell gewählte
CEKs und Transkripte, einer generischen Kollisionsmarge von ~128 Bit (die Geburtstagsschranke
auf einer 256-Bit-Ausgabe), die für das Bedrohungsmodell angemessen ist. Die
Manipulationsfestigkeit des Transkripts selbst erbt die ~2^128-Kollisionsschranke von SHA-256:
Jede Änderung an den committeten Header-Feldern oder Slot-Bytes verändert slots_hash, und einen
unveränderten slots_hash über ein anderes Transkript zu fälschen, ist genau diese
~2^128-Kollisionssuche. Weil das Commitment von slots_mac geliefert wird, muss das wrap-AEAD
pro Slot keine committing AEAD sein; das voreingestellte, nicht committing ChaCha20-Poly1305
ist hier solide.
Verbotene Muster
Eine konforme Implementierung DARF NICHT:
- einen Ephemeral pro Slot wiederverwenden, weder über Slots noch über Datensätze hinweg, oder einen KEK auf andere Weise wiederholen lassen; das Wickeln mit Null-Nonce hängt von der Eindeutigkeit des KEK pro Slot ab.
- einen CEK über Umschläge hinweg wiederverwenden – ein frischer CSPRNG-CEK pro
enc-tragendem Element, innerhalb eines Datensatzes wie über Datensätze hinweg. - einen Passphrase-Salt wiederverwenden – für jeden Passphrase-Umschlag einen
frischen CSPRNG-
enc.passphrase.salterzeugen; der Salt ist der einzige datensatzübergreifende Trenner für eine wiederverwendete Passphrase. - KEMs mischen innerhalb eines
slots-Arrays (einenc.kempro Datensatz). - Slots in der Eingabereihenfolge veröffentlichen; das Durchmischen per CSPRNG ist erforderlich.
- den CEK mit einer anderen Nonce als der 12-Byte-Null-Nonce oder mit leerer
Wrap-AEAD-AAD wickeln; die Wrap-AAD ist das
info-Label-Literal des KEM. - einen öffentlichen Empfängerschlüssel auf den Draht legen; das Design der Probeentschlüsselung ist das Datenschutzmerkmal, und das Veröffentlichen von Public Keys hebt es auf.
- die Verifizierung von
slots_macüberspringen; ohne sie gelingt das Austauschen von Slots. - den Klartext unter der
ar://-/ipfs://-URI speichern; veröffentlicht wird nur der Chiffretext; der Klartext wird out of band zugestellt oder vom Absender vorgehalten. - Chiffretext über ein anderes Schema als
ar://oderipfs://referenzieren; die inhaltsadressierten Schemata binden die URI an die Bytes; eine von einem Host ausgelieferte URL würde einen gesonderten On-Chain-Commitment auf den Chiffretext erfordern, den die versiegelte PoE nicht trägt. - den CEK, einen KEK, den HMAC-Schlüssel der Slot-Menge, den Passphrase-MAC-Schlüssel, den Inhaltsschlüssel, ein gemeinsames ECDH-Geheimnis, einen privaten ephemeren Schlüssel oder einen privaten Empfängerschlüssel protokollieren oder dauerhaft speichern.
Verwandte Seiten
- Schlüssel — die aus dem Seed abgeleiteten X25519- und X-Wing-Schlüsselpaare, die das Schlüsselmaterial von Empfänger und Absender liefern.
- Der Datensatz — wo
encin der Datensatz-Map sitzt und der Transport über den gesamten Körper, der den Datensatz on-chain trägt. - Algorithmen-Registries — die Kennungen
enc.aead,enc.kemund die der Passphrase-KDF sowie die ihnen zugrunde liegenden Primitiven. - Inhalt und Hashing — der Commitment auf den Klartext-Hash, den jeder versiegelte Datensatz trägt.
- Verifizierung — die Validierungs-Pipeline, warum der Validator nie entschlüsselt, und der Fehlerkatalog.
Signaturen
Das optionale `sigs`-Array auf Datensatzebene, ein abgetrenntes COSE_Sign1 über den gesamten Datensatzkörper, sein domänengetrennter signierter Inhalt, die beiden Wege zur Übermittlung des Signaturschlüssels und die strenge Ed25519-Verifizierung.
Verifizierung
Die drei Verifizierer-Rollen von Label 309, die Verdikt-Zustände, die Bestätigungstiefe und der typisierte Fehlerkatalog, also wie jede prüfende Stelle allein aus öffentlicher Infrastruktur zur selben Antwort gelangt.