@nucleoledger/verify
v0.4.0-alpha.0
Published
Offline verifier for Núcleo receipts. Zero runtime dependencies.
Maintainers
Readme
@nucleoledger/verify
Offline verifier for Núcleo receipts.
A Núcleo receipt proves that a specific record was in a transparency log at a specific time. This package checks that proof — without contacting the issuer, their server, or anything else. It works in a browser tab with the network disconnected.
Zero runtime dependencies. Ed25519 and SHA-256 come from WebCrypto, which is part of the platform. Nothing else is needed.
npm install @nucleoledger/verifyUpgrading from 0.3.0-alpha.0
Required if your issuer's policy carries erasureKey. Receipts are read exactly as
before; what changes is the policy grammar.
| | 0.3.0-alpha.0 | 0.4.0-alpha.0 |
|---|---|---|
| Policy members | origin, logKey, signerKey, witnesses, quorum | the same, plus erasureKey (optional) |
| A policy with erasureKey | rejected: miembro desconocido "erasureKey" | accepted, if it is 64 lower-case hex characters and differs from logKey, signerKey and every witness key |
| Receipts | nucleo.org/receipt@v2 | the same; erasureKey plays no part in verifying a receipt |
Why 0.3.0-alpha.0 rejects it: the policy is a strict wire format (the project's ADR-018),
and an unknown member is an error on purpose — that is how a signerkey/signerKey pair
once gave two verifiers two different keys. erasureKey was added by nucleo v0.4.0-alpha
(ADR-032, PROTOCOL.md 0.7-draft): it is the public key that authorizes erasures, so that an
erasure counts only if a key that is not on the issuer's server signed it. The issuer
publishes one policy for everyone; once it carries erasureKey, every verifier that
reads it has to know the member. Nothing else changes: no code change in your
application, only the version.
This package reads erasureKey and does not check erasure authorizations: it has no
function for them and does not consume the erasure test vectors. Whether an erasure counts is
decided only by the nucleo CLI (nucleo reconcile, in Go), as ADR-032 scopes it; a third
party can check one by hand with the receipts and the policy (PROTOCOL.md §5.1).
Upgrading from 0.2.0-alpha.0
Nothing to change. 0.3.0-alpha.0 has the same code as 0.2.0-alpha.0 and reads the same
receipts (nucleo.org/receipt@v2); what changed is the package metadata —the author
field now carries the copyright holder's full legal name—, published alongside nucleo
v0.3.0-alpha.
Upgrading from 0.1.0-alpha.0
0.1.0-alpha.0 predates the current receipt format, and the two versions do not read each other's receipts:
| | 0.1.0-alpha.0 | 0.2.0-alpha.0 |
|---|---|---|
| Receipt format | nucleo.org/receipt@v1 (leaf/v1) | nucleo.org/receipt@v2 (leaf/v2) — what nucleo emits today |
| A receipt of the other format | rejected: "se esperaba nucleo.org/receipt@v1" | rejected, saying it is a @v1 receipt and why |
| Policy | a plain object {origin, logKey, witnesses, quorum} | read with parsePolicyText; signerKey is required |
So an upgrade is two changes in your code, not one: read the policy with
parsePolicyText instead of building the object yourself, and make sure the policy
file has signerKey — nucleo sync --json prints it. With the old object and no
signerKey, every receipt comes back valid: false, which is the safe direction to
fail but will look like an outage. Receipts issued by a nucleo binary from before the
receipt@v2 change need the old version; the format change is explained in the
project's PROTOCOL.md §2.1 and ADR-014.
Requirements
An environment with Ed25519 in WebCrypto: Node ≥ 20, Chrome ≥ 137,
Safari ≥ 17, Firefox ≥ 130. In a browser the page must be served over https or
from localhost, because crypto.subtle is unavailable otherwise.
That is what the published package needs, and it has no runtime dependencies
at all. Working on this repository needs more: the test runner (vitest 5) wants
Node ≥ 22.12, which is what .nvmrc pins.
package.json carries one overrides entry — vite: ^7 — and it is there for a
reason worth writing down, since a manifest cannot hold comments: vitest pulls
vite in, and vite 8 declares a peer on esbuild ^0.27 || ^0.28, which
conflicts with the esbuild 0.25.0 this package pins to build the browser bundle.
Vite 7 declares no esbuild peer at all, so constraining it there keeps the
bundler where it is. Bumping esbuild is a separate decision with its own
verification — it changes bytes that ship in web/verify/nucleo-verify.js — and
not something a security patch on the test runner gets to do as a side effect.
Usage
import { readFile } from "node:fs/promises";
import { parsePolicyText, verifyReceipt } from "@nucleoledger/verify";
// The policy is the file `nucleo sync` prints — the same file the issuer uses
// with --policy-file. Read it with parsePolicyText, NOT JSON.parse: JSON.parse
// silently keeps the last of two duplicated members, which is exactly how a
// third audit made the CLI and this library read two different keys from one
// file (PROTOCOL.md §3.2, ADR-018).
const policy = parsePolicyText(await readFile("politica.json", "utf8"));
// {
// "origin": "example.com/my-company",
// "logKey": "9ad2d5b3…", lower-case hex, 32 bytes
// "signerKey": "57857f0e…", required to verify a receipt (ADR-017)
// "erasureKey": "03a107bf…", optional; authorizes erasures (ADR-032),
// read and not used to verify a receipt
// "witnesses": { "witness.example/w1": "dd7e84d0…" },
// "quorum": 1 required, 1 ≤ quorum ≤ witnesses
// }
const result = await verifyReceipt(receiptText, policy);
if (!result.valid) {
console.error("invalid receipt:", result.reasons);
} else {
console.log("record", result.blockIndex, "was in the log");
console.log("declared:", result.declaredTime); // the issuer's clock
console.log("provable:", result.provableTime); // a witness's clock
}In the browser it is the same call:
<script type="module">
import { verifyReceipt } from "https://esm.sh/@nucleoledger/verify";
const result = await verifyReceipt(await file.text(), policy);
</script>Or, with no bundler and no CDN, bundle it once and load a single file — that is
what web/verify/index.html
does.
The two clocks
verifyReceipt returns both and never conflates them, because they answer
different questions:
declaredTimeis the timestamp the issuer put in the record. It comes from a clock they control, so it can lie. It is returned because it is useful, not because it proves anything.provableTimeis the earliest timestamp among the witness cosignatures that verify under the policy you passed. An independent third party stated it saw that tree at that moment.
A policy without witnesses, or without quorum, is rejected rather than
treated as "quorum 0": a receipt that no third party vouches for is not valid under
0.3-draft (ADR-018). A witness counts once toward the quorum however many lines it
has in the note, and its time is its earliest verifying cosignature.
Why bigint
blockIndex and the checkpoint size are bigint, and the whole inclusion-proof
loop runs in bigint. That is not fastidiousness.
JavaScript's bitwise operators coerce their operands to 32-bit signed
integers, and number is a double, exact only up to 2⁵³. A transparency log has
no reason to stop below two billion entries, and on the day one does not, an
implementation using >> and Number would compute a different index — silently,
with no exception and no warning, which is the worst possible failure mode for a
verifier.
There is not a single bitwise operator left in this package, and the test suite enforces that with a grep.
The practical consequence: JSON.stringify(result) throws on a bigint. Convert
with String(result.blockIndex) when serializing. Inconvenient, but honest.
It never throws
verifyReceipt returns { valid: false, reasons: [...] } instead of raising.
A verifier that throws forces every caller into a try, and one forgotten try
turns a bad receipt into an uncaught exception — a button that does nothing in a
browser, a 500 instead of "invalid receipt" on a server.
That promise covers every input, not just the receipt: a malformed policy —
a key with an odd number of hex digits, a 31-byte key, null where an object was
expected — comes back as a reason too. There is also a last-resort catch around
the whole function, because a promise with exceptions is not a promise.
interface Result {
valid: boolean;
declaredTime: string | null;
provableTime: string | null;
blockIndex: bigint | null; // bigint: a tree index can exceed 2^53
recipient: string | null;
cosigners: string[];
ignoredSignatures: string[];
logAdditionalSignatures: number; // the log's own ML-DSA-44 signature (ADR-007), not verified here
reasons: string[]; // in Spanish, meant to be shown to a person
checkpoint: { origin: string; size: string; rootHash: string } | null;
}What gets checked
- The policy itself follows PROTOCOL.md §3.2: exact members, no duplicates or
case variants, lower-case hex, at least one witness,
quoruman integer literal. - The receipt parses, its header is in canonical form, and the note's signature block follows PROTOCOL.md §3.3 line by line.
- The checkpoint is signed by the log key in your policy.
- Cosignatures verify against the witness keys in your policy — every line of a known
key must verify — and the quorum of distinct witnesses is met.
3b. The block is signed by the
signerKeyof your policy, and the issuer's signature covers the whole receipt, recipient included. - The RFC 9162 inclusion path leads from the record to the checkpoint root.
- The human-readable header matches the proof, byte for byte. A receipt with a flawless proof and a doctored visible text is rejected. This one matters: the text is what a person reads.
Signatures it ignores
A signed note can carry signatures from keys this verifier does not know — the
log's ML-DSA-44 signature, cosignatures from other witnesses. They are ignored,
as c2sp.org/signed-note requires. That is what lets one receipt circulate
between parties who trust different witnesses.
What is not ignored is a signature from a key you do know that fails to verify. That invalidates the whole receipt.
Test vectors
The test suite reads the same files under
testdata/vectors/
as the Go implementation — the official RFC 6962 Merkle vectors, the RFC 8785 key
ordering cases, and three golden receipts generated by the Go code. Not a copy:
the same files. Shared vectors that are not shared verify nothing.
That is how a real bug was caught: this verifier treated a block hash as an
already-computed Merkle leaf, while Go treats it as leaf data and applies the
0x00 domain prefix itself. Two conventions, two different roots for the same
tree.
License
Copyright (C) 2026 Sergio Iván Ullaguari Alvarado. AGPL-3.0-or-later. Commercial licenses are available for embedding Núcleo in proprietary software.
