Skip to main content

m4a_agent/
keyauth.rs

1//! Login by signature, the client half (the verifier half is `m4a_seam::keyproof` plus the
2//! product kit's `ChallengeBook`). Shared by every tier that talks to a product door; transport
3//! comes in through [`Wire`].
4
5use m4a_seam::keyproof::Challenge;
6use serde_json::{json, Value};
7
8use crate::backend::wire::{field, Wire};
9use crate::error::{AgentError, Result};
10use crate::identity::{token_label, IdentityStore, SessionIdentity};
11
12pub const CHALLENGE_PATH: &str = "/product/v1/login/key/challenge";
13pub const ENROLL_PATH: &str = "/product/v1/enroll";
14pub const LOGIN_PATH: &str = "/product/v1/login/key";
15pub const LOGIN_TOKEN_PATH: &str = "/product/v1/login/key/token";
16
17pub fn refused_or(status: u16, what: &str) -> AgentError {
18    match status {
19        401 | 403 => AgentError::Refused(format!("{what}: the server did not accept the proof")),
20        s => AgentError::Protocol(format!("{what}: status {s}")),
21    }
22}
23
24pub fn parse_challenge(v: &Value) -> Result<Challenge> {
25    serde_json::from_value(v.clone()).map_err(|_| AgentError::Protocol("malformed challenge".into()))
26}
27
28/// Asks the product door for a challenge for this identity's key.
29pub fn fetch_challenge(wire: &dyn Wire, id: &SessionIdentity) -> Result<Challenge> {
30    let (st, v) = wire.post(CHALLENGE_PATH, &json!({ "key_id": id.key_id }), None)?;
31    if st != 200 {
32        return Err(refused_or(st, "challenge"));
33    }
34    parse_challenge(&v)
35}
36
37/// Redeems the operator's invite: proves possession of the private key and binds the public key to
38/// the account the operator prepared (the nick was assigned when the operator invited). Marks the
39/// identity enrolled, remembers the nick and the token; returns `(nick, token)`.
40pub fn enroll(wire: &dyn Wire, ids: &IdentityStore, id: &mut SessionIdentity, invite: &str) -> Result<(String, String)> {
41    // The audience comes from the product itself; the signature is only good for that product.
42    let aud = fetch_challenge(wire, id)?.audience;
43    let signature = id.sign_enroll(ids.vault(), &aud, invite)?;
44    let label = format!("m4a-agent:{}", id.session_id);
45    let mut body = json!({ "invite": invite, "public_key": id.public_key, "signature": signature, "label": label });
46    if let Some(nick) = id.requested_nick.as_deref().filter(|n| !n.is_empty()) {
47        body["nick"] = json!(nick);
48    }
49    let (st, v) = wire.post(ENROLL_PATH, &body, None)?;
50    if st == 400 || st == 409 || st == 429 {
51        // The nick request was refused (rules, taken, cooldown, or the invite reserves another): say so.
52        let code = v.get("errcode").and_then(|c| c.as_str()).unwrap_or("");
53        return Err(AgentError::Refused(format!("enroll: the server refused the requested nick ({st} {code}); the invite is still good")));
54    }
55    if st != 200 {
56        return Err(refused_or(st, "enroll"));
57    }
58    let (nick, token) = (field(&v, "nick")?, field(&v, "token")?);
59    id.enrolled = true;
60    id.nick = Some(nick.clone());
61    ids.save(id)?;
62    ids.vault().put(&token_label(&id.session_id), token.as_bytes())?;
63    Ok((nick, token))
64}
65
66/// Logs in by signing a fresh challenge at the product door; returns `(nick, token)`.
67pub fn login(wire: &dyn Wire, ids: &IdentityStore, id: &SessionIdentity) -> Result<(String, String)> {
68    let c = fetch_challenge(wire, id)?;
69    let signature = id.sign_login(ids.vault(), &c)?;
70    let (st, v) = wire.post(LOGIN_PATH, &json!({ "key_id": id.key_id, "challenge_id": c.challenge_id, "signature": signature }), None)?;
71    if st != 200 {
72        return Err(refused_or(st, "login"));
73    }
74    let (nick, token) = (field(&v, "nick")?, field(&v, "token")?);
75    ids.vault().put(&token_label(&id.session_id), token.as_bytes())?;
76    Ok((nick, token))
77}
78
79/// Proves possession of the key at the product door and gets a one-time Matrix login token
80/// (`m.login.token`, short lived, single use) back. No session of this identity is touched. The
81/// caller must not print or keep the token.
82pub fn login_token(wire: &dyn Wire, ids: &IdentityStore, id: &SessionIdentity) -> Result<String> {
83    let c = fetch_challenge(wire, id)?;
84    let signature = id.sign_login(ids.vault(), &c)?;
85    let (st, v) = wire.post(LOGIN_TOKEN_PATH, &json!({ "key_id": id.key_id, "challenge_id": c.challenge_id, "signature": signature }), None)?;
86    if st != 200 {
87        return Err(refused_or(st, "login token"));
88    }
89    field(&v, "login_token")
90}
91
92/// The token stored for this session, if any.
93pub fn stored_token(ids: &IdentityStore, id: &SessionIdentity) -> Result<Option<String>> {
94    Ok(ids.vault().get(&token_label(&id.session_id))?.and_then(|b| String::from_utf8(b.to_vec()).ok()))
95}