Skip to content

Commit 7a4ccfd

Browse files
prov: tamper-evident hash chain — V-L1-B1 + V-L2-N1 + V-L2-C1..C4 + V-L2-L1..L2 (#33)
Step 2 of the bottom-up plan. Brings the Provenance octad concern up to the claim made in the README: tampering with any audit-relevant field in a logged entry breaks `verify()`. V-L1-B1 — docs/theory/provenance-threat-model.adoc: Four-adversary model (R / SW / SR / SR+CK), per-adversary protection matrix, the field-coverage and canonical-encoding requirements that bind V-L2-C1 + V-L2-C2, the append-serialisation requirement that binds V-L2-L1 + V-L2-L2, anchor/notary future work, open questions (None vs Some(""), chain_id). Each Step 2 issue cites a section. V-L2-N1 — deduplicate ProvenanceRecord vs ProvenanceEntry: Delete src/tier1/provenance.rs::ProvenanceRecord (orphan duplicate of abi::ProvenanceEntry with its own compute_hash that risked drifting). tier1/provenance.rs now re-exports the canonical type; the file is the future home of V-L1-C1's write-path helpers (sqlite3_update_hook → append_provenance). TOPOLOGY.md updated. V-L2-C1 — full-field, domain-separated hash: compute_hash signature changes from (4 strs) to (5 strs + DateTime + 2 Options). New preimage = domain tag b"verisim-prov-v1\0" || length-prefixed (previous_hash, entity_id, operation, actor) || canonical timestamp (V-L2-C2) || length-prefixed (before_snapshot, transformation). All seven fields participate. PROV_DOMAIN_TAG versioning is reserved for a future SHA-256→? migration. verify(), genesis(), chain() all pass the full field set. V-L2-C2 — canonical timestamp: Replace timestamp.to_rfc3339() (multiple valid forms per instant) with i64_le(timestamp()) || u32_le(timestamp_subsec_nanos()), 12 bytes total. Round-trip unit test asserts two construction paths that yield the same instant produce the same hash. V-L2-C3 — positive tamper-detection tests: Eight new unit tests in abi::tests covering each hash-covered field (entity_id, actor, before_snapshot, transformation, operation, previous_hash, timestamp) plus the canonical-encoding property test plus a 4-entry chain mutation-matrix that asserts every field mutation on every entry breaks verify(). 9 new test cases (26 → 35 lib tests). V-L2-C4 — flip the wontfix test: tests/integration_test.rs::test_provenance_chain_integrity_multi_step previously codified the bug ("Actor is not part of hash — tamper to actor alone is invisible"). Replaced with assertions that tampering with actor and with before_snapshot both break verify(). V-L2-L1 — chain_head table + write-path serialisation spec: codegen/overlay.rs emits a new verisimdb_provenance_chain_head (entity_id PK, head_hash, updated_at) alongside the provenance log. The write-path lock (SELECT … FOR UPDATE / BEGIN IMMEDIATE on the head row, INSERT into log, UPDATE head, COMMIT) is specified in the threat-model doc and the table-generator docstring. The library function that performs the transaction is V-L1-C1's job; V-L2-L1 only lands the schema. V-L2-L2 — UNIQUE INDEX makes forks unrepresentable: CREATE UNIQUE INDEX IF NOT EXISTS ux_provenance_chain ON verisimdb_provenance_log(entity_id, previous_hash). Genesis rows all carry previous_hash='' so the same constraint enforces exactly one genesis per entity. Two new DDL tests assert presence of both the UNIQUE INDEX and the chain_head table. Verified locally: - cargo fmt --all -- --check clean - cargo clippy --all-targets -- -D warnings clean - cargo test reports 35 + 9 = 44 tests, 0 failed Closes #25, #26, #27, #28, #29, #30, #31, #32 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent db7b7e9 commit 7a4ccfd

6 files changed

Lines changed: 450 additions & 76 deletions

File tree

docs/architecture/TOPOLOGY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ verisimiser/
1212
│ ├── src/manifest/ — TOML manifest parsing (verisimiser.toml)
1313
│ ├── src/tier1/ — Tier 1 piggyback data types
1414
│ │ ├── drift.rs — DriftReport, DriftCategory (8 categories)
15-
│ │ ├── provenance.rs — ProvenanceRecord, SHA-256 hash chain
15+
│ │ ├── provenance.rs — re-exports abi::ProvenanceEntry; future write-path helpers (V-L1-C1)
1616
│ │ └── temporal.rs — TemporalVersion, point-in-time snapshots
1717
│ ├── src/tier2/ — Tier 2 overlay stubs (graph, vector, tensor, semantic, document, spatial)
1818
│ ├── src/intercept/ — Per-backend interception strategies
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
= Provenance threat model
4+
:toc: left
5+
:toclevels: 3
6+
:icons: font
7+
8+
This document fixes what the Provenance concern's hash chain proves and
9+
what it doesn't. It binds the design choices made in V-L2-C1, V-L2-C2,
10+
V-L2-L1, V-L2-L2, and the ADR-0004 follow-up.
11+
12+
Resolves: https://github.com/hyperpolymath/verisimiser/issues/25[V-L1-B1].
13+
14+
== Scope
15+
16+
In scope:: the `Provenance` octad concern as implemented by
17+
`ProvenanceEntry` in `src/abi/mod.rs` plus the sidecar table
18+
`verisimdb_provenance_log` plus (post V-L2-L1) the `chain_head` table.
19+
20+
Out of scope:: denial-of-service against the sidecar; side-channels
21+
(timing, cache); tampering of the target database itself
22+
(verisimiser only sees what its interceptors intercept); retroactive
23+
provenance for pre-existing rows (the genesis entry for an entity
24+
attests its existence at the moment verisimiser started observing it,
25+
not before); cryptographic compromise of SHA-256.
26+
27+
== Adversaries
28+
29+
Four adversaries cover the relevant capability axes. Each is a
30+
*lattice point*; real attackers combine capabilities.
31+
32+
[cols="1,3"]
33+
|===
34+
| Tag | Capability
35+
36+
| **R** | Read-only — can read both the target database and the
37+
sidecar. No write to either. Models: a forensic auditor;
38+
a leaked replica; a debugging copy on a laptop.
39+
40+
| **SW** | Sidecar-Write — can append new rows to
41+
`verisimdb_provenance_log` and `verisimdb_temporal_versions` but
42+
**cannot delete or rewrite existing rows**. Models: a sidecar
43+
configured append-only (filesystem-level WORM, S3 Object Lock,
44+
SQLite + revoked-DELETE/UPDATE permissions); also models a buggy
45+
verisimiser daemon that double-writes.
46+
47+
| **SR** | Sidecar-Rewrite — can rewrite or delete arbitrary rows
48+
in the sidecar. Models: root on the sidecar host; compromised
49+
application credential with full sidecar privileges; a backup
50+
operator restoring an older sidecar snapshot.
51+
52+
| **CK** | Clock-skew — can write entries (via SW or SR) with
53+
timestamps that lie. Models: a system clock that drifts; an
54+
adversary who controls the clock source; coordinated backdating.
55+
|===
56+
57+
== Per-adversary protection matrix
58+
59+
For each adversary, what the chain proves about each field:
60+
**P** = protected (tampering detected),
61+
**N** = not protected,
62+
**C** = conditionally protected (see note).
63+
64+
[cols="2,1,1,1,1"]
65+
|===
66+
| Field | R | SW | SR | SR+CK
67+
68+
| Genesis existence / order | P | P | N | N
69+
| `previous_hash` of any entry | P | P | C-1 | C-1
70+
| `entity_id` of any entry | P | P | C-1 | C-1
71+
| `operation` of any entry | P | P | C-1 | C-1
72+
| `actor` of any entry | P | P | C-1 | C-1
73+
| `timestamp` of any entry | P | P | C-1 | N (CK falsifies)
74+
| `before_snapshot` of any entry | P | P | C-1 | C-1
75+
| `transformation` of any entry | P | P | C-1 | C-1
76+
| Absence of an entry | C-2 | C-2 | N | N
77+
| Total ordering across entities | N | N | N | N
78+
|===
79+
80+
**C-1** — under SR (or SR+CK), the adversary can rewrite an
81+
arbitrary suffix of the chain (recomputing hashes as they go). What's
82+
preserved against SR is **only the prefix up to the most-recent
83+
externally attested hash** (e.g. a hash periodically signed by an
84+
out-of-band notary, anchored to an append-only log, or published to
85+
a transparency service). Without an external anchor, the chain
86+
proves *nothing* against SR.
87+
88+
**C-2** — absence is provable only if every legitimate append goes
89+
through verisimiser. Direct writes to the target database that
90+
bypass interception are invisible to the chain; the chain cannot
91+
attest to what it never saw.
92+
93+
== Field coverage requirement
94+
95+
A direct consequence of C-1 / C-2 and the per-adversary matrix:
96+
97+
[NOTE]
98+
====
99+
Every field that an auditor will rely on for forensic purposes
100+
**must** participate in the hash. `actor`, `before_snapshot`, and
101+
`transformation` are all such fields — they are the audit. If they
102+
are not in the preimage, the chain protects them against R and SW
103+
only by *coincidence* (because the row itself was hash-keyed in the
104+
DB), not by design.
105+
106+
This document therefore *requires* V-L2-C1: the preimage must cover
107+
`previous_hash`, `entity_id`, `operation`, `actor`, `timestamp`,
108+
`before_snapshot`, `transformation`. Any future field added to
109+
`ProvenanceEntry` must either be added to the preimage or
110+
explicitly recorded here with a justification for its omission.
111+
====
112+
113+
== Canonical encoding requirement
114+
115+
A direct consequence of "the hash protects the field" being a
116+
*function*, not a relation:
117+
118+
[NOTE]
119+
====
120+
Two distinct preimages must produce distinct hashes (collision
121+
resistance is SHA-256's job). Two *equal* preimages must produce
122+
equal hashes (canonicalisation is our job). The encoding must:
123+
124+
. Domain-separate verisimiser provenance hashes from any other
125+
hash the system computes (`b"verisim-prov-v1\0"`).
126+
. Length-prefix variable-length fields so concatenation is
127+
unambiguous.
128+
. Use a canonical timestamp encoding (V-L2-C2:
129+
`i64_le(secs) || u32_le(nanos)`), not a string representation that
130+
admits multiple valid forms for the same instant.
131+
====
132+
133+
== Append serialisation requirement
134+
135+
A direct consequence of "previous_hash chains entries linearly":
136+
137+
[NOTE]
138+
====
139+
Two writers cannot independently chain from the same `previous_hash`
140+
without forking the chain. Verisimiser must serialise appends
141+
per-entity. V-L2-L1 specifies the write-path lock; V-L2-L2 specifies
142+
the database UNIQUE constraint that makes forks structurally
143+
impossible even if the lock is bypassed.
144+
145+
The chain is *per-entity-serial* but *cross-entity-parallel*.
146+
A global serial order across entities is *not* a requirement
147+
(see "Total ordering" in the matrix above).
148+
====
149+
150+
== Anchor / notary (future)
151+
152+
Protection against SR requires an *external anchor* that the
153+
adversary cannot rewrite. Options, none of which this document
154+
mandates yet:
155+
156+
. **Periodic notarisation** — every N minutes, sign the latest
157+
chain_head with a key not held on the sidecar host, and publish
158+
the signature to an out-of-band log.
159+
. **Transparency log** — submit each `chain_head` update to an
160+
external append-only log (Sigstore-style).
161+
. **Replication to immutable storage** — write each new entry to
162+
S3 Object Lock (or equivalent) as a defence in depth.
163+
164+
The threat model leaves the choice for ADR-0005 once a deployment
165+
context exists.
166+
167+
== Out-of-band assumptions
168+
169+
. The sidecar host's clock is monotonic and within bounded skew of
170+
real time. Without this, all timestamps are advisory (see CK in the
171+
matrix).
172+
. Verisimiser's process integrity is assumed — a verisimiser binary
173+
that has been swapped for a malicious one can produce a hash-chain
174+
that verifies against itself but attests to nothing real. Binary
175+
provenance is a separate concern (out of scope here).
176+
. SHA-256 is collision-resistant in the cryptographic sense for the
177+
lifetime of the audit window.
178+
179+
== Open questions
180+
181+
. Should `Option<String>` fields (`before_snapshot`, `transformation`)
182+
encode `None` vs `Some("")` distinctly? The current proposal collapses
183+
them (both encode as `u64_le(0)` length). Document explicitly that
184+
the chain treats "no snapshot" and "empty snapshot" identically; if a
185+
future use case requires distinguishing them, a single sentinel byte
186+
(`0x00` for None, `0x01` for Some) prefixed inside the length-prefixed
187+
slot resolves it.
188+
. Should the chain include an explicit `chain_id` covering all of an
189+
entity's entries (in addition to chaining via `previous_hash`)? Cheap
190+
defence in depth against entity_id confusion; defer to ADR-0004.
191+
192+
== Cross-references
193+
194+
* V-L2-C1 — implements the field coverage + domain separation
195+
* V-L2-C2 — implements canonical timestamp encoding
196+
* V-L2-C3 — positive tamper-detection tests
197+
* V-L2-C4 — removes the wontfix test that codified the C-1 gap
198+
* V-L2-L1 — per-entity write-path serialisation
199+
* V-L2-L2 — UNIQUE INDEX(entity_id, previous_hash) defence in depth
200+
* V-L2-N1 — deduplicates the type used here (ProvenanceEntry vs
201+
ProvenanceRecord)
202+
* ADR-0004 (future) — records the binding choices made here

0 commit comments

Comments
 (0)