qub-protocolspecificatie

qub is een protocol voor cryptografische temporele toezeggingen: een systeem om woorden te verzegelen tot een toekomstige datum en, wanneer die datum aanbreekt, te bewijzen wat er precies werd gezegd en wanneer.

Drie primitieven maken dit mogelijk. drand is een gedecentraliseerd willekeursignaal — de onthullingsdatum wordt afgedwongen door de fysica, niet door de welwillendheid van een betrokken partij. Permanente openbare opslag is een manipulatiebestendige openbare opslag — geen enkele partij kan een qub bewerken of verwijderen zodra deze is verzegeld. ML-DSA-65 is een post-quantum digitale handtekening — elke qub is verbonden aan een sleutelpaar waarvan het geheim het apparaat van de auteur nooit verlaat.

Samen produceren deze primitieven een verklaring die tijdvergrendeld, manipulatiebestendig en toewijsbaar is — een ontvangstbewijs waarvan de waarde groeit naarmate het vermogen van de wereld om het verleden te vervalsen toeneemt.

De rest van dit document is de normatieve specificatie die nodig is voor interoperabele implementaties.


qub-protocolspecificatie

Veld Waarde
Versie 1.0 (protocolversie 0x01, buitenste wrapper-versie 0x01)
Datum 2026-05-01
Status Concept
Beoordeeld tot 2026-05-01

Dit document is de normatieve protocolspecificatie voor het qub-systeem voor temporele toezeggingen. Het definieert datastructuren, serialisatieregels, afleidingsformules en verificatieprocedures die nodig zijn voor interoperabele implementaties.

Reikwijdte: de protocollaag is opzettelijk taalneutraal — de qub-body is ondoorzichtige platte tekst / markdown / pact-bytes, en locale-afhankelijke rendering is de verantwoordelijkheid van de lezer (qub.social-webapp, <qub-embed>-iframe, MCP-clients, enz.).


1. Notatie en conventies

Notatie Betekenis
u8, u64, i64 Niet-getekende / getekende gehele getallen van de aangegeven bitbreedte
[u8; N] Byte-array met vaste lengte van N bytes
Vec<u8> Byte-array met variabele lengte
Option<T> Waarde van type T, of afwezig
String UTF-8-tekenreeks, NFC-genormaliseerd
`
SHA3-256(x) NIST SHA3-256-hash van bytereeks x (FIPS 202)
ceil(x) Afrondingsfunctie naar boven: kleinste geheel getal ≥ x
CBOR Concise Binary Object Representation (RFC 8949)
big-endian Meest significante byte eerst

Alle gehele getallen in preimage-constructies worden gecodeerd als big-endian-byte-arrays met vaste breedte (i64 → 8 bytes, u8 → 1 byte), tenzij anders aangegeven.

Alle tijdstempels zijn Unix-seconden in UTC.


2. Datastructuren

2.1 ComposeQub (in-memory-status van de maker)

Niet geserialiseerd naar CBOR. Niet naar permanente opslag geschreven. Lokaal in de maker-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 qub body
    sender_label:   Option<String>,  // Decorative display name; not authenticated
    status:         DraftStatus,     // Composing | Sealed | Uploaded | Failed
}

2.2 QubEnvelope (ontsleutelde payload)

Geserialiseerd met canonieke CBOR (§3). Versleuteld binnen de SealedQub. Dit is de structuur die de integriteit van de inhoud bewijst na ontsleuteling.

QubEnvelope {
    version:             u8,              // Protocol major version (0x01 for v1)
    qub_id:              [u8; 32],        // Derived (see §4.1)
    content_type:        u8,              // Content type registry (see §6)
    created_at:          i64,             // Unix seconds UTC
    unlock_at:           i64,             // Unix seconds UTC
    outcome_at:          Option<i64>,     // V1.1 — when reality renders judgment (verdict-uplift-plan §3.1)
    sender_label:        Option<String>,  // Decorative; not authenticated in MVP
    reply_to:            Option<[u8; 32]>,// Parent qub_id for reply chains; not in qub_id preimage; not signed (see §9.3)
    body:                Vec<u8>,         // Content payload (UTF-8 for text, CBOR for pact)
    body_hash:           [u8; 32],        // SHA3-256(body) (see §4.2)
    sig_alg:             u8,              // Signature algorithm (see §9.2)
    author_signature:    Option<Vec<u8>>, // Set when sig_alg != 0x00
    author_pubkey:       Option<Vec<u8>>, // Set when sig_alg != 0x00
    cosigner_pubkey:     Option<Vec<u8>>, // Set for cosigned pact bilateral agreements
    cosigner_signature:  Option<Vec<u8>>, // Set for cosigned pact bilateral agreements
}

Basis (onondertekende tekst-qub): version = 0x01, content_type = 0x01, sig_alg = 0x00, alle Option-velden afwezig.

Andere v1-configuraties: content_type = 0x03 (pact-body, zie §6.1); sig_alg = 0x01 (ML-DSA-65) met author_signature en author_pubkey aanwezig (zie §9.3); cosigner_pubkey en cosigner_signature samen aanwezig voor medeondertekende pacts (zie §9.7); reply_to ingesteld op het qub_id van de bovenliggende qub voor qubs in antwoordketens (zie §9.3 voor de implicaties voor het handtekeningbereik).

2.3 SealedQub (canoniek wire-formaat)

Geserialiseerd met canonieke CBOR (§3). Naar permanente opslag geschreven. Dit is het on-chain-artefact.

SealedQub {
    version:           u8,              // Protocol major version (0x01 for v1)
    qub_id:            [u8; 32],        // Same as QubEnvelope.qub_id
    visibility:        u8,              // 0x01 = public; v1 viewers reject other values
    unlock_at:         i64,             // Unix seconds UTC
    outcome_at:        Option<i64>,     // V1.1 — surfaced on the verdict-watch CTA
                                        //   before reveal; mirrors QubEnvelope.outcome_at;
                                        //   bound to qub_id via the §4.1 preimage.
    drand_chain_id:    String,          // drand chain hash (hex string)
    drand_round:       u64,             // Target drand round number
    tlock_ciphertext:  Vec<u8>,         // tlock-encrypted QubEnvelope CBOR bytes
    recipient_pubkey:  Option<[u8; 32]>,// Reserved field; accepted by canonical CBOR
                                        //   but not interpreted by the v1 reference viewer
    title:             Option<String>,  // Plaintext title surfaced on the viewer
                                        //   countdown before reveal. Bound to qub_id
                                        //   via title_hash (§4.1). 1..=100 NFC code
                                        //   points, no control characters.
}

2.4 RevealedQub (toepassingsstatus van de lezer)

Niet geserialiseerd naar CBOR. Lokaal in de lezer-app. Geconstrueerd na geslaagde ontsleuteling en verificatie.

RevealedQub {
    qub_id:              [u8; 32],
    arweave_tx_id:       String,
    visibility:          u8,
    content_type:        u8,
    created_at:          i64,
    unlock_at:           i64,
    outcome_at:          Option<i64>,       // V1.1 — overgenomen uit QubEnvelope.outcome_at / SealedQub.outcome_at; voedt het verdict-watch-blok op de onthullingspagina (verdict-uplift-plan §5.1)
    drand_chain_id:      String,
    drand_round:         u64,
    sender_label:        Option<String>,
    title:               Option<String>,    // Carried forward from SealedQub.title
    reply_to:            Option<[u8; 32]>,
    body:                Vec<u8>,
    body_hash:           [u8; 32],
    body_hash_verified:  bool,
    author_signature:    Option<Vec<u8>>,
    author_pubkey:       Option<Vec<u8>>,
    signature_verified:  Option<bool>,
    cosigner_pubkey:     Option<Vec<u8>>,
    cosigner_signature:  Option<Vec<u8>>,
    cosigner_verified:   Option<bool>,
}

3. Canoniek CBOR-profiel

Alle SealedQub- en QubEnvelope-serialisatie MOET voldoen aan dit profiel. Twee implementaties moeten, gegeven dezelfde logische structuur, identieke bytes produceren.

3.1 Coderingsregels

Regel Specificatie
Standaard RFC 8949 §4.2.1 (Core Deterministic Encoding Requirements)
Volgorde van mapsleutels Gesorteerd op gecodeerde bytelengte eerst (korter vóór langer), daarna lexicografisch (byte-voor-byte voor coderingen van dezelfde lengte)
Gehele-getalcodering Kortste vorm: 0–23 in initiële byte; 24–255 in 2 bytes; 256–65535 in 3 bytes; enz.
Lengtecodering Alleen bepaalde lengten. Geen arrays, maps, byte-strings of tekst-strings met onbepaalde lengte (additional info = 31 is verboden).
Tags Geen CBOR-tags (major type 6 is verboden).
Drijvende komma Geen floats (major type 7 waarden 0xF9–0xFB zijn verboden).
Tekst-strings UTF-8-gecodeerd, NFC-genormaliseerd (Unicode Normalization Form C).
Byte-strings Ruwe bytes. Geen base64-codering op de CBOR-laag.
Dubbele sleutels Weigeren met fout. Parsers MOGEN GEEN dubbele mapsleutels stilzwijgend accepteren.
Eenvoudige waarden Alleen true (0xF5), false (0xF4) en null (0xF6) zijn toegestaan.
Optionele velden Afwezige optionele velden worden volledig uit de CBOR-map weggelaten (niet gecodeerd als null). Aanwezige optionele velden worden opgenomen in gesorteerde sleutelvolgorde.

3.2 Geverifieerde canonieke sleutelvolgordes

Deze sleutelvolgordes zijn normatief. Implementaties MOETEN sleutels in exact deze volgorde uitvoeren. Debug-asserties ZOUDEN de volgorde MOETEN verifiëren in niet-release-builds.

QubEnvelope (versie 0x01, niet ondertekend, alle optionele velden afwezig):

"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)

Afleiding van de QubEnvelope-sleutelvolgorde: elke sleutel is een CBOR-tekst-string. Gecodeerde lengte = 1 byte header + stringlengte (voor strings korter dan 24 bytes). Sorteer eerst op totale gecodeerde lengte, daarna lexicografisch voor sleutels van dezelfde lengte.

SealedQub (versie 0x01, openbaar, geen ontvanger):

"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):

"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 (rij van de 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 Bytecoderingsreferentie

Type CBOR-codering Voorbeeld
SHA3-256-hash (32 bytes) 0x58 0x20 + 32 bytes body_hash, qub_id
Tijdstempels (i64) Major type 0 (positief) of 1 (negatief), kortste codering Unix-seconden
Versie (u8, waarde 1) 0x01 (enkele byte)
Inhoudstype (u8, waarde 1) 0x01 (enkele byte)
sig_alg (u8, waarde 0) 0x00 (enkele byte)
ML-DSA-65-handtekening (3.309 bytes) 0x59 0x0C 0xED + 3.309 bytes author_signature, cosigner_signature
ML-DSA-65-publieke sleutel (1.952 bytes) 0x59 0x07 0xA0 + 1.952 bytes author_pubkey, cosigner_pubkey

4. Normatieve afleidingen

4.1 qub_id

De qub_id identificeert een qub op unieke wijze en koppelt de QubEnvelope aan de SealedQub. Hij wordt deterministisch afgeleid uit de envelope-inhoud.

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

Codering van de domeinseparator: de string "QUB_ID_V2" bestaat uit 9 ASCII-bytes. Eén 0x00-padding-byte wordt toegevoegd om 10 bytes te bereiken voor uitlijning. Implementaties MOETEN exact deze 10 bytes gebruiken: [0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x32, 0x00].

Codering van outcome_at: V1.1 heeft het preimage uitgebreid van 92 naar 100 bytes om het optionele outcome_at-veld in de binding op te nemen. Een afwezige outcome_at wordt gecodeerd als 8 nul-bytes; de protocolvalidators weigeren outcome_at <= 0 overal, zodat deze sentinel niet kan botsen met een legitieme waarde. Zie §3.2 (wire-formaat) en het tasks/verdict-uplift-plan.md-document in de repo voor de oordeel-mechanica die dit veld motiveert.

Codering van drand_round: V1.2 heeft het preimage uitgebreid van 100 naar 108 bytes om drand_round (de doel-drand-ronde, §4.3) in de binding op te nemen, en heeft de domeinseparator verhoogd naar QUB_ID_V2. Dit bindt de tijdslot-ronde aan de identiteit van de qub: een gateway kan de ciphertext niet aan een andere (bijv. al verstreken) ronde binden dan de getoonde unlock_at impliceert. De ontsleutel-procedure (§8) verifieert daarnaast dat de ronde die in de tlock-ciphertext-stanza is ingebakken overeenkomt met unlock_round(unlock_at), zodat de getoonde ontgrendelingstijd aantoonbaar de ronde is die de ontsleuteling afschermt.

Eigenschappen:

4.2 body_hash

body_hash = SHA3-256(body)

Waarbij body de ruwe Vec<u8>-inhoudspayload is. Voor tekst-qubs is dit de UTF-8-gecodeerde qub-body.

4.2.1 title_hash

title_hash = SHA3-256(NFC(title).utf8_bytes)   if title is present
title_hash = [0u8; 32]                         if title is absent

Waarbij title de optionele platte-tekst-titel is die wordt getoond op de aftelling van de lezer vóór onthulling (zie §3.2). NFC-normalisatie wordt op hashtijd uitgevoerd zodat de digest stabiel is voor visueel gelijkwaardige code-puntreeksen. De all-zeros-sentinel is gereserveerd voor het afwezige geval; een lege string wordt geweigerd op de canonieke CBOR-grens als een niet-canonieke codering van "afwezig" (de canonieke codering laat het veld volledig weg).

4.3 Unlock-ronde-mapping

drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds)
Parameter Bron Voorbeeld
unlock_at Door gebruiker gekozen Unix-seconden 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

De ceil()-bewerking selecteert de eerste drand-ronde waarvan de onthullingstijd ≥ unlock_at is. Dit zorgt ervoor dat de qub niet ontsleutelbaar wordt vóór de gekozen ontgrendelingstijd.

Randgeval: als (unlock_at - chain_genesis_time) exact deelbaar is door chain_period_seconds, is het resultaat precies die ronde — de qub wordt ontgrendeld op exact de onthullingstijd van die ronde.

Validatie: unlock_at MOET in de toekomst liggen op het moment van verzegelen. unlock_at MAG NIET meer dan 10 jaar na created_at liggen (om langetermijn-drand-afhankelijkheidsrisico te beperken; de UI ZOU MOETEN waarschuwen voor ontgrendelingsdata buiten 2 jaar).


5. Wire-formaat-newtypes

Wire-formaat-newtypes bieden veiligheid tijdens compilatie tegen het verwarren van CBOR-bytes met JSON, ruwe platte tekst of andere bytecoderingen.

Type Bevat Geproduceerd door Geconsumeerd door
SealedQubCbor Canonieke CBOR van SealedQub serialize_sealed_qub() Permanente-opslag-upload, lezerophalen
QubEnvelopeCbor Canonieke CBOR van QubEnvelope serialize_qub_envelope() tlock-versleutelingsinvoer, tlock-ontsleutelingsuitvoer

5.1 Constructieregels

// 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 Validatie bij constructie

from_encoded() ZOU MOETEN valideren dat de invoer begint met een geldige CBOR-map-header. Volledige structurele validatie gebeurt op parse-tijd, niet op constructietijd, om dubbel parseren te vermijden.


6. Inhoudstype-register

Waarde Type Max body-grootte Opmerkingen
0x00 Gereserveerd (ongeldig) MAG NIET worden gebruikt
0x01 Platte tekst (UTF-8, beperkte Markdown) 50 KB betaald / 10 KB gratis Zie §10 voor renderregels. De splitsing tussen gratis en betaald wordt afgedwongen door de uploadservice; het harde plafond op de protocollaag is 50 KB.
0x02 Gereserveerd (toekomstig) Toegewezen aan een toekomstig inhoudstype; niet geldig in v1. Lezers MOETEN weigeren volgens de regel hieronder.
0x03 Pact (bilaterale overeenkomst, CBOR-body) 100 KB Body is canonieke CBOR PactTerms (§6.1). Medeondertekenaarsondertekening volgens §9.7.
0x04 Verdict (zelfbeoordeling door de maker, CBOR-body) 8 KB Body is canonieke CBOR VerdictBody (§6.2). Wordt alleen uitgezonden door de systeemzijdige verdict-intentie. De relatie met de bovenliggende qub staat op de Arweave-tag Parent-Tx-Id, niet in de body. Zie verdict-uplift-plan §3.4.

Lezers MOETEN onbekende inhoudstypen weigeren met een duidelijke voor de gebruiker zichtbare fout. Lezers MOGEN GEEN poging doen onbekende typen als tekst te renderen.

6.1 Pact-body (content_type = 0x03)

Een pact-body is de canonieke CBOR-codering van een PactTerms-waarde:

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)> }

Canonieke CBOR-sleutelvolgordes voor alle drie de maps staan in §3.2. De totale geserialiseerde pact-CBOR MAG NIET groter zijn dan 100 KB (komt overeen met §6).

Schema-discriminator. De eerste rij in terms voor een structured/v1-pact MOET zijn: { key: "pact_schema", value: "structured/v1" }. Rijen zonder deze marker zijn "custom"-pacts en ontvangen geen gestructureerde validatie of schemabewuste rendering.

Bevroren bevestigingsslots. structured/v1-pacts bevatten precies vier bevestigingsrijen onder deze sleutels:

"initiator_standard_terms"
"initiator_capacity_terms"
"counterparty_standard_terms"
"counterparty_capacity_terms"

De value voor elk is een van acht bevroren Engelse strings, gekozen op basis van het paar (role, kind), waarbij role ∈ { seller, buyer, provider, client } en kind ∈ { standard, capacity }. De strings zelf zijn normatieve protocoldata — de ML-DSA-65-handtekeningen van beide partijen leggen zich vast op de exacte bytes via body_hash. Ze worden NIET gelokaliseerd; de ondertekende body is taalneutraal. Iedere wijziging van de bewoording vereist een nieuwe schemaversie (structured/v2).

De acht strings, hun opzoeking (acknowledgement_for(role, kind)) en de rationale voor elk zijn vastgepind door de referentie-implementatie. Conforme implementaties MOETEN byte-identieke bevestigingswaarden produceren; golden-fixture-SHA3-256-body-hashtests die alle vier de rolcombinaties dekken, vangen elke drift op.

Weergavevolgorde voor lezers. De bevestigingsstrings bevatten zinsneden zoals "described above", die veronderstellen dat de beschrijvings- / scope-rijen vóór de bevestigingen worden gerenderd. Lezers MOETEN de terms-array in CBOR-volgorde renderen; herordening breekt de prozasemantiek.

Contact van de tegenpartij. Wanneer de contact van Partij B een geldig e-mailadres is, verzendt de qub-uploadservice automatisch een review- / medeondertekeningsuitnodigingsmail op stage-tijd en bindt de uiteindelijke medeondertekening aan verificatie van datzelfde adres (§9.7). Pacts waarbij het contact van Partij B afwezig is, kunnen nog steeds worden medeondertekend, maar alleen via een out-of-band-kanaal — de service weigert medeondertekeningsverzoeken die geen overeenkomstige 15-minuten-e-mailverificatiemarker kunnen produceren.

6.2 Verdict-body (content_type = 0x04)

Een verdict-body is de canonieke CBOR-codering van een VerdictBody-waarde:

VerdictBody {
    verdict_version: u8,                  // 0x01 for structured/v1
    outcome:         u8,                  // 1=Right · 2=Partial · 3=Wrong · 4=Unfalsifiable
    reflection:      Option<String>,      // ≤ 2,000 bytes NFC; "what changed, what did you learn"
    evidence_url:    Option<String>,      // ≤ 2,048 bytes; HTTPS only; absent key when omitted
}

Canonieke CBOR-sleutelvolgorde:

"outcome"          (8 encoded bytes)
"reflection"       (11 encoded bytes)  ← only if present
"evidence_url"     (13 encoded bytes)  ← only if present
"verdict_version"  (16 encoded bytes)

De totale geserialiseerde verdict-CBOR MAG NIET groter zijn dan 8 KB (komt overeen met de registerrij hierboven).

Outcome-enum. De wire-byte is intentieneutraal; de vier categorieën Right / Partial / Wrong / Unfalsifiable dekken de uitkomstruimte van elke verdict-dragende intentie. Per-intentielabels ("Goed voorspeld" / "Gehouden" / "Geleverd" / "Bevestigd" voor Right, enz.) zijn een renderzorg aan de lezerskant die wordt opgelost aan de hand van de intentie van de bovenliggende qub — de wire blijft taal- en intentieneutraal. Waarden buiten 1..=4 MOETEN bij decoderen worden geweigerd.

Koppeling met bovenliggende qub. Een verdict-qub draagt de verwijzing naar de bovenliggende qub NIET in zijn body. Het Arweave-transactie-id van de bovenliggende qub wordt bij upload-tijd uitgezonden als de opslagtag Parent-Tx-Id (§7 opslagtaglaag). Dit houdt de body een op zichzelf staande ondertekende verklaring van zelfbeoordeling; de audit-keten ("waarover gelijk?") wordt vastgesteld via de opzoeking op Arweave-tag.

Veiligheid van de bewijslink (normatief). Wanneer evidence_url aanwezig is, MOETEN validators (compose-zijde, wire-zijde, Worker-edge) afdwingen:

  1. Alleen HTTPS. De string MOET beginnen met de bytesequentie https://. Elk ander schema — http, ftp, javascript, data, file, enz. — wordt geweigerd.
  2. Lengtelimiet. ≤ 2.048 bytes (praktische browser-URL-limiet).
  3. NFC + controle op vijandige codepunten. Zelfde regel als title en reflection — bidi-override- / nulbreedte- / tag-blok- / BOM- / C0- / C1-codepunten worden geweigerd. De definitie komt overeen met de Rust crate::handle::contains_hostile_text_codepoint en de TS workers/api/src/utils/unicode.ts::isHostileCodepoint (houd ze gelijklopend).
  4. Geen witruimte, geen ASCII-stuurtekens. Witruimte / DEL / bytes onder 0x20 waar dan ook in de URL worden geweigerd — sluit de \n/\t-injectievector die de bidi-regel niet dekt.
  5. Niet-leeg host-segment. Alles tussen https:// en de eerste /, ? of # MOET niet leeg zijn.

Geen server-side fetch. De Worker MAG de URL NIET proxyen, ophalen of een preview tonen. Het protocol slaat een string op; rendering gebeurt aan de lezerszijde met rel="nofollow noopener noreferrer" target="_blank" en een zichtbare host die naast de linktekst wordt getoond.

Reflectie. Optionele door de maker geschreven reflectietekst ("wat is er veranderd, wat heb je geleerd"). Zelfde NFC + controle op vijandige codepunten als title. Lege invoer / invoer met alleen witruimte klapt bij constructie samen tot afwezig.

Schemaversie. v1 ondersteunt alleen verdict_version = 0x01. Toekomstige schema-revisies verhogen deze byte en landen samen met een nieuwe protocolversie volgens §12.


7. Verzegelingsprotocol

De volledige verzegelingssequentie. Elke stap is normatief.

 1. User composes plaintext and metadata in ComposeQub.
 2. Validate:
    a. body is non-empty.
    b. body size ≤ max for content_type and user tier (see §6).
    c. unlock_at is in the future.
    d. unlock_at ≤ created_at + 10 years.
    e. content_type is a known, supported value.
 3. Compute body_hash = SHA3-256(body).
 4. Set created_at = current Unix seconds UTC.
 5. Select drand chain. Load chain_genesis_time and chain_period_seconds, and
    compute drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds).
    (Computed here, before qub_id, because drand_round is bound into the qub_id
    preimage — §4.1, V1.2.)
 6. Compute qub_id (see §4.1), folding in drand_round from step 5.
 7. Construct QubEnvelope with all fields.
 8. Serialise QubEnvelope using canonical CBOR → bytes B.
    Assert: serialised output matches canonical profile (§3).
 9. Compute C = tlock_encrypt(B, drand_round, drand_chain_public_key).
10. Construct SealedQub with tlock_ciphertext = C, and matching qub_id, version,
    unlock_at, drand_chain_id, drand_round.
12. Serialise SealedQub using canonical CBOR → SealedQubCbor.
12a. Generate K = 32 random bytes (CSPRNG) and N = 12 random bytes (CSPRNG).
     Compute W = wrap_sealed_qub(SealedQubCbor, qub_id=qub_id, key=K, nonce=N)
     per §13. The bytes uploaded to permanent storage are the OuterWrapper CBOR W,
     never the bare SealedQubCbor. K leaves the device only as the URL
     fragment in step 16.
13. Display seal-time disclosure. User confirms.
14. Validate upload eligibility via the qub upload service (bot-detection, entitlement, rate limits).
15. Submit W (the OuterWrapper bytes) to the qub upload service; the service
    signs and uploads to permanent storage. The service is byte-blind to the inner
    SealedQubCbor and never receives K.
16. Receive arweave_tx_id from the service. Construct delivery URL as
    `<origin>/c/<arweave_tx_id>#<base64url(K)>` (or `<origin>/s/<short_code>#<base64url(K)>`
    when a short code is allocated). Browsers do not transmit URL fragments
    to servers, so K is never observed by qub.social or any storage gateway.

