//! The two calls the mint ladder is made of, against the homeserver next door. //! //! 🩸 **Every error in this module is built from the response's `status` and //! its `errcode`, never its body.** A successful `/register` or `/login` body //! *is* an access token, and an error body is one malformed response away from //! being the same bytes — so a `body: {json}` in a message here would put the //! sender token in the journal. use anyhow::{Context, Result, bail}; /// Client-server API calls are one round trip each against a homeserver in the /// same netns; a slow one is a broken one. const TIMEOUT_SECS: u64 = 10; /// Bytes of the throwaway password `/register` is given. /// /// Protocol overhead, and stored nowhere: this account authenticates by access /// token, and the recovery path when that token is lost is the appservice login /// below rather than anything a password could open. const PASSWORD_BYTES: usize = 32; /// What the homeserver said about a registration attempt. /// /// An enum rather than a string match on the error text: `M_USER_IN_USE` is the /// *expected* answer here — the hive's `@hive-:` account is the appservice /// registration's own `sender_localpart`, so the homeserver creates it at startup, before /// anything gets to ask — and an expected answer should not have to be /// recovered from a formatted message. pub enum Registered { /// A fresh account, and the access token minted with it. Token(String), /// The account is already there; it has to be logged into instead. AlreadyExists, } /// An HTTP client with this module's timeout. /// /// # Errors /// When the TLS backend will not initialise, which is the only way building a /// client fails. pub fn client() -> Result { reqwest::Client::builder() .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) .build() .context("building the HTTP client for the homeserver") } /// Create `localpart`'s account as the appservice and return its access token. /// /// One round trip: an appservice-typed registration needs no UIAA stage, so /// there is no session to carry and no shared registration secret in the /// picture. The `device_id` is pinned so that a later [`appservice_login`] /// replaces this device rather than accumulating one per run. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses with anything other than `M_USER_IN_USE` — which is /// [`Registered::AlreadyExists`] rather than an error. pub async fn register( client: &reqwest::Client, base: &str, localpart: &str, as_token: &str, ) -> Result { let body = serde_json::json!({ // What makes this an appservice registration rather than an ordinary // one: without it the homeserver asks for a UIAA flow even though the // request carries the as_token. "type": "m.login.application_service", "username": localpart, "password": random_password()?, "device_id": device_id(localpart), "initial_device_display_name": format!("hyperhive ({localpart})"), "inhibit_login": false, }); let (status, json) = post( client, &format!("{base}/_matrix/client/v3/register?kind=user"), as_token, &body, ) .await .context("POST /register as the appservice")?; if status.is_success() { return Ok(Registered::Token(access_token(&json)?)); } if errcode(&json) == Some("M_USER_IN_USE") { return Ok(Registered::AlreadyExists); } bail!( "the homeserver refused to register @{localpart}: {}", why(status, &json) ); } /// Log in as an **existing** account using the appservice's authority, and /// return a fresh access token for it. /// /// No password: the appservice is authorised for every localpart in its /// namespace, so it mints a session for one without knowing anything about the /// account — which is just as well, since an account the homeserver created for /// its own registration has none. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses. pub async fn appservice_login( client: &reqwest::Client, base: &str, localpart: &str, as_token: &str, ) -> Result { let body = serde_json::json!({ "type": "m.login.application_service", "identifier": { "type": "m.id.user", "user": localpart, }, // Matching `register`'s, so a re-login REPLACES that device's token // rather than leaving a second live device behind. "device_id": device_id(localpart), "initial_device_display_name": format!("hyperhive ({localpart})"), }); let (status, json) = post( client, &format!("{base}/_matrix/client/v3/login"), as_token, &body, ) .await .context("POST /login as the appservice")?; if !status.is_success() { bail!( "the homeserver refused to log in @{localpart}: {}", why(status, &json) ); } access_token(&json) } /// The device every token this binary mints is pinned to. fn device_id(localpart: &str) -> String { format!("hyperhive-{localpart}") } /// One authenticated JSON POST, returning the status beside the decoded body. async fn post( client: &reqwest::Client, url: &str, as_token: &str, body: &serde_json::Value, ) -> Result<(reqwest::StatusCode, serde_json::Value)> { let resp = client .post(url) .bearer_auth(as_token) .json(body) .send() .await .context("sending the request")?; let status = resp.status(); let json = resp .json::() .await .context("decoding the response as JSON")?; Ok((status, json)) } /// Pull `access_token` out of a successful response. fn access_token(json: &serde_json::Value) -> Result { json["access_token"] .as_str() .map(str::to_owned) // Not `{json}`: on the success path this object holds the credential, // and a response missing the field is exactly when a reflex to print it // would fire. .context("the homeserver's response carried no `access_token`") } /// The matrix error code, when the body is a standard error object. fn errcode(json: &serde_json::Value) -> Option<&str> { json["errcode"].as_str() } /// Everything about a refusal that is safe to put in a message. /// /// The `errcode` is a closed vocabulary from the spec and the status is a /// number; between them they say which of the ladder's arms was taken. The /// `error` string beside them is free-form homeserver text, so it stays out. fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String { match errcode(json) { Some(code) => format!("HTTP {status}, errcode {code}"), None => format!("HTTP {status}, no errcode"), } } /// A throwaway password for [`register`], as hex. /// /// From `/dev/urandom` directly rather than through an RNG crate: this is the /// one random value the binary needs, and the kernel is already the source any /// such crate would reach for here. /// /// # Errors /// When `/dev/urandom` cannot be read. fn random_password() -> Result { use std::io::Read as _; let mut buf = [0u8; PASSWORD_BYTES]; std::fs::File::open("/dev/urandom") .context("opening /dev/urandom")? .read_exact(&mut buf) .context("reading from /dev/urandom")?; Ok(buf.iter().fold(String::new(), |mut acc, b| { use std::fmt::Write as _; // Infallible: `write!` into a `String` only fails if the formatter // does, and `{:02x}` of a `u8` has nothing to fail at. let _ = write!(acc, "{b:02x}"); acc })) } #[cfg(test)] mod tests { use super::*; #[test] fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() { // The whole reason the recovery arm is safe to take repeatedly: an // unpinned login mints a NEW device each time, and a homeserver // accumulating devices for this account is one where revoking the credential // means finding all of them. assert_eq!(device_id("hive"), "hyperhive-hive"); } #[test] fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() { // 🩸 The invariant this module exists to keep. `error` is free-form // homeserver text and `access_token` is the credential itself; a // message built from the body would carry whichever of them the // response happened to hold. let json = serde_json::json!({ "errcode": "M_FORBIDDEN", "error": "some free-form text", "access_token": "syt_the_actual_secret", }); let message = why(reqwest::StatusCode::FORBIDDEN, &json); assert!(message.contains("M_FORBIDDEN"), "{message}"); assert!(!message.contains("syt_the_actual_secret"), "{message}"); assert!(!message.contains("free-form"), "{message}"); } #[test] fn a_refusal_with_no_errcode_still_produces_a_message() { // A homeserver behind a proxy answers with HTML, not a matrix error // object. The status is then the only thing there is to say, and // saying it is better than an empty report. let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({})); assert!(message.contains("502"), "{message}"); } #[test] fn the_expected_already_exists_answer_is_recognised_by_its_errcode() { // Matched on the spec's code rather than on message text, because this // is the arm a healthy homeserver takes every time: the hive's `@hive-:` // account is the appservice registration's own sender, created at startup. let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" }); assert_eq!(errcode(&json), Some("M_USER_IN_USE")); } #[test] fn a_response_without_a_token_is_an_error_that_does_not_quote_it() { let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" })) .expect_err("no access_token in this object"); assert!(!format!("{e}").contains("@hive:t.local"), "{e}"); } #[test] fn a_minted_password_is_hex_of_the_declared_length() { // The control on the hex fold: a short or non-hex password would be // accepted by the homeserver and only surface much later, if at all. let pw = random_password().expect("/dev/urandom is readable"); assert_eq!(pw.len(), PASSWORD_BYTES * 2); assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex"); } }