Skip to main content

interlink/
policy.rs

1//! Per-peer authorization policy: `peers.json`.
2//!
3//! A peer is a **public key** with a local petname. Being on the list means the
4//! peer is *admitted*: it is a trusted chat partner, and its messages are handled
5//! inline. The **default for an unlisted peer is deny-everything**, so the safe
6//! state is the fallback.
7//!
8//! ```json
9//! {
10//!   "my-laptop":  { "key": "8Emom3…" },
11//!   "my-desktop": { "key": "rq2AzH…" }
12//! }
13//! ```
14//!
15//! Admission is all-or-nothing: interlink is a *chat* between mutually trusted
16//! agents, not a sandbox for a semi-trusted one. (A legacy `"may"` field from
17//! older files is accepted and ignored, so those files still load.)
18
19use std::collections::BTreeMap;
20use std::path::Path;
21
22use anyhow::{Context, Result, bail};
23use serde::{Deserialize, Serialize};
24
25use crate::identity::AgentId;
26
27/// The on-disk form of one peer. Extra fields (e.g. a legacy `"may"`) are
28/// ignored, so files written by older versions still parse.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30struct RawPeer {
31    key: String,
32}
33
34/// One resolved peer: its authenticated identity and local petname.
35#[derive(Debug, Clone)]
36pub struct Peer {
37    pub petname: String,
38    pub id: AgentId,
39}
40
41/// The whole allowlist. Parsing validates every key up front, so a malformed
42/// `peers.json` fails loudly at startup rather than silently at message time.
43#[derive(Debug, Clone, Default)]
44pub struct Policy {
45    peers: Vec<Peer>,
46}
47
48impl Policy {
49    pub fn load(path: &Path) -> Result<Self> {
50        let raw = std::fs::read_to_string(path)
51            .with_context(|| format!("reading peers file {}", path.display()))?;
52        Self::parse(&raw)
53    }
54
55    pub fn parse(raw: &str) -> Result<Self> {
56        let map: BTreeMap<String, RawPeer> =
57            serde_json::from_str(raw).context("peers file is not valid policy JSON")?;
58        let mut peers = Vec::with_capacity(map.len());
59        for (petname, rp) in map {
60            let id = AgentId::from_b64(&rp.key)
61                .with_context(|| format!("peer '{petname}' has an invalid key"))?;
62            peers.push(Peer { petname, id });
63        }
64        // Two petnames for one key is almost certainly a mistake and makes the
65        // authenticated-sender lookup ambiguous.
66        for i in 0..peers.len() {
67            for j in (i + 1)..peers.len() {
68                if peers[i].id == peers[j].id {
69                    bail!(
70                        "peers '{}' and '{}' share the same key",
71                        peers[i].petname,
72                        peers[j].petname
73                    );
74                }
75            }
76        }
77        Ok(Self { peers })
78    }
79
80    /// Look up an *authenticated* sender. `None` ⇒ not on the allowlist ⇒ drop.
81    pub fn peer(&self, id: AgentId) -> Option<&Peer> {
82        self.peers.iter().find(|p| p.id == id)
83    }
84
85    /// Resolve a petname to a key for outbound sends.
86    pub fn resolve(&self, petname: &str) -> Result<AgentId> {
87        self.peers
88            .iter()
89            .find(|p| p.petname == petname)
90            .map(|p| p.id)
91            .with_context(|| format!("unknown peer '{petname}'"))
92    }
93
94    pub fn len(&self) -> usize {
95        self.peers.len()
96    }
97
98    pub fn is_empty(&self) -> bool {
99        self.peers.is_empty()
100    }
101
102    /// All peers, for listing.
103    pub fn peers(&self) -> &[Peer] {
104        &self.peers
105    }
106
107    /// Admit a peer (or no-op if the same petname→key pair is already present).
108    /// Rejects a petname already bound to a *different* key, or a key already
109    /// held under a *different* petname — either would make the authenticated
110    /// sender lookup ambiguous, exactly as [`Policy::parse`] guards against.
111    pub fn add(&mut self, petname: &str, key_b64: &str) -> Result<()> {
112        let id = AgentId::from_b64(key_b64)
113            .with_context(|| format!("peer '{petname}' has an invalid key"))?;
114        if let Some(other) = self
115            .peers
116            .iter()
117            .find(|p| p.id == id && p.petname != petname)
118        {
119            bail!("that key is already authorized as '{}'", other.petname);
120        }
121        match self.peers.iter_mut().find(|p| p.petname == petname) {
122            Some(existing) => existing.id = id,
123            None => self.peers.push(Peer {
124                petname: petname.to_string(),
125                id,
126            }),
127        }
128        Ok(())
129    }
130
131    /// Remove a peer by petname; returns whether one was removed.
132    pub fn remove(&mut self, petname: &str) -> bool {
133        let before = self.peers.len();
134        self.peers.retain(|p| p.petname != petname);
135        self.peers.len() != before
136    }
137
138    /// Serialize back to the `peers.json` object form.
139    pub fn to_json(&self) -> Result<String> {
140        let map: BTreeMap<String, RawPeer> = self
141            .peers
142            .iter()
143            .map(|p| (p.petname.clone(), RawPeer { key: p.id.to_b64() }))
144            .collect();
145        serde_json::to_string_pretty(&map).context("serializing peers")
146    }
147
148    /// Persist the current allowlist to `path`.
149    pub fn save(&self, path: &Path) -> Result<()> {
150        let mut json = self.to_json()?;
151        json.push('\n');
152        std::fs::write(path, json).with_context(|| format!("writing {}", path.display()))?;
153        Ok(())
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::identity::AgentKey;
161
162    fn policy_with_key(k: &AgentKey) -> Policy {
163        let raw = format!(r#"{{ "alice": {{ "key": "{}" }} }}"#, k.id().to_b64());
164        Policy::parse(&raw).unwrap()
165    }
166
167    #[test]
168    fn listed_key_resolves_to_its_petname() {
169        let k = AgentKey::generate().unwrap();
170        let p = policy_with_key(&k);
171        assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
172    }
173
174    #[test]
175    fn legacy_may_field_is_ignored() {
176        // Files written by older versions carried a `"may"`; they must still load.
177        let k = AgentKey::generate().unwrap();
178        let raw = format!(
179            r#"{{ "alice": {{ "key": "{}", "may": "*" }} }}"#,
180            k.id().to_b64()
181        );
182        let p = Policy::parse(&raw).unwrap();
183        assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
184    }
185
186    #[test]
187    fn unlisted_sender_is_denied() {
188        let k = AgentKey::generate().unwrap();
189        let p = policy_with_key(&k);
190        let stranger = AgentKey::generate().unwrap();
191        assert!(
192            p.peer(stranger.id()).is_none(),
193            "unlisted key must not resolve"
194        );
195    }
196
197    #[test]
198    fn invalid_key_fails_at_parse_time() {
199        let raw = r#"{ "alice": { "key": "not-a-real-key" } }"#;
200        assert!(Policy::parse(raw).is_err());
201    }
202
203    #[test]
204    fn duplicate_keys_are_rejected() {
205        let k = AgentKey::generate().unwrap();
206        let raw = format!(
207            r#"{{ "a": {{ "key": "{0}" }}, "b": {{ "key": "{0}" }} }}"#,
208            k.id().to_b64()
209        );
210        assert!(Policy::parse(&raw).is_err());
211    }
212
213    #[test]
214    fn resolve_maps_petname_to_key() {
215        let k = AgentKey::generate().unwrap();
216        let p = policy_with_key(&k);
217        assert_eq!(p.resolve("alice").unwrap(), k.id());
218        assert!(p.resolve("nobody").is_err());
219    }
220
221    #[test]
222    fn add_then_resolve_and_persist_round_trip() {
223        let k = AgentKey::generate().unwrap();
224        let mut p = Policy::default();
225        p.add("desktop", &k.id().to_b64()).unwrap();
226        assert_eq!(p.resolve("desktop").unwrap(), k.id());
227        let reparsed = Policy::parse(&p.to_json().unwrap()).unwrap();
228        assert_eq!(reparsed.resolve("desktop").unwrap(), k.id());
229    }
230
231    #[test]
232    fn add_same_petname_is_idempotent() {
233        let k = AgentKey::generate().unwrap();
234        let mut p = Policy::default();
235        p.add("bot", &k.id().to_b64()).unwrap();
236        p.add("bot", &k.id().to_b64()).unwrap();
237        assert_eq!(p.len(), 1, "same petname does not duplicate");
238    }
239
240    #[test]
241    fn add_rejects_key_under_a_second_petname() {
242        let k = AgentKey::generate().unwrap();
243        let mut p = Policy::default();
244        p.add("first", &k.id().to_b64()).unwrap();
245        assert!(
246            p.add("second", &k.id().to_b64()).is_err(),
247            "one key must not get two petnames"
248        );
249    }
250
251    #[test]
252    fn add_rejects_invalid_key() {
253        let mut p = Policy::default();
254        assert!(p.add("x", "not-a-key").is_err());
255    }
256
257    #[test]
258    fn remove_reports_whether_it_removed() {
259        let k = AgentKey::generate().unwrap();
260        let mut p = Policy::default();
261        p.add("gone", &k.id().to_b64()).unwrap();
262        assert!(p.remove("gone"));
263        assert!(!p.remove("gone"), "already absent");
264        assert!(p.is_empty());
265    }
266}