swarm-matrix-ctl: mint the swarm's own appservice registration
The swarm gets an appservice identity of its own, separate from each hive's `hyperhive` registration. `swarm-matrix-ctl appservice render` mints its tokens inside the matrix container when they are absent and renders the registration tuwunel loads; `appservice publish` writes its as_token to `swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no hive's policy grants. The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client, with a `whoami`, so swarm-controller can mint agents' accounts through the same pinned device id instead of a copy of them.
This commit is contained in:
parent
cb176c7be7
commit
89aff8d613
10 changed files with 523 additions and 23 deletions
371
swarm-matrix-client/src/lib.rs
Normal file
371
swarm-matrix-client/src/lib.rs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
//! The appservice calls against the swarm's homeserver: register, log in, and
|
||||
//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`;
|
||||
//! see the README for why both mint through the same device id.
|
||||
//!
|
||||
//! 🩸 **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};
|
||||
|
||||
/// Every call here is one client-server API round trip; 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 an
|
||||
/// *expected* answer — a registration's own `sender_localpart` is created by
|
||||
/// the homeserver at startup, and an agent minted once before already exists —
|
||||
/// 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> {
|
||||
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<Registered> {
|
||||
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_hex(PASSWORD_BYTES)?,
|
||||
"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<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// What the homeserver says a token is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Whoami {
|
||||
/// A live token, and the full user id it authenticates as.
|
||||
User(String),
|
||||
/// `M_UNKNOWN_TOKEN`: the homeserver does not know this token, because a
|
||||
/// later login on the same device replaced it or it was revoked.
|
||||
UnknownToken,
|
||||
}
|
||||
|
||||
/// Ask the homeserver who `token` is.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses with anything other than `M_UNKNOWN_TOKEN`. That one is
|
||||
/// [`Whoami::UnknownToken`], because it is a verdict about the token and the
|
||||
/// rest are not.
|
||||
pub async fn whoami(client: &reqwest::Client, base: &str, token: &str) -> Result<Whoami> {
|
||||
let resp = client
|
||||
.get(format!("{base}/_matrix/client/v3/account/whoami"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("GET /account/whoami: sending the request")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("GET /account/whoami: decoding the response as JSON")?;
|
||||
whoami_verdict(status, &json)
|
||||
}
|
||||
|
||||
/// [`whoami`]'s reading of a response, apart from the IO.
|
||||
fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result<Whoami> {
|
||||
if status.is_success() {
|
||||
return json["user_id"]
|
||||
.as_str()
|
||||
.map(|u| Whoami::User(u.to_owned()))
|
||||
.context("the homeserver's whoami response carried no `user_id`");
|
||||
}
|
||||
if errcode(json) == Some("M_UNKNOWN_TOKEN") {
|
||||
return Ok(Whoami::UnknownToken);
|
||||
}
|
||||
bail!("the homeserver refused whoami: {}", why(status, 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::<serde_json::Value>()
|
||||
.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<String> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s
|
||||
/// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints.
|
||||
///
|
||||
/// From `/dev/urandom` directly rather than through an RNG crate: the kernel is
|
||||
/// already the source any such crate would reach for here.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `/dev/urandom` cannot be read.
|
||||
pub fn random_hex(bytes: usize) -> Result<String> {
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut buf = vec![0u8; 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-<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 whoami_reads_the_user_of_a_live_token() {
|
||||
let json =
|
||||
serde_json::json!({ "user_id": "@atlas:t.local", "device_id": "hyperhive-atlas" });
|
||||
assert_eq!(
|
||||
whoami_verdict(reqwest::StatusCode::OK, &json).expect("a live token"),
|
||||
Whoami::User("@atlas:t.local".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_token_is_a_verdict_and_not_an_error() {
|
||||
// The arm a mint decides on: this is how a token some other login on
|
||||
// the same device replaced looks.
|
||||
let json = serde_json::json!({ "errcode": "M_UNKNOWN_TOKEN", "error": "Unknown token" });
|
||||
assert_eq!(
|
||||
whoami_verdict(reqwest::StatusCode::UNAUTHORIZED, &json).expect("a verdict"),
|
||||
Whoami::UnknownToken
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_refusal_is_an_error_so_nobody_mints_on_it() {
|
||||
// The control for the arm above: a 401 with another code, or a 5xx,
|
||||
// says nothing about the token, and a caller that minted on it would
|
||||
// rotate every token during an outage.
|
||||
for (status, json) in [
|
||||
(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
serde_json::json!({ "errcode": "M_MISSING_TOKEN" }),
|
||||
),
|
||||
(reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})),
|
||||
] {
|
||||
assert!(whoami_verdict(status, &json).is_err(), "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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_hex(PASSWORD_BYTES).expect("/dev/urandom is readable");
|
||||
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
|
||||
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue