Это ознакомительный перевод. Нормативной является английская версия — при расхождениях верна она. Открыть английскую версию

Sealed PoE

Конверт шифрования Label 309 — как отправитель запечатывает содержимое для одного или нескольких ключей получателей, тогда как в блокчейн попадают только хеш открытого текста и обёрнутые слоты ключей, но никогда — сам открытый текст и никогда — идентичности получателей.

Запечатанный PoE закрепляет в блокчейне привязанное ко времени обязательство в отношении открытого текста, оставляя этот текст доступным для чтения лишь выбранному кругу адресатов. Ончейн-запись несёт хеш открытого текста — подтверждение момента времени, ровно как у любой другой записи, — плюс конверт шифрования (enc) с материалом, нужным для восстановления ключа шифрования содержимого. Сам шифртекст в блокчейн не попадает: он лежит по адресу с адресацией по содержимому (ar:// или ipfs://). Ничто в блокчейне не выдаёт открытый текст и ничто не выдаёт, кто такие получатели.

На этой странице описан конверт enc: два взаимоисключающих способа доставки ключа, слоты ключей для каждого получателя, MAC набора слотов, сегментированный STREAM содержимого и пробное расшифровывание, с помощью которого получатель обнаруживает и открывает адресованное ему сообщение. Сами ключи получателей — производные от сида пары X25519 и X-Wing — определены на странице Ключи; здесь они используются как данность. Место карты enc в карте записи и передачу тела целиком, которая несёт её в блокчейне, задаёт страница Запись.

Это не HPKE

Это не RFC 9180 HPKE. Это схема в духе age с доставкой нескольким получателям по модели «инкапсуляция KEM, затем обёртывание»: инкапсуляция для каждого получателя, ключ шифрования ключа, выведенный через HKDF, и ключ шифрования содержимого, обёрнутый через AEAD, — со шаблоном станзы age v1, перенесённым в каноническую CBOR. У неё нет ни suite_id, ни каскада LabeledExtract/LabeledExpand; оценивайте её по литературе об ECIES и по спецификации age v1, а не по анализу HPKE.

Модель и её свойства приватности

Отправитель хочет опубликовать постоянное, привязанное ко времени обязательство, доказывающее, что в момент времени T конкретный открытый текст был запечатан для конкретного круга адресатов, — и при этом прочитать его может только этот круг. PoE с одним лишь хешем даёт утверждение о времени, но не привязывает аудиторию; PoE поверх открытого шифртекста не даёт никакой конфиденциальности. Запечатанный PoE соединяет одно с другим: запись фиксирует хеш открытого текста (публичный, привязанный ко времени) и несёт в enc материал для доставки ключа, тогда как шифртекст по адресу ar:// или ipfs:// нельзя расшифровать без подходящего секрета разблокировки.

Конструкция намеренно устроена так, чтобы блокчейн выдавал о сообщении как можно меньше, а о его аудитории — ничего:

  • Открытого текста в блокчейне нет никогда. В нём — только его хеш и обёрнутые ключи. Тот, кто впоследствии получит открытый текст, сможет доказать: «именно этот открытый текст был зафиксирован во время блока T»; больше никто не узнает, что было запечатано.
  • Открытых ключей получателей в блокчейне нет никогда. Открытый ключ получателя нигде в enc не фигурирует. Получатель опознаёт сообщение как своё лишь по успешному пробному расшифровыванию слота — поля адресата, которое можно было бы прочитать, попросту нет. Наблюдатель, не имеющий ключей-кандидатов, узнаёт лишь число слотов, семейство KEM (enc.kem) и различие «запечатано или открыто». Более сильное свойство — что противник, держащий ключи-кандидаты получателей, всё равно не может проверить, какому из них (если вообще какому-либо) адресован слот, — это приватность к ключу, заявленная только для классического пути x25519; для гибридного пути mlkem768x25519 она не заявляется (см. Анонимность и разделение по KEM).
  • Получатели ничего не узнают друг о друге. Каждый слот — это непрозрачный обёрнутый ключ для отдельного получателя. Открыв свой слот, получатель не может вывести ключ ни одного другого получателя и не может сказать, кому ещё было адресовано сообщение.
  • Порядок слотов ничего не выдаёт. Порядок, в котором отправитель перечисляет получателей (например, «главный — первым»), — это конфиденциальные метаданные. Перед публикацией массив слотов перемешивается с помощью CSPRNG, поэтому даже их позиции не несут никакого сигнала.
  • Запечатанный PoE без подписи сохраняет анонимность отправителя. Подписи авторства необязательны (см. Подписи). Запечатанная запись без sigs[] не привязывает к блокчейну никакой идентичности отправителя — именно это и нужно для сливов от информаторов, аукционов с закрытыми ставками и хранения доказательств.

А вот что блокчейн всё же показывает, и круг этого узок: что запись — это запечатанный PoE (присутствует enc), хеш открытого текста, метку времени блока и количество слотов (длину массива). Количество — единственный факт, как-то связанный с получателями, и он говорит лишь «сколько», но никогда «кто». Корреляция записей по времени — это угроза на уровне метаданных, которую криптография проводного формата решить не в силах; отправителю, которому нужно её устранить, придётся группировать публикации в стороне от чувствительной временно́й линии.

Открытые ключи получателей передаются вне полосы (out of band). Label 309 не предписывает никакого механизма обнаружения: получатель волен опубликовать свой ключ на собственном сайте, в DNS-записи, в профиле соцсети, в QR-коде или в ончейн-самоаттестации. Верификатор принимает байты ключа получателя на вход и не утверждает ничего о том, чей это ключ, — за его подлинность отвечает отправитель, ровно как и при отправке PGP-ключа по электронной почте.

Конверт и два его пути

Карта enc несёт общие поля плюс ровно один из двух взаимоисключающих способов доставки ключа. Их взаимоисключаемость проверяет структурный валидатор; запись, в которой есть оба способа или нет ни одного, отклоняется.

ПолеСтатусЗначение
schemeОБЯЗАТЕЛЬНОВерсия семейства конструкции. v1 задаёт scheme = 1.
aeadОБЯЗАТЕЛЬНОИдентификатор формата содержимого. v1 задаёт "chacha20-poly1305-stream64k".
nonceОБЯЗАТЕЛЬНО24 случайных байта — уникальная для конверта соль ключа содержимого и каждого KEK слота.
kemтолько путь слотовВыбор KEM для каждого слота ("x25519" или "mlkem768x25519").
slotsодин из путейМассив слотов ключей для получателей (несколько получателей).
slots_macтолько путь слотов32-байтовый HMAC, привязывающий набор слотов и утверждение о хеше элемента к ключу содержимого.
passphraseдругой путьБлок KDF парольной фразы (ключ, выведенный из парольной фразы).
  • enc.slots — несколько получателей. Конверт несёт N независимо обёрнутых слотов ключей, по одному на получателя. Шифртекст нельзя расшифровать без закрытого ключа, подходящего к одному из слотов. Описан ниже, в разделе Слоты и MAC набора слотов.
  • enc.passphrase — выведение из парольной фразы. Конверт не несёт слотов; ключ содержимого выводится напрямую из нормализованной парольной фразы. Описан ниже, в разделе Путь парольной фразы.

Оба пути разделяют scheme, aead и nonce. Различаются они тем, какой ключ присутствует, и, как следствие, тем, где живёт обязательство по ключу. На пути слотов обязательство — в блокчейне: slots_mac — это HMAC с ключом от CEK над транскриптом, который фиксирует поля заголовка, набор слотов и утверждение о хеше элемента, так что получатель удостоверяется в правильности ключа ещё до того, как что-либо выгрузит. На пути парольной фразы привязывать нечего — слотов нет, поэтому обязательство — это 32-байтовый заголовок, несомый внутри блоба шифртекста: чтобы проверить догадку о парольной фразе, нужен сам блоб, а не только публичный блокчейн. Каждый путь сериализует свой транскрипт одной и той же функцией canonicalEncode, а производитель или верификатор выбирает путь, проверяя, что из slots / passphrase присутствует. Эти два пути исчерпывающи и взаимоисключающи.

enc.scheme именует семейство конструкции независимо от поля v записи. Верификатор ДОЛЖЕН требовать enc.scheme === 1 и отклонять любое другое значение. Поле зарезервировано под будущее сквозное изменение — иной регламент MAC набора слотов или иной формат содержимого, — но не под добавление KEM: KEM для каждого слота выбирается полем enc.kem, и оба описанных ниже KEM существуют под scheme = 1 уже с первого выпуска. Шире, enc.scheme: 1 обозначает всю криптографическую схему, а не только MAC и формат содержимого: правила canonicalEncode, схема слота, хеш HKDF, хеш HMAC, AEAD обёртки по слотам, сегментированный формат содержимого STREAM, схемы транскриптов слотов и парольной фразы (включая привязку элемента hashes_hash), обязательство по парольной фразе внутри шифртекста, закреплённая редакция X-Wing, метки разделения доменов, версия и профиль Argon2id, а также профиль нормализации парольной фразы — всё это им зафиксировано, так что изменение любого из них требует нового значения enc.scheme.

Слой содержимого

Оба пути сходятся к одному симметричному проходу над открытым текстом, на ключе, выведенном из единого 32-байтового ключа шифрования содержимого (CEK). CEK — это то, что доставляют слоты (каждый слот его обёртывает) или что выдаёт KDF парольной фразы; содержимое шифруется не под CEK напрямую. Вместо этого каждый путь выводит отдельный 32-байтовый ключ содержимого как лист HKDF от CEK, посоленный уникальным для конверта enc.nonce под зависящим от пути info, так что слой доставки ключа и слой содержимого никогда не ключуют один и тот же примитив на одних и тех же байтах:

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)

Затем содержимое запечатывается в сегментированном STREAM, именуемом идентификатором формата chacha20-poly1305-stream64k. Это компоновка STREAM из спецификации age v1: ChaCha20-Poly1305 (RFC 8439, вариант с 12-байтовым nonce) над открытым текстом, разбитым на фрагменты фиксированного размера, причём каждый фрагмент запечатывается под ключом содержимого со счётным nonce на фрагмент:

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

Финальный флаг разделяет домены последнего фрагмента и всех остальных, и именно это делает усечение обнаружимым: поток, последний фрагмент которого не несёт флага 0x01, флаг 0x01 на фрагменте, не являющемся последним, данные после финального фрагмента или нефинальный фрагмент короче CHUNK_SIZE — всё это ДОЛЖНО приводить к отказу расшифровки (TAMPERED_CIPHERTEXT). Поскольку каждый запечатанный фрагмент не меньше своего 16-байтового тега, компоновка задаёт и структурный минимум — корректный блоб шифртекста на пути слотов никогда не короче 16 байт, одиночного тега пустого финального фрагмента.

AAD на фрагмент по замыслу пуст: весь контекст привязан к содержимому транзитивно. Ключ содержимого выводится из CEK, а CEK привязан ко всему заголовку полем slots_mac на пути слотов (чей транскрипт охватывает scheme, path, aead, kem, nonce, набор слотов и утверждение о хеше элемента) либо обязательством внутри шифртекста на пути парольной фразы. Переверните любое поле заголовка — и получатель выведет или примет другой ключ, так что расшифровка не удастся; AAD на фрагмент заново привязывал бы тот же контекст на каждом фрагменте, не добавляя стойкости.

Счётные nonce фрагментов безопасны потому, что ключ содержимого одноразов: он выводится из свежего CEK, посоленного уникальным для конверта enc.nonce, так что никакие два потока не делят пару (key, nonce), а производители без состояния — вкладки браузера, запуски CLI, воркеры, повторные попытки — никогда не согласуют nonce между конвертами. 88-битный счётчик допускает 2^88 фрагментов, что намного выше любой осуществимой полезной нагрузки, поэтому формат не налагает криптографического потолка на размер; практический максимум — это политика защиты от отказа в обслуживании в развёртывании, а не проводная константа.

На вход подаются ровно исходные байты содержимого. Конструкция не дописывает в начало или конец и не шифрует ни имя файла, ни MIME-тип, ни поле размера, ни манифест — поток расшифровывается обратно в эти байты, и только в них.

Выданные фрагменты предварительны до пересверки хеша

Сегментированный формат существует ради того, чтобы верификатор мог аутентифицировать и выдавать многогигабайтную полезную нагрузку инкрементально, при ограниченной памяти. Тег каждого фрагмента проверяется до того, как выдан открытый текст этого фрагмента, а усечение ловится финальным флагом, — но пересверка хеша открытого текста идёт над всем открытым текстом, после последнего фрагмента. Поэтому потоковый потребитель ДОЛЖЕН считать выданные байты предварительными — без побочных эффектов, без подтверждения приёма, без статуса «получено» — пока эта финальная проверка не пройдёт.

