Skip to main content

dent8_core/
anchor.rs

1//! External tamper-*resistance* anchor for the event-log hash chain.
2//!
3//! The per-event hash chain ([`crate::hash`]) is tamper-*evident*: altering an event
4//! without recomputing its hash is caught on replay. It is **not** tamper-*resistant*
5//! against a writer with full store access who edits an event **and** re-hashes the whole
6//! log forward — that rewritten chain is internally self-consistent, so an internal
7//! re-verify passes (this is the [threat model](../../docs/threat-model.md) T6 residual).
8//!
9//! The defense is an **external** anchor: a commitment to the chain head (event count +
10//! head digest) authenticated by a key the writer does **not** hold (an external witness).
11//! If the writer rewrites history the head changes, and the witness's old commitment no
12//! longer matches; the writer cannot forge a fresh one without the key.
13//!
14//! This v0 is a keyed **HMAC-SHA256** commitment over a domain-separated, length-framed
15//! `(count, head)` tuple. It is *symmetric*: the verifier must hold the same witness key,
16//! which must be kept off the writer's machine for the resistance to hold. An *asymmetric*
17//! signed tree head (RFC 6962-style, so anyone can verify a published head) is the
18//! production upgrade — it needs a signature dependency this keyless v0 deliberately
19//! avoids (it reuses only the SHA-256 already in `hash.rs`).
20//!
21//! **These functions are the primitive, not a witness deployment.** Resistance holds only
22//! if the witness (a) issues the anchor at write time, (b) holds the key off the writer,
23//! and (c) publishes a **monotonic, append-only** anchor sequence (non-decreasing
24//! `event_count`) so that a *never-issued* anchor or a *rolled-back* old anchor is itself
25//! detectable. A writer that holds the key, never anchors, or replays a stale anchor gets
26//! no resistance. See the threat model's T6 residuals.
27
28use sha2::{Digest, Sha256};
29
30use crate::hash::{CANON_VERSION, CanonError, hash_chain};
31use crate::model::ClaimEvent;
32
33/// Domain-separation prefix for an anchor (tree-head) commitment — distinct from the
34/// `0x00` leaf and `0x01` interior-node prefixes used in [`crate::hash`].
35const ANCHOR_PREFIX: u8 = 0x02;
36
37/// SHA-256 block size, used by the HMAC construction.
38const BLOCK_LEN: usize = 64;
39
40/// An authenticated commitment to a log's chain head at a point in time: the event count
41/// and the head digest, MAC'd under a witness key. Storing/publishing this lets a later
42/// reader detect a history rewrite that an internal chain re-verify
43/// ([`crate::hash_chain`]) cannot.
44#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
45pub struct ChainAnchor {
46    pub event_count: u64,
47    /// Lowercase-hex head digest, or `None` for an empty log.
48    pub head: Option<String>,
49    /// Lowercase-hex HMAC-SHA256 over the framed `(count, head)` under the witness key.
50    pub mac: String,
51}
52
53/// Commit to the current chain head under `witness_key`. For the anchor to add
54/// tamper-*resistance* (not just evidence), the key must be held by a party **other than**
55/// the log writer — a writer who also holds the key can simply re-anchor a rewrite.
56pub fn anchor_head(events: &[ClaimEvent], witness_key: &[u8]) -> Result<ChainAnchor, CanonError> {
57    let head = hash_chain(events)?.pop();
58    let event_count = events.len() as u64;
59    let mac = hex::encode(hmac_sha256(
60        witness_key,
61        &anchor_message(event_count, head.as_deref()),
62    ));
63    Ok(ChainAnchor {
64        event_count,
65        head,
66        mac,
67    })
68}
69
70/// Verify `anchor` against the current `events` under `witness_key`: recompute the head and
71/// count, recompute the MAC, and constant-time-compare. Returns `Ok(false)` (not an error)
72/// when the log no longer matches the commitment — i.e. tamper detected, including the
73/// re-hashed-forward rewrite an internal re-verify would miss.
74///
75/// `Err` means verification could not be *performed* (a canonicalization failure), **not**
76/// that the anchor is valid — treat it as **not verified**. Never collapse the result with
77/// `.is_ok()` or `.unwrap_or(true)`: only `Ok(true)` means verified.
78pub fn verify_anchor(
79    events: &[ClaimEvent],
80    anchor: &ChainAnchor,
81    witness_key: &[u8],
82) -> Result<bool, CanonError> {
83    let head = hash_chain(events)?.pop();
84    let event_count = events.len() as u64;
85    let expected = hex::encode(hmac_sha256(
86        witness_key,
87        &anchor_message(event_count, head.as_deref()),
88    ));
89    Ok(event_count == anchor.event_count
90        && head == anchor.head
91        && constant_time_eq(expected.as_bytes(), anchor.mac.as_bytes()))
92}
93
94/// Domain-separated, length-framed message committed to by an anchor: `ANCHOR_PREFIX ||
95/// CANON_VERSION || count(8 BE) || head_present(0|1) [|| len(head)(8 BE) || head]`. The
96/// length frame and the present/absent tag make `(count, head)` pairs unambiguous.
97fn anchor_message(count: u64, head: Option<&str>) -> Vec<u8> {
98    let mut message = vec![ANCHOR_PREFIX, CANON_VERSION];
99    message.extend_from_slice(&count.to_be_bytes());
100    match head {
101        None => message.push(0u8),
102        Some(head) => {
103            message.push(1u8);
104            message.extend_from_slice(&(head.len() as u64).to_be_bytes());
105            message.extend_from_slice(head.as_bytes());
106        }
107    }
108    message
109}
110
111/// HMAC-SHA256 (RFC 2104) built on the `sha2` already used for the chain — no extra
112/// dependency. The key is hashed if it exceeds the block size, then zero-padded.
113fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
114    let mut block = [0u8; BLOCK_LEN];
115    if key.len() > BLOCK_LEN {
116        block[..32].copy_from_slice(&Sha256::digest(key));
117    } else {
118        block[..key.len()].copy_from_slice(key);
119    }
120
121    let mut ipad = [0x36u8; BLOCK_LEN];
122    let mut opad = [0x5cu8; BLOCK_LEN];
123    for ((byte, inner), outer) in block.iter().zip(ipad.iter_mut()).zip(opad.iter_mut()) {
124        *inner ^= byte;
125        *outer ^= byte;
126    }
127
128    let mut inner = Sha256::new();
129    inner.update(ipad);
130    inner.update(message);
131    let inner_digest = inner.finalize();
132
133    let mut outer = Sha256::new();
134    outer.update(opad);
135    outer.update(inner_digest);
136
137    let mut mac = [0u8; 32];
138    mac.copy_from_slice(&outer.finalize());
139    mac
140}
141
142/// Length-independent-result constant-time byte comparison (no early return on mismatch).
143fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
144    if a.len() != b.len() {
145        return false;
146    }
147    let mut diff = 0u8;
148    for (x, y) in a.iter().zip(b) {
149        diff |= x ^ y;
150    }
151    diff == 0
152}
153
154// ---- Asymmetric (publicly-verifiable) anchor — feature `signed-anchor` ----------------
155//
156// The HMAC anchor above is *symmetric*: the verifier must hold the witness key. A
157// **signed tree head** (RFC 6962-style) instead signs the same domain-separated
158// `(count, head)` message with an Ed25519 key, so **anyone with the public key can verify
159// a published head** — the witness keeps only the private key. This is the upgrade that
160// makes the anchor publicly auditable rather than shared-secret.
161
162/// An Ed25519-signed commitment to a log's chain head — a publicly-verifiable tree head.
163///
164/// Strict deserialization (`deny_unknown_fields`): a signed head is a security artifact whose
165/// signature covers only `(event_count, head)` — an unknown field in a stored/published head
166/// is unsigned noise at best and tampering at worst, so it fails loudly instead of being
167/// silently dropped.
168#[cfg(feature = "signed-anchor")]
169#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct SignedTreeHead {
172    pub event_count: u64,
173    /// Lowercase-hex head digest, or `None` for an empty log.
174    pub head: Option<String>,
175    /// Lowercase-hex Ed25519 signature over the framed `(count, head)` message.
176    pub signature: String,
177}
178
179/// Sign the current chain head with `signing_key`. The signature commits to the same
180/// domain-separated message the HMAC anchor uses, so the framings cannot be confused.
181#[cfg(feature = "signed-anchor")]
182pub fn sign_head(
183    events: &[ClaimEvent],
184    signing_key: &ed25519_dalek::SigningKey,
185) -> Result<SignedTreeHead, CanonError> {
186    use ed25519_dalek::Signer;
187
188    let head = hash_chain(events)?.pop();
189    let event_count = events.len() as u64;
190    let signature = signing_key.sign(&anchor_message(event_count, head.as_deref()));
191    Ok(SignedTreeHead {
192        event_count,
193        head,
194        signature: hex::encode(signature.to_bytes()),
195    })
196}
197
198/// Verify a [`SignedTreeHead`] against the current `events` using only the **public**
199/// `verifying_key` — no secret required. Returns `Ok(false)` (not an error) when the log no
200/// longer matches the commitment or the signature does not verify (tamper detected).
201///
202/// `Err` means verification could not be *performed* (a canonicalization failure), **not**
203/// that the head is valid — treat it as **not verified**. Never collapse the result with
204/// `.is_ok()` or `.unwrap_or(true)`: only `Ok(true)` means verified.
205#[cfg(feature = "signed-anchor")]
206pub fn verify_signed_head(
207    events: &[ClaimEvent],
208    head: &SignedTreeHead,
209    verifying_key: &ed25519_dalek::VerifyingKey,
210) -> Result<bool, CanonError> {
211    use ed25519_dalek::Signature;
212
213    let recomputed = hash_chain(events)?.pop();
214    let event_count = events.len() as u64;
215    // Redundant with the signature (which binds both fields) — a cheap short-circuit that
216    // also yields a clean `Ok(false)` on a count/head mismatch before the curve op.
217    if event_count != head.event_count || recomputed != head.head {
218        return Ok(false);
219    }
220    let Ok(bytes) = hex::decode(&head.signature) else {
221        return Ok(false);
222    };
223    let Ok(bytes) = <[u8; 64]>::try_from(bytes.as_slice()) else {
224        return Ok(false);
225    };
226    let signature = Signature::from_bytes(&bytes);
227    // `verify_strict` additionally rejects small-order points and pins one canonical
228    // verification equation (vs the plain `verify`); the witness signs with `sign`.
229    Ok(verifying_key
230        .verify_strict(
231            &anchor_message(event_count, recomputed.as_deref()),
232            &signature,
233        )
234        .is_ok())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::{ChainAnchor, anchor_head, hmac_sha256, verify_anchor};
240    use crate::ids::{ActorId, ClaimEventId, ClaimId, EvidenceId, SourceId, TimestampMillis};
241    use crate::model::{
242        Authority, AuthorityLevel, ClaimEvent, ClaimEventKind, ClaimValue, Confidence, EntityRef,
243        Evidence, EvidenceKind, Predicate, Provenance, Ttl,
244    };
245
246    const KEY: &[u8] = b"witness-secret-held-off-the-writer";
247
248    fn asserted(event_id: &str, value: &str) -> ClaimEvent {
249        ClaimEvent {
250            event_id: ClaimEventId::new(event_id).expect("event id"),
251            claim_id: ClaimId::new("claim:1").expect("claim id"),
252            kind: ClaimEventKind::Asserted,
253            subject: EntityRef::new("repo", "dent8").expect("entity"),
254            predicate: Predicate::new("database").expect("predicate"),
255            value: Some(ClaimValue::Text(value.to_string())),
256            confidence: Confidence::from_millis(900).expect("confidence"),
257            authority: Authority {
258                level: AuthorityLevel::High,
259                issuer: None,
260                scope: None,
261            },
262            ttl: Ttl::Never,
263            provenance: Provenance {
264                source: SourceId::new("source:test").expect("source"),
265                actor: ActorId::new("actor:test").expect("actor"),
266                tool: None,
267                run_id: None,
268                input_digest: None,
269                recorded_at: TimestampMillis::from_unix_millis(1),
270                attestation: None,
271            },
272            evidence: vec![Evidence {
273                id: EvidenceId::new("evidence:1").expect("evidence id"),
274                kind: EvidenceKind::UserStatement,
275                locator: "x".to_string(),
276                digest: None,
277                summary: None,
278            }],
279            observed_at: None,
280            valid_from: None,
281            valid_to: None,
282        }
283    }
284
285    #[test]
286    fn an_anchor_verifies_against_its_own_log() {
287        let events = [
288            asserted("event:0", "postgres"),
289            asserted("event:1", "redis"),
290        ];
291        let anchor = anchor_head(&events, KEY).expect("anchor");
292        assert_eq!(anchor.event_count, 2);
293        assert!(anchor.head.is_some());
294        assert!(verify_anchor(&events, &anchor, KEY).expect("verify"));
295    }
296
297    #[test]
298    fn an_empty_log_anchors_and_verifies() {
299        let events: [ClaimEvent; 0] = [];
300        let anchor = anchor_head(&events, KEY).expect("anchor");
301        assert_eq!(anchor.event_count, 0);
302        assert!(anchor.head.is_none());
303        assert!(verify_anchor(&events, &anchor, KEY).expect("verify"));
304    }
305
306    #[test]
307    fn a_rehashed_forward_rewrite_is_caught_by_the_anchor() {
308        // The operator-with-DB-access attack: edit an event AND recompute the whole chain
309        // forward, producing an internally-consistent (re-verifying) log. The external
310        // anchor still rejects it because the head changed and the witness MAC cannot be
311        // forged without the key.
312        let original = [
313            asserted("event:0", "postgres"),
314            asserted("event:1", "redis"),
315        ];
316        let anchor = anchor_head(&original, KEY).expect("anchor");
317
318        let rewritten = [
319            asserted("event:0", "postgres"),
320            asserted("event:1", "mysql"), // the operator's quiet edit
321        ];
322        // The rewritten log is internally consistent (its own chain re-verifies), yet the
323        // external anchor detects the rewrite.
324        assert!(
325            !verify_anchor(&rewritten, &anchor, KEY).expect("verify"),
326            "a re-hashed-forward rewrite must fail anchor verification"
327        );
328    }
329
330    #[test]
331    fn the_wrong_witness_key_does_not_verify() {
332        let events = [asserted("event:0", "postgres")];
333        let anchor = anchor_head(&events, KEY).expect("anchor");
334        assert!(!verify_anchor(&events, &anchor, b"attacker-key").expect("verify"));
335    }
336
337    #[test]
338    fn a_truncated_log_is_caught_even_if_its_own_chain_is_valid() {
339        // Dropping the last event leaves a perfectly valid shorter chain — but the count
340        // and head no longer match the anchor.
341        let full = [
342            asserted("event:0", "postgres"),
343            asserted("event:1", "redis"),
344        ];
345        let anchor = anchor_head(&full, KEY).expect("anchor");
346        let truncated = [asserted("event:0", "postgres")];
347        assert!(!verify_anchor(&truncated, &anchor, KEY).expect("verify"));
348    }
349
350    #[test]
351    fn a_tampered_mac_does_not_verify() {
352        let events = [asserted("event:0", "postgres")];
353        let mut anchor = anchor_head(&events, KEY).expect("anchor");
354        anchor.mac = ChainAnchor {
355            event_count: anchor.event_count,
356            head: anchor.head.clone(),
357            mac: "00".repeat(32),
358        }
359        .mac;
360        assert!(!verify_anchor(&events, &anchor, KEY).expect("verify"));
361    }
362
363    #[test]
364    fn hmac_matches_a_known_rfc4231_vector() {
365        // RFC 4231 test case 1: key = 0x0b*20, data = "Hi There".
366        let key = [0x0bu8; 20];
367        let mac = hmac_sha256(&key, b"Hi There");
368        let expected = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
369        assert_eq!(hex::encode(mac), expected);
370    }
371
372    #[cfg(feature = "signed-anchor")]
373    #[test]
374    fn a_signed_tree_head_is_publicly_verifiable_and_tamper_detecting() {
375        use super::{sign_head, verify_signed_head};
376        use ed25519_dalek::SigningKey;
377
378        let signing_key = SigningKey::from_bytes(&[7u8; 32]);
379        let verifying_key = signing_key.verifying_key();
380        let events = [
381            asserted("event:0", "postgres"),
382            asserted("event:1", "redis"),
383        ];
384        let sth = sign_head(&events, &signing_key).expect("sign");
385
386        // Anyone holding only the PUBLIC key can verify the witness's signed head.
387        assert!(verify_signed_head(&events, &sth, &verifying_key).expect("verify"));
388
389        // A re-hashed-forward rewrite is detected: the head changes and the signature, which
390        // the writer cannot forge without the private key, no longer verifies.
391        let rewritten = [
392            asserted("event:0", "postgres"),
393            asserted("event:1", "mysql"),
394        ];
395        assert!(!verify_signed_head(&rewritten, &sth, &verifying_key).expect("verify"));
396
397        // A different (attacker) key does not verify the witness's signature.
398        let attacker = SigningKey::from_bytes(&[9u8; 32]).verifying_key();
399        assert!(!verify_signed_head(&events, &sth, &attacker).expect("verify"));
400    }
401}