Ceci est une traduction à titre informatif. La version anglaise est normative et prévaut. Lire la version anglaise

PoE scellée

L’enveloppe de chiffrement de Label 309 : comment un expéditeur scelle un contenu vers une ou plusieurs clés de destinataire, tandis que la chaîne ne transporte que l’empreinte du texte en clair et les emplacements de clé enveloppés, jamais le texte en clair et jamais les destinataires.

Une PoE scellée ancre un engagement horodaté sur un texte en clair tout en maintenant ce texte en clair lisible uniquement par un public choisi. L’enregistrement sur la chaîne transporte l’empreinte du texte en clair — la preuve de temporalité, exactement comme pour tout autre enregistrement — ainsi qu’une enveloppe de chiffrement (enc) qui contient le matériel nécessaire pour récupérer la clé de chiffrement du contenu. Le texte chiffré lui-même ne touche jamais la chaîne : il réside à un URI adressé par contenu (ar:// ou ipfs://). Rien sur la chaîne ne révèle le texte en clair, et rien ne révèle qui sont les destinataires.

Cette page spécifie l’enveloppe enc : ses deux chemins de livraison de clé mutuellement exclusifs, les emplacements de clé par destinataire, le MAC de l’ensemble des emplacements, le STREAM segmenté du contenu et le déchiffrement d’essai qu’un destinataire effectue pour découvrir et ouvrir un message qui lui est adressé. Les clés de destinataire elles-mêmes — les paires de clés X25519 et X-Wing dérivées du seed — sont définies sur Clés ; cette page les consomme. La place de la carte enc dans la carte de l’enregistrement, et le transport du corps complet qui la porte sur la chaîne, sont définis sur L’enregistrement.

Ce n’est pas HPKE

Ce n’est pas le HPKE de RFC 9180. C’est une conception multidestinataire de type age, KEM-puis-enveloppe : encapsulation par destinataire, une clé de chiffrement de clé dérivée par HKDF et une clé de chiffrement de contenu enveloppée par AEAD, avec le motif de strophes d’age v1 transposé en CBOR canonique. Elle n’a ni suite_id ni la cascade LabeledExtract/LabeledExpand ; évaluez-la au regard de la littérature sur ECIES et de la spécification age v1, non au regard de l’analyse de HPKE.

Le modèle et ses propriétés de confidentialité

Un expéditeur veut publier un engagement permanent et horodaté prouvant qu’un texte en clair précis a été scellé pour un public précis à l’instant T — tout en garantissant que seul ce public puisse le lire. Une PoE en empreinte seule fournit l’affirmation de temporalité mais aucun lien avec le public ; une PoE sur du texte chiffré ouvert n’offre aucune confidentialité. La PoE scellée fait le pont entre les deux : l’enregistrement s’engage sur l’empreinte du texte en clair (publique, horodatée) et transporte le matériel de livraison de clé dans enc, tandis que le texte chiffré à l’URI ar:// ou ipfs:// est indéchiffrable sans un secret de déverrouillage correspondant.

La construction est délibérément conçue pour que la chaîne divulgue le moins possible sur le message et rien sur son public :

  • Le texte en clair n’est jamais sur la chaîne. Seuls le sont son empreinte et les clés enveloppées. Quiconque obtient ensuite le texte en clair peut prouver « ce texte en clair exact a été engagé à l’heure du bloc T » ; personne d’autre n’apprend ce qui a été scellé.
  • Les clés publiques des destinataires ne sont jamais sur la chaîne. La clé publique d’un destinataire n’apparaît nulle part dans enc. Un destinataire reconnaît un message comme étant le sien uniquement en déchiffrant d’essai avec succès un emplacement — il n’y a aucun champ de destinataire à lire. Un observateur sans clés candidates n’apprend que le nombre d’emplacements, la famille de KEM (enc.kem) et la distinction scellé/ouvert. La propriété plus forte — qu’un adversaire qui détient des clés de destinataire candidates ne puisse pas pour autant tester laquelle (s’il y en a une) un emplacement vise — est la confidentialité de clé, revendiquée uniquement pour le chemin classique x25519 ; elle n’est pas revendiquée pour le chemin hybride mlkem768x25519 (voir Anonymat et la séparation par KEM).
  • Les destinataires n’apprennent rien les uns des autres. Chaque emplacement par destinataire est une clé enveloppée opaque. Un destinataire qui ouvre son propre emplacement ne peut dériver la clé d’aucun autre destinataire, ni savoir à qui d’autre le message était adressé.
  • L’ordre des emplacements ne divulgue rien. L’ordre dans lequel un expéditeur énumère les destinataires (par exemple « le principal en premier ») est une métadonnée privilégiée. Le tableau des emplacements est mélangé avec un CSPRNG avant la publication, de sorte que même l’ordre positionnel ne transporte aucun signal.
  • Une PoE scellée non signée préserve l’anonymat de l’expéditeur. Les signatures de paternité sont facultatives (voir Signatures). Un enregistrement scellé sans sigs[] ne lie aucune identité d’expéditeur sur la chaîne — exactement ce qu’exigent les fuites de lanceurs d’alerte, les enchères sous pli scellé et la consignation de preuves.

Ce que la chaîne révèle bel et bien est étroit : qu’un enregistrement est une PoE scellée (enc est présent), l’empreinte du texte en clair, l’horodatage du bloc et le nombre d’emplacements (la longueur du tableau). Le nombre est le seul fait adjacent aux destinataires qui soit exposé, et il ne révèle que « combien », jamais « qui ». La corrélation temporelle entre enregistrements est une question de métadonnées que la cryptographie au niveau du fil ne peut résoudre ; les expéditeurs qui ont besoin de la déjouer doivent regrouper leurs publications en dehors de la chronologie sensible.

Les clés publiques des destinataires s’échangent hors bande. Label 309 ne prescrit aucun mécanisme de découverte : un destinataire peut publier sa clé sur son propre site web, dans un enregistrement DNS, sur un profil social, dans un code QR ou dans une auto-attestation sur la chaîne. Un vérificateur prend en entrée les octets de la clé du destinataire et ne formule aucune affirmation sur l’identité de son titulaire — la provenance est une décision de confiance de l’expéditeur, exactement comme lorsqu’on envoie une clé PGP par courriel.

L’enveloppe et ses deux chemins

La carte enc transporte des champs communs plus exactement un des deux chemins de livraison de clé mutuellement exclusifs. Un validateur structurel impose cette exclusivité ; un enregistrement qui transporte les deux, ou aucun, est rejeté.

ChampStatutSignification
schemeREQUISVersion de la famille de construction. La v1 définit scheme = 1.
aeadREQUISIdentifiant du format de contenu. La v1 définit "chacha20-poly1305-stream64k".
nonceREQUIS24 octets aléatoires — le sel unique par enveloppe de la clé de contenu et de chaque KEK d’emplacement.
kemchemin slots seulSélecteur de KEM par emplacement ("x25519" ou "mlkem768x25519").
slotsun seul cheminTableau d’emplacements de clé par destinataire (multidestinataire).
slots_macchemin slots seulHMAC de 32 octets liant l’ensemble des emplacements et l’affirmation d’empreinte de l’élément à la clé de contenu.
passphrasel’autre cheminBloc de KDF par phrase secrète (clé dérivée de la phrase secrète).
  • enc.slots — multidestinataire. L’enveloppe transporte N emplacements de clé enveloppés indépendamment, un par destinataire. Le texte chiffré est indéchiffrable sans une clé privée correspondant à l’un des emplacements. Spécifié plus bas dans Emplacements et MAC de l’ensemble des emplacements.
  • enc.passphrase — dérivée d’une phrase secrète. L’enveloppe ne transporte aucun emplacement ; la clé de contenu est dérivée directement d’une phrase secrète normalisée. Spécifié plus bas dans Chemin par phrase secrète.

Les deux chemins partagent scheme, aead et nonce. Ils diffèrent par quelle clé est présente et, par conséquent, par où réside l’engagement envers la clé. Sur le chemin slots, l’engagement est sur la chaîne : slots_mac est un HMAC à clé dérivée de la CEK sur une transcription qui fixe les champs d’en-tête, l’ensemble des emplacements et l’affirmation d’empreinte de l’élément, de sorte qu’un destinataire confirme la bonne clé avant de récupérer quoi que ce soit. Sur le chemin par phrase secrète, il n’y a aucun emplacement à lier, donc l’engagement est un en-tête de 32 octets transporté à l’intérieur du blob de texte chiffré — tester une tentative de phrase secrète exige le blob lui-même, jamais la seule chaîne publique. Chaque chemin sérialise sa transcription avec la même fonction canonicalEncode, et un producteur ou un vérificateur sélectionne le chemin en inspectant lequel de slots / passphrase est présent. Les deux chemins sont exhaustifs et mutuellement exclusifs.

enc.scheme nomme la famille de construction, indépendamment du champ v de l’enregistrement. Un vérificateur DOIT exiger enc.scheme === 1 et rejeter toute autre valeur. Le champ est réservé à un futur changement transversal — un ordonnancement différent du MAC de l’ensemble des emplacements ou un format de contenu différent —, non à l’ajout d’un KEM : le KEM par emplacement est sélectionné par enc.kem, et les deux KEM ci-dessous vivent sous scheme = 1 dès la première version. Plus largement, enc.scheme: 1 identifie la suite cryptographique entière, et pas seulement le MAC et le format de contenu : les règles de canonicalEncode, le schéma des emplacements, le hachage HKDF, le hachage HMAC, l’AEAD d’enveloppement par emplacement, le format de contenu à STREAM segmenté, les schémas de transcription des emplacements et de la phrase secrète (y compris la liaison d’élément hashes_hash), l’engagement de phrase secrète dans le texte chiffré, la révision fixée de X-Wing, les étiquettes de séparation de domaine, la version et le profil d’Argon2id, et le profil de normalisation de la phrase secrète sont tous fixés par lui, de sorte que changer l’un quelconque d’entre eux exige une nouvelle valeur de enc.scheme.

La couche de contenu

Les deux chemins convergent vers une unique passe symétrique sur le texte en clair, avec une clé dérivée d’une valeur issue d’une seule clé de chiffrement du contenu (CEK) de 32 octets. La CEK est ce que les emplacements livrent (chaque emplacement l’enveloppe) ou ce que la KDF de la phrase secrète produit ; le contenu n’est pas chiffré directement sous la CEK. Chaque chemin dérive à la place une clé de contenu distincte de 32 octets, comme feuille HKDF de la CEK — salée par le enc.nonce unique par enveloppe, sous un info propre au chemin — de sorte que la couche de livraison de clé et la couche de contenu n’appliquent jamais la clé à la même primitive sur les mêmes octets :

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)

