1use 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
40pub const ATTESTATION_DOMAIN: &str = "wm-record-attestation/v1";
44
45pub const ATTESTATIONS_DB: &str = "attestations";
47
48pub const ATTESTATION_KEY_ENV: &str = "WM_MESH_KEY";
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct RecordAttestation {
56 pub domain: String,
58 pub galaxy: String,
60 pub memory_id: String,
62 pub record_hash: String,
64 pub agent_id: String,
67 pub timestamp: u64,
69 pub public_key_hex: String,
71 pub signature_hex: String,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct AttestationReport {
78 pub attested: bool,
80 pub signature_valid: bool,
82 pub matches_head: bool,
86 pub memory_present: bool,
90 pub breaks: Vec<String>,
92}
93
94#[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#[must_use]
109pub fn attestation_key(galaxy: Galaxy, id: MemoryId) -> Vec<u8> {
110 format!("att:{}:{}", galaxy.db_name(), id).into_bytes()
111}
112
113#[must_use]
115pub fn attestation_prefix() -> Vec<u8> {
116 b"att:".to_vec()
117}
118
119#[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
131fn 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#[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#[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#[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#[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#[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#[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 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 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 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 assert_eq!(
380 merkle_root_hex(&[]),
381 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
382 );
383 assert_eq!(merkle_root_hex(&["abc".to_string()]), "abc");
385 assert_eq!(
387 merkle_root_hex(&["a".to_string(), "b".to_string()]),
388 sha256_hex("ab")
389 );
390 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 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}