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().find(|p| p.petname == petname) {
122            // Exact name→key match already present: idempotent no-op. A different key
123            // under this petname is rejected, not silently rekeyed — otherwise a
124            // pairing message claiming an existing peer's name would repoint trust.
125            Some(existing) if existing.id == id => {}
126            Some(existing) => bail!(
127                "petname '{petname}' is already authorized under a different key ({}); \
128                 remove it first to reassign",
129                existing.id.to_b64()
130            ),
131            None => self.peers.push(Peer {
132                petname: petname.to_string(),
133                id,
134            }),
135        }
136        Ok(())
137    }
138
139    /// Remove a peer by petname; returns whether one was removed.
140    pub fn remove(&mut self, petname: &str) -> bool {
141        let before = self.peers.len();
142        self.peers.retain(|p| p.petname != petname);
143        self.peers.len() != before
144    }
145
146    /// Serialize back to the `peers.json` object form.
147    pub fn to_json(&self) -> Result<String> {
148        let map: BTreeMap<String, RawPeer> = self
149            .peers
150            .iter()
151            .map(|p| (p.petname.clone(), RawPeer { key: p.id.to_b64() }))
152            .collect();
153        serde_json::to_string_pretty(&map).context("serializing peers")
154    }
155
156    /// Persist the current allowlist to `path`.
157    pub fn save(&self, path: &Path) -> Result<()> {
158        let mut json = self.to_json()?;
159        json.push('\n');
160        std::fs::write(path, json).with_context(|| format!("writing {}", path.display()))?;
161        Ok(())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::identity::AgentKey;
169
170    fn policy_with_key(k: &AgentKey) -> Policy {
171        let raw = format!(r#"{{ "alice": {{ "key": "{}" }} }}"#, k.id().to_b64());
172        Policy::parse(&raw).unwrap()
173    }
174
175    #[test]
176    fn listed_key_resolves_to_its_petname() {
177        let k = AgentKey::generate().unwrap();
178        let p = policy_with_key(&k);
179        assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
180    }
181
182    #[test]
183    fn legacy_may_field_is_ignored() {
184        // Files written by older versions carried a `"may"`; they must still load.
185        let k = AgentKey::generate().unwrap();
186        let raw = format!(
187            r#"{{ "alice": {{ "key": "{}", "may": "*" }} }}"#,
188            k.id().to_b64()
189        );
190        let p = Policy::parse(&raw).unwrap();
191        assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
192    }
193
194    #[test]
195    fn unlisted_sender_is_denied() {
196        let k = AgentKey::generate().unwrap();
197        let p = policy_with_key(&k);
198        let stranger = AgentKey::generate().unwrap();
199        assert!(
200            p.peer(stranger.id()).is_none(),
201            "unlisted key must not resolve"
202        );
203    }
204
205    #[test]
206    fn invalid_key_fails_at_parse_time() {
207        let raw = r#"{ "alice": { "key": "not-a-real-key" } }"#;
208        assert!(Policy::parse(raw).is_err());
209    }
210
211    #[test]
212    fn duplicate_keys_are_rejected() {
213        let k = AgentKey::generate().unwrap();
214        let raw = format!(
215            r#"{{ "a": {{ "key": "{0}" }}, "b": {{ "key": "{0}" }} }}"#,
216            k.id().to_b64()
217        );
218        assert!(Policy::parse(&raw).is_err());
219    }
220
221    #[test]
222    fn resolve_maps_petname_to_key() {
223        let k = AgentKey::generate().unwrap();
224        let p = policy_with_key(&k);
225        assert_eq!(p.resolve("alice").unwrap(), k.id());
226        assert!(p.resolve("nobody").is_err());
227    }
228
229    #[test]
230    fn add_then_resolve_and_persist_round_trip() {
231        let k = AgentKey::generate().unwrap();
232        let mut p = Policy::default();
233        p.add("desktop", &k.id().to_b64()).unwrap();
234        assert_eq!(p.resolve("desktop").unwrap(), k.id());
235        let reparsed = Policy::parse(&p.to_json().unwrap()).unwrap();
236        assert_eq!(reparsed.resolve("desktop").unwrap(), k.id());
237    }
238
239    #[test]
240    fn add_same_petname_is_idempotent() {
241        let k = AgentKey::generate().unwrap();
242        let mut p = Policy::default();
243        p.add("bot", &k.id().to_b64()).unwrap();
244        p.add("bot", &k.id().to_b64()).unwrap();
245        assert_eq!(p.len(), 1, "same petname does not duplicate");
246    }
247
248    #[test]
249    fn add_refuses_to_rekey_an_existing_petname() {
250        // A pairing message must not be able to repoint a trusted petname to a new key.
251        let old = AgentKey::generate().unwrap();
252        let attacker = AgentKey::generate().unwrap();
253        let mut p = Policy::default();
254        p.add("laptop", &old.id().to_b64()).unwrap();
255        assert!(
256            p.add("laptop", &attacker.id().to_b64()).is_err(),
257            "an existing petname must not be silently rekeyed"
258        );
259        assert_eq!(
260            p.resolve("laptop").unwrap(),
261            old.id(),
262            "the original binding is preserved"
263        );
264    }
265
266    #[test]
267    fn add_rejects_key_under_a_second_petname() {
268        let k = AgentKey::generate().unwrap();
269        let mut p = Policy::default();
270        p.add("first", &k.id().to_b64()).unwrap();
271        assert!(
272            p.add("second", &k.id().to_b64()).is_err(),
273            "one key must not get two petnames"
274        );
275    }
276
277    #[test]
278    fn add_rejects_invalid_key() {
279        let mut p = Policy::default();
280        assert!(p.add("x", "not-a-key").is_err());
281    }
282
283    #[test]
284    fn remove_reports_whether_it_removed() {
285        let k = AgentKey::generate().unwrap();
286        let mut p = Policy::default();
287        p.add("gone", &k.id().to_b64()).unwrap();
288        assert!(p.remove("gone"));
289        assert!(!p.remove("gone"), "already absent");
290        assert!(p.is_empty());
291    }
292}