qub Protocol Specification
qub Protocol Specification
| Field | Value |
|---|---|
| Version | 1.0 (protocol version 0x01) |
| Date | 2026-04-16 |
| Status | Draft — Companion to PDD v1.0 |
| Reviewed through | 2026-04-18 (F4-e embed locale infrastructure, F4-g publisher contract — no protocol change; both live in the viewer presentation layer, not the wire format) |
This document is the normative protocol specification for the qub timed commitment system. It defines data structures, serialisation rules, derivation formulas, and verification procedures required for interoperable implementations.
The PDD (§7) provides a summary; this document provides the complete specification.
Scope: the protocol layer is intentionally language-neutral — the qub body is opaque plaintext / markdown / voice / pact bytes, and locale-aware rendering is the viewer's responsibility (qub.social web app, <qub-embed> iframe, MCP clients, etc.). Embed presentation, loader URL pinning, and BCP 47 locale resolution are specified in docs/EMBED.md, not here.
1. Notation and Conventions
| Notation | Meaning |
|---|---|
u8, u64, i64 |
Unsigned/signed integers of specified bit width |
[u8; N] |
Fixed-length byte array of N bytes |
Vec<u8> |
Variable-length byte array |
Option<T> |
Value of type T, or absent |
String |
UTF-8 text string, NFC normalised |
| ` | |
SHA3-256(x) |
NIST SHA3-256 hash of byte string x (FIPS 202) |
ceil(x) |
Ceiling function: smallest integer ≥ x |
| CBOR | Concise Binary Object Representation (RFC 8949) |
| big-endian | Most significant byte first |
All integers in preimage constructions are encoded as big-endian fixed-width byte arrays (i64 → 8 bytes, u8 → 1 byte) unless otherwise specified.
All timestamps are Unix seconds in UTC.
2. Data Structures
2.1 ComposeQub (Creator In-Memory State)
Not serialised to CBOR. Not stored on Arweave. Local to the creator app.
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 message body
sender_label: Option<String>, // Decorative display name; not authenticated
status: DraftStatus, // Composing | Sealed | Uploaded | Failed
}
2.2 QubEnvelope (Decrypted Payload)
Serialised using canonical CBOR (§3). Encrypted inside the SealedQub. This is the structure that proves content integrity after decryption.
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
sender_label: Option<String>, // Decorative; not authenticated in MVP
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>>, // Phase 2+
author_pubkey: Option<Vec<u8>>, // Phase 2+
cosigner_pubkey: Option<Vec<u8>>, // Phase 2+ (pact bilateral agreements)
cosigner_signature: Option<Vec<u8>>, // Phase 2+ (pact bilateral agreements)
}
Baseline (unsigned text qub): version = 0x01, content_type = 0x01, sig_alg = 0x00, all Option fields absent.
Other v1 configurations: content_type = 0x03 (pact body, see §6.1); sig_alg = 0x01 (ML-DSA-65) with author_signature and author_pubkey present (see §9.3); cosigner_pubkey and cosigner_signature present together for cosigned pacts (see §9.7).
2.3 SealedQub (Canonical Wire Format)
Serialised using canonical CBOR (§3). Uploaded to Arweave. This is the on-chain artifact.
SealedQub {
version: u8, // Protocol major version (0x01 for v1)
qub_id: [u8; 32], // Same as QubEnvelope.qub_id
visibility: u8, // 0x01 = public, 0x00 = private (Phase 2+)
unlock_at: i64, // Unix seconds UTC
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]>,// Phase 2+ (private qubs only)
}
2.4 RevealedQub (Viewer Application State)
Not serialised to CBOR. Local to the viewer app. Constructed after successful decryption and verification.
RevealedQub {
qub_id: [u8; 32],
arweave_tx_id: String,
visibility: u8,
created_at: i64,
unlock_at: i64,
drand_chain_id: String,
drand_round: u64,
sender_label: Option<String>,
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>,
}
3. Canonical CBOR Profile
All SealedQub and QubEnvelope serialisation MUST conform to this profile. Two implementations given the same logical structure MUST produce identical bytes.
3.1 Encoding Rules
| Rule | Specification |
|---|---|
| Standard | RFC 8949 §4.2.1 (Core Deterministic Encoding Requirements) |
| Map key ordering | Sorted by encoded byte length first (shorter before longer), then lexicographically (byte-by-byte for same-length encodings) |
| Integer encoding | Shortest form: 0–23 in initial byte; 24–255 in 2 bytes; 256–65535 in 3 bytes; etc. |
| Length encoding | Definite lengths only. No indefinite-length arrays, maps, byte strings, or text strings (additional info = 31 is forbidden). |
| Tags | No CBOR tags (major type 6 is forbidden). |
| Floating-point | No floats (major types 7 values 0xF9–0xFB are forbidden). |
| Text strings | UTF-8 encoded, NFC normalised (Unicode Normalization Form C). |
| Byte strings | Raw bytes. No base64 encoding at the CBOR layer. |
| Duplicate keys | Reject with error. Parsers MUST NOT silently accept duplicate map keys. |
| Simple values | Only true (0xF5), false (0xF4), and null (0xF6) are permitted. |
| Optional fields | Absent optional fields are omitted from the CBOR map entirely (not encoded as null). Present optional fields are included in sorted key order. |
3.2 Verified Canonical Key Orders
These key orders are normative. Implementations MUST emit keys in exactly this order. Debug assertions SHOULD verify ordering in non-release builds.
QubEnvelope (version 0x01, unsigned, all optional fields absent):
"body" (5 encoded bytes)
"qub_id" (7 encoded bytes)
"sig_alg" (8 encoded bytes)
"version" (8 encoded bytes)
"body_hash" (10 encoded bytes)
"unlock_at" (10 encoded bytes)
"created_at" (11 encoded bytes)
"content_type" (13 encoded bytes)
"sender_label" (13 encoded bytes) ← only if present
"author_pubkey" (14 encoded bytes) ← Phase 2+, only if present
"cosigner_pubkey" (16 encoded bytes) ← Phase 2+, only if present (pact)
"author_signature" (17 encoded bytes) ← Phase 2+, only if present
"cosigner_signature" (19 encoded bytes) ← Phase 2+, only if present (pact)
QubEnvelope key order derivation: each key is a CBOR text string. Encoded length = 1 byte header + string length (for strings under 24 bytes). Sort by total encoded length first, then lexicographically for same-length keys.
SealedQub (version 0x01, public, no recipient):
"qub_id" (7 encoded bytes)
"version" (8 encoded bytes)
"unlock_at" (10 encoded bytes)
"visibility" (11 encoded bytes)
"drand_round" (12 encoded bytes)
"drand_chain_id" (15 encoded bytes)
"recipient_pubkey" (17 encoded bytes) ← only if present (Phase 2+)
"tlock_ciphertext" (17 encoded bytes)
PactTerms (pact body, 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 (row of the terms array):
"key" (4 encoded bytes)
"value" (6 encoded bytes)
PartyIdentifier (party_a / party_b map):
"label" (6 encoded bytes)
"contact" (8 encoded bytes) ← only if present
3.3 Byte Encoding Reference
| Type | CBOR encoding | Example |
|---|---|---|
| SHA3-256 hash (32 bytes) | 0x58 0x20 + 32 bytes |
body_hash, qub_id |
| Timestamps (i64) | Major type 0 (positive) or 1 (negative), shortest encoding | Unix seconds |
| Version (u8, value 1) | 0x01 (single byte) |
|
| Content type (u8, value 1) | 0x01 (single byte) |
|
| sig_alg (u8, value 0) | 0x00 (single byte) |
|
| ML-DSA-65 signature (3,309 bytes) | 0x59 0x0C 0xED + 3,309 bytes |
Phase 2+ |
| ML-DSA-65 public key (1,952 bytes) | 0x59 0x07 0xA0 + 1,952 bytes |
Phase 2+ |
4. Normative Derivations
4.1 qub_id
The qub_id uniquely identifies a qub and binds the QubEnvelope to the SealedQub. It is derived deterministically from envelope content.
qub_id = SHA3-256(
"QUB_ID_V1" || // domain separator: ASCII bytes [0x51 0x55 0x42 0x5F 0x49 0x44 0x5F 0x56 0x31] (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)
body_hash // [u8; 32] (32 bytes)
)
// Total preimage: 60 bytes → 32-byte output
Domain separator encoding: The string "QUB_ID_V1" is 9 ASCII bytes. A single 0x00 padding byte is appended to reach 10 bytes for alignment. Implementations MUST use exactly these 10 bytes: [0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x31, 0x00].
Properties:
- Changing any field in the QubEnvelope (body, timestamps, content type, version) produces a different qub_id.
- The qub_id is computed before encryption. Both QubEnvelope and SealedQub carry the same qub_id. The viewer verifies they match after decryption.
- qub_id does not depend on
sender_label,author_signature, orauthor_pubkey. This means the same content sealed at the same time produces the same qub_id regardless of who signs it.
4.2 body_hash
body_hash = SHA3-256(body)
Where body is the raw Vec<u8> content payload. For text qubs, this is the UTF-8 encoded message body.
4.3 Unlock-Round Mapping
drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds)
| Parameter | Source | Example |
|---|---|---|
unlock_at |
User-chosen Unix seconds 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 |
The ceil() operation selects the first drand round whose reveal time is ≥ unlock_at. This ensures the qub does not become decryptable before the chosen unlock time.
Edge case: if (unlock_at - chain_genesis_time) is exactly divisible by chain_period_seconds, the result is that exact round — the qub unlocks precisely at that round’s reveal time.
Validation: unlock_at MUST be in the future at seal time. unlock_at MUST NOT be more than 10 years from created_at (to limit long-horizon drand dependency risk; the UI SHOULD warn for unlock dates beyond 2 years).
5. Wire Format Newtypes
Wire format newtypes provide compile-time safety against confusing CBOR bytes with JSON, raw plaintext, or other byte encodings.
| Type | Contains | Produced By | Consumed By |
|---|---|---|---|
SealedQubCbor |
Canonical CBOR of SealedQub | serialize_sealed_qub() |
Arweave upload, viewer fetch |
QubEnvelopeCbor |
Canonical CBOR of QubEnvelope | serialize_qub_envelope() |
tlock encrypt input, tlock decrypt output |
5.1 Construction Rules
// 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 Validation on Construction
from_encoded() SHOULD validate that the input begins with a valid CBOR map header. Full structural validation happens at parse time, not construction time, to avoid double-parsing.
6. Content Type Registry
| Value | Type | Phase | Max Body Size | Notes |
|---|---|---|---|---|
0x00 |
Reserved (invalid) | — | — | MUST NOT be used |
0x01 |
Plain text (UTF-8, restricted Markdown) | MVP | 50 KB | See §11 for rendering rules |
0x02 |
Voice (Opus audio) | Phase 2 | 2 MB | Duration-capped |
0x03 |
Pact (bilateral agreement, CBOR body) | Phase 2 | 100 KB | Body is canonical CBOR PactTerms (§6.1). Cosigner signing per §9.7. |
0x04–0x0F |
Reserved for qub-defined types | — | — | Assigned in future spec revisions |
0x10–0x7F |
Reserved for future standardisation | — | — | Requires spec approval |
0x80–0xFE |
Experimental / private use | — | — | Not guaranteed interoperable |
0xFF |
Reserved (invalid) | — | — | MUST NOT be used |
Viewers MUST reject unknown content types with a clear user-visible error. Viewers MUST NOT attempt to render unknown types as text.
6.1 Pact Body (content_type = 0x03)
A pact body is the canonical CBOR encoding of a PactTerms value:
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)> }
Canonical CBOR key orders for all three maps are given in §3.2. Total serialised pact CBOR MUST NOT exceed 100 KB (matches §6).
Schema discriminator. The first row in terms for a structured/v1 pact MUST be { key: "pact_schema", value: "structured/v1" }. Rows without this marker are "custom" pacts and receive no structured validation or schema-aware rendering.
Frozen acknowledgement slots. structured/v1 pacts carry exactly four acknowledgement rows under these keys:
"initiator_standard_terms"
"initiator_capacity_terms"
"counterparty_standard_terms"
"counterparty_capacity_terms"
The value for each is one of eight frozen English strings chosen by the (role, kind) pair, where role ∈ { seller, buyer, provider, client } and kind ∈ { standard, capacity }. The strings themselves are normative protocol data — both parties' ML-DSA-65 signatures commit to the exact bytes via body_hash. They are NOT localised; the signed body is language-neutral. Any wording change requires a new schema version (structured/v2).
The eight strings, their lookup (acknowledgement_for(role, kind)), and the rationale for each are pinned by the reference implementation. Conforming implementations MUST emit byte-identical acknowledgement values; golden-fixture SHA3-256 body-hash tests covering all four role combinations catch any drift.
Viewer display order. The acknowledgement strings contain phrases such as "described above", which presume the description / scope rows render ahead of the acknowledgements. Viewers MUST render the terms array in CBOR order; reordering breaks the prose semantics.
Counter-party contact. When Party B's contact is a valid email address, the qub upload service auto-dispatches a review / co-sign invite email at stage time and binds the eventual co-sign to verification of that same address (§9.7). Pacts whose Party B contact is absent can still be co-signed, but only through an out-of-band channel — the service refuses co-sign requests that cannot produce a matching 15-minute email-verification marker.
7. Seal Protocol
The complete seal sequence. Each step is normative.
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, PDD §8.4).
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. Compute qub_id (see §4.1).
6. Construct QubEnvelope with all fields.
7. Serialise QubEnvelope using canonical CBOR → bytes B.
Assert: serialised output matches canonical profile (§3).
8. Select drand chain. Load chain_genesis_time and chain_period_seconds.
9. Compute drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds).
10. Compute C = tlock_encrypt(B, drand_round, drand_chain_public_key).
11. 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.
13. Display seal-time disclosure. User confirms.
14. Validate upload eligibility via the qub upload service (bot-detection, entitlement, rate limits).
15. Submit SealedQubCbor to the qub upload service; the service signs and uploads to Arweave.
16. Receive arweave_tx_id and delivery URL from the service.
8. Unlock Protocol
The complete unlock sequence. Each step is normative.
1. Viewer opens delivery URL. Extract arweave_tx_id from path.
2. Check denylist. If tx_id is denylisted → display block message. Stop.
3. Fetch SealedQubCbor from Arweave (with multi-gateway fallback).
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.
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 §11 for text, §6 for pact).
17. Construct RevealedQub for display.
9. Authorship Signing (Phase 2+)
9.1 Rationale
Qubs are stored permanently on Arweave. Authorship signatures must remain unforgeable indefinitely. This makes post-quantum signatures (ML-DSA-65) preferable over classical schemes (Ed25519), whose security may degrade within the qub’s permanent lifetime.
9.2 Algorithm Registry
sig_alg |
Scheme | Key Size | Signature Size | Status |
|---|---|---|---|---|
0x00 |
No signature (unsigned) | — | — | MVP default |
0x01 |
ML-DSA-65 (FIPS 204) | 1,952 bytes | 3,309 bytes | Phase 2 default |
0x02 |
Ed25519 | 32 bytes | 64 bytes | Reserved fallback |
0x03–0xFF |
Reserved | — | — | Future |
Viewers MUST reject unknown sig_alg values. Viewers that support sig_alg = 0x00 but not 0x01 SHOULD display “signature present but not verifiable by this viewer version” rather than silently ignoring the signature.
9.3 Signed Preimage Construction
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)
org_id_present // u8 (1 byte): 0x00 = individual, 0x01 = org
// org_id // [u8; 32] only if org_id_present == 0x01 (Phase 4+)
)
// Individual (MVP/Phase 2): total preimage = 91 bytes → 32-byte hash
// Org-delegated (Phase 4+): total preimage = 123 bytes → 32-byte hash
signature = Sign(author_secret_key, sig_input)
Domain separator: "QUB_AUTHOR_SIG_V1" is 17 ASCII bytes: [0x51, 0x55, 0x42, 0x5F, 0x41, 0x55, 0x54, 0x48, 0x4F, 0x52, 0x5F, 0x53, 0x49, 0x47, 0x5F, 0x56, 0x31]. No padding.
org_id_present: In Phase 2, this is always 0x00 (individual signing). In Phase 4+, 0x01 indicates the signature was made under an org delegation, and org_id ([u8; 32]) follows. This ensures individual signatures are structurally distinct from org-delegated signatures — preventing cross-context reuse.
Signature scope — what is and isn't covered. sig_input commits to four envelope fields: version, qub_id, body_hash, unlock_at (plus the fixed domain separator and org_id_present byte). Three of those four are structural invariants: qub_id is itself derived from version, content_type, created_at, unlock_at, and body_hash via the §4.1 preimage, so any change to content_type or created_at produces a different qub_id and invalidates the signature transitively. The directly-authenticated surface is therefore:
| Field | Authenticated by signature | How |
|---|---|---|
version |
✓ | Direct input to sig_input |
qub_id |
✓ | Direct input |
body_hash |
✓ | Direct input |
unlock_at |
✓ | Direct input |
content_type |
✓ | Transitively, via qub_id preimage |
created_at |
✓ | Transitively, via qub_id preimage |
body |
✓ | Transitively, via body_hash = SHA3-256(body) |
author_pubkey |
— (implicit) | Key that verified the signature is the author, by definition |
sender_label |
✗ | Display-only text; mutable without signature breakage |
reply_to |
✗ | Threading pointer; mutable without signature breakage |
cosigner_pubkey / cosigner_signature |
— | Independently signed over the same sig_input (see §9.7) |
drand_round, drand_chain_id, tlock_ciphertext, visibility |
— | Outer SealedQub fields, not inside the envelope — covered by their own structural invariants (round / chain consistency) but not by the author signature |
Security implications of non-authenticated fields.
- A party with write access to the stored bytes could swap
sender_label("Alice" → "Mallory") without invalidating the author signature. Theauthor_pubkeyinside the envelope remains the true identity anchor — viewers MUST derive the display identity fromauthor_pubkey(via the §9.5 attestation layer) rather than trustingsender_label. - A
reply_tofield can likewise be edited post-signing. Becausequb_idis content-addressed, an attacker can't pointreply_toat a non-existent target, but they can silently re-parent a reply to a different existing qub. - For reply chains to carry end-to-end integrity, future protocol versions SHOULD either include
reply_toinsig_inputor require a separatereply_sigover(qub_id, reply_to). Neither is part of Phase 2.
Implementations that display sender_label or reply_to to end users MUST surface the authenticated identity (pubkey fingerprint, attestation) as the primary identity signal, not the label.
9.4 Verification Procedure
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."
Signature verification is the most expensive operation (especially ML-DSA-65). It SHOULD be performed after all cheaper checks (hash, qub_id, unlock_at) have passed.
9.5 Identity Attestations
Identity attestations — the mapping of author_pubkey to human-recognisable identity claims such as email, social handles, or passkey credentials — are defined in IDENTITY.md. Attestation resolution is a viewer-side progressive enhancement and is not required for signature verification.
A conforming verifier can complete every check in §9.4 without contacting the qub API, without any network beyond Arweave and drand, and without any server-side lookup. Identity display (beyond the fallback fingerprint defined in IDENTITY.md §2) is a separate best-effort step performed only after signature verification has succeeded.
9.6 Size Impact
| Ed25519 | ML-DSA-65 | |
|---|---|---|
| Signature | 64 bytes | 3,309 bytes |
| Public key | 32 bytes | 1,952 bytes |
| Total per qub | 96 bytes | 5,261 bytes |
| Arweave cost delta (at ~$5/MB) | ~$0.0005 | ~$0.026 |
For a text qub of 500–2,000 bytes, ML-DSA-65 roughly triples the stored size. The absolute cost is negligible.
9.7 Cosigner Verification (Phase 2+ — Pact Bilateral Agreements)
For bilateral agreements (content_type = 0x03), a second signature layer proves both parties consented to the same terms.
Envelope fields:
cosigner_pubkey: ML-DSA-65 public key of the counter-signer (Party B).cosigner_signature: Signature over the samesig_inputas the author (§9.3).
Both fields MUST be present together or both absent. If exactly one is present, viewers MUST report an integrity error.
Verification procedure:
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."
Properties:
- The cosigner signs the identical
sig_inputas the author — both parties commit to the samequb_id,body_hash, andunlock_at. qub_idderivation (§4.1) does NOT include cosigner fields. Adding a cosigner to an existing envelope does not change thequb_id.- A pact can be author-signed only (one-sided commitment), cosigner-only (unusual), or both (full bilateral proof).
Email-binding gate (operational). When a staged pact carries a Party B email contact (§6.1), the qub upload service MUST refuse the co-sign request unless a short-lived email-verification marker exists matching both the staging id and the normalised-email hash of that contact. The marker is written by /api/v1/auth/verify when the magic-link token carries a staging_id and the verified address matches SHA-256(normalise_email(party_b.contact)) — where normalise_email(addr) preserves the local-part case and lowercases only the domain part (per RFC 5321 §2.3.11), and SHA-256 here is the NIST FIPS 180-4 hash (distinct from the SHA3-256 used in §4 derivations) — and expires 900 seconds (15 minutes) after issue. This is an operational anti-impersonation gate, NOT part of the on-chain qub proof — a third-party verifier replaying §12 needs only Arweave and drand, without any server-side lookup. The marker exists server-side only and is never part of the signed body.
Size impact (ML-DSA-65 author + cosigner):
| Component | Size |
|---|---|
| Author signature | 3,309 bytes |
| Author public key | 1,952 bytes |
| Cosigner signature | 3,309 bytes |
| Cosigner public key | 1,952 bytes |
| Total crypto overhead | 10,522 bytes |
| Arweave cost delta | ~$0.05 |
10. Private qub Encryption (Phase 2+)
Private qubs use two encryption layers: tlock for time-binding, and recipient-key encryption for audience restriction.
10.1 Encryption Stack
Layer 1 (inner): AES-256-GCM with key derived from X25519 key exchange
Layer 2 (outer): tlock against drand round (same as public qubs)
Encrypt:
1. Sender performs X25519(sender_ephemeral_secret, recipient_pubkey) → shared_secret
2. Derive AES key via HKDF-SHA256(shared_secret, salt, "QUB_PRIVATE_V1")
3. Encrypt QubEnvelope CBOR bytes with AES-256-GCM → inner_ciphertext
4. tlock_encrypt(inner_ciphertext, drand_round) → outer_ciphertext
5. SealedQub.tlock_ciphertext = outer_ciphertext
6. SealedQub.recipient_pubkey = recipient's X25519 public key
Decrypt (after drand round):
1. tlock_decrypt(outer_ciphertext, round_signature) → inner_ciphertext
2. Recipient performs X25519(recipient_secret, sender_ephemeral_pubkey) → shared_secret
3. Derive AES key via HKDF-SHA256(shared_secret, salt, "QUB_PRIVATE_V1")
4. Decrypt inner_ciphertext with AES-256-GCM → QubEnvelope CBOR bytes
10.2 Post-Quantum Note
X25519 is not quantum-resistant. Private qubs stored permanently on Arweave face long-term quantum decryption risk. ML-KEM-768 (FIPS 203) is the intended future replacement, gated on library maturity and viewer bundle impact assessment. The version field and protocol versioning support introducing ML-KEM without breaking existing private qubs.
11. Markdown Rendering and Sanitisation
This section is security-critical. The viewer renders text qubs (content_type = 0x01) using a restricted Markdown subset.
11.1 Allowed Elements
- Headings:
#through####(no#####or######) - Emphasis: bold (
**), italic (*), strikethrough (~~) - Lists: ordered (
1.) and unordered (-,*) - Blockquotes (
>) - Code: inline spans (```) and fenced blocks (`````)
- Horizontal rules (
---) - Line breaks (two trailing spaces or blank line)
- Paragraphs
11.2 Forbidden Elements
| Element | Handling |
|---|---|
Raw HTML (<div>, <script>, etc.) |
Stripped entirely. No HTML passes through. |
Images () |
Stripped. Image syntax is removed from output. |
Links ([text](url)) |
URL rendered as visible plain text. Not auto-linked. Not clickable without explicit user action. |
| Dangerous URL schemes | javascript:, data:, vbscript:, file: — stripped. |
| Iframes, embeds, objects | Stripped. |
| HTML entities | Decoded to display characters only if safe. |
11.3 Implementation
Implementations MUST use a strict allowlist parser, not a blocklist. The recommended approach:
- Parse Markdown using
pulldown-cmark(or equivalent). - Walk the AST and drop any node not in the allowlist (§11.1).
- For link nodes: emit the URL as visible text, not as a clickable
<a>element. - Convert the filtered AST into a typed intermediate representation (e.g., a
MarkdownNodeenum with only safe variants). Raw HTML is structurally unrepresentable in this IR. - Render from the typed IR to the target view layer (e.g., reactive view components, DOM nodes). No HTML string concatenation or
innerHTMLat any point.
Blocklist approaches are fragile because new Markdown extensions or parser quirks can introduce unfiltered elements. The typed-AST approach makes XSS structurally impossible — there is no variant that can carry arbitrary HTML.
11.4 Size and Structure Limits
- Maximum rendered heading depth:
####(H4).#####and deeper are rendered as bold text. - No limit on paragraph count (body size limits in §6 are the constraint).
- Fenced code blocks: no syntax highlighting in MVP. Rendered as monospace preformatted text.
12. Third-Party Verification
Any third party can verify a public qub without qub cooperation. The verification procedure:
1. Obtain arweave_tx_id (from delivery URL or direct knowledge).
2. Fetch SealedQubCbor from any Arweave gateway.
3. Confirm Arweave 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.
What verification proves:
| Proof | What it establishes |
|---|---|
| Commitment | The ciphertext existed by the Arweave block timestamp. |
| Integrity | The plaintext body matches the committed hash and has not been altered. |
| Timing | The content was unreadable until the drand round, which corresponds to the chosen unlock time (subject to tlock and drand security assumptions). |
What verification does NOT prove:
| Non-proof | Why |
|---|---|
| Authorship | The sender_label is decorative. Without sig_alg ≥ 0x01, anyone could have sealed this content. |
| Intent | The qub proves content and timing, not what the creator subjectively meant. |
| Pre-event timing | Arweave block inclusion may lag actual upload by minutes. The commitment timestamp is the block time, not the moment the user pressed “seal.” |
13. Versioning
13.1 Protocol Version
The version field (u8) in both SealedQub and QubEnvelope identifies the major protocol version.
- Viewers MUST reject unknown major versions with a clear error.
- Known major versions MAY tolerate unknown optional fields if forward compatibility rules allow (optional fields absent from the canonical key order are ignored).
- Content types (
content_type) and signature schemes (sig_alg) are version-gated: new values may only be introduced alongside a new protocol version or explicit registry update.
13.2 Version History
| Version | Value | Description |
|---|---|---|
| v1 | 0x01 |
Public text qubs (content_type 0x01), pact bilateral agreements (0x03, structured/v1 schema, ML-DSA-65 author + cosigner), tlock, SHA3-256 |
13.3 Forward Compatibility
A v1 viewer encountering a QubEnvelope with unknown optional CBOR map keys (keys not in the §3.2 canonical order) SHOULD ignore those keys and proceed with verification using known fields. This allows future minor additions (e.g., new metadata) without requiring a major version bump.
A v1 viewer encountering sig_alg = 0x01 (ML-DSA-65) but lacking ML-DSA-65 verification support SHOULD display the qub content with a “signature present but not verifiable” notice, not reject the qub entirely.
14. Test Vectors
14.1 qub_id Derivation
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)
body = "Hello, future." (UTF-8, 14 bytes)
Intermediate:
body_hash = SHA3-256("Hello, future.")
= 76ab8b3f843c6ed4f2d0fd75b9f457b4
ad49dd4450f9c22723ae430e3af3211d
Domain separator (10 bytes):
[0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x31, 0x00]
Preimage (60 bytes):
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)
body_hash // 32 bytes
Expected output:
qub_id = SHA3-256(preimage)
= f955de1c44502eea9c2132506c765f3b
949fb79f26ecfcb27b6787c374c7ba01
Implementations MUST produce identical body_hash and qub_id values for this input. This test vector SHOULD be the first unit test written. The canonical values above were computed by the reference implementation and MUST match bit-for-bit.
14.2 Unlock-Round Mapping
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 Canonical CBOR Round-Trip
Implementations MUST verify that serialize(parse(serialize(qub))) == serialize(qub) for all valid inputs. This is a property test, not a single vector.
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)
The canonical CBOR bytes and SHA3-256 body_hash are computed by the reference implementation. Implementations MUST produce byte-identical CBOR for this input.
Implementations MUST also verify that serialize(parse(serialize(pact))) == serialize(pact) for all valid PactTerms inputs (property test).