Skip to main content

qualia_client_core/
accountability_ledger.rs

1//! **Tamper-evident accountability ledger** — the append-only, ed25519-**signed**, hash-**chained** store
2//! that makes the conduct + disclosure records **un-erasable (tamper-evident)** and **attributable
3//! (signed)**.
4//!
5//! ADR 0011 (D4/D5) needs the conduct trail and disclosure events to survive revocation and to resist a
6//! betrayer quietly deleting them. This module realises the "tamper-evident signed-WAL" property with **real
7//! primitives** (`sha2` + `ed25519-dalek`): each entry carries a monotone sequence, the **previous entry's
8//! hash** (the chain), a hash of its own content, and an **ed25519 signature** over that hash by the actor.
9//! Any modification, deletion, insertion, or reordering breaks a hash link or a signature, so
10//! [`AccountabilityLedger::verify`] **detects it and names the entry**.
11//!
12//! Scope: this gives tamper-**evidence** (deletion is *detectable*). Anti-deletion *durability* — that copies
13//! cannot all be removed — is the commons **replication** layer (swarm/WebTorrent; coordinate), and the two
14//! compose: replicate the ledger, and any diverging/pruned copy is provably tampered. The ledger is generic
15//! over the record kind (`"conduct"`, `"disclosure"`, `"switch"`, …) carrying serialised JSON, so it does not
16//! couple to those types.
17
18use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22/// The chain root — the `prev_hash` of the first entry.
23const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
24
25/// One ledgered, signed, chained entry.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct LedgerEntry {
28    /// Monotone position in the chain (0-based).
29    pub seq: u64,
30    /// Hash of the previous entry (or [`GENESIS_HASH`] for the first) — the chain link.
31    pub prev_hash_hex: String,
32    /// Hash of this entry's content (`seq ∥ prev ∥ kind ∥ payload ∥ signer ∥ time`) — the next entry chains
33    /// to it, and it is what the signature signs.
34    pub entry_hash_hex: String,
35    /// Record kind (`"conduct"`, `"disclosure"`, `"switch"`, …).
36    pub kind: String,
37    /// The serialised record (JSON).
38    pub payload_json: String,
39    /// The actor who signed (ed25519 verifying key, hex) — the attribution.
40    pub signer_pubkey_hex: String,
41    /// ed25519 signature over `entry_hash`, hex.
42    pub signature_hex: String,
43    pub time_unix: u64,
44}
45
46/// A detected tamper, naming the offending entry.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum LedgerTamper {
50    /// `prev_hash` does not match the previous entry's hash — an entry was inserted, removed, or reordered.
51    BrokenChain { seq: u64 },
52    /// The stored content hash does not match the content — the entry was modified.
53    ContentModified { seq: u64 },
54    /// The signature does not verify under the stated signer — forged or altered.
55    BadSignature { seq: u64 },
56    /// The stored seq is out of order.
57    BadSequence { seq: u64 },
58}
59
60/// An append-only, tamper-evident ledger of accountability records.
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
62pub struct AccountabilityLedger {
63    entries: Vec<LedgerEntry>,
64}
65
66/// Compute the content hash of an entry's fields.
67fn content_hash(
68    seq: u64,
69    prev_hash_hex: &str,
70    kind: &str,
71    payload_json: &str,
72    signer_pubkey_hex: &str,
73    time_unix: u64,
74) -> [u8; 32] {
75    let mut h = Sha256::new();
76    h.update(seq.to_le_bytes());
77    h.update(b"\x1f");
78    h.update(prev_hash_hex.as_bytes());
79    h.update(b"\x1f");
80    h.update(kind.as_bytes());
81    h.update(b"\x1f");
82    h.update(payload_json.as_bytes());
83    h.update(b"\x1f");
84    h.update(signer_pubkey_hex.as_bytes());
85    h.update(b"\x1f");
86    h.update(time_unix.to_le_bytes());
87    h.finalize().into()
88}
89
90impl AccountabilityLedger {
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    pub fn entries(&self) -> &[LedgerEntry] {
96        &self.entries
97    }
98    pub fn len(&self) -> usize {
99        self.entries.len()
100    }
101    pub fn is_empty(&self) -> bool {
102        self.entries.is_empty()
103    }
104
105    /// The hash the next appended entry will chain to (the last entry's hash, or genesis).
106    pub fn head_hash(&self) -> String {
107        self.entries
108            .last()
109            .map(|e| e.entry_hash_hex.clone())
110            .unwrap_or_else(|| GENESIS_HASH.to_string())
111    }
112
113    /// Append a record, **signed** by `signer` and **chained** to the current head. Returns the new entry.
114    pub fn append(
115        &mut self,
116        kind: impl Into<String>,
117        payload_json: impl Into<String>,
118        signer: &SigningKey,
119        time_unix: u64,
120    ) -> &LedgerEntry {
121        let kind = kind.into();
122        let payload_json = payload_json.into();
123        let seq = self.entries.len() as u64;
124        let prev_hash_hex = self.head_hash();
125        let signer_pubkey_hex = hex::encode(signer.verifying_key().to_bytes());
126
127        let hash = content_hash(
128            seq,
129            &prev_hash_hex,
130            &kind,
131            &payload_json,
132            &signer_pubkey_hex,
133            time_unix,
134        );
135        let signature = signer.sign(&hash);
136
137        self.entries.push(LedgerEntry {
138            seq,
139            prev_hash_hex,
140            entry_hash_hex: hex::encode(hash),
141            kind,
142            payload_json,
143            signer_pubkey_hex,
144            signature_hex: hex::encode(signature.to_bytes()),
145            time_unix,
146        });
147        self.entries.last().expect("just pushed")
148    }
149
150    /// Verify the whole chain: every entry's content hash recomputes, chains to the previous, its sequence is
151    /// in order, and its signature verifies. Returns the **first** tamper found, or `Ok(())`.
152    pub fn verify(&self) -> Result<(), LedgerTamper> {
153        let mut expected_prev = GENESIS_HASH.to_string();
154        for (i, e) in self.entries.iter().enumerate() {
155            if e.seq != i as u64 {
156                return Err(LedgerTamper::BadSequence { seq: e.seq });
157            }
158            if e.prev_hash_hex != expected_prev {
159                return Err(LedgerTamper::BrokenChain { seq: e.seq });
160            }
161            let hash = content_hash(
162                e.seq,
163                &e.prev_hash_hex,
164                &e.kind,
165                &e.payload_json,
166                &e.signer_pubkey_hex,
167                e.time_unix,
168            );
169            if hex::encode(hash) != e.entry_hash_hex {
170                return Err(LedgerTamper::ContentModified { seq: e.seq });
171            }
172            if !verify_signature(&e.signer_pubkey_hex, &hash, &e.signature_hex) {
173                return Err(LedgerTamper::BadSignature { seq: e.seq });
174            }
175            expected_prev = e.entry_hash_hex.clone();
176        }
177        Ok(())
178    }
179
180    /// Entries of a given kind (e.g. all `"conduct"`), preserving order.
181    pub fn of_kind<'a>(&'a self, kind: &str) -> Vec<&'a LedgerEntry> {
182        self.entries.iter().filter(|e| e.kind == kind).collect()
183    }
184}
185
186fn verify_signature(signer_pubkey_hex: &str, hash: &[u8; 32], signature_hex: &str) -> bool {
187    let Ok(pk_bytes) = hex::decode(signer_pubkey_hex) else {
188        return false;
189    };
190    let Ok(pk_arr): Result<[u8; 32], _> = pk_bytes.as_slice().try_into() else {
191        return false;
192    };
193    let Ok(vk) = VerifyingKey::from_bytes(&pk_arr) else {
194        return false;
195    };
196    let Ok(sig_bytes) = hex::decode(signature_hex) else {
197        return false;
198    };
199    let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.as_slice().try_into() else {
200        return false;
201    };
202    vk.verify(hash, &Signature::from_bytes(&sig_arr)).is_ok()
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn signer(seed: u8) -> SigningKey {
210        SigningKey::from_bytes(&[seed; 32])
211    }
212
213    #[test]
214    fn appends_are_signed_chained_and_verify() {
215        let sk = signer(1);
216        let mut led = AccountabilityLedger::new();
217        led.append("conduct", r#"{"act":"accessed record"}"#, &sk, 1_000);
218        led.append("disclosure", r#"{"to":"did:wf:mp"}"#, &sk, 1_100);
219        led.append("conduct", r#"{"act":"requested placement"}"#, &sk, 1_200);
220
221        assert_eq!(led.len(), 3);
222        assert_eq!(led.verify(), Ok(()), "a well-formed chain verifies");
223        // Each entry is attributed to its signer.
224        assert!(led
225            .entries()
226            .iter()
227            .all(|e| e.signer_pubkey_hex == hex::encode(sk.verifying_key().to_bytes())));
228        // The chain links.
229        assert_eq!(
230            led.entries()[1].prev_hash_hex,
231            led.entries()[0].entry_hash_hex
232        );
233        assert_eq!(led.of_kind("conduct").len(), 2);
234    }
235
236    #[test]
237    fn modifying_an_entrys_content_is_detected() {
238        let sk = signer(1);
239        let mut led = AccountabilityLedger::new();
240        led.append("conduct", r#"{"act":"accessed record"}"#, &sk, 1_000);
241        led.append("conduct", r#"{"act":"did nothing"}"#, &sk, 1_100);
242        // A betrayer edits the payload of entry 1 to hide what they did.
243        led.entries[1].payload_json = r#"{"act":"acted diligently"}"#.to_string();
244        assert_eq!(led.verify(), Err(LedgerTamper::ContentModified { seq: 1 }));
245    }
246
247    #[test]
248    fn deleting_an_entry_breaks_the_chain() {
249        let sk = signer(1);
250        let mut led = AccountabilityLedger::new();
251        led.append("conduct", "a", &sk, 1_000);
252        led.append("disclosure", "b", &sk, 1_100); // the inconvenient one
253        led.append("conduct", "c", &sk, 1_200);
254        // Remove the middle entry to erase evidence.
255        led.entries.remove(1);
256        // The chain breaks (seq/prev mismatch) — deletion is detectable.
257        assert!(matches!(
258            led.verify(),
259            Err(LedgerTamper::BadSequence { .. }) | Err(LedgerTamper::BrokenChain { .. })
260        ));
261    }
262
263    #[test]
264    fn a_forged_signature_is_detected() {
265        let sk = signer(1);
266        let attacker = signer(2);
267        let mut led = AccountabilityLedger::new();
268        led.append("conduct", r#"{"act":"x"}"#, &sk, 1_000);
269        // The attacker rewrites the payload AND re-signs with THEIR key + fixes the hash — but the entry now
270        // claims signer = the original actor, so the signature fails under the claimed signer.
271        let e = &mut led.entries[0];
272        e.payload_json = r#"{"act":"forged"}"#.to_string();
273        let hash = content_hash(
274            e.seq,
275            &e.prev_hash_hex,
276            &e.kind,
277            &e.payload_json,
278            &e.signer_pubkey_hex,
279            e.time_unix,
280        );
281        e.entry_hash_hex = hex::encode(hash);
282        e.signature_hex = hex::encode(attacker.sign(&hash).to_bytes()); // signed by the wrong key
283        assert_eq!(led.verify(), Err(LedgerTamper::BadSignature { seq: 0 }));
284    }
285
286    #[test]
287    fn serde_round_trips_and_still_verifies() {
288        let sk = signer(3);
289        let mut led = AccountabilityLedger::new();
290        led.append("switch", r#"{"fired":true}"#, &sk, 1_000);
291        let json = serde_json::to_string(&led).unwrap();
292        let back: AccountabilityLedger = serde_json::from_str(&json).unwrap();
293        assert_eq!(led, back);
294        assert_eq!(back.verify(), Ok(()));
295    }
296}