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. |
| Clés inconnues | Rejet avec erreur. Les analyseurs NE DOIVENT PAS tolérer de clés de map hors de l’ensemble de clés canonique du type — deux chaînes d’octets canoniques distinctes ne doivent jamais se décoder vers la même valeur (encode(decode(x)) == x), et pour les charges utiles signées, une clé supplémentaire serait du contenu caché sur lequel les deux signatures s’engagent. L’évolution du schéma passe par version, jamais par des clés supplémentaires. |
| 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
Deux versions de pré-image ont existé. Toutes les signatures DOIVENT utiliser V2, et les vérificateurs DOIVENT accepter uniquement V2. L’ancienne pré-image V1 (documentée ci-dessous à titre de référence historique) était acceptée comme repli de vérification uniquement pendant la migration vers V2 ; ce repli a été retiré et une signature V1 uniquement est désormais rejetée.
V2 (actuelle — produite par toute nouvelle signature d’auteur, ainsi que par les deux signatures du flux de préparation / contre-signature de pacte) :
sig_input = SHA3-256(
"QUB_AUTHOR_SIG_V2" || // 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.x
sender_label_hash || // [u8; 32] : SHA3-256(NFC(sender_label)),
// ou 32 octets nuls si absent
reply_to_or_zero // [u8; 32] : qub_id parent, ou 32 octets
// nuls si absent
)
// Pré-image totale : 155 octets → hachage de 32 octets
signature = Sign(author_secret_key, sig_input)
sender_label_hash suit la même convention de sentinelle d’absence que title_hash (§4.2.1) : 32 octets nuls ne constituent pas une sortie SHA3-256 valide, de sorte que « absent » ne peut jamais entrer en collision avec une étiquette présente. Tous les champs sont de largeur fixe, donc la pré-image est sans ambiguïté sans préfixes de longueur.
V1 (ancienne — RETIRÉE ; n’est plus produite ni acceptée à la vérification) :
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
La pré-image V1 omettait sender_label et reply_to. Elle était acceptée comme repli de vérification uniquement pendant la migration vers V2 ; ce repli a depuis été retiré — les vérificateurs DOIVENT accepter uniquement la pré-image V2. La définition est conservée ici à titre de référence historique et pour expliquer le séparateur de domaine ci-dessous. Une signature qui ne se vérifie que contre V1 DOIT être traitée comme un échec de vérification.
Séparateurs de domaine : "QUB_AUTHOR_SIG_V1" / "QUB_AUTHOR_SIG_V2" font 17 octets ASCII chacun ([0x51, 0x55, 0x42, 0x5F, 0x41, 0x55, 0x54, 0x48, 0x4F, 0x52, 0x5F, 0x53, 0x49, 0x47, 0x5F, 0x56, 0x31/0x32]). Pas de remplissage. Le séparateur différent sépare par domaine les deux constructions, de sorte qu’une signature sur une pré-image ne peut jamais se vérifier comme l’autre.
Octet org_id_present : l’octet suivant unlock_at 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. La sig_input V2 s’engage directement sur version, qub_id, body_hash, unlock_at, sender_label et reply_to (plus le séparateur de domaine fixe et l’octet org_id_present). qub_id est lui-même dérivé de version, content_type, created_at, unlock_at, outcome_at, drand_round et body_hash via la pré-image §4.1, donc tout changement de ces champs produit un qub_id différent et invalide la signature transitivement. La surface 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 |
sender_label |
✓ | Entrée directe via sender_label_hash (pré-image V2 — la seule forme acceptée) |
reply_to |
✓ | Entrée directe via reply_to_or_zero (pré-image V2 — la seule forme acceptée) |
content_type |
✓ | Transitivement, via la pré-image qub_id |
created_at |
✓ | Transitivement, via la pré-image qub_id |
outcome_at |
✓ | Transitivement, via la pré-image qub_id |
drand_round |
✓ | Transitivement, via la pré-image qub_id (V1.2) |
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 |
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.) |
Pourquoi V2 est la seule pré-image acceptée.
- Sous la pré-image V1 retirée, une partie ayant un accès en écriture aux octets stockés pouvait échanger
sender_label(« Alice » → « Mallory ») ou re-rattacherreply_to— et re-chiffrer après le tour — sans invalider la signature de l’auteur, car aucun de ces champs n’était dans la pré-image signée. V2 couvre les deux, de sorte que tout changement de l’un ou l’autre champ fait basculer la vérification à « échec ». Comme les vérificateurs n’acceptent désormais que V2, cet échange est fermé pour chaque signature : une signature qui ne lie aucun des deux champs (c.-à-d. qui ne se vérifie que contre V1) est rejetée d’emblée plutôt que dégradée vers celle-ci. - Le
author_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.
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 (formule V2, §9.3).
6. Verify(author_pubkey, sig_input, author_signature). La pré-image V2 est la
seule forme acceptée — l’ancien repli V1 est retiré (§9.3), donc une
signature qui ne se vérifie pas contre V2 échoue, point final.
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 (V2 uniquement — l’ancien
repli V1 est retiré ; tous les clients de pacte produisent des signatures V2).
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(et, sous V2, les mêmessender_label_hashetreply_to_or_zero). - Pour permettre au contre-signataire de reconstruire la pré-image V2 sans accès aux octets bruts de l’enveloppe, le service d’émission impose au moment de l’émission que le
sender_labeld’une enveloppe de pacte soit égal àpact_terms.party_a.labelet quereply_tosoit absent. Les deux conditions sont vraies pour tout pacte de client de référence ; les enveloppes qui les violent sont rejetées à l’émission. - 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.
- Au sein d’une version majeure connue, les décodeurs DOIVENT rejeter les clés de map inconnues (§3.1) — l’évolution du schéma passe par l’introduction d’une nouvelle
version, pas par l’ajout de clés que les décodeurs existants ignoreraient. (Des révisions antérieures de cette spécification permettaient de tolérer des champs optionnels inconnus ; cette clause est retirée — elle rendaitencode(decode(x))non injectif et ouvrait un vecteur de contenu signé caché sur les charges utiles des pactes.) - 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 de map CBOR inconnues (clés absentes de l’ordre canonique §3.2) DOIT le rejeter avec une erreur de décodage (§3.1). La compatibilité ascendante repose sur le champ version, pas sur la tolérance de clés : les additions futures — même des métadonnées mineures — sont livrées sous une nouvelle valeur de version, qu’un lecteur v1 rejette avec une erreur claire « protocole plus récent » plutôt que d’écarter silencieusement du contenu sur lequel les signatures s’engagent.
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). - Appelant de l’API de scellement côté serveur : son CSPRNG local ; l’appelant fournit et conserve
Ksous la formewrapper_key_b64url. Le Worker utiliseKen mémoire pour l’enveloppe mais NE DOIT PAS la persister. Cela permet à une nouvelle tentative idempotente de récupérer une réponse expurgée à l’aide de la capacité conservée par l’appelant, au lieu de dépendre d’un secret à usage unique généré par le serveur.
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 Les 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.
16. Journal de transparence et niveaux de durabilité (Conception — revue achevée)
Statut. Cette section est une spécification de conception. Les formats de fil, le hachage et le modèle de confiance ci-dessous sont normatifs pour l’implémentation, mais aucun code de journal de transparence n’a encore été livré. La revue externe W5 est achevée : §16.15 consigne les décisions résolues et les contraintes de lancement contraignantes qui en sont issues. L’implémentation peut procéder sous ces contraintes. §16 reste tournée vers l’avenir au même sens que §15 — elle fixe la cible afin que l’implémentation atterrisse sur une conception arrêtée plutôt que de re-dériver le modèle de confiance lors de la revue de code. Elle est strictement additive — chaque qub existant conserve sa transaction de stockage permanent individuelle et il n’y a aucun changement au format de fil
SealedQub/QubEnvelope.
16.1 Justification et niveaux de durabilité
Aujourd’hui, la durabilité d’un qub et son engagement temporel reposent tous deux sur une unique transaction de stockage permanent par qub (§11). Cela couple la latence de scellement à la finalité du stockage permanent, fait de l’upload par qub un plafond de coût produit (ARWEAVE_DAILY_CEILING), et n’offre aucun ordonnancement à inviolabilité prouvable entre qubs. Le journal de transparence ajoute deux couches en dessous et autour de ce niveau unique :
| Niveau | Nom | Garantie | Quand |
|---|---|---|---|
| T1 | Accusé synchrone R2-d’abord | Plancher de durabilité — les octets scellés sont écrits sur un stockage durable avant le retour du scellement (< 300 ms p95). |
Chaque qub, de manière synchrone (§16.10). |
| T2 | Inclusion par lots au journal de transparence | Engagement universel en ajout seul, à inviolabilité prouvable + ordre total, ancré au stockage permanent. | Chaque qub, différé + par lots (§16.5–16.7). |
| T3 | Permanence de stockage permanent par qub | Une transaction de stockage permanent individuelle pour le qub. | Vente incitative payante, et le repli en cas d’indisponibilité du stockage permanent (§16.8). |
T2 fait du stockage permanent par qub un choix (T3) plutôt que l’unique chemin de durabilité. ARWEAVE_DAILY_CEILING est retiré comme plafond produit et rétrogradé en disjoncteur sur le seul portefeuille d’ancrage dédié (§16.7) ; les scellements des utilisateurs ne sont jamais rejetés pour l’avoir dépassé.
Honnêteté sur la durabilité (résolu — §16.15 Q6). La durabilité ne régresse pas : l’écriture T1 sur R2 est synchrone et à écriture unique, donc un qub de palier gratuit qui n’a pas acheté T3 est pleinement durable à l’instant où le scellement retourne. Ce qui se grossit, c’est le temps d’engagement borne-supérieure prouvable : pour un qub gratuit, il devient l’heure du bloc d’ancrage plutôt que l’heure du bloc d’une transaction par qub. À faible volume — l’état réaliste du lancement précoce et des heures creuses — la cadence quotidienne complète est le plancher typique, pas un cas limite rare. Le cadrage produit est donc une borne supérieure sans aucune latence numérique engagée — « scellé et durable maintenant ; un horodatage public indépendant est ajouté au prochain ancrage de journal (en général quotidien) » — et la preuve d’engagement à l’heure exacte est une propriété T3 payante, divulguée sur la surface de comparaison des niveaux et dans les conditions (§16.11, §16.15 Q6). Toute borne temporelle est un SLO interne seulement, jamais un SLA commercialisé.
16.2 Structure du LogLeaf (deux formes engagées)
Une entrée de journal est un LogLeaf, encodé en CBOR canonique écrit à la main sous le profil §3.1 (longueur définie, sans tags, sans flottants, entiers en forme la plus courte, texte NFC, champs optionnels omis lorsqu’ils sont absents, clés triées par longueur d’octets encodés croissante puis lexicographiquement). Le garde-fou canonique §3.1 analyser → ré-encoder → comparer est appliqué sur le chemin d’encodage avant le hachage (et pas seulement au décodage), de sorte que deux implémentations ne peuvent diverger sur les octets de la feuille par une différence de largeur d’entier ou d’ordre de clés. Tous les entiers sont des u8 / u64 / i64 ; tous les condensats sont des chaînes d’octets de 32 octets (bstr[32]). Un identifiant de transaction de stockage permanent enregistré est un condensat SHA-256 brut de 32 octets porté comme bstr[32], jamais une chaîne de texte base64url (conforme à §3.3).
La feuille a deux formes sélectionnées par un octet kind, parce que sur le chemin d’upload par défaut le Worker est aveugle aux octets : POST /api/v1/upload ne reçoit que qub_id et unlock_at comme assertions client non fiables — body_hash, drand_round, created_at et drand_chain_version sont tous scellés à l’intérieur de l’enveloppe externe §13, dont le Worker ne détient jamais la clé. Seul le chemin de scellement côté serveur (POST /api/v1/seal) dérive body_hash / drand_round du texte clair. Une forme de feuille unique portant body_hash + drand_round engagerait donc des valeurs que l’opérateur n’a jamais vérifiées pour la majorité des qubs réels. La séparation maintient chaque valeur engagée honnête :
| Clé | Long. enc. | Type | Présence | Signification |
|---|---|---|---|---|
seq |
4 | u64 |
requis | Index global de feuille à base 0 ; la position à laquelle la preuve d’inclusion s’engage. |
kind |
5 | u8 |
requis | 0x01 attesté (scellement-serveur) ou 0x02 asserté (scellement-client / upload aveugle aux octets). |
ref |
4 | bstr[32] |
requis | Identifiant de référence de la feuille. Attesté → qub_id brut. Asserté → l’identifiant aveuglé SHA3-256(qub_id ‖ log_blind_secret) (§16.2.1). |
chash |
6 | bstr[32] |
requis | Adresse de contenu SHA3-256(stored_bytes) — le seul lien de contenu que le Worker peut toujours calculer honnêtement, sur les deux chemins. |
unlock_at |
10 | i64 |
requis | Copié (attesté) ou asserté (asserté) ; validé > 0 avant d’entrer dans la feuille. |
received_at |
12 | i64 |
requis | Horloge murale du Worker à l’accusé R2. Non probant (asserté par l’opérateur ; §16.6). Présent pour l’auto-description, jamais une preuve. Validé > 0. |
body_hash |
10 | bstr[32] |
kind=0x01 seulement |
Omis sur 0x02 — le Worker ne le détient pas sous §13. |
drand_round |
12 | u64 |
kind=0x01 seulement |
Omis sur 0x02. |
Une feuille kind=0x02 n’engage délibérément ni body_hash ni drand_round : elle atteste l’engagement et l’ordonnancement d’un chiffré opaque à l’adresse de contenu chash, revendiquant qub_id et unlock_at — pas son texte clair ni son tour. Les jambes texte-clair/tour d’un qub asserté proviennent de la vérification de bundle .qub §11 existante, pas du journal (§16.11). drand_chain_version n’est pas dans la feuille (il est à l’intérieur du wrapper sur le chemin par défaut) ; la granularité de chaîne réside sur l’ancre (§16.7). Discipline de l’encodeur : rejeter un ref ou chash tout-à-zéro, et rejeter un unlock_at / received_at non positif, reflétant le garde sentinelle outcome_at > 0 dans cbor.rs.
16.2.1 Aveuglement des qubs privés
Le journal ne doit pas devenir l’oracle d’énumération que l’enveloppe externe §13 existe précisément pour empêcher (§13.1). Pour un qub privé (enveloppé), la feuille asserted engage l’identifiant aveuglé SHA3-256(qub_id ‖ log_blind_secret), où log_blind_secret est un secret détenu par le serveur, et omet body_hash. Un tiers ne peut pas lier une telle feuille à un qub_id spécifique ; le détenteur du qub, qui a l’URL de remise et donc qub_id, peut recalculer l’aveuglement pour confirmer sa propre inclusion. Un qub public (déjà énumérable, portant déjà l’étiquette de stockage permanent Visibility: public selon §13.8) engage le qub_id brut. C’est le seul endroit où la vérifiabilité autonome cède délibérément à un invariant de confidentialité porteur ; le lien autonome pour les qubs privés est chash (§16.9).
Garde de log_blind_secret (résolu — §16.15 Q4). L’aveuglement protège la non-liabilité des feuilles, pas la confidentialité du texte clair (le wrapper §13 assure celle-ci indépendamment). En cas de compromission de log_blind_secret, pour tout qub_id que l’adversaire détient déjà ou peut reconstruire (chaque qub dont il a le bundle/l’URL, plus tout qub_id à faible entropie ou public), il recalcule la ref de la feuille en un seul hachage et la lie — c’est une liaison directe d’une population connue, pas une recherche par force brute sur un espace inconnu. Classer log_blind_secret comme un secret de niveau corrélation/Sybil dans le même palier de garde que les autres secrets serveur, et le faire tourner uniquement vers l’avant (une rotation ré-aveugle les feuilles futures ; elle ne peut pas dé-lier rétroactivement celles déjà ancrées).
16.3 Hachage des feuilles et des nœuds
Hachage à séparation de domaine RFC 6962 §2.1, SHA-256 remplacé par SHA3-256 :
leaf_hash = SHA3-256(0x00 || canonical_cbor(LogLeaf))
node_hash(l,r) = SHA3-256(0x01 || l || r)
empty tree = SHA3-256("") // défini mais jamais ancré
Les octets de préfixe de domaine 0x02 (chaîne d’entrées, §16.4) et 0x03 (hash STH, §16.6) sont réservés et disjoints de ceux-ci. Ce sont des octets uniques et ne peuvent donc pas entrer en collision avec les séparateurs de domaine ASCII de 10 octets existants (QUB_ID_V2, etc.). L’arbre est l’arbre RFC 6962 déséquilibré complet à gauche (chaque scission interne au plus grand puissance de deux strictement inférieure au nombre de feuilles du sous-arbre), ce qui permet aux preuves d’inclusion et de cohérence de partager un unique algorithme de chemin d’audit. La spécification de référence porte un pseudocode explicite de dérivation gauche/droite et fixe un vecteur de test non-puissance-de-deux (5 feuilles) afin que le cas de promotion de bord droit — qu’un vecteur à 4 feuilles masque — soit exercé.
16.4 Chaînage de hachage (interne)
Le LogDO maintient une chaîne d’entrées interne à des fins de cohérence-après-crash uniquement. Elle n’est jamais publiée et jamais exposée au vérificateur :
entry_chain[seq] = SHA3-256(0x02 || entry_chain[seq-1] || leaf_hash[seq])
entry_chain[-1] = SHA3-256("QUB_TLOG_GENESIS_V1")
L’autorité en ajout seul publiée est la racine de Merkle cumulative + son ancre (§16.5–16.6), jamais l’ordre brut dans lequel l’opérateur se trouve servir les feuilles : la chaîne se recalcule pour tout ordre servi, donc seule la racine ancrée fixe la position canonique.
16.5 Arbre de Merkle cumulatif et regroupement par lots
Il y a un unique arbre RFC 6962 en croissance perpétuelle sur toutes les feuilles dans l’ordre seq — pas des arbres isolés par lot. (Une construction par chaînage de feuilles-de-report par lot a été rejetée : ce n’est pas une vraie relation de préfixe, donc ses « preuves de cohérence » sont infondées.) L’arbre cumulatif donne de véritables preuves de cohérence RFC 9162 et permet à une seule ancre récente de prouver l’inclusion de tout qub plus ancien.
Le Durable Object LogDO est l’unique rédacteur (blockConcurrencyWhile, à l’image de QuotaDO / EntitlementDO) — ajouter à un journal partagé est une opération lecture-modification-écriture sur un état partagé et DOIT donc passer par un DO, jamais par KV. Il met en cache la frontière du bord droit de l’arbre (O(log n) hachages) afin que la clôture d’un lot soit en O(batch). Un lot est l’ensemble des feuilles ancrées ensemble ; ses déclencheurs sont configurables, non figés dans le protocole : une avance de tree_size d’au moins LOG_BATCH_MAX_LEAVES (défaut 4096), ou un âge atteignant la cadence d’ancrage, ou un vidage forcé lorsqu’un scellement T3 payant atterrit. root_i est le hash d’arbre de Merkle cumulatif sur les feuilles 0 .. tree_size_i.
16.6 Tête d’arbre signée via ancre de stockage permanent
La transaction d’ancrage de stockage permanent est la tête d’arbre signée (Signed Tree Head) et remplace une signature de l’opérateur pour la tête d’arbre elle-même : l’ancre quotidienne n’a besoin d’aucune clé qub parce que le champ owner de la transaction de stockage permanent est la signature. La thèse du fossé tient — le substrat immuable, et non un secret détenu par qub, est porteur pour la racine ancrée.
Il existe exactement une clé de signature qub à chaud dans la conception, et elle est épinglée : la clé de reçu par scellement (§16.10). Sa clé publique est engagée dans le LogProfile (distribué avec le vérificateur) et contre-signée par anchor_owner, de sorte qu’un vérificateur valide un reçu contre la même racine épinglée que l’ancre. C’est la résolution de §16.15 Q2 — une clé de reçu non épinglée et rotatable par l’opérateur serait répudiable (l’opérateur pourrait nier que la clé était la sienne), ce qui annulerait la valeur de responsabilité du reçu face à l’adversaire de niveau opérateur que le reçu existe pour dissuader. Donc : qub ne détient aucune clé de signature de journal non épinglée ; la clé de reçu est épinglée et contre-signée par anchor_owner.
Le SignedTreeHead est du CBOR canonique (clés par longueur encodée) : size:u64, root:bstr[32], batch:u64, prev:bstr[32] (le sth_hash précédent ; genèse = 32 octets nuls), log_id:bstr[32], first_seq:u64, anchored_at:i64. Son hash est sth_hash = SHA3-256(0x03 || canonical_cbor(SignedTreeHead)).
Racine de confiance épinglée. log_id = SHA3-256("QUB_TLOG_V1" || anchor_owner_address). Un vérificateur conforme DOIT exiger anchor_tx.owner == LogProfile.anchor_owner, où anchor_owner (et la clé publique de reçu) est intégré dans qub_core comme le LogProfile — aux côtés des constantes quicknet déjà présentes dans DrandTimelockProvider::quicknet() — et distribué avec le binaire du vérificateur. Le vérificateur DOIT aussi vérifier la liaison données → tx_id de la transaction de stockage permanent localement plutôt que de faire confiance à une réponse /raw/ de passerelle. Cela ferme la faille d’équivocation par portefeuille pirate : « ancré sur le stockage permanent » est dénué de sens tant que le vérificateur n’épingle pas quel portefeuille.
La rotation est une extension de la gouvernance §15, pas une réutilisation (résolu — §16.15 Q3). La surface de profil de §15.2 énumère actuellement seulement les sig_algs / chaînes drand / versions de wrapper / types de contenu, et la liste de déclencheurs de §15.3 n’en contient aucun — LogProfile / anchor_owner n’est pas encore dans la surface de §15. La gouvernance de rotation doit donc être construite : §15.3 est étendue (ci-dessous) pour ajouter le déclencheur LogProfile, et une rotation est un changement signé de LogProfile livré dans une mise à jour du vérificateur. Une rotation planifiée porte une contre-signature sortante → entrante ; une rotation motivée par une compromission ne le peut pas (la clé sortante est non fiable/indisponible précisément alors) et se replie sur le changement gouverné par §15, la vérification de fourche de l’ancre précédente (ci-dessous) bornant les dommages dans l’intervalle.
Fenêtre d’équivocation (paramètre de confiance de premier rang). Une feuille n’est résistante à l’équivocation qu’une fois son ancre couvrante confirmée sur le stockage permanent. La fenêtre est received_at → confirmation de l’ancre (≤ cadence + finalité du stockage permanent). Pendant celle-ci, les seules garanties sont le reçu de scellement épinglé (§16.10) et l’intégrité opérationnelle de qub. Trois artefacts de responsabilité rendent cela honnête plutôt qu’élusif (le modèle de témoin est la résolution de §16.15 Q2) :
- Reçu de scellement signé épinglé — l’analogue du SCT retourné dans la réponse d’upload (§16.10), signé par la clé de reçu épinglée et contre-signée par
anchor_owner. Une feuille abandonnée avant son ancre laisse à la victime un reçu non répudiable à publier, fermant la faille d’omission silencieuse. - Méthodologie de surveillance publiée + parcours de la chaîne prev — la chaîne
prevde l’ancre est parcourue de la tête vers la genèse ; une fourche (deux ancres à un mêmesizeavec unrootdifférent, ou unprevrompu) est une preuve publiable d’inconduite. La détection d’équivocation est un engagement opérationnel déclaré, pas une hypothèse silencieuse. - Têtes auto-publiées en double — chaque nouvelle tête
{sth_hash, tree_size}est postée sur un dépôt GitHub public en ajout seul dédié, détenu par qub (la jambe porteuse d’auto-publication à inviolabilité prouvable), avec une publication sociale comme corroboration de meilleur effort seulement. Un échec de publication DOIT déclencher une alerte (pas échouer silencieusement).
Borne d’honnêteté (contrainte contraignante). Parce que qub contrôle les deux surfaces de publication, c’est auto-publié, non témoigné de manière indépendante. Aucune surface produit, marketing ou juridique ne peut prétendre que le journal est « témoigné de manière indépendante » ; l’affirmation permise est que l’équivocation est détectable et laisse un reçu non répudiable. Un véritable témoin tiers indépendant est différé à un futur changement de gouvernance §15.
received_at est asserté par l’opérateur et aucune affirmation ne peut s’appuyer dessus — il n’est jamais présenté comme preuve ni comme corroboration de litige sur quelque surface produit / juridique / API / de rendu de preuve que ce soit. L’heure du bloc d’ancrage de stockage permanent T est le seul horodatage sans confiance (une borne supérieure sur « journalisé le »). Toute vérification de cohérence de surveillance sur received_at DOIT comparer à T, pas au champ STH anchored_at contrôlé par l’opérateur ; une telle vérification est un garde-fou contre un bug d’horloge d’un opérateur honnête seulement, pas un contrôle de responsabilité contre un opérateur malveillant (§16.15 Q5).
16.7 Format et cadence de la transaction d’ancrage
L’AnchorBundle est le corps de transaction de stockage permanent en CBOR canonique, écrit via le bundler §16.8 : ver:u8, sth:bstr (octets SignedTreeHead canoniques), prev_anchor:bstr (identifiant de transaction de l’ancre précédente en octets bruts ; omis à la genèse), chain_hash:tstr (la chaîne drand en vigueur — quicknet), et le flux de feuilles-CBOR du lot dans l’ordre seq afin que l’ancre soit autonome : un surveillant re-dérive root à partir du corps sans aucune dépendance à qub. (Si le flux de feuilles devient volumineux à haut volume, une révision future pourra n’engager qu’une plage de feuilles par référence ; noté, non adopté en v1.)
Les étiquettes de stockage permanent sont intentionnellement énumérables — le journal est fait pour être trouvé, contrairement aux qubs privés : App-Name: qub-tlog, Anchor-Format: 1, Log-Id: <hex>, Batch: <n>, Tree-Size: <n>, Root: <hex>, Prev-Anchor: <tx>, Content-Type: application/cbor. Les étiquettes sont des indices non fiables ; le corps CBOR est l’unique autorité.
Cadence : quotidienne par défaut, révisée selon le volume (le déclencheur de taille raccourcit automatiquement la cadence effective sous charge) ; un scellement T3 payant force une ancre afin que les clients payants n’attendent jamais un jour. Le portefeuille d’ancrage est dédié et à faible vélocité, séparé du portefeuille d’upload — il DOIT être son propre JWK (une clé distincte, pas un rôle logique sur le portefeuille d’upload) afin qu’une compromission du portefeuille d’upload ne puisse pas falsifier des ancres — avec un budget strict de transactions d’ancrage par jour (l’ARWEAVE_DAILY_CEILING rétrogradé). La posture de garde est énoncée sans détour : une clé à chaud à portée étroite avec un disjoncteur serré et un solde faible, pas « à froid » — un portefeuille qui auto-signe quotidiennement ne peut pas être à froid, et la spécification ne prétend pas le contraire.
16.8 Bundler ANS-104
Un encodeur de DataItem ANS-104 et un signeur de deep-hash maison, environ 300 lignes, Web Crypto uniquement, zéro dépendance npm (les deux SDK Turbo échouent au garde-fou de chaîne d’approvisionnement npm ci --ignore-scripts). Disposition des octets du DataItem :
signatureType (2, LE) || raw_signature || owner || target(flag+0|32) || anchor(flag+0|32) || num_tags(8, LE) || tags_len(8, LE) || avro_tags || data
La signature est le deepHash Arweave — un condensat SHA-384 récursif (exigence de fil d’Arweave, crypto.subtle.digest("SHA-384")) sur ["dataitem", "1", sig_type, owner, target, anchor, encoded_tags, data] — puis RSA-PSS sur le deep-hash avec le JWK du portefeuille via crypto.subtle ; id = base64url(SHA-256(signature)). Le SHA-384 ici est mis en quarantaine comme primitive Arweave-fil-seulement, jamais une primitive de confiance qub (§15 consigne la cloison ; le hachage de confiance qub est SHA3-256 de bout en bout).
Un unique chemin de code sert trois consommateurs : la permanence T3 par qub payante, le repli en cas d’indisponibilité du stockage permanent (mettre le DataItem en file, retourner l’accusé R2-d’abord quoi qu’il arrive — cela ferme l’impasse ARWEAVE_UNAVAILABLE 503 actuelle), et l’écriture de l’AnchorBundle. Schéma de signature (résolu — §16.15 Q8) : v1 signe avec RSA-PSS (type de signature 1) en réutilisant le mécanisme de JWK de portefeuille de stockage permanent existant (zéro nouvelle garde de clé à longue durée de vie, servant la thèse « un secret de moins ») ; Ed25519 est différé au chemin de migration PQ de §15.
Le deep-hash écrit à la main est le code le plus à risque et le moins naturellement couvert de W5, donc son verrouillage est non négociable (§16.15 Q8) :
- La fixture cross-langage
tlog_v1.json(Rust + TS, le motifwrapper_v1.jsonde §14.5) couvre le deep-hash, les octets + l’id du DataItem, les hachages de feuilles, une racine + chemin d’audit à 5 feuilles, un hash STH, une preuve d’inclusion et une preuve de cohérence — dans les deux directions, signature et vérification (la direction vérification importe parce que la vérification locale tx → tx_id de §16.6 tire le deep-hash dans chaque vérificateur autonome, pas seulement chez le rédacteur). - Un aller-retour d’interopérabilité ponctuel à travers un bundler ANS-104 de référence, consommé comme données de test statiques uniquement — jamais une dépendance npm d’exécution (la posture Web-Crypto-uniquement / sans scripts d’installation tient).
- Le chemin deep-hash + RSA-PSS doit faire un aller-retour à travers les mêmes primitives
crypto.subtleque la production utilise, afin que l’encodeur maison soit compatible octet pour octet. - Un surveillant d’acceptation post-bundle continu confirme que chaque DataItem d’ancre / de repli atteint effectivement l’acceptation sur le stockage permanent, avec une alarme + un disjoncteur — parce que le deep-hash sert aussi la file de repli en cas d’indisponibilité du stockage permanent, donc une régression silencieuse remplirait cette file d’éléments rejetés par le réseau pendant la panne même qu’elle existe pour couvrir.
16.9 Preuves d’inclusion et de cohérence
Les deux sont du RFC 9162, SHA3-256, servies en CBOR canonique.
InclusionProof — GET /api/v1/qub/:tx_id/proof : ver:u8, leaf:bstr (le CBOR exact de la feuille — le vérificateur recalcule leaf_hash lui-même et ne fait jamais confiance à un hash fourni), index:u64, size:u64, audit:[bstr[32]], root:bstr[32], anchor:{ txid:bstr[32], batch:u64, sth:bstr[32], log_id:bstr[32], block_height?:u64, anchored_at?:i64 }.
ConsistencyProof — GET /api/v1/log/consistency?first=<size_a>&second=<size_b> : ver:u8, first_size:u64, second_size:u64, first_root:bstr[32], second_root:bstr[32], nodes:[bstr[32]], first_anchor, second_anchor. Une unique liste de clés non ambiguë, fixée par vecteur de test.
Vérification autonome (sans serveur qub, étend §11) :
1. Analyser le bundle .qub → SealedQub ; recalculer qub_id (§4.1).
2. Lire leaf.kind.
3a. kind=0x01 (attesté) :
asserter leaf.ref == qub_id
asserter leaf.body_hash == SHA3-256(body)
asserter leaf.drand_round == unlock_round(unlock_at)
3b. kind=0x02 (asserté) :
asserter leaf.ref == SHA3-256(qub_id || blind) // le détenteur fournit blind
OU traiter ref comme opaque et lier via leaf.chash == SHA3-256(stored_bytes)
4. Recalculer leaf_hash = SHA3-256(0x00 || leaf) ; replier `audit` selon RFC 6962
en utilisant index/size ; exiger racine dérivée == proof.root.
5. Récupérer anchor.txid depuis n’importe quelle passerelle ; vérifier la liaison
données → tx_id de la transaction (ne pas faire confiance à une réponse /raw/ de
passerelle) ; EXIGER anchor_tx.owner == LogProfile.anchor_owner.
6. Analyser AnchorBundle ; exiger racine engagée == proof.root et size ==
proof.size ; lire l’heure du bloc de stockage permanent T.
7. Émettre l’affirmation cadrée par leaf.kind (§16.11).
Le stockage de service de preuves DOIT être clé par coordonnée (résolu — §16.15 Q7, précondition bloquante). La génération de preuves pour feuilles froides est neutre en correction seulement si le matériel d’audit R2 est un magasin persistant de nœuds de Merkle clé par coordonnée absolue de l’arbre (level, index) — pas des deltas de nœuds par batch. Avec un magasin clé par coordonnée, tout chemin d’audit (feuille i, taille N) est un ensemble de O(log N) GET R2 directs sans recalcul à travers les frontières de lots ; avec un magasin clé par lot, ce ne l’est pas, ce qui est la lacune de disposition de stockage que cette résolution comble. Les corps de feuilles sont de même adressables par contenu via seq. Un vecteur de test W5 DOIT prouver une feuille froide de l’ère genèse contre une racine bien plus tardive en utilisant uniquement R2 + le stockage permanent avec le stockage du LogDO effacé, afin que l’affirmation de sûreté de récupération de §16.13 soit étayée plutôt qu’assertée. Les GET R2 séquentiels en O(log N) n’ont leur place que sur le point de terminaison de preuve asynchrone — jamais sur le chemin chaud du scellement (§16.10) ni sur un cron par tick.
16.10 Ordonnancement de l’accusé R2-d’abord
La séquence de POST /api/v1/upload devient :
- Gardes de première moitié (auth, validation, clé de partition d’idempotence) — inchangées.
- De manière synchrone
await QUB_CACHE.put(qub-cache/<tx_id>, wrappedBytes)— le plancher de durabilité ; cela ferme aussi la course de pré-cache de W1 (auparavant unctx.waitUntilaprès l’envoi sur le stockage permanent). - De manière synchrone
await LogDO.append(leaf)— un RPC DO en colo ; l’unique rédacteur assigneseq, étend la chaîne d’entrées et met à jour la frontière. (LME sur état partagé → DO, jamais KV.) Le RPCappendne fait que cela — le travail de clôture de lot Merkle enO(batch)s’exécute hors de ce RPC sur l’alarme du LogDO, sinon le p95 de l’appendflambe à chaqueLOG_BATCH_MAX_LEAVES-ième scellement. - Retourner l’accusé maintenant — avec le reçu de scellement (signé par la clé de reçu épinglée, §16.6) et
{ tx_id, log_seq, anchor_status: "pending" }. L’éventail multi-secondes du stockage permanent est retiré du chemin critique. - Un
ctx.waitUntilmet en file le travail différé : l’envoi par qub sur le stockage permanent (désormais meilleur effort / payant ; en cas d’échec il route vers la file de repli du bundler plutôt que de renvoyer un 503 à l’utilisateur) plus les écritures de méta provisoires existantes. La clôture de lot et l’ancrage s’exécutent indépendamment depuis l’alarme du LogDO et le cron d’ancrage quotidien. Pas dectx.waitUntilà l’intérieur d’une boucle ; la clé de partition d’idempotence existante est préservée.
Budget de latence (résolu — §16.15 Q7). La cible < 300 ms p95 est un garde de lancement mesuré, pas une hypothèse. Le chemin critique honnête est les lectures KV de première moitié + un PUT R2 + deux Durable Objects sérialisés — le débit de quota de scellement QuotaDO existant et l’append du nouveau LogDO — donc le budget doit tenir compte de deux allers-retours DO en colo, pas d’un seul. Livrer une alarme de latence LogDO reflétant celle de QuotaDO et traiter une régression du p95 comme un bloqueur de publication.
16.11 Modèle de confiance — l’affirmation précise, cadrée par le kind de feuille
Pour kind=0x01 (attesté) : « Ce contenu — corps correspondant à body_hash, identifié par qub_id — a été engagé dans le journal en ajout seul de qub à la position seq et existait au plus tard à l’heure du bloc de stockage permanent T ; il était cryptographiquement illisible jusqu’au tour drand R = unlock_round(unlock_at). » C’est le triplet complet {liaison de tour tlock + inclusion de Merkle + racine ancrée}.
Pour kind=0x02 (asserté, le défaut) : « Un chiffré opaque d’adresse de contenu chash, revendiquant qub_id et unlock_at, a été engagé dans le journal en ajout seul à la position seq et existait au plus tard à l’heure du bloc de stockage permanent T. » Les jambes du tour et du corps sont fournies par la vérification de bundle .qub §11 existante (qub_core::unlock), pas par le journal ; ce que le journal ajoute par rapport à une simple transaction par qub est un ordonnancement à inviolabilité prouvable, un temps d’engagement borne-supérieure sans confiance, et une résistance à l’équivocation.
Les deux affirmations excluent, selon §11 : l’authorship sans sig_alg ≥ 0x01, l’intention, et la synchronisation sous la granularité de l’ancre. Aucune ne laisse une affirmation s’appuyer sur received_at.
Plafond d’affirmation (contrainte de lancement contraignante — résolu §16.15 Q1). Pour les qubs gratuits / par défaut (kind=0x02), l’affirmation cadrée kind=0x02 ci-dessus est le plafond de ce que toute surface produit, marketing, conditions ou de rendu de preuve peut asserter. Aucune surface ne peut déclarer ou laisser entendre que le journal prouve le contenu ou le tour de déverrouillage d’un qub par défaut — le journal prouve l’ordonnancement + un temps d’engagement borne-supérieure sans confiance d’un chiffré opaque. La preuve de contenu et de tour provient exclusivement de la vérification de bundle .qub §11 existante, qui est indépendante du journal. C’est un bloqueur de lancement strict sur la copie, pas une préférence stylistique ; c’est la résolution qui maintient honnête le chemin par défaut aveugle aux octets.
16.12 Versionnement et coordination W3
Il n’y a aucun saut de fil SealedQub et donc aucun saut de version de protocole (§12.1) : le journal est un side-car qui s’engage sur des champs et des octets existants, donc il n’entre pas dans l’historique de versions de protocole §12.2. Le drand_chain_version optionnel de W3 est intact et reste le seul champ SealedQub optionnel. Le journal introduit à la place ses propres espaces de version indépendants — LOG_VERSION_1, ANCHOR_FORMAT_1, InclusionProof.ver — reflétant l’indépendance de version de wrapper de §12.4 (le wrapper porte un octet de version indépendant de la version de protocole, et les versions de journal suivent la même séparation).
La livraison de preuve est récupérée par défaut, avec une option de ride-along. Une preuve ne peut pas exister au moment du scellement (l’ancre n’a pas encore été écrite), donc le bundle .qub au moment du scellement reste sans preuve. Le vérificateur de W7 récupère GET …/proof une fois, ou en mode entièrement hors ligne reconstruit la preuve à partir de l’AnchorBundle public via une requête de stockage permanent sur Log-Id. Le bundle .qub (W7) réserve un membre inclusion_proof optionnel — absent au scellement, peuplé par une ré-exportation post-ancrage pour l’archivage froid — suivant le même motif « optionnel, omis par défaut, additif » que le drand_chain_version de W3.
16.13 Conservation
Les fenêtres de conservation pour la queue ouverte du LogDO, le substrat R2 de service de preuves, les compteurs de disjoncteur d’ancrage et la file de repli du bundler sont spécifiées dans docs/DATA-RETENTION.md. Principe : le stockage chaud par entrée du journal (LogDO) est récupérable après ancrage ; son matériel d’audit — le magasin de nœuds de Merkle clé par coordonnée (level, index) + les corps de feuilles adressés par seq (§16.9) + les ancres de stockage permanent — est permanent. Récupérer une feuille froide du DO n’invalide jamais une preuve émise, parce qu’une preuve se résout contre ce magasin de nœuds R2 permanent et l’ancre de stockage permanent, pas contre le DO (et le vecteur de test à DO effacé de §16.9 le prouve).
16.14 Vecteurs de test
W5 livre la fixture cross-langage tlog_v1.json (§16.8) plus des vecteurs travaillés : une feuille kind=0x01 et une feuille kind=0x02 → leaf_hash ; la racine cumulative à 5 feuilles ; une preuve d’inclusion ; une preuve de cohérence ; un AnchorBundle ; et un id de DataItem. Ceux-ci vivent aux côtés des vecteurs d’enveloppe externe de §14.5 et sont exercés par les deux implémentations, Rust (qub-core) et TypeScript (Worker).
16.15 Décisions de revue (W5 — résolues)
La revue externe W5 (une passe de conception adversariale + l’aval du propriétaire) est achevée. Chaque décision ci-dessous est arrêtée et reflétée dans le texte de §16 ci-dessus ; les contraintes de lancement contraignantes sont reprises à la fin. L’implémentation peut procéder sous celles-ci.
- Honnêteté de la feuille du chemin par défaut (
kind=0x02) — RÉSOLU. Livrer la séparation à deux kinds de feuille comme spécifié :kind=0x02n’engage nibody_hashnidrand_round. Aucun champ*_body_hashsur le chemin aveugle aux octets (ce serait le plus lisible des faux signaux « vérifié » pour les intégrateurs et une commodité que §11 fournit déjà depuis le bundle). Ne pas exiger le scellement-serveur pour les qubs journalisés-attestés (cela forcerait le texte clair à travers le Worker et détruirait le fossé de crypto-déchirure). Tout court-circuit auto-descriptif relève du bundle.qub/ de l’enveloppe de preuve comme champ recalculé par le vérificateur, jamais un champ de feuille. Plafond d’affirmation confirmé par le propriétaire : §16.11. - Responsabilité d’équivocation / d’omission — RÉSOLU. La clé de reçu de scellement est épinglée dans
LogProfile+ contre-signée paranchor_owner(fermant la contradiction antérieure « pas de clé de signature » ; §16.6). Modèle de témoin au lancement : reçu épinglé + méthodologie de surveillance + parcours de la chaîne prev + têtes auto-publiées en double (dépôt GitHub public détenu par qub, social en meilleur effort), commercialisé comme détectable + reçu, jamais témoigné de manière indépendante. Un véritable témoin tiers est différé à un changement de gouvernance §15. - Racine de confiance anchor-owner épinglée + rotation — RÉSOLU. Adopter l’épinglage
LogProfile(§16.6) ; le vérificateur contrôleanchor_tx.owner == anchor_owneret vérifie la liaison données → tx_id de la transaction localement. La gouvernance de rotation est une extension à construire de §15 (déclencheur §15.3 ajouté), pas une réutilisation ; les rotations planifiées se contre-signent, les rotations motivées par compromission se replient sur le changement §15 avec la vérification de fourche bornant les dommages. - Aveuglement de la feuille des qubs privés — RÉSOLU. Garder l’aveuglement pour les qubs privés (
ref = SHA3-256(qub_id ‖ log_blind_secret)),qub_idbrut pour les qubs publics (déjà §16.2.1),chashcomme lien autonome.log_blind_secretest un secret de niveau corrélation/Sybil, rotation vers l’avant uniquement (§16.2.1). received_at— RÉSOLU. Le garder dans la feuille, engagé mais explicitement non probant ; jamais présenté comme preuve ni comme corroboration de litige sur quelque surface que ce soit. Toute vérification de cohérence de surveillance compare à l’heure du bloc de stockage permanentT, pas à l’anchored_atcontrôlé par l’opérateur (§16.6).- Synchronisation prouvable du palier gratuit — RÉSOLU (aval du propriétaire). La durabilité ne régresse pas ; seul le temps d’engagement borne-supérieure prouvable se grossit jusqu’à l’heure du bloc d’ancrage. La copie du palier gratuit n’utilise aucun SLA numérique (« …ajouté au prochain ancrage de journal, en général quotidien ») ; la preuve à l’heure exacte est une propriété T3 payante, divulguée sur la surface de comparaison des niveaux + dans les conditions (§16.1).
- Arbre cumulatif sur Workers — RÉSOLU. Unique arbre RFC 9162 cumulatif + LogDO à rédacteur unique à frontière en cache (marge confortable face au plafond DO d’environ 1 k écritures/s ; différer le partitionnement Merkle-de-racines-de-partition jusqu’à s’en approcher). Précondition bloquante : magasin de nœuds R2 clé par coordonnée
(level, index)+ le vecteur de test de feuille froide à DO effacé (§16.9) ;< 300 msest un garde de lancement mesuré sur deux DO sérialisés (§16.10). - Schéma de signature ANS-104 + deep-hash — RÉSOLU. RSA-PSS (type de sig 1, réutilisant le JWK du portefeuille d’ancrage dédié) ; Ed25519 différé au chemin PQ §15. Le deep-hash SHA-384 écrit à la main est verrouillé sur la fixture cross-impl dans les deux directions, une vérification d’interopérabilité de bundler de référence statique uniquement, l’aller-retour à
crypto.subtlepartagé, et le surveillant d’acceptation post-bundle sur le stockage permanent (§16.8).
Contraintes de lancement contraignantes (à reporter dans l’implémentation + la revue produit/juridique) :
- Plafond d’affirmation (Q1/Q6). Aucune surface ne peut dire que le journal prouve le contenu ou le tour de déverrouillage d’un qub par défaut ; l’affirmation permise est ordonné, à inviolabilité prouvable, avec un temps d’engagement borne-supérieure sans confiance. La copie d’horodatage du palier gratuit ne porte aucune latence numérique ; la preuve à l’heure exacte est T3 payant uniquement.
- Honnêteté du témoin (Q2). Commercialiser l’équivocation comme détectable + reçu, jamais témoigné de manière indépendante.
- Clés de reçu + d’ancre (Q2/Q8). La clé de reçu est épinglée + contre-signée ; le portefeuille d’ancrage est son propre JWK distinct du portefeuille d’upload.
- Garde du deep-hash (Q8). Aucune ancre ni transaction T3 n’est livrée tant que la fixture dans les deux directions + la vérification d’interopérabilité ne passent pas ; le surveillant d’acceptation alerte en cas d’échec.
- Précondition de stockage (Q7). Le magasin de nœuds clé par coordonnée + le vecteur de feuille froide à DO effacé sont des prérequis pour la garantie « la récupération n’invalide jamais une preuve ».