Skip to main content

m4a_agent/backend/
matrix.rs

1//! Tier 3: the Matrix client API. Same key proof, carried by the custom login type
2//! `org.m4a.login.signature`; enrollment (once) goes through the product's enroll endpoint, since
3//! Matrix has no notion of an invite-bound key.
4//!
5//! With the `engine` feature it also runs the engine: `.well-known` discovery of the real server
6//! address, the spec prefix only when the server serves it, a token refresh by signature when the
7//! server forgot the token, and sync by sliding sync (MSC4186) when the server offers it, falling
8//! back to `/sync` v3.
9
10use std::sync::Arc;
11
12use m4a_seam::keyproof::LOGIN_TYPE;
13use serde_json::json;
14use zeroize::Zeroizing;
15
16use super::wire::{field, HttpWire, Wire};
17use super::{Backend, BackendKind, Capabilities, Session};
18use crate::error::{AgentError, Result};
19use crate::identity::{token_label, IdentityStore, SessionIdentity};
20use crate::keyauth;
21
22const LOGIN: &str = "/_matrix/client/v3/login";
23const WHOAMI: &str = "/_matrix/client/v3/account/whoami";
24
25/// Which sync the engine's `/sync` calls become.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SyncMode {
28    /// Sliding sync when the server advertises it, `/sync` v3 otherwise (and after a refusal).
29    Auto,
30    /// Always `/sync` v3.
31    V3,
32}
33
34pub struct MatrixBackend {
35    base: String,
36    wire: Arc<dyn Wire>,
37    #[cfg(feature = "engine")]
38    live: Arc<super::live::Live>,
39    #[cfg(feature = "engine")]
40    sliding: super::sliding::Sliding,
41}
42
43/// `GET /.well-known/matrix/client`: the server address the domain says clients should use.
44/// `None` when there is no (usable) answer: the origin itself is then the server.
45#[cfg(feature = "engine")]
46pub fn discover_base(origin: &str) -> Option<String> {
47    let exec = crate::engine::HttpExec::new(origin).ok()?;
48    let (st, v) = exec.get_json("/.well-known/matrix/client").ok()?;
49    if st != 200 {
50        return None;
51    }
52    let found = v.get("m.homeserver")?.get("base_url")?.as_str()?.trim_end_matches('/').to_string();
53    // Only a real origin is taken; anything else is ignored rather than trusted.
54    crate::engine::http::parse_origin(&found).ok()?;
55    Some(found)
56}
57
58impl MatrixBackend {
59    /// No network. The origin is taken as the server.
60    pub fn new(base_url: &str) -> Result<Self> {
61        Self::with_wire(base_url, Box::new(HttpWire::new(base_url)?))
62    }
63
64    /// Asks the domain's `.well-known` for the real server address first, then connects there.
65    #[cfg(feature = "engine")]
66    pub fn discover(origin: &str) -> Result<Self> {
67        let real = discover_base(origin).unwrap_or_else(|| origin.trim_end_matches('/').to_string());
68        Self::new(&real)
69    }
70
71    pub fn with_wire(base_url: &str, wire: Box<dyn Wire>) -> Result<Self> {
72        Ok(Self {
73            base: base_url.trim_end_matches('/').to_string(),
74            wire: Arc::from(wire),
75            #[cfg(feature = "engine")]
76            live: Arc::new(super::live::Live::new(crate::engine::HttpExec::new(base_url)?)),
77            #[cfg(feature = "engine")]
78            sliding: super::sliding::Sliding::new(),
79        })
80    }
81
82    #[cfg(feature = "engine")]
83    pub fn with_sync_mode(self, mode: SyncMode) -> Self {
84        self.sliding.set_mode(mode);
85        self
86    }
87
88    /// Whether `/sync` calls are currently served by sliding sync (after the first sync decided).
89    #[cfg(feature = "engine")]
90    pub fn uses_sliding_sync(&self) -> bool {
91        self.sliding.active()
92    }
93
94    /// The server's real base URL (after discovery), without any fragment.
95    pub fn base(&self) -> &str {
96        &self.base
97    }
98
99    /// The Matrix flow: ask with the key id, get the challenge in a 401, answer with the signature.
100    fn login_by_signature(wire: &dyn Wire, ids: &IdentityStore, id: &SessionIdentity) -> Result<(String, Option<String>, String)> {
101        let (st, v) = wire.post(LOGIN, &json!({ "type": LOGIN_TYPE, "key_id": id.key_id }), None)?;
102        if st != 401 {
103            return Err(keyauth::refused_or(st, "login challenge"));
104        }
105        let c = keyauth::parse_challenge(v.get(LOGIN_TYPE).ok_or_else(|| AgentError::Protocol("no challenge in the 401".into()))?)?;
106        let signature = id.sign_login(ids.vault(), &c)?;
107        let (st, v) = wire.post(LOGIN, &json!({ "type": LOGIN_TYPE, "key_id": id.key_id, "challenge_id": c.challenge_id, "signature": signature }), None)?;
108        if st != 200 {
109            return Err(keyauth::refused_or(st, "login"));
110        }
111        let token = field(&v, "access_token")?;
112        ids.vault().put(&token_label(&id.session_id), token.as_bytes())?;
113        Ok((token, v.get("device_id").and_then(|d| d.as_str()).map(str::to_string), field(&v, "user_id")?))
114    }
115
116    fn session_of(&self, id: &SessionIdentity, token: String, user_id: String, device_id: Option<String>) -> Session {
117        let nick = user_id.trim_start_matches('@').split(':').next().unwrap_or_default().to_string();
118        let _ = self;
119        Session { token: Zeroizing::new(token), nick, cred_ref: id.key_id.clone(), user_id: Some(user_id), device_id }
120    }
121
122    #[allow(unused_variables)]
123    fn established(&self, ids: &IdentityStore, id: &SessionIdentity, token: &str) {
124        #[cfg(feature = "engine")]
125        {
126            self.live.set_token(token);
127            // The server is asked whether it serves the spec prefix; ours does not need it.
128            self.live.with_exec(|e| e.probe_prefix());
129            let (ids, id, wire) = (ids.clone(), id.clone(), Arc::clone(&self.wire));
130            let live = Arc::clone(&self.live);
131            self.live.set_refresher(Arc::new(move || {
132                let (token, _, _) = Self::login_by_signature(&*wire, &ids, &id)?;
133                let _ = &live;
134                Ok(token)
135            }));
136            self.sliding.decide(&self.live);
137        }
138    }
139}
140
141impl Backend for MatrixBackend {
142    fn kind(&self) -> BackendKind {
143        BackendKind::Matrix
144    }
145    fn capabilities(&self) -> Capabilities {
146        Capabilities { rooms: true, federation: true }
147    }
148    fn server_ref(&self) -> &str {
149        &self.base
150    }
151
152    fn ensure_session(&self, ids: &IdentityStore, id: &mut SessionIdentity, invite: Option<&str>) -> Result<Session> {
153        if id.tier != BackendKind::Matrix || id.server_ref != self.base {
154            return Err(AgentError::Identity("identity belongs to another tier or server".into()));
155        }
156        if let Some(t) = keyauth::stored_token(ids, id)? {
157            let (st, v) = self.wire.get(WHOAMI, Some(&t))?;
158            if st == 200 {
159                let user_id = field(&v, "user_id")?;
160                self.established(ids, id, &t);
161                return Ok(self.session_of(id, t, user_id, v.get("device_id").and_then(|d| d.as_str()).map(str::to_string)));
162            }
163        }
164        if !id.enrolled {
165            keyauth::enroll(&*self.wire, ids, id, invite.ok_or(AgentError::NeedsInvite)?)?;
166        }
167        let (token, device_id, user_id) = Self::login_by_signature(&*self.wire, ids, id)?;
168        self.established(ids, id, &token);
169        Ok(self.session_of(id, token, user_id, device_id))
170    }
171
172    #[cfg(feature = "engine")]
173    fn execute(&self, request: &mail4agent_messenger::OutgoingRequest) -> Result<mail4agent_messenger::HttpResponseDescriptor> {
174        if request.kind == mail4agent_messenger::OutgoingRequestKind::Sync {
175            if let Some(answer) = self.sliding.sync(&self.live, request)? {
176                return Ok(answer);
177            }
178        }
179        self.live.execute(request)
180    }
181
182    #[cfg(feature = "engine")]
183    fn wait_for_news(&self, timeout: std::time::Duration) -> Result<crate::engine::News> {
184        self.live.wait_for_news(timeout)
185    }
186
187    #[cfg(feature = "engine")]
188    fn whoami(&self) -> Result<(String, String)> {
189        self.live.whoami()
190    }
191
192    #[cfg(feature = "engine")]
193    fn keep_prefix(&self) -> bool {
194        self.live.keep_prefix()
195    }
196}