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 :

4.2 body_hash

body_hash = SHA3-256(body)

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

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 :

  1. 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é.
  2. Plafond de longueur. ≤ 2 048 octets (limite pratique des URL dans le navigateur).
  3. NFC + contrôle des codepoints hostiles. Même règle que title et reflection — les codepoints de bidi-override / largeur nulle / bloc tag / BOM / C0 / C1 sont rejetés. La définition correspond à la fonction Rust crate::handle::contains_hostile_text_codepoint et à la fonction TS workers/api/src/utils/unicode.ts::isHostileCodepoint (à garder en synchronisation).
  4. Aucun blanc, aucun caractère de contrôle ASCII. Tout blanc / DEL / octet sous 0x20 n’importe où dans l’URL est rejeté — referme le vecteur d’injection \n / \t que la règle bidi ne couvre pas.
  5. 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.

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 :

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 :

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

10.2 Éléments interdits

Élément Traitement
HTML brut (<div>, <script>, etc.) Entièrement retiré. Aucun HTML ne passe.
Images (![alt](url)) 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 :

  1. Analyser le Markdown avec pulldown-cmark (ou équivalent).
  2. Parcourir l’AST et retirer tout nœud absent de l’allowlist (§10.1).
  3. Pour les nœuds liens : émettre l’URL en texte visible, pas en élément <a> cliquable.
  4. Convertir l’AST filtré en représentation intermédiaire typée (par exemple un enum MarkdownNode avec uniquement des variantes sûres). Le HTML brut est structurellement non représentable dans cette IR.
  5. 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


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_alg0x01, 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.

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
0x020xFF 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 :

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.

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 :

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

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 :

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 :

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 :

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 :

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.