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