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::fmt;
21use std::path::Path;
22
23use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25
26use crate::identity::AgentId;
27use crate::state::atomic_write;
28
29#[derive(Debug)]
30pub struct PeerConflict(String);
31
32impl fmt::Display for PeerConflict {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(&self.0)
35    }
36}
37impl std::error::Error for PeerConflict {}
38
39/// The on-disk form of one peer. Extra fields (e.g. a legacy `"may"`) are
40/// ignored, so files written by older versions still parse.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42struct RawPeer {
43    key: String,
44}
45
46/// One resolved peer: its authenticated identity and local petname.
47#[derive(Debug, Clone)]
48pub struct Peer {
49    pub petname: String,
50    pub id: AgentId,
51}
52
53/// The whole allowlist. Parsing validates every key up front, so a malformed
54/// `peers.json` fails loudly at startup rather than silently at message time.
55#[derive(Debug, Clone, Default)]
56pub struct Policy {
57    peers: Vec<Peer>,
58}
59
60impl Policy {
61    pub fn load(path: &Path) -> Result<Self> {
62        let raw = std::fs::read_to_string(path)
63            .with_context(|| format!("reading peers file {}", path.display()))?;
64        Self::parse(&raw)
65    }
66
67    pub fn parse(raw: &str) -> Result<Self> {
68        let map: BTreeMap<String, RawPeer> =
69            serde_json::from_str(raw).context("peers file is not valid policy JSON")?;
70        let mut peers = Vec::with_capacity(map.len());
71        for (petname, rp) in map {
72            let id = AgentId::from_b64(&rp.key)
73                .with_context(|| format!("peer '{petname}' has an invalid key"))?;
74            peers.push(Peer { petname, id });
75        }
76        // Two petnames for one key is almost certainly a mistake and makes the
77        // authenticated-sender lookup ambiguous.
78        for i in 0..peers.len() {
79            for j in (i + 1)..peers.len() {
80                if peers[i].id == peers[j].id {
81                    bail!(
82                        "peers '{}' and '{}' share the same key",
83                        peers[i].petname,
84                        peers[j].petname
85                    );
86                }
87            }
88        }
89        Ok(Self { peers })
90    }
91
92    /// Look up an *authenticated* sender. `None` ⇒ not on the allowlist ⇒ drop.
93    pub fn peer(&self, id: AgentId) -> Option<&Peer> {
94        self.peers.iter().find(|p| p.id == id)
95    }
96
97    /// Resolve a petname to a key for outbound sends.
98    pub fn resolve(&self, petname: &str) -> Result<AgentId> {
99        self.peers
100            .iter()
101            .find(|p| p.petname == petname)
102            .map(|p| p.id)
103            .with_context(|| format!("unknown peer '{petname}'"))
104    }
105
106    pub fn len(&self) -> usize {
107        self.peers.len()
108    }
109
110    pub fn is_empty(&self) -> bool {
111        self.peers.is_empty()
112    }
113
114    /// All peers, for listing.
115    pub fn peers(&self) -> &[Peer] {
116        &self.peers
117    }
118
119    /// Admit a peer (or no-op if the same petname→key pair is already present).
120    /// Rejects a petname already bound to a *different* key, or a key already
121    /// held under a *different* petname — either would make the authenticated
122    /// sender lookup ambiguous, exactly as [`Policy::parse`] guards against.
123    pub fn add(&mut self, petname: &str, key_b64: &str) -> Result<()> {
124        let id = AgentId::from_b64(key_b64)
125            .with_context(|| format!("peer '{petname}' has an invalid key"))?;
126        if let Some(other) = self
127            .peers
128            .iter()
129            .find(|p| p.id == id && p.petname != petname)
130        {
131            return Err(PeerConflict(format!(
132                "that key is already authorized as '{}'",
133                other.petname
134            ))
135            .into());
136        }
137        match self.peers.iter().find(|p| p.petname == petname) {
138            // Exact name→key match already present: idempotent no-op. A different key
139            // under this petname is rejected, not silently rekeyed — otherwise a
140            // pairing message claiming an existing peer's name would repoint trust.
141            Some(existing) if existing.id == id => {}
142            Some(existing) => return Err(PeerConflict(format!(
143                "petname '{petname}' is already authorized under a different key ({}); remove it first to reassign", existing.id.to_b64()
144            )).into()),
145            None => self.peers.push(Peer {
146                petname: petname.to_string(),
147                id,
148            }),
149        }
150        Ok(())
151    }
152
153    /// Remove a peer by petname; returns whether one was removed.
154    pub fn remove(&mut self, petname: &str) -> bool {
155        let before = self.peers.len();
156        self.peers.retain(|p| p.petname != petname);
157        self.peers.len() != before
158    }
159
160    /// Serialize back to the `peers.json` object form.
161    pub fn to_json(&self) -> Result<String> {
162        let map: BTreeMap<String, RawPeer> = self
163            .peers
164            .iter()
165            .map(|p| (p.petname.clone(), RawPeer { key: p.id.to_b64() }))
166            .collect();
167        serde_json::to_string_pretty(&map).context("serializing peers")
168    }
169
170    /// Persist the current allowlist to `path`.
171    pub fn save(&self, path: &Path) -> Result<()> {
172        let mut json = self.to_json()?;
173        json.push('\n');
174        atomic_write(path, json.as_bytes())?;
175        Ok(())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::identity::AgentKey;
183
184    fn policy_with_key(k: &AgentKey) -> Policy {
185        let raw = format!(r#"{{ "alice": {{ "key": "{}" }} }}"#, k.id().to_b64());
186        Policy::parse(&raw).unwrap()
187    }
188
189    #[test]
190    fn listed_key_resolves_to_its_petname() {
191        let k = AgentKey::generate().unwrap();
192        let p = policy_with_key(&k);
193        assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
194    }
195
196    #[test]
197    fn legacy_may_field_is_ignored() {
198        // Files written by older versions carried a `"may"`; they must still load.
199        let k = AgentKey::generate().unwrap();
200        let raw = format!(
201            r#"{{ "alice": {{ "key": "{}", "may": "*" }} }}"#,
202            k.id().to_b64()
203        );
204        let p = Policy::parse(&raw).unwrap();
205        assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
206    }
207
208    #[test]
209    fn unlisted_sender_is_denied() {
210        let k = AgentKey::generate().unwrap();
211        let p = policy_with_key(&k);
212        let stranger = AgentKey::generate().unwrap();
213        assert!(
214            p.peer(stranger.id()).is_none(),
215            "unlisted key must not resolve"
216        );
217    }
218
219    #[test]
220    fn invalid_key_fails_at_parse_time() {
221        let raw = r#"{ "alice": { "key": "not-a-real-key" } }"#;
222        assert!(Policy::parse(raw).is_err());
223    }
224
225    #[test]
226    fn duplicate_keys_are_rejected() {
227        let k = AgentKey::generate().unwrap();
228        let raw = format!(
229            r#"{{ "a": {{ "key": "{0}" }}, "b": {{ "key": "{0}" }} }}"#,
230            k.id().to_b64()
231        );
232        assert!(Policy::parse(&raw).is_err());
233    }
234
235    #[test]
236    fn resolve_maps_petname_to_key() {
237        let k = AgentKey::generate().unwrap();
238        let p = policy_with_key(&k);
239        assert_eq!(p.resolve("alice").unwrap(), k.id());
240        assert!(p.resolve("nobody").is_err());
241    }
242
243    #[test]
244    fn add_then_resolve_and_persist_round_trip() {
245        let k = AgentKey::generate().unwrap();
246        let mut p = Policy::default();
247        p.add("desktop", &k.id().to_b64()).unwrap();
248        assert_eq!(p.resolve("desktop").unwrap(), k.id());
249        let reparsed = Policy::parse(&p.to_json().unwrap()).unwrap();
250        assert_eq!(reparsed.resolve("desktop").unwrap(), k.id());
251    }
252
253    #[test]
254    fn add_same_petname_is_idempotent() {
255        let k = AgentKey::generate().unwrap();
256        let mut p = Policy::default();
257        p.add("bot", &k.id().to_b64()).unwrap();
258        p.add("bot", &k.id().to_b64()).unwrap();
259        assert_eq!(p.len(), 1, "same petname does not duplicate");
260    }
261
262    #[test]
263    fn add_refuses_to_rekey_an_existing_petname() {
264        // A pairing message must not be able to repoint a trusted petname to a new key.
265        let old = AgentKey::generate().unwrap();
266        let attacker = AgentKey::generate().unwrap();
267        let mut p = Policy::default();
268        p.add("laptop", &old.id().to_b64()).unwrap();
269        assert!(
270            p.add("laptop", &attacker.id().to_b64()).is_err(),
271            "an existing petname must not be silently rekeyed"
272        );
273        assert_eq!(
274            p.resolve("laptop").unwrap(),
275            old.id(),
276            "the original binding is preserved"
277        );
278    }
279
280    #[test]
281    fn add_rejects_key_under_a_second_petname() {
282        let k = AgentKey::generate().unwrap();
283        let mut p = Policy::default();
284        p.add("first", &k.id().to_b64()).unwrap();
285        assert!(
286            p.add("second", &k.id().to_b64()).is_err(),
287            "one key must not get two petnames"
288        );
289    }
290
291    #[test]
292    fn add_rejects_invalid_key() {
293        let mut p = Policy::default();
294        assert!(p.add("x", "not-a-key").is_err());
295    }
296
297    #[test]
298    fn remove_reports_whether_it_removed() {
299        let k = AgentKey::generate().unwrap();
300        let mut p = Policy::default();
301        p.add("gone", &k.id().to_b64()).unwrap();
302        assert!(p.remove("gone"));
303        assert!(!p.remove("gone"), "already absent");
304        assert!(p.is_empty());
305    }
306}