Especificación del protocolo de qub

qub es un protocolo de compromisos temporales criptográficos: un sistema para sellar palabras a una fecha futura y demostrar, cuando esa fecha llegue, exactamente qué se dijo y cuándo.

Tres primitivas lo hacen posible. drand es una baliza de aleatoriedad descentralizada — la fecha de revelación se hace cumplir por la física, no por la buena voluntad de ninguna parte. El almacenamiento público permanente es un almacén público a prueba de manipulaciones — ninguna parte puede editar o eliminar un qub una vez sellado. ML-DSA-65 es una firma digital post-cuántica — cada qub está vinculado a un par de claves cuyo secreto nunca abandona el dispositivo del autor.

Juntas, estas primitivas producen una declaración bloqueada en el tiempo, a prueba de manipulaciones y atribuible — un recibo cuyo valor crece a medida que mejora la capacidad del mundo para fabricar el pasado.

El resto de este documento es la especificación normativa requerida para implementaciones interoperables.


Especificación del protocolo qub

Campo Valor
Versión 1.0 (versión del protocolo 0x01, versión del wrapper externo 0x01)
Fecha 2026-05-01
Estado Borrador
Revisado hasta 2026-05-01

Este documento es la especificación normativa del protocolo para el sistema de compromiso temporal qub. Define las estructuras de datos, las reglas de serialización, las fórmulas de derivación y los procedimientos de verificación requeridos para implementaciones interoperables.

Alcance: la capa del protocolo es intencionalmente neutra respecto al idioma — el cuerpo del qub es plaintext / markdown / bytes de pact opacos, y el renderizado localizado es responsabilidad del visor (aplicación web qub.social, iframe <qub-embed>, clientes MCP, etc.).


1. Notación y convenciones