Опубликованный шифртекст — это единственный объект. На пути слотов это в точности фрагменты STREAM; на пути парольной фразы 32-байтовый заголовок-обязательство по ключу дописывается в начало внутри того же блоба (тот же объект, тот же URI, та же выгрузка — никогда не второй хранимый объект):

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

Хеш открытого текста в items[].hashes всегда фиксирует именно открытый текст, даже когда присутствует enc. Это и есть несущее свойство всей конструкции: верификатор, который не может расшифровать, всё равно вправе подтвердить, что запись существует, что её конверт правильно сформирован и что URI доступен для выгрузки, — но лишь держатель подходящего ключа получателя может расшифровать шифртекст и, пересчитав хеш, убедиться, в отношении чего именно дано обязательство. Поэтому валидатор НЕ ДОЛЖЕН расшифровывать ради «проверки» хешей; сверка хеша открытого текста происходит у получателя, после того как байты восстановлены. См. Содержимое и хеширование и Проверку.

Слоты и MAC набора слотов

На пути с несколькими получателями enc.slots — это непустой массив слотов, по одному на получателя. Каждый слот обёртывает один и тот же CEK под ключом шифрования ключа (KEK), своим для каждого получателя; открыв любой слот, получатель восстанавливает тот единственный CEK, который расшифровывает содержимое. Отправитель:

  1. Выбирает один KEM на всю запись и генерирует CEK (32 случайных байта) и nonce (24 случайных байта).
  2. Для каждого получателя выводит KEK слота и обёртывает под ним CEK (подробности по каждому KEM — ниже).
  3. Перемешивает массив слотов с помощью CSPRNG (несмещённый Фишер — Йетс).
  4. Строит транскрипт слотов над перемешанным массивом, межкемными полями заголовка и утверждением о хеше элемента, хеширует его в slots_hash и вычисляет slots_mac как HMAC с ключом от CEK над этим хешем.
  5. Выводит ключ содержимого из CEK и enc.nonce и запечатывает содержимое в сегментированном STREAM, описанном выше.

Обёртывание в слоте

Каждый слот обёртывает CEK с помощью ChaCha20-Poly1305 (RFC 8439, вариант с 12-байтовым nonce) под KEK слота, давая 48-байтовый wrap (32-байтовый шифртекст CEK + 16-байтовый тег 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)

12-байтовый нулевой nonce безопасен именно потому, что KEK каждого слота уникален в пределах записи: следовательно, KEK используется ровно для одного обёртывания, и nonce физически не может совпасть под одним ключом. Это жёсткий инвариант — если какая-либо будущая редакция позволит повторно использовать KEK (кеширование, детерминированные эфемерные ключи, дедупликация получателей с переиспользованием слота), то в том же изменении нулевой nonce придётся заменить на случайный.

MAC набора слотов

slots_mac привязывает весь набор слотов — вместе с межкемными полями заголовка, которые задают, как слоты интерпретируются, и утверждением о хеше открытого текста элемента — к CEK, пресекая подмену, удаление и перестановку слотов, а также сшивку конвертов. Привязка устроена в два шага: транскрипт слотов единожды хешируется в 32-байтовый slots_hash, и этот хеш служит сообщением для HMAC с ключом от 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 — это закрытая карта, несущая ровно этот набор из семи ключей, сериализованная через canonicalEncode, так что обе стороны дают побайтово одинаковые байты; порядок её ключей — это побайтовая сортировка RFC 8949 §4.2.1, а не расставленная вручную. Значение slots — это перемешанный массив закрытых карт слотов ровно в том виде, в каком они появляются на проводе ({epk, wrap} для x25519, {kem_ct, wrap} для mlkem768x25519), так что всё проводное содержимое каждого слота оказывается внутри транскрипта. Транскрипт дополнительно фиксирует scheme, path, aead, kem и nonce: ретранслятор, перевернувший любое из этих полей заголовка, оставив формы слотов действительными, даёт другой slots_hash, и MAC не проходит. Префиксы SHA-256 для slots_hash и hashes_hash (cardano-poe-slots-transcript-v1, cardano-poe-item-hashes-v1) — это точный ASCII без терминатора и без префикса длины.

hashes_hash — это то, что привязывает конверт к утверждению о хеше именно этого элемента: это помеченный SHA-256 над canonicalEncode полной карты hashes элемента. Поскольку получатель пересчитывает slots_mac из одних лишь ончейн-байтов, совпадение MAC подтверждает, что конверт был запечатан именно под это утверждение, — конверт, пришитый к элементу с другой картой hashes, проваливает шаг ончейн-сверки ещё до выгрузки шифртекста. Поле uris[] элемента намеренно не привязывается, так что шифртекст можно перенести на новый URI с адресацией по содержимому, не аннулируя конверт; отправитель, для которого список URI является частью утверждения, привязывает его подписью на уровне записи.

В выведении HMAC_KEY salt = "" — это октетная строка нулевой длины, конвенция отсутствующей соли из RFC 5869 §2.2 (HKDF-Extract подставляет HashLen нулевых байт — 32 для SHA-256). Она закрепляется байт-точным вектором соответствия, а не оставляется на умолчание библиотеки, так что реализация, неправильно обрабатывающая отсутствующую соль, проваливает вектор, а не молча выводит другой ключ.

slots_hash вычисляется единожды на запись и неизменен на протяжении цикла пробного расшифровывания получателя — проверка MAC по каждому слоту заново ключует HMAC от каждого CEK-кандидата, но всегда над одним и тем же 32-байтовым slots_hash. Свойство обязательства сохраняется, потому что ключ HMAC по-прежнему равен HKDF-SHA-256(CEK, …): предварительное хеширование транскрипта лишь меняет сообщение HMAC с полного транскрипта на его SHA-256, оставляя нетронутой привязку с ключом от CEK.

MAC набора слотов зафиксирован значением enc.scheme: на проводе у него нет идентификатора, на каждое значение scheme приходится ровно одна конструкция, и она одинакова для обоих KEM. slots_mac ДОЛЖЕН быть ровно 32 байта (ENC_SLOTS_MAC_INVALID_LENGTH при неверной длине) и ДОЛЖЕН проверяться за постоянное время.

Транскрипт напрямую зависит от проводных байтов каждого слота. Оба поля слота — это единственные байтовые строки CBOR (epk — 32 байта, kem_ct — 1120 байт), так что пофрагментной нормализации нет и нет неоднозначности границ фрагментов: единственное разбиение, которое выполняет Label 309, — это транспортное разбиение тела целиком на странице Запись, отменяемое до того, как что-либо из этого запускается. Переворот байта где угодно в слоте меняет slots_hash и проваливает MAC.

Слою содержимого не нужна отдельная привязка к набору слотов на каждый проход: ключ содержимого — это лист HKDF от CEK, а CEK уже привязан ко всему заголовку — включая hashes_hash — полем slots_mac. Правка любого слота или поля заголовка меняет то, что выводит получатель, так что поток содержимого попросту не открывается. Поэтому AAD на фрагмент пуст (см. Слой содержимого).

Два KEM

KEM, выбираемый на каждую запись полем enc.kem, задаёт форму слота и порядок выведения KEK. Оба зарегистрированы под enc.scheme = 1 уже с первого выпуска.

enc.kemKEMОткрытый ключ получателяФорма слотаInfo-строка KEK
"x25519"X25519 (классический)32 байта{ epk: bstr(32), wrap: bstr(48) }"cardano-poe-kek-v1"
"mlkem768x25519"X-Wing = X25519 + ML-KEM-7681216 байт{ kem_ct: bstr(1120), wrap: bstr(48) }"cardano-poe-kek-mlkem768x25519-v1"

Создателям записей СЛЕДУЕТ по умолчанию выбирать mlkem768x25519. Гибридный KEM устойчив и к классическому противнику, и к квантовому по схеме «собери сейчас, расшифруй потом», сохраняя классическую стойкость X25519 как нижнюю границу — комбинатор X-Wing привязывает оба общих секрета. Этот порог «никогда не ниже классической безопасности X25519» задан для корректно сгенерированных ключей получателя: он предполагает, что открытый ключ проходит проверку действительности ключа из закреплённой редакции X-Wing (применяемую при инкапсуляции, см. Гибридный: mlkem768x25519 ниже). Классический KEM x25519 остаётся доступным для получателей, чей опубликованный ключ — только X25519. Идентификатор mlkem768x25519 намеренно записан без дефисов, в соответствии с написанием, принятым в экосистеме X-Wing/age.

Оба KEM используют один и тот же шаблон станзы age — материал KEM для каждого получателя плюс симметричное обёртывание ключа файла — и одну и ту же привязку заголовка (MAC набора слотов), так что обе схемы покрываются одной единообразной конструкцией без зависимости от HPKE. Классический путь x25519 почти зеркально повторяет нативного получателя X25519 из age. Гибридный путь mlkem768x25519 намеренно расходится с собственным постквантовым выбором age: age v1.3.0 поставляет нативных постквантовых получателей (видимый префикс age1pq…), которые обёртывают ключ файла через HPKE SealBase (RFC 9180) над KEM ML-KEM-768 + X25519, а не через шаблон станзы. Сохранение станзового обёртывания для гибридного пути и позволяет одному единообразному обёртыванию и одной единообразной привязке заголовка покрыть оба KEM. Поэтому гибридное обёртывание не наследует HPKE-конструкцию age, и никакого заявления о наследовании от age для него не делается; отдельная кодировка получателя age1pqc (см. Ключи) отражает то, что две гибридные кодировки независимы.

Классический: x25519

Для каждого получателя отправитель генерирует свежую эфемерную пару ключей X25519, выполняет ECDH с открытым ключом получателя и выводит KEK через HKDF (RFC 5869):

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

Единственный ключевой материал на проводе — 32-байтовый эфемерный открытый ключ epk; открытый ключ получателя не публикуется никогда. Соль — это помеченный SHA-256, привязывающий три значения: pub_epk делает KEK каждого слота уникальным, pub_R привязывает его к конкретному получателю (пресекая любую попытку переиспользовать epk против другого получателя), а уникальный для конверта enc.nonce якорит KEK к одному конверту — так что сбой CSPRNG, повторивший случайность KEM между двумя конвертами, деградирует лишь до связываемости между конвертами, но никогда — до повторной пары (KEK, нулевой nonce) при обёртывании. Реализации X25519 ДОЛЖНЫ отклонять нулевой общий секрет согласно RFC 7748 §6.1; основные библиотеки делают это автоматически.

Гибридный: mlkem768x25519 (X-Wing)

Гибридный KEM — это конструкция X-Wing (draft-connolly-cfrg-xwing-kem-10), объединяющая ML-KEM-768 (FIPS 203) с X25519. Каждая инкапсуляция берёт свежую случайность ML-KEM и свежий эфемерный ключ X25519 и даёт 1120-байтовый шифртекст и 32-байтовый объединённый общий секрет. Выведение KEK привязывает получателя через внешнюю соль, вычисленную над собственными проводными байтами слота:

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

Размеры ключей и шифртекста X-Wing:

КомпонентРазмерСостав
Открытый ключ1216 байтML-KEM-768 ek (1184) ‖ X25519 pk (32)
Шифртекст1120 байтML-KEM-768 ct (1088) ‖ X25519 ephemeral (32)
Общий секрет32 байтаВыход комбинатора X-Wing
Ключ декапсуляции32 байтасид; открытый ключ выводится из него

Гибридный слот не несёт поля epk — эфемерный ключ X25519 это последние 32 байта 1120-байтового kem_ct. XWing.Encapsulate ДОЛЖНА применять к pub_R проверку действительности открытого ключа из закреплённой редакции X-Wing и отклонять недействительный ключ, а не инкапсулировать к нему; это предусловие, при котором гибридный порог никогда не опускается ниже классической безопасности X25519. Конструкция использует X-Wing через адаптер только с именованными полями: Encapsulate(pk) даёт .ct (1120 Б) и .ss (32 Б); Decapsulate(sk, ct) даёт 32-байтовый общий секрет. Реализации ДОЛЖНЫ отображаться на API закреплённой редакции по имени и НЕ ДОЛЖНЫ потреблять возвращаемые значения по позиции — закреплённая редакция возвращает (ss, ct) из инкапсуляции и записывает декапсуляцию как Decapsulate(ct, sk), обратно наивному чтению слева направо. Выведение KEK привязывает получателя через помеченную соль фиксированной длины, SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R), где kem_ct — это 1120-байтовый шифртекст ровно в том виде, в каком он несётся в слоте, а pub_R — 1216-байтовый открытый ключ получателя X-Wing. Это та же конструкция из трёх значений, что использует классическая соль под собственной меткой: kem_ct якорит KEK к уникальному для слота значению, pub_R привязывает его к конкретному получателю, а enc.nonce якорит его к одному конверту, — выраженная через дайджест SHA-256, поскольку гибридные входы слишком велики для сырой соли. В обеих солях член pub_R — это каноническая проводная кодировка ключа получателя: ровно 32-байтовый x25519_publicKey(priv_R) для x25519, ровно закреплённая 1216-байтовая байтовая строка открытого ключа X-Wing для mlkem768x25519. Отправитель и верификатор ДОЛЖНЫ использовать именно эту кодировку и НЕ ДОЛЖНЫ подставлять никакой неканонический или перекодированный эквивалент, иначе обе стороны выведут разные KEK, и честная запись не откроется. Принципиально, что привязка вычисляется вне KEM, над собственными проводными байтами слота, поэтому конструкция держит X-Wing как KEM-чёрный ящик: она использует лишь публичный интерфейс KEM (инкапсуляция, декапсуляция, 32-байтовый общий секрет) и не делает никаких предположений о внутреннем хешировании комбинатора. Различающаяся по KEM метка info cardano-poe-kek-mlkem768x25519-v1 дополнительно гарантирует, что KEK, выведенный под один KEM, никогда не совпадёт с KEK, выведенным под другой, даже при идентичном 32-байтовом общем секрете. 1120-байтовый шифртекст несётся как единственная байтовая строка CBOR в slot.kem_ct — для передачи фрагментируется лишь тело записи целиком (см. Запись), но никогда — отдельное поле.

Один KEM на запись

Один элемент запечатанного PoE несёт ровно один enc.kem; каждый слот использует форму и порядок выведения KEK именно этого KEM. Файл либо целиком классический, либо целиком гибридный: слоты разных KEM НЕ ДОЛЖНЫ оказаться в одном массиве slots, и верификатор ДОЛЖЕН отклонить запись, формы слотов которой не согласуются с объявленным enc.kem (ENC_SLOT_INVALID_SHAPE).

Материал инкапсуляции обязан также быть различным в пределах одного массива slots: для x25519 все значения epk ДОЛЖНЫ различаться, для mlkem768x25519 все значения kem_ct ДОЛЖНЫ различаться. Дубликат отвергается — ещё до запуска любого примитива KEM или AEAD — с кодом ENC_SLOTS_DUPLICATE_KEM_MATERIAL. Это проверяемая часть инварианта уникальности KEK по слотам, на которую опирается обёртывание с нулевым nonce: повторное использование KEK между записями или между ключами — это обязанность производителя, которую верификатор обнаружить не может, но дубликат внутри записи структурно виден и ДОЛЖЕН приводить к отказу.

Пробное расшифровывание получателем

Получатель держит закрытый ключ (32-байтовый скаляр X25519 для x25519 либо 32-байтовый сид декапсуляции X-Wing для mlkem768x25519 — оба производны от сида; см. Ключи). Заранее он не знает, какой именно слот предназначен ему и есть ли такой вообще, поэтому он пробно расшифровывает массив. Форму цикла определяют два свойства: проверка MAC набора слотов вплетена внутрь (слот принимается только тогда, когда его CEK-кандидат вдобавок воспроизводит ончейн-значение slots_mac), а цикл проходит по всем слотам без досрочного выхода, выбирая совпадение за постоянное время, чтобы наблюдатель за временем не мог вывести, какой индекс слота совпал.

Прежде чем обратиться к любому примитиву KEM или AEAD, верификатор ДОЛЖЕН выполнить структурные проверки формы (защита от разделяющего оракула): scheme == 1, aead/kem зарегистрированы, nonce 24 байта, slots_mac 32 байта, slots непуст, секрет получателя 32 байта, каждый slot.wrap ровно 48 байт, каждый epk для x25519 — ровно 32 байта без kem_ct, каждый kem_ct для mlkem768x25519 — ровно 1120 байт без epk, и весь материал инкапсуляции различен в пределах slots (иначе ENC_SLOTS_DUPLICATE_KEM_MATERIAL).

Тем же доисполнительным проходом верификатор ДОЛЖЕН также ограничить ресурсы парсера: контрольные границы — это MAX_SLOTS = 1024 слотов и 65536 байт для декодированного конверта enc. Обе лежат намного выше потолка метаданных транзакции Cardano в ≈ 16 КиБ, ограничивающего честную запись, так что запись, превышающая любую из них, недоформирована и отклоняется здесь — ENC_SLOTS_TOO_MANY при слишком большом числе слотов, ENC_ENVELOPE_TOO_LARGE при конверте сверх размера — ещё до запуска любого примитива KEM или AEAD. Эти границы — проверяемые верификатором, закреплённые на стороне развёртывания константы, а не проводные поля; развёртывание МОЖЕТ ужесточать их.

; 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

Выведение KEK ветвится по enc.kem: для x25519 получатель выполняет ECDH со slot.epk и заново выводит ту же помеченную соль над enc.nonce || slot.epk || pub_R; для mlkem768x25519 он напрямую выполняет декапсуляцию X-Wing над slot.kem_ct (единственной байтовой строкой длиной 1120 байт) и заново вычисляет ту же помеченную соль над enc.nonce || slot.kem_ct || pub_R, где pub_R — это его собственный 1216-байтовый открытый ключ X-Wing, выведенный из хранимого сида. Отвержение нулевого общего секрета X25519 здесь явное, а не полагающееся на это автоматически: слот, смастеренный так, чтобы свести общий секрет к zeros(32) (RFC 7748 §6.1), выставляет в ложь не зависящий от секрета бит валидности kem_ok, KEK за постоянное время выбирается равным dummy_KEK, выведенному из zeros(32) под той же солью и info, так что цикл выполняет идентичную работу, а kem_ok вплетается в ok — так что слот с недействительным ECDH не может быть принят независимо от исхода обёртки или MAC, и запись выдаёт единственный общий отказ, если ничто иное не совпадает. Всё, что идёт после раскрытия обёртки, — проверка MAC набора слотов, выведение ключа содержимого и расшифровывание содержимого — от KEM не зависит.

Оба AEAD-примитива *_open_or_dummy атомарны: при отказе проверки тега они не возвращают открытого текста, а возвращаемый кандидат (candidate_CEK при открытии обёртки, plaintext при открытии содержимого) — это фиксированная или псевдослучайная заглушка, не зависящая от провалившегося шифртекста. Непроверенный открытый текст никогда не отдаётся вызывающему, так что провалившееся открытие не может стать оракулом расшифровки.