Le contenu est ensuite scellé dans un STREAM segmenté, nommé par l’identifiant de format de contenu chacha20-poly1305-stream64k. C’est la disposition STREAM de la spécification age v1 : ChaCha20-Poly1305 (RFC 8439, la variante à nonce de 12 octets) sur le texte en clair découpé en segments de taille fixe, chacun scellé sous la clé de contenu avec un nonce à compteur par segment :

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

Le drapeau final sépare par domaine le dernier segment du reste, ce qui rend la troncature détectable : un flux dont le dernier segment ne porte pas le drapeau 0x01, un drapeau 0x01 sur un segment qui n’est pas le dernier, des données suivant le segment final, ou un segment non final plus court que CHUNK_SIZE DOIVENT tous faire échouer le déchiffrement (TAMPERED_CIPHERTEXT). Parce que chaque segment scellé vaut au moins son étiquette de 16 octets, la disposition implique aussi un plancher structurel : un blob de texte chiffré bien formé du chemin slots ne fait jamais moins de 16 octets, l’étiquette isolée d’un segment final vide.

L’AAD par segment est vide par conception : tout le contexte est lié au contenu de manière transitive. La clé de contenu dérive de la CEK, et la CEK est engagée envers l’en-tête complet par slots_mac sur le chemin slots (dont la transcription couvre scheme, path, aead, kem, nonce, l’ensemble des emplacements et l’affirmation d’empreinte de l’élément) ou par l’engagement dans le texte chiffré sur le chemin par phrase secrète. Modifiez n’importe quel champ d’en-tête et le destinataire dérive ou accepte une clé différente, donc le déchiffrement échoue ; un AAD par segment relierait le même contexte sur chaque segment sans ajouter de sécurité.

Les nonces à compteur des segments sont sûrs parce que la clé de contenu est à usage unique : elle dérive d’une CEK fraîche salée par le enc.nonce unique par enveloppe, de sorte que deux flux ne partagent jamais une paire (key, nonce) et que les producteurs sans état — onglets de navigateur, exécutions de la CLI, workers, nouvelles tentatives — ne coordonnent jamais les nonces entre enveloppes. Le compteur de 88 bits admet 2^88 segments, bien au-delà de toute charge utile réalisable, de sorte que le format n’impose aucun plafond cryptographique à la charge utile ; un maximum pratique relève d’une politique de déni de service du déploiement, non d’une constante de fil.

L’entrée en clair est la séquence exacte d’octets du contenu original. La construction ne préfixe, n’ajoute ni ne chiffre aucun nom de fichier, type MIME, champ de taille ou manifeste — le flux se déchiffre en redonnant ces octets et uniquement ces octets.

Les segments libérés sont provisoires jusqu’à la revérification de l’empreinte

Le format segmenté existe pour qu’un vérificateur puisse authentifier et libérer une charge utile de plusieurs Gio de manière incrémentale avec une mémoire bornée. L’étiquette de chaque segment est vérifiée avant que le texte en clair de ce segment ne soit libéré, et la troncature est attrapée par le drapeau final — mais la revérification de l’empreinte du texte en clair s’exécute sur le texte en clair entier, après le dernier segment. Un consommateur en flux DOIT donc traiter les octets libérés comme provisoires — aucun effet de bord, aucun accusé de réception, aucun statut « reçu » — jusqu’à ce que ce contrôle final réussisse.

Le texte chiffré publié est un objet unique. Sur le chemin slots, il s’agit exactement des segments du STREAM ; sur le chemin par phrase secrète, un en-tête d’engagement de clé de 32 octets est préfixé à l’intérieur du même blob (même objet, même URI, même récupération — jamais un second objet stocké) :

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