Notación Significado
u8, u64, i64 Enteros sin signo / con signo del ancho de bits indicado
[u8; N] Arreglo de bytes de longitud fija de N bytes
Vec<u8> Arreglo de bytes de longitud variable
Option<T> Valor de tipo T, o ausente
String Cadena de texto UTF-8, normalizada NFC
`
SHA3-256(x) Hash NIST SHA3-256 de la cadena de bytes x (FIPS 202)
ceil(x) Función techo: el menor entero ≥ x
CBOR Concise Binary Object Representation (RFC 8949)
big-endian Byte más significativo primero

Todos los enteros en las construcciones de preimagen se codifican como arreglos de bytes big-endian de ancho fijo (i64 → 8 bytes, u8 → 1 byte) salvo que se indique lo contrario.

Todas las marcas de tiempo están en segundos Unix UTC.


2. Estructuras de datos

2.1 ComposeQub (estado en memoria del creador)

No serializado en CBOR. No escrito en el almacenamiento permanente. Local a la aplicación del creador.

ComposeQub {
    draft_id:       [u8; 16],        // Aleatorio, generado localmente
    created_at:     i64,             // Segundos Unix UTC
    unlock_at:      Option<i64>,     // Segundos Unix UTC; None mientras se compone
    visibility:     u8,              // 0x01 = público (único valor en MVP)
    content_type:   u8,              // 0x01 = texto (único valor en MVP)
    plaintext:      Vec<u8>,         // Cuerpo del qub UTF-8
    sender_label:   Option<String>,  // Nombre decorativo; no autenticado
    status:         DraftStatus,     // Composing | Sealed | Uploaded | Failed
}

2.2 QubEnvelope (carga útil descifrada)

Serializado usando CBOR canónico (§3). Cifrado dentro del SealedQub. Es la estructura que prueba la integridad del contenido tras el descifrado.

QubEnvelope {
    version:             u8,              // Versión mayor del protocolo (0x01 para v1)
    qub_id:              [u8; 32],        // Derivado (ver §4.1)
    content_type:        u8,              // Registro de tipos de contenido (ver §6)
    created_at:          i64,             // Segundos Unix UTC
    unlock_at:           i64,             // Segundos Unix UTC
    outcome_at:          Option<i64>,     // V1.1 — cuando la realidad emite su veredicto (verdict-uplift-plan §3.1)
    sender_label:        Option<String>,  // Decorativo; no autenticado en MVP
    reply_to:            Option<[u8; 32]>,// qub_id padre para cadenas de respuesta; no en la preimagen de qub_id; no firmado (ver §9.3)
    body:                Vec<u8>,         // Carga útil del contenido (UTF-8 para texto, CBOR para pact)
    body_hash:           [u8; 32],        // SHA3-256(body) (ver §4.2)
    sig_alg:             u8,              // Algoritmo de firma (ver §9.2)
    author_signature:    Option<Vec<u8>>, // Presente cuando sig_alg != 0x00
    author_pubkey:       Option<Vec<u8>>, // Presente cuando sig_alg != 0x00
    cosigner_pubkey:     Option<Vec<u8>>, // Presente para acuerdos bilaterales pact con cofirma
    cosigner_signature:  Option<Vec<u8>>, // Presente para acuerdos bilaterales pact con cofirma
}

Línea base (qub de texto sin firma): version = 0x01, content_type = 0x01, sig_alg = 0x00, todos los campos Option ausentes.

Otras configuraciones v1: content_type = 0x03 (cuerpo pact, ver §6.1); sig_alg = 0x01 (ML-DSA-65) con author_signature y author_pubkey presentes (ver §9.3); cosigner_pubkey y cosigner_signature presentes juntos para pacts cofirmados (ver §9.7); reply_to establecido al qub_id del qub padre para qubs de cadena de respuesta (ver §9.3 para las implicaciones del alcance de la firma).

2.3 SealedQub (formato de cable canónico)

Serializado usando CBOR canónico (§3). Cargado al almacenamiento permanente. Es el artefacto on-chain.

SealedQub {
    version:           u8,              // Versión mayor del protocolo (0x01 para v1)
    qub_id:            [u8; 32],        // Igual que QubEnvelope.qub_id
    visibility:        u8,              // 0x01 = público; los visores v1 rechazan otros valores
    unlock_at:         i64,             // Segundos Unix UTC
    outcome_at:        Option<i64>,     // V1.1 — visible en el CTA de verdict-watch
                                        //   antes de la revelación; refleja QubEnvelope.outcome_at;
                                        //   vinculado a qub_id mediante la preimagen de §4.1.
    drand_chain_id:    String,          // Hash de la cadena drand (cadena hex)
    drand_round:       u64,             // Número de round drand objetivo
    tlock_ciphertext:  Vec<u8>,         // Bytes CBOR del QubEnvelope cifrados con tlock
    recipient_pubkey:  Option<[u8; 32]>,// Campo reservado; aceptado por el CBOR canónico
                                        //   pero no interpretado por el visor de referencia v1
    title:             Option<String>,  // Título en plaintext mostrado en la cuenta atrás
                                        //   del visor antes de la revelación. Vinculado a qub_id
                                        //   vía title_hash (§4.1). 1..=100 code points NFC,
                                        //   sin caracteres de control.
}

2.4 RevealedQub (estado de la aplicación visor)

No serializado en CBOR. Local a la aplicación visor. Construido tras un descifrado y verificación exitosos.

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 — trasladado desde QubEnvelope.outcome_at / SealedQub.outcome_at; impulsa el bloque verdict-watch de la página de revelación (verdict-uplift-plan §5.1)
    drand_chain_id:      String,
    drand_round:         u64,
    sender_label:        Option<String>,
    title:               Option<String>,    // Trasladado desde 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. Perfil CBOR canónico

Toda serialización de SealedQub y QubEnvelope DEBE ajustarse a este perfil. Dos implementaciones, dada la misma estructura lógica, DEBEN producir bytes idénticos.

3.1 Reglas de codificación

Regla Especificación
Estándar RFC 8949 §4.2.1 (Core Deterministic Encoding Requirements)
Orden de claves del map Ordenadas primero por longitud de bytes codificada (las más cortas antes que las más largas), luego lexicográficamente (byte por byte para codificaciones de la misma longitud)
Codificación de enteros Forma más corta: 0–23 en el byte inicial; 24–255 en 2 bytes; 256–65535 en 3 bytes; etc.
Codificación de longitud Solo longitudes definidas. No se permiten arrays, maps, byte strings ni text strings de longitud indefinida (additional info = 31 está prohibido).
Tags Sin tags CBOR (el major type 6 está prohibido).
Punto flotante Sin floats (los valores 0xF9–0xFB del major type 7 están prohibidos).
Text strings Codificados en UTF-8, normalizados NFC (Unicode Normalization Form C).
Byte strings Bytes en bruto. Sin codificación base64 en la capa CBOR.
Claves duplicadas Rechazar con error. Los parsers NO DEBEN aceptar silenciosamente claves duplicadas en el map.
Valores simples Solo true (0xF5), false (0xF4) y null (0xF6) están permitidos.
Campos opcionales Los campos opcionales ausentes se omiten del map CBOR por completo (no se codifican como null). Los campos opcionales presentes se incluyen en el orden de claves ordenado.

3.2 Órdenes canónicos verificados de claves

Estos órdenes de claves son normativos. Las implementaciones DEBEN emitir las claves exactamente en este orden. Las aserciones de depuración DEBERÍAN verificar el orden en builds que no sean release.

QubEnvelope (versión 0x01, sin firma, todos los campos opcionales ausentes):

"body"                (5 encoded bytes)
"qub_id"              (7 encoded bytes)
"sig_alg"             (8 encoded bytes)
"version"             (8 encoded bytes)
"reply_to"            (9 encoded bytes)   ← solo si está presente (cadenas de respuesta)
"body_hash"           (10 encoded bytes)
"unlock_at"           (10 encoded bytes)
"created_at"          (11 encoded bytes)
"outcome_at"          (11 encoded bytes)  ← solo si está presente (mecánica verdict V1.1)
"content_type"        (13 encoded bytes)
"sender_label"        (13 encoded bytes)  ← solo si está presente
"author_pubkey"       (14 encoded bytes)  ← solo si está presente
"cosigner_pubkey"     (16 encoded bytes)  ← solo si está presente (cofirma de pact)
"author_signature"    (17 encoded bytes)  ← solo si está presente
"cosigner_signature"  (19 encoded bytes)  ← solo si está presente (cofirma de pact)

Derivación del orden de claves de QubEnvelope: cada clave es un text string CBOR. Longitud codificada = 1 byte de cabecera + longitud de la cadena (para cadenas de menos de 24 bytes). Ordenar primero por longitud codificada total, luego lexicográficamente para claves de la misma longitud.

SealedQub (versión 0x01, público, sin recipient):

"title"             (6 encoded bytes)   ← solo si está presente
"qub_id"            (7 encoded bytes)
"version"           (8 encoded bytes)
"unlock_at"         (10 encoded bytes)
"outcome_at"        (11 encoded bytes)  ← solo si está presente (mecánica verdict V1.1)
"visibility"        (11 encoded bytes)
"drand_round"       (12 encoded bytes)
"drand_chain_id"    (15 encoded bytes)
"recipient_pubkey"  (17 encoded bytes)  ← solo si está presente
"tlock_ciphertext"  (17 encoded bytes)

PactTerms (cuerpo pact, content_type 0x03):

"notes"         (6 encoded bytes)  ← solo si está presente
"terms"         (6 encoded bytes)
"title"         (6 encoded bytes)
"party_a"       (8 encoded bytes)
"party_b"       (8 encoded bytes)
"pact_version"  (13 encoded bytes)

PactTerm (fila del array terms):

"key"    (4 encoded bytes)
"value"  (6 encoded bytes)

PartyIdentifier (map de party_a / party_b):

"label"    (6 encoded bytes)
"contact"  (8 encoded bytes)  ← solo si está presente

3.3 Referencia de codificación de bytes

Tipo Codificación CBOR Ejemplo
Hash SHA3-256 (32 bytes) 0x58 0x20 + 32 bytes body_hash, qub_id
Marcas de tiempo (i64) Major type 0 (positivo) o 1 (negativo), codificación más corta Segundos Unix
Versión (u8, valor 1) 0x01 (un solo byte)
Tipo de contenido (u8, valor 1) 0x01 (un solo byte)
sig_alg (u8, valor 0) 0x00 (un solo byte)
Firma ML-DSA-65 (3.309 bytes) 0x59 0x0C 0xED + 3.309 bytes author_signature, cosigner_signature
Clave pública ML-DSA-65 (1.952 bytes) 0x59 0x07 0xA0 + 1.952 bytes author_pubkey, cosigner_pubkey

4. Derivaciones normativas

4.1 qub_id

El qub_id identifica de forma única a un qub y vincula el QubEnvelope con el SealedQub. Se deriva de manera determinista a partir del contenido del envelope.

qub_id = SHA3-256(
    "QUB_ID_V2"    ||    // separador de dominio: bytes ASCII [0x51 0x55 0x42 0x5F 0x49 0x44 0x5F 0x56 0x32] (9 bytes) + relleno 0x00 (1 byte) = 10 bytes
    version        ||    // u8 (1 byte)
    content_type   ||    // u8 (1 byte)
    created_at     ||    // i64 big-endian (8 bytes)
    unlock_at      ||    // i64 big-endian (8 bytes)
    outcome_at_or_zero || // i64 big-endian (8 bytes; 0 cuando outcome_at está ausente)
    drand_round    ||    // u64 big-endian (8 bytes)
    body_hash      ||    // [u8; 32] (32 bytes)
    title_hash           // [u8; 32] (32 bytes; centinela-ausente = [0u8; 32])
)
// Preimagen total: 108 bytes → salida de 32 bytes

Codificación del separador de dominio: la cadena "QUB_ID_V2" son 9 bytes ASCII. Se añade un único byte de relleno 0x00 para alcanzar 10 bytes para alineación. Las implementaciones DEBEN usar exactamente estos 10 bytes: [0x51, 0x55, 0x42, 0x5F, 0x49, 0x44, 0x5F, 0x56, 0x32, 0x00].

Codificación de outcome_at: V1.1 amplió la preimagen de 92 a 100 bytes para incorporar el campo opcional outcome_at al vínculo criptográfico. Un outcome_at ausente se codifica como 8 bytes a cero; los validadores del protocolo rechazan outcome_at <= 0 en todas partes, de modo que este centinela no puede colisionar con un valor legítimo. Consulta §3.2 (formato de cable) y el documento interno tasks/verdict-uplift-plan.md para la mecánica verdict que motiva este campo.

Codificación de drand_round: V1.2 amplió la preimagen de 100 a 108 bytes para incorporar drand_round (el round drand objetivo, §4.3) al vínculo criptográfico, y elevó el separador de dominio a QUB_ID_V2. Esto vincula el round del timelock a la identidad del qub: un gateway no puede revincular el ciphertext a un round distinto (por ejemplo, ya pasado) del que implica el unlock_at mostrado. El procedimiento de unlock (§8) verifica además que el round incrustado en la stanza del ciphertext tlock coincide con unlock_round(unlock_at), de modo que la hora de unlock mostrada es demostrablemente el round que controla el descifrado.

Propiedades:

4.2 body_hash

body_hash = SHA3-256(body)

Donde body es la carga útil de contenido Vec<u8> cruda. Para qubs de texto, este es el cuerpo del qub codificado en UTF-8.

4.2.1 title_hash

title_hash = SHA3-256(NFC(title).utf8_bytes)   si title está presente
title_hash = [0u8; 32]                         si title está ausente

Donde title es el título opcional en plaintext mostrado en la cuenta atrás del visor antes de la revelación (ver §3.2). La normalización NFC se aplica en el momento del hash de modo que el digest sea estable a través de secuencias de code points visualmente equivalentes. El centinela de ceros se reserva para el caso ausente; una cadena vacía se rechaza en la frontera CBOR canónica como una codificación no canónica de "ausente" (la codificación canónica omite el campo por completo).

4.3 Mapeo de unlock-round

drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds)
Parámetro Fuente Ejemplo
unlock_at Segundos Unix UTC elegidos por el usuario 1735689600 (2025-01-01 00:00:00 UTC)
chain_genesis_time Información de cadena drand (genesis_time) 1595431050
chain_period_seconds Información de cadena drand (period) 30

La operación ceil() selecciona el primer round drand cuya hora de revelación sea ≥ unlock_at. Esto garantiza que el qub no se vuelva descifrable antes del momento de unlock elegido.

Caso límite: si (unlock_at - chain_genesis_time) es exactamente divisible entre chain_period_seconds, el resultado es ese round exacto — el qub se desbloquea precisamente en la hora de revelación de ese round.

Validación: unlock_at DEBE estar en el futuro al momento del sellado. unlock_at NO DEBE superar created_at + 10 años (para limitar el riesgo de dependencia drand a largo plazo; la UI DEBERÍA advertir sobre fechas de unlock más allá de 2 años).


5. Newtypes del formato de cable

Los newtypes del formato de cable proporcionan seguridad en tiempo de compilación contra confundir bytes CBOR con JSON, plaintext crudo u otras codificaciones de bytes.

Tipo Contiene Producido por Consumido por
SealedQubCbor CBOR canónico de SealedQub serialize_sealed_qub() Carga al almacenamiento permanente, fetch del visor
QubEnvelopeCbor CBOR canónico de QubEnvelope serialize_qub_envelope() Entrada de cifrado tlock, salida de descifrado tlock

5.1 Reglas de construcción

// Production code — only through CBOR serialisers:
let sealed = SealedQubCbor::from_encoded(cbor_bytes);

// There is deliberately NO From<Vec<u8>> implementation.
// You cannot accidentally wrap arbitrary bytes in a wire format type.

// Accessing raw bytes:
let bytes: &[u8] = sealed.as_bytes();
let bytes: Vec<u8> = sealed.into_bytes();

5.2 Validación en construcción

from_encoded() DEBERÍA validar que la entrada comience con una cabecera de map CBOR válida. La validación estructural completa ocurre en el momento del parseo, no en el de la construcción, para evitar el doble parseo.


6. Registro de tipos de contenido

Valor Tipo Tamaño máximo del cuerpo Notas
0x00 Reservado (inválido) NO DEBE usarse
0x01 Texto plano (UTF-8, Markdown restringido) 50 KB de pago / 10 KB gratuito Ver §10 para las reglas de renderizado. La división gratis / pago se aplica en el servicio de carga; el techo absoluto de la capa del protocolo es 50 KB.
0x02 Reservado (futuro) Asignado para un futuro tipo de contenido; no válido en v1. Los visores DEBEN rechazarlo según la regla de abajo.
0x03 Pact (acuerdo bilateral, cuerpo CBOR) 100 KB El cuerpo es CBOR canónico PactTerms (§6.1). Firma del cosigner según §9.7.
0x04 Verdict (autocalificación del creador, cuerpo CBOR) 8 KB El cuerpo es CBOR canónico VerdictBody (§6.2). Emitido únicamente por el intent del sistema verdict. La relación con el padre va en la etiqueta de Arweave Parent-Tx-Id, no en el cuerpo. Ver verdict-uplift-plan §3.4.

Los visores DEBEN rechazar tipos de contenido desconocidos con un error claro y visible al usuario. Los visores NO DEBEN intentar renderizar tipos desconocidos como texto.

6.1 Cuerpo Pact (content_type = 0x03)

Un cuerpo pact es la codificación CBOR canónica de un valor PactTerms:

PactTerms {
    pact_version:  u8,                    // 0x01 para structured/v1
    title:         String,                // ≤ 200 bytes, NFC
    terms:         Vec<PactTerm>,         // ≤ 20 filas
    party_a:       PartyIdentifier,       // iniciador
    party_b:       PartyIdentifier,       // cofirmante
    notes:         Option<String>,        // ≤ 5.000 bytes, NFC; clave ausente si no hay
}

PactTerm       { key: String (≤ 100), value: String (≤ 2,000) }   // NFC en ambos lados
PartyIdentifier{ label: String (≤ 100), contact: Option<String (≤ 320)> }

Los órdenes canónicos de claves CBOR para los tres maps se dan en §3.2. El CBOR pact serializado total NO DEBE exceder los 100 KB (coincide con §6).

Discriminador de esquema. La primera fila en terms para un pact structured/v1 DEBE ser { key: "pact_schema", value: "structured/v1" }. Las filas sin este marcador son pacts "personalizados" y no reciben validación estructurada ni renderizado consciente del esquema.

Slots de reconocimiento congelados. Los pacts structured/v1 portan exactamente cuatro filas de reconocimiento bajo estas claves:

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

El value de cada una es una de ocho cadenas en inglés congeladas elegidas por el par (role, kind), donde role ∈ { seller, buyer, provider, client } y kind ∈ { standard, capacity }. Las cadenas mismas son datos normativos del protocolo — las firmas ML-DSA-65 de ambas partes se comprometen a los bytes exactos vía body_hash. NO están localizadas; el cuerpo firmado es neutro en cuanto al idioma. Cualquier cambio de redacción requiere una nueva versión de esquema (structured/v2).

Las ocho cadenas, su búsqueda (acknowledgement_for(role, kind)) y la justificación de cada una están fijadas por la implementación de referencia. Las implementaciones conformes DEBEN emitir valores de reconocimiento idénticos byte a byte; los tests de body-hash SHA3-256 con golden-fixtures que cubren las cuatro combinaciones de roles capturan cualquier deriva.

Orden de visualización en el visor. Las cadenas de reconocimiento contienen frases como "described above", que presuponen que las filas de descripción / alcance se renderizan antes que los reconocimientos. Los visores DEBEN renderizar el array terms en orden CBOR; reordenar rompe la semántica de la prosa.

Contacto de la contraparte. Cuando el contact de Party B es una dirección de email válida, el servicio de carga de qubs envía automáticamente un email de invitación a revisión / cofirma en el momento del staging y vincula la eventual cofirma a la verificación de esa misma dirección (§9.7). Los pacts cuyo contacto de Party B esté ausente todavía se pueden cofirmar, pero solo a través de un canal fuera de banda — el servicio rechaza solicitudes de cofirma que no puedan producir un marcador coincidente de verificación de email de 15 minutos.

6.2 Cuerpo Verdict (content_type = 0x04)

Un cuerpo verdict es la codificación CBOR canónica de un valor VerdictBody:

VerdictBody {
    verdict_version: u8,                  // 0x01 para structured/v1
    outcome:         u8,                  // 1=Right · 2=Partial · 3=Wrong · 4=Unfalsifiable
    reflection:      Option<String>,      // ≤ 2.000 bytes NFC; "qué cambió, qué aprendiste"
    evidence_url:    Option<String>,      // ≤ 2.048 bytes; solo HTTPS; clave ausente cuando se omite
}

Orden canónico de claves CBOR:

"outcome"          (8 encoded bytes)
"reflection"       (11 encoded bytes)  ← solo si está presente
"evidence_url"     (13 encoded bytes)  ← solo si está presente
"verdict_version"  (16 encoded bytes)

El CBOR de verdict serializado total NO DEBE exceder los 8 KB (coincide con la fila de la tabla anterior).

Enum de outcome. El byte de cable es neutro respecto al intent; las cuatro categorías Right / Partial / Wrong / Unfalsifiable cubren el espacio de desenlaces de todo intent portador de veredicto. Las etiquetas por intent ("Acerté" / "Lo cumplí" / "Entregado" / "Confirmada" para Right, etc.) son una decisión de renderizado del lado del lector, resuelta contra el intent del qub padre — el cable se mantiene neutro en cuanto a idioma e intent. Los valores fuera de 1..=4 DEBEN rechazarse al decodificar.

Vínculo con el padre. Un qub verdict NO porta la referencia al padre en su cuerpo. El id de transacción de Arweave del qub padre se emite como la etiqueta de almacenamiento Parent-Tx-Id en el momento de la carga (§7, capa de etiquetas de almacenamiento). Esto mantiene el cuerpo como una declaración firmada autocontenida de autoevaluación; la cadena de auditoría ("¿acerté sobre qué?") se establece mediante la búsqueda por etiqueta de Arweave.

Seguridad de la URL de evidencia (normativo). Cuando evidence_url está presente, los validadores (lado de composición, lado de cable, edge del Worker) DEBEN aplicar:

  1. Solo HTTPS. La cadena DEBE empezar con la secuencia de bytes https://. Cualquier otro esquema — http, ftp, javascript, data, file, etc. — se rechaza.
  2. Tope de longitud. ≤ 2.048 bytes (límite práctico de URL en navegadores).
  3. Verificación NFC + de codepoints hostiles. Misma regla que para title y reflection — los codepoints bidi-override / zero-width / tag-block / BOM / C0 / C1 se rechazan. La definición coincide con la Rust crate::handle::contains_hostile_text_codepoint y la TS workers/api/src/utils/unicode.ts::isHostileCodepoint (mantenlas sincronizadas).
  4. Sin espacios en blanco, sin controles ASCII. Cualquier espacio en blanco / DEL / byte sub-0x20 en cualquier parte de la URL se rechaza — cierra el vector de inyección \n/\t que la regla bidi no cubre.
  5. Segmento de host no vacío. Todo lo que va entre https:// y la primera /, ? o # DEBE ser no vacío.

Sin fetching del lado del servidor. El Worker NO DEBE proxiar, buscar ni previsualizar la URL. El protocolo almacena una cadena; el renderizado ocurre del lado del lector con rel="nofollow noopener noreferrer" target="_blank" y un host visible mostrado junto al texto del enlace.

Reflexión. Texto opcional de reflexión escrito por el creador ("qué cambió, qué aprendiste"). Misma validación NFC + de codepoints hostiles que title. La entrada vacía o de solo espacios en blanco se colapsa a ausente en el momento de la construcción.

Versión del esquema. v1 admite únicamente verdict_version = 0x01. Futuras revisiones del esquema incrementan este byte y aterrizan junto a una nueva versión del protocolo según §12.


7. Protocolo de sellado

La secuencia completa de sellado. Cada paso es normativo.

 1. El usuario compone plaintext y metadatos en ComposeQub.
 2. Validar:
    a. body no está vacío.
    b. tamaño del body ≤ máximo para content_type y nivel del usuario (ver §6).
    c. unlock_at está en el futuro.
    d. unlock_at ≤ created_at + 10 años.
    e. content_type es un valor conocido y soportado.
 3. Calcular body_hash = SHA3-256(body).
 4. Establecer created_at = segundos Unix UTC actuales.
 5. Seleccionar cadena drand. Cargar chain_genesis_time y chain_period_seconds, y
    calcular drand_round = ceil((unlock_at - chain_genesis_time) / chain_period_seconds).
    (Se calcula aquí, antes de qub_id, porque drand_round se vincula a la preimagen
    de qub_id — §4.1, V1.2.)
 6. Calcular qub_id (ver §4.1), incorporando drand_round del paso 5.
 7. Construir QubEnvelope con todos los campos.
 8. Serializar QubEnvelope usando CBOR canónico → bytes B.
    Aserción: la salida serializada coincide con el perfil canónico (§3).
 9. Calcular C = tlock_encrypt(B, drand_round, drand_chain_public_key).
10. Construir SealedQub con tlock_ciphertext = C, y qub_id, version,
    unlock_at, drand_chain_id, drand_round coincidentes.
12. Serializar SealedQub usando CBOR canónico → SealedQubCbor.
12a. Generar K = 32 bytes aleatorios (CSPRNG) y N = 12 bytes aleatorios (CSPRNG).
     Calcular W = wrap_sealed_qub(SealedQubCbor, qub_id=qub_id, key=K, nonce=N)
     según §13. Los bytes cargados a almacenamiento permanente son el CBOR del OuterWrapper W,
     nunca el SealedQubCbor pelado. K abandona el dispositivo solo como
     fragmento de URL en el paso 16.
13. Mostrar la divulgación al momento del sellado. El usuario confirma.
14. Validar la elegibilidad de carga vía el servicio de carga de qubs (detección de bots, entitlement, límites de tasa).
15. Enviar W (los bytes del OuterWrapper) al servicio de carga de qubs; el servicio
    firma y carga a almacenamiento permanente. El servicio es ciego a los bytes del SealedQubCbor
    interno y nunca recibe K.
16. Recibir arweave_tx_id del servicio. Construir la URL de entrega como
    `<origin>/c/<arweave_tx_id>#<base64url(K)>` (o `<origin>/s/<short_code>#<base64url(K)>`
    cuando se asigna un código corto). Los navegadores no transmiten los fragmentos
    de URL a los servidores, por lo que K nunca es observada por qub.social ni por
    ningún gateway de almacenamiento.