Почему проверка MAC живёт внутри цикла

Недобросовестный отправитель может смастерить слот, который раскрывается под ключом получателя, но даёт выбранный атакующим CEK (для инкапсуляции к открытому ключу получателя закрытый ключ не нужен). Если бы получатель принял первый же успех AEAD за «свой слот», эта подделка заслонила бы честный слот, расположенный дальше в массиве. Вплетение проверки slots_mac в цикл означает, что слот принимается, лишь когда его CEK-кандидат воспроизводит MAC над slots_hash, — поэтому поддельный слот пропускается, а сканирование продолжается. Длину slot.wrap ДОЛЖНО проверять на равенство 48 байтам до любого обращения к AEAD — эту защиту от разделяющего оракула применяет и age v1.

Несколько совпадающих слотов: дублирование допустимо, конфликт CEK — нет. Закрытый ключ получателя МОЖЕТ правомерно совпасть более чем с одним слотом. Производитель может запечатать один и тот же CEK одному и тому же получателю в нескольких слотах — каждый со своим свежим эфемералем по слоту, — чтобы дополнить видимое число получателей; это правомерный приём приватности. Верификатор выбирает CEK первого совпадения и НЕ ДОЛЖЕН отклонять лишь потому, что совпало более одного слота. Это отлично от отклонения дубликата материала инкапсуляции внутри записи (ENC_SLOTS_DUPLICATE_KEM_MATERIAL), срабатывающего на повторяющемся epk или собранном заново kem_ct: честное дублирование берёт свежую случайность KEM по слоту для каждого появления, так что его epk / kem_ct различны, и оно никогда не задевает ту проверку. Единственная аномалия, которую верификатор ДОЛЖЕН отклонить, — это два совпавших слота, восстанавливающих разные CEK (сравнение за постоянное время): цикл несёт бит cek_conflict через все слоты и, если какое-либо более позднее совпадение восстанавливает CEK, отличный от выбранного, выдаёт единственный общий отказ. Это защита в глубину — при свойстве обязательства, которое даёт восстановленный CEK (MAC набора слотов привязывает CEK к единственному транскрипту слота; см. Анонимность и разделение по KEM), совпадение с другим CEK и так неосуществимо, будучи в точности той коллизией при многих ключах, которую обязательство исключает, — так что проверка закрывается наглухо против сломанной реализации или будущего ослабления этого допущения.

Одна форма общего отказа, постоянное время по всем слотам

Недоверенный вызывающий ДОЛЖЕН получать ровно одну общую форму отказа независимо от того, почему расшифровка не удалась, — ни один слот не открылся, набор слотов был подменён или не прошёл AEAD содержимого, — и ответ НЕ ДОЛЖЕН различать их, ни раскрывать, какой слот совпал. Реализация МОЖЕТ выдавать внутренние типизированные коды — WRONG_RECIPIENT_KEY (ни один слот не открывается), TAMPERED_HEADER (слот открывается, но ни один CEK-кандидат не воспроизводит slots_mac над slots_hash), TAMPERED_CIPHERTEXT (AEAD содержимого не проходит после того, как CEK восстановлен) — доверенному локальному вызывающему для диагностики, но эти коды НЕ ДОЛЖНЫ утекать внешнему наблюдателю через различимый ответ.

По времени верификатор МОЖЕТ вернуться на проверке отсутствия совпадения (if NOT found) до расшифровки содержимого. Этот ранний возврат выдаёт лишь получателя против неполучателя — никогда не то, какой слот совпал, и никакого ключевого материала, — потому что межслотовый цикл выше к моменту этой проверки уже отработал полностью. Единообразие времени между случаем неполучателя и получателем, чей шифртекст не открывается, НЕ требуется, а фиктивное открытие содержимого НЕ ДОЛЖНО предписываться: навязывание каждому неполучателю полной стоимости расшифровки содержимого не даёт приватности, которой цикл уже не обеспечивает. Гарантия постоянного времени, которая всё же держится, — это межслотовый инвариант: цикл выполняет постоянное число операций над слотами на каждый закрытый ключ без досрочного выхода, так что наблюдатель сетевого уровня узнаёт лишь число слотов, но никогда — какой слот (если вообще какой-либо) разворачивает ключ. Получатель, держащий несколько ключей (например, архивные ключи после ротации идентичности), перебирает закрытый ключ × слот, заново выводя половину соли pub_R из текущего ключа; он МОЖЕТ замыкать накоротко между ключами (утекает лишь слабый сигнал «какой ключ совпал»), но ДОЛЖЕН оставаться постоянным по времени на слотах любого отдельного ключа.

Восстановив открытый текст, получатель — на уровне приложения, а не внутри функции расшифровывания — пересчитывает хеш открытого текста и сверяет его с items[].hashes. Несовпадение означает, что ончейн-обязательство записи не соответствует расшифрованным байтам, и тогда получатель ДОЛЖЕН отказаться действовать на основании этого открытого текста. Именно этот шаг замыкает круг: блокчейн засвидетельствовал некоторое обязательство в момент времени T, а получатель удостоверяет, что обязательство дано в отношении ровно этих байтов.

Путь парольной фразы

Альтернативный способ доставки ключа заменяет слоты получателей парольной фразой. Нет ни массива slots, ни slots_mac, ни эфемерного ключа на каждый слот, ни цикла пробного расшифровывания: CEK выводится прямо из нормализованной парольной фразы через Argon2id (RFC 9106) над солью и параметрами, лежащими в блокчейне. Обязательство по ключу, которое на пути слотов даёт slots_mac, здесь живёт в 32-байтовом заголовке внутри блоба шифртекста, дописанном перед фрагментами STREAM, — тот же объект, тот же URI, та же выгрузка.

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

Блок enc.passphrase на проводе — это { alg, salt, params }: он называет KDF ("argon2id"), соль и параметры. Label 309 задаёт нижнюю границу параметров: m ≥ 65536 КиБ (64 МиБ), t ≥ 3, p ≥ 1; создатель записи выбирает значения на этой границе или выше, а соль составляет от 16 до 64 байт включительно (потолок в 64 байта — это предел на байтовую строку метаданного). Там, где платформа это поддерживает, издателям СЛЕДУЕТ использовать p = 4 (второй рекомендованный профиль RFC 9106 §4); верификаторы МОГУТ принимать любое p ≥ 1 в пределах потолков развёртывания ниже.

