Segunda capa

La API, el formato, la cadena

Todo aquello de lo que está hecha un acta, y todas las rutas que crean o sirven una. Alguien te ha dado un acta y quieres desmontarla sin nuestra ayuda: para eso está esta página, y desmontarla sin nuestra ayuda es todo el diseño.

El acceso, dicho con franqueza

No hay clave de API, ni cuenta, ni plan que desbloquee un límite mayor. Todas las rutas de abajo son públicas o están protegidas por un token que pertenece a una sesión o a un acta y se entrega a quien la inició.

Las rutas que llevan un documento o el dinero rechazan una petición cuyo encabezado Origin nombre un host que no es el nuestro. Una petición que no envía ningún encabezado Origin —un servidor llamando a otro servidor— no se rechaza, y preferimos decirlo aquí a que alguien lo descubra y suponga que estaba escondido. No es una superficie de integración soportada: no tiene promesa de versionado, ni clave que rotar, ni un límite de peticiones pensado para ella, y puede cambiar en cualquier despliegue.

Si quieres construir sobre esto en serio, escríbenos y dilo. Una superficie soportada y versionada, con claves, es algo que construiríamos para alguien real y que no hemos construido para alguien hipotético.

Rutas

Toda respuesta es JSON. Las rutas de sesión son no-store y noindex; un token bien formado cuya sesión ha terminado responde 404 con expired, que es una cosa distinta de un token que nunca existió.

La tabla de abajo es el protocolo en sí y se publica solo en inglés.

MétodoRutaQué haceResponde
POST/api/checkStart a check. Multipart with a file, or JSON {text, ref}. A ref of word-win, word-mac or word-web over a posted string records that the caller assembled the string out of an open document, and the receipt then names the add-in's extractor rather than the file reader's. — quién: same-origin202 {token, expires_at, arm}
GET/api/check/:tokenThe session as it stands: every claim found, and each verdict as it lands. This is what the page polls while a check runs. — quién: the tokenthe session view
GET/api/check/:token/statusThe completion signal, read off the database row rather than out of memory — so a deploy in the middle of a payment does not tell somebody who has paid that their session is gone. — quién: the token{receipt_id, sealed, checked_out, paid, closed, session}
GET/api/check/:token/preview.jsonThe unsigned, unchained receipt-shaped preview of a check that has finished but has not been sealed. — quién: the tokena receipt-shaped object with no signature
GET/api/check/:token/binding.jsonThe binding bundle: what turns the per-claim fingerprints in a receipt back into the claims they were computed from. — quién: the tokena downloaded file
POST/api/check/:token/holdsRun the adversarial third level over one claim. Off by default, never sealed into a receipt, and it sends the sentence you wrote. — quién: the token202
DELETE/api/check/:tokenEnd the session now and drop the preview. The session expires on its own either way. — quién: the token{deleted: true}
POST/api/checkoutTurn a finished check into an invoice. {token, email, tier}. — quién: same-origina payment URL
GET/api/receipt/:idThe public projection of a sealed receipt: every cited URL removed, every verdict, count and hash kept, signed in its own right.the stored canonical bytes
GET/api/receipt/:id/proofThe inclusion proof pointing at the day's Merkle root. 202 while that day has not been rooted yet.an audit path
GET/r/:id/receipt.jsonThe full sealed receipt, cited URLs and all. This is the creator's file, and the reason the projection exists. — quién: the creator's linkthe sealed bytes
GET/r/:id/receipt.pdfThe printable receipt: the page somebody staples to a report and is asked about under oath. — quién: the creator's linka PDF
GET/r/:id/qr.pngThe receipt's QR as a raster, for a printed page.a PNG
GET/chain/headWhere the chain is now.{seq, self_hash, issued_at, last_root}
GET/chain/root/:dateOne UTC day's signed root file, exactly as it was stored and published.the stored canonical bytes
GET/chain/verifyOur own replay of the chain, and the first break in it if there is one. Rooted days are re-derived on every call; the answer is memoised for sixty seconds.{ok, checked, head_seq, first_broken, …}
GET/keys.jsonEvery public key this install has ever signed with, so a verifier can pin them rather than trust the key a receipt carries about itself.the key set
GET/healthzWhether the service is up.{ok}

Una ruta de acta nunca confirma un identificador que no tiene, y una ruta de administración sin su token responde byte a byte igual que una ruta que no existe: mismo estado, mismos encabezados, mismo cuerpo. Es deliberado: la diferencia entre ambas respuestas sería, si no, un mapa de lo que existe.

Un servidor para agentes de IA

Todavía no lo hay, y este es el único sitio donde lo anunciaríamos. El cambio que reordenó este producto puso un servidor orientado a modelos en la misma segunda capa que las rutas de arriba, y la posición honesta hoy es que esas rutas son toda la superficie para máquinas: una comprobación es un POST, su resultado es un GET y un acta es una URL pública. Cuando se construya un servidor para agentes, envolverá justo eso y nada más, y esta sección lo dirá con el nombre y el transporte dentro. Hasta entonces no hay nada que instalar ni nada que configurar.

La firma

Un acta es JSON canónico. Su self_hash es el SHA-256 de los bytes canónicos del acta con dos miembros de primer nivel retirados, self_hash y signatures, de modo que el hash vive fuera de sí mismo y las firmas viven fuera del cuerpo al que se aplica el hash. Eso es lo que permite contrafirmar un acta más tarde sin mover su hash.

La firma es Ed25519 sobre los 32 bytes en bruto que se obtienen al decodificar self_hash desde hexadecimal. No sobre los 64 caracteres hexadecimales, ni sobre el JSON canónico, ni sobre un hash del hash: Ed25519 aplica su propio hash a la entrada, así que firmar el resumen directamente es todo el esquema.

Todo valor base64 del formato es canónico: alfabeto estándar, relleno completo, sin espacios y con los bits bajos no usados del último símbolo a cero, de modo que una clave tiene una sola escritura y dos claves se pueden comparar como bytes o como texto con el mismo resultado. Un decodificador que tolerase un relleno ausente o el alfabeto seguro para URL leería los mismos bytes de varias escrituras, y este formato admite una.

Un acta lleva una sola firma cuyo papel es issuer. Cero dice que nunca se emitió; dos o más dicen que no puede declarar quién la emitió; ambas cosas son fallos. La firma se verifica contra la clave publicada para su identificador de clave, nunca contra la clave que el acta dice de sí misma: un acta no puede responder por sí sola.

La cadena y las raíces

Hay una sola cadena. seq es densa y empieza en 1, y prev_hash en seq n es el self_hash de seq n−1. Ambos están dentro del cuerpo firmado, así que la posición de un acta se firma con ella: mover un acta, o reescribir cualquier cosa anterior, cambia su hash.

Las actas encadenadas de cada día UTC forman un árbol de Merkle, con el hash calculado tal como lo define el RFC 6962: las hojas con un byte cero por delante y los nodos interiores con un uno, de modo que una hoja nunca puede hacerse pasar por un nodo. La raíz del día se firma con una clave de raíz aparte y se publica en un repositorio público que no podemos editar en silencio. Un día vacío también tiene su archivo, así que un hueco en la serie se vería desde fuera.

La prueba de inclusión de un acta es un camino de auditoría hacia una de esas raíces. Un acta sellada hoy no tiene prueba hasta que se corte la raíz de esta noche, y la ruta de la prueba dice pending en lugar de inventarse una.

Lo que no lleva un acta pública

Lo que ve quien abre el enlace de un acta es una proyección: cada URL citada eliminada, cada localizador reducido a {type, domain} y el identificador propio del registro de sentencias descartado con él, porque el conjunto de esos identificadores sería la tabla de autoridades del documento. Todo lo demás se queda byte a byte, hashes y firmas incluidos, así que quien tenga el acta completa puede vincular las dos. Una proyección se firma por sí misma, con un miembro extra que el acta sellada nunca tiene, de modo que toda su superficie visible —veredictos, recuentos, estados, dominios— queda cubierta por una firma y no solo por el hash del acta.

El verificador

El verificador tiene licencia MIT y está replicado en un alojamiento público de código. No hace ninguna petición propia: verificar es calcular hashes y comprobar firmas sobre bytes que ya tienes, que es por lo que el contador que hay a su lado en la página de verificación puede marcar cero honestamente.

Se ejecuta aquí, en tu navegador, como un módulo compilado servido desde este sitio. Carga la página de verificación una vez, desconéctate y sigue verificando: esa es la comprobación que hay que hacer antes de fiarse de nada de esto, y la que hay que repetir si algún día desaparecemos.

Desde un terminal

Las mismas comprobaciones se ejecutan desde la línea de comandos contra un acta de tu disco. El paquete todavía no está en el registro público, así que npx exhibitb hoy no resuelve y no vamos a imprimirlo como si lo hiciera. Lo que existe ahora es la compilación para navegador de arriba, que es el mismo verificador, y el código en el alojamiento público, que compila para ambos. Cuando se publique el paquete se dirá su nombre en el registro de cambios el día en que ocurra, no antes.

