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:
- Het wijzigen van een veld in de QubEnvelope (body, tijdstempels, inhoudstype, versie) produceert een andere qub_id.
- De qub_id wordt berekend vóór versleuteling. Zowel QubEnvelope als SealedQub dragen dezelfde qub_id. De lezer verifieert dat ze overeenkomen na ontsleuteling.
- qub_id is niet afhankelijk van
sender_label,author_signatureofauthor_pubkey. Dit betekent dat dezelfde inhoud, verzegeld op hetzelfde moment, dezelfde qub_id oplevert ongeacht wie er ondertekent. - Het wijzigen van de
titlevan de SealedQub (met alle andere velden vast) verandertqub_idviatitle_hash. Een gateway kan daarom de platte-tekst-titel die op de aftelling wordt getoond niet vervangen zonder de identiteit van de qub ongeldig te maken. - Het wijzigen van de
outcome_atvan de SealedQub (met alle andere velden vast) verandertqub_idvia het preimage. Een gateway kan de pre-onthullings-oordeel-op-datum die op de aftelling wordt getoond niet vervangen zonder de identiteit van de qub ongeldig te maken. - Het wijzigen van
drand_round(met alle andere velden vast) verandertqub_idvia het preimage. Een gateway kan de tijdslot-ciphertext niet aan een andere ronde binden zonder de identiteit van de qub ongeldig te maken; gecombineerd met de stanza-ronde-controle bij ontgrendeling uit §8 is de getoondeunlock_atde ronde die de ontsleuteling daadwerkelijk afschermt.
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:
- Alleen HTTPS. De string MOET beginnen met de bytesequentie
https://. Elk ander schema —http,ftp,javascript,data,file, enz. — wordt geweigerd. - Lengtelimiet. ≤ 2.048 bytes (praktische browser-URL-limiet).
- NFC + controle op vijandige codepunten. Zelfde regel als
titleenreflection— bidi-override- / nulbreedte- / tag-blok- / BOM- / C0- / C1-codepunten worden geweigerd. De definitie komt overeen met de Rustcrate::handle::contains_hostile_text_codepointen de TSworkers/api/src/utils/unicode.ts::isHostileCodepoint(houd ze gelijklopend). - Geen witruimte, geen ASCII-stuurtekens. Witruimte / DEL / bytes onder
0x20waar dan ook in de URL worden geweigerd — sluit de\n/\t-injectievector die de bidi-regel niet dekt. - 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.
- Een partij met schrijftoegang tot de opgeslagen bytes zou
sender_label("Alice" → "Mallory") kunnen vervangen zonder de auteurshandtekening ongeldig te maken. Deauthor_pubkeybinnen de envelope blijft het echte identiteitsanker — lezers MOETEN de weergegeven identiteit afleiden vanauthor_pubkey(via de §9.5-attestatielaag) in plaats van te vertrouwen opsender_label. - Een
reply_to-veld kan eveneens worden bewerkt na ondertekening. Omdatqub_idcontent-geadresseerd is, kan een aanvallerreply_toniet richten op een niet-bestaand doelwit, maar kan deze wel stilletjes een reply herouderen naar een andere bestaande qub.
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:
cosigner_pubkey: ML-DSA-65-publieke sleutel van de medeondertekenaar (Partij B).cosigner_signature: Handtekening over dezelfdesig_inputals die van de auteur (§9.3).
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:
- De medeondertekenaar ondertekent dezelfde
sig_inputals de auteur — beide partijen leggen zich vast op dezelfdequb_id,body_hashenunlock_at. - De
qub_id-afleiding (§4.1) bevat GEEN medeondertekenaarsvelden. Een medeondertekenaar toevoegen aan een bestaande envelope verandert dequb_idniet. - Een pact kan alleen door de auteur ondertekend zijn (eenzijdige toezegging), alleen door de medeondertekenaar (ongebruikelijk), of door beiden (volledige bilaterale bewijs).
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
- Koppen:
#tot en met####(geen#####of######) - Nadruk: vet (
**), cursief (*), doorhalen (~~) - Lijsten: geordend (
1.) en ongeordend (-,*) - Blokcitaten (
>) - Code: inline-spans (```) en omsloten blokken (`````)
- Horizontale lijnen (
---) - Regeleinden (twee afsluitende spaties of een lege regel)
- Alinea's
10.2 Verboden elementen
| Element | Behandeling |
|---|---|
Ruwe HTML (<div>, <script>, enz.) |
Volledig verwijderd. Geen HTML komt erdoor. |
Afbeeldingen () |
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:
- Parse Markdown met
pulldown-cmark(of equivalent). - Loop door de AST en verwijder elke node die niet in de allowlist staat (§10.1).
- Voor link-nodes: zend de URL uit als zichtbare tekst, niet als een aanklikbaar
<a>-element. - 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. - Render vanuit de getypeerde IR naar de doel-viewlaag (bijv. reactieve viewcomponenten, DOM-nodes). Geen HTML-string-concatenatie of
innerHTMLop 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
- Maximale gerenderde kopdiepte:
####(H4).#####en dieper worden gerenderd als vetgedrukte tekst. - Geen limiet op het aantal alinea's (body-groottelimieten in §6 zijn de beperking).
- Omsloten codeblokken: geen syntaxismarkering in MVP. Gerenderd als monospaced preformatted tekst.
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_alg ≥ 0x01 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.
- Lezers MOETEN onbekende hoofdversies weigeren met een duidelijke fout.
- Bekende hoofdversies KUNNEN onbekende optionele velden tolereren als de regels voor voorwaartse compatibiliteit dat toestaan (optionele velden die afwezig zijn in de canonieke sleutelvolgorde worden genegeerd).
- Inhoudstypen (
content_type) en handtekeningschema's (sig_alg) zijn versiegebonden: nieuwe waarden mogen alleen worden geïntroduceerd samen met een nieuwe protocolversie of expliciete registerupdate.
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 |
| — | 0x02–0xFF |
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:
- Standaard immuniteit voor enumeratie. Ingepakte bytes in de permanente opslag zijn byte-onderscheidbaar van willekeurige ciphertext. Een verzamelaarsstrategie van "GraphQL-query voor qub-vormige uploads, bulk-ontsleutel met openbare drand-handtekeningen" eindigt niet met platte tekst.
- Crypto-shredding-privacypositie. qub.social kan zijn eigen corpus letterlijk niet ontsleutelen. Dagvaardingen bereiken ciphertext, geen platte tekst.
- Twee-traps-vertrouwelijkheidsladder. Standaard = link-gecontroleerde toegang (deze sectie). Ontvanger-versleutelde privé-qubs (een gereserveerde fase 2-functie, nog niet gespecificeerd) bouwen daar bovenop als de tweede trap.
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.
versionMOET gelijk zijn aan0x01voor v1.0-wrapper-bytes.qub_idMOET gelijk zijn aan hetqub_id-veld van de SealedQub die wordt hersteld na uitpakken. De uitpakstap dwingt dit niet rechtstreeks af (de AEAD-AAD-binding maakt byte-niveau-manipulatie onmogelijk), maar de ontgrendelingslaag controleert de relatie transitief: als een maker eenSealedQubCborinpakt waarvan de innerqub_idniet overeenkomt met de wrapper-qub_id, faalt §8 stap 11.nonceMOET 96 bits (12 bytes) zijn, vers gegenereerd door een CSPRNG voor elke wrap-bewerking. Het hergebruiken van een nonce onder dezelfde sleutel maakt AEAD-nonce-hergebruik-aanvallen mogelijk die de platte tekst herstellen; producenten MOETEN (key,nonce)-paren behandelen als eenmalig.ciphertextis de AES-256-GCM-uitvoer: ciphertext-bytes samengevoegd met de 16-byte-authenticatietag.ciphertext.len() == SealedQubCbor.len() + 16exact.
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:
- WASM-maker:
getrandom(WebCrypto onder dewasm_js-backend). - Worker-serverside-verzegelingsroute:
crypto.getRandomValues.
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
- Auteurschapsondertekening (§9) is onveranderd: handtekeningen worden berekend binnen de inner
QubEnvelopeen worden hersteld na unwrap → tlock-ontsleuteling → CBOR-parse. - Ontvanger-versleutelde privé-qubs (een gereserveerde fase 2-functie, nog niet gespecificeerd) componeren bovenop deze wrapper als een tweede vertrouwelijkheidstrap; beide trappen kunnen tegelijkertijd actief zijn.
- Pacts (§6, content_type
0x03) worden exact zoals tekst-qubs ingepakt; de wrapper is byte-blind voor het inner inhoudstype.
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:
- Geen immuniteit voor enumeratie. Openbare qubs zien per ontwerp af van de §13.1-eigenschap immuniteit voor enumeratie. De referentie-uploaddienst stempelt er een
Visibility: public-permanente-opslag-tag op (en alleen daarop) zodat ze opzettelijk vindbaar zijn; privé-qubs dragen zo'n tag niet en behouden hun byte-ononderscheidbaarheid. - Platte-tekst-titel zichtbaar op verzegelingstijd. Het §3.2-veld
titleis platte tekst binnenSealedQubCbor. Onder de wrapper is het verborgen totdat een lezerKaanlevert; zonder de wrapper is het voor iedereen leesbaar in de permanente opslag vanaf het moment van uploaden, vóór ontgrendeling. Conforme maker-apps MOETEN dit bekendmaken op verzegelingstijd. - Detectie is structureel. Een conforme lezer/embed onderscheidt de twee vormen door te parsen: bytes die parsen als
OuterWrappervolgen het uitpakpad-met-K; bytes die parsen als een kaleSealedQubCborworden rechtstreeks geaccepteerd. Er is geen wire-vlag vereist, enqub_idbindt zichtbaarheid niet — dezelfde inhoud is byte-identiek op deSealedQub-laag, of hij nu openbaar of privé is verzegeld.
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:
- Rust:
crates/qub-core/tests/wrapper_vectors.rs(cargo test -p qub-core --test wrapper_vectors). - TypeScript:
workers/api/src/crypto/__tests__/wrapper.test.ts(npm test).
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:
- Handtekening: ML-DSA-65 (
sig_alg = 0x01; publieke sleutel van 1.952 bytes, handtekening van 3.309 bytes) en niet ondertekend (sig_alg = 0x00). Het §9.2-register definieert geen andere waarden; een v1-verifier MOET elkesig_algbuiten{0x00, 0x01}weigeren. Een toekomstige Ed25519-vermelding wordt verwacht (§15.3) maar is niet toegewezen in v1. - Timelock: alleen drand quicknet — het chain-hash, de publieke sleutel, de genesis-tijd en de periode zijn vaste netwerkparameters die worden gedragen door de referentie-
DrandTimelockProvider::quicknet()(crates/qub-core/src/tlock.rs) enconfig/drand-endpoints.json. - Buitenste wrapper: alleen AES-256-GCM v1 (§13).
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:
- Een tweede
sig_alg-byte (Ed25519-activering, ML-DSA-87, of een nieuwe vermelding in het §9-register). - Een tweede drand-chain in productiegebruik.
- Een tweede versie van de buitenste wrapper.
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.