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

Руководство для разработчиков

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

Label 309 — это формат данных и набор криптографических конструкций, а не продукт. Может сосуществовать сколько угодно независимых реализаций — на TypeScript, Python, Rust, Go или нативной мобильной платформе, — и запись, созданная одной из них, ДОЛЖНА проверяться в любой другой. Эта страница адресована команде, которая строит такую реализацию. Здесь описана архитектура, которая удерживает криптографическую поверхность в проверяемых границах, точный контракт, благодаря которому две реализации совместимы между собой, и набор тестов соответствия, который механически решает, выполнили вы этот контракт или нет.

Совместимость Label 309 между языками держится на двух вещах. Первая — детерминированность: все конструкции привязаны к публичным стандартам (канонический CBOR из RFC 8949, Ed25519 из RFC 8032, X25519 из RFC 7748, HKDF из RFC 5869, Argon2id из RFC 9106, COSE из RFC 9052), поэтому одни и те же входные данные везде дают одни и те же байты. Вторая — набор тестов соответствия: побайтово точные тестовые векторы, которые реализация либо воспроизводит, либо нет. Соответствие — это свойство, которое можно проверить, а не утверждение, которое вы делаете.

Многоуровневая архитектура

Совместимой реализации РЕКОМЕНДУЕТСЯ отделять криптографические примитивы от прикладной логики, разнося их по разным уровням, каждый из которых зависит только от уровня под собой. Названия ниже — это роли, а не названия пакетов; выбирайте свои.

┌─────────────────────────────────────────────────────────┐
│  application                                            │
│  UI, routing, persistence, payments, background jobs    │
├─────────────────────────────────────────────────────────┤
│  SDK                                                    │
│  service client + standalone verifier + helpers         │
├─────────────────────────────────────────────────────────┤
│  wire-format library                                    │
│  schema · structural validator · canonical-CBOR codec   │
├─────────────────────────────────────────────────────────┤
│  cryptographic core                                     │
│  hashes · KDFs · signatures · KEM · AEAD · CBOR · COSE  │
│  no application or framework dependencies               │
└─────────────────────────────────────────────────────────┘

Границы здесь несущие, а не декоративные. У каждого уровня одна задача и короткий список того, о чём ему знать запрещено.

Криптографическое ядро

Нижний уровень содержит только примитивы: хеш-функции, KDF, операции подписи и KEM, уровень шифрования содержимого на AEAD, канонический CBOR, COSE_Sign1, конструкцию упаковки и распаковки для запечатанного подтверждения существования, корни и доказательства Merkle, а также типизированные классы ошибок, которые они выбрасывают. Здесь нет доменной логики, нет HTTP, нет обращений к базе данных и нет импортов из UI- или серверных фреймворков.

Этот уровень ДОЛЖЕН оставаться свободным от любых прикладных или серверных зависимостей и ДОЛЖЕН быть безопасным для браузера — по трём конкретным причинам:

  • Он работает везде. Хеширование файла, сборка конверта и, что особенно важно, автономный верификатор одинаково легко работают в браузерах, в бессерверных обработчиках и в командной строке — не только на сервере. Серверная зависимость (драйвер базы данных, фреймворк логирования, привязанный к конкретной среде выполнения, UI-библиотека) сломала бы эти сценарии и раздула бы каждого потребителя, который включает ядро в сборку.
  • Это поверхность аудита. Рецензент может прочитать пакет, состоящий только из примитивов, от начала до конца, сверяя его с RFC. Как только сюда просачивается прикладной код, поверхность, которую специалист по безопасности должен удерживать в голове, начинает расти безгранично.
  • Именно его встраивают сторонние участники. Независимый верификатор — тот, кто не доверяет ни одному сервису, только цепочке, — подключает этот уровень и ничего выше него. Именно компактность и переносимость делают практичной идею «проверь сам».

Конкретно: ядро НЕ ДОЛЖНО импортировать ORM или драйверы баз данных, UI-фреймворки, привязанные к серверу фреймворки логирования или любой прикладной модуль. Случайность ДОЛЖНА поступать из платформенного CSPRNG (Web Crypto getRandomValues или эквивалентного реэкспорта), но никак не из источника, доступного только в Node, чтобы один и тот же код без изменений работал и в браузере.

Закрепляйте границу в CI, а не на код-ревью

Правило нулевых зависимостей разрушается в тот же момент, когда внутрь проскакивает удобный импорт. Реализации РЕКОМЕНДУЕТСЯ запускать линтер графа зависимостей, который обходит каждый импорт в ядре и в библиотеке формата данных и роняет сборку на любом идентификаторе вне списка разрешённых для данного уровня. Рецензенты забывают; линтер — нет.

Библиотека формата данных