L’empreinte du texte en clair dans items[].hashes s’engage toujours sur le texte en clair, même lorsque enc est présent. C’est la propriété porteuse : un vérificateur qui ne peut pas déchiffrer peut tout de même confirmer que l’enregistrement existe, que son enveloppe est bien formée et que l’URI est récupérable — mais seul le détenteur d’une clé de destinataire correspondante peut déchiffrer le texte chiffré et confirmer ce sur quoi porte l’engagement en recalculant l’empreinte. Le validateur NE DOIT DONC PAS déchiffrer pour « vérifier » les empreintes ; la vérification de l’empreinte du texte en clair a lieu chez le destinataire, après que les octets ont été récupérés. Voir Contenu et hachage et Vérification.

Emplacements et MAC de l’ensemble des emplacements

Sur le chemin multidestinataire, enc.slots est un tableau non vide d’emplacements par destinataire. Chaque emplacement enveloppe la même CEK sous une clé de chiffrement de clé (KEK) par destinataire ; un destinataire qui ouvre n’importe quel emplacement récupère l’unique CEK qui déchiffre le contenu. L’expéditeur :

  1. Sélectionne un seul KEM pour tout l’enregistrement et génère la CEK (32 octets aléatoires) et le nonce (24 octets aléatoires).
  2. Pour chaque destinataire, dérive une KEK par emplacement et y enveloppe la CEK (les détails par KEM sont donnés plus bas).
  3. Mélange le tableau des emplacements avec un CSPRNG (Fisher-Yates non biaisé).
  4. Construit la transcription des emplacements sur le tableau mélangé, les champs d’en-tête communs aux KEM et l’affirmation d’empreinte de l’élément, la hache en slots_hash, et calcule slots_mac comme un HMAC à clé dérivée de la CEK sur cette empreinte.
  5. Dérive la clé de contenu à partir de la CEK et de enc.nonce, et scelle le contenu dans le STREAM segmenté ci-dessus.

L’enveloppement par emplacement

Chaque emplacement enveloppe la CEK avec ChaCha20-Poly1305 (RFC 8439, la variante à nonce de 12 octets) sous la KEK de l’emplacement, produisant un wrap de 48 octets (32 octets de texte chiffré de la CEK + 16 octets d’étiquette Poly1305) :

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

Le nonce de 12 octets entièrement à zéro est sûr précisément parce que la KEK de chaque emplacement est unique par enregistrement : une KEK n’est donc utilisée que pour exactement un enveloppement, de sorte que le nonce ne peut jamais entrer en collision sous une seule clé. C’est un invariant strict — si une révision quelconque autorisait un jour la réutilisation d’une KEK (mise en cache, éphémères déterministes, déduplication de destinataires qui réutilise un emplacement), le nonce zéro devrait être remplacé par un nonce aléatoire dans le même changement.

Le MAC de l’ensemble des emplacements

slots_mac lie l’ensemble entier des emplacements — conjointement avec les champs d’en-tête communs aux KEM qui fixent la manière dont les emplacements sont interprétés, et l’affirmation d’empreinte du texte en clair de l’élément — à la CEK, déjouant les falsifications par substitution, suppression et réordonnancement d’emplacements et par épissage d’enveloppe. La liaison est une construction en deux étapes : une transcription des emplacements est hachée une fois en un slots_hash de 32 octets, et cette empreinte est le message d’un HMAC à clé dérivée de la CEK.

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

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

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

SLOTS_TRANSCRIPT est une carte fermée qui porte exactement cet ensemble de sept clés, sérialisée avec canonicalEncode afin que les deux côtés produisent des octets identiques ; son ordre de clés est le tri octet par octet de RFC 8949 §4.2.1, jamais arrangé à la main. La valeur slots est le tableau mélangé de cartes d’emplacement fermées exactement telles qu’elles apparaissent sur le fil ({epk, wrap} pour x25519, {kem_ct, wrap} pour mlkem768x25519), de sorte que l’intégralité du contenu sur le fil de chaque emplacement est à l’intérieur de la transcription. La transcription fixe en outre scheme, path, aead, kem et nonce : un relais qui retourne l’un quelconque de ces champs d’en-tête tout en laissant valides les formes des emplacements produit un slots_hash différent, donc le MAC échoue. Les préfixes SHA-256 de slots_hash et hashes_hash (cardano-poe-slots-transcript-v1, cardano-poe-item-hashes-v1) sont de l’ASCII exact sans terminateur ni préfixe de longueur.

hashes_hash est ce qui lie l’enveloppe à l’affirmation d’empreinte de cet élément : c’est un SHA-256 étiqueté sur le canonicalEncode de la carte hashes complète de l’élément. Parce que le destinataire recalcule slots_mac à partir des seuls octets sur la chaîne, une correspondance du MAC confirme que l’enveloppe a été scellée pour cette affirmation exacte — une enveloppe épissée sur un élément doté d’une carte hashes différente échoue à l’étape de correspondance sur la chaîne, avant toute récupération de texte chiffré. Les uris[] de l’élément ne sont délibérément pas liées, de sorte que le texte chiffré peut être réhébergé à un nouvel URI adressé par contenu sans invalider l’enveloppe ; un expéditeur pour qui la liste des URI fait partie de l’affirmation la lie plutôt par une signature au niveau de l’enregistrement.

Dans la dérivation de HMAC_KEY, salt = "" est une chaîne d’octets de longueur nulle, la convention de sel absent de RFC 5869 §2.2 (HKDF-Extract substitue HashLen octets nuls — 32 pour SHA-256). Elle est fixée par un vecteur de conformité octet à octet plutôt que laissée au comportement par défaut d’une bibliothèque, de sorte qu’une implémentation qui gère mal le sel absent échoue au vecteur au lieu de dériver en silence une clé différente.

slots_hash est calculé une seule fois par enregistrement et est constant tout au long de la boucle de déchiffrement d’essai du destinataire — le contrôle du MAC par emplacement re-dérive la clé HMAC à partir de chaque CEK candidate mais toujours sur le même slots_hash de 32 octets. La propriété d’engagement est préservée parce que la clé du HMAC reste HKDF-SHA-256(CEK, …) : pré-hacher la transcription ne change que le message du HMAC, de la transcription complète à son SHA-256, laissant intacte la liaison à clé dérivée de la CEK.

Le MAC de l’ensemble des emplacements est fixé par enc.scheme : il n’existe aucun identifiant sur le fil pour lui, il existe exactement une construction par valeur de scheme, et il est identique pour les deux KEM. slots_mac DOIT faire exactement 32 octets (ENC_SLOTS_MAC_INVALID_LENGTH en cas de longueur erronée) et DOIT être vérifié en temps constant.

La transcription dépend directement des octets sur le fil de chaque emplacement. Les deux champs d’emplacement sont des chaînes d’octets CBOR uniques — epk fait 32 octets, kem_ct fait 1120 octets — de sorte qu’il n’y a aucun découpage par champ à normaliser ni aucune ambiguïté de frontière de segment : le seul découpage que Label 309 effectue est la division de transport du corps complet sur L’enregistrement, défaite avant que tout ceci ne s’exécute. Une inversion d’octet n’importe où dans un emplacement change slots_hash et fait échouer le MAC.

