Skip to content

Commit 4ee90c8

Browse files
fix(provenance): domain-separated, length-prefixed hash over all fields (#88)
Closes #27, closes #28, closes #29, closes #30. `ProvenanceEntry::compute_hash` was hashing `previous_hash + entity_id + operation + timestamp` as raw byte concatenation: 1. Three fields — `actor`, `before_snapshot`, `transformation` — were *not* in the preimage. Tampering with any of them left `verify()` returning `true`. The integration test `test_provenance_chain_integrity_multi_step` *codified the hole* (#30 / V-L2-C4): "Actor is not part of hash — tamper to actor alone is invisible". 2. No domain separation. Hash bytes were concatenated without length prefixes or a domain tag, so a future field reorder / addition / removal could silently produce a colliding digest for distinct inputs. No version tag either, so a migration to a different hash algorithm had no way to mark old vs new entries. 3. Timestamp encoded as `to_rfc3339()`. Two valid RFC3339 strings for the same instant (sub-second precision, `Z` vs `+00:00`, etc.) produce two different hashes. This commit lands all four V-L2-C* fixes together because they necessarily ship as one change: - **#27 / V-L2-C1** — new `compute_hash` signature accepts all seven preimage fields and prepends `b"verisim-prov-v1\0"` as the domain tag. Variable-length fields are length-prefixed with `u64_le(len)` for canonical encoding. - **#28 / V-L2-C2** — timestamp encoded as `i64_le(secs) || u32_le(nanos)` via `chrono::DateTime::timestamp()` + `.timestamp_subsec_nanos()`. Different RFC3339 strings for the same instant now produce the same hash. RFC3339 is kept for display surface (JSON, status output) but is no longer part of the hash preimage. - **#29 / V-L2-C3** — five new tests in `abi::tests`: `test_provenance_tamper_actor`, `test_provenance_tamper_before_snapshot`, `test_provenance_tamper_transformation`, `test_provenance_timestamp_canonical_encoding`, `test_provenance_mutation_matrix_breaks_verification` (4-entry chain × 7 fields × every entry, asserts every single mutation breaks `verify()`). - **#30 / V-L2-C4** — the wontfix assertion in `tests/integration_test.rs` is flipped to assert `!tampered.verify()` with the updated comment. API note: `compute_hash` signature changed (4 args → 7 args). The function is `pub`, so this is a breaking change. It's a security fix and the previous signature was wrong by construction; the callers inside the crate are updated. No external callers known. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0436bfe commit 4ee90c8

2 files changed

Lines changed: 170 additions & 16 deletions

File tree

src/abi/mod.rs

Lines changed: 165 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,42 +161,81 @@ pub struct ProvenanceEntry {
161161
pub transformation: Option<String>,
162162
}
163163

164+
/// Domain-separation tag for the provenance hash preimage. The trailing
165+
/// NUL is the standard separator between context and payload, and the
166+
/// `v1` suffix lets future migrations to a different encoding mark old
167+
/// vs new entries unambiguously. Closes #27 (V-L2-C1).
168+
const PROVENANCE_HASH_DOMAIN: &[u8] = b"verisim-prov-v1\0";
169+
164170
impl ProvenanceEntry {
165-
/// Compute the SHA-256 hash for a provenance entry, chaining from the previous hash.
171+
/// Compute the SHA-256 hash for a provenance entry.
172+
///
173+
/// Preimage is the canonical length-prefixed concatenation of every
174+
/// field that participates in tamper detection:
166175
///
167-
/// The hash covers: previous_hash, entity_id, operation, and timestamp.
168-
/// This ensures that any tampering with the chain is detectable.
176+
/// ```text
177+
/// SHA-256(
178+
/// "verisim-prov-v1\0" // domain tag + version
179+
/// || u64_le(len(previous_hash)) || previous_hash
180+
/// || u64_le(len(entity_id)) || entity_id
181+
/// || u64_le(len(operation)) || operation
182+
/// || u64_le(len(actor)) || actor
183+
/// || i64_le(secs) || u32_le(nanos) // canonical timestamp
184+
/// || u64_le(len(before_snapshot)) || before_snapshot
185+
/// || u64_le(len(transformation)) || transformation
186+
/// )
187+
/// ```
188+
///
189+
/// `Option<String>` fields encode as `len(0) || ""` when `None`. The
190+
/// timestamp is encoded from `chrono::DateTime`'s seconds-since-epoch
191+
/// + subsecond nanos rather than RFC3339, so timestamps with
192+
/// different valid string forms but the same instant produce the same
193+
/// hash (closes #28 / V-L2-C2).
169194
pub fn compute_hash(
170195
previous_hash: &str,
171196
entity_id: &str,
172197
operation: &str,
173-
timestamp: &str,
198+
actor: &str,
199+
timestamp: &DateTime<Utc>,
200+
before_snapshot: Option<&str>,
201+
transformation: Option<&str>,
174202
) -> String {
175203
let mut hasher = Sha256::new();
176-
hasher.update(previous_hash.as_bytes());
177-
hasher.update(entity_id.as_bytes());
178-
hasher.update(operation.as_bytes());
179-
hasher.update(timestamp.as_bytes());
204+
hasher.update(PROVENANCE_HASH_DOMAIN);
205+
write_len_prefixed(&mut hasher, previous_hash.as_bytes());
206+
write_len_prefixed(&mut hasher, entity_id.as_bytes());
207+
write_len_prefixed(&mut hasher, operation.as_bytes());
208+
write_len_prefixed(&mut hasher, actor.as_bytes());
209+
hasher.update(&timestamp.timestamp().to_le_bytes());
210+
hasher.update(&timestamp.timestamp_subsec_nanos().to_le_bytes());
211+
write_len_prefixed(&mut hasher, before_snapshot.unwrap_or("").as_bytes());
212+
write_len_prefixed(&mut hasher, transformation.unwrap_or("").as_bytes());
180213
format!("{:x}", hasher.finalize())
181214
}
182215

183216
/// Verify that this entry's hash is consistent with its contents.
184217
///
185-
/// Returns `true` if the stored hash matches the recomputed hash.
218+
/// Returns `true` iff the stored hash matches a freshly recomputed
219+
/// hash over the same fields. All seven preimage fields participate,
220+
/// so tampering with any of them (including `actor`,
221+
/// `before_snapshot`, `transformation`) is detectable.
186222
pub fn verify(&self) -> bool {
187223
let expected = Self::compute_hash(
188224
&self.previous_hash,
189225
&self.entity_id,
190226
&self.operation,
191-
&self.timestamp.to_rfc3339(),
227+
&self.actor,
228+
&self.timestamp,
229+
self.before_snapshot.as_deref(),
230+
self.transformation.as_deref(),
192231
);
193232
self.hash == expected
194233
}
195234

196235
/// Create a new genesis entry (first in the chain for an entity).
197236
pub fn genesis(entity_id: &str, actor: &str) -> Self {
198237
let timestamp = Utc::now();
199-
let hash = Self::compute_hash("", entity_id, "insert", &timestamp.to_rfc3339());
238+
let hash = Self::compute_hash("", entity_id, "insert", actor, &timestamp, None, None);
200239
Self {
201240
hash,
202241
previous_hash: String::new(),
@@ -216,7 +255,10 @@ impl ProvenanceEntry {
216255
&self.hash,
217256
&self.entity_id,
218257
operation,
219-
&timestamp.to_rfc3339(),
258+
actor,
259+
&timestamp,
260+
None,
261+
None,
220262
);
221263
Self {
222264
hash,
@@ -231,6 +273,14 @@ impl ProvenanceEntry {
231273
}
232274
}
233275

276+
/// Length-prefix `bytes` with a little-endian `u64` length and feed both
277+
/// into `hasher`. Canonical encoding for variable-length fields: distinct
278+
/// inputs always produce distinct concatenations.
279+
fn write_len_prefixed(hasher: &mut Sha256, bytes: &[u8]) {
280+
hasher.update((bytes.len() as u64).to_le_bytes());
281+
hasher.update(bytes);
282+
}
283+
234284
// ---------------------------------------------------------------------------
235285
// LineageEdge — a directed edge in the data lineage DAG
236286
// ---------------------------------------------------------------------------
@@ -498,6 +548,109 @@ mod tests {
498548
assert!(!entry.verify(), "Tampered entry should fail verification");
499549
}
500550

551+
/// Tampering with `actor` must break `verify()` (closes #29 / V-L2-C3).
552+
/// Before V-L2-C1, `actor` was outside the hash preimage and this
553+
/// mutation was invisible — see V-L2-C4.
554+
#[test]
555+
fn test_provenance_tamper_actor() {
556+
let mut e = ProvenanceEntry::genesis("post-1", "alice");
557+
e.actor = "mallory".to_string();
558+
assert!(!e.verify(), "actor must participate in the hash");
559+
}
560+
561+
/// Tampering with `before_snapshot` must break `verify()`.
562+
#[test]
563+
fn test_provenance_tamper_before_snapshot() {
564+
let mut e = ProvenanceEntry::genesis("post-1", "alice");
565+
e.before_snapshot = Some("{\"redacted\":true}".to_string());
566+
assert!(
567+
!e.verify(),
568+
"before_snapshot must participate in the hash"
569+
);
570+
}
571+
572+
/// Tampering with `transformation` must break `verify()`.
573+
#[test]
574+
fn test_provenance_tamper_transformation() {
575+
let mut e = ProvenanceEntry::genesis("post-1", "alice");
576+
e.transformation = Some("evil-rewrite".to_string());
577+
assert!(
578+
!e.verify(),
579+
"transformation must participate in the hash"
580+
);
581+
}
582+
583+
/// Two `DateTime<Utc>` values constructed via different paths but
584+
/// representing the same instant must produce the same hash. The
585+
/// previous RFC3339-string encoding could produce different hashes
586+
/// for the same instant depending on the serialiser's formatting
587+
/// choices (closes #28 / V-L2-C2).
588+
#[test]
589+
fn test_provenance_timestamp_canonical_encoding() {
590+
let ts_parsed: DateTime<Utc> = "2026-05-13T08:00:00.000Z".parse().unwrap();
591+
let ts_offset: DateTime<Utc> = "2026-05-13T08:00:00+00:00".parse().unwrap();
592+
assert_eq!(ts_parsed, ts_offset, "the two strings denote the same instant");
593+
594+
let h1 = ProvenanceEntry::compute_hash(
595+
"",
596+
"post-1",
597+
"insert",
598+
"alice",
599+
&ts_parsed,
600+
None,
601+
None,
602+
);
603+
let h2 = ProvenanceEntry::compute_hash(
604+
"",
605+
"post-1",
606+
"insert",
607+
"alice",
608+
&ts_offset,
609+
None,
610+
None,
611+
);
612+
assert_eq!(h1, h2, "same instant must produce same hash regardless of input string form");
613+
}
614+
615+
/// Round-trip: build a 4-entry chain and assert every entry verifies;
616+
/// then mutate each field of each entry in turn and assert the
617+
/// mutation breaks `verify()` (closes #29 mutation-matrix clause).
618+
#[test]
619+
fn test_provenance_mutation_matrix_breaks_verification() {
620+
let mut chain_entries = vec![
621+
ProvenanceEntry::genesis("post-1", "alice"),
622+
];
623+
for actor in ["bob", "carol", "dave"] {
624+
let next = chain_entries.last().unwrap().chain("update", actor);
625+
chain_entries.push(next);
626+
}
627+
for e in &chain_entries {
628+
assert!(e.verify(), "every entry must verify before mutation");
629+
}
630+
631+
// Mutate each hash-covered field of each entry. Every mutation must break verify().
632+
for original in &chain_entries {
633+
for mutator in [
634+
|e: &mut ProvenanceEntry| e.entity_id = format!("{}-X", e.entity_id),
635+
|e: &mut ProvenanceEntry| e.operation = format!("{}-X", e.operation),
636+
|e: &mut ProvenanceEntry| e.actor = format!("{}-X", e.actor),
637+
|e: &mut ProvenanceEntry| e.before_snapshot = Some("X".to_string()),
638+
|e: &mut ProvenanceEntry| e.transformation = Some("X".to_string()),
639+
|e: &mut ProvenanceEntry| {
640+
e.timestamp += chrono::Duration::nanoseconds(1)
641+
},
642+
|e: &mut ProvenanceEntry| e.previous_hash = format!("{}X", e.previous_hash),
643+
] {
644+
let mut tampered = original.clone();
645+
mutator(&mut tampered);
646+
assert!(
647+
!tampered.verify(),
648+
"mutation should break verify() but didn't"
649+
);
650+
}
651+
}
652+
}
653+
501654
#[test]
502655
fn test_temporal_version_chain() {
503656
let v1 = TemporalVersion::initial("post-1", serde_json::json!({"title": "Hello"}));

tests/integration_test.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,14 +271,15 @@ fn test_provenance_chain_integrity_multi_step() {
271271
assert_ne!(update1.hash, update2.hash);
272272
assert_ne!(update2.hash, delete.hash);
273273

274-
// Tamper detection: mutating any entry should break verification.
274+
// Tamper detection: post-V-L2-C1 the hash covers actor, so a
275+
// tamper to actor alone now breaks verification (closes #30 / V-L2-C4).
275276
let mut tampered = update1.clone();
276277
tampered.actor = "evil-mallory".to_string();
277278
assert!(
278-
tampered.verify(),
279-
"Actor is not part of hash — tamper to actor alone is invisible"
279+
!tampered.verify(),
280+
"Tampering with actor must break verification"
280281
);
281-
// But modifying a hash-covered field should be detected.
282+
// Modifying a hash-covered field is also detected.
282283
let mut tampered_op = update1.clone();
283284
tampered_op.operation = "delete".to_string();
284285
assert!(

0 commit comments

Comments
 (0)