Skip to main content

wire/
blocklist.rs

1//! RFC-001 Security §T16 — per-peer block-list (rogue / compromised org admin
2//! containment).
3//!
4//! `ORG_VERIFIED` lets an org admin vouch a peer into every org-mate's inbox
5//! with no per-receiver gate (and, under Option-A auto-pair, no operator tap).
6//! T16's mitigation is a **local** kill switch: `wire block-peer <did>` removes
7//! a single peer from this receiver's locally-effective roster *without leaving
8//! the org*. A blocked DID can never be org-auto-pinned or surface an
9//! org-notify prompt; the inbound pair attempt is dropped silently (no
10//! fingerprintable response).
11//!
12//! Scope of a block is a **DID prefix-free exact match** on whichever DID the
13//! operator names:
14//!   - block a **session DID** (`did:wire:<handle>-<8hex>`) → mutes that one
15//!     session;
16//!   - block an **operator DID** (`did:wire:op:<handle>-<32hex>`) → mutes every
17//!     session that carries that `op_did` (the T16 intent: cut off the single
18//!     adversary the rogue admin injected, across all their sessions).
19//!
20//! **Fail-safe.** A missing file loads as the empty block-list (nothing
21//! blocked — the common case). A *malformed* file also loads empty but logs a
22//! warning: a corrupt block-list must not wedge the daemon, and erring toward
23//! "not blocked" matches the rest of wire's trust surface (block-list is
24//! defense-in-depth on top of the per-org opt-in, never the only gate). The
25//! block decision is consulted at the org-easing path only; bilateral SAS
26//! (`VERIFIED`) is an explicit operator gesture that is out of scope here — if
27//! you SAS-pair a peer you blocked, that deliberate act wins (see
28//! `wire block-peer --help`).
29
30use crate::agent_card::{self, AgentCard};
31use anyhow::Result;
32use serde_json::{Value, json};
33use std::collections::BTreeMap;
34use std::path::Path;
35use time::OffsetDateTime;
36use time::format_description::well_known::Rfc3339;
37
38const FILE: &str = "blocklist.json";
39
40/// One block-list entry: when it was added + an optional operator note.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct BlockEntry {
43    pub at: String,
44    pub note: Option<String>,
45}
46
47/// File-backed per-peer block-list. Maps a DID → entry. Absence = not blocked.
48#[derive(Debug, Clone, Default)]
49pub struct Blocklist {
50    blocked: BTreeMap<String, BlockEntry>,
51}
52
53impl Blocklist {
54    /// Load from `config/wire/blocklist.json`. Missing → empty. Malformed →
55    /// empty + a warning (fail-safe: never wedge, never silently lose a real
56    /// block without saying so).
57    pub fn load() -> Self {
58        match crate::config::config_dir() {
59            Ok(dir) => Self::load_path(&dir.join(FILE)),
60            Err(_) => Self::default(),
61        }
62    }
63
64    /// Load from an explicit path (testable).
65    pub fn load_path(path: &Path) -> Self {
66        let Ok(bytes) = std::fs::read(path) else {
67            return Self::default();
68        };
69        let Ok(json) = serde_json::from_slice::<Value>(&bytes) else {
70            eprintln!(
71                "wire: blocklist at {path:?} is malformed JSON — treating as empty \
72                 (no peers blocked). Fix or remove the file to restore your blocks."
73            );
74            return Self::default();
75        };
76        let mut blocked = BTreeMap::new();
77        if let Some(map) = json.get("blocked").and_then(|v| v.as_object()) {
78            for (did, entry) in map {
79                let at = entry
80                    .get("at")
81                    .and_then(Value::as_str)
82                    .unwrap_or_default()
83                    .to_string();
84                let note = entry
85                    .get("note")
86                    .and_then(Value::as_str)
87                    .map(str::to_string);
88                blocked.insert(did.clone(), BlockEntry { at, note });
89            }
90        }
91        Self { blocked }
92    }
93
94    /// Block a DID (idempotent: re-blocking refreshes the note, keeps `at`).
95    /// Returns `true` if this is a newly-added block, `false` if already present.
96    pub fn block(&mut self, did: &str, note: Option<String>) -> bool {
97        match self.blocked.get_mut(did) {
98            Some(existing) => {
99                if note.is_some() {
100                    existing.note = note;
101                }
102                false
103            }
104            None => {
105                self.blocked.insert(
106                    did.to_string(),
107                    BlockEntry {
108                        at: now_iso(),
109                        note,
110                    },
111                );
112                true
113            }
114        }
115    }
116
117    /// Remove a DID from the block-list. Returns `true` if it was present.
118    pub fn unblock(&mut self, did: &str) -> bool {
119        self.blocked.remove(did).is_some()
120    }
121
122    /// Is this exact DID blocked?
123    pub fn is_blocked(&self, did: &str) -> bool {
124        self.blocked.contains_key(did)
125    }
126
127    /// Does this card belong to a blocked peer? Checks both the session DID and
128    /// the operator DID (`op_did`) the card carries, so blocking an operator
129    /// cuts off all of their sessions. Returns the matched DID for diagnostics.
130    pub fn blocks_card<'c>(&self, card: &'c AgentCard) -> Option<&'c str> {
131        let session_did = card.get("did").and_then(Value::as_str);
132        if let Some(d) = session_did
133            && self.is_blocked(d)
134        {
135            return Some(d);
136        }
137        if let Some(op_did) = agent_card::card_op_did(card)
138            && self.is_blocked(op_did)
139        {
140            return Some(op_did);
141        }
142        None
143    }
144
145    /// Iterate entries (sorted by DID via the `BTreeMap`), for `wire blocked`.
146    pub fn entries(&self) -> impl Iterator<Item = (&String, &BlockEntry)> {
147        self.blocked.iter()
148    }
149
150    pub fn len(&self) -> usize {
151        self.blocked.len()
152    }
153
154    pub fn is_empty(&self) -> bool {
155        self.blocked.is_empty()
156    }
157
158    /// Persist to `config/wire/blocklist.json`.
159    pub fn save(&self) -> Result<()> {
160        let dir = crate::config::config_dir()?;
161        std::fs::create_dir_all(&dir)?;
162        self.save_path(&dir.join(FILE))?;
163        Ok(())
164    }
165
166    /// Persist to an explicit path (testable).
167    pub fn save_path(&self, path: &Path) -> std::io::Result<()> {
168        std::fs::write(path, self.to_json())
169    }
170
171    fn to_json(&self) -> String {
172        let blocked: serde_json::Map<String, Value> = self
173            .blocked
174            .iter()
175            .map(|(did, e)| {
176                let mut obj = json!({ "at": e.at });
177                if let Some(note) = &e.note {
178                    obj["note"] = json!(note);
179                }
180                (did.clone(), obj)
181            })
182            .collect();
183        serde_json::to_string_pretty(&json!({ "version": 1, "blocked": blocked }))
184            .unwrap_or_else(|_| "{}".into())
185    }
186}
187
188fn now_iso() -> String {
189    OffsetDateTime::now_utc()
190        .format(&Rfc3339)
191        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use serde_json::json;
198
199    fn tmp(name: &str) -> std::path::PathBuf {
200        std::env::temp_dir().join(format!("wire-blocklist-{}-{name}.json", std::process::id()))
201    }
202
203    #[test]
204    fn missing_file_blocks_nobody() {
205        let p = tmp("missing");
206        let _ = std::fs::remove_file(&p);
207        let bl = Blocklist::load_path(&p);
208        assert!(bl.is_empty());
209        assert!(!bl.is_blocked("did:wire:anyone-deadbeef"));
210    }
211
212    #[test]
213    fn malformed_file_fails_safe_to_empty() {
214        let p = tmp("malformed");
215        std::fs::write(&p, b"not json {{{").unwrap();
216        let bl = Blocklist::load_path(&p);
217        assert!(bl.is_empty(), "malformed block-list must load empty");
218        let _ = std::fs::remove_file(&p);
219    }
220
221    #[test]
222    fn block_unblock_roundtrip_persists() {
223        let p = tmp("roundtrip");
224        let mut bl = Blocklist::default();
225        assert!(bl.block("did:wire:rogue-aabbccdd", Some("spammer".into())));
226        assert!(
227            !bl.block("did:wire:rogue-aabbccdd", None),
228            "second block of same DID is not newly-added"
229        );
230        bl.save_path(&p).unwrap();
231
232        let loaded = Blocklist::load_path(&p);
233        assert!(loaded.is_blocked("did:wire:rogue-aabbccdd"));
234        let (_, entry) = loaded.entries().next().unwrap();
235        assert_eq!(entry.note.as_deref(), Some("spammer"));
236        assert!(!entry.at.is_empty());
237        let _ = std::fs::remove_file(&p);
238    }
239
240    #[test]
241    fn unblock_reports_presence() {
242        let mut bl = Blocklist::default();
243        bl.block("did:wire:x-1", None);
244        assert!(bl.unblock("did:wire:x-1"));
245        assert!(!bl.unblock("did:wire:x-1"), "second unblock is a no-op");
246        assert!(!bl.is_blocked("did:wire:x-1"));
247    }
248
249    #[test]
250    fn blocks_card_matches_session_did() {
251        let mut bl = Blocklist::default();
252        bl.block("did:wire:peer-12345678", None);
253        let card = json!({"did": "did:wire:peer-12345678", "handle": "peer"});
254        assert_eq!(bl.blocks_card(&card), Some("did:wire:peer-12345678"));
255    }
256
257    #[test]
258    fn blocks_card_matches_op_did_across_sessions() {
259        // T16 intent: block the operator → mute every session under them.
260        // The card's session DID is NOT itself blocked; the `op_did` is.
261        let op = "did:wire:op:darby-0123456789abcdef0123456789abcdef";
262        let mut bl = Blocklist::default();
263        bl.block(op, Some("compromised operator".into()));
264        let card = json!({
265            "did": "did:wire:fresh-session-99887766",
266            "handle": "fresh-session",
267            "op_did": op,
268        });
269        assert_eq!(bl.blocks_card(&card), Some(op));
270    }
271
272    #[test]
273    fn blocks_card_none_for_unblocked_peer() {
274        let mut bl = Blocklist::default();
275        bl.block("did:wire:someone-else-aaaa1111", None);
276        let card = json!({
277            "did": "did:wire:innocent-bbbb2222",
278            "handle": "innocent",
279            "op_did": "did:wire:op:clean-ffffffffffffffffffffffffffffffff",
280        });
281        assert_eq!(bl.blocks_card(&card), None);
282    }
283}