Capa de tags de almacenamiento (fuera de banda). El servicio de carga de qubs adjunta un conjunto deliberadamente pequeño de tags de transacción de almacenamiento junto con el payload envuelto. Content-Type=application/octet-stream es normativamente requerido. El servicio de referencia adjunta adicionalmente tres tags opcionales cuando el creador elige exponerlos: Intent (intención de composición validada por allowlist — p. ej., quote, reply, commitment), Author (fingerprint de la pubkey §9.3 del creador como hex en minúsculas de 64 caracteres) y Parent-Tx-Id (ID de transacción de almacenamiento del qub padre para cadenas de respuesta, base64url de 43 caracteres).

El tag Author es opt-in por qub: la aplicación creadora de referencia lo adjunta solo cuando el usuario habilita explícitamente la atribución pública en el momento del sellado. Cuando el toggle está desactivado — el valor por defecto — no se escribe ningún tag Author y el qub queda sin atribución en la cadena: nada en el almacenamiento permanente vincula la carga con el handle, email u otros qubs del creador. Cuando el toggle está activado, el fingerprint Author se resuelve al @handle elegido por el creador a través de la cadena de attestation §9.5. Las relaciones de cadena de respuesta y el Intent no son identificativos. El wrapper externo (§13) protege el cuerpo interno de la correlación de ciphertext — impidiendo que un harvester reconozca y descifre en masa cargas con forma de qub después de que su round drand se publique.