Следующий уровень владеет собственно Label 309: схемой записи, структурным валидатором, а также кодировщиком и декодировщиком канонического CBOR. Он зависит от криптографического ядра (для хеширования, COSE и кодека CBOR) и больше ни от чего прикладного. Его поверхность компактна и чиста:

  • encode — выдаёт байты канонического CBOR для проверенной записи.
  • decode — обратная операция.
  • validate — прогоняет структурные и семантические проверки стандарта над декодированной записью и возвращает типизированный результат (см. Проверку).

На этом уровне в виде кода живут правила страницы Запись: закрытый набор ключей, дисциплина повторной сборки фрагментов, инвариант «items либо merkle», требования канонического CBOR. Как и ядро, он остаётся свободным от HTTP-клиентов, драйверов баз данных и импортов фреймворков.

SDK и приложение

SDK оборачивает нижние уровни в удобные вспомогательные функции — клиент сервиса, функции сборки и распаковки конверта и автономный верификатор, то есть функцию, которая декодирует запись, проверяет её структуру, сверяет любые подписи на уровне записи с ключом из блокчейна и выносит вердикт, опираясь только на публичные данные. Автономный верификатор ДОЛЖЕН работать без сетевого доступа к какому-либо сервису разработчика реализации; единственный внешний вход для него — публичный обозреватель блокчейна, который верификатор выбирает сам. SDK также РЕКОМЕНДУЕТСЯ оставлять безопасным для браузера.

Прикладной уровень — UI, маршрутизация, хранение данных, выставление счетов, фоновые задачи — пишется с нуля и не несёт никаких обязательств по совместимости. Стандарт не накладывает ограничений на то, как вы его строите; он требует лишь, чтобы этот уровень располагался над проверенной криптографической поверхностью, а не залезал внутрь неё.

Контракт побайтовой идентичности

Совместимость — это свойство байтов, а не намерений. Две реализации совместимы тогда и только тогда, когда примитивы, у которых нет свободы в формировании результата, выдают одни и те же байты из одних и тех же входных данных. Это и есть контракт паритета, и он лежит в основе соответствия.

Контракт чётко делится на две части. Операции, чей результат полностью определяется их входными данными, ДОЛЖНЫ быть побайтово идентичными во всех реализациях. Операции, потребляющие случайность, не могут давать совпадающие байты от вызова к вызову; для них контракт — это взаимная потребляемость: значение, созданное одной реализацией, ДОЛЖНО быть пригодным для использования любой другой (шифртекст, запечатанный на одном языке, расшифровывается на другом).

Побайтово идентичные примитивы

Каждая операция ниже — чистая функция своих входных данных и ДОЛЖНА выдавать побайтово идентичный результат в любой совместимой реализации:

ПримитивПривязан кСовпадающий результат
Сид → пара ключей Ed25519 / X25519HKDF-SHA-256 с зарегистрированными info-константамипроизводные открытый и закрытый ключи
HKDF-SHA-256RFC 5869выходной ключевой материал для фиксированного входа
HMAC-SHA-256, MAC набора слотовRFC 2104байты slots_hash и тега slots_mac для фиксированных CEK и набора слотов
Argon2id (KDF по парольной фразе)RFC 9106производный ключ для фиксированных (m, t, p, salt, len, password)
SHA-256FIPS 180-4дайджест
BLAKE2b-256RFC 7693дайджест
Кодирование в канонический CBORRFC 8949 §4.2.1закодированные байты для фиксированного входа
Кодирование COSE_Sign1RFC 9052байты структуры для фиксированных заголовка, нагрузки, подписи
Подпись / проверка Ed25519RFC 8032 (строгий режим)подпись; вердикт
ECDH на X25519RFC 7748общий секрет для фиксированных скаляров
Упаковка / распаковка запечатанного PoESealed PoEбайты каждого слота и MAC при заданных эфемерных ключах и CEK
Корень Merkle + доказательства включенияRFC 9162 §2.1.1корень и доказательства по каждому листу для упорядоченного списка листьев

Два момента стоит подчеркнуть. Ed25519 работает в строгом режиме: совместимый верификатор ДОЛЖЕН применять правила канонического S и отклонения точек малого порядка из RFC 8032 §5.1.7, так что две реализации сходятся не только в том, какие подписи они принимают, но и в том, какие отвергают. Argon2id пересекает границы экосистем: разные языки тянут за собой разные библиотеки Argon2, но каждая совместимая библиотека реализует RFC 9106 и ДОЛЖНА выдавать идентичный результат для идентичных параметров — контрактом служит набор параметров, а не библиотека.

Операции, потребляющие случайность

Генерация ключей, упаковка запечатанного подтверждения существования со свежими эфемерными ключами для каждого слота и шифрование конверта — все они берут свежую случайность, поэтому их результат меняется при каждом вызове и не может быть зафиксирован побайтово. Контракт для них — взаимная потребляемость: результат, полученный одной реализацией, ДОЛЖЕН быть пригоден для использования любой другой. Запись, запечатанная на одном языке, ДОЛЖНА расшифровываться на другом; пара ключей, созданная на одном, ДОЛЖНА проверяться и служить адресатом шифрования на другом. Наборы тестов соответствия фиксируют это с помощью детерминированных тестовых хуков, которые подставляют эфемерные ключи, делая упаковку воспроизводимой, и с помощью фикстур полного цикла, которые шифруют на одном языке и расшифровывают на другом.

