1use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34use wm_core::Galaxy;
35
36use crate::memory::MemoryId;
37
38pub const ATTESTATION_DOMAIN: &str = "wm-record-attestation/v1";
42
43pub const ATTESTATIONS_DB: &str = "attestations";
45
46pub const ATTESTATION_KEY_ENV: &str = "WM_MESH_KEY";
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct RecordAttestation {
54 pub domain: String,
56 pub galaxy: String,
58 pub memory_id: String,
60 pub record_hash: String,
62 pub agent_id: String,
65 pub timestamp: u64,
67 pub public_key_hex: String,
69 pub signature_hex: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct AttestationReport {
76 pub attested: bool,
78 pub signature_valid: bool,
80 pub matches_head: bool,
84 pub memory_present: bool,
88 pub breaks: Vec<String>,
90}
91
92#[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#[must_use]
107pub fn attestation_key(galaxy: Galaxy, id: MemoryId) -> Vec<u8> {
108 format!("att:{}:{}", galaxy.db_name(), id).into_bytes()
109}
110
111#[must_use]
113pub fn attestation_prefix() -> Vec<u8> {
114 b"att:".to_vec()
115}
116
117#[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
129fn 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#[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#[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#[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#[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 assert_eq!(
312 merkle_root_hex(&[]),
313 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
314 );
315 assert_eq!(merkle_root_hex(&["abc".to_string()]), "abc");
317 assert_eq!(
319 merkle_root_hex(&["a".to_string(), "b".to_string()]),
320 sha256_hex("ab")
321 );
322 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 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}