primer-verify
v1.3.0
Published
Verify a Primer verified-reviews register from public data only: append-only hash chain, canonical-JSON preimages, Ed25519 signatures via JWKS, Merkle inclusion, and OpenTimestamps/Bitcoin anchoring. Zero dependencies.
Maintainers
Readme
primer-verify
Check a Primer verified-reviews register from public data only. No database, no API key, no account, no runtime dependencies.
npx primer-verify https://www.example-salon.com --corroborateThe tool reads the salon's published documents, re-derives every hash itself, and prints — step by step — what it checked, what it could not check, and why.
Why this exists
A salon's reviews are stored by the platform that shows them. That is the problem: the party with the strongest motive to delete a bad review is the party holding the database. "We don't delete reviews" is a promise, and a promise is not evidence.
So the reviews are not the record. The register is the record, and it is built so that the platform cannot rewrite it without the rewrite being visible to anyone who runs this tool:
- every ledger entry commits to the one before it, so removing entry 7 changes entry 8's hash and every hash after it;
- the day's entries are summarised into a Merkle root, signed by the platform;
- that root is timestamped into the Bitcoin blockchain through OpenTimestamps, so the platform cannot backdate a replacement;
- and this tool fetches the platform's side of the evidence from a pinned origin, never from a URL the salon under test supplies.
That last point is the whole difference between corroboration and theatre. A
register that rewrote its own history can serve a matching leaf set and a
matching fresh timestamp from its own infrastructure. One was built to do exactly
that and it passed 12 of 12 against an earlier version of this verifier
(fixtures/s1/evil-full/). It is refused now, and the test suite in this repo
keeps it refused.
primer-verify is published under MIT so that nobody has to take Primer's word
for the checking either. The full format is in SPEC.md — complete
enough to reimplement, which an independent auditor has already done from it.
What it verifies
| | |
|---|---|
| Append-only hash chain | every entry re-derives from its own canonical preimage, gapless in seq, each prevHash equal to the previous entryHash |
| Review content | the exported payload is re-hashed and compared with the payloadHash the chain commits to — the text on the page, not just a hash of itself |
| Canonical JSON preimages | sha256(canonicalJson(normalizePayload(kind, payload, algo))) under each entry's own declared rule — v3, v4, v5 and v6, with v2 accepted as legacy |
| Ed25519 signatures | every public document is signed, and the key comes from the origin's own /.well-known/jwks.json |
| Key continuity | the signing key must have been announced by a KEY_ANCHOR inside the chain itself, at or below the sequence number the platform anchored |
| Merkle inclusion | the day's root recomputes from the leaves the platform published, and this register's head is a leaf of it — or is committed by an O(log n) inclusion proof |
| The two surfaces' heads | the head inside the signed document is the head the export ends at, or is an entry of that export at the same seq and hash — a cached older generation of the same chain, PASS with the fact stated. A head the export does not carry fails, in either direction |
| OpenTimestamps / Bitcoin | the .ots proof parses, commits to that same root, and (with --corroborate) its Bitcoin attestation is checked against a public block explorer |
What it cannot verify, it says out loud on every run. Every result ends with a
NOT CHECKED block — on green runs too. Read it before concluding anything from
an exit 0. The known limits are enumerated in SPEC.md §1;
the biggest is that a nightly anchoring run means the newest entries have not
been witnessed by anyone outside yet, and the tool names that sequence number and
the size of that tail every time.
Usage
primer-verify <register-base> [--corroborate] [--json] [options]
primer-verify --ots <file.ots> [--root <64-hex>] [--json]<register-base> is the address the register is published under, and a salon has
exactly one of two:
https://<salon-domain> a salon with a verified custom domain
https://primer.tech/salon/<slug> every other salon — the majorityBoth serve <base>/.well-known/reviews-log.json and <base>/.well-known/jwks.json,
and documentOrigin inside the signed bytes is that base, path included — this
tool compares the two, so one salon's genuine document republished at another
salon's path fails step 2. A register has exactly one of the two, never both:
ask for a domain-owning salon's register under /salon/<slug> and the platform
answers 307 naming its canonical address, which this tool reports rather than
follows. A bare https://primer.tech is not a subject: that
host is this tool's trust root and a run pointed at it stops with NOT A REGISTER
rather than measuring a register that is not there. The same host with a
/salon/<slug> path is an ordinary subject. The full rule is
SPEC.md § The base.
| option | meaning |
|---|---|
| --corroborate | look the Bitcoin block up at a public explorer. Without it the block height is read from the proof and nothing more, and the run says so. |
| --json | machine-readable output: { steps, verdict, exitCode, warnings, platformOrigin, trustRootMoved, notChecked }. Every key is always present — warnings as an empty array, platformOrigin/trustRootMoved as null/false on runs that corroborate against nothing (an offline --ots check). |
| --page-limit <n> | entries per export page (default 1000). The export is always read to its end regardless. |
| --history-days <n> | how many days of the platform's anchor history a "never anchored" claim is held against (default 30; 0 disables the walk). |
| --explorer <api-base> | block explorer API base (default https://mempool.space/api). |
| --platform-origin <url> | moves the trust root. For a staging, preview or self-hosted deployment you already trust — those fail step 4 with REGISTER_POINTS_AT_FOREIGN_NETWORK otherwise, which is the pin working. A run that uses it prints that fact in NOT CHECKED, every time, and reports it as trustRootMoved: true with the origin under --json. Every "external" check is then only as independent as the origin you named. |
| --ots <file> | check a downloaded .ots proof offline, with no network at all. |
Node 20 or newer. Zero dependencies.
Exit codes
| code | meaning |
|---|---|
| 0 | nothing failed. Read the NOT CHECKED block before concluding anything. |
| 1 | at least one step FAILED. |
| 2 | the tool could not run: bad arguments, or the subject is not a register. Nothing is alleged — failures is empty and no step is FAIL. |
| 3 | NOT PROVEN — nothing failed, and nothing is alleged, but at least one material claim rests on nothing but the register's own word. A WARNINGS block says which. |
3 is not an accusation. It exists because a rewritten unanchored tail used to
receive the identical 0 and the identical summary sentence as a register nobody
had touched — and verdict + exitCode are the two fields a machine consumer
actually reads.
Expect 3 on ordinary honest registers in three states, each of them a true
statement about what could be established rather than a suspicion:
| warning | honest cause | what to do |
|---|---|---|
| NO_EXTERNALLY_WITNESSED_PREFIX | a new salon the platform has not anchored yet | re-run after the next nightly anchoring run |
| SIGNING_KID_ANNOUNCED_ABOVE_THE_ANCHORED_PREFIX | a key rotated since the last anchoring run | re-run after the next one |
| ORIGIN_BINDING_UNSIGNED | a deployment with no signing key provisioned | provision the key |
| SIGNING_KID_UNVERIFIED_OVER_A_TRUNCATED_EXPORT | the export read was rate-limited | re-run |
Adverse-case codes
When a step fails, the code names the shape of the problem. The chain-level ones:
| code | what happened |
|---|---|
| HASH_MISMATCH | an entry's entryHash is not what its own contents hash to. The entry was edited in place. |
| LINK_BROKEN | an entry's prevHash is not the previous entry's entryHash. Something was removed, inserted or reordered. |
| SEQ_GAP | the sequence numbers skip. Entries were deleted. (SEQ_DUPLICATE for the mirror case.) |
| CONTENT_TAMPERED | the exported review text does not hash to the payloadHash the chain commits to. The words on the page were changed while the committed hash was left in place. |
| ALGO_UNKNOWN | an entry declares a hashAlgo this verifier has no rule for. Unverifiable, not waved through — "I cannot check this", never "this is wrong". A build older than the rule an honest register just started writing answers this way: 1.1.0 and 1.2.0 do it for sha256-canonicaljson-v6, which 1.3.0 knows. |
And the ones that decide whether the outside world backs the register up:
| code | what happened |
|---|---|
| REGISTER_POINTS_AT_FOREIGN_NETWORK | the register names a "platform" it controls. A reader who followed it would corroborate the register against itself. |
| REGISTER_ROOT_DISAGREES_WITH_PLATFORM | the daily root the register claims is not the one the platform published. |
| HEAD_NOT_IN_ANCHORED_ROOT | the register's head is neither a leaf of the anchored root nor an ancestor of one. |
| SIGNING_KID_NOT_ANCHORED | the document is signed by a key no KEY_ANCHOR in its own chain resolves to — a republication under a key the register never announced. |
| COLD_START_REFUTED_BY_PLATFORM_HISTORY | the register claims it was never anchored; the platform's own anchor history carries a leaf for it. |
| ROOT_MISMATCH | the Merkle root does not recompute from the published leaves. |
| HEAD_NOT_IN_EXPORT | the head inside the signed document is not carried by the export at that seq — the register signed one history and serves another. |
| STALE_SIGNED_HEAD_COUNT_MISMATCH | the document's own head and its own entry count describe different chains: it signed seq k and published a figure that is not the number of entries at or below k. One signature covers both. |
And one that is deliberately not a failure (since 1.2):
| code | what happened |
|---|---|
| STALE_SIGNED_HEAD_INCLUDED | the signed document is BEHIND its own export, and the head it signed IS carried by that export at the same seq with the same hash. A signed document must be byte-stable for a cache window or its signature would move on every fetch, so a publisher always has two surfaces of different ages; the chain re-derived in step 5 extends the head that was signed. PASS, with the fact in the step detail and the residual in NOT CHECKED. The opposite direction — a document ahead of its export — stays a FAIL and is never softened, and the tolerance needs that head positively carried by the entries actually read — a throttled, partial read confirms nothing, so it never earns this. |
The complete vocabulary, with the reasoning behind each verdict tier, is in SPEC.md.
Example runs
A live deployment, pointed at the trust root by mistake
$ primer-verify https://dev.primer.tech --corroborate --platform-origin https://dev.primer.tech
[PASS] 1. FETCH register document — https://dev.primer.tech/.well-known/reviews-log.json
NOT CHECKED:
- THE TRUST ROOT WAS MOVED: this run treated https://dev.primer.tech as the Primer
platform, not the pinned https://primer.tech / https://www.primer.tech. Every
"external" check below is only as independent as that origin is.
- everything below step 1 — this run stopped at the subject:
https://dev.primer.tech/.well-known/reviews-log.json is a platform-scope document,
and steps 2..12 measure a salon register. No signature here was verified, in
either direction
NOT A REGISTER — https://dev.primer.tech serves a PLATFORM-scope document — the Primer
network summary, not a salon register: it names no salonId, publishes no chain of its
own and no anchor list […] NOTHING was checked and NOTHING is alleged about this host
$ echo $?
2A real OpenTimestamps receipt, offline
fixtures/ots/ holds bytes this project did not author: receipts from the
volunteer OpenTimestamps calendars, and the same file after the official
client's ots upgrade.
$ primer-verify --ots fixtures/ots/2026-08-21.a.upgraded.ots
[PASS] 9. OTS parse — 1308 bytes, digest b329562a30e2eb8e3c042ddd3ddd9f1677f7a8093e6a38ccb6f575a23685984c
[SKIP] 8. DIGEST matches the expected root — no --root was given to compare against
[PASS] 10. OTS attestation — BITCOIN block 963410
[UNVR] 11. BITCOIN corroboration — offline file check — no explorer was queried
NOT CHECKED:
- DIGEST matches the expected root: no --root was given to compare against
- BITCOIN corroboration: offline file check — no explorer was queried
VALID AS FAR AS CHECKED — not checked: 2 item(s)A full register, against the shipped fixtures
npm run demo runs the verifier offline through the identical code path a
network run takes — the fixtures declare their own origin, so origin binding is
genuinely exercised.
$ npm run demo
[PASS] 1. FETCH register document — https://fixture.test/.well-known/reviews-log.json
[PASS] 2. ORIGIN BINDING — documentOrigin = https://fixture.test
[PASS] 3. SIGNATURE (register document) — kid fixture-salon-2026-08
[PASS] 4. SIGNATURE (network document) — kid fixture-platform-2026-08 at https://platform.fixture.test
[PASS] 5. CHAIN — 12 entr(ies) re-derived, gapless and linked (2 page(s) of up to 1000)
[PASS] 6. HEAD — seq 12
[SKIP] 5b. CONTENT — this export carries no payloads, so no content was re-derived here
[PASS] 7. KEY_ANCHOR continuity — 1 anchor(s); signing kid fixture-salon-2026-08 is
announced at seq 1, at or below the seq the platform anchored (12)
[PASS] 8. ANCHOR root — root recomputed from 3 leaf/leaves; this register's head is
leaf seq 12 (published by https://platform.fixture.test)
[PASS] 9. OTS parse — 128 bytes, digest = the published root
[PASS] 10. OTS attestation — PENDING
[UNVR] 11. BITCOIN corroboration — the proof carries no Bitcoin attestation yet
[SKIP] 8b. NETWORK INCLUSION (cross-origin) — this register is not listed in the
platform network directory (0 listed)
[PASS] 12. COUNTERS — 12 entries, agreeing with the export
VALID AS FAR AS CHECKED — not checked: 3 item(s)Try the attackers too — each one is a rewrite that beat an earlier version of this tool:
npm run demo evil-full platform # exit 1 — the full forgery
npm run demo evil-tail-rekey platform-grown # exit 3 — NOT PROVEN
npm run demo honest-grown platform-grown # exit 0 — the honest controlTests
npm test18 cases, no network, no dependencies. They are not smoke tests: every attacker
fixture is one that was measured passing an earlier version of this verifier, and
each assertion names the step and the code that catches it — exit 1
alone would still pass if a refactor left only the signature check firing, and
the external defence would stop working silently.
fixtures/README.md documents every variant, and is explicit about which bytes
are real third-party evidence (fixtures/ots/) and which are synthetic
(fixtures/s1/*/anchors/*.ots — well-formed, and evidence of nothing, because no
calendar ever saw those digests).
Programmatic use
import { verifyPublicSurface } from 'primer-verify'
import { toJson } from 'primer-verify/report'
const summary = await verifyPublicSurface('https://www.example-salon.com', { corroborate: true })
console.log(toJson(summary)) // { steps, verdict, exitCode, warnings, platformOrigin, trustRootMoved, notChecked }The loader is injectable ({ fetchJson, fetchText, fetchBytes }) — that is how
the offline fixture runs use the same code path as a network run.
Submodules: primer-verify/canonical, /chain, /merkle, /inclusion, /ots,
/jwks, /keyanchor, /report.
License
MIT © 2026 WISE PEOPLE S.R.L. See LICENSE.
