//! matrix-sdk `Client` setup for the daemon: read the per-agent access //! token from the state-dir file `hive-c0re::matrix::ensure_user_for` //! wrote, probe `whoami` to recover the agent's matrix `user_id` + //! `device_id`, restore the matrix-sdk session, return the Client ready //! to start sync. //! //! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's //! ccc.de connection): for the in-hive tuwunel the `registration_token` //! UIAA flow already minted the token + user/device, hive-c0re just //! handed us the bearer in a file. matrix-sdk's `restore_session` with //! a constructed `MatrixSession` skips the login flow entirely. //! //! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`. //! Crypto keys are persisted in the sqlite store under `state_dir` //! (survives container restarts, lost on `--purge`). //! //! Cross-signing is bootstrapped best-effort after session restore //! (`ensure_cross_signing`) so the agent's device stops showing as //! "unverified" in other users' Matrix clients: the in-hive tuwunel accepts //! the initial device-signing-key upload on the bearer token alone (no UIAA //! re-auth). A homeserver that DID require UIAA just fails the call, which is //! swallowed — the session stays exactly as unverified as before. Automatic //! key backup is still left at its default (disabled). use std::path::Path; use anyhow::{Context, Result, anyhow}; use matrix_sdk::{ Client, SessionMeta, SessionTokens, authentication::matrix::MatrixSession, encryption::EncryptionSettings, ruma::{OwnedDeviceId, OwnedUserId}, }; use serde::Deserialize; use tokio::fs; /// Sentinel returned when `build_and_restore` detects that the token is /// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been /// removed from disk. Callers should NOT retry — the account needs /// re-provisioning by hive-c0re. /// /// Distinct from the general `anyhow::Error` path so callers can use /// `err.downcast_ref::()` to distinguish "retry /// won't help" from a transient network/DNS/5xx failure. #[derive(Debug)] pub struct PermanentBringUpError(pub String); impl std::fmt::Display for PermanentBringUpError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl std::error::Error for PermanentBringUpError {} /// Subset of the `/_matrix/client/v3/account/whoami` response we care /// about. matrix-spec field names; `device_id` is optional per spec /// (servers MAY omit it for legacy bearer scopes) but tuwunel always /// returns it. #[derive(Debug, Deserialize)] struct WhoamiResponse { user_id: String, device_id: Option, } /// Build + restore a matrix-sdk `Client` for the per-agent bearer /// token at `token_file`. The Client points at `homeserver`, persists /// its sqlite cache under `state_dir`, and is ready for sync once /// returned. /// /// Steps: /// 1. Read the bearer token from `token_file` (trim trailing whitespace). /// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with /// the bearer — this gives us back the matrix `user_id` + /// `device_id` (the registration response had them but hive-c0re /// only persisted the token; whoami is the cheapest recovery path /// and avoids matrix-sdk's circular requirement of needing a /// session to call whoami). /// 3. Build the real Client with the sqlite store + `restore_session` /// using a synthetic `MatrixSession`. /// /// # Errors /// /// Returns an error if the token file is missing or empty, if the /// `whoami` request fails, or if the matrix-sdk client fails to build /// or restore the session. pub async fn build_and_restore( homeserver: &str, token_file: &Path, state_dir: &Path, is_primary: bool, ) -> Result { let token = fs::read_to_string(token_file) .await .with_context(|| format!("read matrix token from {}", token_file.display()))? .trim() .to_owned(); if token.is_empty() { return Err(anyhow!("matrix token at {} is empty", token_file.display())); } let (user_id, device_id) = match whoami(homeserver, &token).await { Ok(ids) => ids, Err(e) => { let msg = format!("{e:#}"); if msg.contains("M_UNKNOWN_TOKEN") { // Homeserver rejected our token — stale session after a homeserver // state wipe or token expiry. Delete the stale token file so we // don't loop on it. The rest of the handling depends on whether // this is the primary (hive-internal) account or a secondary one, // because a single bad secondary token must NOT take down the // whole daemon (and with it every healthy account). tracing::warn!( path = %token_file.display(), is_primary, "matrix token rejected (M_UNKNOWN_TOKEN); removing stale token" ); let _ = fs::remove_file(token_file).await; if is_primary { // Primary: also drop the matrix-sdk sqlite state keyed to the // now-invalid session, then exit 0 so hive-c0re's `ensure_all` // re-provisions the account and the systemd.paths watcher // restarts us once the fresh token file appears. Exit 0 (not // Err) keeps systemd's Restart=on-failure from looping; the // call site is before any tasks are spawned so there's // nothing to clean up. if let Err(re) = fs::remove_dir_all(state_dir).await { tracing::warn!( path = %state_dir.display(), err = %re, "failed to remove sdk state dir; next startup may fail with stale state" ); } std::process::exit(0); } // Secondary: token removed (so it's cleanly skipped next boot // rather than re-erroring); leave the sdk state in place in case // the operator re-provisions a fresh token for the same device. // Return a PermanentBringUpError so the caller can distinguish // "don't retry" from a transient network/DNS failure. return Err(anyhow::Error::new(PermanentBringUpError( "matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(), ))); } return Err(e); } }; fs::create_dir_all(state_dir) .await .with_context(|| format!("mkdir matrix state dir {}", state_dir.display()))?; let client = Client::builder() .homeserver_url(homeserver) .sqlite_store(state_dir, None) .with_encryption_settings(EncryptionSettings::default()) .build() .await .with_context(|| format!("build matrix client for {homeserver}"))?; let session = MatrixSession { meta: SessionMeta { user_id, device_id }, tokens: SessionTokens { access_token: token, refresh_token: None, }, }; client .restore_session(session) .await .context("restore matrix session")?; tracing::info!( user = %client.user_id().map(ToString::to_string).unwrap_or_default(), device = %client.device_id().map(ToString::to_string).unwrap_or_default(), "matrix session restored" ); Ok(client) } /// Bare-reqwest whoami probe — used at startup to recover the /// `user_id` + `device_id` the registration response carried but /// hive-c0re didn't persist alongside the access token. Cheaper than /// teaching the hive-c0re side to persist them, plus matches what a /// fresh deployment with a hand-rolled token (`HIVE_MATRIX_TOKEN_FILE` /// pointing somewhere unexpected) needs anyway. async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> { let url = format!("{homeserver}/_matrix/client/v3/account/whoami"); let resp = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .context("build whoami reqwest client")? .get(&url) .bearer_auth(token) .send() .await .with_context(|| format!("GET {url}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(anyhow!("whoami GET {url} → HTTP {status}, body: {body}")); } let body: WhoamiResponse = resp.json().await.context("parse whoami response")?; let user_id: OwnedUserId = body .user_id .parse() .with_context(|| format!("invalid user_id in whoami: {}", body.user_id))?; let device_id_raw = body .device_id .ok_or_else(|| anyhow!("whoami response missing device_id"))?; let device_id: OwnedDeviceId = device_id_raw.into(); Ok((user_id, device_id)) } /// Best-effort: bootstrap this account's cross-signing identity so its /// device stops showing as "unverified" in other users' Matrix clients. /// /// `bootstrap_cross_signing_if_needed(None)` is idempotent — it queries the /// account's existing keys and only uploads the master / self-signing / /// user-signing key set when no cross-signing identity exists yet. That /// upload also self-signs the current device, which is what actually clears /// the unverified warning. On a restart with the identity already present /// it's a no-op. /// /// The `None` is the UIAA auth: the in-hive tuwunel accepts the initial /// `device_signing/upload` on the bearer token alone (no interactive /// re-auth). If a homeserver required UIAA the call returns a `UiaaResponse` /// error we can't satisfy with a bare bearer token — so this is best-effort: /// the failure is logged and swallowed, leaving the session no worse off than /// before. Never breaks account bring-up. /// /// Known gap (self-verify only): this signs the *current* device against a /// freshly-bootstrapped identity. A device re-provisioned under an already /// existing identity (new `device_id`, e.g. after a token reissue) would need /// the cross-signing secret keys via secret storage to re-sign itself — /// tracked as the longer-term mutual-verification follow-up. pub async fn ensure_cross_signing(client: &Client, account: &str) { match client .encryption() .bootstrap_cross_signing_if_needed(None) .await { Ok(()) => { tracing::info!( account, "matrix cross-signing ensured (device self-verified)" ); } Err(e) => { tracing::warn!( account, error = %format!("{e:#}"), "matrix cross-signing bootstrap failed; session stays unverified (non-fatal)" ); } } } /// Best-effort: set this account's matrix avatar from the rasterized icon /// PNG. The path comes from `HIVE_ICON_PNG` (a nix-built derivation the /// harness forwards into the daemon env; absent when no icon resolves). /// Idempotent via a per-account `avatar-icon-hash` file in the account's /// sdk `state_dir` — a re-upload only happens when the icon bytes change, /// so daemon restarts don't re-spam the homeserver. /// /// This replaces the old `matrix-avatar-sync` systemd curl oneshot: the /// daemon already holds an authenticated `Client` pointed at the correct /// homeserver, so it uploads over the live connection — no hardcoded URL, /// no token re-read, no token-file globbing. Any failure is logged and /// swallowed: avatar trouble must never break account bring-up or sync. pub async fn sync_avatar(client: &Client, state_dir: &Path, account: &str) { let Ok(png_path) = std::env::var("HIVE_ICON_PNG") else { return; // no icon forwarded → nothing to sync }; let bytes = match fs::read(&png_path).await { Ok(b) => b, Err(e) => { tracing::warn!(account, path = %png_path, error = %e, "matrix avatar: icon PNG unreadable; skipping"); return; } }; // FNV-1a content hash — enough to answer "did the icon change?" and // deterministic across Rust/std versions (unlike `DefaultHasher`, whose // output may change between toolchains, spuriously mismatching the // persisted hash and re-uploading the same avatar). No crypto strength // needed here, so no dependency on a hashing crate. let hash = { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for &b in &bytes { h ^= u64::from(b); h = h.wrapping_mul(0x0000_0100_0000_01b3); } format!("{h:016x}") }; let hash_file = state_dir.join("avatar-icon-hash"); if let Ok(prev) = fs::read_to_string(&hash_file).await && prev.trim() == hash { tracing::debug!(account, "matrix avatar: icon unchanged; skipping upload"); return; } match client .account() .upload_avatar(&mime::IMAGE_PNG, bytes) .await { Ok(mxc) => { tracing::info!(account, mxc = %mxc, "matrix avatar set"); if let Err(e) = fs::write(&hash_file, &hash).await { tracing::warn!(account, error = %e, "matrix avatar: set ok but hash write failed (re-uploads next start)"); } } Err(e) => { tracing::warn!(account, error = %format!("{e:#}"), "matrix avatar: upload failed; skipping (non-fatal)"); } } }