sigil-protocol-verifier
v3.0.0
Published
Reference verifier for the Sigil Protocol v1.1. Independent implementation per the published specification.
Readme
sigil-protocol-verifier
Reference verifier for Sigil Protocol v1.1
(which amends v1.0). Independent of the Sigil
platform — built from the spec, with node-forge as the only runtime dependency.
Install
npm install -g sigil-protocol-verifierUse
sigil-verify <path-to-sealed.pdf> # verify a sealed PDF (§5)
sigil-verify chain <path-to.json> # verify a served load bundle (§6.3, §10)
[--expect-load <id>] # the load id YOU asked for (§6.3.8 point 7)
[--expect-root <hex>] # the anchor root YOU fetched (§11.9)
[--seal-record <base-url>] # perform the §11.8.1 lookup (off by default)
sigil-verify --version # protocol versions implemented (§9)--version answers the §9 conformance requirement that a verifier advertise the
maximum protocol version it implements. It prints JSON, because the caller is as
likely to be a script deciding whether this binary can verify a v1.1 bundle as it
is to be a human:
{ "protocolVersion": "1.1", "supportedProtocolVersions": ["1.0", "1.1"] }This is not the package version and the two will diverge. The package gets
patch releases that change no protocol behaviour. Read protocolVersion (or the
exported PROTOCOL_VERSION / SUPPORTED_PROTOCOL_VERSIONS) to learn what spec
this implements — never the package version. They already differ.
Both print a JSON report and exit 0 on a clean verify, 1 on a bad one, 2 on a usage or I/O error — so a shell can branch on the exit code without parsing anything. The bare-PDF form is unchanged from 1.0.x.
chain is the whole diligence check in one scriptable step: it recomputes the
v2 load chain, opens any escrowed disclosures, and confirms the head is included
under an anchor root, with Sigil out of the loop. The document it reads is the
JSON a Sigil API serves:
{
"loadId": "load_01H...",
"entries": [ /* the v2 chain */ ],
"openings": [ /* optional, index-aligned escrowed disclosures */ ],
"anchor": { "head": {...}, "proof": [...], "root": "..." },
"expectedRoot": "the operator's own claim about the root"
}Only entries is required.
The two values you supply YOURSELF (§6.3.8 point 7, §11.9)
sigil-verify chain bundle.json --expect-load <the load id you asked for>
sigil-verify chain bundle.json --expect-root <the root you fetched yourself>These are the only inputs that can corroborate anything, and it is worth being precise about why. Everything else in the document arrived from the party you are checking. A value that arrived with the artifact cannot corroborate the artifact, so:
--expect-rootis the root you obtained out of band, from the anchor feed or by parsing the RFC-3161 token. Only this setsrootCorroborated: true. The envelope's ownexpectedRootmember cannot and never could: the operator agreeing with itself is not evidence. If it disagrees with the served anchor root the bundle isBROKEN, because that is the operator contradicting itself.--expect-loadis the load id you asked for. Without it the chain is checked only against the bundle's self-assertedloadId, which a transplant rewrites too, so an internally perfect document can be a real chain re-sealed onto a different load.
rootCorroborated: false is not a failure. It means you hold an inclusion claim
rather than an external-timestamp claim, which is a real claim and a weaker one.
This section replaced text that was wrong, 2026-07-27. Earlier releases of this README told you to put the root you trust into the envelope's
expectedRootand said that upgraded the result to "the root you trust". That is the defect clean-room run 4 found in all three verifiers: a bundle whose envelope root equalled its own served anchor root reportedrootCorroborated: true, so the protocol's strongest guarantee was satisfiable by the operator alone. §11.9 closed it. Published 2.0.0 does not implement §11.9 and rejects--expect-rootas an unknown argument; check what you installed withnpm ls sigil-protocol-verifier.
Programmatic
import { verifyBundle, verifyLoadChainV2, verifyAnchorInclusion } from 'sigil-protocol-verifier';
verifyBundle(doc); // what `sigil-verify chain` runs
verifyLoadChainV2(served.entries, 'load_01HQZ...'); // §6.3.6 — pass the load id you asked for
verifyAnchorInclusion(head, proof, publishedRoot); // §10.4Payload roots and selective disclosure (§6.4)
payloadRoot is a per-field Merkle root, not a whole-payload digest. Its
canonical form landed in 1.3.0, so this package can now produce a root and
check a single-field disclosure against a sealed one — the half §6.3.4 has
advertised since v1.1 shipped:
import {
computePayloadRoot,
payloadFieldOpens,
payloadFieldProof,
payloadLeaves,
verifyPayloadField,
MAX_PAYLOAD_DEPTH,
MAX_PAYLOAD_LEAVES,
} from 'sigil-protocol-verifier';
computePayloadRoot({ agreedRateCents: 400000, currency: 'USD' }); // §6.4 — the sealed root
payloadLeaves(payload); // §6.4.6/§6.4.7 — the canonical sorted leaf set
payloadFieldProof(payload, ['agreedRateCents']); // §6.4.9.2 — { disclosure, proof }
verifyPayloadField(disclosure, proof, sealedRoot); // §6.4.9 — true / false, never throws
payloadFieldOpens(leafHashes, ['currency'], 'USD'); // §6.4.9 — candidate membershipThings worth knowing before you rely on it:
- A disclosure that cannot be encoded returns
false, it does not throw. A non-terminal value or a number outside §6.4.2's band is "not proven", never an exception a caller might mistake for something else. The leaf is always recomputed from(path, value); a caller-supplied digest is never accepted, because the fold would happily carry an interior node to the root. computePayloadRootandpayloadLeafHashDO throw, deliberately. §6.4.2's accepted band is every safe integer plus non-integral magnitudes in[1e-4, 1e16)— exactly the window where JavaScript'sString(n)and Python'srepr(n)agree digit for digit. Outside it the two emit different forms (0.00001vs1e-05), so the value is refused rather than committed to a digest another conforming implementation would call BROKEN. Carry such a value as a string; money should stay integer cents.- A path step's JSON type is load-bearing. A string step is an object key and
a number step is an array index, so
['a', 0]and['a', '0']name different leaves. Never coerce one into the other. A number step must be a non-negative integral binary64 within the safe-integer range (§6.4.9.1);0.5,-1and1e21name no leaf and are refused.1and1.0are the same index. - A string with no UTF-8 encoding is REFUSED, never substituted (§6.4.1.1). Node's encoder turns an unpaired surrogate into U+FFFD silently, which collides a payload carrying U+D800 with one carrying U+FFFD onto a single root, ties §6.4.7's total order for two distinct keys, and lets a genuine field proof authenticate a value that was never sealed. Both keys and values are checked, at every depth; U+FFFD itself and well-formed astral pairs are accepted as normal.
- Two caps, and they are not redundant.
MAX_PAYLOAD_LEAVESis 8192, counted per leaf collected rather than against any one array's length.MAX_PAYLOAD_DEPTHis 64, checked on descent — the leaf cap cannot see a 1500-deep payload, because that payload has exactly one leaf. - A malformed proof step returns
false, it does not throw, andsiblingIsRightmust be an actual boolean. Truthiness coercion made this implementation and the Python one return opposite verdicts on identical bytes (§6.4.9).
sigil-verify chain is unchanged: it does not recompute payloadRoot, because a
chain walk treats it as an opaque 64-hex field inside the §6.3.4 preimage (§1).
What it verifies
- §3 hash chain (programmatic API).
- §4 canonical payload form.
- §5 sealed PDF — real PAdES-B-B signature verification:
/ByteRangeextraction, the signedmessageDigestattribute checked against the SHA-256 of the signed bytes, and the CMS SignerInfo signature verified over the DER re-encoding of the SignedAttributes (node-forgeparses the ASN.1;node:cryptoperforms the signature check). A single altered byte returnsTAMPERED, neverVALID. - §6.3 v2 load chain — length-prefixed, domain-separated entry hashes; commitment binding for actor, geo, and the device-attestation blob; opening verification; and rejection of any entry whose version is not 2.
- §6.4 payload roots — the full canonical form: number encoding and its accepted band, path encoding, the six terminal type tags, the absent-key rule, the 8192-leaf and 64-step caps, the UTF-8 encodability rule, UTF-8 leaf ordering, the fold, and selective-disclosure proofs including the self-sibling case at odd interior levels.
- §10 external chain-head anchoring — Merkle inclusion against a published checkpoint root, with the leaf recomputed from the head rather than trusted.
Recognizing a document (§11.8) — off by default
VALID from this verifier means the signature is sound, not Sigil sealed
this. Anyone can self-sign a PDF, and it will verify against the certificate
embedded beside it. v1.0 §8 always required status = UNKNOWN for a document
with "no matching public seal record" — but that phrase was defined nowhere, so
until v1.1 §11.8 the rule was implementable by the operator and by nobody else.
Pass a base URL to ask:
npx sigil-verify sealed.pdf --seal-record https://sealedby.comThe verifier then fetches {base}/seals/{sha256} — one unauthenticated GET,
the only network request this package ever makes — and applies §11.8.3:
| recognized | meaning |
| ------------ | ------------------------------------------------------- |
| null | no lookup was performed. This verifier did not ask. |
| false | asked, and the operator has no record of this document |
| true | asked, and the operator sealed it |
null and false are different claims and are never collapsed. Neither is a
failed lookup: if the operator is unreachable or answers badly, the command
exits 2 and prints nothing as a verdict, because "I could not ask" must not
be reported as "the answer was no".
TAMPERED outranks a recognition miss. Editing a sealed document is exactly
what makes its digest miss every record, so a tampered document would otherwise
be demoted to not recognized — reading as nothing-to-see-here on the one
artifact where something did happen.
signatureValid is reported separately and always, so the cryptographic finding
survives an UNKNOWN verdict.
Trust scope, and why a bare verify is now UNKNOWN
The prod signing certificate is self-issued ("Sigil Document Signing"). That
one fact decides this whole section: there is no CA chain to validate against,
and an impostor self-issues a certificate carrying the identical subject
name. A PDF signed by a certificate reading Totally Not Sigil, Inc. verified
as VALID from all three verifiers until 2026-07-28, and nothing about that was
wrong as cryptography — the signature really did verify against the certificate
sitting beside it. The defect was that VALID is read as an endorsement.
v1.0 §5.2 step 4 was supposed to prevent it, by confirming the signer against "a configured trust policy". It said to match the subject CN, and shipped a permissive default. Matching a name an attacker chooses is not a check, and a permissive default is not a policy.
So, per v1.1 §11.8.7: tell this verifier which certificate you trust, by fingerprint.
npx sigil-verify sealed.pdf --trust-cert <sha256-of-the-DER-certificate>Repeatable — pass the retired certificate too, because a document sealed under it
stays genuine after a rotation. openssl x509 -in cert.pem -noout -fingerprint
-sha256 prints it, and the uppercase colon-separated spelling is accepted as-is.
| signerTrusted | meaning |
| --------------- | -------------------------------------------------------- |
| null | no policy was supplied. This verifier was not asked. |
| false | a policy was supplied and this certificate is not in it |
| true | a policy was supplied and this certificate is in it |
With no --trust-cert and no --seal-record, a perfectly sound signature is
UNKNOWN and the command exits 1. Nothing has vouched for the signer, so
there is no basis for an endorsement. This is a deliberate change to what the
bare invocation returns; signatureValid stays true and the detail names
which question went unanswered, so the cryptographic finding is reported rather
than lost.
The two vouchers are alternatives. A seal-record lookup answering
known: true is the operator saying I sealed this document, which is stronger
than this is the certificate I told you about, and it reaches VALID on its
own. A trust anchor that DIFFERS outranks either — you named a certificate and
this is not it.
Get the fingerprint out of band. This report includes signerCertSha256 so
you can COMPARE it against a pin you already hold. Reading it out of the report
and handing it straight back is the document corroborating itself, which is the
same defect §11.9 records for anchor roots.
VALID still means the document is byte-unaltered since signing by that
key — the signature is verified against the embedded certificate. It does
not establish CA / AATL certificate-chain trust; that is out of scope for
this reference verifier (see AATL).
verifyChainAnchored reports valid: true only when the anchor evidence's head
is the presented chain's head. Chain validity and inclusion validity are each
independently true of unrelated artifacts, so the pairing is what the claim rests
on. Anchor checkpoints fold org chain heads; a v2 load chain reaches the same
guarantee transitively via the LOAD_EVENT_SEALED cross-reference (§6.2).
An anchor root is only as good as its publication and timestamp. This verifier checks inclusion under a root you supply; obtaining that root from the public feed, and validating its RFC-3161 token, is the caller's responsibility.
What it does not yet verify
- §5 PAdES profile-specific constraints beyond B-B (LTV, LTA timestamping), and certificate-chain / AATL trust. This reference verifier proves a Sigil seal's signature is intact and the document unmodified since signing; full PAdES-B-LTA conformance is a planned add-on.
- §7 capability tokens (verifier integration shipped with the platform in S10.T4). Standalone offline verification needs a standardised JWKS URL, which v1.1 does not introduce — it remains implementation-defined.
- The RFC-3161 token over an anchor root (see the trust-scope note above).
Test
npm testCross-language parity against the Python reference is enforced by the Compliance Test Suite, which runs both over the same corpus and fails on any divergence.