El servicio de referencia intencionalmente NO adjunta tags App-Name, App-Version ni Type: cualquier filtro de un solo valor de ese tipo devolvería todo el corpus de qubs a una consulta GraphQL, lo que es inconsistente con el alcance de confidencialidad solo-cuerpo del wrapper.

Un verificador conforme NO DEBE depender de ningún tag de almacenamiento para la verificación tercera de §11; el body hash / qub_id / firma se comprometen únicamente al CBOR interno, nunca al conjunto de tags.


8. Protocolo de unlock

La secuencia completa de unlock. Cada paso es normativo.

 1. El visor abre la URL de entrega. Extraer arweave_tx_id de la ruta Y
    K = base64url_decode(fragment) del fragmento de URL. Si el fragmento
    está ausente o malformado → mostrar "esta URL no tiene su clave de
    descifrado" y detener; el visor NO DEBE contactar al gateway de almacenamiento
    sin K, ya que obtener bytes envueltos que el visor no puede descifrar
    no sirve para nada y solo filtra el intento de acceso.
 2. Comprobar la denylist. Si tx_id está en la denylist → mostrar mensaje de bloqueo. Detener.
 3. Obtener los bytes del OuterWrapper de almacenamiento permanente (con fallback multi-gateway).
 3a. Desenvolver: parsear los bytes como OuterWrapper (§13), verificar que
    el byte `version` del wrapper sea `0x01`, y calcular SealedQubCbor =
    unwrap_sealed_qub(OuterWrapper, key=K). Cualquier fallo de autenticación
    AEAD (K incorrecta, ciphertext manipulado, qub_id-como-AAD intercambiado,
    nonce intercambiado) → mostrar "la clave de descifrado de esta URL no
    coincide con el qub almacenado" y detener. Los fallos de autenticación
    son indistinguibles para el visor según §13.5.
 4. Parsear SealedQubCbor → SealedQub.
 5. Validar: SealedQub.version es conocida (0x01). Rechazar versiones desconocidas.
 6. Si la hora actual < SealedQub.unlock_at → mostrar cuenta atrás. Hacer poll o esperar.
 6a. Comprobación de vinculación de round (V1.2). Recalcular expected_round =
    ceil((SealedQub.unlock_at - chain_genesis_time) / chain_period_seconds).
    Rechazar a menos que SealedQub.drand_round == expected_round Y el round incrustado
    en la stanza del ciphertext tlock (leído vía la cabecera age/tlock, sin requerir
    firma) == expected_round. El round de la stanza es el que realmente controla el
    descifrado; sin esta comprobación, un creador malicioso podría vincular el ciphertext
    a un round ya pasado mientras muestra una cuenta atrás futura, de modo que cualquiera
    que lea los bytes almacenados podría descifrar antes de unlock_at. Las implementaciones
    sin identidad de cadena (mocks de prueba) omiten esta comprobación.
 7. Una vez que la hora actual ≥ SealedQub.unlock_at:
    a. Obtener la firma del round drand para SealedQub.drand_round de la red drand.
    b. Calcular B = tlock_decrypt(SealedQub.tlock_ciphertext, round_signature).
 8. Parsear B → QubEnvelope.
 9. Validar que QubEnvelope.version es conocida.
