Dies ist eine informative Übersetzung. Maßgeblich ist die englische Fassung; sie hat im Zweifel Vorrang. Zur englischen Fassung

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 enc auf. 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 klassischen x25519-Pfad beansprucht wird; für den hybriden mlkem768x25519-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.

FeldStatusBedeutung
schemeERFORDERLICHVersion der Konstruktionsfamilie. v1 definiert scheme = 1.
aeadERFORDERLICHKennung des Inhaltsformats. v1 definiert "chacha20-poly1305-stream64k".
nonceERFORDERLICH24 zufällige Bytes — das umschlageindeutige Salt des Inhaltsschlüssels und jedes Slot-KEK.
kemnur Slots-PfadKEM-Auswahl pro Slot ("x25519" oder "mlkem768x25519").
slotsein PfadArray von Empfänger-Slots (mehrere Empfänger).
slots_macnur Slots-Pfad32-Byte-HMAC, der die Slot-Menge und den Hash-Anspruch des Elements an den Inhaltsschlüssel bindet.
passphraseder andere PfadPassphrase-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:

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

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:

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

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

Das 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):

CBOR
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:

  1. Wählt ein KEM für den gesamten Datensatz und erzeugt den CEK (32 zufällige Bytes) und die nonce (24 zufällige Bytes).
  2. Leitet für jeden Empfänger einen KEK pro Slot ab und wickelt den CEK darunter (KEM-Details siehe unten).
  3. Durchmischt das Slot-Array mit einem CSPRNG (unverzerrtes Fisher-Yates).
  4. Bildet das Slots-Transkript über das durchmischte Array, die KEM-übergreifenden Header-Felder und den Hash-Anspruch des Elements, hasht es zu slots_hash und berechnet slots_mac als CEK-geschlüsselten HMAC über diesen Hash.
  5. Leitet den Inhaltsschlüssel aus dem CEK und enc.nonce ab 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):

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

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.

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

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

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

SLOTS_TRANSCRIPT 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.kemKEMÖffentlicher EmpfängerschlüsselSlot-FormKEK-Info-String
"x25519"X25519 (klassisch)32 Bytes{ epk: bstr(32), wrap: bstr(48) }"cardano-poe-kek-v1"
"mlkem768x25519"X-Wing = X25519 + ML-KEM-7681216 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:

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

Der 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:

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

Größen der X-Wing-Schlüssel und -Chiffretexte:

KomponenteGrößeZusammensetzung
Öffentlicher Schlüssel1216 BytesML-KEM-768 ek (1184) ‖ X25519 pk (32)
Chiffretext1120 BytesML-KEM-768 ct (1088) ‖ X25519-Ephemeral (32)
Gemeinsames Geheimnis32 BytesAusgabe des X-Wing-Combiners
Dekapselungsschlüssel32 Bytesein 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_CIPHERTEXT

Die 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.

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

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

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

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

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

Der 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:

  1. NFKC. Normalisierungsform KC gemäß UAX #15 unter Unicode 16.0 anwenden.
  2. Leerraum. „Leerraum" als jedes Zeichen definieren, das unter Unicode 16.0 die Unicode-Eigenschaft White_Space trägt, und jede maximale Folge solcher Zeichen zu einem einzelnen U+0020 SPACE zusammenfassen.
  3. Trimmen. Führenden und abschließenden Leerraum entfernen.
  4. 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 aus slot.epk und slot.wrap kann 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ängende epk-/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.salt erzeugen; der Salt ist der einzige datensatzübergreifende Trenner für eine wiederverwendete Passphrase.
  • KEMs mischen innerhalb eines slots-Arrays (ein enc.kem pro 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:// oder ipfs:// 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 enc in 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.kem und 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.