PASSPHRASE_TRANSCRIPT привязывает параметры KDF, поля заголовка и утверждение о хеше элемента к обязательству: верификатор пересчитывает транскрипт из полученной карты enc и карты hashes элемента, так что подмена salt, любого значения params, nonce, aead или пришивание конверта к другому утверждению о хеше дают другой pw_hash, и проверка обязательства не проходит. Содержимое затем запечатывается в том же сегментированном STREAM, что и на пути слотов, под ключом содержимого пути парольной фразы. Значение "normalization" — это зафиксированная схемой константа, подаваемая в транскрипт, чтобы закрепить ровно тот профиль, под которым был выведен CEK; на провод оно никогда не сериализуется.

Порядок проверки. Верификатор выводит CEK-кандидат из введённой парольной фразы, читает ведущие 32 байта блоба шифртекста, пересчитывает обязательство и сравнивает его за постоянное время — до открытия любого фрагмента STREAM. Блоб пути парольной фразы короче 48 байт — 32-байтовый заголовок-обязательство плюс 16-байтовый минимум STREAM — не может быть корректным и является недоформированным шифртекстом (TAMPERED_CIPHERTEXT). При несовпадении — неверная парольная фраза, подменённые salt / params, подменённый заголовок или пришитый конверт — верификатор выдаёт тот же единственный общий отказ, что и при любом другом отказе расшифровки, и НЕ ДОЛЖЕН начинать потоковую передачу. Поэтому неверная парольная фраза неотличима от подменённой записи.

До нормализации и Argon2id реализация ДОЛЖНА ограничить длину сырого входа парольной фразы, чтобы непомерно большая парольная фраза не могла вызвать отказ в обслуживании до KDF: контрольная граница — это 4096 байт UTF-8 сырого входа, отвергаемого до любой работы по нормализации или хешированию. Как и границы MAX_SLOTS и декодированного конверта enc, обеспечиваемые на пути слотов, это проверяемая верификатором, закреплённая на стороне развёртывания константа — а не проводное поле, — и развёртывание МОЖЕТ ужесточать её. Сверх нижней границы параметров реализациям СЛЕДУЕТ также обеспечивать верхние границы на m, t и p против DoS на стороне верификатора; эти потолки не нормативны (зависят от оборудования) и их НЕ ДОЛЖНО смешивать с нижней границей.

Почему обязательство — вне блокчейна

Ончейн-обязательство по парольной фразе вручало бы каждому наблюдателю бесплатный офлайн-оракул для проверки — вывести CEK-кандидат из угаданной парольной фразы, сверить его с блокчейном — для каждой записи с парольной фразой, навсегда, включая записи, чей шифртекст придержан. Перенос обязательства внутрь блоба шифртекста означает, что проверка догадки требует самого блоба: запись с придержанным шифртекстом не выставляет на вечный реестр никакого перебираемого по парольной фразе материала, а законный получатель, у которого блоб уже есть, ничего не платит за то, чтобы сначала прочитать 32-байтовый заголовок.

Профиль нормализации

Нормализация, применяемая к парольной фразе перед Argon2id, — это фиксированный профиль cardano-poe-pw-norm-v1. Он нормативен: две реализации ДОЛЖНЫ выводить побайтово одинаковый CEK из одной и той же парольной фразы, а единственный способ это гарантировать — закреплённая нормализация. Профиль, применяемый по порядку, такой:

  1. Отклонить неназначенные кодовые точки. Парольная фраза, содержащая любую кодовую точку, неназначенную в Unicode 16.0, отклоняется с ENC_PASSPHRASE_UNNORMALIZABLE ещё до запуска любого шага нормализации.
  2. NFKC. Применить форму нормализации KC согласно UAX #15 под Unicode 16.0.
  3. Пробельные символы. Определить «пробельный символ» как всякий символ, несущий свойство Unicode White_Space под Unicode 16.0, и схлопнуть каждую максимальную последовательность таких символов в один U+0020 SPACE.
  4. Обрезка. Удалить ведущие и завершающие пробельные символы.
  5. Отклонить пустую. Если результат — пустая строка, отклонить с ENC_PASSPHRASE_EMPTY: парольная фраза из одних пробелов или иным образом пустая нормализуется в ноль байт, что Argon2id молча принял бы, привязав запись к CEK, который вправе вывести кто угодно.
  6. Кодирование. Закодировать результат в UTF-8; эти байты и есть пароль на вход Argon2id.

Шаг 1 — это то, что делает профиль детерминированным между реализациями и во времени. Политика стабильности нормализации Unicode гарантирует, что нормализация строки стабильна в будущих версиях Unicode, только когда каждая кодовая точка в ней назначена в той версии, в которой строка была нормализована; неназначенная кодовая точка может позже получить декомпозицию и молча изменить выведенный CEK. Отклонение неназначенных кодовых точек закрывает эту брешь полностью и незаметно для честных пользователей — каждый символ из реального письменного употребления назначен.

Версия Unicode закреплена на Unicode 16.0 буквально и НЕ ДОЛЖНА «плавать»: набор свойства White_Space, набор назначенных кодовых точек и таблицы отображения NFKC — все зависят от версии, и верификатор, разрешающий профиль против другой версии Unicode, мог бы вывести другой CEK из той же парольной фразы и не суметь расшифровать честную запись. Будущая редакция, принимающая более новую версию Unicode, делает это под новым идентификатором профиля, а не переосмысляя cardano-poe-pw-norm-v1.

Энтропия парольной фразы — единственный заслон

Соль и параметры Argon2id навсегда открыты в блокчейне, поэтому у атакующего есть неограниченное офлайн-время, чтобы перебирать парольную фразу против них. Энтропия парольной фразы — единственный запас прочности на этом пути. Создателям записей СЛЕДУЕТ брать парольную фразу из diceware, сгенерированную CSPRNG, а не придуманную человеком, и СЛЕДУЕТ показывать заметное предупреждение, когда они принимают набранную вручную парольную фразу, о том, что ончейн-шифртекст навсегда останется уязвим к офлайн-атаке.

Прямая секретность и независимость слотов