La couche de contenu n’a besoin d’aucune liaison séparée par passe à l’ensemble des emplacements : la clé de contenu est une feuille HKDF de la CEK, et la CEK est déjà engagée envers l’en-tête complet — y compris hashes_hash — par slots_mac. Éditer n’importe quel emplacement ou champ d’en-tête change ce que le destinataire dérive, donc le flux de contenu ne s’ouvre tout simplement pas. L’AAD par segment est donc vide (voir La couche de contenu).

Les deux KEM

Le KEM, sélectionné par enregistrement via enc.kem, fixe la forme de l’emplacement et la dérivation de la KEK. Les deux sont enregistrés sous enc.scheme = 1 dès la première version.

enc.kemKEMClé publique du destinataireForme de l’emplacementChaîne info de la KEK
"x25519"X25519 (classique)32 octets{ epk: bstr(32), wrap: bstr(48) }"cardano-poe-kek-v1"
"mlkem768x25519"X-Wing = X25519 + ML-KEM-7681216 octets{ kem_ct: bstr(1120), wrap: bstr(48) }"cardano-poe-kek-mlkem768x25519-v1"

Les producteurs DEVRAIENT prendre mlkem768x25519 par défaut. Le KEM hybride est sûr à la fois contre les adversaires classiques et contre les adversaires quantiques de type « récolter maintenant, déchiffrer plus tard », tout en conservant la sécurité classique de X25519 comme plancher — le combineur X-Wing lie les deux secrets partagés. Ce plancher « jamais en dessous de la sécurité classique de X25519 » est circonscrit aux clés de destinataire générées validement : il présuppose que la clé publique passe le contrôle de validité de clé de la révision fixée de X-Wing (appliqué à l’encapsulation, voir Hybride : mlkem768x25519 ci-dessous). Le KEM classique x25519 reste disponible pour les destinataires dont la clé publiée est uniquement X25519. L’identifiant mlkem768x25519 est délibérément écrit sans tirets, conformément à l’orthographe de l’écosystème X-Wing/age.

Les deux KEM utilisent le même motif de strophes d’age — matériel KEM par destinataire plus un enveloppement symétrique de la clé du fichier — et la même liaison d’en-tête (le MAC de l’ensemble des emplacements), de sorte qu’une seule construction uniforme couvre les deux sans aucune dépendance à HPKE. Le chemin classique x25519 reflète de près le destinataire X25519 natif d’age. Le chemin hybride mlkem768x25519 diverge délibérément du propre choix post-quantique d’age : age v1.3.0 livre des destinataires post-quantiques natifs (préfixe visible age1pq…) qui enveloppent la clé du fichier via HPKE SealBase (RFC 9180) sur un KEM ML-KEM-768 + X25519, et non le motif de strophes. Conserver l’enveloppement à strophes pour le chemin hybride est ce qui permet à un seul enveloppement uniforme et à une seule liaison d’en-tête uniforme de couvrir les deux KEM. L’enveloppement hybride n’hérite donc pas de la construction HPKE d’age, et aucune affirmation d’héritage d’age n’est faite à son sujet ; l’encodage de destinataire distinct age1pqc (voir Clés) reflète le fait que les deux encodages hybrides sont indépendants.

Classique : x25519

Pour chaque destinataire, l’expéditeur génère une nouvelle paire de clés éphémère X25519, effectue un ECDH contre la clé publique du destinataire et dérive la KEK avec HKDF (RFC 5869) sous un sel à hachage étiqueté :

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

La clé publique éphémère epk de 32 octets est le seul matériel de clé sur le fil ; la clé publique du destinataire n’est jamais publiée. Le sel est un SHA-256 étiqueté qui lie trois valeurs : pub_epk rend la KEK de chaque emplacement unique, pub_R la lie au destinataire précis (déjouant toute tentative de réutiliser un epk contre un destinataire différent), et le enc.nonce unique par enveloppe ancre la KEK à une seule enveloppe — de sorte qu’une défaillance du CSPRNG qui répéterait l’aléa du KEM entre deux enveloppes ne se dégrade qu’en corrélabilité entre enveloppes, jamais en une paire d’enveloppement (KEK, nonce-zéro) répétée. Les implémentations de X25519 DOIVENT rejeter le secret partagé entièrement à zéro selon RFC 7748 §6.1 ; les bibliothèques courantes le font de manière transitive.

Hybride : mlkem768x25519 (X-Wing)

Le KEM hybride est la construction X-Wing (draft-connolly-cfrg-xwing-kem-10), combinant ML-KEM-768 (FIPS 203) avec X25519. Chaque encapsulation tire un aléa ML-KEM frais et un éphémère X25519 frais, et produit un texte chiffré de 1120 octets et un secret partagé combiné de 32 octets. La dérivation de la KEK lie le destinataire via un sel externe calculé sur les octets sur le fil de l’emplacement lui-même :

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

Tailles de clé et de texte chiffré de X-Wing :

ComposantTailleComposition
Clé publique1216 octetsML-KEM-768 ek (1184) ‖ X25519 pk (32)
Texte chiffré1120 octetsML-KEM-768 ct (1088) ‖ X25519 éphémère (32)
Secret partagé32 octetssortie du combineur X-Wing
Clé de décapsulation32 octetsun seed ; la clé publique en est dérivée

Un emplacement hybride ne porte aucun champ epk — l’éphémère X25519 constitue les 32 derniers octets du kem_ct de 1120 octets. XWing.Encapsulate DOIT appliquer le contrôle de validité de clé publique de la révision fixée de X-Wing à pub_R et rejeter une clé invalide plutôt que d’encapsuler vers elle ; c’est la précondition sous laquelle le plancher hybride ne descend jamais en dessous de la sécurité classique de X25519. La construction consomme X-Wing à travers un adaptateur à champs nommés exclusivement : Encapsulate(pk) produit .ct (1120 o) et .ss (32 o) ; Decapsulate(sk, ct) produit le secret partagé de 32 octets. Les implémentations DOIVENT correspondre à l’API de la révision fixée par nom et NE DOIVENT PAS consommer de valeurs de retour positionnelles — la révision fixée renvoie (ss, ct) de l’encapsulation et écrit la décapsulation comme Decapsulate(ct, sk), l’inverse d’une lecture naïve de gauche à droite. La dérivation de la KEK lie le destinataire à travers un sel étiqueté de longueur fixe, SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R), où kem_ct est le texte chiffré de 1120 octets exactement tel qu’il est porté dans l’emplacement et pub_R est la clé publique X-Wing du destinataire de 1216 octets. C’est la même forme à trois valeurs qu’utilise le sel classique sous sa propre étiquette — kem_ct ancre la KEK à une valeur unique par emplacement, pub_R la lie au destinataire précis, et enc.nonce l’ancre à une seule enveloppe — exprimée à travers un condensé SHA-256 parce que les entrées hybrides sont surdimensionnées pour un sel brut. Dans les deux sels, le terme pub_R est l’encodage canonique sur le fil de la clé du destinataire : exactement les 32 octets de x25519_publicKey(priv_R) pour x25519, exactement la chaîne d’octets de clé publique X-Wing fixée de 1216 octets pour mlkem768x25519. Le producteur et le vérificateur DOIVENT utiliser cet encodage exact et NE DOIVENT PAS lui substituer un équivalent non canonique ou réencodé, faute de quoi les deux côtés dérivent des KEK différentes et un enregistrement honnête ne s’ouvre pas. Point crucial, la liaison est calculée à l’extérieur du KEM, sur les octets sur le fil de l’emplacement lui-même, de sorte que la construction tient X-Wing comme un KEM en boîte noire : elle ne consomme que l’interface KEM publique (encapsuler, décapsuler, le secret partagé de 32 octets) et ne fait aucune hypothèse sur le hachage interne du combineur. L’étiquette info distincte par KEM cardano-poe-kek-mlkem768x25519-v1 garantit en outre qu’une KEK dérivée pour un KEM ne puisse jamais égaler une KEK dérivée pour l’autre, même sur un secret partagé identique de 32 octets. Le texte chiffré de 1120 octets est porté comme une unique chaîne d’octets CBOR dans slot.kem_ct — seul le corps complet de l’enregistrement est découpé en segments pour le transport (voir L’enregistrement), jamais un champ individuel.