Сборка конструкции запечатанного PoE

Запечатанный PoE — самая плотная часть проводного формата и та часть, где единственный неверный байт — переставленный ключ карты, метка, ошибочная на один символ, неканоническая фрагментация — даёт конверт, который открывается в вашей собственной реализации, но ни в какой другой. Этот раздел — чек-лист сборки: точные рецепты, дополнительные аутентифицируемые данные, которые покрывает каждый AEAD, цикл пробного расшифровывания и проверки, которые обязан применять каждый производитель и верификатор. Справочник по конструкции на странице Sealed PoE — это проза; здесь — то, как соединить всё воедино, чтобы загорелся зелёным контроль паритета. Закрепите эти внешние черновики в точности, поскольку их внутренности фиксируют байты, которые вам предстоит воспроизвести:

  • chacha20-poly1305-stream64k — формат содержимого — это ChaCha20-Poly1305 (RFC 8439) в сегментированной компоновке STREAM по 64 КиБ из спецификации age v1. Закрепите размер фрагмента (65536), 12-байтовый nonce на фрагмент uint88_be(counter) ‖ final_flag, пустой AAD на фрагмент и правило финального флага в точности — они фиксируют байты, которые вам предстоит воспроизвести.
  • X-Wing (KEM mlkem768x25519) — это draft-connolly-cfrg-xwing-kem-10. Относитесь к нему как к KEM-чёрному ящику: конструкция привязывает открытый ключ получателя и шифртекст в сам шаг выведения ключа, поэтому она не опирается ни на какое свойство внутреннего хеширования комбинатора. XWing.Encapsulate ДОЛЖНА применять проверку действительности открытого ключа из закреплённой редакции и отказываться инкапсулировать к ключу, её не прошедшему; порог «никогда не ниже классической безопасности X25519» задан для корректно сгенерированных ключей, и пропуск проверки утрачивает порог для этого получателя. Векторы KEM в наборе соответствия фиксируют инкапсуляцию против draft-10, так что несоответствие версии черновика всплывает немедленно.

Один CEK, два пути доставки ключа

Запечатанная запись шифрует открытый текст единожды под единым ключом шифрования содержимого (CEK), а затем доставляет этот CEK одним из двух взаимоисключающих путей, различаемых по наличию полей, — тега режима нет:

  • путь слотов — CEK обёртывается независимо для каждого получателя под ключом шифрования ключа своего слота. enc несёт slots (а также kem, slots_mac).
  • путь парольной фразы — CEK выводится напрямую из нормализованной парольной фразы через Argon2id. enc несёт passphrase; он не несёт ни kem, ни slots, ни slots_mac.

Оба пути разделяют enc.scheme (всегда 1; отклоняйте всё прочее), enc.aead (chacha20-poly1305-stream64k) и enc.nonce (24 байта). Различаются они тем, где живёт обязательство по ключу: в блокчейне в slots_mac на пути слотов, в 32-байтовом заголовке внутри блоба шифртекста на пути парольной фразы. Оба привязывают утверждение о хеше элемента в свой транскрипт, и оба запечатывают содержимое в одном и том же сегментированном STREAM; различие — в доставке ключа и обязательстве, а не в слое содержимого.

Обёртывание по слотам (путь слотов)

Выберите один KEM на всю запись — никогда не смешивайте KEM в пределах одного slots[]. Для каждого из N получателей выведите свежий ключ шифрования ключа своего слота и обёрните под ним тот же CEK с помощью ChaCha20-Poly1305 при 12-байтовом нулевом nonce, с AAD, равными литералу метки info этого KEM (никогда не пустые AAD), получая ровно 48 байт (32-байтовый шифртекст CEK + 16-байтовый тег). Нулевой nonce безопасен лишь потому, что ключ шифрования ключа задан для слота; см. проверку уникальности ниже.

x25519 (классический). Свежая эфемерная пара ключей X25519 на каждый слот:

priv_epk : randomBytes(32)                        ; fresh per slot
pub_epk  : x25519_publicKey(priv_epk)
shared   : x25519_sharedSecret(priv_epk, pub_R)   ; reject all-zero result
kek_salt : SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R)   ; 32 B
KEK      : HKDF-SHA-256(ikm  = shared, salt = kek_salt,
                        info = "cardano-poe-kek-v1", L = 32)
wrap     : ChaCha20-Poly1305(key = KEK, nonce = zeros(12),
                             ad = "cardano-poe-kek-v1", plaintext = CEK)   ; 48 B
slot     : { "epk": pub_epk, "wrap": wrap }

mlkem768x25519 (гибридный; X-Wing). Свежая инкапсуляция X-Wing на каждый слот:

enc    = XWing.Encapsulate(pub_R)       ; named fields — MUST NOT consume positional order
kem_ct = enc.ct                         ; 1120 B
shared = enc.ss                         ; 32 B
kek_salt : SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R)   ; 32 B
KEK      : HKDF-SHA-256(ikm  = shared, salt = kek_salt,
                        info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
wrap     : ChaCha20-Poly1305(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

Обе соли имеют одну форму — SHA-256(label || enc.nonce || <KEM-материал слота> || pub_R) — несущую 32-байтовый эфемерный pub_epk на классическом пути и 1120-байтовый шифртекст X-Wing kem_ct на гибридном; || — это байтовая конкатенация, а каждый литерал префикса соли — точный ASCII без терминатора и без префикса длины. pub_R — это каноническая проводная кодировка ключа получателя (32 Б для x25519, закреплённые 1216 Б для mlkem768x25519). Гибридный слот не несёт отдельного epk; эфемерный ключ X25519 это последние 32 байта kem_ct, а kem_ct — это единственная байтовая строка CBOR ровно в 1120 байт: для передачи фрагментируется лишь тело записи целиком, но никогда — отдельное поле.

Соль привязывает три значения: KEM-материал слота (KEK уникален для слота), pub_R (пресекая ретрансляцию-«запутанного-помощника» против другого получателя) и enc.nonce (якорь KEK к одному конверту, так что повторная случайность KEM деградирует лишь до связываемости между конвертами). Различающиеся метки info дают межкемное разделение доменов, так что ни один KEK, выведенный под один KEM, не может совпасть с выведенным под другой при идентичном общем секрете. Используйте каждую из одиннадцати внутренних меток байт в байт — cardano-poe-kek-v1, cardano-poe-kek-mlkem768x25519-v1, cardano-poe-x25519-kek-salt-v1, cardano-poe-xwing-kek-salt-v1, cardano-poe-item-hashes-v1, cardano-poe-slots-transcript-v1, cardano-poe-slots-mac-v1, cardano-poe-passphrase-transcript-v1, cardano-poe-passphrase-mac-v1, cardano-poe-payload-v1, cardano-poe-payload-passphrase-v1. Ни одна из них никогда не сериализуется на проводе; это фиксированные константы, а не выбираемые через реестр. Единственный отличающийся байт даёт slots_mac, обязательство или тег AEAD, который честный производитель не сможет воспроизвести.

Перемешайте до того, как считать MAC. Порядок ввода («главный получатель — первым») — это привилегированные метаданные; публикация слотов в порядке ввода их утекает. Перемешайте slots[] с помощью CSPRNG несмещённой перестановкой Фишера — Йетса — простой выбор индекса u32 % m смещён к малым остаткам и должен выбираться по схеме отказа до равномерного индекса — до вычисления MAC набора слотов, который привязывает перемешанный проводной порядок.

MAC набора слотов: хешируйте транскрипт, затем HMAC под CEK

MAC набора слотов привязывает весь набор слотов плюс поля заголовка, задающие, как слоты читаются, к CEK. Стройте его в два шага — хешируйте закрытый транскрипт единожды, затем сделайте HMAC от этого хеша:

hashes_hash : SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))   ; 32 B

SLOTS_TRANSCRIPT = {                          ; closed 7-key map; keys are a set, not an order
    "scheme":      1,
    "path":        "slots",
    "aead":        <enc.aead>,                ; 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
}
slots_hash : SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
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 B

Три вещи здесь решают судьбу паритета:

  • Транскрипт — это закрытая карта, сериализованная через canonicalEncode. Её порядок ключей — это сортировка RFC 8949 §4.2.1, а не расставленная вручную. Фиксация scheme, path, aead, kem и nonce рядом со слотами означает, что ретранслятор, перевернувший любое поле заголовка — даже оставив формы слотов действительными, — меняет slots_hash и ломает MAC.
  • Транскрипт привязывает утверждение о хеше элемента. hashes_hash — это помеченный SHA-256 над canonicalEncode полной карты hashes элемента. Поскольку получатель пересчитывает slots_mac из одних лишь ончейн-байтов, совпадение MAC подтверждает, что конверт был запечатан именно под это утверждение о хеше, — конверт, пришитый к элементу с другой картой hashes, проваливает шаг ончейн-сверки ещё до выгрузки шифртекста. Значение slots — это перемешанный массив проводных карт слотов напрямую: каждое поле слота — единственная байтовая строка (epk 32 Б, kem_ct 1120 Б), так что пофрагментной канонизации нет.
  • slots_hash вычисляется единожды и держится неизменным на протяжении цикла пробного расшифровывания. Проверка MAC по каждому слоту заново ключует HMAC от каждого CEK-кандидата, но всегда над одним и тем же 32-байтовым slots_hash. Предварительное хеширование оставляет нетронутым обязательство с ключом от CEK: оно меняет сообщение HMAC с полного транскрипта на его SHA-256, и ничего больше.

