Спецификация протокола qub
qub — это протокол криптографических временных обязательств: система, которая запечатывает слова к определённой дате в будущем и доказывает, по наступлении этой даты, что именно было сказано и когда.
Работу обеспечивают три примитива. drand — это децентрализованный маяк случайности — момент раскрытия обеспечивается физикой, а не доброй волей какой-либо стороны. Постоянное публичное хранилище — это защищённое от подделки публичное хранилище — никакая сторона не может изменить или удалить qub после того, как он был запечатан. ML-DSA-65 — это постквантовая цифровая подпись — каждый qub связан с парой ключей, секретная часть которой никогда не покидает устройство автора.
Вместе эти примитивы создают высказывание, защищённое временем, очевидно неподделанное и атрибутируемое — квитанцию, ценность которой растёт по мере того, как улучшается способность мира фальсифицировать прошлое.
Оставшаяся часть документа — это нормативная спецификация, необходимая для совместимых реализаций.
Спецификация протокола qub
| Поле | Значение |
|---|---|
| Версия | 1.0 (версия протокола 0x01, версия внешней обёртки 0x01) |
| Дата | 2026-05-01 |
| Статус | Черновик |
| Просмотрено до | 2026-05-01 |
Этот документ — нормативная спецификация протокола системы qub для запечатанных по времени обязательств. Он определяет структуры данных, правила сериализации, формулы вывода и процедуры верификации, необходимые для совместимых реализаций.
Область применения: уровень протокола намеренно нейтрален в отношении языка — тело qub представляет собой непрозрачный открытый текст / markdown / байты пакта, а локализованный рендеринг — это ответственность просмотрщика (веб-приложение qub.social, iframe <qub-embed>, MCP-клиенты и т. д.).
1. Нотация и соглашения
| Нотация | Значение |
|---|---|
u8, u64, i64 |
Беззнаковые/знаковые целые числа указанной разрядности |
[u8; N] |
Байтовый массив фиксированной длины из N байт |
Vec<u8> |
Байтовый массив переменной длины |
Option<T> |
Значение типа T или отсутствует |
String |
UTF-8 текстовая строка, нормализованная по NFC |
| ` | |
SHA3-256(x) |
Хеш NIST SHA3-256 байтовой строки x (FIPS 202) |
ceil(x) |
Функция округления вверх: наименьшее целое число ≥ x |
| CBOR | Concise Binary Object Representation (RFC 8949) |
| big-endian | Старший байт первым |
Все целые числа в конструкциях прообразов кодируются как байтовые массивы фиксированной ширины big-endian (i64 → 8 байт, u8 → 1 байт), если не указано иное.
Все временные метки — это секунды Unix в UTC.
2. Структуры данных
2.1 ComposeQub (состояние создателя в памяти)
Не сериализуется в CBOR. Не записывается в постоянное хранилище. Локально в приложении создателя.
ComposeQub {
draft_id: [u8; 16], // Random, generated locally
created_at: i64, // Unix seconds UTC
unlock_at: Option<i64>, // Unix seconds UTC; None while composing
visibility: u8, // 0x01 = public (only value in MVP)
content_type: u8, // 0x01 = text (only value in MVP)
plaintext: Vec<u8>, // UTF-8 qub body
sender_label: Option<String>, // Decorative display name; not authenticated
status: DraftStatus, // Composing | Sealed | Uploaded | Failed
}
2.2 QubEnvelope (расшифрованная полезная нагрузка)
Сериализуется с использованием канонического CBOR (§3). Зашифровано внутри SealedQub. Это структура, которая подтверждает целостность содержимого после расшифровки.
QubEnvelope {
version: u8, // Protocol major version (0x01 for v1)
qub_id: [u8; 32], // Derived (see §4.1)
content_type: u8, // Content type registry (see §6)
created_at: i64, // Unix seconds UTC
unlock_at: i64, // Unix seconds UTC
outcome_at: Option<i64>, // V1.1 — when reality renders judgment (verdict-uplift-plan §3.1)
sender_label: Option<String>, // Decorative; not authenticated in MVP
reply_to: Option<[u8; 32]>,// Parent qub_id for reply chains; not in qub_id preimage; not signed (see §9.3)
body: Vec<u8>, // Content payload (UTF-8 for text, CBOR for pact)
body_hash: [u8; 32], // SHA3-256(body) (see §4.2)
sig_alg: u8, // Signature algorithm (see §9.2)
author_signature: Option<Vec<u8>>, // Set when sig_alg != 0x00
author_pubkey: Option<Vec<u8>>, // Set when sig_alg != 0x00
cosigner_pubkey: Option<Vec<u8>>, // Set for cosigned pact bilateral agreements
cosigner_signature: Option<Vec<u8>>, // Set for cosigned pact bilateral agreements
}
Базовая конфигурация (неподписанный текстовый qub): version = 0x01, content_type = 0x01, sig_alg = 0x00, все поля Option отсутствуют.
Другие конфигурации v1: content_type = 0x03 (тело пакта, см. §6.1); sig_alg = 0x01 (ML-DSA-65) с присутствующими author_signature и author_pubkey (см. §9.3); cosigner_pubkey и cosigner_signature присутствуют вместе для совместно подписанных пактов (см. §9.7); reply_to установлено в qub_id родительского qub для qub в цепочках ответов (см. §9.3 относительно последствий для области действия подписи).
2.3 SealedQub (канонический сетевой формат)
Сериализуется с использованием канонического CBOR (§3). Записывается в постоянное хранилище. Это on-chain артефакт.
SealedQub {
version: u8, // Protocol major version (0x01 for v1)
qub_id: [u8; 32], // Same as QubEnvelope.qub_id
visibility: u8, // 0x01 = public; v1 viewers reject other values
unlock_at: i64, // Unix seconds UTC
outcome_at: Option<i64>, // V1.1 — surfaced on the verdict-watch CTA
// before reveal; mirrors QubEnvelope.outcome_at;
// bound to qub_id via the §4.1 preimage.
drand_chain_id: String, // drand chain hash (hex string)
drand_round: u64, // Target drand round number
tlock_ciphertext: Vec<u8>, // tlock-encrypted QubEnvelope CBOR bytes
recipient_pubkey: Option<[u8; 32]>,// Reserved field; accepted by canonical CBOR
// but not interpreted by the v1 reference viewer
title: Option<String>, // Plaintext title surfaced on the viewer
// countdown before reveal. Bound to qub_id
// via title_hash (§4.1). 1..=100 NFC code
// points, no control characters.
}
2.4 RevealedQub (состояние приложения просмотрщика)
Не сериализуется в CBOR. Локально в приложении просмотрщика. Конструируется после успешной расшифровки и верификации.
RevealedQub {
qub_id: [u8; 32],
arweave_tx_id: String,
visibility: u8,
content_type: u8,
created_at: i64,
unlock_at: i64,
outcome_at: Option<i64>, // V1.1 — переносится из QubEnvelope.outcome_at / SealedQub.outcome_at; управляет блоком отслеживания вердикта на странице раскрытия (verdict-uplift-plan §5.1)
drand_chain_id: String,
drand_round: u64,
sender_label: Option<String>,
title: Option<String>, // Carried forward from SealedQub.title
reply_to: Option<[u8; 32]>,
body: Vec<u8>,
body_hash: [u8; 32],
body_hash_verified: bool,
author_signature: Option<Vec<u8>>,
author_pubkey: Option<Vec<u8>>,
signature_verified: Option<bool>,
cosigner_pubkey: Option<Vec<u8>>,
cosigner_signature: Option<Vec<u8>>,
cosigner_verified: Option<bool>,
}
3. Канонический профиль CBOR
Вся сериализация SealedQub и QubEnvelope ДОЛЖНА соответствовать этому профилю. Две реализации, получившие одинаковую логическую структуру, ДОЛЖНЫ производить идентичные байты.
3.1 Правила кодирования
| Правило | Спецификация |
|---|---|
| Стандарт | RFC 8949 §4.2.1 (Core Deterministic Encoding Requirements) |
| Порядок ключей карты | Сортировка сначала по закодированной длине в байтах (более короткие перед более длинными), затем лексикографически (побайтово для кодировок одинаковой длины) |
| Кодирование целых чисел | Кратчайшая форма: 0–23 в начальном байте; 24–255 в 2 байтах; 256–65535 в 3 байтах; и т. д. |
| Кодирование длины | Только определённые длины. Никаких массивов, карт, байтовых или текстовых строк неопределённой длины (additional info = 31 запрещено). |
| Тэги | Никаких тэгов CBOR (major type 6 запрещён). |
| С плавающей точкой | Никаких float-значений (значения major type 7 0xF9–0xFB запрещены). |
| Текстовые строки | Кодированы в UTF-8, нормализованы по NFC (Unicode Normalization Form C). |
| Байтовые строки | Сырые байты. Никакого base64-кодирования на уровне CBOR. |
| Дублирующиеся ключи | Отклонять с ошибкой. Парсеры НЕ ДОЛЖНЫ молча принимать дублирующиеся ключи карты. |
| Простые значения | Разрешены только true (0xF5), false (0xF4) и null (0xF6). |
| Необязательные поля | Отсутствующие необязательные поля полностью опускаются из CBOR-карты (не кодируются как null). Присутствующие необязательные поля включаются в отсортированном порядке ключей. |
3.2 Верифицированные канонические порядки ключей
Эти порядки ключей являются нормативными. Реализации ДОЛЖНЫ выводить ключи именно в этом порядке. Debug-assertions СЛЕДУЕТ проверять порядок в нерелизных сборках.
QubEnvelope (версия 0x01, неподписанная, все необязательные поля отсутствуют):
"body" (5 encoded bytes)
"qub_id" (7 encoded bytes)
"sig_alg" (8 encoded bytes)
"version" (8 encoded bytes)
"reply_to" (9 encoded bytes) ← only if present (reply chains)
"body_hash" (10 encoded bytes)
"unlock_at" (10 encoded bytes)
"created_at" (11 encoded bytes)
"outcome_at" (11 encoded bytes) ← only if present (V1.1 verdict mechanic)
"content_type" (13 encoded bytes)
"sender_label" (13 encoded bytes) ← only if present
"author_pubkey" (14 encoded bytes) ← only if present
"cosigner_pubkey" (16 encoded bytes) ← only if present (pact cosign)
"author_signature" (17 encoded bytes) ← only if present
"cosigner_signature" (19 encoded bytes) ← only if present (pact cosign)
Вывод порядка ключей QubEnvelope: каждый ключ — это текстовая строка CBOR. Закодированная длина = 1 байт заголовка + длина строки (для строк менее 24 байт). Сортируйте сначала по общей закодированной длине, затем лексикографически для ключей одинаковой длины.
SealedQub (версия 0x01, публичный, без получателя):
"title" (6 encoded bytes) ← only if present
"qub_id" (7 encoded bytes)
"version" (8 encoded bytes)
"unlock_at" (10 encoded bytes)
"outcome_at" (11 encoded bytes) ← only if present (V1.1 verdict mechanic)
"visibility" (11 encoded bytes)
"drand_round" (12 encoded bytes)
"drand_chain_id" (15 encoded bytes)
"recipient_pubkey" (17 encoded bytes) ← only if present
"tlock_ciphertext" (17 encoded bytes)
PactTerms (тело пакта, content_type 0x03):
"notes" (6 encoded bytes) ← only if present
"terms" (6 encoded bytes)
"title" (6 encoded bytes)
"party_a" (8 encoded bytes)
"party_b" (8 encoded bytes)
"pact_version" (13 encoded bytes)
PactTerm (строка массива terms):
"key" (4 encoded bytes)
"value" (6 encoded bytes)
PartyIdentifier (карта party_a / party_b):
"label" (6 encoded bytes)
"contact" (8 encoded bytes) ← only if present
3.3 Справочник байтового кодирования
| Тип | Кодирование CBOR | Пример |
|---|---|---|
| Хеш SHA3-256 (32 байта) | 0x58 0x20 + 32 байта |
body_hash, qub_id |
| Временные метки (i64) | Major type 0 (положительные) или 1 (отрицательные), кратчайшее кодирование | Секунды Unix |
| Версия (u8, значение 1) | 0x01 (один байт) |
|
| Content type (u8, значение 1) | 0x01 (один байт) |
|
| sig_alg (u8, значение 0) | 0x00 (один байт) |
|
| Подпись ML-DSA-65 (3 309 байт) | 0x59 0x0C 0xED + 3 309 байт |
author_signature, cosigner_signature |
| Публичный ключ ML-DSA-65 (1 952 байта) | 0x59 0x07 0xA0 + 1 952 байта |
author_pubkey, cosigner_pubkey |
4. Нормативные выводы
4.1 qub_id
qub_id уникально идентифицирует qub и связывает QubEnvelope с SealedQub. Он выводится детерминированно из содержимого envelope.
qub_id = SHA3-256(
"QUB_ID_V2" || // domain separator: ASCII bytes [0x51 0x55 0x42 0x5F 0x49 0x44 0x5F 0x56 0x32] (9 bytes) + 0x00 padding (1 byte) = 10 bytes
version || // u8 (1 byte)
content_type || // u8 (1 byte)
created_at || // i64 big-endian (8 bytes)
unlock_at || // i64 big-endian (8 bytes)
outcome_at_or_zero || // i64 big-endian (8 bytes; 0 when outcome_at is absent)
drand_round || // u64 big-endian (8 bytes)
body_hash || // [u8; 32] (32 bytes)
title_hash // [u8; 32] (32 bytes; absent-sentinel = [0u8; 32])
)
// Total preimage: 108 bytes → 32-byte output
Кодирование domain separator: строка "QUB_ID_V2" — это 9 ASCII-байт. Один байт паддинга 0x00 добавляется, чтобы достичь 10 байт для выравнивания. Реализации ДОЛЖНЫ использовать ровно эти 10 байт: [0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x32, 0x00].
Кодирование outcome_at: V1.1 расширил прообраз с 92 до 100 байт, чтобы свернуть необязательное поле outcome_at в привязку. Отсутствующее outcome_at кодируется как 8 нулевых байт; валидаторы протокола повсюду отклоняют outcome_at <= 0, поэтому этот сентинел не может совпасть с легитимным значением. См. §3.2 (сетевой формат) и внутрипроектный файл tasks/verdict-uplift-plan.md для механики вердикта, которая мотивирует это поле.
Кодирование drand_round: V1.2 расширил прообраз со 100 до 108 байт, чтобы свернуть drand_round (целевой раунд drand, §4.3) в привязку, и повысил domain separator до QUB_ID_V2. Это связывает раунд таймлока с идентичностью qub: шлюз не может перепривязать шифротекст к другому раунду (например, уже прошедшему), отличному от того, который подразумевает отображаемый unlock_at. Процедура разблокировки (§8) дополнительно проверяет, что раунд, запечённый в станзу tlock-шифротекста, совпадает с unlock_round(unlock_at), так что отображаемое время разблокировки доказуемо является тем раундом, который управляет расшифровкой.
Свойства:
- Изменение любого поля в QubEnvelope (body, временные метки, content type, версия) даёт другой qub_id.
- qub_id вычисляется до шифрования. И QubEnvelope, и SealedQub несут один и тот же qub_id. Просмотрщик проверяет их совпадение после расшифровки.
- qub_id не зависит от
sender_label,author_signatureилиauthor_pubkey. Это означает, что одно и то же содержимое, запечатанное в одно и то же время, даёт один и тот же qub_id независимо от того, кто его подписывает. - Изменение
titleв SealedQub (при неизменности всего остального) меняетqub_idчерезtitle_hash. Поэтому шлюз не может подменить отображаемый в обратном отсчёте открытый заголовок, не нарушив идентичность qub. - Изменение
outcome_atв SealedQub (при неизменности всего остального) меняетqub_idчерез прообраз. Шлюз не может подменить отображаемую в обратном отсчёте дату объявления вердикта до раскрытия, не нарушив идентичность qub. - Изменение
drand_round(при неизменности всего остального) меняетqub_idчерез прообраз. Шлюз не может перепривязать таймлок-шифротекст к другому раунду, не нарушив идентичность qub; в сочетании с проверкой раунда станзы при разблокировке из §8 отображаемыйunlock_atявляется тем раундом, который фактически управляет расшифровкой.
4.2 body_hash
body_hash = SHA3-256(body)
Где body — это сырая Vec<u8> полезная нагрузка содержимого. Для текстовых qub это UTF-8-кодированное тело qub.
4.2.1 title_hash
title_hash = SHA3-256(NFC(title).utf8_bytes) if title is present
title_hash = [0u8; 32] if title is absent
Где title — это необязательный открытый заголовок, отображаемый в обратном отсчёте просмотрщика до раскрытия (см. §3.2). Нормализация NFC выполняется во время хеширования, чтобы дайджест был стабилен для визуально эквивалентных последовательностей кодовых точек. Сентинел из нулей зарезервирован для случая отсутствия; пустая строка отклоняется на границе канонического CBOR как неканоническое кодирование «отсутствия» (каноническое кодирование полностью опускает поле).
4.3 Сопоставление раунда разблокировки
drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds)
| Параметр | Источник | Пример |
|---|---|---|
unlock_at |
Выбранные пользователем секунды Unix UTC | 1735689600 (2025-01-01 00:00:00 UTC) |
chain_genesis_time |
drand chain info (genesis_time) |
1595431050 |
chain_period_seconds |
drand chain info (period) |
30 |
Операция ceil() выбирает первый раунд drand, чьё время раскрытия ≥ unlock_at. Это гарантирует, что qub не станет расшифровываемым до выбранного времени разблокировки.
Граничный случай: если (unlock_at - chain_genesis_time) точно делится на chain_period_seconds, результат — именно этот раунд — qub разблокируется ровно во время раскрытия этого раунда.
Валидация: unlock_at ДОЛЖЕН быть в будущем во время запечатывания. unlock_at НЕ ДОЛЖЕН быть более чем на 10 лет позже created_at (для ограничения риска долгосрочной зависимости от drand; интерфейс СЛЕДУЕТ предупреждать о датах разблокировки более чем через 2 года).
5. Newtypes сетевого формата
Newtypes сетевого формата обеспечивают защиту во время компиляции от смешивания байт CBOR с JSON, сырым открытым текстом или другими байтовыми кодировками.
| Тип | Содержит | Производится | Потребляется |
|---|---|---|---|
SealedQubCbor |
Канонический CBOR SealedQub | serialize_sealed_qub() |
Загрузка в постоянное хранилище, выборка просмотрщиком |
QubEnvelopeCbor |
Канонический CBOR QubEnvelope | serialize_qub_envelope() |
Вход в tlock-шифрование, выход из tlock-расшифровки |
5.1 Правила конструирования
// Production code — only through CBOR serialisers:
let sealed = SealedQubCbor::from_encoded(cbor_bytes);
// There is deliberately NO From<Vec<u8>> implementation.
// You cannot accidentally wrap arbitrary bytes in a wire format type.
// Accessing raw bytes:
let bytes: &[u8] = sealed.as_bytes();
let bytes: Vec<u8> = sealed.into_bytes();
5.2 Валидация при конструировании
from_encoded() СЛЕДУЕТ проверять, что вход начинается с действительного заголовка карты CBOR. Полная структурная валидация происходит во время парсинга, а не во время конструирования, чтобы избежать двойного парсинга.
6. Реестр типов содержимого
| Значение | Тип | Максимальный размер тела | Примечания |
|---|---|---|---|
0x00 |
Зарезервировано (недействительно) | — | НЕ ДОЛЖНО использоваться |
0x01 |
Простой текст (UTF-8, ограниченный Markdown) | 50 КБ платно / 10 КБ бесплатно | См. §10 для правил рендеринга. Разделение бесплатно / платно обеспечивается сервисом загрузки; жёсткий потолок на уровне протокола — 50 КБ. |
0x02 |
Зарезервировано (на будущее) | — | Выделено для будущего типа содержимого; недействительно в v1. Просмотрщики ДОЛЖНЫ отклонять согласно правилу ниже. |
0x03 |
Пакт (двустороннее соглашение, тело CBOR) | 100 КБ | Тело — канонический CBOR PactTerms (§6.1). Подпись соподписанта согласно §9.7. |
0x04 |
Вердикт (самооценка автора, тело CBOR) | 8 КБ | Тело — канонический CBOR VerdictBody (§6.2). Эмитируется только системным намерением verdict. Связь с родителем — на Arweave-теге Parent-Tx-Id, а не в теле. См. verdict-uplift-plan §3.4. |
Просмотрщики ДОЛЖНЫ отклонять неизвестные типы содержимого с чётко видимой пользователю ошибкой. Просмотрщики НЕ ДОЛЖНЫ пытаться рендерить неизвестные типы как текст.
6.1 Тело пакта (content_type = 0x03)
Тело пакта — это каноническое CBOR-кодирование значения PactTerms:
PactTerms {
pact_version: u8, // 0x01 for structured/v1
title: String, // ≤ 200 bytes, NFC
terms: Vec<PactTerm>, // ≤ 20 rows
party_a: PartyIdentifier, // initiator
party_b: PartyIdentifier, // counter-signer
notes: Option<String>, // ≤ 5,000 bytes, NFC; absent key if none
}
PactTerm { key: String (≤ 100), value: String (≤ 2,000) } // NFC on both sides
PartyIdentifier{ label: String (≤ 100), contact: Option<String (≤ 320)> }
Канонические порядки ключей CBOR для всех трёх карт приведены в §3.2. Общая сериализованная CBOR-форма пакта НЕ ДОЛЖНА превышать 100 КБ (соответствует §6).
Дискриминатор схемы. Первая строка в terms для пакта structured/v1 ДОЛЖНА быть { key: "pact_schema", value: "structured/v1" }. Строки без этого маркера являются «пользовательскими» пактами и не получают структурированной валидации или схемоориентированного рендеринга.
Замороженные слоты подтверждения. Пакты structured/v1 содержат ровно четыре строки подтверждения под этими ключами:
"initiator_standard_terms"
"initiator_capacity_terms"
"counterparty_standard_terms"
"counterparty_capacity_terms"
value для каждого — одна из восьми замороженных английских строк, выбираемых парой (role, kind), где role ∈ { seller, buyer, provider, client } и kind ∈ { standard, capacity }. Сами строки являются нормативными данными протокола — подписи ML-DSA-65 обеих сторон обязуются на точные байты через body_hash. Они НЕ локализуются; подписанное тело нейтрально в отношении языка. Любое изменение формулировки требует новой версии схемы (structured/v2).
Восемь строк, их поиск (acknowledgement_for(role, kind)) и обоснование каждой зафиксированы эталонной реализацией. Соответствующие реализации ДОЛЖНЫ выдавать побайтово идентичные значения подтверждения; тесты SHA3-256 body-hash с эталонными фикстурами, покрывающие все четыре комбинации ролей, обнаружат любой дрейф.
Порядок отображения в просмотрщике. Строки подтверждения содержат такие выражения, как «described above», которые предполагают, что строки описания / объёма рендерятся до подтверждений. Просмотрщики ДОЛЖНЫ рендерить массив terms в порядке CBOR; переупорядочивание нарушает текстовую семантику.
Контакт встречной стороны. Когда contact стороны B — действительный адрес электронной почты, сервис загрузки qub автоматически отправляет приглашение к просмотру / соподписанию на этапе стейджинга и связывает последующее соподписание с верификацией того же адреса (§9.7). Пакты, у которых контакт стороны B отсутствует, могут быть соподписаны, но только через out-of-band канал — сервис отклоняет запросы на соподписание, которые не могут предъявить соответствующий 15-минутный маркер email-верификации.
6.2 Тело вердикта (content_type = 0x04)
Тело вердикта — это каноническое CBOR-кодирование значения VerdictBody:
VerdictBody {
verdict_version: u8, // 0x01 for structured/v1
outcome: u8, // 1=Right · 2=Partial · 3=Wrong · 4=Unfalsifiable
reflection: Option<String>, // ≤ 2,000 bytes NFC; "what changed, what did you learn"
evidence_url: Option<String>, // ≤ 2,048 bytes; HTTPS only; absent key when omitted
}
Канонический порядок ключей CBOR:
"outcome" (8 encoded bytes)
"reflection" (11 encoded bytes) ← only if present
"evidence_url" (13 encoded bytes) ← only if present
"verdict_version" (16 encoded bytes)
Общая сериализованная CBOR-форма вердикта НЕ ДОЛЖНА превышать 8 КБ (соответствует строке реестра выше).
Перечисление outcome. Байт на проводе нейтрален в отношении намерения; четыре корзины Right / Partial / Wrong / Unfalsifiable покрывают пространство исходов для всякого намерения, несущего вердикт. Метки по конкретному намерению («Called it» / «Kept it» / «Shipped» / «Confirmed» для Right и т. д.) — забота рендеринга на стороне просмотрщика, разрешаемая через намерение родительского qub; провод остаётся нейтральным в отношении языка и намерения. Значения вне 1..=4 ДОЛЖНЫ быть отклонены при декодировании.
Связь с родителем. Qub вердикта НЕ несёт ссылку на родителя в своём теле. Идентификатор Arweave-транзакции родительского qub эмитируется как тег хранения Parent-Tx-Id во время загрузки (§7, слой тегов хранения). Это сохраняет тело как самодостаточное подписанное заявление о самооценке; цепочка аудита («о чём прав?») устанавливается через поиск по Arweave-тегу.
Безопасность evidence_url (нормативно). Когда evidence_url присутствует, валидаторы (на стороне компоновки, на проводе, на краю Worker) ДОЛЖНЫ обеспечить:
- Только HTTPS. Строка ДОЛЖНА начинаться с байтовой последовательности
https://. Любая другая схема —http,ftp,javascript,data,fileи т. д. — отклоняется. - Ограничение длины. ≤ 2 048 байт (практический предел URL в браузерах).
- NFC + проверка на враждебные кодпойнты. То же правило, что и для
titleиreflection— кодпойнты bidi-override / zero-width / tag-block / BOM / C0 / C1 отклоняются. Определение соответствует Rustcrate::handle::contains_hostile_text_codepointи TSworkers/api/src/utils/unicode.ts::isHostileCodepoint(держите их синхронизированными). - Без пробельных символов, без управляющих ASCII. Пробельные символы / DEL / байты ниже
0x20где-либо в URL отклоняются — закрывает вектор инъекции\n/\t, который не покрывает правило bidi. - Непустой сегмент хоста. Всё между
https://и первым/,?или#ДОЛЖНО быть непустым.
Без получения на стороне сервера. Worker НЕ ДОЛЖЕН проксировать, получать или предпросматривать URL. Протокол хранит строку; рендеринг происходит на стороне просмотрщика с rel="nofollow noopener noreferrer" target="_blank" и видимым хостом, отображаемым рядом с текстом ссылки.
Reflection. Необязательный написанный автором текст размышления («что изменилось, что вы узнали»). Та же валидация NFC + враждебных кодпойнтов, что и для title. Пустой ввод / ввод только из пробельных символов сводится к отсутствующему во время конструирования.
Версия схемы. v1 поддерживает только verdict_version = 0x01. Будущие ревизии схемы инкрементируют этот байт и выходят вместе с новой версией протокола согласно §12.
7. Протокол запечатывания
Полная последовательность запечатывания. Каждый шаг нормативен.
1. User composes plaintext and metadata in ComposeQub.
2. Validate:
a. body is non-empty.
b. body size ≤ max for content_type and user tier (see §6).
c. unlock_at is in the future.
d. unlock_at ≤ created_at + 10 years.
e. content_type is a known, supported value.
3. Compute body_hash = SHA3-256(body).
4. Set created_at = current Unix seconds UTC.
5. Select drand chain. Load chain_genesis_time and chain_period_seconds, and
compute drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds).
(Computed here, before qub_id, because drand_round is bound into the qub_id
preimage — §4.1, V1.2.)
6. Compute qub_id (see §4.1), folding in drand_round from step 5.
7. Construct QubEnvelope with all fields.
8. Serialise QubEnvelope using canonical CBOR → bytes B.
Assert: serialised output matches canonical profile (§3).
9. Compute C = tlock_encrypt(B, drand_round, drand_chain_public_key).
10. Construct SealedQub with tlock_ciphertext = C, and matching qub_id, version,
unlock_at, drand_chain_id, drand_round.
12. Serialise SealedQub using canonical CBOR → SealedQubCbor.
12a. Generate K = 32 random bytes (CSPRNG) and N = 12 random bytes (CSPRNG).
Compute W = wrap_sealed_qub(SealedQubCbor, qub_id=qub_id, key=K, nonce=N)
per §13. The bytes uploaded to permanent storage are the OuterWrapper CBOR W,
never the bare SealedQubCbor. K leaves the device only as the URL
fragment in step 16.
13. Display seal-time disclosure. User confirms.
14. Validate upload eligibility via the qub upload service (bot-detection, entitlement, rate limits).
15. Submit W (the OuterWrapper bytes) to the qub upload service; the service
signs and uploads to permanent storage. The service is byte-blind to the inner
SealedQubCbor and never receives K.
16. Receive arweave_tx_id from the service. Construct delivery URL as
`<origin>/c/<arweave_tx_id>#<base64url(K)>` (or `<origin>/s/<short_code>#<base64url(K)>`
when a short code is allocated). Browsers do not transmit URL fragments
to servers, so K is never observed by qub.social or any storage gateway.
Слой тэгов хранилища (out-of-band). Сервис загрузки qub прикрепляет намеренно небольшой набор тэгов транзакции хранилища вместе с обёрнутой полезной нагрузкой. Content-Type=application/octet-stream нормативно требуется. Эталонный сервис дополнительно прикрепляет три необязательных тэга, когда создатель решает их раскрыть: Intent (валидированное по allowlist намерение составления — например, quote, reply, commitment), Author (отпечаток публичного ключа §9.3 создателя в виде 64-символьной строчной шестнадцатеричной строки) и Parent-Tx-Id (идентификатор транзакции хранилища родительского qub для цепочек ответов, 43-символьный base64url).
Тэг Author — opt-in для каждого qub: эталонное приложение создателя прикрепляет его только тогда, когда пользователь явно включает публичную атрибуцию во время запечатывания. Когда переключатель выключен — по умолчанию — тэг Author не записывается, и qub остаётся не атрибутированным в цепи: ничто в постоянном хранилище не связывает загрузку с handle создателя, его email или другими qub. Когда переключатель включён, отпечаток Author разрешается в выбранный создателем @handle через цепочку аттестаций §9.5. Связи цепочек ответов и Intent не являются идентифицирующими. Внешняя обёртка (§13) защищает внутреннее тело от корреляции шифротекста — предотвращая возможность для сборщика распознавать и массово расшифровывать загрузки qub-формы после публикации их раунда drand.
Эталонный сервис намеренно НЕ прикрепляет тэги App-Name, App-Version или Type: любой такой фильтр с единственным значением возвращал бы весь корпус qub по GraphQL-запросу, что несовместимо с областью конфиденциальности обёртки, ограниченной только телом.
Соответствующий верификатор НЕ ДОЛЖЕН зависеть от какого-либо тэга хранилища для верификации сторонними лицами по §11; body hash / qub_id / подпись обязуются только на внутреннем CBOR, никогда — на наборе тэгов.
8. Протокол разблокировки
Полная последовательность разблокировки. Каждый шаг нормативен.
1. Viewer opens delivery URL. Extract arweave_tx_id from path AND
K = base64url_decode(fragment) from the URL fragment. If the fragment
is absent or malformed → display "this URL is missing its decryption
key" and stop; the viewer MUST NOT contact the storage gateway
without K, since fetching wrapped bytes the viewer cannot decrypt
serves no purpose and only leaks the access attempt.
2. Check denylist. If tx_id is denylisted → display block message. Stop.
3. Fetch OuterWrapper bytes from permanent storage (with multi-gateway fallback).
3a. Unwrap: parse the bytes as OuterWrapper (§13), verify the wrapper
`version` byte is `0x01`, and compute SealedQubCbor =
unwrap_sealed_qub(OuterWrapper, key=K). Any AEAD authentication
failure (wrong K, tampered ciphertext, swapped qub_id-as-AAD,
swapped nonce) → display "this URL's decryption key does not match
the stored qub" and stop. Authentication failures are
indistinguishable to the viewer per §13.5.
4. Parse SealedQubCbor → SealedQub.
5. Validate: SealedQub.version is known (0x01). Reject unknown versions.
6. If current time < SealedQub.unlock_at → display countdown. Poll or wait.
6a. Round-binding check (V1.2). Recompute expected_round =
ceil((SealedQub.unlock_at - chain_genesis_time) / chain_period_seconds).
Reject unless SealedQub.drand_round == expected_round AND the round baked
into the tlock ciphertext stanza (read via the age/tlock header, no signature
required) == expected_round. The stanza round is the one that actually gates
decryption; without this check a malicious creator could bind the ciphertext
to an already-past round while displaying a future countdown, so anyone
reading the stored bytes could decrypt before unlock_at. Implementations with
no chain identity (test mocks) skip this check.
7. Once current time ≥ SealedQub.unlock_at:
a. Fetch drand round signature for SealedQub.drand_round from drand network.
b. Compute B = tlock_decrypt(SealedQub.tlock_ciphertext, round_signature).
8. Parse B → QubEnvelope.
9. Validate QubEnvelope.version is known.
10. Verify: SHA3-256(QubEnvelope.body) == QubEnvelope.body_hash.
Fail → integrity error.
11. Verify: QubEnvelope.qub_id == SealedQub.qub_id.
Fail → integrity error.
12. Verify: QubEnvelope.unlock_at == SealedQub.unlock_at.
Fail → integrity error.
13. Verify: QubEnvelope.content_type is known and renderable.
Known values: 0x01 (text), 0x03 (pact). Unknown → display error.
14. If QubEnvelope.sig_alg != 0x00 → verify author signature (see §9.4).
15. If cosigner_pubkey or cosigner_signature present → verify cosigner (see §9.7).
16. Render content using appropriate renderer (see §10 for text, §6 for pact).
17. Construct RevealedQub for display.
9. Подпись авторства
9.1 Обоснование
qub хранятся в постоянном хранилище. Подписи авторства должны оставаться неподделываемыми бессрочно, поэтому v1.0 использует постквантовую схему ML-DSA-65 (FIPS 204) вместо классической схемы, безопасность которой может ухудшиться в течение постоянного срока жизни qub.
9.2 Реестр алгоритмов
sig_alg |
Схема | Размер ключа | Размер подписи |
|---|---|---|---|
0x00 |
Нет подписи (неподписанный) | — | — |
0x01 |
ML-DSA-65 (FIPS 204) | 1 952 байта | 3 309 байт |
Просмотрщики ДОЛЖНЫ отклонять неизвестные значения sig_alg.
9.3 Конструирование подписываемого прообраза
sig_input = SHA3-256(
"QUB_AUTHOR_SIG_V1" || // domain separator (17 bytes)
version || // u8 (1 byte)
qub_id || // [u8; 32] (32 bytes)
body_hash || // [u8; 32] (32 bytes)
unlock_at || // i64 big-endian (8 bytes)
0x00 // u8 (1 byte): MUST be 0x00 in v1.0
)
// Total preimage: 91 bytes → 32-byte hash
signature = Sign(author_secret_key, sig_input)
Domain separator: "QUB_AUTHOR_SIG_V1" — это 17 ASCII-байт: [0x51, 0x55, 0x42, 0x5F, 0x41, 0x55, 0x54, 0x48, 0x4F, 0x52, 0x5F, 0x53, 0x49, 0x47, 0x5F, 0x56, 0x31]. Без паддинга.
Завершающий байт: 91-й байт прообраза ДОЛЖЕН быть 0x00. Эталонная реализация выставляет это как константу ORG_ID_PRESENT_INDIVIDUAL = 0x00 в crates/qub-core/src/signing.rs; просмотрщики, реконструирующие sig_input для верификации, ДОЛЖНЫ выводить тот же байт.
Область действия подписи — что покрыто и что не покрыто. sig_input обязуется на четыре поля envelope: version, qub_id, body_hash, unlock_at (плюс фиксированный domain separator и байт org_id_present). Три из этих четырёх являются структурными инвариантами: qub_id сам выводится из version, content_type, created_at, unlock_at, outcome_at, drand_round и body_hash через прообраз §4.1, так что любое изменение этих полей производит другой qub_id и транзитивно инвалидирует подпись. Поэтому непосредственно аутентифицированная поверхность такова:
| Поле | Аутентифицировано подписью | Каким образом |
|---|---|---|
version |
✓ | Прямой вход для sig_input |
qub_id |
✓ | Прямой вход |
body_hash |
✓ | Прямой вход |
unlock_at |
✓ | Прямой вход |
content_type |
✓ | Транзитивно, через прообраз qub_id |
created_at |
✓ | Транзитивно, через прообраз qub_id |
outcome_at |
✓ | Транзитивно, через прообраз qub_id |
drand_round |
✓ | Транзитивно, через прообраз qub_id (V1.2) |
body |
✓ | Транзитивно, через body_hash = SHA3-256(body) |
author_pubkey |
— (неявно) | Ключ, верифицировавший подпись, и есть автор по определению |
sender_label |
✗ | Только отображаемый текст; изменяем без нарушения подписи |
reply_to |
✗ | Указатель ветвления; изменяем без нарушения подписи |
cosigner_pubkey / cosigner_signature |
— | Независимо подписаны над тем же sig_input (см. §9.7) |
drand_chain_id, tlock_ciphertext, visibility |
— | Поля внешнего SealedQub, вне envelope — покрыты собственными структурными инвариантами (согласованность раунда / цепочки), но не подписью автора. (drand_round теперь связан транзитивно через прообраз qub_id — см. выше.) |
Последствия для безопасности неаутентифицированных полей.
- Сторона с доступом на запись к хранимым байтам могла бы подменить
sender_label(«Alice» → «Mallory»), не инвалидируя подпись автора.author_pubkeyвнутри envelope остаётся истинным якорем идентичности — просмотрщики ДОЛЖНЫ выводить отображаемую идентичность изauthor_pubkey(через слой аттестаций §9.5), а не доверятьsender_label. - Поле
reply_toможет быть подобным образом отредактировано после подписания. Посколькуqub_idадресуется по содержимому, атакующий не может направитьreply_toна несуществующую цель, но он может молча перепривязать ответ к другому существующему qub.
Реализации, отображающие sender_label или reply_to конечным пользователям, ДОЛЖНЫ выдвигать аутентифицированную идентичность (отпечаток публичного ключа, аттестацию) как основной сигнал идентичности, а не label.
9.4 Процедура верификации
1. Read sig_alg from QubEnvelope.
2. If sig_alg == 0x00 → unsigned. No verification. Display "unsigned qub."
3. If sig_alg is unknown → reject. Display "unrecognised signature scheme."
4. Extract author_signature and author_pubkey. If either is absent → integrity error.
5. Reconstruct sig_input using fields from QubEnvelope (same formula as §9.3).
6. Verify(author_pubkey, sig_input, author_signature).
7. If verification succeeds → display "signed by [key fingerprint]."
8. If verification fails → display "signature verification failed."
Верификация подписи — самая дорогая операция (особенно ML-DSA-65). Её СЛЕДУЕТ выполнять после того, как все более дешёвые проверки (hash, qub_id, unlock_at) пройдены.
9.5 Аттестации идентичности
Аттестации идентичности — отображение author_pubkey на распознаваемые человеком утверждения об идентичности, такие как qub-handle, email-адрес, social handle или passkey-credential — это прогрессивное улучшение на стороне просмотрщика и не требуются для верификации подписи. Просмотрщики, разрешающие аттестации в отображаемую идентичность, ДОЛЖНЫ применять приоритет:
handle > email > social > fingerprint
Резерв-отпечаток — это шестнадцатеричная строка в нижнем регистре от SHA3-256(author_pubkey); он всегда доступен для любого подписанного qub. Просмотрщики МОГУТ сокращать его для отображения — эталонный просмотрщик отображает qub:, за которым следуют первые и последние четыре байта (qub:<8 hex>…<8 hex>).
Соответствующий верификатор может завершить каждую проверку в §9.4 без обращения к API qub, без какой-либо сети, кроме постоянного хранилища и drand, и без какого-либо серверного поиска. Разрешение аттестаций — отдельный best-effort шаг, выполняемый только после успешной верификации подписи.
9.6 Влияние на размер
| Ed25519 | ML-DSA-65 | |
|---|---|---|
| Подпись | 64 байта | 3 309 байт |
| Публичный ключ | 32 байта | 1 952 байта |
| Всего на qub | 96 байт | 5 261 байт |
| Дельта стоимости хранилища (при ~$5/МБ) | ~$0,0005 | ~$0,026 |
Для текстового qub в 500–2 000 байт ML-DSA-65 примерно утраивает сохранённый размер. Абсолютная стоимость пренебрежимо мала.
9.7 Верификация соподписанта (двусторонние соглашения-пакты)
Для двусторонних соглашений (content_type = 0x03) второй слой подписи доказывает, что обе стороны согласились с одними и теми же условиями.
Поля envelope:
cosigner_pubkey: публичный ключ ML-DSA-65 встречного подписанта (сторона B).cosigner_signature: подпись над тем жеsig_input, что и у автора (§9.3).
Оба поля ДОЛЖНЫ присутствовать вместе или оба отсутствовать. Если присутствует ровно одно, просмотрщики ДОЛЖНЫ сообщить об ошибке целостности.
Процедура верификации:
1. If cosigner_pubkey absent and cosigner_signature absent → no cosigner. Done.
2. If exactly one is present → integrity error.
3. Verify cosigner_pubkey != author_pubkey (prevent self-cosigning).
Fail → display "cosigner pubkey must differ from author."
4. Reconstruct sig_input using the same formula as §9.3.
5. Verify(cosigner_pubkey, sig_input, cosigner_signature).
6. Success → display "co-signed by [cosigner fingerprint]."
7. Failure → display "co-signature verification failed."
Свойства:
- Соподписант подписывает идентичный
sig_input, что и автор — обе стороны обязуются на один и тот жеqub_id,body_hashиunlock_at. - Вывод
qub_id(§4.1) НЕ включает поля соподписанта. Добавление соподписанта к существующему envelope не меняетqub_id. - Пакт может быть подписан только автором (одностороннее обязательство), только соподписантом (необычно) или обоими (полный двусторонний доказ).
Email-связывающий шлюз (операционный). Когда staged-пакт несёт email-контакт стороны B (§6.1), сервис загрузки qub ДОЛЖЕН отклонить запрос на соподписание, если не существует кратковременного маркера email-верификации, соответствующего как staging id, так и хешу нормализованного email этого контакта. Маркер записывается /api/v1/auth/verify, когда токен magic-link несёт staging_id и верифицированный адрес совпадает с SHA-256(normalise_email(party_b.contact)) — где normalise_email(addr) сохраняет регистр локальной части и приводит к нижнему регистру только доменную часть (согласно RFC 5321 §2.3.11), а SHA-256 здесь — это хеш NIST FIPS 180-4 (отличный от SHA3-256, используемого в выводах §4) — и истекает через 900 секунд (15 минут) после выпуска. Это операционный анти-имитационный шлюз, НЕ часть on-chain доказательства qub — стороннему верификатору, воспроизводящему §11, нужны только постоянное хранилище и drand, без какого-либо серверного поиска. Маркер существует только на стороне сервера и никогда не является частью подписанного тела.
Влияние на размер (ML-DSA-65 автор + соподписант):
| Компонент | Размер |
|---|---|
| Подпись автора | 3 309 байт |
| Публичный ключ автора | 1 952 байта |
| Подпись соподписанта | 3 309 байт |
| Публичный ключ соподписанта | 1 952 байта |
| Общие криптографические накладные расходы | 10 522 байта |
| Дельта стоимости хранилища | ~$0,05 |
10. Рендеринг и санитизация Markdown
Этот раздел критичен для безопасности. Просмотрщик рендерит текстовые qub (content_type = 0x01), используя ограниченное подмножество Markdown.
10.1 Разрешённые элементы
- Заголовки: от
#до####(без#####или######) - Выделение: жирный (
**), курсив (*), зачёркнутый (~~) - Списки: упорядоченные (
1.) и неупорядоченные (-,*) - Цитаты блоком (
>) - Код: встроенные фрагменты (```) и огороженные блоки (`````)
- Горизонтальные линейки (
---) - Переносы строк (два завершающих пробела или пустая строка)
- Абзацы
10.2 Запрещённые элементы
| Элемент | Обработка |
|---|---|
Сырой HTML (<div>, <script> и т. д.) |
Полностью вырезается. Никакой HTML не пропускается. |
Изображения () |
Вырезаются. Синтаксис изображения удаляется из вывода. |
Ссылки ([text](url)) |
URL рендерится как видимый простой текст. Не делается автоссылкой. Не кликабелен без явного действия пользователя. |
| Опасные схемы URL | javascript:, data:, vbscript:, file: — вырезаются. |
| Iframes, embeds, objects | Вырезаются. |
| HTML-сущности | Декодируются в отображаемые символы только если это безопасно. |
10.3 Реализация
Реализации ДОЛЖНЫ использовать строгий парсер на основе allowlist, а не blocklist. Рекомендуемый подход:
- Парсить Markdown с помощью
pulldown-cmark(или эквивалента). - Обойти AST и отбросить любой узел, не входящий в allowlist (§10.1).
- Для узлов ссылок: выдавать URL как видимый текст, а не как кликабельный элемент
<a>. - Преобразовать отфильтрованный AST в типизированное промежуточное представление (например, enum
MarkdownNodeтолько с безопасными вариантами). Сырой HTML структурно непредставим в этом IR. - Рендерить из типизированного IR в целевой слой представления (например, реактивные view-компоненты, узлы DOM). Никакой конкатенации HTML-строк или
innerHTMLни на каком этапе.
Подходы на основе blocklist хрупки, так как новые расширения Markdown или особенности парсера могут вводить нефильтруемые элементы. Подход с типизированным AST делает XSS структурно невозможным — нет варианта, который мог бы нести произвольный HTML.
10.4 Ограничения размера и структуры
- Максимальная глубина отрендеренного заголовка:
####(H4).#####и глубже рендерятся как жирный текст. - Нет ограничения на количество абзацев (ограничения размера тела в §6 являются ограничением).
- Огороженные блоки кода: без подсветки синтаксиса в MVP. Рендерятся как моноширинный преформатированный текст.
11. Сторонняя верификация
Любая третья сторона может верифицировать публичный qub без сотрудничества qub. Процедура верификации:
1. Obtain arweave_tx_id (from delivery URL or direct knowledge).
2. Fetch SealedQubCbor from any storage gateway.
3. Confirm storage block inclusion (block height, block timestamp).
4. Parse SealedQubCbor → SealedQub.
5. Fetch drand round signature for SealedQub.drand_round.
6. tlock_decrypt(tlock_ciphertext, round_signature) → QubEnvelope CBOR bytes.
7. Parse → QubEnvelope.
8. Verify SHA3-256(body) == body_hash.
9. Verify QubEnvelope.qub_id == SealedQub.qub_id.
10. Verify QubEnvelope.unlock_at == SealedQub.unlock_at.
11. If sig_alg != 0x00: verify author_signature (see §9.4).
12. All checks pass → qub is verified.
Что доказывает верификация:
| Доказательство | Что устанавливает |
|---|---|
| Обязательство | Шифротекст существовал на момент временной метки блока хранилища. |
| Целостность | Тело в открытом виде соответствует зафиксированному хешу и не было изменено. |
| Время | Содержимое было нечитаемо до раунда drand, который соответствует выбранному времени разблокировки (с учётом допущений безопасности tlock и drand). |
Что верификация НЕ доказывает:
| Не-доказательство | Почему |
|---|---|
| Авторство | sender_label декоративен. Без sig_alg ≥ 0x01 любой мог запечатать это содержимое. |
| Намерение | qub доказывает содержимое и время, а не то, что создатель субъективно имел в виду. |
| Время до события | Включение в блок хранилища может отставать от фактической загрузки на минуты. Временная метка обязательства — это время блока, а не момент, когда пользователь нажал «запечатать». |
12. Версионирование
12.1 Версия протокола
Поле version (u8) как в SealedQub, так и в QubEnvelope идентифицирует мажорную версию протокола.
- Просмотрщики ДОЛЖНЫ отклонять неизвестные мажорные версии с чёткой ошибкой.
- Известные мажорные версии МОГУТ допускать неизвестные необязательные поля, если правила прямой совместимости это позволяют (необязательные поля, отсутствующие в каноническом порядке ключей, игнорируются).
- Типы содержимого (
content_type) и схемы подписи (sig_alg) привязаны к версии: новые значения могут вводиться только вместе с новой версией протокола или явным обновлением реестра.
12.2 История версий
| Версия | Значение | Описание |
|---|---|---|
| v1 | 0x01 |
Публичные текстовые qub (content_type 0x01), двусторонние соглашения-пакты (0x03, схема structured/v1, ML-DSA-65 автор + соподписант), tlock, SHA3-256 |
12.3 Прямая совместимость
Просмотрщик v1, встречающий QubEnvelope с неизвестными необязательными ключами карты CBOR (ключами, не входящими в канонический порядок §3.2), СЛЕДУЕТ игнорировать эти ключи и продолжать верификацию, используя известные поля. Это допускает будущие небольшие добавления (например, новые метаданные) без необходимости поднимать мажорную версию.
Просмотрщик v1, встречающий sig_alg = 0x01 (ML-DSA-65), но без поддержки верификации ML-DSA-65, СЛЕДУЕТ отображать содержимое qub с уведомлением «подпись присутствует, но не верифицируема», а не отклонять qub целиком. Эталонная реализация сегодня отклоняет любое значение sig_alg, кроме 0x00 и 0x01, потому что реестр v1 не содержит другого действительного алгоритма — строгое отклонение и soft-fail наблюдательно идентичны, пока не зарегистрирован третий алгоритм. Описанное выше поведение soft-fail становится несущим, как только §9.2 допустит новую запись, и эталонный просмотрщик будет обновлён на soft-fail в этот момент.
12.4 Версия внешней обёртки
OuterWrapper, описанный в §13, несёт собственный байт version, независимый от SealedQub.version и QubEnvelope.version. Эти два пространства версий развиваются раздельно: будущая постквантово-безопасная симметричная замена увеличивает байт обёртки, не затрагивая внутреннюю версию протокола, а будущее добавление на уровне протокола (например, новое поле envelope) увеличивает внутреннюю версию, не затрагивая байт обёртки.
OUTER_WRAPPER_VERSION_* |
Значение | Алгоритм | Статус |
|---|---|---|---|
OUTER_WRAPPER_VERSION_1 |
0x01 |
AES-256-GCM с 12-байтовым nonce, 16-байтовым тэгом аутентификации, AAD привязан к qub_id |
По умолчанию v1 |
| — | 0x02–0xFF |
Зарезервировано | Будущее |
Просмотрщики ДОЛЖНЫ отклонять неизвестные версии обёртки с чёткой ошибкой. Протокол намеренно сохраняет пространство версий обёртки узким, пока не появится конкретный драйвер миграции (например, рекомендация NIST в пользу другого AEAD); слот 0x02 будет выделен в той же ревизии, которая вводит алгоритм.
13. Внешняя обёртка шифрования
13.1 Обоснование
Слои протокола (QubEnvelope → tlock → SealedQub) делают запечатанный qub запечатанным по времени: тело нечитаемо до unlock_at и до публикации подписи раунда drand. Однако после разблокировки подпись раунда становится публичной, а каноническая CBOR-форма SealedQub распознаваема, поэтому сборщик, индексировавший транзакции постоянного хранилища, мог бы массово расшифровать весь корпус qub.
Внешняя обёртка шифрования закрывает этот канал, вставляя дополнительный симметричный AEAD-слой между каноническим SealedQubCbor и байтами, загружаемыми в постоянное хранилище. 256-битный ключ K живёт только во фрагменте URL ссылки доставки и на устройствах пользователей; браузеры не передают фрагменты URL серверам, поэтому qub.social, каждый шлюз хранилища и каждый CDN перед любым из них наблюдательно слепы к K. Поэтому каждый qub в постоянном хранилище — это непрозрачный шифротекст, открытый текст которого невосстановим без URL, который создатель решил поделиться.
Чистый эффект:
- Иммунитет к перечислению по умолчанию. Обёрнутые байты в постоянном хранилище байт-неотличимы от произвольного шифротекста. Стратегия сборщика «GraphQL-запрос загрузок qub-формы, массовая расшифровка публичными подписями drand» не завершается открытым текстом.
- Поза приватности с криптошреддингом. qub.social буквально не может расшифровать собственный корпус. Повестки достигают шифротекста, а не открытого текста.
- Двухуровневая лестница конфиденциальности. По умолчанию = доступ, контролируемый ссылкой (этот раздел). Приватные qub с шифрованием на получателя (зарезервированная функция фазы 2, пока не специфицирована) накладываются сверху в качестве второго уровня.
13.2 Многослойность
plaintext body ← QubEnvelope.body (§2.2)
↓ canonical CBOR (§3)
envelope CBOR
↓ tlock encrypt to drand round (§7 step 10)
tlock_ciphertext (inside SealedQub) (§2.3)
↓ canonical CBOR (§3)
SealedQubCbor bytes ← inner wire artifact
↓ AES-256-GCM(K, nonce, AAD=qub_id) (§7 step 12a, this section)
OuterWrapper CBOR bytes ← uploaded to permanent storage (§7 step 15)
Запечатывание и разблокировка на уровне протокола (§7, §8) остаются неизменными ниже границы обёртки; обёртка прикрепляется в точке вызова seal() и снимается в точке вызова unlock().
13.3 Структура данных OuterWrapper
struct OuterWrapper {
version: u8, // 0x01, see §12.4
qub_id: [u8; 32], // copied from inner SealedQub; AEAD AAD
nonce: [u8; 12], // 96-bit AEAD nonce
ciphertext: Vec<u8>, // AES-256-GCM(K, nonce, SealedQubCbor, AAD=qub_id) || 16-byte tag
}
Инварианты полей.
versionДОЛЖЕН быть равен0x01для байт обёртки v1.0.qub_idДОЛЖЕН быть равен полюqub_idSealedQub, восстановленного после распаковки. Шаг распаковки не обеспечивает это напрямую (привязка AEAD AAD делает побайтовую подделку невозможной), но слой разблокировки проверяет связь транзитивно: если создатель оборачиваетSealedQubCbor, внутреннийqub_idкоторого не совпадает сqub_idобёртки, §8 шаг 11 не проходит.nonceДОЛЖЕН быть 96 бит (12 байт), сгенерированных свежим CSPRNG для каждой операции wrap. Повторное использование nonce под одним ключом допускает атаки повторного использования nonce AEAD, восстанавливающие открытый текст; производители ДОЛЖНЫ обращаться с парами (key,nonce) как с одноразовыми.ciphertext— это выход AES-256-GCM: байты шифротекста, конкатенированные с 16-байтовым тэгом аутентификации. Ровноciphertext.len() == SealedQubCbor.len() + 16.
Кодирование CBOR. Канонический CBOR согласно §3, с тем же правилом упорядочивания ключей (сортировка по закодированной длине в байтах по возрастанию, затем лексикографически). Четыре ключа:
| Ключ | Закодированных байт | Порядок |
|---|---|---|
nonce |
6 | 1 |
qub_id |
7 | 2 |
version |
8 | 3 |
ciphertext |
11 | 4 |
Первый байт CBOR OuterWrapper, следовательно, является заголовком карты определённой длины для карты из 4 записей (0xA4).
13.4 Привязка AAD к qub_id
Обёртка привязывает qub_id как дополнительные аутентифицированные данные AEAD. Это несущая структурная защита от трёх классов атак:
| Атака | Защита |
|---|---|
Переместить шифротекст под другое поле qub_id в обёртке |
Несоответствие AAD → аутентификация AEAD не проходит |
| Смешать фрагмент URL qub A с байтами хранилища qub B | Несоответствие AAD → аутентификация AEAD не проходит |
Изменить поле qub_id обёртки после загрузки |
Несоответствие AAD → аутентификация AEAD не проходит |
Перенос qub_id в открытом тексте обёртки не ослабляет иммунитет к перечислению существенно — qub_id сам по себе является хешем SHA3-256 от прообраза §4.1 без восстановимого прообраза из дайджеста, и перечислитель, уже собравший байты обёртки, не узнаёт из видимого qub_id ничего, что не мог бы вывести из самого существования загрузки.
13.5 Алгоритмы Wrap и Unwrap
wrap_sealed_qub(SealedQubCbor S, qub_id Q, key K, nonce N):
require K.len() == 32 and N.len() == 12 and Q.len() == 32
C := AES_256_GCM_encrypt(key=K, nonce=N, msg=S, aad=Q)
// C includes the 16-byte authentication tag at the end
return canonical_cbor_encode(OuterWrapper{
version: 0x01,
qub_id: Q,
nonce: N,
ciphertext: C,
})
unwrap_sealed_qub(OuterWrapper bytes W, key K):
require K.len() == 32
O := canonical_cbor_decode(W) as OuterWrapper
require O.version == 0x01 // §12.4
P := AES_256_GCM_decrypt(
key=K, nonce=O.nonce, ciphertext=O.ciphertext, aad=O.qub_id
)
// any AEAD failure → DECRYPT_FAILED, indistinguishable to caller
return P // P is the inner SealedQubCbor
Схлопывание режимов сбоя. Неверный K, неверный nonce, несоответствие AAD и подделанный шифротекст — все производят одну и ту же ошибку DECRYPT_FAILED. Это намеренное свойство AEAD: различение режима сбоя создало бы боковой канал, который удалённый атакующий мог бы прозондировать, отправляя сформированные неправильно обёртки и измеряя время ответа. Эталонные реализации ДОЛЖНЫ схлопывать все сбои AEAD в единую форму ошибки.
13.6 Ключевой материал и распространение
Ключ обёртки K — это 256-битное равномерное случайное значение, генерируемое для каждого qub с помощью CSPRNG. Эталонные реализации получают его из:
- WASM-создатель:
getrandom(WebCrypto под бэкендомwasm_js). - Серверный маршрут запечатывания Worker:
crypto.getRandomValues.
Распространение: K ДОЛЖЕН быть закодирован как URL-безопасный base64 (RFC 4648 §5, без паддинга) и добавлен к ссылке доставки как компонент фрагмента:
delivery_url = <origin>/c/<arweave_tx_id>#<base64url(K)>
Фрагмент никогда не передаётся ни на какой сервер соответствующим браузером. Каналы восстановления (серверный индекс истории, опт-ин email auto-send), которые персистируют полную ссылку доставки — включая фрагмент — за пределы устройства пользователя, являются явным компромиссом против стандартной криптошреддинговой позы и ДОЛЖНЫ быть привязаны к явному согласию пользователя.
Потеря фрагмента. Если пользователь теряет фрагмент URL и не имеет канала восстановления, qub нечитаем. Это несущий компромисс дизайна и ДОЛЖЕН раскрываться пользователю во время запечатывания. MVP усиливает раскрытие во время запечатывания явным текстом «сохраните этот URL» и каналом восстановления через верифицированную почту для пользователей, которые соглашаются.
13.7 Вне области применения этого раздела
- Подпись авторства (§9) неизменна: подписи вычисляются внутри внутреннего
QubEnvelopeи восстанавливаются после Unwrap → tlock-расшифровка → парсинг CBOR. - Приватные qub с шифрованием на получателя (зарезервированная функция фазы 2, пока не специфицирована) компонуются поверх этой обёртки как второй уровень конфиденциальности; оба уровня могут быть активны одновременно.
- Пакты (§6, content_type
0x03) оборачиваются точно так же, как текстовые qub; обёртка байт-слепа к внутреннему типу содержимого.
13.8 Публичные qub (опускание обёртки)
Внешняя обёртка необязательна на уровне доставки. Создатель может запечатать qub как публичный, и в этом случае канонический SealedQubCbor записывается в постоянное хранилище напрямую, без слоя OuterWrapper и без ключа K:
SealedQubCbor bytes ──(public)──▶ uploaded to permanent storage as-is
SealedQubCbor bytes ──(private)─▶ AES-256-GCM(K, …) ▶ OuterWrapper ▶ uploaded
Публичный qub запечатан по времени, но не ограничен ссылкой: он остаётся нечитаемым до публикации своего раунда drand (слой tlock неизменен), но после разблокировки любой, у кого есть arweave_tx_id, может его расшифровать — фрагмент URL не требуется, поскольку ключа K нет. Это намеренный компромисс для поверхностей, которые должен обслуживать сервер: email-уведомления о раскрытии, сторонние встраивания и более богатое SEO после раскрытия — всем им нужна ссылка, работающая без секрета, которого у сервера никогда нет (§13.6).
Последствия, которые производитель ДОЛЖЕН учитывать:
- Нет иммунитета к перечислению. Публичные qub по построению отказываются от свойства иммунитета к перечислению из §13.1. Эталонный сервис загрузки штампует на них (и только на них) тэг постоянного хранилища
Visibility: public, чтобы они были намеренно обнаружимы; приватные qub не несут такого тэга и сохраняют свою байт-неотличимость. - Открытый заголовок раскрыт во время запечатывания. Поле
titleиз §3.2 — это открытый текст внутриSealedQubCbor. Под обёрткой оно скрыто до тех пор, пока просмотрщик не предоставитK; без обёртки оно доступно для чтения всем в постоянном хранилище с момента загрузки, до разблокировки. Соответствующие приложения создателя ДОЛЖНЫ раскрывать это во время запечатывания. - Обнаружение структурно. Соответствующий просмотрщик/встраивание различает две формы парсингом: байты, парсящиеся как
OuterWrapper, идут по пути распаковки сK; байты, парсящиеся как голыйSealedQubCbor, принимаются напрямую. Никакой флаг на проводе не требуется, иqub_idне связывает видимость — одно и то же содержимое байт-идентично на уровнеSealedQubнезависимо от того, запечатано ли оно публично или приватно.
Приватный (обёрнутый) остаётся по умолчанию; публичный — это явный выбор создателя для каждого qub.
14. Тестовые векторы
14.1 Вывод qub_id
Input:
version = 0x01
content_type = 0x01
created_at = 1735689600 (2025-01-01 00:00:00 UTC)
unlock_at = 1736294400 (2025-01-08 00:00:00 UTC)
outcome_at = absent
drand_round = 4695445 (= (1736294400 - 1595431050) / 30, drand mainnet params §14.2)
body = "Hello, future." (UTF-8, 14 bytes)
title = absent
Intermediate:
body_hash = SHA3-256("Hello, future.")
= 76ab8b3f843c6ed4f2d0fd75b9f457b4
ad49dd4450f9c22723ae430e3af3211d
title_hash = [0u8; 32] (title absent — §4.2.1 sentinel)
Domain separator (10 bytes):
[0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x32, 0x00]
Preimage (108 bytes — V1.2):
domain_separator || // 10 bytes
0x01 || // version
0x01 || // content_type
0x0000000067748580 || // created_at as i64 big-endian (1735689600)
0x00000000677DC000 || // unlock_at as i64 big-endian (1736294400)
0x0000000000000000 || // outcome_at_or_zero (outcome_at absent)
0x000000000047A595 || // drand_round as u64 big-endian (4695445)
body_hash || // 32 bytes
title_hash // 32 bytes (all-zeros sentinel; title absent)
Expected output:
qub_id = SHA3-256(preimage)
= 3a9fcb31b750d985c262fada6d4f777f
d6a28be831d941d85c131f5a4bbaf8a4
Реализации ДОЛЖНЫ выдавать идентичные значения body_hash и qub_id для этого входа. Этот тестовый вектор СЛЕДУЕТ сделать первым написанным юнит-тестом. Канонические значения выше были вычислены эталонной реализацией и ДОЛЖНЫ совпадать побитово. Исторические схемы прообраза (до запуска — ни один действующий qub от них не зависел): 92-байтовый qub_id V1.0 был 3d9fc2390eab043d38a1669ed3b71be76f9eefe872b9569ab1aaa027b88392b0; 100-байтовый qub_id V1.1 (после сворачивания outcome_at_or_zero) был b0d032898ad629795150fdcb3f84e518f59ed05b7a2a82bc24ebdb87f52144ed. V1.2 сворачивает drand_round и повышает domain separator до QUB_ID_V2.
14.2 Сопоставление раунда разблокировки
Input:
unlock_at = 1735689600
chain_genesis_time = 1595431050
chain_period_seconds = 30
Calculation:
(1735689600 - 1595431050) / 30 = 4675285.0
ceil(4675285.0) = 4675285
drand_round = 4675285
14.3 Round-trip канонического CBOR
Реализации ДОЛЖНЫ проверить, что serialize(parse(serialize(qub))) == serialize(qub) для всех допустимых входов. Это property-тест, а не одиночный вектор.
14.4 PactTerms CBOR (content_type 0x03)
Input:
pact_version = 1
title = "Scooter deposit"
terms = [
{ key: "Item", value: "Honda Metropolitan scooter" },
{ key: "Price", value: "$100" },
{ key: "Deposit", value: "$10" }
]
party_a = { label: "Alice" }
party_b = { label: "Bob", contact: "bob@example.com" }
notes = absent
Canonical CBOR key order (PactTerms):
"notes"(6) < "terms"(6) < "title"(6) < "party_a"(8) < "party_b"(8) < "pact_version"(13)
Canonical CBOR key order (PactTerm):
"key"(4) < "value"(6)
Canonical CBOR key order (PartyIdentifier):
"label"(6) < "contact"(8)
Канонические байты CBOR и body_hash SHA3-256 вычисляются эталонной реализацией. Реализации ДОЛЖНЫ выдавать побайтово идентичный CBOR для этого входа.
Реализации ДОЛЖНЫ также проверить, что serialize(parse(serialize(pact))) == serialize(pact) для всех допустимых входов PactTerms (property-тест).
14.5 Кросс-языковые векторы внешней обёртки
Внешняя обёртка (§13) имеет отдельную каноническую фикстуру в crates/qub-core/tests/vectors/wrapper_v1.json. Каждый случай фиксирует кортеж (key, nonce, qub_id, sealed_cbor) как непрозрачные шестнадцатеричные входы и утверждает конкретный выход expected_wrapper_hex. Обе эталонные реализации потребляют один и тот же JSON-файл:
- Rust:
crates/qub-core/tests/wrapper_vectors.rs(cargo test -p qub-core --test wrapper_vectors). - TypeScript:
workers/api/src/crypto/__tests__/wrapper.test.ts(npm test).
Фикстура в настоящее время закрепляет три случая:
| Случай | Покрытие |
|---|---|
basic-text-public |
Наименьшая реалистичная форма SealedQub; без необязательных полей. Устанавливает каноническую форму обёртки для qub, типичного для v1.0. |
with-recipient-pubkey |
SealedQub с установленным recipient_pubkey (путь Phase 2). Другой набор внутренних ключей CBOR, другой qub_id. |
longer-body |
Тело ~4 KiB — упражняет многобайтовые префиксы длины CBOR как во внутреннем envelope, так и во внешнем шифротексте. |
Реализации ДОЛЖНЫ выдавать побайтово идентичный expected_wrapper_hex для записанных входов. Регенерация фикстуры требует QUB_REGEN_VECTORS=1 cargo test -p qub-core --test wrapper_vectors и зарезервирована для намеренных изменений формата.
15. Управление криптографическим профилем (будущее)
Этот раздел является информативным для v1 и становится нормативным в тот момент, когда второй алгоритм впервые войдёт в любую из криптографических примитивов qub.
15.1 Текущая позиция
Протокол v1 привязывает ровно один алгоритм на примитив:
- Подпись: ML-DSA-65 (
sig_alg = 0x01; публичный ключ 1952 байта, подпись 3309 байт) и без подписи (sig_alg = 0x00). Реестр §9.2 не определяет других значений; верификатор v1 ДОЛЖЕН отклонять каждыйsig_algвне{0x00, 0x01}. Будущая запись Ed25519 предвидится (§15.3), но не выделяется в v1. - Timelock: только drand quicknet — хеш chain, публичный ключ, время генезиса и период являются фиксированными сетевыми параметрами, переносимыми эталонной реализацией
DrandTimelockProvider::quicknet()(crates/qub-core/src/tlock.rs) иconfig/drand-endpoints.json. - Внешняя обёртка: только AES-256-GCM v1 (§13).
Верификаторы в настоящее время жёстко кодируют длины ключа и подписи на примитив. Формат передачи не предоставляет никакой поверхности гибкости.
15.2 Предполагаемая форма
Когда второй алгоритм войдёт в протокол, верификатор будет настроен на именованный CryptoProfile (например, ExqubV1), перечисляющий точный набор разрешённых значений на примитив — sig_algs, chains drand, версии обёртки, типы контента. Профиль фиксируется во время верификации, никогда не согласуется по основному каналу. Любое значение вне активного профиля отклоняется.
Это гарантирует, что добавление ML-DSA-87 или активация Ed25519 не может задним числом ослабить существующие конфигурации верификаторов: верификатор v1 остаётся верификатором v1 даже после публикации профиля v2.
15.3 Условия активации
Повышайте §15 до нормативного статуса, когда предлагается любое из следующего:
- Второй байт
sig_alg(активация Ed25519, ML-DSA-87 или любая новая запись в реестре §9). - Вторая chain drand в продакшен-использовании.
- Вторая версия внешней обёртки.
До тех пор §15 является заглушкой, которая фиксирует форму миграции, чтобы будущие PR приземлялись против известной цели, а не пере-обсуждали поверхность согласования с нуля.