# qub > Seal your words today. Prove when you said them. Even AI can't fake the past. qub lets a person — or an AI agent — write something today that the world can only read at a future moment, and prove later that it was written before that moment arrived. As more of the internet becomes machine-generated, being able to point at something and prove "this existed at that exact moment, and I wrote it" gets more valuable, not less. qub is the simplest tool we know of for doing that. A qub is sealed in the user's (or agent's) browser before anything is uploaded — the platform itself cannot read sealed content, including before the unlock moment arrives. Sealed bytes live in permanent public storage — a decentralised network qub does not run; verification works without qub being online. After the unlock time, anyone with the link can decrypt and verify the qub. Public attribution is **opt-in per qub** — by default a qub goes up unsigned and there is no on-chain link from it back to a creator. Authors who want credit (a "called it" moment, a receipt that they said something first) can attach their fingerprint at seal time. ## What agents can do - **Create qubs** — seal a qub with a future unlock date via API or MCP server - **Create pacts** — stage a bilateral agreement, share the staging link, and co-sign when both parties are ready - **Read qubs** — fetch qub status and content (if unlocked) as structured JSON - **Verify qubs** — cryptographic proof that content existed before the unlock time - **Monitor qubs** — check status, register webhooks for unlock notifications - **Engage with sealed qubs** — increment watch counters, subscribe an email for reveal-time notification - **Verify creator identity** — look up an author's verified email/handle by public-key fingerprint - **Rotate API keys** — issue a new key with a grace period so deployments can roll over without downtime ## Use cases for AI agents - Provably commit to predictions before outcomes are known - Schedule time-locked announcements or reveals - Timestamp ideas, designs, or hypotheses before exposure (`intent: "proof"`) - Seal informal agreements between agents or between an agent and a human (`intent: "commitment"`) - Author reply-qubs that link back to a parent qub via `reply_to` (qub_id inside the encrypted envelope) and `parent_tx_id` (public storage tag) — the conversational viral loop - Create verifiable audit trails with cryptographic timing guarantees - Build workflows that trigger on qub unlock events ## API Base URL: `https://qub.social` ### Authentication Agent integrations authenticate with an API key. Pass it in the `Authorization` header: ``` Authorization: Bearer qub_sk_... ``` Required for `/api/v1/seal`, `/api/v1/webhooks`, and key rotation. For `/api/v1/upload`, `/api/v1/upload-auth`, and the pact endpoints, the API key is also accepted (browser callers without one supply a Cloudflare Turnstile token in the request body as `turnstile_token`). ### Endpoints | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/v1/qub/{tx_id}` | Optional | Read qub status and content (JSON) | | GET | `/api/v1/qub/{tx_id}/meta` | None | Lightweight metadata read (no decrypt). Returns `arweave_block_timestamp`, `intent`, `parent_tx_id` (reply-chain back-link), `author_fingerprint` (Author tag — the lookup key for the §9.5 attestation chain), `watching` (live watch count), `reactions` (called_it / wrong / total tallies for revealed predictions), and `denylisted` (moderation flag the embed iframe gates on). The viewer and the `` iframe re-poll this every 30s during countdown; polling halts in the final minute before unlock. | | GET | `/api/v1/qub/{tx_id}/bytes` | None | Raw sealed CBOR bytes (R2 cache-aside, immutable caching). Used by embed + SPA for client-side decrypt. | | POST | `/api/v1/qub/{tx_id}/watch` | None | Increment the pre-reveal "watching" counter for social proof | | POST | `/api/v1/qub/{tx_id}/view` | None | Increment the post-reveal view counter | | POST | `/api/v1/qub/{tx_id}/verdict-watch` | None | Commit to watching for the verdict on a verdict-bearing qub. Per-device deduped (re-click no-op; unsubscribe doesn't decrement). Body: `{ device_id }`. Returns `{ count, watching }`. | | GET | `/api/v1/qub/{tx_id}/verdict-watch` | None | Read the current verdict-watcher count without modifying the set. Used by the reveal-page 30s poll. | | POST | `/api/v1/qub/{tx_id}/notify` | None | Subscribe an email to be notified when the qub unlocks | | POST | `/api/v1/qub/{tx_id}/react` | None | Record a `called_it` / `wrong` reaction for a revealed prediction; returns updated tallies | | POST | `/api/v1/seal` | API key | Create a qub (server-side encryption) | | POST | `/api/v1/upload` | API key or Turnstile | Upload pre-sealed CBOR (client-side encryption) | | POST | `/api/v1/upload-auth` | API key or Turnstile | Pre-check upload eligibility | | GET | `/api/v1/entitlements` | None | Check device tier and remaining qubs | | POST | `/api/v1/api-keys/rotate` | API key | Rotate the current API key, returns the new secret with a grace period | | GET | `/api/v1/api-keys/mine` | Magic-link (device_id) | List the API keys owned by the verified email — backs `/developer/keys` | | PATCH | `/api/v1/api-keys/{keyHash}` | Magic-link (device_id) | Update label / cap / IP allowlist / scope / disabled on a key the caller owns | | GET | `/api/v1/handle/{handle}` | None | Public handle reverse lookup. Resolves a handle to its owning fingerprint and attestation record. 60-second edge cache. Used by viewer surfaces and `@handle` autocomplete. | | POST | `/api/v1/webhooks` | API key | Register unlock notification webhook | | GET | `/api/v1/webhooks` | API key | List registered webhooks | | DELETE | `/api/v1/webhooks/{id}` | API key | Remove a webhook | | POST | `/api/v1/pact/stage` | API key or Turnstile | Stage a signed pact envelope for co-signing | | GET | `/api/v1/pact/stage/{id}` | None | Review a staged pact's terms and metadata | | POST | `/api/v1/pact/cosign/{id}` | API key or Turnstile | Co-sign a staged pact and trigger upload to permanent storage | | DELETE | `/api/v1/pact/stage/{id}` | API key or Turnstile | Retract a staged pact before co-signing (proof-of-possession required) | | POST | `/api/v1/pact/invite/accept` | None | One-click accept of a pact invite from the email link — writes the cosign-enabling marker; the email click proves email control | | GET | `/api/v1/pacts/mine` | None | Cross-device list of staged pacts authored by the device's linked email | | GET | `/api/v1/identity/attestation/{fingerprint}` | None | Look up the attestation for a public key fingerprint | | GET | `/api/v1/openapi.json` | None | OpenAPI 3.1 specification | | GET | `/api/v1/og/{tx_id}.png` | None | Dynamic Open Graph image (sealed: reveal date + watching count; revealed: called-it percentage). R2 cache-aside. | | GET | `/c/{tx_id}` | None | Viewer page (HTML for browsers, OG meta tags for bots, redirects to JSON for `Accept: application/json`) | | GET | `/s/{code}` | None | 7-character short-URL redirect (302) to `/c/{tx_id}`. Allocated at upload and seal time; preferred for share surfaces because it saves ~56 characters vs the full storage `tx_id`. | | GET | `/oembed` | None | oEmbed discovery endpoint for auto-embedding qub viewer cards on WordPress, Notion, Medium, Substack | ### Creating a qub (server-side seal) ``` POST /api/v1/seal Authorization: Bearer qub_sk_... Content-Type: application/json { "body": "My prediction: the market will close above 5000 on Friday.", "unlock_at": 1746057600, "sender_label": "Agent Smith", "title": "Q1 BTC call" } ``` `title` is optional — a short plaintext label (≤100 NFC code points, no control characters) shown on the viewer countdown before reveal. Bound to `qub_id` via `title_hash`, so a gateway cannot swap it. Omit the field to leave the countdown without a creator-set title. The `Author` tag is **opt-in per qub**: API callers attach their fingerprint by passing `author_fingerprint` (64-char lowercase hex of the signing pubkey, PROTOCOL.md §2.2) on the upload request. Omit the field to seal an unattributed qub — nothing in permanent storage links it to a creator. Attribution is decided per qub and is permanent once written. Response: ```json { "tx_id": "abc123...", "delivery_url": "https://qub.social/c/abc123...", "short_delivery_url": "https://qub.social/s/aB12xYz", "qub_id": "f955de1c...", "unlock_at": 1746057600, "drand_round": 17754411 } ``` The `short_delivery_url` is present when a 7-character base62 short code was allocated at seal time; prefer it for social-channel share surfaces (tweets, QR matrices) because it saves ~56 characters vs the `/c/{tx_id}` form. Both URLs resolve to the same viewer page. Absent if allocation failed transiently — `delivery_url` is always usable as a fallback. Note: The server-side seal endpoint passes plaintext through the server for convenience. For end-to-end encryption where plaintext never leaves your machine, use the MCP server. ### Reading a qub ``` GET /api/v1/qub/{tx_id} ``` Returns JSON with `status: "locked"` (with countdown metadata) or `status: "unlocked"` (with verified body content). ### Webhooks Register a webhook to be notified when a qub unlocks: ``` POST /api/v1/webhooks Authorization: Bearer qub_sk_... Content-Type: application/json { "tx_id": "abc123...", "url": "https://your-agent.example/callback", "secret": "your-hmac-secret-min-16-chars" } ``` All three fields are required. `secret` must be at least 16 characters. The callback receives a POST with the qub data and an `X-Qub-Signature` HMAC-SHA256 header computed with `secret`. ### Notify-me (email subscription) For human-facing flows where a webhook isn't appropriate, an email address can be subscribed to a sealed qub. The Worker's reveal-time cron sends a one-shot email when the qub unlocks. No verification step — best-effort delivery. ``` POST /api/v1/qub/{tx_id}/notify Content-Type: application/json { "email": "alice@example.com", "unlock_at": 1746057600, "locale": "en", "intent": "prediction" } ``` Hard-capped at 1000 subscribers per qub. Rate-limited 5/min/IP. Response is always `{"ok": true}` on success — the user shouldn't be able to enumerate which qubs already have a given subscriber. `intent` is optional and accepts `prediction | letter | secret | announcement | thesis | commitment | proof | verdict`. The eighth value, `verdict`, is system-emitted — only the `/verdict/{tx_id}` flow produces verdict-tagged qubs; agents should not pass it directly when sealing. When supplied, the reveal email uses an intent-aware subject and body where a per-intent template exists (e.g. "The prediction reveals now." instead of the generic "The qub just revealed."); intents without a per-intent template fall through to the generic copy. Unknown values are silently dropped server-side and fall back to the generic template — sending an unrecognised intent never errors. ### Engagement counters Lightweight social-proof counters. Both endpoints are unauthenticated, rate-limited 10/min/IP, and return the new count. ``` POST /api/v1/qub/{tx_id}/watch → { "count": 42 } // pre-reveal POST /api/v1/qub/{tx_id}/view → { "count": 318 } // post-reveal POST /api/v1/qub/{tx_id}/verdict-watch → { "count": 1248, "watching": true } // verdict-bearing qubs only — per-device deduped; body { device_id } GET /api/v1/qub/{tx_id}/verdict-watch → { "count": 1248 } // poll ``` ### Pact staging (bilateral agreements) A pact is a structured agreement between two parties, sealed as a qub with content type `0x03`. The staging flow: 1. **Party A stages** — sends a signed envelope containing the pact terms (title, key-value terms, party identifiers, optional notes) via `POST /api/v1/pact/stage`. Returns a `staging_id`. The Worker also emails Party B a link `https://qub.social/p/?t=` carrying an HMAC-signed invite token. 2. **Party B accepts the invite** — clicks the email link. The client posts the token to `POST /api/v1/pact/invite/accept`, which writes the cosign-enabling marker; the email click proves email control. 3. **Party B reviews** — fetches the staged pact via `GET /api/v1/pact/stage/{id}`. Returns the CBOR envelope, terms summary, and Party A's public key. 4. **Party B co-signs** — sends their signature via `POST /api/v1/pact/cosign/{id}`. The Worker merges both signatures into the envelope and uploads to permanent storage. Returns the `tx_id` and delivery URL. 5. **Retraction** — Party A can retract before co-signing via `DELETE /api/v1/pact/stage/{id}` (proof-of-possession required). 6. **Cross-device discovery** — Party A on a new device can fetch their staged pacts via `GET /api/v1/pacts/mine?device_id=` (resolves to the linked email and returns the `pact-by-email:*` author index). Staged pacts expire after 7 days if not co-signed. ### Identity attestation lookup Authors can link a verified email and a personalised handle to their signing key so viewers can see who wrote a qub. Agents can verify that link by looking it up by fingerprint: ``` GET /api/v1/identity/attestation/{fingerprint} → { "fingerprint": "...", "email": "...", "handle": "@alice", "verified_at": 1746057600 } ``` `fingerprint` is the lowercase 64-char hex of `SHA3-256(pubkey)` — the same value returned in the `Author` tag and in `getQubMeta`'s `author_fingerprint` field. Returns 404 if no attestation exists for that fingerprint. Establishing or revoking an attestation is a creator-side flow done in the qub web app (email-confirmation challenge + ML-DSA-65 proof of possession) and is not part of the agent API surface. ## MCP Server For Claude and other tool-using LLMs, install the qub MCP server: ```json { "mcpServers": { "qub": { "command": "qub-mcp", "env": { "QUB_API_KEY": "qub_sk_..." } } } } ``` The MCP server encrypts locally using the same cryptographic library as the web app — plaintext never leaves your machine. Tools: `create_qub`, `read_qub`, `check_status`. The MCP server is intentionally minimal — it covers the core seal/read/status operations and nothing else. For notify-me, engagement counters, identity linking, webhooks, and key rotation, use the HTTP API directly. ## Protocol - **Encryption (inner):** drand timelock encryption (tlock) using the quicknet chain (BLS12-381, 3-second rounds). The qub cannot be opened until drand publishes the relevant round signature — qub does not run drand and cannot ask it to publish ahead of time. - **Encryption (outer wrapper):** every upload to permanent storage is also AES-256-GCM-wrapped (PROTOCOL.md §13). The 32-byte wrapper key K rides as the URL fragment of the delivery URL (`#`); browsers do not transmit URL fragments to servers, so neither qub nor any storage gateway is byte-aware of K. Wrapped bytes in permanent storage are byte-indistinguishable from arbitrary ciphertext, closing the bulk-decrypt enumeration channel that the inner protocol layer alone left open. - **Storage:** permanent public storage (immutable, censorship-resistant) — qub does not control it. - **Hashing:** SHA3-256 for content integrity. The 32-byte `qub_id` preimage is 92 bytes covering version, content type, created/unlock timestamps, body hash, and the SHA3-256 of the optional NFC-normalised title (so a gateway cannot swap a title without the qub failing verification). - **Authorship signing:** ML-DSA-65 (FIPS 204, post-quantum) — opt-in, keypair stays on the creator's device; verifiers don't need qub online to check it. - **Public attribution:** opt-in per qub via the `Author` tag. There is no creator-keyed index on the qub side; the public profile at `/u/` is a verified-identity card and does not list a creator's qubs. - **Serialisation:** Canonical CBOR (RFC 8949 deterministic profile). - **Verification:** Any third party can independently verify a qub without qub's cooperation. ## Links - Website: https://qub.social - Full bundle (llms.txt + protocol + agent reference, single file): https://qub.social/llms-full.txt - API spec: https://qub.social/api/v1/openapi.json - Protocol spec: https://qub.social/protocol - Sitemap: https://qub.social/sitemap.xml - MCP discovery: https://qub.social/.well-known/mcp.json - Source: https://github.com/qubsocial/qub-public --- # Protocol Specification Source: https://qub.social/protocol qub is a protocol for cryptographic temporal commitments: a system for sealing words to a future date and proving, when that date arrives, exactly what was said and when. Three primitives make it work. **drand** is a decentralised randomness beacon — the reveal date is enforceable by physics, not by any party's goodwill. **Permanent public storage** is a tamper-proof public store — no party can edit or delete a qub once it has been sealed. **ML-DSA-65** is a post-quantum digital signature — each qub is tied to a key pair whose secret never leaves the author's device. Together these primitives make a statement that is time-locked, tamper-evident, and attributable — a receipt whose value grows as the world's ability to fabricate the past improves. The remainder of this document is the normative specification required for interoperable implementations. ______________________________________________________________________ # qub Protocol Specification | Field | Value | | ----------- | ----------------------------- | | **Version** | 1.0 (protocol version `0x01`, outer wrapper version `0x01`) | | **Date** | 2026-05-01 | | **Status** | Draft | | **Reviewed through** | 2026-05-01 | 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. Scope: the protocol layer is intentionally language-neutral — the qub body is opaque plaintext / markdown / pact bytes, and locale-aware rendering is the viewer's responsibility (qub.social web app, `` iframe, MCP clients, etc.). ______________________________________________________________________ ## 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` | Variable-length byte array | | `Option` | Value of type T, or absent | | `String` | UTF-8 text string, NFC normalised | | `||` | Byte concatenation | | `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 written to permanent storage. Local to the creator app. ```text ComposeQub { draft_id: [u8; 16], // Random, generated locally created_at: i64, // Unix seconds UTC unlock_at: Option, // 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, // UTF-8 qub body sender_label: Option, // 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. ```text 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, // V1.1 — when reality renders judgment (verdict-uplift-plan §3.1) sender_label: Option, // 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, // 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>, // Set when sig_alg != 0x00 author_pubkey: Option>, // Set when sig_alg != 0x00 cosigner_pubkey: Option>, // Set for cosigned pact bilateral agreements cosigner_signature: Option>, // Set for cosigned 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); `reply_to` set to the parent qub's `qub_id` for reply-chain qubs (see §9.3 for the signature-scope implications). ### 2.3 SealedQub (Canonical Wire Format) Serialised using canonical CBOR (§3). Written to permanent storage. This is the on-chain artifact. ```text 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, // 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, // 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, // 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 (Viewer Application State) Not serialised to CBOR. Local to the viewer app. Constructed after successful decryption and verification. ```text RevealedQub { qub_id: [u8; 32], arweave_tx_id: String, visibility: u8, content_type: u8, created_at: i64, unlock_at: i64, outcome_at: Option, // V1.1 — carried forward from QubEnvelope.outcome_at / SealedQub.outcome_at; drives the reveal-page verdict-watch block (verdict-uplift-plan §5.1) drand_chain_id: String, drand_round: u64, sender_label: Option, title: Option, // Carried forward from SealedQub.title reply_to: Option<[u8; 32]>, body: Vec, body_hash: [u8; 32], body_hash_verified: bool, author_signature: Option>, author_pubkey: Option>, signature_verified: Option, cosigner_pubkey: Option>, cosigner_signature: Option>, cosigner_verified: Option, } ``` ______________________________________________________________________ ## 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):** ```text "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 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):** ```text "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 (pact body, content_type `0x03`):** ```text "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):** ```text "key" (4 encoded bytes) "value" (6 encoded bytes) ``` **PartyIdentifier (party_a / party_b map):** ```text "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 | author_signature, cosigner_signature | | ML-DSA-65 public key (1,952 bytes) | `0x59 0x07 0xA0` + 1,952 bytes | author_pubkey, cosigner_pubkey | ______________________________________________________________________ ## 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. ```text 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 encoding:** The string `"QUB_ID_V2"` 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, 0x32, 0x00]`. **`outcome_at` encoding:** V1.1 extended the preimage from 92 to 100 bytes to fold the optional `outcome_at` field into the binding. Absent `outcome_at` is encoded as 8 zero bytes; the protocol validators reject `outcome_at <= 0` everywhere so this sentinel cannot collide with a legitimate value. See §3.2 (wire format) and the in-tree `tasks/verdict-uplift-plan.md` for the verdict mechanic that motivates this field. **`drand_round` encoding:** V1.2 extended the preimage from 100 to 108 bytes to fold `drand_round` (the target drand round, §4.3) into the binding, and bumped the domain separator to `QUB_ID_V2`. This binds the timelock round into the qub identity: a gateway cannot rebind the ciphertext to a different (e.g. already-past) round than the displayed `unlock_at` implies. The unlock procedure (§8) additionally verifies that the round baked into the tlock ciphertext stanza matches `unlock_round(unlock_at)`, so the displayed unlock time is provably the round that gates decryption. **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`, or `author_pubkey`. This means the same content sealed at the same time produces the same qub_id regardless of who signs it. - Changing the SealedQub `title` (with everything else fixed) changes `qub_id` via `title_hash`. A gateway therefore cannot swap the plaintext title displayed on the countdown without invalidating the qub identity. - Changing the SealedQub `outcome_at` (with everything else fixed) changes `qub_id` via the preimage. A gateway cannot swap the pre-reveal verdict-on date displayed on the countdown without invalidating the qub identity. - Changing `drand_round` (with everything else fixed) changes `qub_id` via the preimage. A gateway cannot rebind the timelock ciphertext to a different round without invalidating the qub identity; combined with the §8 unlock-time stanza-round check, the displayed `unlock_at` is the round that actually gates decryption. ### 4.2 body_hash ```text body_hash = SHA3-256(body) ``` Where `body` is the raw `Vec` content payload. For text qubs, this is the UTF-8 encoded qub body. ### 4.2.1 title_hash ```text title_hash = SHA3-256(NFC(title).utf8_bytes) if title is present title_hash = [0u8; 32] if title is absent ``` Where `title` is the optional plaintext title surfaced on the viewer countdown before reveal (see §3.2). NFC normalisation runs at hash time so the digest is stable across visually-equivalent code-point sequences. The all-zeros sentinel is reserved for the absent case; an empty string is rejected at the canonical CBOR boundary as a non-canonical encoding of "absent" (the canonical encoding omits the field entirely). ### 4.3 Unlock-Round Mapping ```text 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()` | Permanent-storage upload, viewer fetch | | `QubEnvelopeCbor` | Canonical CBOR of QubEnvelope | `serialize_qub_envelope()` | tlock encrypt input, tlock decrypt output | ### 5.1 Construction Rules ```rust // Production code — only through CBOR serialisers: let sealed = SealedQubCbor::from_encoded(cbor_bytes); // There is deliberately NO From> implementation. // You cannot accidentally wrap arbitrary bytes in a wire format type. // Accessing raw bytes: let bytes: &[u8] = sealed.as_bytes(); let bytes: Vec = 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 | Max Body Size | Notes | | ------ | --------------------------------------- | ----------------------- | --------------------------------------------------------------------- | | `0x00` | Reserved (invalid) | — | MUST NOT be used | | `0x01` | Plain text (UTF-8, restricted Markdown) | 50 KB paid / 10 KB free | See §10 for rendering rules. The free / paid split is enforced by the upload service; the protocol-layer hard ceiling is 50 KB. | | `0x02` | Reserved (future) | — | Allocated for a future content type; not valid in v1. Viewers MUST reject per the rule below. | | `0x03` | Pact (bilateral agreement, CBOR body) | 100 KB | Body is canonical CBOR `PactTerms` (§6.1). Cosigner signing per §9.7. | | `0x04` | Verdict (creator self-grading, CBOR body) | 8 KB | Body is canonical CBOR `VerdictBody` (§6.2). Emitted only by the system-side `verdict` intent. Parent relationship is on the `Parent-Tx-Id` Arweave tag, not on the body. See verdict-uplift-plan §3.4. | 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: ```text PactTerms { pact_version: u8, // 0x01 for structured/v1 title: String, // ≤ 200 bytes, NFC terms: Vec, // ≤ 20 rows party_a: PartyIdentifier, // initiator party_b: PartyIdentifier, // counter-signer notes: Option, // ≤ 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 } ``` 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: ```text "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. ### 6.2 Verdict Body (`content_type = 0x04`) A verdict body is the canonical CBOR encoding of a `VerdictBody` value: ```text VerdictBody { verdict_version: u8, // 0x01 for structured/v1 outcome: u8, // 1=Right · 2=Partial · 3=Wrong · 4=Unfalsifiable reflection: Option, // ≤ 2,000 bytes NFC; "what changed, what did you learn" evidence_url: Option, // ≤ 2,048 bytes; HTTPS only; absent key when omitted } ``` Canonical CBOR key order: ```text "outcome" (8 encoded bytes) "reflection" (11 encoded bytes) ← only if present "evidence_url" (13 encoded bytes) ← only if present "verdict_version" (16 encoded bytes) ``` Total serialised verdict CBOR MUST NOT exceed **8 KB** (matches the registry row above). **Outcome enum.** The wire byte is intent-neutral; the four buckets `Right` / `Partial` / `Wrong` / `Unfalsifiable` cover every verdict-bearing intent's outcome space. Per-intent labels ("Called it" / "Kept it" / "Shipped" / "Confirmed" for `Right`, etc.) are a viewer-side rendering concern resolved against the parent qub's intent — the wire stays language- and intent-neutral. Values outside `1..=4` MUST be rejected at decode. **Parent linkage.** A verdict qub does NOT carry the parent reference in its body. The parent qub's Arweave transaction id is emitted as the `Parent-Tx-Id` storage tag at upload time (§7 storage-tag layer). This keeps the body a self-contained signed statement of self-assessment; the audit chain ("right about what?") is established via the Arweave-tag lookup. **Evidence URL safety (normative).** When `evidence_url` is present, validators (compose-side, wire-side, Worker edge) MUST enforce: 1. **HTTPS only.** The string MUST start with the byte sequence `https://`. Any other scheme — `http`, `ftp`, `javascript`, `data`, `file`, etc. — is rejected. 1. **Length cap.** ≤ 2,048 bytes (browser URL practical limit). 1. **NFC + hostile-codepoint check.** Same rule as `title` and `reflection` — bidi-override / zero-width / tag-block / BOM / C0 / C1 codepoints are rejected. Definition matches the Rust `crate::handle::contains_hostile_text_codepoint` and the TS `workers/api/src/utils/unicode.ts::isHostileCodepoint` (keep in lockstep). 1. **No whitespace, no ASCII controls.** Whitespace / DEL / sub-`0x20` bytes anywhere in the URL are rejected — closes the `\n`/`\t` injection vector the bidi rule doesn't cover. 1. **Non-empty host segment.** Everything between `https://` and the first `/`, `?`, or `#` MUST be non-empty. **No server-side fetching.** The Worker MUST NOT proxy, fetch, or preview the URL. The protocol stores a string; rendering happens viewer-side with `rel="nofollow noopener noreferrer" target="_blank"` and a visible host displayed alongside the link text. **Reflection.** Optional creator-written reflection text ("what changed, what did you learn"). Same NFC + hostile-codepoint validation as `title`. Empty / whitespace-only input collapses to absent at construction time. **Schema version.** v1 supports `verdict_version = 0x01` only. Future schema revisions bump this byte and land alongside a new protocol version per §12. ______________________________________________________________________ ## 7. Seal Protocol The complete seal sequence. Each step is normative. ```text 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 `/c/#` (or `/s/#` 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. ``` **Storage tag layer (out-of-band).** The qub upload service attaches a deliberately small set of storage transaction tags alongside the wrapped payload. `Content-Type=application/octet-stream` is normatively required. The reference service additionally attaches three optional tags when the creator chooses to surface them: `Intent` (allowlist-validated compose intent — e.g., `quote`, `reply`, `commitment`), `Author` (creator's §9.3 pubkey fingerprint as 64-char lowercase hex), and `Parent-Tx-Id` (parent qub's storage transaction id for reply chains, 43-char base64url). The `Author` tag is **opt-in per qub**: the reference creator app attaches it only when the user explicitly enables public attribution at seal time. When the toggle is off — the default — no `Author` tag is written and the qub is unattributed on the chain: nothing in permanent storage links the upload to a creator's handle, email, or other qubs. When the toggle is on, the `Author` fingerprint resolves to the creator's chosen `@handle` via the §9.5 attestation chain. Reply-chain relationships and `Intent` are non-identifying. The outer wrapper (§13) protects the inner *body* from ciphertext correlation — preventing a harvester from recognising and bulk-decrypting qub-shaped uploads after their drand round publishes. The reference service intentionally does NOT attach `App-Name`, `App-Version`, or `Type` tags: any such single-value filter would return the entire qub corpus to a GraphQL query, which is inconsistent with the wrapper's body-only confidentiality scope. A conforming verifier MUST NOT depend on any storage tag for §11 third-party verification; the body hash / qub_id / signature commit only to the inner CBOR, never to the tag set. ______________________________________________________________________ ## 8. Unlock Protocol The complete unlock sequence. Each step is normative. ```text 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. Authorship Signing ### 9.1 Rationale Qubs are stored in permanent storage. Authorship signatures must remain unforgeable indefinitely, which is why v1.0 uses the post-quantum ML-DSA-65 scheme (FIPS 204) rather than a classical scheme whose security may degrade within the qub’s permanent lifetime. ### 9.2 Algorithm Registry | `sig_alg` | Scheme | Key Size | Signature Size | | --------- | ----------------------- | ----------- | -------------- | | `0x00` | No signature (unsigned) | — | — | | `0x01` | ML-DSA-65 (FIPS 204) | 1,952 bytes | 3,309 bytes | Viewers MUST reject unknown `sig_alg` values. ### 9.3 Signed Preimage Construction ```text 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"` is 17 ASCII bytes: `[0x51, 0x55, 0x42, 0x5F, 0x41, 0x55, 0x54, 0x48, 0x4F, 0x52, 0x5F, 0x53, 0x49, 0x47, 0x5F, 0x56, 0x31]`. No padding. **Trailing byte:** the 91st preimage byte MUST be `0x00`. The reference implementation exposes this as the constant `ORG_ID_PRESENT_INDIVIDUAL = 0x00` in `crates/qub-core/src/signing.rs`; viewers reconstructing `sig_input` for verification MUST emit the same byte. **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`, `outcome_at`, `drand_round`, and `body_hash` via the §4.1 preimage, so any change to those fields 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 | | `outcome_at` | ✓ | Transitively, via `qub_id` preimage | | `drand_round` | ✓ | Transitively, via `qub_id` preimage (V1.2) | | `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_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. (`drand_round` is now bound transitively via the `qub_id` preimage — see above.) | **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. The `author_pubkey` inside the envelope remains the true identity anchor — viewers MUST derive the display identity from `author_pubkey` (via the §9.5 attestation layer) rather than trusting `sender_label`. - A `reply_to` field can likewise be edited post-signing. Because `qub_id` is content-addressed, an attacker can't point `reply_to` at a non-existent target, but they can silently re-parent a reply to a different existing qub. 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 ```text 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 a qub handle, email address, social handle, or passkey credential — are a **viewer-side progressive enhancement** and are **not required** for signature verification. Viewers that resolve attestations to a display identity MUST apply the precedence: ```text handle > email > social > fingerprint ``` The fingerprint fallback is the lowercase hex of `SHA3-256(author_pubkey)`; it is always available for any signed qub. Viewers MAY abbreviate it for display — the reference viewer renders `qub:` followed by the first and last four bytes (`qub:<8 hex>…<8 hex>`). A conforming verifier can complete every check in §9.4 without contacting the qub API, without any network beyond permanent storage and drand, and without any server-side lookup. Attestation resolution 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 | | Storage 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 (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 same `sig_input` as 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:** ```text 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_input` as the author — both parties commit to the same `qub_id`, `body_hash`, and `unlock_at`. - `qub_id` derivation (§4.1) does NOT include cosigner fields. Adding a cosigner to an existing envelope does not change the `qub_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 §11 needs only permanent storage 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** | | Storage cost delta | ~$0.05 | ______________________________________________________________________ ## 10. Markdown Rendering and Sanitisation This section is security-critical. The viewer renders text qubs (`content_type` = `0x01`) using a restricted Markdown subset. ### 10.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 ### 10.2 Forbidden Elements | Element | Handling | | ------------------------------------ | ------------------------------------------------------------------------------------------------ | | Raw HTML (`
`, `