test(assurance): property-pin canonical JSON, custody, sealing, and timestamp tokens - #153
Conversation
…okens
Completes the productionization plan's §E17 ("Expand property-based
testing"). The packet verifier already had a Hypothesis hostile-input
target; the four primitives its verdicts rest on had only example-based
coverage.
tests/test_property_invariants.py adds 58 property tests over:
* canonical JSON — round-trip, idempotence, key-insertion-order
independence for both bytes and digest, sorted keys at every level, no
insignificant whitespace outside string literals, UTF-8 (never \u)
output, and streaming-digest agreement across the 1 MiB read-chunk seam;
* the chain of custody — no accepted reordering, replay, interior
deletion, hashed-field edit, forged entry hash, or forged signature;
redacted exports verify standalone and carry no identity; HLC-mapped
integrity proofs re-link into a chain that still verifies and leak no
original HLC;
* sealed boxes and vault AEAD — round-trips to the addressed device only,
non-deterministic sealing, and exactly one named CryptoError for
arbitrary hostile bytes;
* timestamp tokens — dev, RFC 3161, and archive chains.
Two limits are now pinned executably instead of overclaimed: a
hash-linked chain proves a prefix, so suffix truncation is caught by the
separately committed head hash rather than by the chain; and an RFC 3161
CMS wrapper legitimately carries bytes outside its signature, so the
invariant there is that mutation can never move the attested
(gen_time, digest, trusted_chain) verdict.
The suites surfaced four fail-closed defects, fixed here:
* open_sealed let a degenerate (all-zero/low-order) ephemeral X25519 key
escape as ValueError instead of CryptoError, contradicting crypto.py's
stated contract that every authentication failure is a CryptoError.
* TimestampToken.from_dict let malformed base64 escape as binascii.Error.
sync's _token_or_none/_token_list have no broad handler, so an
authorized peer's malformed token record raised a traceback instead of
a SyncError; vault.py had already worked around this at one call site.
* _verify_dev_token let invalid UTF-8 in token bytes escape as
UnicodeDecodeError, and malformed pubkey/sig base64 escape as
binascii.Error.
* _verify_dev_token accepted non-canonical base64 spellings of its pubkey
and sig, so 15 distinct single-byte rewrites of a token's signature
went undetected. Dev tokens now reject alternate spellings the same way
pairing.py already rejects them for pairing material; an exhaustive
sweep of every single-byte mutation now accepts none.
No packet, vault, or sync format changed; no version bump; no new
dependency. Old packets keep verifying: the committed v1/v2/v3 golden
corpus is green, and every token_b64 habitable has emitted is canonical
base64, so the stricter decode cannot reject one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQxMdBhpKxXg57SBgC8nUQ
The committed claim that mutation "can never move the attested (gen_time, digest, trusted_chain) verdict" was only ever exercised with no trust anchor configured, where trusted_chain is constant False. With an anchor it is false: an exhaustive sweep of all 344,715 single-byte mutations of a locally issued token finds 80,836 accepted, of which 75,496 drop trusted_chain from true to false — editing the embedded certificate breaks the anchor match while leaving the CMS signature over TSTInfo verifiable, because the signing public key lives in the unchanged tbs. Claim and evidence now agree, by extending the test rather than narrowing the claim. The suite adds a second synthetic authority and exercises three trust conditions offline — no anchor, this token's own anchor, and a foreign anchor — and asserts what actually holds: - no mutation moves the attested gen_time or digest (0 of 80,836 accepted mutations do, under either anchor state); - no mutation can manufacture trust (0 of 80,836 under a foreign anchor); - trust is losable, and that fail-closed direction is pinned by an executable case rather than claimed away. docs/capabilities.md, CHANGELOG.md, ROADMAP.md, and the module docstring are reworded to that claim. Also: - CHANGELOG no longer asserts §E17 is complete. It shipped the primitive-level targets; §E17's acceptance criterion also requires a stateful harness over hostile packet/token input, which is still open. - evidence.py: CustodyLog.verify decoded an entry signature with a bare base64.b64decode, so a malformed signature escaped as binascii.Error — the same defect class fixed three times in tsa.py, on the one primitive of the four with no "hostile input yields exactly one named error" property. It now decodes strictly and raises CustodyError, and the two missing properties (hostile signatures, hostile imported records) are in place. Verified to fail against the previous commit and pass after. - vault.py: removed the pre-decode and token.data != strict_data check left dead by the TimestampToken.from_dict fix, which now decodes strictly itself. binascii.Error subclasses ValueError, so behavior is identical. - tests: the four bare-return discards across three properties are now hypothesis.assume(), so Hypothesis sees the discard rate; the i == j discard is gone entirely, drawn from the complement instead. - tests: every index draw is bounded by its target instead of an unbounded st.integers reduced with %, matching test_verify_fuzz.py's byte-offset idiom (st.integers(min_value=0, max_value=len(X) - 1)) where the length is fixed at module scope, and st.sampled_from over the real range via st.data() where it is only known inside the example. Gate: make verify green (1014 passed, 2 deselected; 90.04% overall, crypto-core 95%). make integration / repro / relay-repro skipped — network and Docker. pip-audit still fails on cryptography 49.0.0 (PYSEC-2026-3552), pre-existing on main and exactly what PR #152 fixes; uv.lock and pyproject.toml are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQxMdBhpKxXg57SBgC8nUQ
Review fixes —
|
| result | |
|---|---|
| mutations accepted (verified without raising) | 80,836 |
of those, trusted_chain moved true → false |
75,496 |
gen_time or digest moved |
0 |
non-TimestampError exceptions leaked |
0 |
Reproduced as a Hypothesis property using the PR's own assertion text, with an anchor configured — it fails on the first shrink:
AssertionError: assert ('2026-01-02T...6c69f', False) == ('2026-01-02T...26c69f', True)
At index 2 diff: False != True
Falsifying example: position=608, value=0
Why. The last stretch of the token DER is the embedded signing certificate, and the tail of that is the certificate's own signature. Editing it leaves the CMS signature over TSTInfo verifiable — the signing public key lives in the unchanged tbs portion — but the certificate no longer matches the anchor by fingerprint and no longer verifies against a trusted issuer, so _verify_cert_chain returns False. Trust drops; time and digest stand.
Fix: extended the test, kept the claim honest (the preferred route — it is fully offline with a synthetic anchor). The suite now builds a second synthetic authority and exercises three trust conditions: no anchor, this token's own anchor, and a foreign authority's anchor. What it asserts is what actually holds:
- the attested
gen_timeanddigestcan never move — 0 of 80,836 accepted mutations do, in either anchor state; - trust can never be manufactured — a second exhaustive sweep under a foreign anchor: 80,836 accepted, 0 trust upgrades. This is the direction that matters for the threat model;
- trust is losable, and that fail-closed direction is now pinned by a deterministic executable case (
test_a_certificate_edit_drops_trust_without_moving_the_attestation) rather than claimed away.
All four assertion sites reworded to that claim: docs/capabilities.md, CHANGELOG.md, ROADMAP.md, and the module/class docstrings in tests/test_property_invariants.py.
Minors
| Finding | Fix | Proof |
|---|---|---|
CHANGELOG.md:13 asserted §E17 complete |
Now claims only the primitive-level targets, and states outright that §E17's stateful-harness acceptance criterion is still open. ROADMAP.md matches. |
— |
evidence.py:289 — uncaught binascii.Error |
CustodyLog.verify now decodes via a _entry_signature helper (validate=True, ValueError → CustodyError), mirroring the from_dict fix. Added the two missing properties: hostile signature strings and hostile imported records both yield exactly one named CustodyError. |
New property run against the previous commit: AssertionError: verify leaked Error: Invalid base64-encoded string… (falsifying signature='0'). Passes after. |
Five properties using bare return to discard |
Converted. Truthfully it is 4 discard sites across 3 properties — reordering (×2), interior deletion, suffix truncation. The other bare returns are except NamedError: return pass-paths, not discards, and were left alone. The i == j discard is gone entirely: the second index is now drawn from the complement, so no example is discarded for it. |
hypothesis.assume() now reports the rate; suite green. |
vault.py:1450-1454 dead code |
Removed the pre-decode and the token.data != strict_data check; dropped the now-unused import binascii. binascii.Error subclasses ValueError, which stays in the handler, so behavior is identical. |
test_vault_capture / test_token_sidecars / test_tsa / test_golden / test_sync* — 191 passed. |
Eight properties using st.integers(min_value=0) + % len(...) |
Every index draw is now bounded by its target. Where the length is fixed at module scope: st.integers(min_value=0, max_value=len(X) - 1), exactly test_verify_fuzz.py's byte-offset idiom. Where it is only known inside the example: st.sampled_from(range(len(…))) via st.data(). The archive chain draws from a precomputed (link, offset) space, since the links differ in length and a shared bound would either overrun the short ones or never reach the tail of the long one. |
10 sites in total, not 8 — all converted. |
Gate
make verify run in the foreground, in a clean worktree at this commit:
| Stage | Result |
|---|---|
make lint |
✅ pass — 116 files |
make type (mypy --strict) |
✅ pass — 114 files |
make cov |
✅ pass — 1014 passed, 2 deselected; 90.04 % overall (floor 85), crypto-core 95 % (floor 95): crypto.py 100 %, tsa.py 98 %, verify.py 95 %, vault.py 94 %, evidence.py 99 % |
make i18n |
✅ pass — 273 keys, EN/ES at parity |
make doc-links |
✅ pass — 105 files, 20 capability rows with local evidence |
make markers |
✅ pass |
make integration |
⏭️ skipped — needs network (real public TSAs); the 2 deselected tests |
make repro / make relay-repro |
⏭️ skipped — needs Docker/BuildKit; no packaging or container surface touched |
The property suite is 58 → 63 tests, ~6 s.
pip-audit still fails on cryptography 49.0.0 / PYSEC-2026-3552 (fixed in 50.0.0). Pre-existing on main, exactly what open PR #152 fixes. uv.lock and pyproject.toml remain untouched on this branch — reported, not chased.
One residue, disclosed not fixed
While fixing the custody signature decode I found that an entry signature also accepts 15 non-canonical base64 spellings of the same 64 bytes — the trailing-bit aliasing this PR closed for dev tokens. I did not change it: unlike a dev token's sig, the custody signature is not committed to the entry hash and is dropped from the exported form (to_export_records), so no alias can move a verdict or survive into a packet. Narrowing it would be a behavior change beyond this review's scope. Flagging it for a decision rather than deciding silently.
Also note the PR description above still says "Completes the open half of §E17" and repeats the old (gen_time, digest, trusted_chain) wording. I left the body alone since it is not committed to the repo, but it should be reworded before merge to match CHANGELOG.md.
What and why
Advances the productionization plan's §E17 — "Expand property-based testing" (
docs/productionization.md, the working doc.gitignoredeliberately keeps unpublished, so it is referenced by name here the waydocs/adr/0004anddocs/audits/onboarding.mdalready reference it). Its acceptance criterion: "New Hypothesis suites in CI; a stateful harness over hostile packet/token input never accepts-on-tamper and never crashes (raisesVerificationError, never a traceback)." This PR delivers the primitive-level property suites and the fixes they surfaced; §E17 is not complete — the stateful harness remains open.The same work is sanctioned by two committed documents:
ROADMAP.mdworkstream A records the verifier half as shipped (tests/test_verify_fuzz.py), andDEFINITION_OF_DONE.mdTier 1 stage 3 names property-based tests as part of the auto-gate.E17's remaining named targets — canonical-JSON round-trip/stability, custody-chain append/verify invariants (no accepted reordering/insertion/deletion), sealed-box round-trips, timestamp-token parse/verify invariants — had only example-based coverage. This adds them, and fixes the four fail-closed defects they surfaced.
Why this item: it is the highest-value entry in the repo's own plans that needs no reviewer, partner, credential, live service, or adopter.
docs/roadmap-drain-2026-07-22.mdclassifies every other open outcome as externally, ecosystem-, decision-, or protocol-review-blocked ("Open agent-executable feature issues: 0"), and the six open issues (#121–#126) are bounded human-review tasks. It also adds no dependency — Hypothesis is already in thedevgroup — so there is nouv.lockchurn and no overlap with #152.New:
tests/test_property_invariants.py(58 tests, ~6 s)\u-escaped; non-finite floats refused;sha256_fileagrees withsha256_bytes, including across the 1 MiB read-chunk seamactor/actor_salt/signature/private_details; no hashed-field edit, forgedentry_hash, reordering, replayed entry, or interior deletion is accepted; signed entries verify and a forged signature is rejected; an HLC-mapped integrity proof re-links into a chain that still verifies and leaks no original HLC;integrity_proofrefuses to describe a broken chainCryptoError; wrong key and wrong AAD both fail closedTimestampError; no byte mutation of a dev token is accepted; arbitrary token bytes raise onlyTimestampError; a token never verifies against other content; unknown kinds refused; archive chains of any depth verify, and a mutated, dropped, or reordered link breaks the chainTwo limits are pinned honestly and executably rather than overclaimed:
test_suffix_truncation_is_invisible_to_the_chain_but_moves_the_headasserts that dropping trailing entries still verifies — which is exactly why the head hash is committed outside the chain. That boundary can now never be quietly mistaken for a completeness proof.(gen_time, digest, trusted_chain)verdict — was false, not merely untested: it had only ever been exercised with no trust anchor, wheretrusted_chainis constant. An exhaustive sweep of all 344,715 single-byte mutations with the issuing authority as trust anchor accepts 80,836, of which 75,496 movetrusted_chainfrom true to false (the token's DER tail is the embedded signing certificate, so editing it leaves the CMS signature overTSTInfointact while breaking the anchor match). The claim is now stated as what actually holds across three exercised trust conditions:gen_timeanddigestcan never move; trust can never be manufactured (a second exhaustive sweep under a foreign anchor yields 0 upgrades); trust is losable, pinned by an executable case rather than claimed away.Fixed: four fail-closed defects the suites surfaced
All four were verified to fail against unmodified
origin/mainand pass after the fix.crypto.open_sealedleakedValueError. A degenerate (all-zero / low-order) ephemeral public key makes X25519 refuse to produce a shared secret; that escaped as a rawValueError, contradictingcrypto.py's stated contract that "every authentication failure surfaces asCryptoErrorrather than a bare library exception." Sealed boxes are attacker-supplied (relay, courier file, pairing code). Current call sites (sync._try_open,pairing) catch broadly, so this was contained in-tree — but not for anyone embedding the Apache-2.0 kernel/verify subset.TimestampToken.from_dictleakedbinascii.Erroron malformed base64.sync._token_or_none/_token_listhave no broad handler, so an authorized peer's malformed token record raised a traceback instead of aSyncError.vault.pyhad already worked around this at one call site by catchingbinascii.Errorexplicitly._verify_dev_tokenleakedUnicodeDecodeErroron invalid UTF-8 in token bytes (json.loadsonbytes, caught only asJSONDecodeError), andbinascii.Erroron malformed base64 in itspubkey/sig._verify_dev_tokenaccepted a byte-level tamper. The trailing base64 character of a padded group carries unused bits thatb64decodesilently discards, so 15 distinct single-byte rewrites of a dev token'ssigwere accepted. Dev tokens now reject non-canonical spellings the same waypairing.pyalready rejects them for pairing material; an exhaustive sweep of all 308 × 255 single-byte mutations now yields zero accepted and zero non-TimestampErrorexceptions.Compatibility, threat model, observability
tests/test_golden.pyare green, and everytoken_b64committed in the repo is canonical base64, so the stricter decode cannot reject anything habitable has emitted.devgroup;uv.lockis untouched.Gate: what ran, what was skipped
Run in the foreground in a clean worktree from
origin/main:make lint(ruff format --check + check)make type(mypy --strict, 114 files)make cov(pytest-m "not integration")crypto.py100 %,tsa.py98 %,verify.py95 %,vault.py94 %)make i18nmake doc-linksmake markersmake a11y(axe-core browser scan)-m "not integration", so the axe/keyboard/reflow/PWA suites ran as part of the run abovemake integration(real public TSAs)make repro/make relay-repro(reproducible wheel + relay OCI)CI is the gate of record for the skipped stages.
Pre-existing CI failure, not caused by this PR
dependency vulnerability audit(pip-audit) fails on this branch and onorigin/main:cryptography 49.0.0/PYSEC-2026-3552, fixed in 50.0.0. That is exactly what open PR #152 does. This branch does not touchuv.lockorpyproject.toml(git diff origin/main -- uv.lock pyproject.tomlis empty), deliberately, to avoid conflicting with #152. Reported, not chased.Checklist
make verifyis green (ruff format+check, mypy --strict, pytest+coverage).gitignorestill excludes vaults/packets/keysREADME.mdsrc/habitable/verify.py) stays independent of vault/sync — unchanged by this PRSigned-off-byline, matching the repo's other agent-authored commitsCHANGELOG.mdentry added🤖 Generated with Claude Code
https://claude.ai/code/session_01CQxMdBhpKxXg57SBgC8nUQ