Opslagtaglaag (out-of-band). De qub-uploadservice voegt een opzettelijk kleine set opslagtransactietags toe naast de ingepakte payload. Content-Type=application/octet-stream is normatief vereist. De referentie-service voegt daarnaast drie optionele tags toe wanneer de maker ervoor kiest deze zichtbaar te maken: Intent (allowlist-gevalideerde compose-intentie — bijv. quote, reply, commitment), Author (vingerafdruk van de §9.3-publieke sleutel van de maker als 64 tekens kleine letters hex) en Parent-Tx-Id (opslagtransactie-ID van de bovenliggende qub voor antwoordketens, 43 tekens base64url).

De Author-tag is opt-in per qub: de referentie-maker-app voegt deze alleen toe wanneer de gebruiker expliciet publieke toeschrijving inschakelt op het moment van verzegelen. Wanneer de schakelaar uit staat — de standaard — wordt er geen Author-tag geschreven en is de qub niet toegeschreven op de chain: niets in de permanente opslag koppelt de upload aan de handle, e-mail of andere qubs van een maker. Wanneer de schakelaar aan staat, lost de Author-vingerafdruk op naar de door de maker gekozen @handle via de §9.5-attestatieketen. Relaties in antwoordketens en Intent zijn niet-identificerend. De buitenste wrapper (§13) beschermt de inner body tegen ciphertext-correlatie — dit voorkomt dat een verzamelaar qub-vormige uploads herkent en in bulk ontsleutelt nadat hun drand-ronde is gepubliceerd.

De referentie-service voegt opzettelijk GEEN App-Name-, App-Version- of Type-tags toe: zo'n filter met één waarde zou het hele qub-corpus teruggeven bij een GraphQL-query, wat onverenigbaar is met de body-only-vertrouwelijkheidsreikwijdte van de wrapper.

Een conforme verifier MAG NIET afhankelijk zijn van een opslagtag voor §11 derde-partijverificatie; de body-hash / qub_id / handtekening leggen zich alleen vast op de inner CBOR, nooit op de tagset.


8. Ontgrendelingsprotocol

De volledige ontgrendelingssequentie. Elke stap is normatief.

 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. Auteurschapsondertekening

9.1 Rationale

qubs worden in permanente opslag bewaard. Auteurschapshandtekeningen moeten onvervalsbaar blijven voor onbepaalde tijd, en daarom gebruikt v1.0 het post-quantum-schema ML-DSA-65 (FIPS 204) in plaats van een klassiek schema waarvan de beveiliging binnen de permanente levensduur van de qub kan afnemen.

9.2 Algoritmeregister

sig_alg Schema Sleutelgrootte Handtekeninggrootte
0x00 Geen handtekening (niet ondertekend)
0x01 ML-DSA-65 (FIPS 204) 1.952 bytes 3.309 bytes

