Второй слой

API, формат, цепочка

Всё, из чего сделан протокол, и каждый маршрут, который его создаёт или отдаёт. Вам передали протокол, и вы хотите разобрать его без нашей помощи — страница ровно для этого, а то, что его можно разобрать без нашей помощи, и есть весь замысел.

Доступ, честно

Ключа к API нет, аккаунта нет, и нет тарифа, который открывает лимит повыше. Каждый маршрут ниже либо открыт, либо закрыт токеном, который принадлежит одной сессии или одному протоколу и выдан тому, кто её начал.

Маршруты, по которым идёт документ или деньги, отклоняют запрос, чей заголовок Origin называет не наш хост. Запрос, который не шлёт Origin вовсе — сервер, обращающийся к серверу, — не отклоняется, и мы предпочитаем сказать это здесь, а не дожидаться, пока кто-нибудь это обнаружит и решит, что от него скрывали. Это не поддерживаемая поверхность интеграции: у неё нет обещаний по версиям, нет ключа для ротации и нет настроенного под неё ограничения частоты, и она может измениться в любом выкладывании.

Если вы хотите строить на этом всерьёз, напишите и скажите. Поддерживаемую версионируемую поверхность с ключами мы построили бы для настоящего вызывающего и не стали строить для гипотетического.

Маршруты

Любой ответ — JSON. Маршруты сессии отдаются с no-store и noindex; правильно составленный токен, чья сессия закончилась, отвечает 404 с expired, и это не то же самое, что токен, которого никогда не было.

Таблица ниже — это и есть сам интерфейс, и она публикуется только по-английски.

МетодМаршрутЧто делаетОтвечает
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. — кому: 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. — кому: 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. — кому: 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. — кому: 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. — кому: 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. — кому: the token202
DELETE/api/check/:tokenEnd the session now and drop the preview. The session expires on its own either way. — кому: the token{deleted: true}
POST/api/checkoutTurn a finished check into an invoice. {token, email, tier}. — кому: 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. — кому: 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. — кому: 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}

Маршрут протокола никогда не подтверждает идентификатор, которого у него нет, а административный маршрут без токена отвечает байт в байт так же, как путь, за которым вообще нет маршрута: тот же статус, те же заголовки, то же тело. Это сделано намеренно: разница между двумя ответами была бы картой того, что существует.

Сервер для ИИ-агентов

Его пока нет, и объявили бы мы о нём только здесь. Патч, переставивший этот продукт, положил сервер для моделей в тот же второй слой, что и маршруты выше, и честная позиция на сегодня такова: маршруты выше и есть вся машинная поверхность — проверка это один POST, её результат один GET, а протокол это открытый адрес. Когда сервер для агентов будет построен, он обернёт ровно их и ничего сверх того, и этот раздел скажет об этом, назвав имя и транспорт. До тех пор устанавливать нечего и настраивать нечего.

Подпись

Протокол — это канонический JSON. Его self_hash — это SHA-256 канонических байтов протокола, из которого убраны два члена верхнего уровня: сам self_hash и signatures. Хеш таким образом живёт вне себя, а подписи живут вне хешируемого тела, и именно это позволяет позже подписать протокол ещё раз, не сдвинув его хеш.

Подпись — это Ed25519 по 32 сырым байтам, полученным hex-декодированием self_hash. Не по 64 шестнадцатеричным символам, не по каноническому JSON и не по хешу от хеша: Ed25519 хеширует свой вход сам, поэтому подписать дайджест напрямую — это и есть вся схема.

Каждое значение base64 в формате каноническое: стандартный алфавит, выравнивание ровно по правилам, никаких пробелов, а неиспользуемые младшие биты последнего символа — нули. Поэтому у одного ключа ровно одно написание, и два ключа можно сравнивать хоть как байты, хоть как текст с одним и тем же ответом. Декодер, который терпит недостающий символ выравнивания или URL-безопасный алфавит, прочитал бы одни и те же байты из нескольких написаний, а этот формат допускает одно.

Протокол несёт ровно одну подпись с ролью issuer. Ноль означает, что он никогда не выпускался; две и больше — что он не может сказать, кем выпущен; и то и другое — провал. Подпись проверяется по ключу, опубликованному для её идентификатора ключа, и никогда по ключу, который протокол несёт сам о себе: нельзя позволять протоколу ручаться за самого себя.