Un seul KEM par enregistrement

Un unique élément de PoE scellée porte exactement un enc.kem ; chaque emplacement utilise la forme et la dérivation de KEK de ce KEM. Un fichier est tout classique ou tout hybride — des emplacements de KEM différents NE DOIVENT PAS apparaître dans le même tableau slots, et un vérificateur DOIT rejeter un enregistrement dont les formes d’emplacement sont incohérentes avec le enc.kem déclaré (ENC_SLOT_INVALID_SHAPE).

Le matériel d’encapsulation DOIT aussi être distinct au sein d’un même tableau slots : pour x25519, toutes les valeurs epk DOIVENT différer ; pour mlkem768x25519, toutes les valeurs kem_ct DOIVENT différer. Un doublon est rejeté — avant l’exécution de toute primitive KEM ou AEAD — avec ENC_SLOTS_DUPLICATE_KEM_MATERIAL. C’est la portion vérifiable de l’invariant d’unicité de la KEK par emplacement dont dépend l’enveloppement à nonce zéro : la réutilisation de KEK entre enregistrements ou entre clés est une obligation du producteur qu’un vérificateur ne peut détecter, mais un doublon au sein de l’enregistrement est structurellement visible et DOIT échouer.

Déchiffrement d’essai du destinataire

Un destinataire détient une clé privée (un scalaire X25519 de 32 octets pour x25519, ou un seed de décapsulation X-Wing de 32 octets pour mlkem768x25519 — tous deux dérivés du seed ; voir Clés). Il ne sait pas à l’avance quel emplacement, le cas échéant, est le sien, donc il déchiffre d’essai le tableau. Deux propriétés façonnent la boucle : le contrôle du MAC de l’ensemble des emplacements est replié à l’intérieur (un emplacement n’est accepté que lorsque sa CEK candidate reproduit aussi le slots_mac présent sur le fil), et la boucle parcourt tous les emplacements sans sortie anticipée, sélectionnant la correspondance en temps constant afin qu’un observateur des temps ne puisse pas inférer quel indice d’emplacement a correspondu.

Avant d’invoquer toute primitive KEM ou AEAD, le vérificateur DOIT exécuter les contrôles de forme structurels (la défense contre l’oracle de partitionnement) : scheme == 1, aead/kem enregistrés, nonce de 24 octets, slots_mac de 32 octets, slots non vide, le secret du destinataire de 32 octets, chaque slot.wrap exactement de 48 octets, chaque epk x25519 exactement de 32 octets sans kem_ct, chaque kem_ct mlkem768x25519 exactement de 1120 octets sans epk, et la distinction, au sein de slots, de tout le matériel d’encapsulation (sinon ENC_SLOTS_DUPLICATE_KEM_MATERIAL).

Dans cette même passe préalable aux primitives, le vérificateur DOIT aussi borner l’usage des ressources de l’analyseur : les bornes de référence sont MAX_SLOTS = 1024 emplacements et 65536 octets pour l’enveloppe enc décodée. Les deux se situent bien au-dessus du plafond de ≈ 16 Kio des métadonnées de transaction Cardano qui borne un enregistrement honnête, de sorte qu’un enregistrement dépassant l’une ou l’autre est malformé et est rejeté ici — ENC_SLOTS_TOO_MANY pour trop d’emplacements, ENC_ENVELOPE_TOO_LARGE pour une enveloppe surdimensionnée — avant l’exécution de toute primitive KEM ou AEAD. Ces bornes sont des constantes imposées par le vérificateur et fixées par déploiement, non des champs de fil ; un déploiement PEUT les resserrer.

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

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

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

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

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

La dérivation de la KEK bifurque selon enc.kem : pour x25519, le destinataire effectue un ECDH contre slot.epk et re-dérive le même sel étiqueté sur enc.nonce || slot.epk || pub_R ; pour mlkem768x25519, il décapsule en X-Wing slot.kem_ct directement (une unique chaîne d’octets de 1120 octets) et recalcule le même sel étiqueté sur enc.nonce || slot.kem_ct || pub_R, où pub_R est sa propre clé publique X-Wing de 1216 octets dérivée du seed détenu. Le rejet du secret partagé X25519 entièrement à zéro est ici explicite plutôt que reposant sur un rejet transitif : un emplacement fabriqué pour mener le secret partagé à zeros(32) (RFC 7748 §6.1) met à faux le bit de validité indépendant du secret kem_ok, la KEK est sélectionnée en temps constant vers une dummy_KEK dérivée de zeros(32) sous le même sel et le même info afin que la boucle effectue un travail identique, et kem_ok est replié dans ok — de sorte qu’un emplacement à ECDH invalide ne peut jamais être accepté, quel que soit le résultat de l’enveloppement ou du MAC, et l’enregistrement fait remonter l’unique échec générique si rien d’autre ne correspond. Tout ce qui vient après l’ouverture de l’enveloppement — le contrôle du MAC de l’ensemble des emplacements, la dérivation de la clé de contenu et le déchiffrement du contenu — est indépendant du KEM.

Les deux primitives AEAD *_open_or_dummy sont atomiques : en cas d’échec de la vérification de l’étiquette, elles ne renvoient aucun texte en clair, et le candidat renvoyé (candidate_CEK pour l’ouverture de l’enveloppement, le plaintext pour l’ouverture du contenu) est une valeur factice fixe ou pseudo-aléatoire qui est indépendante du texte chiffré ayant échoué. Aucun texte en clair non vérifié n’est jamais libéré à l’appelant, de sorte qu’une ouverture échouée ne peut pas devenir un oracle de déchiffrement.

Pourquoi le contrôle du MAC vit à l’intérieur de la boucle