Lezers MOETEN onbekende sig_alg-waarden weigeren.

9.3 Constructie van het ondertekende preimage

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)

Domeinseparator: "QUB_AUTHOR_SIG_V1" bestaat uit 17 ASCII-bytes: [0x51, 0x55, 0x42, 0x5F, 0x41, 0x55, 0x54, 0x48, 0x4F, 0x52, 0x5F, 0x53, 0x49, 0x47, 0x5F, 0x56, 0x31]. Geen padding.

Afsluitende byte: de 91e preimage-byte MOET 0x00 zijn. De referentie-implementatie stelt deze beschikbaar als de constante ORG_ID_PRESENT_INDIVIDUAL = 0x00 in crates/qub-core/src/signing.rs; lezers die sig_input reconstrueren voor verificatie MOETEN dezelfde byte uitzenden.

Handtekeningreikwijdte — wat wel en niet wordt gedekt. sig_input legt zich vast op vier envelope-velden: version, qub_id, body_hash, unlock_at (plus de vaste domeinseparator en org_id_present-byte). Drie van die vier zijn structurele invarianten: qub_id wordt zelf afgeleid uit version, content_type, created_at, unlock_at en body_hash via het §4.1-preimage, dus elke wijziging van content_type of created_at produceert een andere qub_id en maakt de handtekening transitief ongeldig. Het direct geauthenticeerde oppervlak is daarom:

Veld Geauthenticeerd door handtekening Hoe
version Directe input voor sig_input
qub_id Directe input
body_hash Directe input
unlock_at Directe input
content_type Transitief, via qub_id-preimage
created_at Transitief, via qub_id-preimage
outcome_at Transitief, via qub_id-preimage
drand_round Transitief, via qub_id-preimage (V1.2)
body Transitief, via body_hash = SHA3-256(body)
author_pubkey — (impliciet) Sleutel die de handtekening heeft geverifieerd is per definitie de auteur
sender_label Alleen-weergave-tekst; muteerbaar zonder handtekeningbreuk
reply_to Threading-pointer; muteerbaar zonder handtekeningbreuk
cosigner_pubkey / cosigner_signature Onafhankelijk ondertekend over dezelfde sig_input (zie §9.7)
drand_round, drand_chain_id, tlock_ciphertext, visibility Buitenste SealedQub-velden, niet binnen de envelope — gedekt door hun eigen structurele invarianten (ronde- / chain-consistentie) maar niet door de auteurshandtekening

Beveiligingsimplicaties van niet-geauthenticeerde velden.

Implementaties die sender_label of reply_to aan eindgebruikers tonen MOETEN de geauthenticeerde identiteit (pubkey-vingerafdruk, attestatie) als het primaire identiteitssignaal naar voren brengen, niet het label.

9.4 Verificatieprocedure

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."

Handtekeningverificatie is de duurste bewerking (vooral ML-DSA-65). Hij ZOU MOETEN worden uitgevoerd nadat alle goedkopere checks (hash, qub_id, unlock_at) zijn geslaagd.

9.5 Identiteitsattestaties

Identiteitsattestaties — de afbeelding van author_pubkey op door mensen herkenbare identiteitsclaims zoals een qub-handle, e-mailadres, socialmedia-handle of passkey-credential — zijn een progressieve verbetering aan de kant van de lezer en zijn niet vereist voor handtekeningverificatie. Lezers die attestaties oplossen naar een weergegeven identiteit MOETEN de volgende voorrang toepassen:

handle > email > social > fingerprint

De vingerafdruk-fallback is de hex in kleine letters van SHA3-256(author_pubkey); deze is altijd beschikbaar voor elke ondertekende qub. Lezers MOGEN deze afkorten voor weergave — de referentielezer rendert qub: gevolgd door de eerste en laatste vier bytes (qub:<8 hex>…<8 hex>).

Een conforme verifier kan elke controle in §9.4 voltooien zonder contact op te nemen met de qub-API, zonder enig netwerk buiten de permanente opslag en drand, en zonder enige serverside-lookup. Attestatieoplossing is een aparte best-effort-stap die alleen wordt uitgevoerd nadat handtekeningverificatie is geslaagd.

9.6 Grootte-impact

Ed25519 ML-DSA-65
Handtekening 64 bytes 3.309 bytes
Publieke sleutel 32 bytes 1.952 bytes
Totaal per qub 96 bytes 5.261 bytes
Opslagkostenverschil (bij ~$5/MB) ~$0,0005 ~$0,026

Voor een tekst-qub van 500–2.000 bytes verdrievoudigt ML-DSA-65 ruwweg de opgeslagen grootte. De absolute kosten zijn verwaarloosbaar.

9.7 Medeondertekenaarsverificatie (pact-bilaterale overeenkomsten)

Voor bilaterale overeenkomsten (content_type = 0x03) bewijst een tweede handtekeninglaag dat beide partijen hebben ingestemd met dezelfde voorwaarden.

Envelope-velden:

Beide velden MOETEN samen aanwezig zijn of beide afwezig. Als er precies één aanwezig is, MOETEN lezers een integriteitsfout melden.

Verificatieprocedure:

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."

Eigenschappen:

E-mailbindingspoort (operationeel). Wanneer een stage-pact een e-mailcontact van Partij B bevat (§6.1), MOET de qub-uploadservice het medeondertekeningsverzoek weigeren tenzij er een kortlevende e-mailverificatiemarker bestaat die overeenkomt met zowel het staging-id als de hash van het genormaliseerde e-mailadres van dat contact. De marker wordt geschreven door /api/v1/auth/verify wanneer het magische-link-token een staging_id draagt en het geverifieerde adres overeenkomt met SHA-256(normalise_email(party_b.contact)) — waarbij normalise_email(addr) de hoofdletter-gevoeligheid van het lokale deel behoudt en alleen het domeingedeelte naar kleine letters omzet (volgens RFC 5321 §2.3.11), en SHA-256 hier de NIST FIPS 180-4-hash is (onderscheiden van de SHA3-256 die wordt gebruikt in de §4-afleidingen) — en verloopt 900 seconden (15 minuten) na uitgifte. Dit is een operationele anti-impersonatiepoort, GEEN onderdeel van het on-chain qub-bewijs — een derde-partij-verifier die §11 herhaalt heeft alleen de permanente opslag en drand nodig, zonder enige serverside-lookup. De marker bestaat alleen aan de server-zijde en is nooit onderdeel van de ondertekende body.

Grootte-impact (ML-DSA-65 auteur + medeondertekenaar):

Component Grootte
Auteurshandtekening 3.309 bytes
Publieke sleutel auteur 1.952 bytes
Medeondertekenaarshandtekening 3.309 bytes
Publieke sleutel medeondertekenaar 1.952 bytes
Totale crypto-overhead 10.522 bytes
Opslagkostenverschil ~$0,05

10. Markdown-rendering en -sanering

Deze sectie is beveiligingskritiek. De lezer rendert tekst-qubs (content_type = 0x01) met een beperkte Markdown-subset.

10.1 Toegestane elementen

10.2 Verboden elementen

Element Behandeling
Ruwe HTML (<div>, <script>, enz.) Volledig verwijderd. Geen HTML komt erdoor.
Afbeeldingen (![alt](url)) Verwijderd. Afbeeldingssyntaxis wordt uit de uitvoer verwijderd.
Links ([text](url)) URL wordt gerenderd als zichtbare platte tekst. Niet automatisch gelinkt. Niet aanklikbaar zonder expliciete gebruikersactie.
Gevaarlijke URL-schema's javascript:, data:, vbscript:, file: — verwijderd.
Iframes, embeds, objects Verwijderd.
HTML-entiteiten Gedecodeerd naar weergavetekens alleen als veilig.

10.3 Implementatie

Implementaties MOETEN een strikte allowlist-parser gebruiken, geen blocklist. De aanbevolen aanpak:

  1. Parse Markdown met pulldown-cmark (of equivalent).
  2. Loop door de AST en verwijder elke node die niet in de allowlist staat (§10.1).
  3. Voor link-nodes: zend de URL uit als zichtbare tekst, niet als een aanklikbaar <a>-element.
  4. Converteer de gefilterde AST naar een getypeerde tussenrepresentatie (bijv. een MarkdownNode-enum met alleen veilige varianten). Ruwe HTML is structureel niet representeerbaar in deze IR.
  5. Render vanuit de getypeerde IR naar de doel-viewlaag (bijv. reactieve viewcomponenten, DOM-nodes). Geen HTML-string-concatenatie of innerHTML op enig moment.

Blocklist-aanpakken zijn broos omdat nieuwe Markdown-extensies of parserkwaliteiten ongefilterde elementen kunnen introduceren. De getypeerde-AST-aanpak maakt XSS structureel onmogelijk — er is geen variant die willekeurige HTML kan dragen.

10.4 Grootte- en structuurlimieten


11. Verificatie door derden

Elke derde kan een openbare qub verifiëren zonder medewerking van qub. De verificatieprocedure:

1. Obtain arweave_tx_id (from delivery URL or direct knowledge).
2. Fetch SealedQubCbor from any storage gateway.
3. Confirm storage block inclusion (block height, block timestamp).
4. Parse SealedQubCbor → SealedQub.
5. Fetch drand round signature for SealedQub.drand_round.
6. tlock_decrypt(tlock_ciphertext, round_signature) → QubEnvelope CBOR bytes.
7. Parse → QubEnvelope.
8. Verify SHA3-256(body) == body_hash.
9. Verify QubEnvelope.qub_id == SealedQub.qub_id.
10. Verify QubEnvelope.unlock_at == SealedQub.unlock_at.
11. If sig_alg != 0x00: verify author_signature (see §9.4).
12. All checks pass → qub is verified.

Wat verificatie bewijst:

Bewijs Wat het vaststelt
Toezegging De ciphertext bestond op het opslagbloktijdstempel.
Integriteit De platte-tekst-body komt overeen met de vastgelegde hash en is niet gewijzigd.
Timing De inhoud was onleesbaar tot de drand-ronde, die overeenkomt met de gekozen ontgrendelingstijd (onder voorbehoud van de veiligheidsaannames van tlock en drand).

Wat verificatie NIET bewijst:

Niet-bewijs Waarom
Auteurschap Het sender_label is decoratief. Zonder sig_alg0x01 had iedereen deze inhoud kunnen verzegelen.
Intentie De qub bewijst inhoud en timing, niet wat de maker subjectief bedoelde.
Pre-event timing Opslagblokopname kan een paar minuten achterlopen op de daadwerkelijke upload. Het toezeggingstijdstempel is de bloktijd, niet het moment waarop de gebruiker op "verzegelen" drukte.

12. Versionering

12.1 Protocolversie

Het veld version (u8) in zowel SealedQub als QubEnvelope identificeert de hoofdprotocolversie.

12.2 Versiegeschiedenis

Versie Waarde Beschrijving
v1 0x01 Openbare tekst-qubs (content_type 0x01), pact-bilaterale overeenkomsten (0x03, schema structured/v1, ML-DSA-65 auteur + medeondertekenaar), tlock, SHA3-256

12.3 Voorwaartse compatibiliteit

Een v1-lezer die een QubEnvelope tegenkomt met onbekende optionele CBOR-mapsleutels (sleutels die niet in de canonieke volgorde van §3.2 staan) ZOU die sleutels MOETEN negeren en doorgaan met verificatie via de bekende velden. Dit maakt toekomstige kleine toevoegingen (bijv. nieuwe metadata) mogelijk zonder een hoofdversiebump te vereisen.

Een v1-lezer die sig_alg = 0x01 (ML-DSA-65) tegenkomt maar geen ML-DSA-65-verificatieondersteuning heeft, ZOU de qub-inhoud MOETEN weergeven met de mededeling "handtekening aanwezig maar niet verifieerbaar", niet de qub volledig weigeren. De huidige referentie-implementatie weigert elke sig_alg-waarde anders dan 0x00 en 0x01 omdat het v1-register geen ander geldig algoritme bevat — strikte weigering en zacht-falen zijn observationeel identiek totdat een derde algoritme wordt geregistreerd. Het hierboven beschreven zacht-falen-gedrag wordt belangrijk zodra §9.2 een nieuwe vermelding toelaat, en de referentie-lezer zal op dat moment worden bijgewerkt om zacht te falen.

12.4 Versie van de buitenste wrapper

De OuterWrapper beschreven in §13 draagt een eigen version-byte, onafhankelijk van SealedQub.version en QubEnvelope.version. De twee versieruimtes evolueren afzonderlijk: een toekomstige post-quantum-veilige symmetrische vervanging verhoogt de wrapper-byte zonder de inner protocolversie aan te raken, en een toekomstige toevoeging op de protocollaag (bijv. een nieuw envelope-veld) verhoogt de inner versie zonder de wrapper-byte aan te raken.

OUTER_WRAPPER_VERSION_* Waarde Algoritme Status
OUTER_WRAPPER_VERSION_1 0x01 AES-256-GCM met 12-byte nonce, 16-byte authenticatietag, AAD gebonden aan qub_id v1-standaard
0x020xFF Gereserveerd Toekomst

Lezers MOETEN onbekende wrapper-versies weigeren met een duidelijke fout. Het protocol houdt de wrapper-versieruimte opzettelijk smal totdat een concrete migratie-aanleiding verschijnt (bijv. NIST-richtlijnen die de voorkeur geven aan een andere AEAD); een 0x02-slot wordt toegewezen in dezelfde revisie die het algoritme introduceert.


13. Buitenste versleutelingswrapper

13.1 Rationale