Алгоритм MAC, выведение его ключа и схема транскрипта — всё зафиксировано enc.scheme = 1 и одинаково для обоих KEM; идентификатора MAC на проводе нет. slots_mac — ровно 32 байта и проверяется за постоянное время.

Шифрование содержимого: сегментированный STREAM

Зашифруйте открытый текст единожды в сегментированном STREAM под ключом содержимого, выведенным из CEK. Ключ содержимого — это отдельный лист HKDF от CEK, посоленный enc.nonce под зависящим от пути info, так что слой обёртывания и слой содержимого никогда не ключуют один и тот же примитив на одних и тех же байтах:

content_key : HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)

; STREAM (chacha20-poly1305-stream64k):
CHUNK_SIZE  : 65536 plaintext bytes per non-final chunk
chunk nonce : uint88_be(counter) || final_flag   ; 12 B; counter from 0, +1 per chunk;
                                                 ; final_flag = 0x01 on the last chunk, else 0x00
per-chunk AAD : empty
ciphertext  : seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
              ; each chunk sealed with ChaCha20-Poly1305 under content_key; sealed = plaintext + 16 B

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

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

Открытый текст — это ровно исходные байты содержимого; конструкция не дописывает в начало или конец и не шифрует ни имя файла, ни MIME-тип, ни поле размера, ни обёртку метаданных. Опубликованный блоб шифртекста — это фрагменты STREAM (на пути парольной фразы им предшествует 32-байтовый заголовок-обязательство ниже). Собранная карта enc и получившийся URI попадают в блокчейн; байты шифртекста — нет: опубликуйте их в хранилище с адресацией по содержимому, а URI ar:// или ipfs:// положите в uris[] элемента.

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

Когда получателей нет, выведите CEK из нормализованной парольной фразы с помощью Argon2id. Нет ни epk, ни обёртывания по слотам, ни MAC набора слотов, ни цикла пробного расшифровывания. Обязательство по ключу, которое на пути слотов даёт slots_mac, здесь живёт в 32-байтовом заголовке внутри блоба шифртекста, дописанном перед фрагментами STREAM:

