Skip to content

test(assurance): property-pin canonical JSON, custody, sealing, and timestamp tokens - #153

Merged
ChelseaKR merged 3 commits into
mainfrom
agent/property-invariants-core
Aug 5, 2026
Merged

test(assurance): property-pin canonical JSON, custody, sealing, and timestamp tokens#153
ChelseaKR merged 3 commits into
mainfrom
agent/property-invariants-core

Conversation

@ChelseaKR

@ChelseaKR ChelseaKR commented Aug 4, 2026

Copy link
Copy Markdown
Owner

What and why

Advances the productionization plan's §E17 — "Expand property-based testing" (docs/productionization.md, the working doc .gitignore deliberately keeps unpublished, so it is referenced by name here the way docs/adr/0004 and docs/audits/onboarding.md already 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 (raises VerificationError, 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.md workstream A records the verifier half as shipped (tests/test_verify_fuzz.py), and DEFINITION_OF_DONE.md Tier 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.md classifies 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 the dev group — so there is no uv.lock churn and no overlap with #152.

New: tests/test_property_invariants.py (58 tests, ~6 s)

Primitive Invariants pinned
Canonical JSON JSON round-trip; encoding idempotence; key-insertion order cannot change the bytes or the digest; every object is key-sorted; no insignificant whitespace outside string literals; non-ASCII emitted as UTF-8, never \u-escaped; non-finite floats refused; sha256_file agrees with sha256_bytes, including across the 1 MiB read-chunk seam
Chain of custody An intact chain verifies and summarizes every item; the redacted export verifies standalone and carries no actor/actor_salt/signature/private_details; no hashed-field edit, forged entry_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_proof refuses to describe a broken chain
Sealed box / vault AEAD Round-trips to the addressed device only; sealing is never deterministic; any single-byte change fails closed; arbitrary bytes raise only CryptoError; wrong key and wrong AAD both fail closed
Timestamp tokens Record round-trip; malformed records raise only TimestampError; no byte mutation of a dev token is accepted; arbitrary token bytes raise only TimestampError; 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 chain