Конструкция слотов использует эфемерно-статический ECDH (или свежую инкапсуляцию X-Wing) со свежим эфемерным ключом на каждый слот, что даёт два свойства, которых лишилась бы статически-статическая схема или схема с общим эфемерным ключом:

  • Прямая секретность при компрометации отправителя. В конструкции у отправителя нет долгосрочного ключа; эфемерный ключ обнуляется сразу после запечатывания. Компрометация состояния отправителя в дальнейшем не позволяет расшифровать записи, опубликованные до компрометации.
  • Независимость слотов. Разным получателям достаются разные эфемерные ключи, а значит, разные общие секреты и KEK. Если один получатель раскроет свой обёрнутый CEK, это выдаст CEK (что неизбежно — это и есть ключ файла), но никогда не выдаст KEK другого получателя.

У запечатанного PoE по замыслу нет прямой секретности для получателя: как только запись запечатана на долговременный ключ получателя, держатель подходящего закрытого ключа сможет расшифровать её и впредь, навсегда. Это свойство шифрования с открытым ключом на долговременный ключ, а не дефект.

Анонимность и разделение по KEM

Когда запись запечатанного PoE не несёт sigs, её проводные байты независимы от идентичности отправителя: каждый слот несёт лишь эфемерный материал KEM, отдельный для каждой записи и каждого слота (эфемерный X25519 в slot.epk или шифртекст X-Wing в slot.kem_ct), долговременные ключи отправителя никогда не появляются, слоты перемешаны CSPRNG, на проводе нет ни одного открытого ключа получателя и нет ни одного описательного поля (имя файла, MIME-тип, размер). Поэтому неподписанная запечатанная запись не привязывает к блокчейну никакой идентичности отправителя — ровно то, что нужно для сливов от информаторов, аукционов с закрытыми ставками и хранения доказательств.

Для обоих KEM честные утечки одинаковы и неизбежны: любому наблюдателю видны число слотов, различие «запечатано или открыто» и семейство KEM «классический или гибридный» (enc.kem); ничего сверх этого о получателях не видно.

Более сильное утверждение — что противник, держащий набор открытых ключей-кандидатов получателей, не может проверить, адресован ли данный слот кому-то из них (приватность к ключу / анонимность получателя), — это свойство, специфичное для KEM:

  • x25519 — приватен к ключу. Инкапсуляция каждого слота — это свежий эфемерный открытый ключ, статистически независимый от ключа получателя. Противник, держащий открытые ключи-кандидаты получателей, не может по одним лишь slot.epk и slot.wrap решить, какому кандидату (если вообще какому-либо) адресован слот, без подходящего закрытого ключа. Поэтому классический путь приватен к ключу, что заодно даёт несвязываемость между записями: два запечатанных PoE одному и тому же получателю выглядят как ничем не связанные блобы epk/wrap.
  • mlkem768x25519 — не заявляется. Анонимность получателя против противника, держащего открытые ключи-кандидаты получателей, — это отдельное свойство, не вытекающее из IND-CCA-стойкости гибридного KEM. Label 309 не заявляет его для пути X-Wing, пока и если оно не будет обосновано для X-Wing независимо. Развёртывание, чья модель угроз требует анонимности получателя против противника, держащего ключи, НЕ ДОЛЖНО полагаться ради этого свойства на гибридный путь.

Отправители, обеспокоенные корреляцией по времени между записями, ДОЛЖНЫ группировать публикации в стороне от критической временно́й линии; криптография уровня провода не решает атаки по временны́м метаданным.

Обязательство даёт MAC набора слотов; обёртывание — не обязано

Восстановленный CEK — это обязательство к тому набору слотов, который совпал у получателя: злонамеренный отправитель не может сконструировать два различных набора слотов, которые один и тот же получатель примет как свои. Требуемое здесь свойство — это ограниченное обязательство к ключу для CEK конверта в смысле RFC 9771 — восстановленный CEK привязан к единственному транскрипту слота, — а не полноценный обязующий AEAD над произвольными входами. Оно опирается на стойкость к коллизиям при многих ключах у CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) для состязательно выбранных CEK и транскриптов — запас в ~128 бит против обобщённой коллизии (граница парадокса дней рождения на 256-битном выходе), достаточный для модели угроз. Доказуемость подмены самого транскрипта наследует границу коллизий SHA-256 в ~2^128: любое изменение обязуемых полей заголовка или байтов слота меняет slots_hash, а подделка неизменного slots_hash над другим транскриптом — это в точности тот самый перебор коллизии на ~2^128. Поскольку это обязательство обеспечивает slots_mac, AEAD обёртывания по слотам не обязан быть обязующим AEAD; принятый по умолчанию необязующий ChaCha20-Poly1305 здесь корректен.

Запрещённые приёмы

Совместимая реализация НЕ ДОЛЖНА:

  • Переиспользовать эфемерный ключ слота между слотами или записями, а равно любым иным путём допускать повтор KEK — обёртывание с нулевым nonce держится на уникальности KEK каждого слота.
  • Смешивать KEM в одном массиве slots (один enc.kem на запись).
  • Публиковать слоты в порядке ввода — перемешивание с помощью CSPRNG обязательно.
  • Обёртывать CEK с любым nonce, кроме 12-байтового нулевого, или с пустыми AAD обёртывающего AEAD — AAD обёртывания равны литералу метки info соответствующего KEM.
  • Помещать открытый ключ получателя на провод — схема пробного расшифровывания и есть та самая защита приватности; публикация открытых ключей её сводит на нет.
  • Пропускать проверку slots_mac — без неё подмена слотов удаётся.
  • Хранить открытый текст по адресу ar:///ipfs:// — публикуется только шифртекст; открытый текст доставляется вне полосы или остаётся у отправителя.
  • Ссылаться на шифртекст по любой схеме, кроме ar:// или ipfs:// — схемы с адресацией по содержимому привязывают URI к байтам; URL, отдаваемый хостом, потребовал бы отдельного ончейн-обязательства в отношении шифртекста, которого запечатанный PoE не несёт.
  • Записывать в журнал или сохранять CEK, любой KEK, ключ HMAC набора слотов, общий секрет ECDH, эфемерный закрытый ключ или закрытый ключ получателя.

Связанные страницы

  • Ключи — производные от сида пары X25519 и X-Wing, поставляющие ключевой материал получателя и отправителя.
  • Запись — где enc находится в карте записи и передача тела целиком, которая несёт запись в блокчейне.
  • Реестры алгоритмов — идентификаторы enc.aead, enc.kem и KDF парольной фразы и стоящие за ними примитивы.
  • Содержимое и хеширование — обязательство в отношении хеша открытого текста, которое несёт каждая запечатанная запись.
  • Проверка — конвейер валидации, причины, по которым валидатор никогда не расшифровывает, и каталог ошибок.