10. Verificar: SHA3-256(QubEnvelope.body) == QubEnvelope.body_hash.
    Falla → error de integridad.
11. Verificar: QubEnvelope.qub_id == SealedQub.qub_id.
    Falla → error de integridad.
12. Verificar: QubEnvelope.unlock_at == SealedQub.unlock_at.
    Falla → error de integridad.
13. Verificar: QubEnvelope.content_type es conocido y renderizable.
    Valores conocidos: 0x01 (texto), 0x03 (pact). Desconocido → mostrar error.
14. Si QubEnvelope.sig_alg != 0x00 → verificar la firma del autor (ver §9.4).
15. Si cosigner_pubkey o cosigner_signature están presentes → verificar el cosigner (ver §9.7).
16. Renderizar el contenido usando el renderer apropiado (ver §10 para texto, §6 para pact).
17. Construir RevealedQub para mostrar.

9. Firma de autoría

9.1 Justificación

Los qubs se almacenan en el almacenamiento permanente. Las firmas de autoría deben permanecer infalsificables indefinidamente, razón por la cual v1.0 utiliza el esquema post-cuántico ML-DSA-65 (FIPS 204) en lugar de un esquema clásico cuya seguridad podría degradarse dentro de la vida permanente del qub.

9.2 Registro de algoritmos

sig_alg Esquema Tamaño de clave Tamaño de firma
0x00 Sin firma (unsigned)
0x01 ML-DSA-65 (FIPS 204) 1.952 bytes 3.309 bytes

Los visores DEBEN rechazar valores sig_alg desconocidos.

9.3 Construcción de la preimagen firmada

sig_input = SHA3-256(
    "QUB_AUTHOR_SIG_V1"  ||    // separador de dominio (17 bytes)
    version              ||    // u8 (1 byte)
    qub_id               ||    // [u8; 32] (32 bytes)
    body_hash            ||    // [u8; 32] (32 bytes)
    unlock_at            ||    // i64 big-endian (8 bytes)
    0x00                       // u8 (1 byte): DEBE ser 0x00 en v1.0
)

// Preimagen total: 91 bytes → hash de 32 bytes

signature = Sign(author_secret_key, sig_input)

Separador de dominio: "QUB_AUTHOR_SIG_V1" son 17 bytes ASCII: [0x51, 0x55, 0x42, 0x5F, 0x41, 0x55, 0x54, 0x48, 0x4F, 0x52, 0x5F, 0x53, 0x49, 0x47, 0x5F, 0x56, 0x31]. Sin relleno.

Byte final: el byte 91 de la preimagen DEBE ser 0x00. La implementación de referencia lo expone como la constante ORG_ID_PRESENT_INDIVIDUAL = 0x00 en crates/qub-core/src/signing.rs; los visores que reconstruyan sig_input para verificación DEBEN emitir el mismo byte.

Alcance de la firma — qué cubre y qué no. sig_input se compromete a cuatro campos del envelope: version, qub_id, body_hash, unlock_at (más el separador de dominio fijo y el byte org_id_present). Tres de esos cuatro son invariantes estructurales: qub_id se deriva a su vez de version, content_type, created_at, unlock_at, outcome_at, drand_round y body_hash mediante la preimagen de §4.1, por lo que cualquier cambio en esos campos produce un qub_id diferente e invalida la firma transitivamente. La superficie autenticada directamente es por tanto:

Campo Autenticado por la firma Cómo
version Entrada directa a sig_input
qub_id Entrada directa
body_hash Entrada directa
unlock_at Entrada directa
content_type Transitivamente, vía preimagen de qub_id
created_at Transitivamente, vía preimagen de qub_id
outcome_at Transitivamente, vía preimagen de qub_id
drand_round Transitivamente, vía preimagen de qub_id (V1.2)
body Transitivamente, vía body_hash = SHA3-256(body)
author_pubkey — (implícito) La clave que verificó la firma es el autor, por definición
sender_label Texto solo de visualización; mutable sin romper la firma
reply_to Puntero de threading; mutable sin romper la firma
cosigner_pubkey / cosigner_signature Firmados independientemente sobre el mismo sig_input (ver §9.7)
drand_chain_id, tlock_ciphertext, visibility Campos externos del SealedQub, no dentro del envelope — cubiertos por sus propias invariantes estructurales (consistencia de round / cadena) pero no por la firma del autor. (drand_round ahora está vinculado transitivamente vía la preimagen de qub_id — ver arriba.)

