Metadata-Version: 2.4
Name: sigil-protocol-verifier
Version: 3.0.0
Summary: Reference verifier for the Sigil Protocol v1.1. Independent implementation per the published specification.
Author: Sigil
License: MIT
Project-URL: Homepage, https://sealedby.com/protocol/v1.1/SPEC.md
Project-URL: Source, https://github.com/caseyglarkin2-png/sigil-protocol
Project-URL: Issues, https://github.com/caseyglarkin2-png/sigil-protocol/issues
Project-URL: Governance, https://sealedby.com/protocol/GOVERNANCE.md
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: cryptography>=42.0
Requires-Dist: asn1crypto>=1.5
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"

# sigil-protocol-verifier (Python)

Reference verifier for [Sigil Protocol v1.1](https://sealedby.com/protocol/v1.1/SPEC.md)
(which amends [v1.0](https://sealedby.com/protocol/v1.0/SPEC.md)). Independent of the Sigil
platform and of the TypeScript reference — built from the spec, with
`cryptography` and `asn1crypto` as its runtime dependencies.

## Install

```bash
pip install sigil-protocol-verifier
```

## Use

```bash
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:

```json
{ "protocolVersion": "1.1", "supportedProtocolVersions": ["1.0", "1.1"] }
```

### Wire field names changed in 1.3.0

The CLI's JSON now uses the field names **spec v1.0 §8 defines** — `signerCommonName`
and `documentSha256`, camelCase. Earlier versions printed `asdict(report)`, which
leaked this package's Python attribute names into the wire format, so the two
reference verifiers disagreed about what to call the same value. Spec §8 is
camelCase, so this package was the non-conforming one.

The `PdfVerificationResult` dataclass **keeps snake_case attributes** — that is a
Python API and snake_case is right for it. Only the serialized report changed.
The old `signer_common_name` / `document_sha256` keys are still emitted as
deprecated aliases so existing scripts keep working; **they are removed in 2.0**.

§5 is now under the cross-language parity gate (`protocol/compliance-suite/fixtures/pdf/`).
It was not before, which is exactly why this could ship undetected in two releases.

**The package version is not the protocol 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:

```json
{
  "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)

```bash
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-root` is the root **you** obtained out of band, from the anchor feed
  or by parsing the RFC-3161 token. Only this sets `root_corroborated: True`.
  The envelope's own `expectedRoot` member **cannot** and never could: the
  operator agreeing with itself is not evidence. If it disagrees with the served
  anchor root the bundle is `BROKEN`, because that is the operator contradicting
  itself.
- `--expect-load` is the load id **you** asked for. Without it the chain is
  checked only against the bundle's self-asserted `loadId`, which a transplant
  rewrites too, so an internally perfect document can be a real chain re-sealed
  onto a different load.

`root_corroborated: 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
> `expectedRoot` and 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 reported
> `root_corroborated: 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-root` as an unknown argument**; check
> what you installed with `pip show sigil-protocol-verifier`.

### Programmatic

```python
from sigil_verify import verify_bundle, verify_load_chain_v2, verify_anchor_inclusion

verify_bundle(doc)                                    # what `sigil-verify chain` runs
verify_load_chain_v2(served["entries"], "load_01H...")  # §6.3.6 — pass the load id you asked for
verify_anchor_inclusion(head, proof, published_root)    # §10.4
```

### Payload roots and selective disclosure (§6.4)

`payloadRoot` is a per-field Merkle root, not a whole-payload digest. Its
canonical form landed in 1.4.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:

```python
from sigil_verify import (
    MAX_PAYLOAD_LEAVES,
    PAYLOAD_EMPTY_ROOT,
    compute_payload_root,
    payload_field_opens,
    payload_field_proof,
    payload_leaves,
    verify_payload_field,
)

compute_payload_root({"agreedRateCents": 400000, "currency": "USD"})  # §6.4 — the sealed root
payload_leaves(payload)                            # §6.4.6/§6.4.7 — canonical sorted leaves
payload_field_proof(payload, ["agreedRateCents"])  # §6.4.9.2 — path, value, proof
verify_payload_field(disclosure, proof, sealed_root)  # §6.4.9 — True/False, never raises
payload_field_opens(leaf_hashes, ["currency"], "USD")  # §6.4.9 — candidate membership
```

Things worth knowing before you rely on it:

- **A disclosure that cannot be encoded returns `False`, it does not raise.** 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.
- **`compute_payload_root` and `payload_leaf_hash` DO raise**, deliberately.
  §6.4.2's accepted band is every safe integer plus non-integral magnitudes in
  `[1e-4, 1e16)` — exactly the window where Python's `repr(n)` and JavaScript's
  `String(n)` agree digit for digit. Outside it the two emit different _forms_
  (`1e-05` vs `0.00001`), 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 `str` 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. The index must be a non-negative
  integral binary64 within the safe-integer range (§6.4.9.1); `0.5`, `-1` and
  `1e21` name no leaf and are refused. **`1` and `1.0` are the same index** —
  `json.loads` invents an `int`/`float` distinction that JSON does not have, and
  that distinction must not reach the path.
- **A string with no UTF-8 encoding is REFUSED, explicitly and with a `TypeError`**
  (§6.4.1.1). Python raises on encoding an unpaired surrogate anyway, so this
  changes no verdict here; it is stated because JavaScript's encoder SUBSTITUTES
  U+FFFD instead, which collides a payload carrying U+D800 with one carrying
  U+FFFD onto a single root and lets a genuine field proof authenticate a value
  that was never sealed. U+FFFD itself and astral code points are accepted as
  normal.
- **Two caps, and they are not redundant.** `MAX_PAYLOAD_LEAVES` is 8192, counted
  per leaf collected rather than against any one list's length.
  `MAX_PAYLOAD_DEPTH` is 64, checked on descent — the leaf cap cannot see a
  1500-deep payload, because that payload has exactly one leaf, and without the
  depth cap CPython's `RecursionError` (not a `TypeError`, `ValueError` or
  `OverflowError`) decides the verdict instead of the spec.
- **A malformed proof step returns `False`, it does not raise**, and
  `siblingIsRight` must be an actual `bool`. `bool()` coercion made this
  implementation and the TypeScript 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: `/ByteRange`
  extraction, the signed `messageDigest` attribute checked against the SHA-256 of
  the signed bytes, and the CMS SignerInfo signature verified over the DER
  re-encoding of the SignedAttributes (`asn1crypto` parses the CMS;
  `cryptography` performs the signature check). A single altered byte returns
  `TAMPERED`, never `VALID`.
- **§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:

```
sigil-verify sealed.pdf --seal-record https://sealedby.com
```

The 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.**

```
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).

verify_chain_anchored`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.

## Cross-language note

`canonicalize_geo` renders numbers to match JavaScript's `String(n)` exactly, and
**refuses** any value the two languages render differently (integral floats,
negative zero, magnitudes outside ±(2^53 − 1), and non-integral values below
1e-4). Verifying a served opening never reaches that path — the opening already
carries the canonical string.

`encode_payload_number` (§6.4.2) applies the same discipline to payload numbers,
with two Python-specific traps the spec calls out by name. `repr` on an integral
float gives `'2.0'` and `'9007199254740991.0'`, so the integral branch goes via
`int` first; and `repr(-0.0)` is `'-0.0'`, so negative zero is normalized to
`'0'` explicitly. Neither normalization is needed in JavaScript, which is exactly
why both are normative.

`json` also yields an arbitrary-precision `int` for an integer token, so a token
outside the double range arrives EXACT rather than as infinity. §6.4.2.1 fixes
the domain as IEEE-754 binary64, so the narrowing is explicit: `10**400` becomes
infinity and is rejected, rather than becoming a finite value with no decimal
form JavaScript could reproduce.

## What it does not yet verify

Same caveats as the TypeScript reference verifier — certificate-chain / AATL
trust, full PAdES-B-LTA conformance, and standalone capability-token
verification (which needs a standardised JWKS URL that v1.1 does not introduce).
The RFC-3161 token over an anchor root is also out of scope; see trust scope.

## Test

```bash
pip install -e .[dev]
pytest
```

Cross-language parity against the TypeScript reference is enforced by the
[Compliance Test Suite](https://sealedby.com/protocol/compliance-suite/README.md), which runs both over the
same corpus and fails on any divergence.