passphrase_bytes = utf8(normalize(passphrase))   ; cardano-poe-pw-norm-v1
CEK = argon2id(passphrase_bytes, salt = enc.passphrase.salt,
               params = enc.passphrase.params, 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,
    "path":        "passphrase",
    "aead":        <enc.aead>,
    "nonce":       <enc.nonce>,               ; bytes(24)
    "hashes_hash": hashes_hash,               ; bytes(32), over this item's hashes
    "passphrase": {                           ; closed sub-map
        "alg":           "argon2id",
        "salt":          enc.passphrase.salt,
        "params":        { "m": m, "t": t, "p": p },
        "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 B

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

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

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

Применяйте нижние границы параметров: длина salt 16–64 байта; m ≥ 65536 КиБ (≈ 64 МиБ), t ≥ 3, p ≥ 1. Закрепите версию Argon2 на 0x13 (19); никакая другая версия не допустима под enc.scheme: 1, и поля версии на проводе нет. Там, где платформа это поддерживает, издателям СЛЕДУЕТ выпускать p = 4 (второй рекомендованный профиль RFC 9106 §4); верификаторы МОГУТ принимать любое p ≥ 1 в пределах потолков развёртывания. Argon2id чисто пересекает границы экосистем — контракт это набор параметров, а не библиотека, — так что фиксированные (m, t, p, salt, len, password) обязаны давать побайтово одинаковый результат в любой реализации. Привязка между парольной фразой и её конвертом — это обязательство внутри шифртекста выше; неверная парольная фраза и подменённый шифртекст обе проявляются как один общий отказ.

Ограничьте сырую парольную фразу до нормализации и Argon2id: отвергайте любой вход длиннее контрольной границы MAX_PASSPHRASE_INPUT_BYTES = 4096 байт UTF-8, чтобы патологическая парольная фраза не могла вызвать отказ в обслуживании до KDF. Как и границы MAX_SLOTS и декодированного конверта на пути слотов, это закреплённая на стороне развёртывания константа, которую вы МОЖЕТЕ ужесточать, а не проводное поле.

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

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

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

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

Пробное расшифровывание: открывайте каждый слот, вплетайте MAC, проваливайтесь обобщённо

Получатель держит один закрытый ключ KEM и обнаруживает свой слот, пытаясь открыть каждый, — открытых ключей получателей на проводе нет. Прежде чем обратиться к любому примитиву KEM или AEAD, выполните ресурсные пределы, затем структурные проверки. Сначала ограничьте ресурсы парсера: отвергайте конверт, чей декодированный размер превышает 65536 байт (ENC_ENVELOPE_TOO_LARGE), или чей slots[] превышает MAX_SLOTS = 1024 (ENC_SLOTS_TOO_MANY). Обе контрольные границы лежат намного выше потолка метаданных транзакции Cardano в ~16 КиБ, ограничивающего честную запись; это закреплённые на стороне развёртывания константы, которые вы МОЖЕТЕ ужесточать, а никогда не проводные поля. Затем структурные проверки: scheme == 1; aead, kem зарегистрированы; nonce 24 байта; slots_mac 32 байта; slots непуст; секрет получателя 32 байта; каждый wrap 48 байт; для каждого KEM — каждый epk 32 байта без kem_ct (x25519) либо каждый kem_ct 1120 байт без epk (mlkem768x25519).

Отвергайте дубликат инкапсуляции внутри записи здесь, до любого примитива. Все значения epk должны быть различны на классическом пути, все значения kem_ct — различны на гибридном пути; дубликат поднимает ENC_SLOTS_DUPLICATE_KEM_MATERIAL. Это проверяемая верификатором часть инварианта уникальности KEK по слотам, на которую опирается обёртывание с нулевым nonce; повторное использование между записями или между ключами — это обязанность производителя, которую ни один верификатор обнаружить не может. Это отклонение срабатывает лишь на повторяющемся epk / kem_ct — запечатывание одному и тому же получателю дважды со свежими эфемералями по слоту правомерно и его не задевает (см. правило о нескольких совпадениях ниже). unwrap-negative несёт случай дубликата epk с переиспользованием KEK.

Затем выполните цикл, заново вычислив slots_hash единожды перед ним и держа его неизменным:

found        = false
cek_conflict = false
selected_CEK = 0^32
for slot in slots:                            ; iterate ALL slots — no early break
    ; derive KEK per-KEM, as in the wrap recipe. For x25519 the all-zero shared
    ; secret is rejected via a secret-independent bit, not an early branch:
    ;   kem_ok = NOT constantTimeEqual(shared, 0^32)
    ;   KEK    = ct_select(kem_ok, real_KEK, dummy_KEK)   ; dummy_KEK from ikm=0^32, same salt/info
    ; (XWing.Decapsulate has no all-zero case; kem_ok stays true on the hybrid path.)
    open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), kem_info_label, slot.wrap)
    HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
    mac_ok   = constantTimeEqual(HMAC-SHA-256(HMAC_KEY, slots_hash), 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 constantTimeEqual(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)
if cek_conflict: reject (single generic failure)

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

Непреложные правила в этом цикле:

  • Открывайте атомарно; никогда не отдавайте непроверенный открытый текст. Оба примитива *_open_or_dummy атомарны: при отказе тега AEAD они не возвращают открытого текста, а возвращаемый кандидат (обёрнутый CEK или открытый текст содержимого) — это фиксированная или псевдослучайная заглушка, не зависящая от провалившегося шифртекста. Именно это позволяет циклу нести candidate_CEK дальше провалившегося открытия обёртки, никогда не раскрывая неаутентифицированных байт.
  • Сверните проверку «весь ноль» в не зависящий от секрета бит kem_ok. Вычислите kem_ok = NOT constantTimeEqual(shared, 0^32) для пути x25519, выберите KEK за постоянное время между настоящим KEK и фиктивным KEK, выведенным из 0^32 под той же солью и info, и вплетите kem_ok в приём (ok = kem_ok AND open_ok AND mac_ok). Не ветвитесь досрочно на недействительной доле — слот с недействительным ECDH принять нельзя, а цикл всё равно делает идентичную работу. (XWing.Decapsulate не имеет случая «весь ноль», поэтому на гибридном пути kem_ok фиксирован в истину.)
  • Вплетайте проверку slots_mac в цикл. Злонамеренный отправитель может смастерить слот, который открывается под ключом получателя с выбранным атакующим CEK (знания закрытого ключа не требуется). Если принять первый же успех AEAD за «свой», эта подделка заслонила бы честный слот. Требование, чтобы CEK-кандидат вдобавок воспроизводил slots_mac над slots_hash, пресекает подмену, удаление и перестановку слотов. Никогда не пропускайте её.
  • Допускайте несколько совпадений; отвергайте лишь конфликт CEK. Ключ получателя МОЖЕТ правомерно совпасть более чем с одним слотом — запечатывание одного и того же CEK одному и тому же получателю в нескольких слотах, каждый со свежими эфемералями, — это правомерное дополнение числа получателей, и оно не задевает отклонение дубликата epk/kem_ct. Выбирайте CEK первого совпадения и не отвергайте лишь потому, что совпало более одного слота. Единственная аномалия, которую следует отвергнуть, — это два совпавших слота, восстанавливающих разные CEK (сравнение за постоянное время): несите бит cek_conflict и выдайте единственный общий отказ, если он выставлен. Это защита в глубину — при обязательстве набора слотов совпадение с другим CEK и так неосуществимо, — так что проверка закрывается наглухо.
  • Перебирайте все слоты в пределах прохода по одному закрытому ключу — постоянное число операций над слотами на ключ, без досрочного выхода, — чтобы наблюдатель за временем не мог вывести, какой слот совпал. Ведите отвержение «весь ноль» через kem_ok и фиктивную работу, а не выходите досрочно. Получатель с несколькими ключами перебирает ключ × слот и МОЖЕТ замыкать накоротко между ключами (утекает лишь слабый сигнал «какой ключ совпал»), но обязан оставаться постоянным по времени на слотах любого одного ключа — и обязан заново выводить половину соли pub_R на каждый ключ, поскольку оба KEM привязывают собственный открытый ключ получателя в соль KEK. Привязывайте эту соль к канонической проводной кодировке ключа — ровно 32-байтовому открытому ключу X25519 или ровно закреплённым 1216-байтовым байтам открытого ключа X-Wing — никогда к неканонической перекодировке, иначе обе стороны выведут разные KEK.
  • Выдавайте недоверенным вызывающим одну форму общего отказа. Внутренне вы можете отслеживать типизированные исходы для локальной диагностики — WRONG_RECIPIENT_KEY (ни один слот не открылся), TAMPERED_HEADER (слот открылся, но ни один CEK-кандидат не воспроизвёл slots_mac), TAMPERED_CIPHERTEXT (AEAD содержимого не прошёл после того, как CEK восстановлен и MAC проверен), — но внешний наблюдатель НЕ ДОЛЖЕН различать их по форме ответа. По времени модель намеренно очерчена: верификатор МОЖЕТ вернуться на проверке if NOT found до расшифровки содержимого, что отделяет неполучателя от получателя, чей шифртекст не открывается. Это выдаёт лишь получателя-против-неполучателя, никогда — какой слот или какой ключевой материал; единообразие времени между этими двумя случаями не требуется, а фиктивное открытие содержимого НЕ ДОЛЖНО предписываться. Держащаяся гарантия постоянного времени — это межслотовый инвариант выше.
  • Пересчитайте и сверьте хеш открытого текста после расшифровки. Ончейн-карта hashes привязывается к открытому тексту, а не к шифртексту, так что получатель (на уровне приложения) обязан пересчитать дайджест и сверить его: запись sha2-256 должна совпасть, а blake2b-256 — если присутствует. Несовпадение означает, что заявление записи о хеше не соответствует расшифрованным байтам, — откажитесь действовать на основании этого открытого текста. Структурный валидатор никогда не расшифровывает.

Ограничивайте полезную нагрузку с обеих сторон

Сегментированный STREAM не налагает криптографического потолка на размер: 88-битный счётчик на фрагмент допускает 2^88 фрагментов, и каждый фрагмент запечатывается под отдельной парой (content_key, nonce) в пределах ограничения RFC 8439 на один вызов, так что риска переполнения счётчика, от которого нужно было бы защищаться, нет. Поэтому максимум, который обеспечивает производитель или верификатор, — это политика защиты от отказа в обслуживании в развёртывании, а не проводная константа: обеспечивайте его инкрементально по мере записи или чтения потока и прерывайтесь до буферизации непомерно большой нагрузки. Усечение ловится структурно финальным флагом, а не пределом размера. Эта позиция действует и на пути слотов, и на пути парольной фразы.

Фикстуры соответствия запечатанного PoE

Уголок корпуса, посвящённый запечатанному PoE, — это место, где всплывает большинство межъязыковых ошибок. Прогоните свою реализацию через весь его объём. Позитивные фикстуры фиксируют детерминированное обёртывание и цикл пробного расшифровывания для обоих KEM — с одним и несколькими получателями, со смешанным N и худший случай с несколькими закрытыми ключами — плюс правомерный случай одного получателя, совпадающего с двумя слотами (свежие эфемерали, тот же CEK, ДОЛЖНО расшифроваться, так что реализация, отвергающая несколько совпадений, на нём проваливается), и путь парольной фразы (заголовок-обязательство плюс фрагменты STREAM в одном блобе). Отдельный набор компоновки STREAM фиксирует пустой открытый текст (один финальный фрагмент нулевой длины), полезную нагрузку из одного фрагмента и полезную нагрузку из нескольких фрагментов, пересекающую границу в 65536 байт. Прицельные KAT фиксируют обе соли KEK (SHA-256(label ‖ enc.nonce ‖ <KEM-материал> ‖ pub_R)), hashes_hash и его место в обоих транскриптах, инкапсуляцию X-Wing против draft-10, извлечение HKDF с солью нулевой длины (конвенция отсутствующей соли из RFC 5869 §2.2, зеркаля выведение ключа slots_mac), кодировки получателя и секрета в Bech32 и кодировку сида идентичности с контрольной суммой.

Негативные фикстуры фиксируют коды отклонения: поддельный теневой слот перед честным (запись ДОЛЖНА всё равно расшифроваться под честным CEK); переворот заголовка (kem/aead/scheme), оставляющий формы слотов действительными; пришивка hashes к элементу с другим утверждением о хеше; сбои обязательства по парольной фразе (неверная парольная фраза, подменённые salt/params, подменённый заголовок — все проваливаются до открытия любого фрагмента); отказы нормализации парольной фразы (вход с неназначенной кодовой точкой и вход из одних пробелов); нулевой общий секрет X25519; дубликат слота внутри записи; и случаи искажения STREAM (перевёрнутый тег фрагмента, усечённый поток, хвостовые данные, короткий нефинальный фрагмент). У двух свойств нет байтового вектора, и они удостоверяются поведенчески: отклонение конфликта CEK (построить такое означало бы ту самую коллизию обязательства при многих ключах, которую стандарт полагает неосуществимой) и гарантия постоянного времени по всем слотам. Воспроизведите каждую закреплённую байтовую строку и выдайте точный код для каждого негативного случая.

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

Соответствие и тестовые векторы

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

Векторы находятся в наборе тестов соответствия стандарта и сгруппированы по классам примитивов: фикстуры записей, упаковка и распаковка запечатанного PoE, подписи COSE_Sign1, HKDF, выведение из сида, Argon2id и канонический CBOR. Каждый вектор фиксирует входные данные в hex-формате нижнего регистра и ожидаемые результаты. Чтобы ими воспользоваться: подайте входные данные в свою реализацию, сравните каждый именованный результат байт за байтом и исправьте свой код при любом расхождении.

Три обязательства, которые должна выполнить каждая реализация

Воспроизвести позитивные векторы. Для каждой фикстуры записи ДОЛЖНЫ выполняться обе половины: encode(record) == expected_cbor И полный цикл encode(decode(expected_cbor)) == expected_cbor. Полный цикл распространяется и за пределы фикстур: для произвольного корректного входа encode(decode(x)) == x. Декодировщик, который теряет или переставляет информацию, либо кодировщик, который не каноничен, нарушает это и не проходит соответствие.

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

Сверяться с реестрами. Идентификаторы алгоритмов — это именованные строки, взятые из реестров на странице Реестры алгоритмов. Нераспознанный идентификатор ДОЛЖЕН приводить к точному коду неподдерживаемого алгоритма, а не к молчаливому принятию или к падению.

Исправляйте реализацию, но не вектор

Векторы привязаны к вышестоящим RFC и к детерминированным конструкциям этого стандарта. Когда сравнение не сходится, ошибка в тестируемой реализации. Правка вектора ради того, чтобы набор тестов прошёл, превращает реальный сбой совместимости в скрытый, который проявится только тогда, когда запись пересечёт границу реализаций в блокчейне, — в самый неподходящий для этого момент.

Прогоняйте паритет при каждом изменении

Реализации, которая поставляется более чем на одном языке — или хочет доказать совместимость с другой, — РЕКОМЕНДУЕТСЯ держать единое задание непрерывной интеграции, которое собирает каждый пакет, прогоняет набор тестов каждого языка против общих фикстур, обеспечивает работу линтера графа зависимостей и проверяет, что набор фикстур идентичен с обеих сторон. Фикстура, добавленная на одной стороне, но не на другой, роняет проверку: две реализации незаметно разошлись, и сборка ловит это раньше, чем настоящая запись. Фикстуры — это каноничный источник; каждый язык держит их побайтово идентичную копию, а проверка утверждает, что копия полна и точна.

Соглашения об именах и о формате данных

Несколько соглашений делают реализацию читаемой, а формат данных — стабильным:

  • Имена полей в формате данных записываются в snake_caseleaf_count, cose_sign1, slots_mac. Это верно для всех языков: даже там, где язык по своим идиомам использует camelCase для своего API в памяти, закодированная запись использует ключи в snake_case, потому что ключи входят в каноничные байты, которые покрывает подпись.
  • Идентификаторы — это строки из реестров, а не перечисления, зашитые в код. Хеши, AEAD, KEM, KDF и подписи — все ссылаются на именованные идентификаторы; добавление алгоритма (скажем, постквантового KEM) — это дополняющая запись в реестре, а вовсе не слом формата данных.
  • Имена методов в разных языках отражают друг друга по смыслу. Функция на одном языке имеет одноимённого по смыслу двойника на другом (encode_canonical_cborencodeCanonicalCbor), так что читатель, свободно владеющий любым из языков, может сопоставить одну поверхность с другой и судить о паритете по одному осмотру.
  • Сначала поднимайте криптографические уровни. Поставьте криптографическое ядро и библиотеку формата данных на ноги, проверьте их против векторов и добейтесь зелёной проверки паритета прежде, чем напишете хоть строчку прикладного кода. Автономный верификатор — наименьшая поверхность, примыкающая к приложению, и его стоит строить следующим; всё остальное опирается на криптографический уровень, правильность которого вы уже доказали.

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

  • Запись — формат данных, который реализуют валидатор и кодировщик.
  • Sealed PoE — справочник по конструкции, стоящий за приведёнными здесь рецептами сборки.
  • Реестры алгоритмов — именованные идентификаторы, которые разрешает реализация.
  • Проверка — конвейер валидации, автономный верификатор и каталог кодов ошибок.

On this page