Un expéditeur malveillant peut fabriquer un emplacement qui s’ouvre sous la clé d’un destinataire mais produit une CEK choisie par l’attaquant (encapsuler vers la clé publique du destinataire ne nécessite aucune clé privée). Si un destinataire acceptait le premier succès AEAD comme « le sien », cet emplacement falsifié éclipserait un emplacement honnête situé plus loin dans le tableau. Replier le contrôle de slots_mac dans la boucle signifie qu’un emplacement n’est accepté que lorsque sa CEK candidate reproduit le MAC sur slots_hash — ainsi un emplacement falsifié est sauté et le balayage se poursuit. La longueur de slot.wrap DOIT être vérifiée comme valant 48 octets avant tout appel AEAD, une défense contre l’oracle de partitionnement qu’age v1 applique aussi.

Plusieurs emplacements correspondants : la duplication est permise, un conflit de CEK ne l’est pas. La clé privée d’un destinataire PEUT légitimement correspondre à plus d’un emplacement. Un producteur peut sceller la même CEK vers le même destinataire à travers plusieurs emplacements — chacun avec son propre éphémère frais par emplacement — pour gonfler le nombre apparent de destinataires, une technique de confidentialité valide. Le vérificateur sélectionne la CEK de la première correspondance et NE DOIT PAS rejeter simplement parce que plus d’un emplacement a correspondu. Cela est distinct du rejet, au sein de l’enregistrement, du matériel-d’encapsulation-dupliqué (ENC_SLOTS_DUPLICATE_KEM_MATERIAL), qui se déclenche sur un epk ou un kem_ct répété : la duplication honnête tire un aléa KEM frais par emplacement à chaque apparition, de sorte que ses epk / kem_ct diffèrent et qu’elle n’entre jamais en collision avec ce contrôle. La seule anomalie que le vérificateur DOIT rejeter est constituée de deux emplacements correspondants qui récupèrent des CEK différentes (comparées en temps constant) : la boucle porte un bit cek_conflict à travers tous les emplacements et, si une correspondance ultérieure quelconque récupère une CEK qui diffère de celle sélectionnée, fait remonter l’unique échec générique. C’est de la défense en profondeur — sous la propriété d’engagement que fournit la CEK récupérée (le MAC de l’ensemble des emplacements lie la CEK à une unique transcription d’emplacements ; voir Anonymat et la séparation par KEM), une correspondance à CEK distincte est déjà infaisable, étant exactement la collision multiclé que l’engagement exclut, de sorte que le contrôle échoue en mode fermé contre une implémentation défectueuse ou un affaiblissement futur de cette hypothèse.

Une seule forme d’échec générique, à temps constant sur tous les emplacements

Un appelant non fiable DOIT recevoir exactement une forme d’échec générique quelle que soit la raison de l’échec du déchiffrement — aucun emplacement ouvert, l’ensemble des emplacements a été falsifié, ou l’AEAD de contenu a échoué — et la réponse NE DOIT PAS les distinguer, ni révéler quel emplacement a correspondu. Une implémentation PEUT exposer des codes typés internes — WRONG_RECIPIENT_KEY (aucun emplacement ne s’ouvre), TAMPERED_HEADER (un emplacement s’ouvre mais aucune CEK candidate ne reproduit le slots_mac sur slots_hash), TAMPERED_CIPHERTEXT (l’AEAD de contenu échoue après qu’une CEK a été récupérée) — à un appelant local de confiance à des fins de diagnostic, mais ces codes NE DOIVENT PAS fuiter vers un observateur externe à travers une réponse distinguable.

Côté temps, le vérificateur PEUT retourner au contrôle de non-correspondance (if NOT found) avant le déchiffrement du contenu. Ce retour anticipé ne révèle que destinataire contre non destinataire — jamais quel emplacement a correspondu ni aucun matériel de clé — parce que la boucle entre emplacements ci-dessus a déjà été exécutée jusqu’au bout au moment où le contrôle est atteint. Des temps uniformes entre le cas du non-destinataire et celui d’un destinataire dont le texte chiffré ne s’ouvre pas ne sont PAS requis, et une ouverture de contenu factice NE DOIT PAS être imposée : forcer chaque non-destinataire à payer le coût complet du déchiffrement du contenu n’achète aucune confidentialité que la boucle ne fournisse déjà. La garantie de temps constant qui tient bel et bien est l’invariant entre emplacements — la boucle traite un nombre constant d’opérations d’emplacement par clé privée sans sortie anticipée, de sorte qu’un observateur au niveau du réseau n’apprend que le nombre d’emplacements, jamais quel emplacement (le cas échéant) la clé déverrouille. Un destinataire détenant plusieurs clés (par exemple des clés archivées à travers une rotation d’identité) itère clé privée × emplacement, re-dérivant la moitié pub_R du sel à partir de la clé courante ; il PEUT court-circuiter entre clés (ne divulguant que le faible signal « quelle clé a correspondu ») mais DOIT rester à temps constant sur les emplacements de toute clé unique.

Après avoir récupéré le texte en clair, le destinataire — dans la couche applicative, non dans la fonction de déchiffrement — recalcule l’empreinte du texte en clair et la compare à items[].hashes. Une discordance signifie que l’engagement sur la chaîne de l’enregistrement ne correspond pas aux octets déchiffrés, et le destinataire DOIT refuser d’agir sur le texte en clair. C’est l’étape qui boucle la boucle : la chaîne a été témoin d’un engagement à l’instant T, et le destinataire confirme qu’il s’agit d’un engagement sur exactement ces octets.

Chemin par phrase secrète

Le chemin alternatif de livraison de clé remplace les emplacements de destinataire par une phrase secrète. Il n’y a aucun tableau slots, aucun slots_mac, aucun éphémère par emplacement et aucune boucle de déchiffrement d’essai : la CEK est dérivée directement d’une phrase secrète normalisée via Argon2id (RFC 9106) sur un sel et des paramètres présents sur la chaîne. L’engagement envers la clé que slots_mac fournit sur le chemin slots réside à la place dans un en-tête de 32 octets à l’intérieur du blob de texte chiffré, préfixé avant les segments du STREAM — même objet, même URI, même récupération.

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

Le bloc enc.passphrase sur le fil est { alg, salt, params } — il nomme la KDF ("argon2id"), le sel et les paramètres. Label 309 fixe un plancher de paramètres de m ≥ 65536 Kio (64 Mio), t ≥ 3, p ≥ 1 ; le producteur choisit des valeurs égales ou supérieures au plancher et le sel fait de 16 à 64 octets inclus (le plafond de 64 octets est la limite de chaîne d’octets de la métadonnée). Là où la plateforme le permet, les producteurs DEVRAIENT utiliser p = 4 (le second profil recommandé de RFC 9106 §4) ; les vérificateurs PEUVENT accepter tout p ≥ 1, sous réserve des plafonds de déploiement ci-dessous.