De protocollagen (QubEnvelope → tlock → SealedQub) maken een verzegelde qub tijdvergrendeld: de body is onleesbaar totdat unlock_at en de drand-rondehandtekening zijn gepubliceerd. Na ontgrendeling is de rondehandtekening echter openbaar en is de canonieke CBOR-vorm van SealedQub herkenbaar, zodat een verzamelaar die permanente-opslag-transacties heeft geïndexeerd het hele qub-corpus in bulk zou kunnen ontsleutelen.

De buitenste versleutelingswrapper sluit dat kanaal door een aanvullende symmetrische AEAD-laag tussen te plaatsen tussen de canonieke SealedQubCbor en de bytes die naar de permanente opslag worden geüpload. De 256-bit-sleutel K leeft alleen in het URL-fragment van de bezorglink en op gebruikersapparaten; browsers verzenden URL-fragmenten niet naar servers, dus qub.social, elke opslaggateway en elke CDN ervoor zijn observationeel blind voor K. Elke qub in de permanente opslag is daarom een ondoorzichtige ciphertext waarvan de platte tekst niet kan worden hersteld zonder de URL die de maker ervoor heeft gekozen te delen.

Netto effect:

13.2 Lagen

plaintext body                       ← QubEnvelope.body (§2.2)
  ↓ canonical CBOR (§3)
envelope CBOR
  ↓ tlock encrypt to drand round (§7 step 10)
tlock_ciphertext (inside SealedQub) (§2.3)
  ↓ canonical CBOR (§3)
SealedQubCbor bytes                  ← inner wire artifact
  ↓ AES-256-GCM(K, nonce, AAD=qub_id) (§7 step 12a, this section)
OuterWrapper CBOR bytes              ← uploaded to permanent storage (§7 step 15)

Verzegelen en ontgrendelen op de protocollaag (§7, §8) zijn onveranderd onder de wrappergrens; de wrapper wordt aangebracht op de aanroepplaats van seal() en losgemaakt op de aanroepplaats van unlock().

13.3 OuterWrapper-datastructuur

struct OuterWrapper {
    version:    u8,           // 0x01, see §12.4
    qub_id:     [u8; 32],     // copied from inner SealedQub; AEAD AAD
    nonce:      [u8; 12],     // 96-bit AEAD nonce
    ciphertext: Vec<u8>,      // AES-256-GCM(K, nonce, SealedQubCbor, AAD=qub_id) || 16-byte tag
}

Veldinvarianten.

CBOR-codering. Canonieke CBOR volgens §3, met dezelfde sleutelvolgorderegel (gesorteerd op gecodeerde bytelengte oplopend, daarna lexicografisch). De vier sleutels zijn:

Sleutel Gecodeerde bytes Volgorde
nonce 6 1
qub_id 7 2
version 8 3
ciphertext 11 4

De eerste byte van de OuterWrapper-CBOR is daarom de map-header met bepaalde lengte voor een map met 4 elementen (0xA4).

13.4 AAD-binding aan qub_id

De wrapper bindt qub_id als AEAD-aanvullende geauthenticeerde data. Dit is de dragende structurele verdediging tegen drie klassen aanvallen:

Aanval Verdediging
Ciphertext verplaatsen onder een ander qub_id-veld in de wrapper AAD-mismatch → AEAD-authenticatie faalt
Het URL-fragment van qub A mengen met de permanente-opslag-bytes van qub B AAD-mismatch → AEAD-authenticatie faalt
Knoeien met het qub_id-veld van de wrapper na upload AAD-mismatch → AEAD-authenticatie faalt

Het meedragen van qub_id in de platte tekst van de wrapper verzwakt de enumeratie-immuniteit niet substantieel — qub_id is zelf een SHA3-256-hash van het §4.1-preimage zonder herstelbare preimage uit de digest, en een enumerator die de wrapper-bytes al heeft verzameld leert niets uit de zichtbare qub_id dat hij niet kon afleiden uit het bestaan van de upload zelf.

13.5 Wrap- en unwrap-algoritmen

wrap_sealed_qub(SealedQubCbor S, qub_id Q, key K, nonce N):
    require K.len() == 32 and N.len() == 12 and Q.len() == 32
    C := AES_256_GCM_encrypt(key=K, nonce=N, msg=S, aad=Q)
    // C includes the 16-byte authentication tag at the end
    return canonical_cbor_encode(OuterWrapper{
        version:    0x01,
        qub_id:     Q,
        nonce:      N,
        ciphertext: C,
    })

unwrap_sealed_qub(OuterWrapper bytes W, key K):
    require K.len() == 32
    O := canonical_cbor_decode(W) as OuterWrapper
    require O.version == 0x01           // §12.4
    P := AES_256_GCM_decrypt(
            key=K, nonce=O.nonce, ciphertext=O.ciphertext, aad=O.qub_id
         )
    // any AEAD failure → DECRYPT_FAILED, indistinguishable to caller
    return P                            // P is the inner SealedQubCbor

Inklappen van faalmodi. Foute K, foute nonce, AAD-mismatch en gemanipuleerde ciphertext produceren allemaal dezelfde DECRYPT_FAILED-fout. Dit is een opzettelijke AEAD-eigenschap: het onderscheiden van de faalmodus zou een zijkanaal creëren dat een externe aanvaller kan onderzoeken door misvormde wrappers te sturen en de respons te timen. Referentie-implementaties MOETEN alle AEAD-fouten samenvouwen tot één enkele foutvorm.

13.6 Sleutelmateriaal en distributie

De wrapping-sleutel K is een 256-bit-uniforme willekeurige waarde die per qub wordt gegenereerd door een CSPRNG. De referentie-implementaties betrekken deze uit:

Distributie: K MOET worden gecodeerd als URL-veilige base64 (RFC 4648 §5, geen padding) en aan de bezorglink worden toegevoegd als het fragmentcomponent:

delivery_url = <origin>/c/<arweave_tx_id>#<base64url(K)>

Het fragment wordt nooit verzonden naar enige server door een conforme browser. Herstelkanalen (serverside-geschiedenisindex, opt-in e-mail-automatisch-verzenden) die de volledige bezorglink — inclusief het fragment — bewaren buiten het apparaat van de gebruiker, vormen een expliciete afweging tegen de standaard crypto-shredding-positie en MOETEN worden afhankelijk gemaakt van expliciete toestemming van de gebruiker.

Fragmentverlies. Als een gebruiker het URL-fragment verliest en geen herstelkanaal heeft, is de qub onleesbaar. Dit is de dragende afweging van het ontwerp en MOET worden bekendgemaakt aan de gebruiker op het moment van verzegelen. De MVP versterkt de mededeling op verzegelingstijd met expliciete tekst "bewaar deze URL" en een geverifieerd-e-mail-herstelkanaal voor gebruikers die zich aanmelden.

13.7 Buiten de reikwijdte van deze sectie

13.8 Openbare qubs (weglaten van de wrapper)

De buitenste wrapper is optioneel op de bezorglaag. Een maker mag een qub als openbaar verzegelen, in welk geval de canonieke SealedQubCbor rechtstreeks naar de permanente opslag wordt geschreven, zonder OuterWrapper-laag en zonder sleutel K:

