Skip to main content

m4a_agent/bridge/
mod.rs

1//! Bridges to other messengers, inside the client, on the user's machine, nowhere else.
2//!
3//! A bridge maps decrypted events of a room to another messenger's client protocol and back. The
4//! rules are structural, not a convention:
5//! - A bridge only ever sees events of rooms the user opted in ([`ConsentStore`]); everything else
6//!   is dropped before the bridge is called. Nothing is bridged implicitly.
7//! - A bridge reads and writes credentials only through its [`ScopedVault`], a view of the
8//!   client's vault limited to `bridge/<name>/`. It cannot reach identity keys, session tokens or
9//!   another bridge's secrets. No credential is ever read from the environment or from the repo.
10//! - [`LocalEvent`] carries plaintext, has no `Serialize`, and prints without its body: it exists
11//!   only in this process. There is no server-side bridge, appservice or hosted relay.
12//! - A bridge compiles only with its own feature `bridge-<name>`; `bridges` is the framework.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::sync::Arc;
16
17use zeroize::Zeroizing;
18
19use crate::error::{AgentError, Result};
20use crate::vault::KeyVault;
21
22#[cfg(feature = "bridge-loopback")]
23pub mod loopback;
24
25/// A decrypted event on its way out to another messenger.
26#[derive(Clone)]
27pub struct LocalEvent {
28    pub room_id: String,
29    pub sender: String,
30    pub body: String,
31    pub event_id: String,
32}
33
34impl std::fmt::Debug for LocalEvent {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("LocalEvent").field("room_id", &self.room_id).field("event_id", &self.event_id).field("body", &"<plaintext hidden>").finish()
37    }
38}
39
40/// A message that arrived from the other messenger for one of our rooms.
41pub struct RemoteMessage {
42    pub room_id: String,
43    pub author: String,
44    pub body: String,
45}
46
47/// The vault as one bridge sees it.
48#[derive(Clone)]
49pub struct ScopedVault {
50    inner: Arc<dyn KeyVault>,
51    prefix: String,
52}
53
54impl ScopedVault {
55    fn label(&self, l: &str) -> Result<String> {
56        if l.is_empty() || l.len() > 64 || !l.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) {
57            return Err(AgentError::Vault("bridge credential label must be 1-64 characters of [A-Za-z0-9._-]".into()));
58        }
59        Ok(format!("{}{l}", self.prefix))
60    }
61    pub fn get(&self, l: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
62        self.inner.get(&self.label(l)?)
63    }
64    pub fn put(&self, l: &str, v: &[u8]) -> Result<()> {
65        self.inner.put(&self.label(l)?, v)
66    }
67    pub fn delete(&self, l: &str) -> Result<bool> {
68        self.inner.delete(&self.label(l)?)
69    }
70}
71
72pub trait Bridge: Send {
73    /// Registry key and feature name (`bridge-<name>`).
74    fn name(&self) -> &'static str;
75    /// Open the connection to the other messenger with credentials from `vault`.
76    fn connect(&mut self, vault: &ScopedVault) -> Result<()>;
77    /// A consented room's event, to be sent on the other messenger. `remote_room` is the other
78    /// side's room the user linked.
79    fn outgoing(&mut self, remote_room: &str, ev: &LocalEvent) -> Result<()>;
80    /// Messages that arrived on the other messenger for `(remote_room)`; each is routed to the
81    /// linked local room only if consent still holds.
82    fn incoming(&mut self) -> Result<Vec<(String, String, String)>>; // (remote_room, author, body)
83}
84
85/// Per-room opt-in, kept in the vault so it survives restarts and travels with the identity.
86pub struct ConsentStore {
87    vault: Arc<dyn KeyVault>,
88}
89
90fn consent_label(bridge: &str) -> String {
91    format!("bridge-consent/{bridge}")
92}
93
94impl ConsentStore {
95    pub fn new(vault: Arc<dyn KeyVault>) -> Self {
96        Self { vault }
97    }
98
99    fn load(&self, bridge: &str) -> Result<BTreeMap<String, String>> {
100        match self.vault.get(&consent_label(bridge))? {
101            Some(raw) => serde_json::from_slice(&raw).map_err(|e| AgentError::Vault(format!("bridge consent: {e}"))),
102            None => Ok(BTreeMap::new()),
103        }
104    }
105
106    fn store(&self, bridge: &str, m: &BTreeMap<String, String>) -> Result<()> {
107        self.vault.put(&consent_label(bridge), &serde_json::to_vec(m).map_err(|e| AgentError::Vault(e.to_string()))?)
108    }
109
110    /// The user opts `room_id` in and links it to `remote_room`. Explicit, per room, per bridge.
111    pub fn grant(&self, bridge: &str, room_id: &str, remote_room: &str) -> Result<()> {
112        let mut m = self.load(bridge)?;
113        m.insert(room_id.to_string(), remote_room.to_string());
114        self.store(bridge, &m)
115    }
116
117    /// Opts out; nothing of the room is bridged from this moment.
118    pub fn revoke(&self, bridge: &str, room_id: &str) -> Result<bool> {
119        let mut m = self.load(bridge)?;
120        let had = m.remove(room_id).is_some();
121        if had {
122            self.store(bridge, &m)?;
123        }
124        Ok(had)
125    }
126
127    pub fn remote_of(&self, bridge: &str, room_id: &str) -> Result<Option<String>> {
128        Ok(self.load(bridge)?.get(room_id).cloned())
129    }
130
131    pub fn local_of(&self, bridge: &str, remote_room: &str) -> Result<Option<String>> {
132        Ok(self.load(bridge)?.into_iter().find(|(_, r)| r == remote_room).map(|(l, _)| l))
133    }
134
135    pub fn rooms(&self, bridge: &str) -> Result<BTreeSet<String>> {
136        Ok(self.load(bridge)?.into_keys().collect())
137    }
138}
139
140#[derive(Debug, Default, PartialEq, Eq)]
141pub struct ForwardReport {
142    pub forwarded: Vec<String>,
143    /// Bridges that failed (name, reason); never contains message text.
144    pub failed: Vec<(String, String)>,
145}
146
147/// The bridges of one session and the rules that connect them to its rooms.
148pub struct BridgeRegistry {
149    vault: Arc<dyn KeyVault>,
150    consent: ConsentStore,
151    bridges: BTreeMap<&'static str, Box<dyn Bridge>>,
152}
153
154impl BridgeRegistry {
155    pub fn new(vault: Arc<dyn KeyVault>) -> Self {
156        Self { consent: ConsentStore::new(vault.clone()), vault, bridges: BTreeMap::new() }
157    }
158
159    pub fn consent(&self) -> &ConsentStore {
160        &self.consent
161    }
162
163    /// Adds a bridge and connects it with its own scoped credentials.
164    pub fn register(&mut self, mut bridge: Box<dyn Bridge>) -> Result<()> {
165        let name = bridge.name();
166        if self.bridges.contains_key(name) {
167            return Err(AgentError::Identity(format!("bridge {name} is already registered")));
168        }
169        bridge.connect(&ScopedVault { inner: self.vault.clone(), prefix: format!("bridge/{name}/") })?;
170        self.bridges.insert(name, bridge);
171        Ok(())
172    }
173
174    pub fn names(&self) -> Vec<&'static str> {
175        self.bridges.keys().copied().collect()
176    }
177
178    /// Offers a decrypted event to every bridge that the room has consented to.
179    pub fn forward(&mut self, ev: &LocalEvent) -> ForwardReport {
180        let mut report = ForwardReport::default();
181        for (name, bridge) in self.bridges.iter_mut() {
182            match self.consent.remote_of(name, &ev.room_id) {
183                Ok(Some(remote)) => match bridge.outgoing(&remote, ev) {
184                    Ok(()) => report.forwarded.push(name.to_string()),
185                    Err(e) => report.failed.push((name.to_string(), e.to_string())),
186                },
187                Ok(None) => {}
188                Err(e) => report.failed.push((name.to_string(), e.to_string())),
189            }
190        }
191        report
192    }
193
194    /// Messages from the other messengers for rooms that still have consent: `(local_room, bridge,
195    /// author, body)`. Anything for an unlinked or revoked room is discarded here.
196    pub fn collect(&mut self) -> Vec<(String, &'static str, String, String)> {
197        let mut out = Vec::new();
198        for (name, bridge) in self.bridges.iter_mut() {
199            let Ok(msgs) = bridge.incoming() else { continue };
200            for (remote, author, body) in msgs {
201                if let Ok(Some(local)) = self.consent.local_of(name, &remote) {
202                    out.push((local, *name, author, body));
203                }
204            }
205        }
206        out
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::vault::MemoryVault;
214
215    #[cfg(feature = "bridge-loopback")]
216    #[test]
217    fn only_consented_rooms_reach_the_bridge_and_credentials_are_scoped() {
218        use loopback::{LoopbackBridge, LoopbackRemote, TOKEN_LABEL};
219        let vault: Arc<dyn KeyVault> = Arc::new(MemoryVault::new());
220        vault.put("identity-key/s1", b"never-visible").unwrap();
221        let remote = Arc::new(LoopbackRemote::default());
222        let mut reg = BridgeRegistry::new(vault.clone());
223        // No credential in the vault: the bridge does not connect.
224        assert!(reg.register(Box::new(LoopbackBridge::new(remote.clone()))).is_err());
225        vault.put(&format!("bridge/loopback/{TOKEN_LABEL}"), b"t").unwrap();
226        reg.register(Box::new(LoopbackBridge::new(remote.clone()))).unwrap();
227        assert!(reg.register(Box::new(LoopbackBridge::new(remote.clone()))).is_err(), "twice");
228
229        let ev = |room: &str, body: &str| LocalEvent { room_id: room.into(), sender: "@a:x".into(), body: body.into(), event_id: "$1".into() };
230        // Nothing is bridged until the user opts the room in.
231        assert_eq!(reg.forward(&ev("!r1", "secret")), ForwardReport::default());
232        assert!(remote.sent.lock().unwrap().is_empty());
233        reg.consent().grant("loopback", "!r1", "remote-1").unwrap();
234        assert_eq!(reg.forward(&ev("!r1", "hello")).forwarded, vec!["loopback"]);
235        assert_eq!(reg.forward(&ev("!r2", "other room")), ForwardReport::default());
236        assert_eq!(remote.sent.lock().unwrap().as_slice(), &[("remote-1".to_string(), "@a:x: hello".to_string())]);
237
238        // Incoming only for linked rooms; revoking stops both directions.
239        remote.inbox.lock().unwrap().push_back(("remote-1".into(), "bob".into(), "hi".into()));
240        remote.inbox.lock().unwrap().push_back(("unlinked".into(), "eve".into(), "x".into()));
241        assert_eq!(reg.collect(), vec![("!r1".to_string(), "loopback", "bob".to_string(), "hi".to_string())]);
242        assert!(reg.consent().revoke("loopback", "!r1").unwrap());
243        remote.inbox.lock().unwrap().push_back(("remote-1".into(), "bob".into(), "again".into()));
244        assert!(reg.collect().is_empty());
245        assert_eq!(reg.forward(&ev("!r1", "after")), ForwardReport::default());
246        assert_eq!(remote.sent.lock().unwrap().len(), 1);
247
248        // The scoped vault cannot name anything outside the bridge's own prefix.
249        let sv = ScopedVault { inner: vault.clone(), prefix: "bridge/loopback/".into() };
250        assert!(sv.get("../identity-key/s1").is_err() && sv.get("a/b").is_err());
251        assert!(sv.get("identity-key").unwrap().is_none());
252    }
253
254    #[test]
255    fn plaintext_does_not_print() {
256        let e = LocalEvent { room_id: "!r".into(), sender: "@a:x".into(), body: "very secret words".into(), event_id: "$1".into() };
257        assert!(!format!("{e:?}").contains("secret"));
258        let _ = MemoryVault::new();
259    }
260}