Implicaciones de seguridad de los campos no autenticados.

Las implementaciones que muestren sender_label o reply_to a usuarios finales DEBEN exponer la identidad autenticada (fingerprint de la pubkey, attestation) como la señal primaria de identidad, no la etiqueta.

9.4 Procedimiento de verificación

1. Leer sig_alg del QubEnvelope.
2. Si sig_alg == 0x00 → sin firma. Sin verificación. Mostrar "qub sin firma."
3. Si sig_alg es desconocido → rechazar. Mostrar "esquema de firma no reconocido."
4. Extraer author_signature y author_pubkey. Si alguna está ausente → error de integridad.
5. Reconstruir sig_input usando los campos del QubEnvelope (misma fórmula que §9.3).
6. Verify(author_pubkey, sig_input, author_signature).
7. Si la verificación tiene éxito → mostrar "firmado por [fingerprint de la clave]."
8. Si la verificación falla → mostrar "verificación de firma fallida."

La verificación de firma es la operación más costosa (especialmente ML-DSA-65). DEBERÍA realizarse después de que todas las comprobaciones más baratas (hash, qub_id, unlock_at) hayan pasado.

9.5 Identity attestations

Las attestations de identidad — el mapeo de author_pubkey a afirmaciones de identidad reconocibles por humanos como un handle de qub, dirección de email, handle social o credencial passkey — son una mejora progresiva del lado del visor y no son requeridas para la verificación de firma. Los visores que resuelvan attestations a una identidad de visualización DEBEN aplicar la precedencia:

handle > email > social > fingerprint

El fallback de fingerprint es el hex en minúsculas de SHA3-256(author_pubkey); siempre está disponible para cualquier qub firmado. Los visores PUEDEN abreviarlo para su visualización — el visor de referencia renderiza qub: seguido de los primeros y los últimos cuatro bytes (qub:<8 hex>…<8 hex>).

Un verificador conforme puede completar todas las comprobaciones de §9.4 sin contactar la API de qub, sin ninguna red más allá del almacenamiento permanente y drand, y sin ninguna búsqueda del lado del servidor. La resolución de attestation es un paso separado de mejor esfuerzo realizado solo después de que la verificación de firma haya tenido éxito.

9.6 Impacto en el tamaño

Ed25519 ML-DSA-65
Firma 64 bytes 3.309 bytes
Clave pública 32 bytes 1.952 bytes
Total por qub 96 bytes 5.261 bytes
Coste delta de almacenamiento (a ~$5/MB) ~$0,0005 ~$0,026

Para un qub de texto de 500–2.000 bytes, ML-DSA-65 aproximadamente triplica el tamaño almacenado. El coste absoluto es despreciable.

9.7 Verificación del cosigner (acuerdos bilaterales pact)

Para acuerdos bilaterales (content_type = 0x03), una segunda capa de firma demuestra que ambas partes consintieron a los mismos términos.

Campos del envelope:

Ambos campos DEBEN estar presentes juntos o ambos ausentes. Si exactamente uno está presente, los visores DEBEN reportar un error de integridad.

Procedimiento de verificación:

1. Si cosigner_pubkey ausente y cosigner_signature ausente → sin cosigner. Hecho.
2. Si exactamente uno está presente → error de integridad.
3. Verificar cosigner_pubkey != author_pubkey (impedir auto-cofirma).
   Falla → mostrar "la pubkey del cosigner debe diferir del autor."
4. Reconstruir sig_input usando la misma fórmula que §9.3.
5. Verify(cosigner_pubkey, sig_input, cosigner_signature).
6. Éxito → mostrar "cofirmado por [fingerprint del cosigner]."
7. Falla → mostrar "verificación de cofirma fallida."

Propiedades:

Verificación por email-binding (operacional). Cuando un pact en staging porta un contacto de email de Party B (§6.1), el servicio de carga de qubs DEBE rechazar la solicitud de cofirma a menos que exista un marcador de verificación de email de corta duración que coincida tanto con el id de staging como con el hash del email normalizado de ese contacto. El marcador es escrito por /api/v1/auth/verify cuando el token del magic-link porta un staging_id y la dirección verificada coincide con SHA-256(normalise_email(party_b.contact)) — donde normalise_email(addr) preserva la mayúscula/minúscula de la parte local y solo pasa a minúsculas la parte del dominio (según RFC 5321 §2.3.11), y SHA-256 aquí es el hash NIST FIPS 180-4 (distinto del SHA3-256 usado en las derivaciones de §4) — y expira 900 segundos (15 minutos) después de su emisión. Esta es una verificación operacional anti-suplantación, NO parte de la prueba on-chain del qub — un verificador tercero que reproduce §11 solo necesita el almacenamiento permanente y drand, sin ninguna búsqueda del lado del servidor. El marcador existe solo del lado del servidor y nunca forma parte del cuerpo firmado.

Impacto en el tamaño (autor ML-DSA-65 + cosigner):

Componente Tamaño
Firma del autor 3.309 bytes
Clave pública del autor 1.952 bytes
Firma del cosigner 3.309 bytes
Clave pública del cosigner 1.952 bytes
Total de overhead criptográfico 10.522 bytes
Coste delta de almacenamiento ~$0,05

10. Renderizado y saneado de Markdown

Esta sección es crítica para la seguridad. El visor renderiza qubs de texto (content_type = 0x01) usando un subconjunto restringido de Markdown.

10.1 Elementos permitidos

10.2 Elementos prohibidos

Elemento Tratamiento
HTML crudo (<div>, <script>, etc.) Eliminado por completo. Ningún HTML pasa.
Imágenes (![alt](url)) Eliminadas. La sintaxis de imagen se elimina de la salida.
Enlaces ([text](url)) URL renderizada como texto plano visible. Sin auto-link. No clickable sin acción explícita del usuario.
Esquemas de URL peligrosos javascript:, data:, vbscript:, file: — eliminados.
Iframes, embeds, objects Eliminados.
Entidades HTML Decodificadas a caracteres de visualización solo si son seguras.

10.3 Implementación

Las implementaciones DEBEN usar un parser de allowlist estricto, no de blocklist. El enfoque recomendado:

  1. Parsear Markdown usando pulldown-cmark (o equivalente).
  2. Recorrer el AST y descartar cualquier nodo que no esté en la allowlist (§10.1).
  3. Para nodos de enlace: emitir la URL como texto visible, no como elemento <a> clickable.
  4. Convertir el AST filtrado en una representación intermedia tipada (p. ej., un enum MarkdownNode con solo variantes seguras). El HTML crudo es estructuralmente irrepresentable en este IR.
  5. Renderizar desde el IR tipado a la capa de vista objetivo (p. ej., componentes de vista reactivos, nodos del DOM). Sin concatenación de strings HTML ni innerHTML en ningún punto.

Los enfoques de blocklist son frágiles porque nuevas extensiones de Markdown o peculiaridades del parser pueden introducir elementos no filtrados. El enfoque del AST tipado hace que el XSS sea estructuralmente imposible — no hay variante que pueda llevar HTML arbitrario.

10.4 Límites de tamaño y estructura


11. Verificación por terceros

Cualquier tercero puede verificar un qub público sin la cooperación de qub. El procedimiento de verificación:

1. Obtener arweave_tx_id (de la URL de entrega o por conocimiento directo).
2. Obtener SealedQubCbor de cualquier gateway de almacenamiento.
3. Confirmar la inclusión en el bloque de almacenamiento (altura del bloque, marca de tiempo del bloque).
4. Parsear SealedQubCbor → SealedQub.
5. Obtener la firma del round drand para SealedQub.drand_round.
6. tlock_decrypt(tlock_ciphertext, round_signature) → bytes CBOR del QubEnvelope.
7. Parsear → QubEnvelope.
8. Verificar SHA3-256(body) == body_hash.
9. Verificar QubEnvelope.qub_id == SealedQub.qub_id.
10. Verificar QubEnvelope.unlock_at == SealedQub.unlock_at.
11. Si sig_alg != 0x00: verificar author_signature (ver §9.4).
12. Todas las comprobaciones pasan → el qub está verificado.

Qué prueba la verificación:

Prueba Qué establece
Compromiso El ciphertext existía en la marca de tiempo del bloque de almacenamiento.
Integridad El cuerpo plaintext coincide con el hash comprometido y no ha sido alterado.
Temporalidad El contenido era ilegible hasta el round drand, que corresponde al momento de unlock elegido (sujeto a las suposiciones de seguridad de tlock y drand).

Qué NO prueba la verificación:

No-prueba Por qué
Autoría El sender_label es decorativo. Sin sig_alg0x01, cualquiera podría haber sellado este contenido.
Intención El qub prueba contenido y temporalidad, no lo que el creador subjetivamente quiso decir.
Temporalidad pre-evento La inclusión en el bloque de almacenamiento puede ir retrasada respecto a la carga real por minutos. La marca de tiempo del compromiso es la hora del bloque, no el momento en que el usuario pulsó "sellar."

12. Versionado

12.1 Versión del protocolo

El campo version (u8) tanto en SealedQub como en QubEnvelope identifica la versión mayor del protocolo.

12.2 Historial de versiones

Versión Valor Descripción
v1 0x01 Qubs de texto público (content_type 0x01), acuerdos bilaterales pact (0x03, esquema structured/v1, autor + cosigner ML-DSA-65), tlock, SHA3-256

12.3 Compatibilidad hacia adelante

Un visor v1 que se encuentre con un QubEnvelope con claves CBOR opcionales desconocidas (claves no presentes en el orden canónico §3.2) DEBERÍA ignorar esas claves y proceder con la verificación usando los campos conocidos. Esto permite futuras adiciones menores (p. ej., nuevos metadatos) sin requerir un salto de versión mayor.

Un visor v1 que se encuentre con sig_alg = 0x01 (ML-DSA-65) pero sin soporte de verificación ML-DSA-65 DEBERÍA mostrar el contenido del qub con un aviso de "firma presente pero no verificable", no rechazar el qub por completo. La implementación de referencia hoy rechaza todo valor sig_alg que no sea 0x00 o 0x01 porque el registro v1 no contiene ningún otro algoritmo válido — el rechazo estricto y el soft-fail son observacionalmente idénticos hasta que se registre un tercer algoritmo. El comportamiento soft-fail anterior se vuelve relevante una vez que §9.2 admita una nueva entrada, y el visor de referencia se actualizará para hacer soft-fail en ese momento.

12.4 Versión del wrapper externo

El OuterWrapper descrito en §13 lleva su propio byte version, independiente de SealedQub.version y QubEnvelope.version. Los dos espacios de versión evolucionan por separado: un futuro reemplazo simétrico post-cuántico-seguro incrementa el byte del wrapper sin tocar la versión interna del protocolo, y una futura adición a la capa del protocolo (p. ej., un nuevo campo del envelope) incrementa la versión interna sin tocar el byte del wrapper.

OUTER_WRAPPER_VERSION_* Valor Algoritmo Estado
OUTER_WRAPPER_VERSION_1 0x01 AES-256-GCM con nonce de 12 bytes, tag de autenticación de 16 bytes, AAD vinculada a qub_id v1 por defecto
0x020xFF Reservado Futuro

Los visores DEBEN rechazar versiones de wrapper desconocidas con un error claro. El protocolo intencionalmente mantiene estrecho el espacio de versiones del wrapper hasta que aparezca un motivo concreto de migración (p. ej., una guía NIST que favorezca un AEAD diferente); se asignará un slot 0x02 en la misma revisión que introduzca el algoritmo.


13. Wrapper externo de cifrado

13.1 Justificación

Las capas del protocolo (QubEnvelope → tlock → SealedQub) hacen que un qub sellado esté bloqueado en el tiempo: el cuerpo es ilegible hasta unlock_at y hasta que se haya publicado la firma del round drand. Después del unlock, sin embargo, la firma del round es pública y la forma CBOR canónica del SealedQub es reconocible, por lo que un harvester que haya indexado las transacciones del almacenamiento permanente podría descifrar en masa el corpus completo de qubs.

El wrapper externo de cifrado cierra ese canal interponiendo una capa simétrica AEAD adicional entre el SealedQubCbor canónico y los bytes escritos en el almacenamiento permanente. La clave de 256 bits K vive únicamente en el fragmento de URL de la URL de entrega y en los dispositivos del usuario; los navegadores no transmiten los fragmentos de URL a los servidores, por lo que qub.social, todo gateway de almacenamiento y todo CDN frente a cualquiera de ellos están observacionalmente ciegos a K. Por lo tanto, cada qub en el almacenamiento permanente es un ciphertext opaco cuyo plaintext es irrecuperable sin la URL que el creador eligió compartir.

Efecto neto:

13.2 Capas

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

El sellado y el unlock en la capa del protocolo (§7, §8) no cambian por debajo de la frontera del wrapper; el wrapper se adjunta en el sitio de llamada de seal() y se desadjunta en el sitio de llamada de unlock().

13.3 Estructura de datos del OuterWrapper

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

Invariantes de campo.

Codificación CBOR. CBOR canónico según §3, con la misma regla de orden de claves (ordenadas por longitud de byte codificada ascendente, luego lexicográficamente). Las cuatro claves son:

Clave Bytes codificados Orden
nonce 6 1
qub_id 7 2
version 8 3
ciphertext 11 4

El primer byte del CBOR del OuterWrapper es por tanto la cabecera de map de longitud definida para un map de 4 entradas (0xA4).

13.4 Vinculación AAD a qub_id

El wrapper vincula qub_id como additional authenticated data del AEAD. Esta es la defensa estructural portante contra tres clases de ataque:

Ataque Defensa
Mover el ciphertext bajo un campo qub_id diferente en el wrapper Discrepancia AAD → la autenticación AEAD falla
Mezclar el fragmento de URL del qub A con los bytes de almacenamiento permanente del qub B Discrepancia AAD → la autenticación AEAD falla
Manipular el campo qub_id del wrapper después de la carga Discrepancia AAD → la autenticación AEAD falla

Llevar qub_id en el plaintext del wrapper no debilita significativamente la inmunidad a la enumeración — qub_id es a su vez un hash SHA3-256 de la preimagen de §4.1 sin preimagen recuperable a partir del digest, y un enumerador que ya haya recopilado los bytes del wrapper no aprende nada del qub_id visible que no pudiera inferir de la mera existencia de la carga.

13.5 Algoritmos de wrap y 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 includes the 16-byte authentication tag at the end
    return canonical_cbor_encode(OuterWrapper{
        version:    0x01,
        qub_id:     Q,
        nonce:      N,
        ciphertext: C,
    })

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