Two limits are pinned honestly and executably rather than overclaimed:

  • A hash-linked chain proves a prefix, not completeness. test_suffix_truncation_is_invisible_to_the_chain_but_moves_the_head asserts 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.
  • An RFC 3161 CMS wrapper legitimately carries bytes outside its signature. Adversarial review caught that the invariant first asserted here — that mutation can never move the attested (gen_time, digest, trusted_chain) verdict — was false, not merely untested: it had only ever been exercised with no trust anchor, where trusted_chain is 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 move trusted_chain from true to false (the token's DER tail is the embedded signing certificate, so editing it leaves the CMS signature over TSTInfo intact while breaking the anchor match). The claim is now stated as what actually holds across three exercised trust conditions: gen_time and digest can 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/main and pass after the fix.

  1. crypto.open_sealed leaked ValueError. A degenerate (all-zero / low-order) ephemeral public key makes X25519 refuse to produce a shared secret; that escaped as a raw ValueError, contradicting crypto.py's stated contract that "every authentication failure surfaces as CryptoError rather 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.
  2. TimestampToken.from_dict leaked binascii.Error on malformed base64. sync._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 by catching binascii.Error explicitly.
  3. _verify_dev_token leaked UnicodeDecodeError on invalid UTF-8 in token bytes (json.loads on bytes, caught only as JSONDecodeError), and binascii.Error on malformed base64 in its pubkey/sig.
  4. _verify_dev_token accepted a byte-level tamper. The trailing base64 character of a padded group carries unused bits that b64decode silently discards, so 15 distinct single-byte rewrites of a dev token's sig were accepted. Dev tokens now reject non-canonical spellings the same way pairing.py already rejects them for pairing material; an exhaustive sweep of all 308 × 255 single-byte mutations now yields zero accepted and zero non-TimestampError exceptions.

Compatibility, threat model, observability

  • No format change. Packet, vault, and sync schemas are untouched; no version bump. Old packets keep verifying — the committed v1/v2/v3 golden corpus and tests/test_golden.py are green, and every token_b64 committed in the repo is canonical base64, so the stricter decode cannot reject anything habitable has emitted.
  • No migration, no rollback step. Reverting the commit restores prior behavior with no data change.
  • Threat model: unchanged boundaries; this narrows how three already-hostile inputs fail (named error instead of traceback) and closes one byte-level malleability. No new external attack surface.
  • Observability: N/A under the no-telemetry rule — CLI/library paths only, nothing relay-facing.
  • Dependencies: none added. Hypothesis is already in the dev group; uv.lock is untouched.
  • ISO 25010: Reliability (fault tolerance), Security (integrity), Maintainability (testability).

Gate: what ran, what was skipped

Run in the foreground in a clean worktree from origin/main:

Stage Result
make lint (ruff format --check + check) ✅ pass
make type (mypy --strict, 114 files) ✅ pass
make cov (pytest -m "not integration") ✅ pass — 1009 passed, 2 deselected; 89.99 % overall (floor 85 %), crypto-core 95 % (crypto.py 100 %, tsa.py 98 %, verify.py 95 %, vault.py 94 %)
make i18n ✅ pass — 273 keys, EN/ES at parity
make doc-links ✅ pass — 105 Markdown files; 20 capability rows carry local evidence
make markers ✅ pass
make a11y (axe-core browser scan) ✅ pass — Chromium was available locally, and these tests are inside -m "not integration", so the axe/keyboard/reflow/PWA suites ran as part of the run above
make integration (real public TSAs) ⏭️ skipped — needs network; the 2 deselected tests, deliberately outside the merge gate
make repro / make relay-repro (reproducible wheel + relay OCI) ⏭️ skipped — needs Docker/BuildKit; no packaging or container surface is touched by this change

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 on origin/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 touch uv.lock or pyproject.toml (git diff origin/main -- uv.lock pyproject.toml is empty), deliberately, to avoid conflicting with #152. Reported, not chased.

Checklist

  • make verify is green (ruff format+check, mypy --strict, pytest+coverage)
  • Tests added/updated; anything touching evidence has a tamper-detection test
  • No real tenant data anywhere; .gitignore still excludes vaults/packets/keys
  • Respects the hard rules in README.md
  • The verifier (src/habitable/verify.py) stays independent of vault/sync — unchanged by this PR
  • Conventional Commit title — the commit carries an agent co-author trailer and no separate DCO Signed-off-by line, matching the repo's other agent-authored commits
  • Noted any change to the threat model, packet format, or verification protocol — none; CHANGELOG.md entry added
  • Observability impact stated: N/A under the no-telemetry rule (CLI/library paths)
  • Rollback/migration noted — none needed; old packets keep verifying (golden corpus green)
  • ISO 25010 quality characteristic(s) named above
  • Any new dependency has a written rationale — no new dependency

🤖 Generated with Claude Code

https://claude.ai/code/session_01CQxMdBhpKxXg57SBgC8nUQ

ChelseaKR and others added 2 commits August 4, 2026 07:36
…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
@ChelseaKR

Copy link
Copy Markdown
Owner Author

Review fixes — 5b40333

BLOCKING: the RFC 3161 invariant was overstated

The review was right, and the claim was not merely unproven — it was false.

I probed it directly. An exhaustive sweep of all 344,715 single-byte mutations of a locally issued token, verified with the issuing authority's certificate as a trust anchor:

result
mutations accepted (verified without raising) 80,836
of those, trusted_chain moved truefalse 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_time and digest can 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, ValueErrorCustodyError), 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.

@ChelseaKR
ChelseaKR merged commit bf39d8e into main Aug 5, 2026
13 checks passed
@ChelseaKR
ChelseaKR deleted the agent/property-invariants-core branch August 5, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant