Skip to main content

m4a_agent/
identity.rs

1//! The session identity: one session = one identity = one nick = one credential set.
2//!
3//! The identity is an ed25519 key pair the client generates itself. The private seed is kept in
4//! the [`KeyVault`] and never leaves it except to sign; the agent behind the client is never given
5//! it. A session is bound to ONE tier and ONE server: asking the store for the same session id
6//! against a different server is an error, not a quiet second identity.
7
8use std::sync::Arc;
9
10use ed25519_dalek::{Signer, SigningKey};
11use m4a_seam::keyproof;
12use serde::{Deserialize, Serialize};
13use zeroize::Zeroizing;
14
15use crate::backend::BackendKind;
16use crate::error::{AgentError, Result};
17use crate::vault::{random_bytes, KeyVault};
18
19/// What is public and persistent about an identity. (The private seed is not in here.)
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct SessionIdentity {
22    pub session_id: String,
23    pub tier: BackendKind,
24    /// The server this identity belongs to (normalized base URL). Part of the binding.
25    pub server_ref: String,
26    pub key_id: String,
27    /// Public key, base64url.
28    pub public_key: String,
29    /// Learned when the operator's invite is redeemed; the nick is the operator's choice.
30    pub nick: Option<String>,
31    pub enrolled: bool,
32    /// The tier-1 (local mail node) session id of the same session, when known. The link is kept
33    /// here and only here; it is one-to-one (see [`IdentityStore::bind_local`]).
34    #[serde(default)]
35    pub local_session: Option<String>,
36    /// The nick this client asks the server for when it enrolls (its session name, as a nick). The
37    /// operator's invite still decides: a reserved nick that differs refuses the enrollment.
38    #[serde(default)]
39    pub requested_nick: Option<String>,
40}
41
42fn seed_label(sid: &str) -> String {
43    format!("identity-key/{sid}")
44}
45fn record_label(sid: &str) -> String {
46    format!("identity/{sid}")
47}
48/// Where a backend keeps the current access token of a session.
49pub fn token_label(sid: &str) -> String {
50    format!("session-token/{sid}")
51}
52
53fn valid_session_id(s: &str) -> bool {
54    !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
55}
56
57impl SessionIdentity {
58    /// Signs `msg` with the identity's private key (read from the vault just for this).
59    pub fn sign(&self, vault: &dyn KeyVault, msg: &[u8]) -> Result<String> {
60        if vault.external_identity(&self.session_id).is_some() {
61            return Ok(keyproof::encode(&vault.sign_external(&self.session_id, msg)?));
62        }
63        let sk = signing_key(vault, &self.session_id)?;
64        Ok(keyproof::encode(&sk.sign(msg).to_bytes()))
65    }
66
67    /// The signature for a login challenge.
68    pub fn sign_login(&self, vault: &dyn KeyVault, c: &keyproof::Challenge) -> Result<String> {
69        self.sign(vault, &keyproof::login_message(c, &self.key_id))
70    }
71
72    /// The proof that this client holds the key it is enrolling.
73    pub fn sign_enroll(&self, vault: &dyn KeyVault, audience: &str, invite: &str) -> Result<String> {
74        self.sign(vault, &keyproof::enroll_message(audience, invite, &self.public_key))
75    }
76}
77
78fn signing_key(vault: &dyn KeyVault, sid: &str) -> Result<SigningKey> {
79    let seed = vault.get(&seed_label(sid))?.ok_or_else(|| AgentError::Identity("the identity key is missing from the vault".into()))?;
80    let seed: Zeroizing<[u8; 32]> = Zeroizing::new(<[u8; 32]>::try_from(&seed[..]).map_err(|_| AgentError::Identity("the identity key has the wrong size".into()))?);
81    Ok(SigningKey::from_bytes(&seed))
82}
83
84/// Finds or creates identities. Everything is kept in the vault.
85#[derive(Clone)]
86pub struct IdentityStore {
87    vault: Arc<dyn KeyVault>,
88}
89
90impl IdentityStore {
91    pub fn new(vault: Arc<dyn KeyVault>) -> Self {
92        Self { vault }
93    }
94
95    pub fn vault(&self) -> &dyn KeyVault {
96        &*self.vault
97    }
98
99    /// The identity of `session_id` for `tier` on `server_ref`: loaded when it exists (and then it
100    /// must match tier and server), generated (new key pair, kept in the vault) when it does not.
101    pub fn resolve(&self, session_id: &str, tier: BackendKind, server_ref: &str) -> Result<SessionIdentity> {
102        if !valid_session_id(session_id) {
103            return Err(AgentError::Identity("session id must be 1-64 characters of [A-Za-z0-9._-]".into()));
104        }
105        let server_ref = server_ref.trim_end_matches('/').to_string();
106        let external = self.vault.external_identity(session_id);
107        if let Some(raw) = self.vault.get(&record_label(session_id))? {
108            let id: SessionIdentity = serde_json::from_slice(&raw).map_err(|e| AgentError::Identity(format!("stored identity: {e}")))?;
109            if id.tier != tier || id.server_ref != server_ref {
110                return Err(AgentError::Identity("this session is bound to another tier or server; use a new session id".into()));
111            }
112            match external {
113                Some(pk) if id.public_key != keyproof::encode(&pk) => {
114                    return Err(AgentError::Identity("the stored identity does not match the external key".into()))
115                }
116                Some(_) => {}
117                None => {
118                    signing_key(&*self.vault, session_id)?; // the key must still be there
119                }
120            }
121            return Ok(id);
122        }
123        if let Some(pk) = external {
124            let id = SessionIdentity { session_id: session_id.to_string(), tier, server_ref, key_id: keyproof::key_id_of(&pk), public_key: keyproof::encode(&pk), nick: None, enrolled: false, local_session: None, requested_nick: None };
125            self.save(&id)?;
126            return Ok(id);
127        }
128        let seed = random_bytes(32);
129        let sk = SigningKey::from_bytes(<&[u8; 32]>::try_from(&seed[..]).map_err(|_| AgentError::Identity("seed".into()))?);
130        let pk = sk.verifying_key();
131        let id = SessionIdentity { session_id: session_id.to_string(), tier, server_ref, key_id: keyproof::key_id_of(pk.as_bytes()), public_key: keyproof::encode(pk.as_bytes()), nick: None, enrolled: false, local_session: None, requested_nick: None };
132        // The seed goes first: a record without its key would be a dead identity.
133        self.vault.put(&seed_label(session_id), &seed)?;
134        self.save(&id)?;
135        Ok(id)
136    }
137
138    pub fn save(&self, id: &SessionIdentity) -> Result<()> {
139        self.vault.put(&record_label(&id.session_id), &serde_json::to_vec(id).map_err(|e| AgentError::Identity(e.to_string()))?)
140    }
141
142    /// Links the session to its tier-1 session id. One session has one local id and one local id
143    /// belongs to one session; a second, different link is an error (never a quiet overwrite).
144    pub fn bind_local(&self, id: &mut SessionIdentity, local_id: &str) -> Result<()> {
145        if !valid_session_id(local_id) {
146            return Err(AgentError::Identity("local session id is not a valid id".into()));
147        }
148        if let Some(have) = &id.local_session {
149            return if have == local_id { Ok(()) } else { Err(AgentError::Identity("this session is already linked to another local session".into())) };
150        }
151        let label = format!("local-link/{local_id}");
152        if let Some(owner) = self.vault.get(&label)? {
153            if owner[..] != *id.session_id.as_bytes() {
154                return Err(AgentError::Identity("that local session belongs to another identity".into()));
155            }
156        }
157        self.vault.put(&label, id.session_id.as_bytes())?;
158        id.local_session = Some(local_id.to_string());
159        self.save(id)
160    }
161
162    /// Records the nick the operator assigned. It is frozen after the first call: a different nick
163    /// for the same identity is an error, the only way to another nick is a new identity.
164    pub fn adopt_nick(&self, id: &mut SessionIdentity, nick: &str) -> Result<()> {
165        match &id.nick {
166            Some(n) if n == nick => Ok(()),
167            Some(_) => Err(AgentError::Identity("this identity already has a different nick".into())),
168            None => {
169                id.nick = Some(nick.to_string());
170                self.save(id)
171            }
172        }
173    }
174
175    /// A random 32-byte key for the session's local store (created on first ask).
176    pub fn store_key(&self, session_id: &str) -> Result<Zeroizing<Vec<u8>>> {
177        let label = format!("store-key/{session_id}");
178        if let Some(k) = self.vault.get(&label)? {
179            return Ok(k);
180        }
181        let k = random_bytes(32);
182        self.vault.put(&label, &k)?;
183        Ok(k)
184    }
185
186    /// Forgets the session entirely: key pair, record, token, store key. A later `resolve` makes a
187    /// NEW identity (which the operator must approve again).
188    pub fn forget(&self, session_id: &str) -> Result<()> {
189        if let Some(raw) = self.vault.get(&record_label(session_id))? {
190            if let Ok(id) = serde_json::from_slice::<SessionIdentity>(&raw) {
191                if let Some(l) = id.local_session {
192                    self.vault.delete(&format!("local-link/{l}"))?;
193                }
194            }
195        }
196        for l in [seed_label(session_id), record_label(session_id), token_label(session_id), format!("store-key/{session_id}")] {
197            self.vault.delete(&l)?;
198        }
199        Ok(())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::vault::MemoryVault;
207
208    fn store() -> IdentityStore {
209        IdentityStore::new(Arc::new(MemoryVault::new()))
210    }
211
212    #[test]
213    fn an_identity_is_generated_once_and_found_again() {
214        let s = store();
215        let a = s.resolve("s1", BackendKind::Server, "https://p.example/").unwrap();
216        assert!(!a.enrolled && a.nick.is_none());
217        assert_eq!(a.server_ref, "https://p.example");
218        assert_eq!(a.key_id, keyproof::key_id_of(&keyproof::decode(&a.public_key).unwrap()));
219        assert_eq!(s.resolve("s1", BackendKind::Server, "https://p.example").unwrap(), a);
220        let b = s.resolve("s2", BackendKind::Server, "https://p.example").unwrap();
221        assert_ne!(a.key_id, b.key_id, "one session, one identity");
222    }
223
224    #[test]
225    fn a_session_cannot_be_moved_to_another_server_or_tier() {
226        let s = store();
227        s.resolve("s1", BackendKind::Server, "https://p.example").unwrap();
228        assert!(s.resolve("s1", BackendKind::Server, "https://other.example").is_err());
229        assert!(s.resolve("s1", BackendKind::Matrix, "https://p.example").is_err());
230        assert!(s.resolve("../x", BackendKind::Server, "https://p.example").is_err());
231        assert!(s.resolve("", BackendKind::Server, "https://p.example").is_err());
232    }
233
234    #[test]
235    fn signatures_verify_under_the_public_key_and_forget_makes_a_new_identity() {
236        let s = store();
237        let id = s.resolve("s1", BackendKind::Matrix, "https://p.example").unwrap();
238        let c = keyproof::Challenge { challenge_id: "c".into(), nonce: "n".into(), expires_ms: 5, audience: "aud".into() };
239        let sig = id.sign_login(s.vault(), &c).unwrap();
240        assert!(keyproof::verify(&id.public_key, &keyproof::login_message(&c, &id.key_id), &sig));
241        let sig = id.sign_enroll(s.vault(), "aud", "inv").unwrap();
242        assert!(keyproof::verify(&id.public_key, &keyproof::enroll_message("aud", "inv", &id.public_key), &sig));
243        s.forget("s1").unwrap();
244        assert!(id.sign(s.vault(), b"x").is_err(), "the key is gone");
245        let again = s.resolve("s1", BackendKind::Matrix, "https://p.example").unwrap();
246        assert_ne!(again.key_id, id.key_id);
247    }
248
249    #[test]
250    fn links_and_nicks_are_one_to_one_and_frozen() {
251        let s = store();
252        let mut a = s.resolve("s1", BackendKind::Server, "https://p.example").unwrap();
253        let mut b = s.resolve("s2", BackendKind::Server, "https://p.example").unwrap();
254        s.bind_local(&mut a, "local-1").unwrap();
255        s.bind_local(&mut a, "local-1").unwrap();
256        assert!(s.bind_local(&mut a, "local-2").is_err(), "one session, one local id");
257        assert!(s.bind_local(&mut b, "local-1").is_err(), "one local id, one session");
258        assert_eq!(s.resolve("s1", BackendKind::Server, "https://p.example").unwrap().local_session.as_deref(), Some("local-1"));
259        s.adopt_nick(&mut a, "kestrel").unwrap();
260        s.adopt_nick(&mut a, "kestrel").unwrap();
261        assert!(s.adopt_nick(&mut a, "other").is_err());
262        s.forget("s1").unwrap();
263        let mut c = s.resolve("s3", BackendKind::Server, "https://p.example").unwrap();
264        s.bind_local(&mut c, "local-1").unwrap(); // the link was released with the identity
265    }
266
267    #[test]
268    fn the_debug_output_and_record_carry_no_private_key() {
269        let s = store();
270        let id = s.resolve("s1", BackendKind::Server, "https://p.example").unwrap();
271        let seed = s.vault().get(&seed_label("s1")).unwrap().unwrap();
272        let seed_b64 = keyproof::encode(&seed);
273        assert!(!format!("{id:?}").contains(&seed_b64));
274        assert!(!String::from_utf8_lossy(&serde_json::to_vec(&id).unwrap()).contains(&seed_b64));
275    }
276
277    #[test]
278    fn an_external_identity_signs_through_the_vault_and_keeps_no_seed() {
279        struct Ext(MemoryVault, SigningKey);
280        impl KeyVault for Ext {
281            fn get(&self, l: &str) -> Result<Option<Zeroizing<Vec<u8>>>> { self.0.get(l) }
282            fn put(&self, l: &str, v: &[u8]) -> Result<()> { self.0.put(l, v) }
283            fn delete(&self, l: &str) -> Result<bool> { self.0.delete(l) }
284            fn kind(&self) -> &'static str { "ext" }
285            fn external_identity(&self, _: &str) -> Option<[u8; 32]> { Some(self.1.verifying_key().to_bytes()) }
286            fn sign_external(&self, _: &str, m: &[u8]) -> Result<[u8; 64]> { Ok(self.1.sign(m).to_bytes()) }
287        }
288        let sk = SigningKey::from_bytes(&[7u8; 32]);
289        let ids = IdentityStore::new(Arc::new(Ext(MemoryVault::new(), sk.clone())));
290        let id = ids.resolve("s1", BackendKind::Server, "http://x").unwrap();
291        assert_eq!(id.key_id, keyproof::key_id_of(sk.verifying_key().as_bytes()));
292        assert!(ids.vault().get("identity-key/s1").unwrap().is_none());
293        let sig = id.sign(ids.vault(), b"m").unwrap();
294        assert_eq!(sig, keyproof::encode(&sk.sign(b"m").to_bytes()));
295        assert_eq!(ids.resolve("s1", BackendKind::Server, "http://x").unwrap(), id);
296    }
297}