diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index ddb462f1..9647b06c 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -92,10 +92,28 @@ enum MatrixCmd { /// the freshly-minted access token to stdout — no /// `/var/lib/hyperhive/agents/` directory is created for the user /// (#662). + /// + /// Without `--password` / `--password-stdin` a random throwaway is + /// used (fine for agents — they auth by `access_token`, never by + /// password). Set a password to log into a matrix web client + /// afterwards (#663). CreateUser { /// Matrix localpart. For agents: the container/agent name. /// For humans: any matrix localpart — `mara`, `damocles`, etc. name: String, + /// Set the account password to this string instead of a random + /// throwaway. Use this for operator accounts that need to log + /// into matrix web clients via `m.login.password` (#663). + /// Mutually exclusive with `--password-stdin`. WARNING: the + /// password is visible in shell history + process listings; + /// prefer `--password-stdin` for anything sensitive. + #[arg(long)] + password: Option, + /// Read the password from stdin (single line, trailing newline + /// stripped) instead of an inline flag. Mutually exclusive with + /// `--password`. + #[arg(long, conflicts_with = "password")] + password_stdin: bool, }, } @@ -113,7 +131,11 @@ async fn main() -> Result<()> { ForgeCmd::CreateUser { name } => forge_create_user(&name).await, }, Cmd::Matrix { cmd } => match cmd { - MatrixCmd::CreateUser { name } => matrix_create_user(&name).await, + MatrixCmd::CreateUser { + name, + password, + password_stdin, + } => matrix_create_user(&name, password.as_deref(), password_stdin).await, }, } } @@ -150,7 +172,37 @@ async fn forge_create_user(name: &str) -> Result<()> { Ok(()) } -async fn matrix_create_user(name: &str) -> Result<()> { +/// Resolve the password the caller asked for, or return `None` to fall +/// back to a random throwaway. `--password ` wins outright; +/// `--password-stdin` reads one line off stdin (trailing newline +/// stripped). Empty stdin is treated as an error so the caller doesn't +/// silently provision an empty-password account. +fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result> { + if let Some(p) = password { + return Ok(Some(p.to_owned())); + } + if password_stdin { + use std::io::BufRead as _; + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .context("read password from stdin")?; + let trimmed = line.trim_end_matches(['\r', '\n']).to_owned(); + if trimmed.is_empty() { + bail!("--password-stdin: empty input"); + } + return Ok(Some(trimmed)); + } + Ok(None) +} + +async fn matrix_create_user( + name: &str, + password: Option<&str>, + password_stdin: bool, +) -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users" @@ -162,7 +214,16 @@ async fn matrix_create_user(name: &str) -> Result<()> { .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; + let user_password = resolve_password(password, password_stdin)?; if is_agent(name) { + if user_password.is_some() { + // The boot-sweep / approval-time agent provisioning path + // doesn't accept a password — agents auth by access_token, + // never by password. Refuse rather than silently dropping it. + bail!( + "matrix create-user: --password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" + ); + } hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token) .await .with_context(|| format!("matrix create-user {name}"))?; @@ -170,11 +231,24 @@ async fn matrix_create_user(name: &str) -> Result<()> { println!("matrix: provisioned agent user '{name}'"); println!("token persisted at: {}", path.display()); } else { - let token = hive_c0re::matrix::provision_user_token(&client, name, ®ister_token) - .await - .with_context(|| format!("matrix create-user {name}"))?; + let effective_password = match user_password { + Some(p) => p, + None => hive_c0re::matrix::random_password() + .context("generate random matrix password")?, + }; + let token = + hive_c0re::matrix::provision_user_token(&client, name, ®ister_token, &effective_password) + .await + .with_context(|| format!("matrix create-user {name}"))?; println!("matrix: provisioned user '{name}' (not an agent — token not persisted)"); println!("token: {token}"); + if password.is_some() || password_stdin { + println!("password: set as supplied — use it to log into a matrix web client"); + } else { + println!( + "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)" + ); + } } Ok(()) } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 8e01dc54..0c51c123 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -169,19 +169,33 @@ async fn register_post( Ok((status, json)) } -/// Run the matrix-spec UIAA flow to register `agent` and return the -/// resulting access token. Two round-trips: first POST elicits the -/// 401 + session id, second POST supplies the registration token in -/// the `auth` block. If the homeserver returns 200 on the first POST -/// (no flow stages required — `allow_registration` with no token), we -/// take the access token directly. +/// Generate a throwaway random password for matrix UIAA registration. +/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. Agents authenticate +/// by `access_token` so the password is protocol overhead we never +/// persist; the operator path in `hivectl` (#663) lets the caller +/// supply a real password instead so they can log into a matrix web +/// client (`m.login.password`). +pub fn random_password() -> Result { + random_hex(PASSWORD_BYTES) +} + +/// Run the matrix-spec UIAA flow to register `agent` with the given +/// `password` and return the resulting access token. Two round-trips: +/// first POST elicits the 401 + session id, second POST supplies the +/// registration token in the `auth` block. If the homeserver returns +/// 200 on the first POST (no flow stages required — `allow_registration` +/// with no token), we take the access token directly. +/// +/// Caller picks the password: agents use [`random_password`] (throwaway +/// — they auth by `access_token`), operators on the `hivectl` path +/// supply their own so they can log into matrix web clients (#663). async fn register_user( client: &reqwest::Client, agent: &str, register_token: &str, + password: &str, ) -> Result { let localpart = user_localpart(agent); - let password = random_hex(PASSWORD_BYTES)?; let initial = serde_json::json!({ "username": localpart, "password": password, @@ -252,7 +266,8 @@ pub async fn ensure_user_for( tracing::debug!(%name, "matrix: token already present"); return Ok(()); } - let access_token = register_user(client, name, register_token).await?; + let password = random_password()?; + let access_token = register_user(client, name, register_token, &password).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } @@ -263,18 +278,24 @@ pub async fn ensure_user_for( Ok(()) } -/// Register a matrix account for `name` and return the freshly-minted -/// access token. Unlike [`ensure_user_for`], the token is **not** -/// persisted to disk — the caller is responsible for storing it. Used -/// by `hivectl matrix create-user` for human (non-agent) accounts so -/// we don't create stray `/var/lib/hyperhive/agents//` directories -/// for users that aren't agents (#662). +/// Register a matrix account for `name` with the supplied `password` +/// and return the freshly-minted access token. Unlike [`ensure_user_for`], +/// the token is **not** persisted to disk — the caller is responsible +/// for storing it. Used by `hivectl matrix create-user` for human +/// (non-agent) accounts so we don't create stray +/// `/var/lib/hyperhive/agents//` directories for users that +/// aren't agents (#662). For operator accounts the caller passes a +/// real password so the operator can `m.login.password` into matrix +/// web clients afterwards (#663); for headless agent re-provisioning +/// the caller can pass [`random_password`] to keep the existing +/// throwaway behaviour. pub async fn provision_user_token( client: &reqwest::Client, name: &str, register_token: &str, + password: &str, ) -> Result { - register_user(client, name, register_token).await + register_user(client, name, register_token, password).await } /// Per-agent matrix sync: ensure the agent has a matrix account + token.