A swarm runs one homeserver and a homeserver has one appservice sender account, so "mint it once" is a property of the thing being minted rather than something a lock has to enforce. That is what makes this account the one to move first: no trigger route, no controller change and no agent list — a boot-time oneshot beside tuwunel is the whole mechanism. `swarm-matrix-minter` runs inside `containers.hive-matrix`, which already holds the appservice token: the rendered registration is bound in read-only because that is how tuwunel is handed it. What the container lacked was an identity of its own, so this adds one — a leaf from the store's CA with a grant of exactly one path, not the hive's leaf, which reads every secret in the store. Both ends of the credential ship here. The minter reads the path it publishes to before it touches the homeserver, and returning on a non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user` reads the same path, authenticating with the hive name already in `HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login ladder stays as the fallback for a store that is empty, unconfigured or unreachable, which is every swarm deployed before this — so nothing needs backfilling and nothing breaks if the rest of the sequence never lands. The credential is not an admin credential, and is not named like one. It is the access token of the appservice registration's own `sender_localpart` — `@hive:<server_name>`, an account the homeserver creates for itself when it loads the registration. The store path is `swarm/services/matrix/sender-token`, the host path is `matrix/access-token`, and the homeserver no longer runs an `admin_execute` promotion for that account at boot. Everything the hive provisions with it — the Space, the chat room, their hierarchy and join rules, the invites — rides on being the creator of those rooms at power level 100, not on homeserver admin; there is no Synapse admin API here to need, tuwunel has none. Two operations do need an admin *sender* and therefore stop working: `hivectl matrix promote-user` and `hivectl matrix reset-password`, both `!admin …` messages into `#admins:<server>`, plus the password-reset recovery path that an agent with a lost password file falls back to. They are swarm-level operations and are left failing loudly rather than served by an over-privileged token every other call site would also carry. The sweep's own admin-rights check and self-repair go with them: an account that is deliberately not an admin has nothing to check. `ephemeral = false` stays, and hive root can still read the container's filesystem. Accepted: what this buys is identity separation — no hive *process* holds or reads the appservice token — not physical isolation. Refs #4345
286 lines
11 KiB
Rust
286 lines
11 KiB
Rust
//! 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
|
|
//! `@hive:` credential 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:` 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> {
|
|
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_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<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)
|
|
}
|
|
|
|
/// 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"),
|
|
}
|
|
}
|
|
|
|
/// 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<String> {
|
|
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:` 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");
|
|
}
|
|
}
|