Skip to main content

wm_memory/
attestation.rs

1//! Record attestations (Track F Slice A, D5 opening move) — tamper-evident
2//! provenance for created memories.
3//!
4//! Every `memory.create` with a node key available appends one attestation
5//! to the `attestations` DBI, keyed `att:{galaxy}:{memory_id}`. The entry
6//! binds the memory's content hash to the creating agent and node via an
7//! Ed25519 signature — the same algorithm as the Sangha mesh identity and
8//! its own HKDF-SHA256 purpose subkey (`wm/record-attestation/v1`) derived
9//! from the same canonical 64-hex root (`wm_core::kdf::root_bytes`; S9 §2.1 /
10//! Q39 §2), so identity and attestation keys never cross.
11//!
12//! Payload domain separation: the signed bytes begin with
13//! `ATTESTATION_DOMAIN` (`wm-record-attestation/v1`), so a record
14//! attestation can never verify as a mesh heartbeat/chat payload or vice
15//! versa, even though both subkeys share one root. The 9.1.8 KDF split
16//! (S9 §2.1) rules new attestations onto the derived subkey; pre-9.1.8 rows
17//! keep verifying against their recorded pubkey, so no re-key of history is
18//! needed.
19//!
20//! Why the sign/verify helper lives here instead of reusing
21//! `wm_sangha::crypto`: `wm-memory` must stay free of `wm-sangha` (mesh is
22//! a transport over stores, never a store dependency — otherwise future
23//! mesh↔store wiring cycles). The scheme is identical (Ed25519 over the
24//! domain-prefixed payload, lowercase hex); only the call site differs.
25//!
26//! Merkle convention for `wm anchor`: [`merkle_root_hex`] uses the same
27//! Bitcoin-convention loop as the karma anchor (duplicate-last on odd
28//! layers, `sha256(left || right)` upward, `sha256("")` for the empty
29//! set), so anchor roots are comparable across subsystems. (Follow-up:
30//! factor the shared loop out of `KarmaLedger::compute_merkle_root`.)
31
32use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35use wm_core::Galaxy;
36use wm_core::kdf::{RECORD_ATTESTATION_INFO, hkdf32};
37
38use crate::memory::MemoryId;
39
40/// Domain prefix for every signed attestation payload. Cross-protocol
41/// confusion is impossible by construction: no other WhiteMagic protocol
42/// signs bytes beginning with this string.
43pub const ATTESTATION_DOMAIN: &str = "wm-record-attestation/v1";
44
45/// Name of the LMDB sub-database holding attestations.
46pub const ATTESTATIONS_DB: &str = "attestations";
47
48/// Environment variable carrying the node signing key (hex, 32 bytes) —
49/// the same `WM_MESH_KEY` the Sangha mesh uses for peer identity.
50pub const ATTESTATION_KEY_ENV: &str = "WM_MESH_KEY";
51
52/// One attestation: a node's signed claim "I created this record with
53/// this content hash at this time as this agent".
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct RecordAttestation {
56    /// Attestation domain (always [`ATTESTATION_DOMAIN`] at v1).
57    pub domain: String,
58    /// Galaxy db name (e.g. `"codex"`).
59    pub galaxy: String,
60    /// Memory id (hyphenated UUID string).
61    pub memory_id: String,
62    /// The memory's `content_hash` at creation time.
63    pub record_hash: String,
64    /// Attributing agent: dispatch session UUID when inside one,
65    /// `user_id` when set, else `"local"`.
66    pub agent_id: String,
67    /// Unix timestamp (seconds) of the creating dispatch.
68    pub timestamp: u64,
69    /// Signer public key (lowercase hex) — the node's mesh identity.
70    pub public_key_hex: String,
71    /// Ed25519 signature over [`attestation_payload`] (lowercase hex).
72    pub signature_hex: String,
73}
74
75/// Result of checking one memory's attestation.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct AttestationReport {
78    /// An attestation row exists for this memory.
79    pub attested: bool,
80    /// The signature verifies against the recorded pubkey and payload.
81    pub signature_valid: bool,
82    /// The attested `record_hash` equals the memory's live `content_hash`
83    /// (false after a content update — the revisions chain, not the
84    /// attestation, covers updates by design).
85    pub matches_head: bool,
86    /// The attested memory id is present in its galaxy (false when the
87    /// memory was deleted after attestation — the attestation row
88    /// survives as evidence of what was claimed).
89    pub memory_present: bool,
90    /// Human-readable break descriptions; empty when fully valid.
91    pub breaks: Vec<String>,
92}
93
94/// Canonical signed payload. Pipe-delimited fixed-order fields — no
95/// canonical-JSON dependency, byte-stable by construction.
96#[must_use]
97pub fn attestation_payload(
98    galaxy: &str,
99    memory_id: &str,
100    record_hash: &str,
101    agent_id: &str,
102    timestamp: u64,
103) -> String {
104    format!("{ATTESTATION_DOMAIN}|{galaxy}|{memory_id}|{record_hash}|{agent_id}|{timestamp}")
105}
106
107/// LMDB key for one memory's attestation: `att:{galaxy}:{memory_id}`.
108#[must_use]
109pub fn attestation_key(galaxy: Galaxy, id: MemoryId) -> Vec<u8> {
110    format!("att:{}:{}", galaxy.db_name(), id).into_bytes()
111}
112
113/// Key prefix covering the whole attestation DBI (for full scans).
114#[must_use]
115pub fn attestation_prefix() -> Vec<u8> {
116    b"att:".to_vec()
117}
118
119/// SHA-256 of a string, lowercase hex.
120#[must_use]
121pub fn sha256_hex(s: &str) -> String {
122    const HEX: &[u8; 16] = b"0123456789abcdef";
123    let digest = Sha256::digest(s.as_bytes());
124    digest.iter().fold(String::with_capacity(64), |mut out, b| {
125        out.push(HEX[(b >> 4) as usize] as char);
126        out.push(HEX[(b & 0x0f) as usize] as char);
127        out
128    })
129}
130
131/// Decode 64-char hex into 32 bytes.
132fn decode_key_hex(hex: &str) -> Option<[u8; 32]> {
133    if hex.len() != 64 {
134        return None;
135    }
136    let bytes = hex.as_bytes();
137    let mut out = [0u8; 32];
138    for (i, chunk) in bytes.chunks_exact(2).enumerate() {
139        let hi = hex_val(chunk[0])?;
140        let lo = hex_val(chunk[1])?;
141        out[i] = (hi << 4) | lo;
142    }
143    Some(out)
144}
145
146const fn hex_val(b: u8) -> Option<u8> {
147    match b {
148        b'0'..=b'9' => Some(b - b'0'),
149        b'a'..=b'f' => Some(b - b'a' + 10),
150        b'A'..=b'F' => Some(b - b'A' + 10),
151        _ => None,
152    }
153}
154
155/// Sign an attestation payload with a 32-byte secret key (hex).
156/// Returns `(public_key_hex, signature_hex)`, or `None` on bad key
157/// material. Pure function — env handling lives at the call site.
158#[must_use]
159pub fn sign_attestation(payload: &str, secret_hex: &str) -> Option<(String, String)> {
160    let secret = decode_key_hex(secret_hex.trim())?;
161    let signing = SigningKey::from_bytes(&secret);
162    let sig = signing.sign(payload.as_bytes());
163    Some((
164        hex_of(&signing.verifying_key().to_bytes()),
165        hex_of(&sig.to_bytes()),
166    ))
167}
168
169/// Derive the record-attestation signing key (hex) from node root material.
170///
171/// HKDF-SHA256 with `info = "wm/record-attestation/v1"` (S9 §2.1 / Q39 §2)
172/// over the **canonical** root: a 64-hex-char `WM_MESH_KEY` decoded to its 32
173/// bytes — the same root the mesh identity derives from. Non-canonical
174/// material derives no attestation subkey (honest negative at the call site;
175/// the mesh identity alone tolerates legacy/test material via
176/// [`wm_core::kdf::root_bytes`]). The legacy hex-decoded lineage stays
177/// accepted during the one-release migration: each attestation stores its
178/// signer pubkey, so old rows verify against their recorded key regardless of
179/// lineage.
180#[must_use]
181pub fn derive_attestation_key_hex(root_hex: &str) -> Option<String> {
182    let root = decode_key_hex(root_hex.trim())?;
183    Some(hex_of(&hkdf32(&root, RECORD_ATTESTATION_INFO)))
184}
185
186/// Sign an attestation payload with the HKDF-derived attestation subkey from
187/// root material. Returns `(public_key_hex, signature_hex)`; the lineage of
188/// the returned key is `wm/record-attestation/v1`.
189#[must_use]
190pub fn sign_attestation_from_root(payload: &str, root_hex: &str) -> Option<(String, String)> {
191    let key = derive_attestation_key_hex(root_hex)?;
192    sign_attestation(payload, &key)
193}
194
195fn hex_of(bytes: &[u8]) -> String {
196    const HEX: &[u8; 16] = b"0123456789abcdef";
197    let mut out = String::with_capacity(bytes.len() * 2);
198    for b in bytes {
199        out.push(HEX[(b >> 4) as usize] as char);
200        out.push(HEX[(b & 0x0f) as usize] as char);
201    }
202    out
203}
204
205/// Verify an attestation's signature against its recorded pubkey.
206#[must_use]
207pub fn verify_attestation(att: &RecordAttestation) -> bool {
208    if att.domain != ATTESTATION_DOMAIN {
209        return false;
210    }
211    let payload = attestation_payload(
212        &att.galaxy,
213        &att.memory_id,
214        &att.record_hash,
215        &att.agent_id,
216        att.timestamp,
217    );
218    let (Some(pk_bytes), Some(sig_bytes)) = (
219        decode_pubkey(&att.public_key_hex),
220        decode_sig(&att.signature_hex),
221    ) else {
222        return false;
223    };
224    let Ok(pk) = VerifyingKey::from_bytes(&pk_bytes) else {
225        return false;
226    };
227    let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
228    pk.verify(payload.as_bytes(), &sig).is_ok()
229}
230
231fn decode_pubkey(hex: &str) -> Option<[u8; 32]> {
232    decode_key_hex(hex)
233}
234
235fn decode_sig(hex: &str) -> Option<[u8; 64]> {
236    if hex.len() != 128 {
237        return None;
238    }
239    let bytes = hex.as_bytes();
240    let mut out = [0u8; 64];
241    for (i, chunk) in bytes.chunks_exact(2).enumerate() {
242        out[i] = (hex_val(chunk[0])? << 4) | hex_val(chunk[1])?;
243    }
244    Some(out)
245}
246
247/// Merkle root over leaf-hash strings.
248///
249/// Same convention as the karma anchor (`KarmaLedger::compute_merkle_root`):
250/// duplicate-last on odd layers, `sha256(left || right)` upward, `sha256("")`
251/// for the empty set. Leaves must be pre-sorted by the caller for determinism.
252#[must_use]
253pub fn merkle_root_hex(leaves: &[String]) -> String {
254    if leaves.is_empty() {
255        return sha256_hex("");
256    }
257    let mut layer: Vec<String> = leaves.to_vec();
258    while layer.len() > 1 {
259        if layer.len() % 2 != 0 {
260            let last = layer.last().cloned().unwrap_or_default();
261            layer.push(last);
262        }
263        let mut next = Vec::with_capacity(layer.len() / 2);
264        for pair in layer.chunks(2) {
265            next.push(sha256_hex(&format!("{}{}", pair[0], pair[1])));
266        }
267        layer = next;
268    }
269    layer.into_iter().next().unwrap_or_default()
270}
271
272/// Anchor leaf input for one attestation: binds the record hash to the
273/// attestation signature. `wm anchor` sorts leaves before hashing.
274#[must_use]
275pub fn anchor_leaf_input(record_hash: &str, signature_hex: &str) -> String {
276    sha256_hex(&format!("{record_hash}|{signature_hex}"))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    const TEST_KEY: &str = "0bd1c44170ca3d916648a983dcdb8583d22f2da5b29fdd5ede4b38e805435577";
284
285    fn test_attestation() -> RecordAttestation {
286        let payload = attestation_payload("codex", "mem-1", "hash-1", "ses-1", 1_700_000_000);
287        let (pk, sig) = sign_attestation(&payload, TEST_KEY).unwrap();
288        RecordAttestation {
289            domain: ATTESTATION_DOMAIN.to_string(),
290            galaxy: "codex".to_string(),
291            memory_id: "mem-1".to_string(),
292            record_hash: "hash-1".to_string(),
293            agent_id: "ses-1".to_string(),
294            timestamp: 1_700_000_000,
295            public_key_hex: pk,
296            signature_hex: sig,
297        }
298    }
299
300    #[test]
301    fn payload_is_domain_prefixed_and_deterministic() {
302        let a = attestation_payload("codex", "m", "h", "a", 1);
303        let b = attestation_payload("codex", "m", "h", "a", 1);
304        assert_eq!(a, b);
305        assert!(a.starts_with("wm-record-attestation/v1|"));
306        assert_ne!(a, attestation_payload("codex", "m", "h", "a", 2));
307    }
308
309    #[test]
310    fn hkdf_attestation_subkey_is_domain_separated_and_verifies() {
311        let derived = derive_attestation_key_hex(TEST_KEY).expect("valid root material");
312        assert_eq!(derived.len(), 64);
313        assert_eq!(derive_attestation_key_hex(TEST_KEY).unwrap(), derived);
314
315        let payload = attestation_payload("codex", "mem-1", "hash-1", "ses-1", 1_700_000_000);
316        let (pk, sig) = sign_attestation_from_root(&payload, TEST_KEY).unwrap();
317        // Deterministic signing; distinct from the legacy hex-decode lineage.
318        assert_eq!(
319            sign_attestation_from_root(&payload, TEST_KEY).unwrap(),
320            (pk.clone(), sig.clone())
321        );
322        assert_ne!(pk, sign_attestation(&payload, TEST_KEY).unwrap().0);
323
324        let mut att = test_attestation();
325        att.public_key_hex = pk;
326        att.signature_hex = sig;
327        assert!(
328            verify_attestation(&att),
329            "a derived-lineage attestation must verify against its recorded pubkey"
330        );
331    }
332
333    #[test]
334    fn attestation_key_follows_the_canonical_root_convention() {
335        // 64-hex material is decoded before derivation (S9 §2.1 canonical
336        // form) — the raw-ASCII interpretation is NOT the root.
337        let canonical = derive_attestation_key_hex(TEST_KEY).unwrap();
338        let raw_lineage = hex_of(&hkdf32(TEST_KEY.as_bytes(), RECORD_ATTESTATION_INFO));
339        assert_ne!(
340            canonical, raw_lineage,
341            "64-hex root material must be hex-decoded, not read as ASCII"
342        );
343
344        // Attestations require canonical root material: non-hex input is an
345        // honest negative (the mesh identity alone tolerates legacy material).
346        assert!(derive_attestation_key_hex("short-legacy-test-key").is_none());
347    }
348
349    #[test]
350    fn sign_and_verify_roundtrip() {
351        assert!(verify_attestation(&test_attestation()));
352    }
353
354    #[test]
355    fn tampered_record_hash_rejected() {
356        let mut att = test_attestation();
357        att.record_hash = "forged".to_string();
358        assert!(!verify_attestation(&att));
359    }
360
361    #[test]
362    fn wrong_domain_rejected() {
363        let mut att = test_attestation();
364        att.domain = "mesh-heartbeat".to_string();
365        assert!(!verify_attestation(&att));
366    }
367
368    #[test]
369    fn bad_key_material_returns_none() {
370        let payload = attestation_payload("codex", "m", "h", "a", 1);
371        assert!(sign_attestation(&payload, "zz").is_none());
372        assert!(sign_attestation(&payload, "abcd").is_none());
373        assert!(sign_attestation(&payload, "").is_none());
374    }
375
376    #[test]
377    fn merkle_root_matches_karma_convention() {
378        // Empty set: sha256("").
379        assert_eq!(
380            merkle_root_hex(&[]),
381            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
382        );
383        // Single leaf: the leaf itself.
384        assert_eq!(merkle_root_hex(&["abc".to_string()]), "abc");
385        // Two leaves: sha256(a || b).
386        assert_eq!(
387            merkle_root_hex(&["a".to_string(), "b".to_string()]),
388            sha256_hex("ab")
389        );
390        // Three leaves: duplicate-last, then hash up.
391        let three = merkle_root_hex(&["a".to_string(), "b".to_string(), "c".to_string()]);
392        let level1 = [sha256_hex("ab"), sha256_hex("cc")];
393        assert_eq!(three, sha256_hex(&format!("{}{}", level1[0], level1[1])));
394        // Deterministic.
395        assert_eq!(
396            merkle_root_hex(&["x".to_string(), "y".to_string()]),
397            merkle_root_hex(&["x".to_string(), "y".to_string()])
398        );
399    }
400
401    #[test]
402    fn keys_are_structured() {
403        let id = MemoryId::nil();
404        let key = String::from_utf8(attestation_key(Galaxy::Codex, id)).unwrap();
405        assert!(key.starts_with("att:codex:"));
406        assert_eq!(String::from_utf8(attestation_prefix()).unwrap(), "att:");
407    }
408}