Le PASSPHRASE_TRANSCRIPT lie les paramètres de la KDF, les champs d’en-tête et l’affirmation d’empreinte de l’élément dans l’engagement : le vérificateur recalcule la transcription à partir de la carte enc reçue et des hashes de l’élément, de sorte que falsifier salt, n’importe quelle valeur de params, nonce, aead, ou épisser l’enveloppe sur une affirmation d’empreinte différente, produit un pw_hash différent et fait échouer le contrôle de l’engagement. Le contenu est ensuite scellé dans le même STREAM segmenté que sur le chemin slots, sous la clé de contenu du chemin par phrase secrète. La valeur "normalization" est une constante fixée par le scheme fournie à la transcription pour fixer le profil exact sous lequel la CEK a été dérivée ; elle n’est jamais sérialisée sur le fil.

Ordre de vérification. Le vérificateur dérive la CEK candidate à partir de la phrase secrète saisie, lit les 32 octets de tête du blob de texte chiffré, recalcule l’engagement et le compare en temps constant — avant d’ouvrir tout segment du STREAM. Un blob du chemin par phrase secrète plus court que 48 octets — l’en-tête d’engagement de 32 octets plus le STREAM minimal de 16 octets — ne peut pas être bien formé et constitue un texte chiffré malformé (TAMPERED_CIPHERTEXT). En cas de discordance — phrase secrète erronée, salt / params falsifiés, en-tête falsifié, ou une enveloppe épissée — le vérificateur fait remonter le même unique échec générique que tout autre échec de déchiffrement et NE DOIT PAS commencer à diffuser en flux. Une phrase secrète erronée est donc indistinguible d’un enregistrement falsifié.

Avant la normalisation et Argon2id, une implémentation DOIT borner la longueur de l’entrée brute de la phrase secrète afin qu’une phrase secrète surdimensionnée ne puisse pas provoquer un déni de service pré-KDF : la borne de référence est de 4096 octets UTF-8 d’entrée brute, rejetée avant tout travail de normalisation ou de hachage. Comme les bornes sur MAX_SLOTS et sur l’enveloppe enc décodée qu’impose le chemin slots, c’est une constante imposée par le vérificateur et fixée par déploiement — non un champ de fil — et un déploiement PEUT la resserrer. Au-delà du plancher de paramètres, les implémentations DEVRAIENT aussi imposer des bornes supérieures sur m, t et p contre un DoS côté vérificateur ; ces plafonds sont non normatifs (dépendants du matériel) et NE DOIVENT PAS être confondus avec le plancher.

Pourquoi l’engagement est hors chaîne

Un engagement de phrase secrète sur la chaîne remettrait à tout observateur un oracle de test hors ligne gratuit — dériver une CEK candidate d’une phrase secrète devinée, la confronter à la chaîne — pour chaque enregistrement par phrase secrète, à jamais, y compris les enregistrements dont le texte chiffré est retenu. Transporter l’engagement à l’intérieur du blob de texte chiffré signifie que tester une tentative exige le blob lui-même : un enregistrement à texte chiffré retenu n’expose aucun matériel devinable par phrase secrète sur le registre permanent, et un destinataire légitime qui possède déjà le blob ne paie rien pour lire d’abord un en-tête de 32 octets.

Le profil de normalisation

La normalisation appliquée à la phrase secrète avant Argon2id est le profil fixe cardano-poe-pw-norm-v1. Il est normatif : deux implémentations DOIVENT dériver une CEK identique octet à octet à partir de la même phrase secrète, et le seul moyen de le garantir est une normalisation fixée. Le profil, appliqué dans l’ordre, est :

  1. Rejeter les points de code non assignés. Une phrase secrète contenant un point de code quelconque non assigné dans Unicode 16.0 est rejetée avec ENC_PASSPHRASE_UNNORMALIZABLE avant l’exécution de toute étape de normalisation.
  2. NFKC. Appliquer la forme de normalisation KC selon UAX #15 sous Unicode 16.0.
  3. Espaces blancs. Définir « espace blanc » comme tout caractère portant la propriété Unicode White_Space sous Unicode 16.0, et réduire toute séquence maximale de tels caractères à un unique U+0020 SPACE.
  4. Rognage. Supprimer les espaces blancs de tête et de queue.
  5. Rejeter la chaîne vide. Si le résultat est la chaîne vide, rejeter avec ENC_PASSPHRASE_EMPTY : une phrase secrète composée uniquement d’espaces blancs ou autrement vide se normalise en zéro octet, ce qu’Argon2id accepterait en silence — liant l’enregistrement à une CEK que n’importe quelle partie peut dériver.
  6. Encoder. Encoder le résultat en UTF-8 ; ces octets sont l’entrée mot de passe d’Argon2id.

L’étape 1 est ce qui rend le profil déterministe entre les implémentations et dans le temps. La Unicode Normalization Stability Policy garantit que la normalisation d’une chaîne est stable entre les versions futures d’Unicode uniquement lorsque chaque point de code qu’elle contient est assigné dans la version où elle a été normalisée ; un point de code non assigné pourrait acquérir ultérieurement une décomposition et changer en silence la CEK dérivée. Rejeter les points de code non assignés ferme entièrement cette faille, et c’est invisible pour les utilisateurs honnêtes — tout caractère effectivement en usage écrit est assigné.

La version d’Unicode est fixée à Unicode 16.0 littéralement et NE DOIT PAS flotter : l’ensemble de la propriété White_Space, l’ensemble des points de code assignés et les tables de mappage NFKC dépendent tous de la version, et un vérificateur résolvant le profil au regard d’une version d’Unicode différente pourrait dériver une CEK différente à partir de la même phrase secrète et échouer à déchiffrer un enregistrement honnête. Une révision future qui adopte une version plus récente d’Unicode le fait sous un nouvel identifiant de profil, non en réinterprétant cardano-poe-pw-norm-v1.

L’entropie de la phrase secrète est l’unique barrière

Le sel et les paramètres d’Argon2id sont publics sur la chaîne à jamais, de sorte qu’un attaquant dispose d’un temps hors ligne illimité pour forcer la phrase secrète au regard de ceux-ci. L’entropie de la phrase secrète est l’unique marge de sécurité sur ce chemin. Les producteurs DEVRAIENT utiliser une phrase secrète de type diceware générée par CSPRNG plutôt qu’une phrase choisie par un humain, et DEVRAIENT afficher un avertissement visible lorsqu’ils acceptent des phrases secrètes saisies, le texte chiffré sur la chaîne devant être soumis de manière permanente à une attaque hors ligne.

Confidentialité persistante et indépendance par emplacement

La construction à emplacements utilise un ECDH éphémère-statique (ou une nouvelle encapsulation X-Wing) avec un éphémère frais par emplacement, ce qui procure deux propriétés qu’une conception statique-statique ou à éphémère partagé perdrait :

  • Confidentialité persistante contre la compromission de l’expéditeur. L’expéditeur ne conserve aucune clé à long terme dans la construction ; l’éphémère est mis à zéro après le scellage. Compromettre ensuite l’état de l’expéditeur ne peut pas déchiffrer les enregistrements publiés avant la compromission.
  • Indépendance par emplacement. Des destinataires différents obtiennent des éphémères différents, donc des secrets partagés et des KEK différents. Un destinataire qui laisse fuiter sa CEK enveloppée révèle la CEK (inévitable — c’est la clé du fichier) mais jamais la KEK d’un autre destinataire.

La PoE scellée n’a aucune confidentialité persistante envers le destinataire, par conception : une fois qu’un enregistrement est scellé vers une clé de destinataire à long terme, le détenteur de la clé privée correspondante peut le déchiffrer à jamais. C’est une propriété du chiffrement à clé publique vers une clé à long terme, non un défaut.

Anonymat et la séparation par KEM

Lorsqu’un enregistrement de PoE scellée ne porte aucun sigs, ses octets sur le fil sont indépendants de l’identité de l’expéditeur : chaque emplacement ne porte que du matériel KEM éphémère par enregistrement et par emplacement (l’éphémère X25519 dans slot.epk, ou le texte chiffré X-Wing dans slot.kem_ct), les clés à long terme de l’expéditeur n’apparaissent jamais, les emplacements sont mélangés par un CSPRNG, aucune clé publique de destinataire n’est sur le fil, et aucun champ descriptif (nom de fichier, type MIME, taille) n’est présent. Un enregistrement scellé non signé ne lie donc aucune identité d’expéditeur sur la chaîne — exactement ce qu’exigent les fuites de lanceurs d’alerte, les enchères sous pli scellé et la consignation de preuves.

Pour les deux KEM, les fuites honnêtes sont identiques et inévitables : le nombre d’emplacements, la distinction scellé/ouvert et la famille de KEM classique-contre-hybride (enc.kem) sont visibles pour tout observateur ; rien de plus sur les destinataires ne l’est.

L’affirmation plus forte — qu’un adversaire qui détient un ensemble de clés publiques de destinataire candidates ne puisse pas tester si un emplacement donné est adressé à l’une d’elles (confidentialité de clé / anonymat du destinataire) — est une propriété propre au KEM :

  • x25519 — confidentialité de clé. L’encapsulation par emplacement est une clé publique éphémère fraîche, statistiquement indépendante de la clé du destinataire. Un adversaire détenant des clés publiques de destinataire candidates ne peut pas, à partir des seuls slot.epk et slot.wrap, décider quelle candidate (le cas échéant) l’emplacement vise sans la clé privée correspondante. Le chemin classique est donc à confidentialité de clé, ce qui donne aussi l’impossibilité de relier les enregistrements entre eux : deux PoE scellées vers le même destinataire ressemblent à des blobs epk/wrap sans relation.
  • mlkem768x25519 — non revendiqué. L’anonymat du destinataire contre un adversaire détenant des clés de destinataire candidates est une propriété distincte non impliquée par la sécurité IND-CCA du KEM hybride. Label 309 ne la revendique pas pour le chemin X-Wing tant qu’elle n’est pas justifiée indépendamment pour X-Wing. Un déploiement dont le modèle de menace exige l’anonymat du destinataire contre un adversaire détenant les clés NE DOIT PAS s’appuyer sur le chemin hybride pour cette propriété.

Les expéditeurs préoccupés par la corrélation temporelle entre enregistrements DOIVENT regrouper leurs publications en dehors de la chronologie critique ; la cryptographie au niveau du fil ne peut pas résoudre les attaques temporelles sur les métadonnées.

Le MAC de l’ensemble des emplacements est l’engagement ; l’enveloppement n’a pas à l’être

La CEK récupérée est un engagement envers l’ensemble des emplacements auquel le destinataire a correspondu : un expéditeur malveillant ne peut pas construire deux ensembles d’emplacements distincts qu’un même destinataire accepte tous deux comme étant les siens. La propriété requise ici est l’engagement de clé restreint pour la CEK de l’enveloppe au sens de RFC 9771 — la CEK récupérée se lie à une unique transcription d’emplacements — et non un AEAD pleinement engageant sur des entrées arbitraires. Elle repose sur la résistance aux collisions multiclé de CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) pour des CEK et des transcriptions choisies de manière adverse — une marge de collision générique de ~128 bits (la borne d’anniversaire sur une sortie de 256 bits), adéquate pour le modèle de menace. La résistance à la falsification de la transcription elle-même hérite de la borne de collision de ~2^128 de SHA-256 : tout changement aux champs d’en-tête engagés ou aux octets des emplacements altère slots_hash, et forger un slots_hash inchangé sur une transcription différente est exactement cette recherche de collision de ~2^128. Parce que l’engagement est fourni par slots_mac, l’AEAD d’enveloppement wrap par emplacement n’a pas besoin d’être un AEAD engageant ; le ChaCha20-Poly1305 non engageant par défaut est sûr ici.

Motifs interdits

Une implémentation conforme NE DOIT PAS :

  • Réutiliser un éphémère par emplacement entre emplacements ou entre enregistrements, ni autrement laisser une KEK se répéter — l’enveloppement à nonce zéro dépend de l’unicité de la KEK par emplacement.
  • Réutiliser une CEK entre enveloppes — une CEK fraîche issue d’un CSPRNG par élément porteur de enc, aussi bien au sein d’un enregistrement qu’entre enregistrements.
  • Réutiliser un sel de phrase secrète — générer un enc.passphrase.salt frais issu d’un CSPRNG pour chaque enveloppe par phrase secrète ; le sel est l’unique séparateur entre enregistrements pour une phrase secrète réutilisée.
  • Mélanger les KEM au sein d’un même tableau slots (un seul enc.kem par enregistrement).
  • Publier les emplacements dans l’ordre d’entrée — le mélange par CSPRNG est requis.
  • Envelopper la CEK avec un nonce autre que le nonce zéro de 12 octets, ou avec un AAD d’enveloppement-AEAD vide — l’AAD de l’enveloppement est le littéral de l’étiquette info du KEM.
  • Mettre une clé publique de destinataire sur le fil — la conception de déchiffrement d’essai est la fonctionnalité de confidentialité ; publier les clés publiques la met en échec.
  • Sauter la vérification de slots_mac — sans elle, la substitution d’emplacement réussit.
  • Stocker le texte en clair à l’URI ar:///ipfs:// — seul le texte chiffré est publié ; le texte en clair est livré hors bande ou conservé par l’expéditeur.
  • Référencer le texte chiffré via un schéma autre que ar:// ou ipfs:// — les schémas adressés par contenu lient l’URI aux octets ; une URL servie par un hôte exigerait un engagement séparé sur la chaîne envers le texte chiffré que la PoE scellée ne transporte pas.
  • Journaliser ou persister la CEK, une quelconque KEK, la clé HMAC de l’ensemble des emplacements, la clé MAC de la phrase secrète, la clé de contenu, un secret partagé ECDH, une clé privée éphémère, ou une clé privée de destinataire.

Pages connexes

  • Clés — les paires de clés X25519 et X-Wing dérivées du seed qui fournissent le matériel de clé du destinataire et de l’expéditeur.
  • L’enregistrement — où se situe enc dans la carte de l’enregistrement et le transport du corps complet qui porte l’enregistrement sur la chaîne.
  • Registres d’algorithmes — les identifiants enc.aead, enc.kem et de KDF par phrase secrète, et leurs primitives sous-jacentes.
  • Contenu et hachage — l’engagement envers l’empreinte du texte en clair que porte tout enregistrement scellé.
  • Vérification — le pipeline de validation, pourquoi le validateur ne déchiffre jamais, et le catalogue d’erreurs.