Skip to main content

mail4agent_server/
identities.rs

1//! Nick + domain identities of this messenger server.
2//!
3//! The server keeps no user database: it knows a nick, the Matrix localpart
4//! derived from it (lowercase nick at FIRST CONTACT, frozen from then on), the
5//! first-contact time, and the devices of the credentials a product vouches
6//! for. Who a person is, how they log in and which nick rules apply are the
7//! product server's business; the product tells this server by signed
8//! assertion ([`m4a_seam`]) and by lifecycle events.
9//!
10//! Remote federated users are NOT identities: they stay negative-id proxy rows
11//! in `matrix_users`.
12
13use rusqlite::{params, Connection, OptionalExtension};
14
15use crate::error::MatrixError;
16use crate::keys::CredentialKind;
17
18/// Longest nick the server will turn into a localpart.
19pub const NICK_MAX: usize = 64;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Identity {
23    /// Same integer as `matrix_users.user_id`.
24    pub id: i64,
25    pub nick: String,
26    pub localpart: String,
27    pub first_contact_ms: i64,
28}
29
30pub fn create_identities_schema(conn: &Connection) -> rusqlite::Result<()> {
31    conn.execute_batch(
32        r#"
33        CREATE TABLE IF NOT EXISTS identities (
34            id               INTEGER PRIMARY KEY,
35            nick             TEXT NOT NULL,
36            nick_ci          TEXT NOT NULL UNIQUE,
37            localpart        TEXT NOT NULL UNIQUE,
38            first_contact_ms INTEGER NOT NULL
39        );
40        CREATE TABLE IF NOT EXISTS reserved_localparts (
41            localpart TEXT PRIMARY KEY,
42            reason    TEXT,
43            since_ms  INTEGER NOT NULL
44        );
45        CREATE TABLE IF NOT EXISTS account_events_seen (
46            event_id TEXT PRIMARY KEY,
47            at_ms    INTEGER NOT NULL
48        );
49        "#,
50    )
51}
52
53/// The only nick check the server makes itself: the nick must be a legal,
54/// predictable Matrix localpart. Product nick rules are the product's.
55pub fn legal_localpart(nick: &str) -> Result<(), MatrixError> {
56    let ok = !nick.is_empty()
57        && nick.len() <= NICK_MAX
58        && nick.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '='));
59    if ok {
60        Ok(())
61    } else {
62        Err(MatrixError::invalid_nick("nick must be 1-64 of A-Z a-z 0-9 _ - . ="))
63    }
64}
65
66const COLS: &str = "id, nick, localpart, first_contact_ms";
67
68fn from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Identity> {
69    Ok(Identity { id: r.get(0)?, nick: r.get(1)?, localpart: r.get(2)?, first_contact_ms: r.get(3)? })
70}
71
72pub fn identity_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<Identity>> {
73    conn.query_row(&format!("SELECT {COLS} FROM identities WHERE id = ?1"), params![id], from_row).optional()
74}
75
76pub fn identity_by_nick(conn: &Connection, nick: &str) -> rusqlite::Result<Option<Identity>> {
77    conn.query_row(&format!("SELECT {COLS} FROM identities WHERE nick_ci = ?1"), params![nick.to_ascii_lowercase()], from_row).optional()
78}
79
80/// Is `lower` unavailable as a nick or localpart for anyone but `except_id`?
81pub fn name_taken(conn: &Connection, lower: &str, except_id: i64) -> rusqlite::Result<bool> {
82    if crate::store::is_reserved_localpart(lower) {
83        return Ok(true);
84    }
85    let q = |sql: &str, p: &[&dyn rusqlite::ToSql]| -> rusqlite::Result<bool> { Ok(conn.query_row(sql, p, |_| Ok(())).optional()?.is_some()) };
86    Ok(q("SELECT 1 FROM identities WHERE id != ?2 AND (nick_ci = ?1 OR localpart = ?1)", &[&lower, &except_id])?
87        || q("SELECT 1 FROM reserved_localparts WHERE localpart = ?1", &[&lower])?
88        || q("SELECT 1 FROM matrix_users WHERE mxid = ?1 AND user_id != ?2", &[&crate::store::mxid_for_public_id(lower), &except_id])?)
89}
90
91fn next_id(conn: &Connection) -> rusqlite::Result<i64> {
92    conn.query_row(
93        "SELECT MAX((SELECT COALESCE(MAX(id), 0) FROM identities), (SELECT COALESCE(MAX(user_id), 0) FROM matrix_users)) + 1",
94        [],
95        |r| r.get(0),
96    )
97}
98
99/// What a valid assertion resolved to.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Resolved {
102    pub identity: Identity,
103    pub device_id: String,
104    pub created: bool,
105}
106
107/// Resolve a verified assertion: find the identity by nick, else by the
108/// credential's device (the product renamed the nick; follow it softly), else
109/// create it. Creation IS first contact: the Matrix user is made and the
110/// localpart frozen in the same transaction. Then the credential's device.
111pub fn resolve_assertion(conn: &mut Connection, nick: &str, cred_ref: &str, now_ms: i64) -> Result<Resolved, MatrixError> {
112    let tx = conn.transaction()?;
113    let mut found = identity_by_nick(&tx, nick)?;
114    if found.is_none() {
115        if let Some(dev) = crate::keys::device_for_credential(&tx, CredentialKind::Web, cred_ref)? {
116            found = identity_by_id(&tx, dev.user_id)?;
117            if let Some(idn) = &found {
118                // The product renamed this person: follow if the new nick is free.
119                match rename_in(&tx, idn.id, nick) {
120                    Ok(()) => found = identity_by_id(&tx, idn.id)?,
121                    Err(e) => tracing::warn!(identity = idn.id, "rename by assertion not applied: {}", e.errcode),
122                }
123            }
124        }
125    }
126    let (identity, created) = match found {
127        Some(i) => {
128            if i.nick != nick && i.nick.eq_ignore_ascii_case(nick) {
129                tx.execute("UPDATE identities SET nick = ?2 WHERE id = ?1", params![i.id, nick])?;
130            }
131            (identity_by_id(&tx, i.id)?.ok_or_else(MatrixError::internal)?, false)
132        }
133        None => {
134            legal_localpart(nick)?;
135            let lower = nick.to_ascii_lowercase();
136            if name_taken(&tx, &lower, 0)? {
137                return Err(MatrixError::nick_conflict());
138            }
139            let id = next_id(&tx)?;
140            crate::store::ensure_matrix_user(&tx, id, &lower, &chrono::Utc::now().to_rfc3339())?;
141            tx.execute(
142                "INSERT INTO identities (id, nick, nick_ci, localpart, first_contact_ms) VALUES (?1, ?2, ?3, ?3, ?4)",
143                params![id, nick, lower, now_ms],
144            )?;
145            (identity_by_id(&tx, id)?.ok_or_else(MatrixError::internal)?, true)
146        }
147    };
148    let device_id = match crate::keys::device_for_credential(&tx, CredentialKind::Web, cred_ref)? {
149        Some(d) if d.user_id == identity.id => d.device_id,
150        Some(_) => return Err(MatrixError::forbidden("credential belongs to another identity")),
151        None => crate::keys::create_device(&tx, identity.id, CredentialKind::Web, cred_ref, &chrono::Utc::now().to_rfc3339())?,
152    };
153    tx.commit()?;
154    Ok(Resolved { identity, device_id, created })
155}
156
157/// Change the nick (display) of an identity; the localpart never moves.
158fn rename_in(conn: &Connection, id: i64, new: &str) -> Result<(), MatrixError> {
159    legal_localpart(new)?;
160    let lower = new.to_ascii_lowercase();
161    if name_taken(conn, &lower, id)? {
162        return Err(MatrixError::nick_conflict());
163    }
164    conn.execute("UPDATE identities SET nick = ?2, nick_ci = ?3 WHERE id = ?1", params![id, new, lower])?;
165    Ok(())
166}
167
168/// Event `nick.changed{old,new}`. `Ok(Some(id))` when applied.
169pub fn apply_nick_changed(conn: &mut Connection, old: &str, new: &str) -> Result<Option<i64>, MatrixError> {
170    let tx = conn.transaction()?;
171    let Some(idn) = identity_by_nick(&tx, old)? else { return Ok(None) };
172    rename_in(&tx, idn.id, new)?;
173    tx.commit()?;
174    Ok(Some(idn.id))
175}
176
177/// Event `account.deleted{nick}`: devices gone, localpart tombstoned forever,
178/// the nick string freed. Returns whether an identity was retired.
179pub fn apply_account_deleted(conn: &mut Connection, nick: &str, now_ms: i64) -> Result<bool, MatrixError> {
180    let Some(idn) = identity_by_nick(conn, nick)? else { return Ok(false) };
181    for d in crate::keys::list_devices(conn, idn.id)? {
182        crate::keys::delete_device(conn, idn.id, &d.device_id, &chrono::Utc::now().to_rfc3339())?;
183    }
184    let tx = conn.transaction()?;
185    tx.execute("INSERT OR IGNORE INTO reserved_localparts (localpart, reason, since_ms) VALUES (?1, 'identity retired', ?2)", params![idn.localpart, now_ms])?;
186    tx.execute("DELETE FROM identities WHERE id = ?1", params![idn.id])?;
187    tx.commit()?;
188    Ok(true)
189}
190
191/// Event `credential.revoked{cred_ref}`: the device is removed. Returns the user id to wake.
192pub fn apply_credential_revoked(conn: &mut Connection, cred_ref: &str) -> Result<Option<i64>, MatrixError> {
193    let hit = crate::keys::delete_device_by_credential(conn, CredentialKind::Web, cred_ref, &chrono::Utc::now().to_rfc3339())?;
194    Ok(hit.map(|(u, _)| u))
195}
196
197/// What a reconcile pass changed.
198#[derive(Debug, Default, PartialEq, Eq)]
199pub struct ReconcileOutcome {
200    pub devices_removed: usize,
201    pub identities_retired: usize,
202    pub wake: Vec<i64>,
203    /// Users whose push connections must be dropped (device removed or identity retired).
204    pub closed: Vec<i64>,
205}
206
207/// Apply the product's snapshot of live credentials. Devices of a listed nick whose
208/// credential is not in its list are removed; with `complete`, identities whose nick is not
209/// listed at all are retired exactly as `account.deleted` would.
210pub fn reconcile(conn: &mut Connection, snap: &m4a_seam::Reconcile, now_ms: i64) -> Result<ReconcileOutcome, MatrixError> {
211    let mut out = ReconcileOutcome::default();
212    let mut listed = std::collections::HashSet::new();
213    for live in &snap.nicks {
214        listed.insert(live.nick.to_ascii_lowercase());
215        let Some(idn) = identity_by_nick(conn, &live.nick)? else { continue };
216        for d in crate::keys::list_devices(conn, idn.id)? {
217            if d.credential_kind == CredentialKind::Web && !live.creds.iter().any(|c| *c == d.credential_ref) {
218                crate::keys::delete_device(conn, idn.id, &d.device_id, &chrono::Utc::now().to_rfc3339())?;
219                out.devices_removed += 1;
220                if !out.wake.contains(&idn.id) {
221                    out.wake.push(idn.id);
222                    out.closed.push(idn.id);
223                }
224            }
225        }
226    }
227    if snap.complete {
228        let all: Vec<String> = {
229            let mut st = conn.prepare("SELECT nick FROM identities")?;
230            let rows = st.query_map([], |r| r.get::<_, String>(0))?;
231            rows.collect::<Result<_, _>>()?
232        };
233        for nick in all.into_iter().filter(|n| !listed.contains(&n.to_ascii_lowercase())) {
234            let id = identity_by_nick(conn, &nick)?.map(|i| i.id);
235            if apply_account_deleted(conn, &nick, now_ms)? {
236                out.closed.extend(id);
237                out.identities_retired += 1;
238            }
239        }
240    }
241    Ok(out)
242}
243
244/// Current nick of an identity, for display labels.
245pub fn nick_of_user(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<String>> {
246    conn.query_row("SELECT nick FROM identities WHERE id = ?1", params![user_id], |r| r.get(0)).optional()
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::store::create_matrix_schema;
253
254    fn db() -> Connection {
255        let c = Connection::open_in_memory().unwrap();
256        c.pragma_update(None, "foreign_keys", "ON").unwrap();
257        create_matrix_schema(&c).unwrap();
258        crate::keys::create_matrix_keys_schema(&c).unwrap();
259        c
260    }
261
262    #[test]
263    fn first_assertion_is_first_contact_and_freezes_the_localpart() {
264        let mut c = db();
265        let r = resolve_assertion(&mut c, "Carol", "c1", 10).unwrap();
266        assert!(r.created);
267        assert_eq!((r.identity.nick.as_str(), r.identity.localpart.as_str()), ("Carol", "carol"));
268        assert_eq!(crate::store::mxid_of(&c, r.identity.id).unwrap().as_deref(), Some("@carol:example.org"));
269        let again = resolve_assertion(&mut c, "carol", "c1", 20).unwrap();
270        assert!(!again.created);
271        assert_eq!(again.identity.id, r.identity.id);
272        assert_eq!(again.device_id, r.device_id);
273        assert_eq!(again.identity.nick, "carol", "case-only change follows");
274        let other = resolve_assertion(&mut c, "dave", "c2", 30).unwrap();
275        assert_ne!(other.device_id, r.device_id);
276    }
277
278    #[test]
279    fn rename_by_assertion_and_by_event_keep_the_localpart() {
280        let mut c = db();
281        let r = resolve_assertion(&mut c, "erin", "k1", 1).unwrap();
282        let healed = resolve_assertion(&mut c, "erin2", "k1", 2).unwrap();
283        assert!(!healed.created);
284        assert_eq!((healed.identity.id, healed.identity.nick.as_str(), healed.identity.localpart.as_str()), (r.identity.id, "erin2", "erin"));
285        resolve_assertion(&mut c, "frank", "k2", 3).unwrap();
286        assert_eq!(apply_nick_changed(&mut c, "erin2", "frank").unwrap_err().errcode, "M4A_NICK_CONFLICT");
287        assert_eq!(apply_nick_changed(&mut c, "erin2", "erin3").unwrap(), Some(r.identity.id));
288        assert_eq!(apply_nick_changed(&mut c, "nobody", "xyz").unwrap(), None);
289        assert!(name_taken(&c, "erin", 0).unwrap(), "frozen localpart stays reserved to others");
290        assert!(!name_taken(&c, "erin2", 0).unwrap(), "old nick string is free");
291    }
292
293    #[test]
294    fn collisions_and_legality() {
295        let mut c = db();
296        resolve_assertion(&mut c, "gina", "k1", 1).unwrap();
297        // another credential asserting an existing nick is the same identity (the product owns uniqueness)
298        assert!(!resolve_assertion(&mut c, "GINA", "k9", 2).unwrap().created);
299        // a nick equal to an existing matrix user's localpart is refused
300        crate::store::ensure_matrix_user(&c, 900, "agentx", "t").unwrap();
301        assert_eq!(resolve_assertion(&mut c, "agentx", "k3", 3).unwrap_err().errcode, "M4A_NICK_CONFLICT");
302        for bad in ["a:b", "a b", "a/b", "", &"x".repeat(65)] {
303            assert_eq!(resolve_assertion(&mut c, bad, "k4", 4).unwrap_err().errcode, "M4A_INVALID_NICK", "{bad:?}");
304        }
305        let n: i64 = c.query_row("SELECT COUNT(*) FROM identities", [], |r| r.get(0)).unwrap();
306        assert_eq!(n, 1);
307    }
308
309    #[test]
310    fn revoke_and_delete_retire_devices_and_tombstone_the_localpart() {
311        let mut c = db();
312        let r = resolve_assertion(&mut c, "hank", "k1", 1).unwrap();
313        assert_eq!(apply_credential_revoked(&mut c, "k1").unwrap(), Some(r.identity.id));
314        assert_eq!(apply_credential_revoked(&mut c, "k1").unwrap(), None);
315        resolve_assertion(&mut c, "hank", "k5", 2).unwrap();
316        assert!(apply_account_deleted(&mut c, "hank", 3).unwrap());
317        assert!(!apply_account_deleted(&mut c, "hank", 3).unwrap());
318        assert!(name_taken(&c, "hank", 0).unwrap(), "retired localpart is never reissued");
319        assert_eq!(resolve_assertion(&mut c, "hank", "k6", 4).unwrap_err().errcode, "M4A_NICK_CONFLICT");
320        assert!(crate::keys::list_devices(&c, r.identity.id).unwrap().is_empty());
321    }
322
323    #[test]
324    fn remote_users_are_not_identities_and_ids_do_not_collide() {
325        let mut c = db();
326        crate::fed_rooms::ensure_remote_user(&c, "@bob:other.example", "t").unwrap();
327        crate::store::ensure_matrix_user(&c, 41, "legacyuser", "t").unwrap();
328        let r = resolve_assertion(&mut c, "ivy", "k1", 1).unwrap();
329        assert!(r.identity.id >= 42);
330        let n: i64 = c.query_row("SELECT COUNT(*) FROM identities", [], |r| r.get(0)).unwrap();
331        assert_eq!(n, 1);
332    }
333}