Colapso del modo de fallo. Una K incorrecta, un nonce incorrecto, una discrepancia AAD y un ciphertext manipulado producen todos el mismo error DECRYPT_FAILED. Esta es una propiedad AEAD deliberada: distinguir el modo de fallo crearía un canal lateral que un atacante remoto podría sondear enviando wrappers malformados y midiendo el tiempo de respuesta. Las implementaciones de referencia DEBEN colapsar todos los fallos AEAD a una única forma de error.

13.6 Material de clave y distribución

La clave de envoltura K es un valor aleatorio uniforme de 256 bits generado por qub mediante un CSPRNG. Las implementaciones de referencia la obtienen de:

Distribución: K DEBE codificarse como base64 URL-safe (RFC 4648 §5, sin padding) y añadirse a la URL de entrega como componente fragment:

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

El fragmento nunca es transmitido a ningún servidor por un navegador conforme. Los canales de recuperación (índice de historial del lado del servidor, auto-envío opt-in por email) que persisten la URL de entrega completa — incluido el fragmento — más allá del dispositivo del usuario son una compensación explícita contra la postura por defecto de crypto-shredding y DEBEN estar condicionados al consentimiento explícito del usuario.

Pérdida del fragmento. Si un usuario pierde el fragmento de URL y no tiene canal de recuperación, el qub queda ilegible. Este es el compromiso portante del diseño y DEBE divulgarse al usuario en el momento del sellado. El MVP refuerza la divulgación al sellado con un texto explícito de "guarda esta URL" y un canal de recuperación por email verificado para los usuarios que opten por ello.

13.7 Fuera del alcance de esta sección

13.8 qubs públicos (omisión del wrapper)

El wrapper externo es opcional en la capa de entrega. Un creador puede sellar un qub como público, en cuyo caso el SealedQubCbor canónico se escribe directamente en el almacenamiento permanente, sin capa OuterWrapper y sin clave K:

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

Un qub público está bloqueado en el tiempo pero no protegido por enlace: permanece ilegible hasta que se publica su round drand (la capa tlock no cambia), pero después del unlock cualquiera que tenga el arweave_tx_id puede descifrarlo — no se requiere ningún fragmento de URL, porque no hay K. Esta es la compensación deliberada para las superficies que el servidor debe impulsar: los emails de notificación de revelación, los embeds de terceros y un SEO post-revelación más rico necesitan todos un enlace que funcione sin un secreto que el servidor nunca posee (§13.6).

Consecuencias que un productor DEBE tener en cuenta:

El modo privado (envuelto) sigue siendo el predeterminado; el público es una elección explícita del creador por cada qub.


14. Vectores de prueba

14.1 Derivación de qub_id

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

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

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

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

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

Las implementaciones DEBEN producir valores body_hash y qub_id idénticos para esta entrada. Este vector de prueba DEBERÍA ser el primer test unitario escrito. Los valores canónicos anteriores fueron computados por la implementación de referencia y DEBEN coincidir bit por bit. Disposiciones históricas de la preimagen (pre-lanzamiento — ningún qub en producción dependía de estas): el qub_id V1.0 de 92 bytes era 3d9fc2390eab043d38a1669ed3b71be76f9eefe872b9569ab1aaa027b88392b0; el qub_id V1.1 de 100 bytes (tras incorporar outcome_at_or_zero) era b0d032898ad629795150fdcb3f84e518f59ed05b7a2a82bc24ebdb87f52144ed. V1.2 incorpora drand_round y eleva el separador de dominio a QUB_ID_V2.

14.2 Mapeo de unlock-round

Input:
  unlock_at           = 1735689600
  chain_genesis_time  = 1595431050
  chain_period_seconds = 30

Calculation:
  (1735689600 - 1595431050) / 30 = 4675285.0
  ceil(4675285.0) = 4675285

drand_round = 4675285

14.3 Round-trip CBOR canónico

Las implementaciones DEBEN verificar que serialize(parse(serialize(qub))) == serialize(qub) para todas las entradas válidas. Esto es un test de propiedad, no un solo vector.

14.4 PactTerms CBOR (content_type 0x03)

Input:
  pact_version = 1
  title        = "Scooter deposit"
  terms        = [
    { key: "Item",    value: "Honda Metropolitan scooter" },
    { key: "Price",   value: "$100" },
    { key: "Deposit", value: "$10" }
  ]
  party_a      = { label: "Alice" }
  party_b      = { label: "Bob", contact: "bob@example.com" }
  notes        = absent

Canonical CBOR key order (PactTerms):
  "notes"(6) < "terms"(6) < "title"(6) < "party_a"(8) < "party_b"(8) < "pact_version"(13)

Canonical CBOR key order (PactTerm):
  "key"(4) < "value"(6)

Canonical CBOR key order (PartyIdentifier):
  "label"(6) < "contact"(8)

Los bytes CBOR canónicos y el body_hash SHA3-256 son computados por la implementación de referencia. Las implementaciones DEBEN producir CBOR idéntico byte a byte para esta entrada.

Las implementaciones DEBEN también verificar que serialize(parse(serialize(pact))) == serialize(pact) para todas las entradas PactTerms válidas (test de propiedad).

14.5 Vectores cross-language del wrapper externo

El wrapper externo (§13) tiene un fixture canónico separado en crates/qub-core/tests/vectors/wrapper_v1.json. Cada caso fija una tupla (key, nonce, qub_id, sealed_cbor) como entradas hex opacas y afirma una salida expected_wrapper_hex específica. Ambas implementaciones de referencia consumen el mismo fichero JSON:

El fixture actualmente fija tres casos:

Caso Cobertura
basic-text-public La forma de SealedQub realista más pequeña; sin campos opcionales. Establece la forma canónica del wrapper para un qub típico v1.0.
with-recipient-pubkey SealedQub con recipient_pubkey establecido (ruta de Phase 2). Conjunto de claves CBOR interno diferente, qub_id diferente.
longer-body Cuerpo de ~4 KiB — ejercita prefijos de longitud CBOR multi-byte tanto dentro del envelope interno como del ciphertext externo.

Las implementaciones DEBEN producir un expected_wrapper_hex idéntico byte a byte para las entradas registradas. La regeneración del fixture requiere QUB_REGEN_VECTORS=1 cargo test -p qub-core --test wrapper_vectors y se reserva para cambios de formato deliberados.


15. Gobernanza del perfil criptográfico (Futuro)

Esta sección es informativa para v1 y se vuelve normativa la primera vez que un segundo algoritmo entre en alguna de las primitivas criptográficas de qub.

15.1 Postura actual

El protocolo v1 vincula exactamente un algoritmo por primitiva:

Los visores actualmente codifican de forma fija las longitudes de clave y firma por primitiva. El formato de cable no expone ninguna superficie de agilidad.

15.2 Forma prevista

Cuando un segundo algoritmo entre en el protocolo, el visor se configurará para un CryptoProfile con nombre (p. ej., ExqubV1) que enumere el conjunto exacto de valores permitidos por primitiva — sig_algs, chains de drand, versiones de wrapper, tipos de contenido. El perfil se fija en el momento de la verificación, nunca se negocia en banda. Cualquier valor fuera del perfil activo es rechazado.

Esto garantiza que añadir ML-DSA-87 o activar Ed25519 no pueda debilitar retroactivamente las configuraciones de visor existentes: un visor v1 sigue siendo un visor v1 incluso después de que se publique un perfil v2.

15.3 Condiciones de disparo

Promueva §15 a estado normativo cuando se proponga cualquiera de los siguientes:

Hasta entonces, §15 es un marcador de posición que fija la forma de la migración para que los PRs futuros aterricen contra un objetivo conocido en lugar de volver a litigar la superficie de negociación desde cero.