Skip to main content

mur_common/
snapshot_request.rs

1//! Signed snapshot-pull request — the agent-side half of the memory-federation
2//! pull leg (spec: docs/superpowers/specs/2026-08-04-unified-memory-federation.md).
3//! An agent runtime writes one (YAML, tmp+rename) into
4//! `<mur_home>/inbox/snapshot-requests/`; the daemon verifies it against the
5//! agent's on-disk pubkey and assembles the snapshot central-side.
6//! Canonical sign-input EXCLUDES `sig` (v3d ChannelEvent precedent).
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::identity::{AgentIdentity, verify_bytes};
12
13/// File-drop directory, relative to the MUR home.
14pub const SNAPSHOT_REQUEST_DIR: &str = "inbox/snapshot-requests";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SnapshotRequest {
18    pub agent: String,
19    pub requested_at: DateTime<Utc>,
20    /// Key-rotation version; 0 = initial identity key. Recorded for forward
21    /// compatibility — P0 verifies against the CURRENT pubkey only.
22    #[serde(default)]
23    pub key_version: u32,
24    /// Multibase (Base58Btc) Ed25519 signature over the canonical sign-input.
25    pub sig: String,
26}
27
28/// Canonical signed bytes: domain tag + fields, `sig` excluded.
29fn sign_input(agent: &str, requested_at: &DateTime<Utc>, key_version: u32) -> Vec<u8> {
30    format!(
31        "mur-snapshot-request-v1\n{agent}\n{}\n{key_version}",
32        requested_at.to_rfc3339()
33    )
34    .into_bytes()
35}
36
37impl SnapshotRequest {
38    pub fn create(agent: &str, identity: &AgentIdentity, now: DateTime<Utc>) -> Self {
39        let input = sign_input(agent, &now, 0);
40        Self {
41            agent: agent.to_string(),
42            requested_at: now,
43            key_version: 0,
44            sig: identity.sign_multibase(&input),
45        }
46    }
47
48    /// Fail-closed signature check against `pubkey`.
49    pub fn verify(&self, pubkey: &[u8; 32]) -> bool {
50        let input = sign_input(&self.agent, &self.requested_at, self.key_version);
51        verify_bytes(pubkey, &input, &self.sig)
52    }
53
54    /// Inside the acceptance window? Blunts replay; not a nonce store —
55    /// consuming the request file on processing is the other half.
56    pub fn is_fresh(&self, now: DateTime<Utc>, max_age_secs: u64) -> bool {
57        let age = now.signed_duration_since(self.requested_at);
58        age >= chrono::Duration::zero() && age <= chrono::Duration::seconds(max_age_secs as i64)
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    fn identity() -> AgentIdentity {
67        AgentIdentity::generate()
68    }
69
70    #[test]
71    fn sign_verify_roundtrip() {
72        let id = identity();
73        let req = SnapshotRequest::create("dr_worker_4", &id, Utc::now());
74        assert!(req.verify(&id.verifying_key_bytes()));
75    }
76
77    #[test]
78    fn tampered_agent_name_fails_verification() {
79        let id = identity();
80        let mut req = SnapshotRequest::create("dr_worker_4", &id, Utc::now());
81        req.agent = "dr_worker_1".into(); // impersonation attempt
82        assert!(!req.verify(&id.verifying_key_bytes()));
83    }
84
85    #[test]
86    fn wrong_key_fails_verification() {
87        let req = SnapshotRequest::create("a", &identity(), Utc::now());
88        assert!(!req.verify(&identity().verifying_key_bytes()));
89    }
90
91    #[test]
92    fn freshness_window_rejects_old_and_future() {
93        let id = identity();
94        let now = Utc::now();
95        let req = SnapshotRequest::create("a", &id, now);
96        assert!(req.is_fresh(now, 600));
97        assert!(!req.is_fresh(now + chrono::Duration::seconds(601), 600)); // stale
98        assert!(!req.is_fresh(now - chrono::Duration::seconds(1), 600)); // future-dated
99    }
100
101    #[test]
102    fn yaml_roundtrip_preserves_signature() {
103        let id = identity();
104        let req = SnapshotRequest::create("a", &id, Utc::now());
105        let yaml = serde_yaml_ng::to_string(&req).unwrap();
106        let back: SnapshotRequest = serde_yaml_ng::from_str(&yaml).unwrap();
107        assert!(back.verify(&id.verifying_key_bytes()));
108    }
109}