Ejecútalo sobre un acta que te enviaron

Lo que envía el complemento

Un acta registra qué extractor produjo el texto que describe, y ese campo no vale nada si un desconocido con el mismo documento no puede reconstruir la misma cadena de texto y obtener el mismo hash. Esta es esa especificación. Es el contrato publicado detrás de la etiqueta exhibitb-word@1, y es la razón por la que un acta creada dentro de un documento se puede vincular al documento.

La especificación de abajo es el contrato publicado en sí y se imprime solo en inglés, para que una reconstrucción contra ella sea idéntica byte a byte en todas partes.

exhibitb-word@1

exhibitb-word@1 — the string a receipt from the add-in describes

A receipt names the extractor that produced the text it was made from. When the check ran from inside Word, the text was assembled in the open document rather than parsed out of an uploaded file, and this is how. Rebuild the string from your own copy of the document, hash it, and it matches the text_sha256 in the receipt — or it does not, and you have learned something.

El orden en que se construye la cadena de texto

  1. Take the paragraphs of the document's main story, in the order Word lays them out. Paragraphs inside a table are part of that order: a table is read row by row, and each row cell by cell in reading order, at the point where the table sits.
  2. Then every footnote body, in footnote-number order.
  3. Then every endnote body, in endnote-number order.
  4. A paragraph's text is its own characters. A hyperlink contributes the words the reader sees; the address it points to is not inserted.
  5. The three groups are concatenated in that order — body, then footnotes, then endnotes — with nothing between them but the same separator that joins paragraphs.

Qué las une

Every paragraph, footnote body and endnote body is joined to the next by two line feeds (U+000A twice), which is one blank line.

Lo que nunca está dentro

  • the .docx file itself — no part of the container is uploaded
  • headers and footers
  • comments, and the identity of whoever wrote them
  • tracked changes: deleted-but-unaccepted text is not sent, insertions are sent as the paragraph reads with them applied, and no revision author is sent
  • styles, fonts, numbering, colour and every other piece of formatting
  • images, charts, embedded objects and their captions' pictures (a caption's own words are a paragraph like any other)
  • the file name, the folder it sits in, and every piece of document metadata
  • fields, in favour of the result they display

La limpieza, aplicada al final y a nada más

  • carriage returns become line feeds: and a lone both become
  • control characters are removed (U+0000–U+0008, U+000B, U+000C, U+000E–U+001F, U+007F)
  • soft hyphens (U+00AD) are removed
  • spaces and tabs immediately before a line feed are removed
  • three or more consecutive line feeds collapse to two
  • leading and trailing whitespace is removed from the whole string

Comprobar tu propia reconstrucción

The cleaning is the same eight lines the uploaded-file path runs, and it is idempotent: running it twice changes nothing, so a rebuild that applies it once is comparable byte for byte. SHA-256 of the resulting UTF-8 bytes is the receipt's document.text_sha256; document.chars is that string's length in code units.

Por qué los dos extractores cuentan distinto

The two paths do not read the same amount of a document, and the label is how a reader tells them apart. exhibitb-word@1 reads footnotes and endnotes. exhibitb-text@1 over an uploaded .docx reads the main story only, so a footnote-heavy report checked as a file will show fewer citations than the same report checked from inside Word. Neither number is wrong; they are counts of different strings, and the receipt says which one it made.

exhibitb-text@1

La otra etiqueta. Un .docx subido lo analiza una biblioteca que recorre los hijos del propio documento, que son el cuerpo principal y las tablas que hay dentro; las notas al pie y las notas finales se guardan en otra parte del contenedor y no se leen. Todo lo demás sobre la cadena de texto es igual, incluidas la limpieza de abajo y el hash sobre ella.

El formato completo

La forma canónica, el esquema del acta miembro a miembro, las reglas de la cadena, la construcción de Merkle, el archivo de la raíz, la prueba de inclusión y la regla de proyección están especificados en un solo documento, con vectores de prueba al lado: el mismo archivo, byte a byte, que se distribuye en el propio repositorio del verificador. Cada número que contiene lo puede reproducir un tercero.

Vectores de prueba

  • the canonical form of an object, with the worked examples that pin the whitespace and the number formatting
  • self-hash vectors, including an object carrying a wrong self-hash and a nonsense signature array hashing to what it hashes to without them
  • a signed receipt, its key set and its verification result
  • Merkle roots for trees of 0, 1, 2, 3 and 7 leaves, with every audit path

Si algo de esta página está mal, o una comprobación que hayas escrito contra ella falla y crees que no debería, la dirección de soporte del pie de página llega a una persona que mirará los bytes contigo.