Spécification du protocole qub
qub est un protocole d’engagement temporel cryptographique : un système pour sceller des mots à une date future et prouver, le moment venu, exactement ce qui a été dit et quand.
Trois primitives le rendent possible. drand est une balise de hasard décentralisée — la date de dévoilement est imposée par la physique, pas par la bonne volonté d’une partie. Le stockage public permanent est un magasin public inviolable — aucune partie ne peut modifier ou supprimer un qub une fois scellé. ML-DSA-65 est une signature numérique post-quantique — chaque qub est rattaché à une paire de clés dont le secret ne quitte jamais l’appareil de l’auteur.
Ensemble, ces primitives produisent une déclaration verrouillée dans le temps, infalsifiable et attribuable — un reçu dont la valeur croît à mesure que la capacité du monde à fabriquer le passé s’améliore.
La suite de ce document est la spécification normative requise pour des implémentations interopérables.
Spécification du protocole qub
| Champ | Valeur |
|---|---|
| Version | 1.0 (version du protocole 0x01, version de l’enveloppe externe 0x01) |
| Date | 2026-05-01 |
| Statut | Brouillon |
| Vérifié jusqu’à | 2026-05-01 |
Ce document est la spécification normative du protocole pour le système d’engagement temporel qub. Il définit les structures de données, les règles de sérialisation, les formules de dérivation et les procédures de vérification requises pour des implémentations interopérables.
Portée : la couche protocole est intentionnellement neutre vis-à-vis de la langue — le corps d’un qub est un texte/markdown/octets de pacte opaque, et le rendu localisé relève du lecteur (application web qub.social, iframe <qub-embed>, clients MCP, etc.).
1. Notation et conventions
| Notation | Signification |
|---|---|
u8, u64, i64 |
Entiers non signés/signés de la largeur indiquée |
[u8; N] |
Tableau d’octets de longueur fixe N |
Vec<u8> |
Tableau d’octets de longueur variable |
Option<T> |
Valeur de type T, ou absente |
String |
Chaîne UTF-8, normalisée NFC |
| ` | |
SHA3-256(x) |
Hachage NIST SHA3-256 de la chaîne d’octets x (FIPS 202) |
ceil(x) |
Fonction plafond : plus petit entier ≥ x |
| CBOR | Concise Binary Object Representation (RFC 8949) |
| big-endian | Octet de poids fort en premier |
Tous les entiers dans les pré-images sont encodés en tableaux d’octets big-endian de largeur fixe (i64 → 8 octets, u8 → 1 octet) sauf indication contraire.
Tous les horodatages sont en secondes Unix UTC.
2. Structures de données
2.1 ComposeQub (état créateur en mémoire)
Non sérialisé en CBOR. Non écrit sur le stockage permanent. Local à l’application créateur.
ComposeQub {
draft_id: [u8; 16], // Aléatoire, généré localement
created_at: i64, // Secondes Unix UTC
unlock_at: Option<i64>, // Secondes Unix UTC ; None pendant la rédaction
visibility: u8, // 0x01 = public (seule valeur en MVP)
content_type: u8, // 0x01 = texte (seule valeur en MVP)
plaintext: Vec<u8>, // Corps du qub UTF-8
sender_label: Option<String>, // Nom décoratif ; non authentifié
status: DraftStatus, // Composing | Sealed | Uploaded | Failed
}
2.2 QubEnvelope (charge utile déchiffrée)
Sérialisé en CBOR canonique (§3). Chiffré dans le SealedQub. C’est la structure qui prouve l’intégrité du contenu après déchiffrement.
QubEnvelope {
version: u8, // Version majeure du protocole (0x01 pour v1)
qub_id: [u8; 32], // Dérivé (voir §4.1)
content_type: u8, // Registre des types de contenu (voir §6)
created_at: i64, // Secondes Unix UTC
unlock_at: i64, // Secondes Unix UTC
outcome_at: Option<i64>, // V1.1 — quand la réalité rend son jugement (verdict-uplift-plan §3.1)
sender_label: Option<String>, // Décoratif ; non authentifié en MVP
reply_to: Option<[u8; 32]>,// qub_id parent pour les fils ; pas dans la pré-image qub_id ; non signé (voir §9.3)
body: Vec<u8>, // Charge utile (UTF-8 pour le texte, CBOR pour le pacte)
body_hash: [u8; 32], // SHA3-256(body) (voir §4.2)
sig_alg: u8, // Algorithme de signature (voir §9.2)
author_signature: Option<Vec<u8>>, // Présent quand sig_alg != 0x00
author_pubkey: Option<Vec<u8>>, // Présent quand sig_alg != 0x00
cosigner_pubkey: Option<Vec<u8>>, // Présent pour les pactes contresignés
cosigner_signature: Option<Vec<u8>>, // Présent pour les pactes contresignés
}
Configuration de référence (qub texte non signé) : version = 0x01, content_type = 0x01, sig_alg = 0x00, tous les champs Option absents.
Autres configurations v1 : content_type = 0x03 (corps de pacte, voir §6.1) ; sig_alg = 0x01 (ML-DSA-65) avec author_signature et author_pubkey présents (voir §9.3) ; cosigner_pubkey et cosigner_signature présents ensemble pour les pactes contresignés (voir §9.7) ; reply_to défini sur le qub_id du qub parent pour les fils de réponses (voir §9.3 pour les implications quant à la portée de la signature).
2.3 SealedQub (format de fil canonique)
Sérialisé en CBOR canonique (§3). Écrit sur le stockage permanent. C’est l’artefact on-chain.
SealedQub {
version: u8, // Version majeure du protocole (0x01 pour v1)
qub_id: [u8; 32], // Identique à QubEnvelope.qub_id
visibility: u8, // 0x01 = public ; les lecteurs v1 rejettent les autres valeurs
unlock_at: i64, // Secondes Unix UTC
outcome_at: Option<i64>, // V1.1 — exposé sur l’appel à action de suivi
// du verdict avant dévoilement ; reflète
// QubEnvelope.outcome_at ; lié à qub_id via
// la pré-image de §4.1.
drand_chain_id: String, // Hachage de la chaîne drand (chaîne hex)
drand_round: u64, // Numéro du tour drand cible
tlock_ciphertext: Vec<u8>, // Octets CBOR du QubEnvelope chiffrés par tlock
recipient_pubkey: Option<[u8; 32]>,// Champ réservé ; accepté par le CBOR canonique
// mais non interprété par le lecteur de référence v1
title: Option<String>, // Titre en clair affiché sur le compte à
// rebours du lecteur avant le dévoilement.
// Lié à qub_id via title_hash (§4.1).
// 1..=100 codepoints NFC, sans caractères de contrôle.
}
2.4 RevealedQub (état de l’application lecteur)
Non sérialisé en CBOR. Local à l’application lecteur. Construit après déchiffrement et vérification réussis.
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 — repris depuis QubEnvelope.outcome_at / SealedQub.outcome_at ; pilote le bloc de suivi du verdict sur la page de dévoilement (verdict-uplift-plan §5.1)
drand_chain_id: String,
drand_round: u64,
sender_label: Option<String>,
title: Option<String>, // Repris depuis 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. Profil CBOR canonique
Toute sérialisation de SealedQub et QubEnvelope DOIT respecter ce profil. Deux implémentations à qui l’on fournit la même structure logique DOIVENT produire des octets identiques.
3.1 Règles d’encodage
| Règle | Spécification |
|---|---|
| Norme | RFC 8949 §4.2.1 (Core Deterministic Encoding Requirements) |
| Ordre des clés de map | Trié par longueur d’octets encodés d’abord (les plus courts avant les plus longs), puis lexicographiquement (octet par octet pour les clés de même longueur) |
| Encodage des entiers | Forme la plus courte : 0–23 dans l’octet initial ; 24–255 sur 2 octets ; 256–65535 sur 3 octets ; etc. |
| Encodage des longueurs | Longueurs définies uniquement. Pas de tableaux, maps, chaînes d’octets ou de texte de longueur indéfinie (additional info = 31 interdit). |
| Tags | Pas de tags CBOR (le major type 6 est interdit). |
| Virgule flottante | Pas de flottants (les valeurs major type 7 0xF9–0xFB sont interdites). |
| Chaînes de texte | Encodées en UTF-8, normalisées NFC (Unicode Normalization Form C). |
| Chaînes d’octets | Octets bruts. Pas d’encodage base64 au niveau CBOR. |
| Clés en double | Rejet avec erreur. Les analyseurs NE DOIVENT PAS accepter silencieusement des clés en double. |
| Valeurs simples | Seules true (0xF5), false (0xF4) et null (0xF6) sont autorisées. |
| Champs optionnels | Les champs optionnels absents sont omis entièrement de la map CBOR (non encodés en null). Les champs optionnels présents sont inclus dans l’ordre de tri des clés. |
3.2 Ordres de clés canoniques vérifiés
Ces ordres de clés sont normatifs. Les implémentations DOIVENT émettre les clés exactement dans cet ordre. Les assertions de débogage DEVRAIENT vérifier l’ordre dans les builds non-release.
QubEnvelope (version 0x01, non signé, tous les champs optionnels absents) :
"body" (5 octets encodés)
"qub_id" (7 octets encodés)
"sig_alg" (8 octets encodés)
"version" (8 octets encodés)
"reply_to" (9 octets encodés) ← uniquement si présent (fils de réponses)
"body_hash" (10 octets encodés)
"unlock_at" (10 octets encodés)
"created_at" (11 octets encodés)
"outcome_at" (11 octets encodés) ← uniquement si présent (mécanique de verdict V1.1)
"content_type" (13 octets encodés)
"sender_label" (13 octets encodés) ← uniquement si présent
"author_pubkey" (14 octets encodés) ← uniquement si présent
"cosigner_pubkey" (16 octets encodés) ← uniquement si présent (contre-signature de pacte)
"author_signature" (17 octets encodés) ← uniquement si présent
"cosigner_signature" (19 octets encodés) ← uniquement si présent (contre-signature de pacte)
Dérivation de l’ordre des clés QubEnvelope : chaque clé est une chaîne de texte CBOR. Longueur encodée = 1 octet d’en-tête + longueur de la chaîne (pour les chaînes inférieures à 24 octets). Trier d’abord par longueur encodée totale, puis lexicographiquement pour les clés de même longueur.
SealedQub (version 0x01, public, sans destinataire) :
"title" (6 octets encodés) ← uniquement si présent
"qub_id" (7 octets encodés)
"version" (8 octets encodés)
"unlock_at" (10 octets encodés)
"outcome_at" (11 octets encodés) ← uniquement si présent (mécanique de verdict V1.1)
"visibility" (11 octets encodés)
"drand_round" (12 octets encodés)
"drand_chain_id" (15 octets encodés)
"recipient_pubkey" (17 octets encodés) ← uniquement si présent
"tlock_ciphertext" (17 octets encodés)
PactTerms (corps de pacte, content_type 0x03) :
"notes" (6 octets encodés) ← uniquement si présent
"terms" (6 octets encodés)
"title" (6 octets encodés)
"party_a" (8 octets encodés)
"party_b" (8 octets encodés)
"pact_version" (13 octets encodés)
PactTerm (ligne du tableau terms) :
"key" (4 octets encodés)
"value" (6 octets encodés)
PartyIdentifier (map party_a / party_b) :
"label" (6 octets encodés)
"contact" (8 octets encodés) ← uniquement si présent
3.3 Référence d’encodage des octets
| Type | Encodage CBOR | Exemple |
|---|---|---|
| Hachage SHA3-256 (32 octets) | 0x58 0x20 + 32 octets |
body_hash, qub_id |
| Horodatages (i64) | Major type 0 (positif) ou 1 (négatif), encodage le plus court | secondes Unix |
| Version (u8, valeur 1) | 0x01 (un seul octet) |
|
| Type de contenu (u8, valeur 1) | 0x01 (un seul octet) |
|
| sig_alg (u8, valeur 0) | 0x00 (un seul octet) |
|
| Signature ML-DSA-65 (3 309 octets) | 0x59 0x0C 0xED + 3 309 octets |
author_signature, cosigner_signature |
| Clé publique ML-DSA-65 (1 952 octets) | 0x59 0x07 0xA0 + 1 952 octets |
author_pubkey, cosigner_pubkey |
4. Dérivations normatives
4.1 qub_id
Le qub_id identifie de manière unique un qub et lie le QubEnvelope au SealedQub. Il est dérivé de manière déterministe du contenu de l’enveloppe.
qub_id = SHA3-256(
"QUB_ID_V2" || // séparateur de domaine : octets ASCII [0x51 0x55 0x42 0x5F 0x49 0x44 0x5F 0x56 0x32] (9 octets) + remplissage 0x00 (1 octet) = 10 octets
version || // u8 (1 octet)
content_type || // u8 (1 octet)
created_at || // i64 big-endian (8 octets)
unlock_at || // i64 big-endian (8 octets)
outcome_at_or_zero || // i64 big-endian (8 octets ; 0 quand outcome_at est absent)
drand_round || // u64 big-endian (8 octets)
body_hash || // [u8; 32] (32 octets)
title_hash // [u8; 32] (32 octets ; sentinelle d’absence = [0u8; 32])
)
// Pré-image totale : 108 octets → sortie de 32 octets
Encodage du séparateur de domaine : la chaîne "QUB_ID_V2" fait 9 octets ASCII. Un seul octet de remplissage 0x00 est ajouté pour atteindre 10 octets pour l’alignement. Les implémentations DOIVENT utiliser exactement ces 10 octets : [0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x32, 0x00].
Encodage d’outcome_at : la V1.1 a étendu la pré-image de 92 à 100 octets afin d’intégrer le champ optionnel outcome_at dans la liaison. Un outcome_at absent est encodé en 8 octets nuls ; les validateurs du protocole rejettent outcome_at <= 0 partout, de sorte que cette sentinelle ne peut pas entrer en collision avec une valeur légitime. Voir §3.2 (format de fil) et le document interne tasks/verdict-uplift-plan.md pour la mécanique de verdict qui motive ce champ.
Encodage de drand_round : la V1.2 a étendu la pré-image de 100 à 108 octets afin d’intégrer drand_round (le tour drand cible, §4.3) dans la liaison, et a fait passer le séparateur de domaine à QUB_ID_V2. Cela lie le tour de verrou temporel à l’identité du qub : une passerelle ne peut pas relier le texte chiffré à un tour différent (par exemple déjà passé) de celui qu’implique l’unlock_at affiché. La procédure de déverrouillage (§8) vérifie en outre que le tour intégré à la strophe du texte chiffré tlock correspond à unlock_round(unlock_at), de sorte que l’heure de déverrouillage affichée est, de façon prouvable, le tour qui conditionne le déchiffrement.
Propriétés :
- Modifier n’importe quel champ du QubEnvelope (corps, horodatages, type de contenu, version) produit un qub_id différent.
- Le qub_id est calculé avant le chiffrement. QubEnvelope et SealedQub portent le même qub_id. Le lecteur vérifie qu’ils correspondent après déchiffrement.
- Le qub_id ne dépend pas de
sender_label,author_signatureouauthor_pubkey. Cela signifie que le même contenu scellé au même moment produit le même qub_id quel que soit le signataire. - Modifier le
titledu SealedQub (tout le reste fixé) modifiequb_idviatitle_hash. Une passerelle ne peut donc pas échanger le titre en clair affiché sur le compte à rebours sans invalider l’identité du qub. - Modifier l’
outcome_atdu SealedQub (tout le reste fixé) modifiequb_idvia la pré-image. Une passerelle ne peut pas échanger la date de verdict pré-dévoilement affichée sur le compte à rebours sans invalider l’identité du qub. - Modifier
drand_round(tout le reste fixé) modifiequb_idvia la pré-image. Une passerelle ne peut pas relier le texte chiffré du verrou temporel à un tour différent sans invalider l’identité du qub ; combiné à la vérification du tour de strophe au déverrouillage (§8), l’unlock_ataffiché est le tour qui conditionne réellement le déchiffrement.
4.2 body_hash
body_hash = SHA3-256(body)
Où body est la charge utile brute Vec<u8>. Pour les qubs texte, c’est le corps du qub encodé en UTF-8.
4.2.1 title_hash
title_hash = SHA3-256(NFC(title).utf8_bytes) si le titre est présent
title_hash = [0u8; 32] si le titre est absent
Où title est le titre optionnel en clair affiché sur le compte à rebours du lecteur avant le dévoilement (voir §3.2). La normalisation NFC se fait au moment du hachage afin que le condensat soit stable entre des séquences de codepoints visuellement équivalentes. La sentinelle entièrement à zéro est réservée au cas absent ; une chaîne vide est rejetée à la frontière du CBOR canonique comme un encodage non canonique de « absent » (l’encodage canonique omet entièrement le champ).
4.3 Correspondance déverrouillage-tour
drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds)
| Paramètre | Source | Exemple |
|---|---|---|
unlock_at |
Secondes Unix UTC choisies par l’utilisateur | 1735689600 (2025-01-01 00:00:00 UTC) |
chain_genesis_time |
Infos de chaîne drand (genesis_time) |
1595431050 |
chain_period_seconds |
Infos de chaîne drand (period) |
30 |
L’opération ceil() sélectionne le premier tour drand dont l’heure de dévoilement est ≥ unlock_at. Cela garantit que le qub ne devient pas déchiffrable avant l’heure de déverrouillage choisie.
Cas limite : si (unlock_at - chain_genesis_time) est exactement divisible par chain_period_seconds, le résultat est ce tour précis — le qub se déverrouille pile à l’heure de dévoilement de ce tour.
Validation : unlock_at DOIT être dans le futur au moment du scellement. unlock_at NE DOIT PAS dépasser created_at + 10 ans (pour limiter le risque de dépendance drand à long horizon ; l’interface DEVRAIT avertir pour les dévoilements au-delà de 2 ans).
5. Newtypes du format de fil
Les newtypes du format de fil offrent une sécurité à la compilation contre la confusion des octets CBOR avec du JSON, du texte brut ou d’autres encodages d’octets.
| Type | Contient | Produit par | Consommé par |
|---|---|---|---|
SealedQubCbor |
CBOR canonique du SealedQub | serialize_sealed_qub() |
Envoi vers le stockage permanent, lecture par le lecteur |
QubEnvelopeCbor |
CBOR canonique du QubEnvelope | serialize_qub_envelope() |
Entrée chiffrement tlock, sortie déchiffrement tlock |
5.1 Règles de construction
// Code de production — uniquement via les sérialiseurs CBOR :
let sealed = SealedQubCbor::from_encoded(cbor_bytes);
// Il n’y a délibérément AUCUNE implémentation From<Vec<u8>>.
// Vous ne pouvez pas envelopper accidentellement des octets arbitraires dans un type de format de fil.
// Accès aux octets bruts :
let bytes: &[u8] = sealed.as_bytes();
let bytes: Vec<u8> = sealed.into_bytes();
5.2 Validation à la construction
from_encoded() DEVRAIT valider que l’entrée commence par un en-tête de map CBOR valide. La validation structurelle complète a lieu au moment de l’analyse, pas à la construction, pour éviter une double analyse.
6. Registre des types de contenu
| Valeur | Type | Taille de corps maximale | Notes |
|---|---|---|---|
0x00 |
Réservé (invalide) | — | NE DOIT PAS être utilisé |
0x01 |
Texte brut (UTF-8, Markdown restreint) | 50 Ko payant / 10 Ko gratuit | Voir §10 pour les règles de rendu. Le partage gratuit/payant est appliqué par le service d’envoi ; le plafond strict de la couche protocole est de 50 Ko. |
0x02 |
Réservé (futur) | — | Alloué pour un futur type de contenu ; non valide en v1. Les lecteurs DOIVENT le rejeter conformément à la règle ci-dessous. |
0x03 |
Pacte (accord bilatéral, corps CBOR) | 100 Ko | Le corps est un PactTerms CBOR canonique (§6.1). Signature du contre-signataire selon §9.7. |
0x04 |
Verdict (auto-évaluation du créateur, corps CBOR) | 8 Ko | Le corps est un VerdictBody CBOR canonique (§6.2). Émis uniquement par l’intent verdict côté système. La relation au parent est portée par l’étiquette Arweave Parent-Tx-Id, et non par le corps. Voir verdict-uplift-plan §3.4. |
Les lecteurs DOIVENT rejeter les types de contenu inconnus avec une erreur claire visible par l’utilisateur. Les lecteurs NE DOIVENT PAS tenter d’afficher des types inconnus en tant que texte.
6.1 Corps de pacte (content_type = 0x03)
Un corps de pacte est l’encodage CBOR canonique d’une valeur PactTerms :
PactTerms {
pact_version: u8, // 0x01 pour structured/v1
title: String, // ≤ 200 octets, NFC
terms: Vec<PactTerm>, // ≤ 20 lignes
party_a: PartyIdentifier, // initiateur
party_b: PartyIdentifier, // contre-signataire
notes: Option<String>, // ≤ 5 000 octets, NFC ; clé absente si aucune
}
PactTerm { key: String (≤ 100), value: String (≤ 2 000) } // NFC des deux côtés
PartyIdentifier{ label: String (≤ 100), contact: Option<String (≤ 320)> }
Les ordres de clés CBOR canoniques pour les trois maps sont donnés en §3.2. Le CBOR de pacte sérialisé total NE DOIT PAS dépasser 100 Ko (correspond à §6).
Discriminateur de schéma. La première ligne de terms pour un pacte structured/v1 DOIT être { key: "pact_schema", value: "structured/v1" }. Les lignes sans ce marqueur sont des pactes « personnalisés » et ne reçoivent ni validation structurée ni rendu sensible au schéma.
Emplacements d’accusé de réception figés. Les pactes structured/v1 portent exactement quatre lignes d’accusé de réception sous ces clés :
"initiator_standard_terms"
"initiator_capacity_terms"
"counterparty_standard_terms"
"counterparty_capacity_terms"
La value de chacune est l’une des huit chaînes anglaises figées choisies par la paire (role, kind), où role ∈ { seller, buyer, provider, client } et kind ∈ { standard, capacity }. Ces chaînes sont elles-mêmes des données de protocole normatives — les signatures ML-DSA-65 des deux parties s’engagent sur les octets exacts via body_hash. Elles NE SONT PAS localisées ; le corps signé est neutre vis-à-vis de la langue. Toute modification de formulation requiert une nouvelle version de schéma (structured/v2).
Les huit chaînes, leur lookup (acknowledgement_for(role, kind)) et le motif de chacune sont fixés par l’implémentation de référence. Les implémentations conformes DOIVENT émettre des valeurs d’accusé de réception identiques au niveau octet ; les tests de fixtures fixées sur le SHA3-256 du body_hash couvrant les quatre combinaisons de rôle attrapent toute dérive.
Ordre d’affichage dans le lecteur. Les chaînes d’accusé de réception contiennent des phrases telles que « described above » (« décrit ci-dessus »), qui supposent que les lignes de description / périmètre s’affichent avant les accusés de réception. Les lecteurs DOIVENT afficher le tableau terms dans l’ordre CBOR ; un réordonnancement casse la sémantique de la prose.
Contact de la contrepartie. Lorsque le contact de Party B est une adresse e-mail valide, le service d’envoi qub envoie automatiquement un e-mail d’invitation à examen / contre-signature au moment de l’émission et lie la contre-signature ultérieure à la vérification de cette même adresse (§9.7). Les pactes dont le contact de Party B est absent peuvent toujours être contresignés, mais uniquement par un canal hors-bande — le service refuse les requêtes de contre-signature qui ne peuvent pas produire un marqueur de vérification d’e-mail correspondant de 15 minutes.
6.2 Corps de verdict (content_type = 0x04)
Un corps de verdict est l’encodage CBOR canonique d’une valeur VerdictBody :
VerdictBody {
verdict_version: u8, // 0x01 pour structured/v1
outcome: u8, // 1=Right · 2=Partial · 3=Wrong · 4=Unfalsifiable
reflection: Option<String>, // ≤ 2 000 octets NFC ; « ce qui a changé, ce que vous avez appris »
evidence_url: Option<String>, // ≤ 2 048 octets ; HTTPS uniquement ; clé absente si omise
}
Ordre canonique des clés CBOR :
"outcome" (8 octets encodés)
"reflection" (11 octets encodés) ← uniquement si présent
"evidence_url" (13 octets encodés) ← uniquement si présent
"verdict_version" (16 octets encodés)
Le CBOR de verdict sérialisé total NE DOIT PAS dépasser 8 Ko (correspond à la ligne du registre ci-dessus).
Énumération du résultat. L’octet sur le fil est neutre vis-à-vis de l’intent ; les quatre catégories Right / Partial / Wrong / Unfalsifiable couvrent l’espace des résultats de toute intent porteuse de verdict. Les libellés par intent (« Vu juste » / « Tenu » / « Livré » / « Confirmée » pour Right, etc.) sont une préoccupation de rendu côté lecteur, résolue par rapport à l’intent du qub parent — le fil reste neutre en langue et en intent. Les valeurs hors de 1..=4 DOIVENT être rejetées au décodage.
Liaison au parent. Un qub de verdict ne porte PAS la référence au parent dans son corps. L’identifiant de transaction Arweave du qub parent est émis comme étiquette de stockage Parent-Tx-Id au moment de l’envoi (§7, couche d’étiquettes de stockage). Cela garde le corps comme une déclaration signée autonome d’auto-évaluation ; la chaîne d’audit (« avoir raison à propos de quoi ? ») est établie par la recherche d’étiquettes Arweave.
Sécurité de l’URL de preuve (normatif). Lorsque evidence_url est présente, les validateurs (côté composition, côté fil, à la périphérie du Worker) DOIVENT appliquer :
- HTTPS uniquement. La chaîne DOIT commencer par la séquence d’octets
https://. Tout autre schéma —http,ftp,javascript,data,file, etc. — est rejeté. - Plafond de longueur. ≤ 2 048 octets (limite pratique des URL dans le navigateur).
- NFC + contrôle des codepoints hostiles. Même règle que
titleetreflection— les codepoints de bidi-override / largeur nulle / bloc tag / BOM / C0 / C1 sont rejetés. La définition correspond à la fonction Rustcrate::handle::contains_hostile_text_codepointet à la fonction TSworkers/api/src/utils/unicode.ts::isHostileCodepoint(à garder en synchronisation). - Aucun blanc, aucun caractère de contrôle ASCII. Tout blanc / DEL / octet sous
0x20n’importe où dans l’URL est rejeté — referme le vecteur d’injection\n/\tque la règle bidi ne couvre pas. - Segment d’hôte non vide. Tout ce qui se trouve entre
https://et le premier/,?ou#DOIT être non vide.
Aucune récupération côté serveur. Le Worker NE DOIT PAS proxifier, récupérer ou prévisualiser l’URL. Le protocole stocke une chaîne ; le rendu se fait côté lecteur avec rel="nofollow noopener noreferrer" target="_blank" et l’hôte affiché visiblement à côté du texte du lien.
Réflexion. Texte de réflexion facultatif rédigé par le créateur (« ce qui a changé, ce que vous avez appris »). Même validation NFC + codepoints hostiles que title. Une saisie vide ou ne contenant que des blancs est repliée à « absente » au moment de la construction.
Version de schéma. La v1 ne prend en charge que verdict_version = 0x01. Les futures révisions de schéma incrémentent cet octet et arrivent en même temps qu’une nouvelle version de protocole selon §12.
7. Protocole de scellement
Séquence complète de scellement. Chaque étape est normative.
1. L’utilisateur compose le texte brut et les métadonnées dans ComposeQub.
2. Validation :
a. body est non vide.
b. taille de body ≤ max pour content_type et le niveau utilisateur (voir §6).
c. unlock_at est dans le futur.
d. unlock_at ≤ created_at + 10 ans.
e. content_type est une valeur connue et prise en charge.
3. Calculer body_hash = SHA3-256(body).
4. Définir created_at = secondes Unix UTC actuelles.
5. Sélectionner la chaîne drand. Charger chain_genesis_time et chain_period_seconds, et
calculer drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds).
(Calculé ici, avant qub_id, car drand_round est lié dans la pré-image du qub_id
— §4.1, V1.2.)
6. Calculer qub_id (voir §4.1), en y intégrant drand_round de l’étape 5.
7. Construire QubEnvelope avec tous les champs.
8. Sérialiser QubEnvelope en CBOR canonique → octets B.
Asserter : la sortie sérialisée correspond au profil canonique (§3).
9. Calculer C = tlock_encrypt(B, drand_round, drand_chain_public_key).
10. Construire SealedQub avec tlock_ciphertext = C, et qub_id, version,
unlock_at, drand_chain_id, drand_round correspondants.
12. Sérialiser SealedQub en CBOR canonique → SealedQubCbor.
12a. Générer K = 32 octets aléatoires (CSPRNG) et N = 12 octets aléatoires (CSPRNG).
Calculer W = wrap_sealed_qub(SealedQubCbor, qub_id=qub_id, key=K, nonce=N)
selon §13. Les octets envoyés vers le stockage permanent sont le CBOR W de l’OuterWrapper,
jamais le SealedQubCbor nu. K ne quitte l’appareil que sous forme de
fragment d’URL à l’étape 16.
13. Afficher la mise en garde au moment du scellement. L’utilisateur confirme.
14. Valider l’éligibilité de l’envoi via le service d’envoi qub (détection de bot, droits, limites de débit).
15. Soumettre W (les octets de l’OuterWrapper) au service d’envoi qub ; le service
signe et envoie vers le stockage permanent. Le service est aveugle aux octets du
SealedQubCbor interne et ne reçoit jamais K.
16. Recevoir arweave_tx_id du service. Construire l’URL de remise sous la forme
`<origin>/c/<arweave_tx_id>#<base64url(K)>` (ou `<origin>/s/<short_code>#<base64url(K)>`
quand un code court est attribué). Les navigateurs ne transmettent pas
les fragments d’URL aux serveurs, donc K n’est jamais observé par
qub.social ni par aucune passerelle de stockage.
Couche de tags de stockage (hors-bande). Le service d’envoi qub attache un ensemble délibérément réduit de tags de transaction de stockage à côté de la charge utile enveloppée. Content-Type=application/octet-stream est requis normativement. Le service de référence attache en plus trois tags optionnels lorsque le créateur choisit de les exposer : Intent (intention de composition validée par allowlist — par exemple, quote, reply, commitment), Author (empreinte de la clé publique §9.3 du créateur sous forme hex minuscule de 64 caractères) et Parent-Tx-Id (ID de transaction de stockage du qub parent pour les fils de réponses, base64url de 43 caractères).
Le tag Author est opt-in par qub : l’application créateur de référence ne l’attache que lorsque l’utilisateur active explicitement l’attribution publique au moment du scellement. Quand l’interrupteur est désactivé — par défaut — aucun tag Author n’est écrit et le qub n’est pas attribué sur la chaîne : rien sur le stockage permanent ne lie l’envoi à l’identifiant, à l’adresse e-mail ou aux autres qubs d’un créateur. Quand l’interrupteur est activé, l’empreinte Author se résout au @handle choisi par le créateur via la chaîne d’attestation §9.5. Les relations de fils de réponses et Intent ne sont pas identifiantes. L’enveloppe externe (§13) protège le corps interne contre la corrélation de chiffrés — empêchant un agrégateur de reconnaître et de déchiffrer en masse les envois en forme de qub après la publication de leur tour drand.
Le service de référence n’attache intentionnellement PAS de tags App-Name, App-Version ou Type : tout filtre à valeur unique de ce genre renverrait l’ensemble du corpus qub à une requête GraphQL, ce qui est incompatible avec la portée de confidentialité « corps uniquement » de l’enveloppe.
Un vérificateur conforme NE DOIT PAS dépendre d’un quelconque tag de stockage pour la vérification tierce §11 ; le hachage de corps / qub_id / signature ne s’engagent que sur le CBOR interne, jamais sur l’ensemble des tags.
8. Protocole de déverrouillage
Séquence complète de déverrouillage. Chaque étape est normative.
1. Le lecteur ouvre l’URL de remise. Extraire arweave_tx_id du chemin ET
K = base64url_decode(fragment) du fragment d’URL. Si le fragment
est absent ou malformé → afficher « cette URL ne contient pas sa clé
de déchiffrement » et arrêter ; le lecteur NE DOIT PAS contacter la
passerelle de stockage sans K, puisque récupérer des octets enveloppés
que le lecteur ne peut pas déchiffrer ne sert à rien et ne fait que
fuiter la tentative d’accès.
2. Vérifier la liste de blocage. Si tx_id est sur la liste → afficher le message de blocage. Arrêter.
3. Récupérer les octets de l’OuterWrapper depuis le stockage permanent (avec repli multi-passerelles).
3a. Désenvelopper : analyser les octets en OuterWrapper (§13), vérifier
l’octet `version` du wrapper = `0x01`, et calculer SealedQubCbor =
unwrap_sealed_qub(OuterWrapper, key=K). Tout échec d’authentification
AEAD (mauvaise K, chiffré altéré, qub_id-as-AAD échangé, nonce
échangé) → afficher « la clé de déchiffrement de cette URL ne
correspond pas au qub stocké » et arrêter. Les échecs
d’authentification sont indissociables pour le lecteur (§13.5).
4. Analyser SealedQubCbor → SealedQub.
5. Valider : SealedQub.version est connue (0x01). Rejeter les versions inconnues.
6. Si l’heure actuelle < SealedQub.unlock_at → afficher le compte à rebours. Sonder ou attendre.
6a. Vérification de liaison au tour (V1.2). Recalculer expected_round =
ceil((SealedQub.unlock_at - chain_genesis_time) / chain_period_seconds).
Rejeter sauf si SealedQub.drand_round == expected_round ET le tour intégré
à la strophe du texte chiffré tlock (lu via l’en-tête age/tlock, sans
signature requise) == expected_round. Le tour de la strophe est celui qui
conditionne réellement le déchiffrement ; sans cette vérification, un créateur
malveillant pourrait relier le texte chiffré à un tour déjà passé tout en
affichant un compte à rebours futur, de sorte que quiconque lit les octets
stockés pourrait déchiffrer avant unlock_at. Les implémentations sans
identité de chaîne (mocks de test) ignorent cette vérification.
7. Une fois l’heure actuelle ≥ SealedQub.unlock_at :
a. Récupérer la signature de tour drand pour SealedQub.drand_round depuis le réseau drand.
b. Calculer B = tlock_decrypt(SealedQub.tlock_ciphertext, round_signature).
8. Analyser B → QubEnvelope.
9. Valider QubEnvelope.version est connue.
10. Vérifier : SHA3-256(QubEnvelope.body) == QubEnvelope.body_hash.
Échec → erreur d’intégrité.
11. Vérifier : QubEnvelope.qub_id == SealedQub.qub_id.
Échec → erreur d’intégrité.
12. Vérifier : QubEnvelope.unlock_at == SealedQub.unlock_at.
Échec → erreur d’intégrité.
13. Vérifier : QubEnvelope.content_type est connue et affichable.
Valeurs connues : 0x01 (texte), 0x03 (pacte). Inconnue → afficher une erreur.
14. Si QubEnvelope.sig_alg != 0x00 → vérifier la signature de l’auteur (voir §9.4).
15. Si cosigner_pubkey ou cosigner_signature présents → vérifier le contre-signataire (voir §9.7).
16. Afficher le contenu via le rendu approprié (voir §10 pour le texte, §6 pour le pacte).
17. Construire RevealedQub pour l’affichage.
9. Signature d’auteur
9.1 Motivation
Les qubs sont stockés sur un stockage permanent. Les signatures d’auteur doivent rester infalsifiables indéfiniment, c’est pourquoi v1.0 utilise le schéma post-quantique ML-DSA-65 (FIPS 204) plutôt qu’un schéma classique dont la sécurité pourrait se dégrader pendant la durée de vie permanente du qub.
9.2 Registre d’algorithmes
sig_alg |
Schéma | Taille de clé | Taille de signature |
|---|---|---|---|
0x00 |
Pas de signature (non signé) | — | — |
0x01 |
ML-DSA-65 (FIPS 204) | 1 952 octets | 3 309 octets |
Les lecteurs DOIVENT rejeter les valeurs sig_alg inconnues.
9.3 Construction de la pré-image signée
sig_input = SHA3-256(
"QUB_AUTHOR_SIG_V1" || // séparateur de domaine (17 octets)
version || // u8 (1 octet)
qub_id || // [u8; 32] (32 octets)
body_hash || // [u8; 32] (32 octets)
unlock_at || // i64 big-endian (8 octets)
0x00 // u8 (1 octet) : DOIT être 0x00 en v1.0
)
// Pré-image totale : 91 octets → hachage de 32 octets
signature = Sign(author_secret_key, sig_input)
Séparateur de domaine : "QUB_AUTHOR_SIG_V1" fait 17 octets ASCII : [0x51, 0x55, 0x42, 0x5F, 0x41, 0x55, 0x54, 0x48, 0x4F, 0x52, 0x5F, 0x53, 0x49, 0x47, 0x5F, 0x56, 0x31]. Pas de remplissage.
Octet de fin : le 91ᵉ octet de pré-image DOIT être 0x00. L’implémentation de référence l’expose sous forme de constante ORG_ID_PRESENT_INDIVIDUAL = 0x00 dans crates/qub-core/src/signing.rs ; les lecteurs reconstruisant sig_input pour vérification DOIVENT émettre le même octet.
Portée de la signature — ce qui est et n’est pas couvert. sig_input s’engage sur quatre champs d’enveloppe : version, qub_id, body_hash, unlock_at (plus le séparateur de domaine fixe et l’octet org_id_present). Trois de ces quatre sont des invariants structurels : qub_id est lui-même dérivé de version, content_type, created_at, unlock_at et body_hash via la pré-image §4.1, donc tout changement de content_type ou created_at produit un qub_id différent et invalide la signature transitivement. La surface directement authentifiée est donc :
| Champ | Authentifié par la signature | Comment |
|---|---|---|
version |
✓ | Entrée directe de sig_input |
qub_id |
✓ | Entrée directe |
body_hash |
✓ | Entrée directe |
unlock_at |
✓ | Entrée directe |
content_type |
✓ | Transitivement, via la pré-image qub_id |
created_at |
✓ | Transitivement, via la pré-image qub_id |
body |
✓ | Transitivement, via body_hash = SHA3-256(body) |
author_pubkey |
— (implicite) | La clé qui a vérifié la signature est l’auteur, par définition |
sender_label |
✗ | Texte d’affichage uniquement ; mutable sans casser la signature |
reply_to |
✗ | Pointeur de fil ; mutable sans casser la signature |
cosigner_pubkey / cosigner_signature |
— | Signés indépendamment sur la même sig_input (voir §9.7) |
drand_chain_id, tlock_ciphertext, visibility |
— | Champs du SealedQub externe, pas dans l’enveloppe — couverts par leurs propres invariants structurels (cohérence tour / chaîne) mais pas par la signature de l’auteur. (drand_round est désormais lié transitivement via la pré-image du qub_id — voir ci-dessus.) |
Implications de sécurité des champs non authentifiés.
- Une partie ayant un accès en écriture aux octets stockés pourrait échanger
sender_label(« Alice » → « Mallory ») sans invalider la signature de l’auteur. Leauthor_pubkeyà l’intérieur de l’enveloppe demeure le véritable ancrage d’identité — les lecteurs DOIVENT dériver l’identité affichée à partir deauthor_pubkey(via la couche d’attestation §9.5) plutôt que de faire confiance àsender_label. - Un champ
reply_topeut également être modifié post-signature. Commequb_idest adressé par contenu, un attaquant ne peut pas pointerreply_tovers une cible inexistante, mais il peut silencieusement re-rattacher une réponse à un autre qub existant.
Les implémentations qui affichent sender_label ou reply_to aux utilisateurs finaux DOIVENT mettre en avant l’identité authentifiée (empreinte de clé publique, attestation) comme signal d’identité principal, pas l’étiquette.
9.4 Procédure de vérification
1. Lire sig_alg depuis QubEnvelope.
2. Si sig_alg == 0x00 → non signé. Pas de vérification. Afficher « qub non signé. »
3. Si sig_alg est inconnu → rejeter. Afficher « schéma de signature non reconnu. »
4. Extraire author_signature et author_pubkey. Si l’un est absent → erreur d’intégrité.
5. Reconstruire sig_input à partir des champs de QubEnvelope (même formule qu’en §9.3).
6. Verify(author_pubkey, sig_input, author_signature).
7. Si la vérification réussit → afficher « signé par [empreinte de clé]. »
8. Si la vérification échoue → afficher « échec de la vérification de signature. »
La vérification de signature est l’opération la plus coûteuse (en particulier ML-DSA-65). Elle DEVRAIT être effectuée après que toutes les vérifications moins coûteuses (hachage, qub_id, unlock_at) soient passées.
9.5 Attestations d’identité
Les attestations d’identité — la correspondance entre author_pubkey et des revendications d’identité reconnaissables (identifiant qub, adresse e-mail, identifiant social, identifiant de passkey) — sont une amélioration progressive côté lecteur et ne sont pas requises pour la vérification de signature. Les lecteurs qui résolvent des attestations en identité affichée DOIVENT appliquer la précédence :
handle > email > social > fingerprint
Le repli sur l’empreinte est l’hex minuscule de SHA3-256(author_pubkey) ; il est toujours disponible pour tout qub signé. Les lecteurs PEUVENT l’abréger pour l’affichage — le lecteur de référence rend qub: suivi des quatre premiers et des quatre derniers octets (qub:<8 hex>…<8 hex>).
Un vérificateur conforme peut effectuer toutes les vérifications de §9.4 sans contacter l’API qub, sans aucun réseau au-delà du stockage permanent et de drand, et sans aucune recherche côté serveur. La résolution d’attestation est une étape distincte au mieux, effectuée seulement après le succès de la vérification de signature.
9.6 Impact en taille
| Ed25519 | ML-DSA-65 | |
|---|---|---|
| Signature | 64 octets | 3 309 octets |
| Clé publique | 32 octets | 1 952 octets |
| Total par qub | 96 octets | 5 261 octets |
| Surcoût de stockage (à ~5 $/Mo) | ~0,0005 $ | ~0,026 $ |
Pour un qub texte de 500–2 000 octets, ML-DSA-65 triple à peu près la taille stockée. Le coût absolu est négligeable.
9.7 Vérification du contre-signataire (pactes bilatéraux)
Pour les accords bilatéraux (content_type = 0x03), une seconde couche de signature prouve que les deux parties ont consenti aux mêmes termes.
Champs de l’enveloppe :
cosigner_pubkey: clé publique ML-DSA-65 du contre-signataire (Party B).cosigner_signature: signature sur la mêmesig_inputque l’auteur (§9.3).
Les deux champs DOIVENT être présents ensemble ou tous deux absents. Si exactement un est présent, les lecteurs DOIVENT signaler une erreur d’intégrité.
Procédure de vérification :
1. Si cosigner_pubkey absent et cosigner_signature absent → pas de contre-signataire. Terminé.
2. Si exactement un est présent → erreur d’intégrité.
3. Vérifier cosigner_pubkey != author_pubkey (empêche l’auto-contresignature).
Échec → afficher « la clé du contre-signataire doit différer de celle de l’auteur. »
4. Reconstruire sig_input avec la même formule qu’en §9.3.
5. Verify(cosigner_pubkey, sig_input, cosigner_signature).
6. Succès → afficher « contresigné par [empreinte du contre-signataire]. »
7. Échec → afficher « échec de la vérification de la contre-signature. »
Propriétés :
- Le contre-signataire signe la
sig_inputidentique à celle de l’auteur — les deux parties s’engagent sur les mêmesqub_id,body_hashetunlock_at. - La dérivation de
qub_id(§4.1) n’inclut PAS les champs du contre-signataire. Ajouter un contre-signataire à une enveloppe existante ne change pas lequb_id. - Un pacte peut être uniquement signé par l’auteur (engagement unilatéral), uniquement contresigné (inhabituel), ou les deux (preuve bilatérale complète).
Verrou de liaison par e-mail (opérationnel). Lorsqu’un pacte émis porte un contact e-mail Party B (§6.1), le service d’envoi qub DOIT refuser la requête de contre-signature à moins qu’il n’existe un marqueur de vérification d’e-mail à courte durée correspondant à la fois à l’id d’émission et au hachage de l’e-mail normalisé de ce contact. Le marqueur est écrit par /api/v1/auth/verify quand le jeton du lien magique porte un staging_id et que l’adresse vérifiée correspond à SHA-256(normalise_email(party_b.contact)) — où normalise_email(addr) préserve la casse de la partie locale et met en minuscules uniquement la partie domaine (selon RFC 5321 §2.3.11), et SHA-256 ici est le hachage NIST FIPS 180-4 (distinct du SHA3-256 utilisé en §4) — et expire 900 secondes (15 minutes) après émission. C’est un verrou opérationnel anti-usurpation, PAS une partie de la preuve qub on-chain — un vérificateur tiers rejouant §11 n’a besoin que du stockage permanent et de drand, sans aucune recherche côté serveur. Le marqueur n’existe que côté serveur et ne fait jamais partie du corps signé.
Impact en taille (auteur ML-DSA-65 + contre-signataire) :
| Composant | Taille |
|---|---|
| Signature de l’auteur | 3 309 octets |
| Clé publique de l’auteur | 1 952 octets |
| Signature du contre-signataire | 3 309 octets |
| Clé publique du contre-signataire | 1 952 octets |
| Surcoût cryptographique total | 10 522 octets |
| Surcoût de stockage | ~0,05 $ |
10. Rendu et assainissement Markdown
Cette section est sensible à la sécurité. Le lecteur affiche les qubs texte (content_type = 0x01) à l’aide d’un sous-ensemble Markdown restreint.
10.1 Éléments autorisés
- Titres :
#à####(pas#####ni######) - Emphase : gras (
**), italique (*), barré (~~) - Listes : ordonnées (
1.) et non ordonnées (-,*) - Citations (
>) - Code : spans en ligne (```) et blocs encadrés (`````)
- Lignes horizontales (
---) - Sauts de ligne (deux espaces en fin de ligne ou ligne vide)
- Paragraphes
10.2 Éléments interdits
| Élément | Traitement |
|---|---|
HTML brut (<div>, <script>, etc.) |
Entièrement retiré. Aucun HTML ne passe. |
Images () |
Retirées. La syntaxe d’image est supprimée du résultat. |
Liens ([text](url)) |
URL affichée en texte brut visible. Pas d’auto-lien. Pas cliquable sans action explicite de l’utilisateur. |
| Schémas d’URL dangereux | javascript:, data:, vbscript:, file: — retirés. |
| Iframes, embeds, objects | Retirés. |
| Entités HTML | Décodées en caractères d’affichage uniquement si elles sont sûres. |
10.3 Implémentation
Les implémentations DOIVENT utiliser un analyseur strict basé sur une allowlist, pas une blocklist. L’approche recommandée :
- Analyser le Markdown avec
pulldown-cmark(ou équivalent). - Parcourir l’AST et retirer tout nœud absent de l’allowlist (§10.1).
- Pour les nœuds liens : émettre l’URL en texte visible, pas en élément
<a>cliquable. - Convertir l’AST filtré en représentation intermédiaire typée (par exemple un enum
MarkdownNodeavec uniquement des variantes sûres). Le HTML brut est structurellement non représentable dans cette IR. - Rendre depuis l’IR typée vers la couche d’affichage cible (par exemple composants de vue réactifs, nœuds DOM). Aucune concaténation de chaînes HTML ni
innerHTMLà aucun moment.
Les approches blocklist sont fragiles parce que de nouvelles extensions Markdown ou des particularités d’analyseur peuvent introduire des éléments non filtrés. L’approche AST typée rend les XSS structurellement impossibles — il n’y a aucune variante capable de transporter du HTML arbitraire.
10.4 Limites de taille et de structure
- Profondeur de titre maximale rendue :
####(H4).#####et plus profond sont rendus en gras. - Pas de limite sur le nombre de paragraphes (les limites de taille de corps en §6 sont la contrainte).
- Blocs de code encadrés : pas de coloration syntaxique en MVP. Rendus en texte préformaté monospace.
11. Vérification tierce
N’importe quel tiers peut vérifier un qub public sans la coopération de qub. La procédure de vérification :
1. Obtenir arweave_tx_id (à partir de l’URL de remise ou par connaissance directe).
2. Récupérer SealedQubCbor depuis n’importe quelle passerelle de stockage.
3. Confirmer l’inclusion dans un bloc de stockage (hauteur de bloc, horodatage du bloc).
4. Analyser SealedQubCbor → SealedQub.
5. Récupérer la signature de tour drand pour SealedQub.drand_round.
6. tlock_decrypt(tlock_ciphertext, round_signature) → octets CBOR du QubEnvelope.
7. Analyser → QubEnvelope.
8. Vérifier SHA3-256(body) == body_hash.
9. Vérifier QubEnvelope.qub_id == SealedQub.qub_id.
10. Vérifier QubEnvelope.unlock_at == SealedQub.unlock_at.
11. Si sig_alg != 0x00 : vérifier author_signature (voir §9.4).
12. Toutes les vérifications passent → le qub est vérifié.
Ce que la vérification prouve :
| Preuve | Ce qu’elle établit |
|---|---|
| Engagement | Le chiffré existait au moment de l’horodatage du bloc de stockage. |
| Intégrité | Le corps en clair correspond au hachage engagé et n’a pas été altéré. |
| Synchronisation | Le contenu était illisible jusqu’au tour drand, qui correspond à l’heure de déverrouillage choisie (sous réserve des hypothèses de sécurité de tlock et de drand). |
Ce que la vérification NE prouve PAS :
| Non-preuve | Pourquoi |
|---|---|
| Authorship | Le sender_label est décoratif. Sans sig_alg ≥ 0x01, n’importe qui aurait pu sceller ce contenu. |
| Intention | Le qub prouve le contenu et la synchronisation, pas ce que le créateur entendait subjectivement. |
| Synchronisation pré-événement | L’inclusion dans un bloc de stockage peut accuser un retard de quelques minutes par rapport à l’envoi réel. L’horodatage d’engagement est l’heure du bloc, pas le moment où l’utilisateur a appuyé sur « sceller ». |
12. Versionnement
12.1 Version du protocole
Le champ version (u8) dans SealedQub et QubEnvelope identifie la version majeure du protocole.
- Les lecteurs DOIVENT rejeter les versions majeures inconnues avec une erreur claire.
- Les versions majeures connues PEUVENT tolérer des champs optionnels inconnus si les règles de compatibilité ascendante l’autorisent (les champs optionnels absents de l’ordre de clés canonique sont ignorés).
- Les types de contenu (
content_type) et les schémas de signature (sig_alg) sont rattachés à une version : de nouvelles valeurs ne peuvent être introduites qu’avec une nouvelle version de protocole ou une mise à jour explicite du registre.
12.2 Historique des versions
| Version | Valeur | Description |
|---|---|---|
| v1 | 0x01 |
qubs texte publics (content_type 0x01), pactes bilatéraux (0x03, schéma structured/v1, ML-DSA-65 auteur + contre-signataire), tlock, SHA3-256 |
12.3 Compatibilité ascendante
Un lecteur v1 rencontrant un QubEnvelope avec des clés CBOR optionnelles inconnues (clés absentes de l’ordre canonique §3.2) DEVRAIT ignorer ces clés et procéder à la vérification avec les champs connus. Cela permet de futures additions mineures (par exemple, de nouvelles métadonnées) sans nécessiter un saut de version majeure.
Un lecteur v1 rencontrant sig_alg = 0x01 (ML-DSA-65) mais sans support de vérification ML-DSA-65 DEVRAIT afficher le contenu du qub avec un avis « signature présente mais non vérifiable », pas rejeter le qub entièrement. L’implémentation de référence rejette aujourd’hui toute valeur sig_alg autre que 0x00 et 0x01 parce que le registre v1 ne contient aucun autre algorithme valide — rejet strict et soft-fail sont observationnellement identiques jusqu’à ce qu’un troisième algorithme soit enregistré. Le comportement soft-fail ci-dessus devient porteur dès que §9.2 admet une nouvelle entrée, et le lecteur de référence sera mis à jour pour faire un soft-fail à ce moment-là.
12.4 Version de l’enveloppe externe
L’OuterWrapper décrit en §13 porte son propre octet version, indépendant de SealedQub.version et QubEnvelope.version. Les deux espaces de version évoluent séparément : un futur remplacement symétrique post-quantique sûr fait évoluer l’octet du wrapper sans toucher à la version interne du protocole, et une future addition à la couche protocole (par exemple, un nouveau champ d’enveloppe) fait évoluer la version interne sans toucher à l’octet du wrapper.
OUTER_WRAPPER_VERSION_* |
Valeur | Algorithme | Statut |
|---|---|---|---|
OUTER_WRAPPER_VERSION_1 |
0x01 |
AES-256-GCM avec nonce de 12 octets, tag d’authentification de 16 octets, AAD lié à qub_id |
défaut v1 |
| — | 0x02–0xFF |
Réservé | Futur |
Les lecteurs DOIVENT rejeter les versions de wrapper inconnues avec une erreur claire. Le protocole garde intentionnellement un espace de version de wrapper étroit jusqu’à ce qu’un moteur de migration concret apparaisse (par exemple, des recommandations NIST en faveur d’un AEAD différent) ; un emplacement 0x02 sera attribué dans la même révision qui introduit l’algorithme.
13. Enveloppe de chiffrement externe
13.1 Motivation
Les couches du protocole (QubEnvelope → tlock → SealedQub) rendent un qub scellé verrouillé dans le temps : le corps est illisible jusqu’à ce que unlock_at et la signature de tour drand aient été publiés. Après le déverrouillage, cependant, la signature de tour est publique et la forme CBOR canonique de SealedQub est reconnaissable, donc un agrégateur ayant indexé les transactions de stockage permanent pourrait déchiffrer en masse l’ensemble du corpus qub.
L’enveloppe de chiffrement externe ferme ce canal en interposant une couche AEAD symétrique additionnelle entre le SealedQubCbor canonique et les octets écrits sur le stockage permanent. La clé 256 bits K ne vit que dans le fragment d’URL de l’URL de remise et sur les appareils des utilisateurs ; les navigateurs ne transmettent pas les fragments d’URL aux serveurs, donc qub.social, toute passerelle de stockage et tout CDN devant l’un ou l’autre sont observationnellement aveugles à K. Chaque qub sur le stockage permanent est donc un chiffré opaque dont le texte clair est irrécupérable sans l’URL que le créateur a choisi de partager.
Effet net :
- Immunité par défaut à l’énumération. Les octets enveloppés sur le stockage permanent sont indissociables au niveau octet d’un chiffré arbitraire. Une stratégie d’agrégateur consistant à « interroger GraphQL pour trouver les envois en forme de qub, déchiffrer en masse avec les signatures drand publiques » ne se termine pas par du texte clair.
- Posture de confidentialité par crypto-déchirure. qub.social ne peut littéralement pas déchiffrer son propre corpus. Les assignations atteignent un chiffré, pas un texte clair.
- Échelle de confidentialité à deux niveaux. Par défaut = accès contrôlé par lien (cette section). Les qubs privés chiffrés pour un destinataire (une fonctionnalité réservée pour la phase 2, pas encore spécifiée) se superposent en deuxième niveau.
13.2 Empilement
corps en clair ← QubEnvelope.body (§2.2)
↓ CBOR canonique (§3)
CBOR de l’enveloppe
↓ chiffrement tlock vers le tour drand (§7 étape 10)
tlock_ciphertext (à l’intérieur de SealedQub) (§2.3)
↓ CBOR canonique (§3)
octets SealedQubCbor ← artefact de fil interne
↓ AES-256-GCM(K, nonce, AAD=qub_id) (§7 étape 12a, cette section)
octets CBOR OuterWrapper ← envoyés vers le stockage permanent (§7 étape 15)
Le scellement et le déverrouillage à la couche protocole (§7, §8) sont inchangés sous la frontière du wrapper ; le wrapper s’attache au site d’appel de seal() et se détache au site d’appel de unlock().
13.3 Structure de données OuterWrapper
struct OuterWrapper {
version: u8, // 0x01, voir §12.4
qub_id: [u8; 32], // copié depuis le SealedQub interne ; AAD AEAD
nonce: [u8; 12], // nonce AEAD 96 bits
ciphertext: Vec<u8>, // AES-256-GCM(K, nonce, SealedQubCbor, AAD=qub_id) || tag de 16 octets
}
Invariants des champs.
versionDOIT valoir0x01pour les octets de wrapper v1.0.qub_idDOIT être égal au champqub_iddu SealedQub récupéré après désenveloppement. L’étape de désenveloppement ne fait pas appliquer cela directement (la liaison AAD AEAD rend la falsification au niveau octet impossible), mais la couche de déverrouillage vérifie la relation transitivement : si un créateur enveloppe unSealedQubCbordont lequb_idinterne ne correspond pas auqub_iddu wrapper, l’étape 11 de §8 échoue.nonceDOIT faire 96 bits (12 octets), généré à neuf par un CSPRNG pour chaque opération de wrap. Réutiliser un nonce sous la même clé permet des attaques par réutilisation de nonce AEAD qui récupèrent le texte clair ; les producteurs DOIVENT traiter les paires (key,nonce) comme à usage unique.ciphertextest la sortie d’AES-256-GCM : octets de chiffré concaténés avec le tag d’authentification de 16 octets.ciphertext.len() == SealedQubCbor.len() + 16exactement.
Encodage CBOR. CBOR canonique selon §3, avec la même règle d’ordre des clés (triées par longueur d’octets encodés croissante, puis lexicographiquement). Les quatre clés sont :
| Clé | Octets encodés | Ordre |
|---|---|---|
nonce |
6 | 1 |
qub_id |
7 | 2 |
version |
8 | 3 |
ciphertext |
11 | 4 |
Le premier octet du CBOR de l’OuterWrapper est donc l’en-tête de map à longueur définie pour une map à 4 entrées (0xA4).
13.4 Liaison AAD à qub_id
Le wrapper lie qub_id comme données authentifiées additionnelles AEAD. C’est la défense structurelle porteuse contre trois classes d’attaques :
| Attaque | Défense |
|---|---|
Déplacer le chiffré sous un autre champ qub_id dans le wrapper |
Mismatch AAD → l’authentification AEAD échoue |
| Mélanger le fragment d’URL du qub A avec les octets de stockage permanent du qub B | Mismatch AAD → l’authentification AEAD échoue |
Falsifier le champ qub_id du wrapper après envoi |
Mismatch AAD → l’authentification AEAD échoue |
Porter qub_id dans le texte clair du wrapper n’affaiblit pas l’immunité à l’énumération de manière significative — qub_id est lui-même un hachage SHA3-256 de la pré-image §4.1 sans pré-image récupérable depuis le condensat, et un énumérateur qui a déjà récolté les octets du wrapper n’apprend rien du qub_id visible qu’il ne pourrait inférer de l’existence de l’envoi lui-même.
13.5 Algorithmes de wrap et d’unwrap
wrap_sealed_qub(SealedQubCbor S, qub_id Q, key K, nonce N):
require K.len() == 32 and N.len() == 12 and Q.len() == 32
C := AES_256_GCM_encrypt(key=K, nonce=N, msg=S, aad=Q)
// C inclut le tag d’authentification de 16 octets à la fin
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
)
// tout échec AEAD → DECRYPT_FAILED, indissociable pour l’appelant
return P // P est le SealedQubCbor interne
Effondrement des modes d’échec. Une mauvaise K, un mauvais nonce, un mismatch AAD et un chiffré falsifié produisent tous la même erreur DECRYPT_FAILED. C’est une propriété AEAD délibérée : distinguer le mode d’échec créerait un canal latéral qu’un attaquant distant pourrait sonder en envoyant des wrappers malformés et en chronométrant la réponse. Les implémentations de référence DOIVENT effondrer tous les échecs AEAD sur une forme d’erreur unique.
13.6 Matériel de clé et distribution
La clé d’enveloppement K est une valeur aléatoire uniforme de 256 bits générée par qub par un CSPRNG. Les implémentations de référence la sourcent depuis :
- Créateur WASM :
getrandom(WebCrypto sous le backendwasm_js). - Route de scellement côté serveur Worker :
crypto.getRandomValues.
Distribution : K DOIT être encodée en base64 URL-safe (RFC 4648 §5, sans padding) et ajoutée à l’URL de remise comme composant fragment :
delivery_url = <origin>/c/<arweave_tx_id>#<base64url(K)>
Le fragment n’est jamais transmis à un serveur par un navigateur conforme. Les canaux de récupération (index d’historique côté serveur, envoi automatique d’e-mail opt-in) qui persistent l’URL de remise complète — fragment compris — au-delà de l’appareil de l’utilisateur sont un compromis explicite contre la posture par défaut de crypto-déchirure et DOIVENT être verrouillés par un consentement utilisateur explicite.
Perte du fragment. Si un utilisateur perd le fragment d’URL et n’a pas de canal de récupération, le qub est illisible. C’est le compromis porteur de la conception et DOIT être divulgué à l’utilisateur au moment du scellement. Le MVP renforce la mise en garde au scellement avec une copie explicite « enregistrez cette URL » et un canal de récupération par e-mail vérifié pour les utilisateurs qui s’y inscrivent.
13.7 Hors de la portée de cette section
- La signature d’auteur (§9) est inchangée : les signatures sont calculées à l’intérieur du
QubEnvelopeinterne et sont récupérées après unwrap → déchiffrement tlock → analyse CBOR. - Les qubs privés chiffrés pour un destinataire (une fonctionnalité réservée pour la phase 2, pas encore spécifiée) se composent par-dessus ce wrapper en deuxième niveau de confidentialité ; les deux niveaux peuvent être actifs simultanément.
- Les pactes (§6, content_type
0x03) sont enveloppés exactement comme les qubs texte ; le wrapper est aveugle aux octets du type de contenu interne.
13.8 Qubs publics (omission du wrapper)
Le wrapper externe est facultatif à la couche de remise. Un créateur peut sceller un qub comme public, auquel cas le SealedQubCbor canonique est écrit sur le stockage permanent directement, sans couche OuterWrapper ni clé K :
SealedQubCbor bytes ──(public)──▶ uploaded to permanent storage as-is
SealedQubCbor bytes ──(private)─▶ AES-256-GCM(K, …) ▶ OuterWrapper ▶ uploaded
Un qub public est verrouillé dans le temps mais non protégé par lien : il reste illisible jusqu’à ce que son tour drand soit publié (la couche tlock est inchangée), mais après le déverrouillage, quiconque dispose de l’arweave_tx_id peut le déchiffrer — aucun fragment d’URL n’est requis, car il n’y a pas de K. C’est le compromis délibéré pour les surfaces que le serveur doit piloter : les e-mails de notification de dévoilement, les intégrations tierces et un référencement post-dévoilement plus riche ont tous besoin d’un lien qui fonctionne sans un secret que le serveur ne détient jamais (§13.6).
Conséquences qu’un producteur DOIT prendre en compte :
- Pas d’immunité à l’énumération. Les qubs publics renoncent par construction à la propriété d’immunité à l’énumération de §13.1. Le service d’upload de référence leur appose une étiquette de stockage permanent
Visibility: public(et à eux seuls) afin qu’ils soient intentionnellement découvrables ; les qubs privés ne portent aucune étiquette de ce type et conservent leur indissociabilité au niveau octet. - Titre en clair exposé au moment du scellement. Le champ
titlede §3.2 est en clair à l’intérieur duSealedQubCbor. Sous le wrapper, il est masqué jusqu’à ce qu’un lecteur fournisseK; sans le wrapper, il est lisible par tous sur le stockage permanent dès l’instant de l’upload, avant le déverrouillage. Les applications créateur conformes DOIVENT divulguer cela au moment du scellement. - La détection est structurelle. Un lecteur/une intégration conforme distingue les deux formes par analyse : des octets qui s’analysent comme
OuterWrapperempruntent le chemin de désenveloppement avecK; des octets qui s’analysent comme unSealedQubCbornu sont acceptés directement. Aucun drapeau de fil n’est requis, etqub_idne lie pas la visibilité — le même contenu est identique au niveau octet à la coucheSealedQub, qu’il soit scellé public ou privé.
Le privé (enveloppé) reste la valeur par défaut ; le public est un choix explicite du créateur, par qub.
14. Vecteurs de test
14.1 Dérivation de qub_id
Entrée :
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, paramètres drand mainnet §14.2)
body = "Hello, future." (UTF-8, 14 octets)
title = absent
Intermédiaire :
body_hash = SHA3-256("Hello, future.")
= 76ab8b3f843c6ed4f2d0fd75b9f457b4
ad49dd4450f9c22723ae430e3af3211d
title_hash = [0u8; 32] (title absent — sentinelle §4.2.1)
Séparateur de domaine (10 octets) :
[0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x32, 0x00]
Pré-image (108 octets — V1.2) :
domain_separator || // 10 octets
0x01 || // version
0x01 || // content_type
0x0000000067748580 || // created_at en i64 big-endian (1735689600)
0x00000000677DC000 || // unlock_at en i64 big-endian (1736294400)
0x0000000000000000 || // outcome_at_or_zero (outcome_at absent)
0x000000000047A595 || // drand_round en u64 big-endian (4695445)
body_hash || // 32 octets
title_hash // 32 octets (sentinelle tout-à-zéro ; title absent)
Sortie attendue :
qub_id = SHA3-256(preimage)
= 3a9fcb31b750d985c262fada6d4f777f
d6a28be831d941d85c131f5a4bbaf8a4
Les implémentations DOIVENT produire des valeurs body_hash et qub_id identiques pour cette entrée. Ce vecteur de test DEVRAIT être le premier test unitaire écrit. Les valeurs canoniques ci-dessus ont été calculées par l’implémentation de référence et DOIVENT correspondre bit pour bit. Dispositions historiques de la pré-image (pré-lancement — aucun qub en production ne dépendait de celles-ci) : le qub_id V1.0 sur 92 octets était 3d9fc2390eab043d38a1669ed3b71be76f9eefe872b9569ab1aaa027b88392b0 ; le qub_id V1.1 sur 100 octets (après intégration d’outcome_at_or_zero) était b0d032898ad629795150fdcb3f84e518f59ed05b7a2a82bc24ebdb87f52144ed. La V1.2 intègre drand_round et fait passer le séparateur de domaine à QUB_ID_V2.
14.2 Correspondance déverrouillage-tour
Entrée :
unlock_at = 1735689600
chain_genesis_time = 1595431050
chain_period_seconds = 30
Calcul :
(1735689600 - 1595431050) / 30 = 4675285.0
ceil(4675285.0) = 4675285
drand_round = 4675285
14.3 Aller-retour CBOR canonique
Les implémentations DOIVENT vérifier que serialize(parse(serialize(qub))) == serialize(qub) pour toutes les entrées valides. C’est un test de propriété, pas un vecteur unique.
14.4 PactTerms CBOR (content_type 0x03)
Entrée :
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
Ordre canonique des clés CBOR (PactTerms) :
"notes"(6) < "terms"(6) < "title"(6) < "party_a"(8) < "party_b"(8) < "pact_version"(13)
Ordre canonique des clés CBOR (PactTerm) :
"key"(4) < "value"(6)
Ordre canonique des clés CBOR (PartyIdentifier) :
"label"(6) < "contact"(8)
Les octets CBOR canoniques et le body_hash SHA3-256 sont calculés par l’implémentation de référence. Les implémentations DOIVENT produire un CBOR identique au niveau octet pour cette entrée.
Les implémentations DOIVENT également vérifier que serialize(parse(serialize(pact))) == serialize(pact) pour toutes les entrées PactTerms valides (test de propriété).
14.5 Vecteurs cross-langage de l’enveloppe externe
L’enveloppe externe (§13) a une fixture canonique distincte à crates/qub-core/tests/vectors/wrapper_v1.json. Chaque cas fixe un tuple (key, nonce, qub_id, sealed_cbor) en entrées hex opaques et asserte une sortie expected_wrapper_hex spécifique. Les deux implémentations de référence consomment le même fichier JSON :
- Rust :
crates/qub-core/tests/wrapper_vectors.rs(cargo test -p qub-core --test wrapper_vectors). - TypeScript :
workers/api/src/crypto/__tests__/wrapper.test.ts(npm test).
La fixture fixe actuellement trois cas :
| Cas | Couverture |
|---|---|
basic-text-public |
La forme SealedQub réaliste la plus petite ; aucun champ optionnel. Établit la forme canonique du wrapper pour un qub typique de v1.0. |
with-recipient-pubkey |
SealedQub avec recipient_pubkey défini (chemin Phase 2). Ensemble de clés CBOR interne différent, qub_id différent. |
longer-body |
Corps d’environ 4 KiB — exerce les préfixes de longueur CBOR multi-octets à l’intérieur de l’enveloppe interne et du chiffré externe. |
Les implémentations DOIVENT produire un expected_wrapper_hex identique au niveau octet pour les entrées enregistrées. La régénération de la fixture nécessite QUB_REGEN_VECTORS=1 cargo test -p qub-core --test wrapper_vectors et est réservée aux changements de format délibérés.
15. Gouvernance du profil cryptographique (futur)
Cette section est informative pour la v1 et devient normative dès la première fois qu’un second algorithme entre dans l’une des primitives cryptographiques de qub.
15.1 Posture actuelle
Le protocole v1 lie exactement un algorithme par primitive :
- Signature : ML-DSA-65 (
sig_alg = 0x01; clé publique de 1 952 octets, signature de 3 309 octets) et non signé (sig_alg = 0x00). Le registre §9.2 ne définit aucune autre valeur ; un vérificateur v1 DOIT rejeter toutsig_algen dehors de{0x00, 0x01}. Une future entrée Ed25519 est anticipée (§15.3) mais n’est pas attribuée en v1. - Timelock : drand quicknet uniquement — le hash de chaîne, la clé publique, l’instant de genèse et la période sont des paramètres réseau fixes portés par le
DrandTimelockProvider::quicknet()de référence (crates/qub-core/src/tlock.rs) etconfig/drand-endpoints.json. - Enveloppe externe : AES-256-GCM v1 uniquement (§13).
Les vérificateurs codent actuellement en dur les longueurs de clé et de signature par primitive. Le format de fil n’expose aucune surface d’agilité.
15.2 Forme prévue
Lorsqu’un second algorithme entrera dans le protocole, le vérificateur sera configuré pour un CryptoProfile nommé (par exemple, ExqubV1) énumérant l’ensemble exact des valeurs autorisées par primitive — sig_algs, chaînes drand, versions de wrapper, types de contenu. Le profil est fixé au moment de la vérification, jamais négocié en bande. Toute valeur en dehors du profil actif est rejetée.
Cela garantit que l’ajout de ML-DSA-87 ou l’activation d’Ed25519 ne peut pas affaiblir rétroactivement les configurations de vérificateur existantes : un vérificateur v1 reste un vérificateur v1 même après la publication d’un profil v2.
15.3 Conditions de déclenchement
Promouvoir §15 au statut normatif dès que l’une des propositions suivantes est faite :
- Un second octet
sig_alg(activation d’Ed25519, ML-DSA-87, ou toute nouvelle entrée au registre §9). - Une seconde chaîne drand en usage en production.
- Une seconde version d’enveloppe externe.
Jusque-là, §15 est un emplacement réservé qui fixe la forme de migration afin que les futures PR atterrissent sur une cible connue plutôt que de re-débattre de la surface de négociation à partir de zéro.