Цепочка и корни

Цепочка одна. seq плотный и начинается с 1, а prev_hash на seq n — это self_hash для seq n−1. Оба лежат внутри подписанного тела, поэтому место протокола подписано вместе с ним: перенести протокол или переписать что-либо до него — значит изменить его хеш.

Сцепленные протоколы каждых суток по UTC образуют одно дерево Меркла, хешируемое ровно так, как определяет RFC 6962: листья с нулевым байтом впереди, внутренние узлы с единицей, чтобы лист нельзя было выдать за узел. Корень дня подписывается отдельным корневым ключом и публикуется в открытом репозитории, который мы не можем тихо отредактировать. Пустой день тоже получает файл, поэтому разрыв в череде был бы виден снаружи.

Доказательство включения протокола — это путь аудита в один из этих корней. У протокола, запечатанного сегодня, доказательства нет, пока ночью не срезан корень, и маршрут доказательства отвечает pending, а не выдумывает его.

Чего не несёт открытый протокол

То, что читатель видит по ссылке протокола, — это проекция: каждый процитированный адрес убран, каждый локатор сведён к {type, domain}, а собственный идентификатор записи в судебном реестре выброшен вместе с ним, потому что набор таких идентификаторов был бы перечнем источников документа. Всё остальное остаётся байт в байт, включая хеши и подписи, так что тот, у кого есть полный протокол, может связать одно с другим. Проекция подписана сама по себе, с одним дополнительным членом, которого у запечатанного протокола нет, поэтому вся её видимая поверхность — вердикты, количества, статусы, домены — покрыта подписью, а не только хешем протокола.

Верификатор

Верификатор под лицензией MIT и продублирован на открытом хостинге кода. Своих запросов он не делает: проверка — это хеширование и проверка подписей над байтами, которые у вас уже есть, поэтому счётчик рядом с ним на странице проверки может честно показывать ноль.

Он работает здесь, у вас в браузере, как скомпилированный модуль, отдаваемый с этого сайта. Откройте страницу проверки один раз, отключитесь — и он всё равно проверяет. Это та проверка, которую стоит провести, прежде чем доверять чему-либо из написанного здесь, и та же самая, которую стоит повторить, если мы когда-нибудь исчезнем.

Из терминала

Те же проверки запускаются из командной строки по протоколу у вас на диске. Пакета в открытом реестре пока нет, поэтому npx exhibitb сегодня не разрешается, и печатать это так, будто он работает, мы не станем. Сейчас существует браузерная сборка выше — это тот же верификатор — и исходный код на открытом хостинге, который собирается в обе. Когда пакет опубликуют, его назовут в истории изменений в тот же день, а не раньше.

Запустить прямо здесь, на присланном вам протоколе

Что отправляет надстройка

Протокол записывает, какой извлекатель произвёл текст, который он описывает, и это поле не стоит ничего, если посторонний с тем же документом на руках не может собрать ту же строку и получить тот же хеш. Вот эта спецификация. Она — опубликованный договор за меткой exhibitb-word@1, и она же объясняет, почему протокол, сделанный внутри документа, вообще можно привязать к документу.

Спецификация ниже — это сам опубликованный договор, и она печатается только по-английски, чтобы пересборка по ней везде давала байт в байт одно и то же.

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.

В каком порядке собирается строка

  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.

Что их соединяет

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

Чего в ней нет никогда

  • 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

Очистка — последним шагом и больше нигде

  • 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

Как проверить свою пересборку

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.

Почему два извлекателя считают по-разному

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

Другая метка. Загруженный .docx разбирает библиотека, которая обходит собственных потомков документа, — это основной текст и таблицы внутри него; обычные и концевые сноски лежат в контейнере отдельно и не читаются. Всё остальное в строке то же самое, включая очистку ниже и хеш поверх неё.

Формат целиком

Каноническая форма, схема протокола член за членом, правила цепочки, построение Меркла, файл корня, доказательство включения и правило проекции описаны в одном документе, а рядом с ним лежат тестовые векторы — тот же файл, байт в байт, что едет в самом дистрибутиве верификатора. Любое число в нём воспроизводимо третьей стороной.

Тестовые векторы

  • 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

Если что-то на этой странице неверно или написанная вами проверка по ней падает, а вы считаете, что не должна, по адресу поддержки в подвале отвечает человек, который сядет разбирать байты вместе с вами.