SealedQubCbor bytes  ──(public)──▶  uploaded to permanent storage as-is
SealedQubCbor bytes  ──(private)─▶  AES-256-GCM(K, …) ▶ OuterWrapper ▶ uploaded

Een openbare qub is tijdvergrendeld maar niet link-gecontroleerd: hij blijft onleesbaar totdat zijn drand-ronde wordt gepubliceerd (de tlock-laag is onveranderd), maar na ontgrendeling kan iedereen die de arweave_tx_id heeft hem ontsleutelen — er is geen URL-fragment vereist, omdat er geen K is. Dit is de doelbewuste afweging voor oppervlakken die de server moet aandrijven: onthullingsmeldingsmails, embeds van derden en rijkere SEO na onthulling hebben allemaal een link nodig die werkt zonder een geheim dat de server nooit bezit (§13.6).

Gevolgen waar een producent rekening mee MOET houden:

Privé (ingepakt) blijft de standaard; openbaar is een expliciete keuze van de maker per qub.


14. Testvectoren

14.1 qub_id-afleiding

Input:
  version      = 0x01
  content_type = 0x01
  created_at   = 1735689600 (2025-01-01 00:00:00 UTC)
  unlock_at    = 1736294400 (2025-01-08 00:00:00 UTC)
  outcome_at   = absent
  drand_round  = 4695445  (= (1736294400 - 1595431050) / 30, drand mainnet params §14.2)
  body         = "Hello, future."  (UTF-8, 14 bytes)
  title        = absent

Intermediate:
  body_hash  = SHA3-256("Hello, future.")
             = 76ab8b3f843c6ed4f2d0fd75b9f457b4
               ad49dd4450f9c22723ae430e3af3211d
  title_hash = [0u8; 32]   (title absent — §4.2.1 sentinel)

Domain separator (10 bytes):
  [0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x32, 0x00]

Preimage (108 bytes — V1.2):
  domain_separator    ||  // 10 bytes
  0x01                ||  // version
  0x01                ||  // content_type
  0x0000000067748580  ||  // created_at as i64 big-endian (1735689600)
  0x00000000677DC000  ||  // unlock_at as i64 big-endian (1736294400)
  0x0000000000000000  ||  // outcome_at_or_zero (outcome_at absent)
  0x000000000047A595  ||  // drand_round as u64 big-endian (4695445)
  body_hash           ||  // 32 bytes
  title_hash              // 32 bytes (all-zeros sentinel; title absent)

Expected output:
  qub_id = SHA3-256(preimage)
         = 3a9fcb31b750d985c262fada6d4f777f
           d6a28be831d941d85c131f5a4bbaf8a4

Implementaties MOETEN identieke body_hash- en qub_id-waarden produceren voor deze invoer. Deze testvector ZOU de eerste unit test MOETEN zijn die wordt geschreven. De canonieke waarden hierboven werden berekend door de referentie-implementatie en MOETEN bit-voor-bit overeenkomen. Historische preimage-indelingen (vóór de lancering — geen live qubs hingen hiervan af): de 92-byte-V1.0-qub_id was 3d9fc2390eab043d38a1669ed3b71be76f9eefe872b9569ab1aaa027b88392b0; de 100-byte-V1.1-qub_id (na het invouwen van outcome_at_or_zero) was b0d032898ad629795150fdcb3f84e518f59ed05b7a2a82bc24ebdb87f52144ed. V1.2 vouwt drand_round erin en verhoogt de domeinseparator naar QUB_ID_V2.

14.2 Unlock-ronde-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 Canonieke CBOR-rondreis

Implementaties MOETEN verifiëren dat serialize(parse(serialize(qub))) == serialize(qub) voor alle geldige invoeren. Dit is een eigenschapstest, geen enkele 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)

De canonieke CBOR-bytes en SHA3-256 body_hash worden berekend door de referentie-implementatie. Implementaties MOETEN byte-identieke CBOR produceren voor deze invoer.

Implementaties MOETEN ook verifiëren dat serialize(parse(serialize(pact))) == serialize(pact) voor alle geldige PactTerms-invoeren (eigenschapstest).

14.5 Cross-language-vectoren voor de buitenste wrapper

De buitenste wrapper (§13) heeft een aparte canonieke fixture op crates/qub-core/tests/vectors/wrapper_v1.json. Elk geval legt een (key, nonce, qub_id, sealed_cbor)-tupel vast als ondoorzichtige hex-invoer en stelt een specifieke expected_wrapper_hex-uitvoer vast. Beide referentie-implementaties consumeren hetzelfde JSON-bestand:

De fixture pint momenteel drie gevallen vast:

Geval Dekking
basic-text-public Kleinste realistische SealedQub-vorm; geen optionele velden. Stelt de canonieke wrapper-vorm vast voor een v1.0-typische qub.
with-recipient-pubkey SealedQub met recipient_pubkey ingesteld (fase 2-pad). Andere inner CBOR-sleutelset, andere qub_id.
longer-body ~4 KiB body — oefent multi-byte-CBOR-lengteprefixen uit binnen zowel de inner envelope als de buitenste ciphertext.

Implementaties MOETEN byte-identieke expected_wrapper_hex produceren voor de vastgelegde invoeren. Het regenereren van de fixture vereist QUB_REGEN_VECTORS=1 cargo test -p qub-core --test wrapper_vectors en is voorbehouden aan opzettelijke formaatwijzigingen.


15. Governance van het cryptografieprofiel (toekomst)

Deze sectie is informatief voor v1 en wordt normatief zodra voor het eerst een tweede algoritme in een van de cryptografische primitieven van qub wordt opgenomen.

15.1 Huidige houding

Protocol v1 bindt precies één algoritme per primitief:

Verifiers coderen momenteel sleutel- en handtekeninglengtes per primitief hard. Het wire-formaat legt geen wendbaarheidsoppervlak bloot.

15.2 Beoogde vorm

Wanneer een tweede algoritme in het protocol wordt opgenomen, wordt de verifier geconfigureerd voor een benoemd CryptoProfile (bijv. ExqubV1) dat de exacte set van toegestane waarden per primitief opsomt — sig_algs, drand-chains, wrapper-versies, inhoudstypen. Het profiel wordt vastgesteld op het verificatiemoment, nooit in-band onderhandeld. Elke waarde buiten het actieve profiel wordt geweigerd.

Dit garandeert dat het toevoegen van ML-DSA-87 of het activeren van Ed25519 bestaande verifier-configuraties niet met terugwerkende kracht kan verzwakken: een v1-verifier blijft een v1-verifier, zelfs nadat een v2-profiel is gepubliceerd.

15.3 Triggervoorwaarden

Promoveer §15 naar normatieve status zodra een van de volgende zaken wordt voorgesteld:

Tot dan is §15 een placeholder die de migratievorm vastlegt zodat toekomstige PR's tegen een bekend doel landen in plaats van het onderhandelingsoppervlak vanaf nul opnieuw te bediscussiëren.