diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 66bf831a..7de37400 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -16,11 +16,7 @@ keeps its state, purging it doesn't.** destroy --purge`) is `DESTR0Y` plus wiping all of it — config history, claude credentials, `/state/` notes, everything. **No undo.** Only reach for this when you actually want the agent gone - for good. The agent's matrix encryption keys go with the state dir, - but an account whose key backup is on (see - [matrix](../integrations/matrix.md)) gets them back from its - homeserver when it next comes up, so its encrypted history stays - readable. + for good. Beyond that: diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index bd363f73..7e4b6204 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -306,26 +306,12 @@ Initial rollout settings: `matrix-sdk` so it can read encrypted rooms it's invited to even when this homeserver doesn't permit room encryption. matrix-sdk stores crypto keys in the per-agent sqlite store under the state dir; they persist across - restarts. `read_room` decrypts via + restarts (lost on `--purge`). `read_room` decrypts via `room.messages()` — UTD events surface as `event_type = "m.room.encrypted"` with `body = "[unable to decrypt]"`. - The daemon bootstraps cross-signing on the bearer token alone, which - works where the homeserver skips re-auth for a first key upload - (MSC3967, as tuwunel does). -- **Key backup.** `swarm-controller` mints a random recovery passphrase - for every matrix account an agent has, at - `swarm/agents//matrix-recovery/` in the swarm secret - store, and never replaces it. When `hive-matrix-daemon` brings an - account up it reads that passphrase: with no backup on the account's - homeserver it turns on server-side key backup and secret storage - (log line `matrix recovery enabled`), unless the account already has - secret storage, which it never replaces (a `WARN` instead). With a - backup there it recovers from it (`recovered from the server-side key backup`), which downloads the - room keys and signs the new device with the account's existing - cross-signing identity. A crypto store lost to `--purge` or to a - primary-token reset therefore comes back with its history. With no passphrase - stored yet the account comes up without backup and picks it up on the - next daemon start. A failure is a `WARN` and never stops the daemon. + Cross-signing and automatic key backup aren't enabled for the first + pass: static bearer-token bot accounts can't bootstrap cross-signing + without MSC3967. ## Hive Matrix Space diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index c3101d53..0d0b5ec1 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -59,7 +59,6 @@ of the cell says how. | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `swarm/agents//matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again | | `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated | -| `swarm/agents//matrix-recovery/` | `swarm-controller`, a random passphrase for every account listed under the agent's `matrix/`, in a five-minute pass | the agent container itself, under the certificate its hive passed in, when `hive-matrix-daemon` brings the account up | ❌ never replaced: the account's server-side key backup opens only with the passphrase it was enabled with, so the pass writes only to an empty path | ❌ read when the account comes up; an account whose passphrase lands later turns backup on at the next daemon start | | `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass | | `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, at start and on refusal | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ✅ read at start and held in memory; read again when authelia refuses it, at most once per backoff of 5 s doubling to 5 min while the store holds the refused value | | `swarm/controller/swarm-controller/forge/token` | the forge, inside the `hive-forge` container, once; `swarm-bao-forge-publisher` on the forge's host copies it in under its own certificate, with a write-only grant | `swarm-controller`, under its own certificate, at start | ❌ the forge mints it once and keeps its copy in the container; the publisher writes that copy each time the forge container starts and each time the mint writes the file | ❌ the controller reads it at start, and in the background with backoff until it first finds it; a replaced token reaches it with the next restart | diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index 4afac6e9..b2fda6c9 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -62,9 +62,7 @@ public handle crosses that boundary; the token never does. A hive reads its agents' credentials out of the swarm's secret store with its own certificate. **Every hive's policy grants read on every agent's credentials**, not only on the agents it hosts — so a compromised hive can read -the matrix token of an agent running on a different hive. That includes each -account's key-backup recovery passphrase, which with the token decrypts the -agent's whole encrypted history, not only messages from then on. +the matrix token of an agent running on a different hive. That's deliberate and it's the interim state, not the intent. An agent's credential path doesn't name the hive hosting it (agents move between hives), diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index 8b61e7ec..86eaf7c7 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -12,34 +12,31 @@ //! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed //! `MatrixSession` skips the login flow entirely. //! -//! E2EE is enabled via `with_encryption_settings`. Crypto keys are persisted -//! in the sqlite store under `state_dir` (survives container restarts, lost on -//! `--purge`). +//! 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. -//! -//! Server-side key backup is turned on or recovered by `ensure_recovery`, with -//! the passphrase `swarm-controller` mints per account, so a crypto store lost -//! to a purge or a token reset gets its history back. +//! 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, bail}; +use anyhow::{Context, Result, anyhow}; use matrix_sdk::{ Client, SessionMeta, SessionTokens, authentication::matrix::MatrixSession, - encryption::{BackupDownloadStrategy, EncryptionSettings}, + encryption::EncryptionSettings, ruma::{OwnedDeviceId, OwnedUserId}, }; use serde::Deserialize; use tokio::fs; -use crate::credential::{Passphrase, Token}; +use crate::credential::Token; /// Sentinel returned when `build_and_restore` detects that the token is /// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been @@ -149,10 +146,7 @@ pub async fn build_and_restore( let client = Client::builder() .homeserver_url(homeserver) .sqlite_store(state_dir, None) - .with_encryption_settings(EncryptionSettings { - backup_download_strategy: BackupDownloadStrategy::OneShot, - ..EncryptionSettings::default() - }) + .with_encryption_settings(EncryptionSettings::default()) .build() .await .with_context(|| format!("build matrix client for {homeserver}"))?; @@ -226,11 +220,11 @@ async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDevi /// the failure is logged and swallowed, leaving the session no worse off than /// before. Never breaks account bring-up. /// -/// This only signs the *current* device against a freshly-bootstrapped -/// identity. A device under an already existing identity is a no-op here; it -/// is signed by [`ensure_recovery`]'s `recover`, which imports the -/// self-signing key from secret storage, and stays unverified for an account -/// with no recovery passphrase. +/// 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() @@ -253,146 +247,6 @@ pub async fn ensure_cross_signing(client: &Client, account: &str) { } } -/// What [`ensure_recovery`] did. -enum Backup { - /// This device was already connected to the account's backup. - AlreadyOn, - /// The server had no backup; one was created, with secret storage that - /// `passphrase` opens. - Enabled, - /// The server had a backup; the keys secret storage holds for it, and - /// for cross-signing if it holds those, were imported. - Restored, -} - -/// Best-effort: turn on server-side key backup for this account, or recover -/// from the backup it already has, with the account's recovery `passphrase`. -/// -/// Run after [`ensure_cross_signing`], so a newly bootstrapped identity's -/// private keys are local when `enable` exports them into secret storage, and -/// an existing identity is left for `recover` to import. After a recover the -/// room keys download in the background (`BackupDownloadStrategy::OneShot`). -/// -/// Every failure is logged and swallowed, as in [`ensure_cross_signing`]: the -/// account comes up as it would have without backup. That includes secret -/// storage this passphrase does not open, which is never replaced: with a -/// backup, `recover` fails; without one, this refuses. -pub async fn ensure_recovery(client: &Client, account: &str, passphrase: &Passphrase) { - match recover_or_enable(client, passphrase.expose()).await { - Ok(Backup::AlreadyOn) => { - tracing::debug!(account, "matrix key backup already on for this device"); - } - Ok(Backup::Enabled) => tracing::info!( - account, - passphrase = %passphrase.path(), - "matrix recovery enabled: server-side key backup created" - ), - Ok(Backup::Restored) => tracing::info!( - account, - passphrase = %passphrase.path(), - "matrix recovery: recovered from the server-side key backup; room keys download in the background" - ), - Err(e) => tracing::warn!( - account, - passphrase = %passphrase.path(), - error = %format!("{e:#}"), - "matrix recovery failed; key backup stays off (non-fatal)" - ), - } -} - -/// The homeserver and device calls [`recover_or_enable`] decides from, so a -/// test can stand in for matrix-sdk. -trait KeyBackup { - /// Wait for the e2ee tasks a restored client starts in the background. - async fn settle(&self); - /// Whether this device is already connected to the account's backup. - async fn on_this_device(&self) -> bool; - /// Whether the homeserver holds a key backup version. - async fn backup_on_server(&self) -> Result; - /// Whether the account has a default secret-storage key. - async fn secret_storage_on_server(&self) -> Result; - /// Open secret storage with `passphrase` and import what it holds. - async fn recover(&self, passphrase: &str) -> Result<()>; - /// Create a backup and new secret storage that `passphrase` opens. - async fn enable(&self, passphrase: &str) -> Result<()>; -} - -impl KeyBackup for Client { - async fn settle(&self) { - self.encryption().wait_for_e2ee_initialization_tasks().await; - } - - async fn on_this_device(&self) -> bool { - self.encryption().backups().are_enabled().await - } - - async fn backup_on_server(&self) -> Result { - Ok(self.encryption().backups().fetch_exists_on_server().await?) - } - - async fn secret_storage_on_server(&self) -> Result { - Ok(self.encryption().secret_storage().is_enabled().await?) - } - - async fn recover(&self, passphrase: &str) -> Result<()> { - Ok(self.encryption().recovery().recover(passphrase).await?) - } - - async fn enable(&self, passphrase: &str) -> Result<()> { - // The recovery key `enable` returns is not kept: the passphrase opens - // the same secret storage. - self.encryption() - .recovery() - .enable() - .with_passphrase(passphrase) - .await?; - Ok(()) - } -} - -/// The body of [`ensure_recovery`], with its outcome returned rather than -/// logged. -/// -/// ⚠️ `enable` makes a new secret-storage key and sets it as the account's -/// default, whatever default was there. With no backup but secret storage -/// present, this refuses rather than enable: that default key is someone -/// else's, and replacing it locks their recovery key out. -async fn recover_or_enable(account: &impl KeyBackup, passphrase: &str) -> Result { - // A device restored from its own store reconnects to the backup in a - // background task; asking before it finishes says no. - account.settle().await; - if account.on_this_device().await { - return Ok(Backup::AlreadyOn); - } - if account - .backup_on_server() - .await - .context("asking the homeserver for the account's key backup")? - { - account - .recover(passphrase) - .await - .context("recovering from the key backup")?; - return Ok(Backup::Restored); - } - if account - .secret_storage_on_server() - .await - .context("asking the homeserver for the account's secret storage")? - { - bail!( - "the account has secret storage but no key backup; not enabling, \ - which would replace a default secret-storage key this daemon did not create" - ); - } - account - .enable(passphrase) - .await - .context("enabling key backup")?; - Ok(Backup::Enabled) -} - /// 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). @@ -452,103 +306,3 @@ pub async fn sync_avatar(client: &Client, state_dir: &Path, account: &str) { } } } - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use super::*; - - /// An account as the homeserver and this device describe it, recording - /// which of the two writes [`recover_or_enable`] made. - #[derive(Default)] - struct Fake { - on_device: bool, - backup: bool, - secret_storage: bool, - calls: Mutex>, - } - - impl KeyBackup for Fake { - async fn settle(&self) {} - - async fn on_this_device(&self) -> bool { - self.on_device - } - - async fn backup_on_server(&self) -> Result { - Ok(self.backup) - } - - async fn secret_storage_on_server(&self) -> Result { - Ok(self.secret_storage) - } - - async fn recover(&self, _: &str) -> Result<()> { - self.calls.lock().unwrap().push("recover"); - Ok(()) - } - - async fn enable(&self, _: &str) -> Result<()> { - self.calls.lock().unwrap().push("enable"); - Ok(()) - } - } - - fn calls(fake: &Fake) -> Vec<&'static str> { - fake.calls.lock().unwrap().clone() - } - - #[tokio::test] - async fn secret_storage_without_a_backup_is_not_enabled_over() { - // `enable` would replace the account's default secret-storage key, - // which another client's recovery key opens. - let fake = Fake { - secret_storage: true, - ..Fake::default() - }; - assert!(recover_or_enable(&fake, "p").await.is_err()); - assert!(calls(&fake).is_empty(), "{:?}", calls(&fake)); - } - - #[tokio::test] - async fn an_account_with_nothing_on_the_server_is_enabled() { - // Control for the test above: the guard is the only thing stopping - // `enable` there. - let fake = Fake::default(); - assert!(matches!( - recover_or_enable(&fake, "p").await.unwrap(), - Backup::Enabled - )); - assert_eq!(calls(&fake), ["enable"]); - } - - #[tokio::test] - async fn a_backup_on_the_server_is_recovered_not_enabled() { - let fake = Fake { - backup: true, - secret_storage: true, - ..Fake::default() - }; - assert!(matches!( - recover_or_enable(&fake, "p").await.unwrap(), - Backup::Restored - )); - assert_eq!(calls(&fake), ["recover"]); - } - - #[tokio::test] - async fn a_device_already_on_the_backup_writes_nothing() { - let fake = Fake { - on_device: true, - backup: true, - secret_storage: true, - ..Fake::default() - }; - assert!(matches!( - recover_or_enable(&fake, "p").await.unwrap(), - Backup::AlreadyOn - )); - assert!(calls(&fake).is_empty()); - } -} diff --git a/hive-matrix-mcp/src/credential.rs b/hive-matrix-mcp/src/credential.rs index 97324ca0..da283457 100644 --- a/hive-matrix-mcp/src/credential.rs +++ b/hive-matrix-mcp/src/credential.rs @@ -1,6 +1,5 @@ //! The matrix access token as a value this process holds, and the two places -//! it comes from; the accounts the store links to this agent; and each -//! account's key-backup recovery passphrase. +//! it comes from; and the accounts the store links to this agent. //! //! 🩸 **A secret is a path, not a value.** Nothing here writes the token //! anywhere, interpolates it into a command, or lets it reach a log line or an @@ -180,73 +179,6 @@ impl Token { } } -/// The passphrase that opens an account's server-side key backup, held in -/// memory for as long as it takes to enable or recover with it. -/// -/// ⚠️ **No `Debug` derive**, for the reason [`Token`] has none: with the token, -/// this decrypts every message the account has backed up. -pub struct Passphrase { - value: String, - path: String, -} - -impl std::fmt::Debug for Passphrase { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Passphrase") - .field("path", &self.path) - .field("value", &"") - .finish() - } -} - -impl Passphrase { - /// The bytes, for the one caller that hands them to matrix-sdk. - #[must_use] - pub fn expose(&self) -> &str { - &self.value - } - - /// The store path it was read from — the only half of it that may be - /// printed. - #[must_use] - pub fn path(&self) -> &str { - &self.path - } - - /// Read this agent's recovery passphrase for `account` out of the swarm - /// secret store, under this agent's own certificate. - /// - /// `Ok(None)` when this container was given no store, or the store holds - /// nothing at the path yet: `swarm-controller` mints it on a periodic - /// pass, so a freshly linked account can come up before it exists. - /// - /// # Errors - /// A name that is not a single path segment, an environment naming an - /// identity that cannot be read, a store that refuses the certificate or - /// the path, or a passphrase stored empty. - pub async fn from_store(agent: &str, account: &str) -> Result> { - let path = matrix::recovery_path(agent, account) - .context("building this agent's recovery passphrase path in the store")?; - let Some(store) = connect(agent).await? else { - return Ok(None); - }; - let Some(stored): Option = store - .read_optional(&path) - .await - .with_context(|| format!("reading {path} from the store"))? - else { - return Ok(None); - }; - let value = stored.value.trim().to_owned(); - if value.is_empty() { - return Err(anyhow!( - "the recovery passphrase at {path} in the store is empty" - )); - } - Ok(Some(Self { value, path })) - } -} - /// An account the store links to this agent: its name, and the homeserver /// stored beside its token. #[derive(Debug, Clone, PartialEq, Eq)] @@ -408,24 +340,6 @@ mod tests { ); } - #[test] - fn formatting_a_passphrase_does_not_reveal_it() { - let p = Passphrase { - value: "SUPER_SECRET_PASSPHRASE".to_owned(), - path: "swarm/agents/a1/matrix-recovery/main".to_owned(), - }; - let rendered = format!("{p:?}"); - assert!( - !rendered.contains("SUPER_SECRET_PASSPHRASE"), - "got {rendered}" - ); - // Control: the path is meant to survive. - assert!( - rendered.contains("swarm/agents/a1/matrix-recovery/main"), - "got {rendered}" - ); - } - /// The same property for the arm a reader actually reaches for: `Display` /// on the origin is what every message in this crate interpolates. #[test] diff --git a/hive-matrix-mcp/src/main.rs b/hive-matrix-mcp/src/main.rs index 6200ebe9..40c78aab 100644 --- a/hive-matrix-mcp/src/main.rs +++ b/hive-matrix-mcp/src/main.rs @@ -408,28 +408,6 @@ async fn account_token(cfg: &AccountCfg) -> Result> { } } -/// Read `account`'s recovery passphrase from the store and hand it to -/// [`client::ensure_recovery`]. With none — no store, or `swarm-controller` -/// has not minted it yet — key backup stays off until the next start. -async fn ensure_recovery(client: &Client, account: &str) { - let found = match credential::agent_name() { - Some(agent) => credential::Passphrase::from_store(&agent, account).await, - None => Ok(None), - }; - match found { - Ok(Some(passphrase)) => client::ensure_recovery(client, account, &passphrase).await, - Ok(None) => tracing::info!( - account, - "no matrix recovery passphrase in the store; key backup stays off" - ), - Err(e) => tracing::warn!( - account, - error = %format!("{e:#}"), - "could not read the matrix recovery passphrase; key backup stays off" - ), - } -} - /// Restore one account's client (when its token exists), install its /// message handler, and build its sync loop. Returns /// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)` @@ -472,8 +450,6 @@ async fn bring_up_account( // flagged "unverified" in other users' clients. Idempotent + swallows // failures (a UIAA-requiring homeserver) inside ensure_cross_signing. client::ensure_cross_signing(&client, &cfg.name).await; - // Best-effort, and after cross-signing: see `client::ensure_recovery`. - ensure_recovery(&client, &cfg.name).await; let sync_client = client.clone(); let cb_client = client.clone(); diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 06b6c3c3..874e09c0 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -2476,17 +2476,15 @@ fn configured_matrix_homeserver() -> Option> { .map(Arc::from) } -/// Start the recovery passphrase pass, which needs only the store; the -/// matrix-account backfill and the hive sender-token pass when a homeserver is -/// configured; and the operator admin pass when the bridge is too. Lifted out -/// of `main` for `clippy::too_many_lines`. +/// Start the matrix-account backfill and the hive sender-token pass when a +/// homeserver is configured, and the operator admin pass when the bridge is +/// too. Lifted out of `main` for `clippy::too_many_lines`. fn spawn_matrix_account_backfill( jobq: &Arc>>, homeserver: Option>, hives: &[HiveEntry], auth: Option>, ) { - matrix_account::recovery::spawn(); let Some(base) = homeserver else { return; }; diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 0b891c20..19258645 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -7,8 +7,6 @@ //! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`]. //! [`operator_admin`] makes authelia's operators homeserver admins with the same //! appservice token. -//! [`recovery`] mints the passphrase that opens each account's server-side key -//! backup, for the swarm's accounts and external ones alike. //! //! **The external one** — [`put_matrix_account`] and everything under it — is //! an account somewhere else that an operator hands us a credential for: put it @@ -39,7 +37,6 @@ use super::{AppState, error_problem, swarm_hive}; pub mod agent_token; pub mod hive_sender; pub mod operator_admin; -pub mod recovery; fn default_mode() -> String { "token".to_owned() diff --git a/swarm-controller/src/matrix_account/recovery.rs b/swarm-controller/src/matrix_account/recovery.rs deleted file mode 100644 index c3c7984c..00000000 --- a/swarm-controller/src/matrix_account/recovery.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! A recovery passphrase for every matrix account of every agent, stored at -//! `swarm_secret_client::matrix::recovery_path`: what the agent's matrix daemon -//! turns on server-side key backup with, and recovers its keys with once its -//! local crypto store is gone. -//! -//! Minted here because the agent's store policy is read-only. [`spawn`]'s pass -//! runs at start and every [`RECONCILE_INTERVAL`] over every agent that holds a -//! store identity — the same roster as [`super::agent_token`] — and every -//! account listed under that agent's `matrix/` directory. -//! -//! ⚠️ **Never replaced.** A backup can only be opened with the passphrase it -//! was enabled with, so a new one locks the agent out of everything backed up -//! under the old. The write is a `cas=0` create, and a path that holds anything -//! is left alone. - -use anyhow::{Context, Result}; -use serde::de::IgnoredAny; -use swarm_secret_client::{Error, SecretStore, client::DEFAULT_CERT_MOUNT, matrix, policy}; - -/// How often [`spawn`] re-checks every account. -const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); - -/// Bytes drawn from the kernel's CSPRNG per passphrase. -const PASSPHRASE_BYTES: usize = 32; - -/// What [`ensure`] did at one path. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Outcome { - /// The path already held a passphrase. - Present, - /// This pass wrote one. - Minted, - /// Another writer's create landed between this pass's read and its own. - LostRace, -} - -/// The two store calls [`ensure`] makes, so a test can stand in for the store. -pub(crate) trait PassphraseStore { - /// Whether anything is stored at `path`, as [`SecretStore::read_optional`] - /// answers it. The value is decoded as [`IgnoredAny`] and never held. - fn exists(&self, path: &str) -> impl Future> + Send; - - /// As [`SecretStore::create`]. - fn create( - &self, - path: &str, - value: &matrix::Recovery, - ) -> impl Future> + Send; -} - -impl PassphraseStore for SecretStore { - async fn exists(&self, path: &str) -> Result { - Ok(self.read_optional::(path).await?.is_some()) - } - - async fn create(&self, path: &str, value: &matrix::Recovery) -> Result<(), Error> { - SecretStore::create(self, path, value).await - } -} - -/// A passphrase: [`PASSPHRASE_BYTES`] from the kernel's CSPRNG, base64url -/// without padding. -/// -/// # Errors -/// When the kernel will not supply randomness. -fn generate() -> Result { - let mut bytes = [0u8; PASSPHRASE_BYTES]; - getrandom::fill(&mut bytes) - .context("drawing a recovery passphrase from the kernel's CSPRNG")?; - Ok(matrix::Recovery { - value: base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes), - }) -} - -/// Make sure `path` holds a passphrase, writing one only if it holds nothing. -/// -/// A create that fails is read back: if the path now holds something, a -/// concurrent writer won and that is the passphrase. -/// -/// # Errors -/// When the store refuses the read, or the create fails and the path is still -/// empty. -pub(crate) async fn ensure(store: &impl PassphraseStore, path: &str) -> Result { - if store - .exists(path) - .await - .with_context(|| format!("reading {path}"))? - { - return Ok(Outcome::Present); - } - let Err(e) = store.create(path, &generate()?).await else { - return Ok(Outcome::Minted); - }; - if store - .exists(path) - .await - .with_context(|| format!("reading {path} back after a failed create"))? - { - return Ok(Outcome::LostRace); - } - Err(anyhow::Error::new(e).context(format!("creating {path}"))) -} - -/// The account names listed under `agent`'s `matrix/` directory. A key ending -/// in `/` is a directory below it, not an account. -async fn accounts(store: &SecretStore, agent: &str) -> Result> { - let dir = matrix::accounts_dir(agent)?; - let keys = store - .list(&dir) - .await - .with_context(|| format!("listing {dir}"))?; - Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect()) -} - -/// One pass over every agent's accounts. Returns how many paths were checked -/// and how many were minted. A failure for one agent or account is logged and -/// the pass carries on. -async fn pass() -> Result<(usize, usize)> { - let store = crate::store::connect() - .await - .context("logging in to the swarm secret store")?; - let roles = store - .list_cert_roles(DEFAULT_CERT_MOUNT) - .await - .context("listing the store's cert-auth roles")?; - let (mut checked, mut minted) = (0, 0); - for agent in policy::agents_from_role_names(&roles) { - let names = match accounts(&store, &agent).await { - Ok(names) => names, - Err(e) => { - tracing::warn!(agent, error = %format!("{e:#}"), "matrix recovery passphrases: listing failed"); - continue; - } - }; - for account in names { - checked += 1; - let path = match matrix::recovery_path(&agent, &account) { - Ok(path) => path, - Err(e) => { - tracing::warn!(agent, account, error = %e, "matrix recovery passphrase: unusable account name"); - continue; - } - }; - match ensure(&store, &path).await { - Ok(Outcome::Present) => {} - Ok(Outcome::Minted) => { - minted += 1; - tracing::info!(agent, account, %path, "matrix recovery passphrase minted"); - } - Ok(Outcome::LostRace) => { - tracing::info!(agent, account, %path, "matrix recovery passphrase: another writer created it first"); - } - Err(e) => tracing::warn!( - agent, - account, - error = %format!("{e:#}"), - "matrix recovery passphrase: mint failed" - ), - } - } - } - Ok((checked, minted)) -} - -/// Run [`pass`] now and every [`RECONCILE_INTERVAL`] after. A pass that fails -/// is logged and retried on the next tick; it never stops the daemon. -pub fn spawn() { - tokio::spawn(async move { - let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); - loop { - ticker.tick().await; - match pass().await { - Ok((checked, minted)) => { - tracing::debug!(checked, minted, "matrix recovery passphrases: pass done"); - } - Err(e) => tracing::warn!( - error = %format!("{e:#}"), - retry_in_s = RECONCILE_INTERVAL.as_secs(), - "matrix recovery passphrases: pass failed; retrying next tick" - ), - } - } - }); -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::Mutex; - - use super::*; - - /// An in-memory store. `race` makes the next create find the path taken by - /// someone else, the way a concurrent `cas=0` loses. - #[derive(Default)] - struct Fake { - held: Mutex>, - race: Option, - refuse_reads: bool, - } - - /// A store error with no store behind it. - fn refused() -> Error { - Error::MissingEnv("BAO_ADDR") - } - - impl PassphraseStore for Fake { - async fn exists(&self, path: &str) -> Result { - if self.refuse_reads { - return Err(refused()); - } - Ok(self.held.lock().unwrap().contains_key(path)) - } - - async fn create(&self, path: &str, value: &matrix::Recovery) -> Result<(), Error> { - let mut held = self.held.lock().unwrap(); - if let Some(winner) = &self.race { - held.insert(path.to_owned(), winner.clone()); - return Err(refused()); - } - if held.contains_key(path) { - return Err(refused()); - } - held.insert(path.to_owned(), value.value.clone()); - Ok(()) - } - } - - const PATH: &str = "swarm/agents/atlas/matrix-recovery/main"; - - #[tokio::test] - async fn an_empty_path_gets_a_passphrase() { - let store = Fake::default(); - assert_eq!(ensure(&store, PATH).await.unwrap(), Outcome::Minted); - assert!(store.held.lock().unwrap().contains_key(PATH)); - } - - #[tokio::test] - async fn a_held_passphrase_is_never_replaced() { - let store = Fake::default(); - store - .held - .lock() - .unwrap() - .insert(PATH.to_owned(), "first".to_owned()); - assert_eq!(ensure(&store, PATH).await.unwrap(), Outcome::Present); - assert_eq!(store.held.lock().unwrap()[PATH], "first"); - } - - #[tokio::test] - async fn a_create_another_writer_beat_counts_as_done() { - let store = Fake { - race: Some("winner".to_owned()), - ..Fake::default() - }; - assert_eq!(ensure(&store, PATH).await.unwrap(), Outcome::LostRace); - assert_eq!(store.held.lock().unwrap()[PATH], "winner"); - } - - #[tokio::test] - async fn a_store_that_cannot_be_read_gets_no_write() { - // Reading "absent" into a refusal would overwrite a live passphrase - // whenever the store was merely unreachable. - let store = Fake { - refuse_reads: true, - ..Fake::default() - }; - assert!(ensure(&store, PATH).await.is_err()); - assert!(store.held.lock().unwrap().is_empty()); - } - - #[test] - fn a_passphrase_is_32_random_bytes_in_a_plain_alphabet() { - let a = generate().unwrap().value; - let b = generate().unwrap().value; - // 32 bytes is 43 base64 characters without padding. - assert_eq!(a.len(), 43, "{a}"); - assert!( - a.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), - "{a}" - ); - assert_ne!(a, b); - } -} diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index e20502f9..ab8eabed 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -35,41 +35,6 @@ pub fn accounts_dir(agent: &str) -> Result { Ok(format!("{prefix}/matrix")) } -/// The path holding the passphrase that opens the server-side key backup of -/// `agent`'s matrix account `account`. -/// -/// A directory of its own beside [`accounts_dir`], not a name under it: every -/// object listed there is read as an account, so a leaf placed there would be -/// brought up as one. -/// -/// # Errors -/// [`Error::PathSegment`] when either name contains anything but -/// `[A-Za-z0-9_-]`. -pub fn recovery_path(agent: &str, account: &str) -> Result { - checked_segment("account", account)?; - let prefix = principal_prefix(Kind::Agent, agent)?; - Ok(format!("{prefix}/matrix-recovery/{account}")) -} - -/// What a [`recovery_path`] holds: the passphrase `swarm-controller` mints and -/// the agent's matrix daemon enables or recovers key backup with. -/// -/// No `Debug` derive: with the account's token, `value` decrypts every message -/// the account has backed up. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Recovery { - /// Named `value` like [`Credential::value`]. - pub value: String, -} - -impl std::fmt::Debug for Recovery { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Recovery") - .field("value", &"") - .finish() - } -} - /// The localpart `hive` acts as on the homeserver: the account /// `swarm-controller` creates for it with the swarm's appservice token, and /// the one hive-c0re's sender token belongs to. @@ -167,55 +132,6 @@ mod tests { assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay"); } - #[test] - fn the_recovery_passphrase_lands_beside_the_accounts_not_among_them() { - // Spelled out for the reason above. - let p = recovery_path("atlas", "ops-relay").expect("both segments are legal"); - assert_eq!(p, "swarm/agents/atlas/matrix-recovery/ops-relay"); - let dir = accounts_dir("atlas").expect("legal"); - assert!( - !p.starts_with(&format!("{dir}/")), - "{p} is under {dir}, where every object is read as an account" - ); - // Control: an account path is under it, so the assertion above can fail. - let account = account_path("atlas", "ops-relay").expect("legal"); - assert!(account.starts_with(&format!("{dir}/"))); - } - - #[test] - fn the_agent_reads_its_own_recovery_passphrase_and_no_other() { - // The daemon reads this under the agent's own policy, so the rendered - // document is what has to cover it. - let doc = crate::policy::render_agent("atlas").expect("a plain name is legal"); - let granted: Vec<&str> = doc - .lines() - .filter_map(|l| l.strip_prefix("path \"secret/data/")) - .filter_map(|p| p.strip_suffix("*\" {")) - .collect(); - let reads = |path: &str| granted.iter().any(|g| path.starts_with(g)); - let own = recovery_path("atlas", "main").expect("legal"); - assert!(reads(&own), "{own} is not readable under {granted:?}"); - let other = recovery_path("argus", "main").expect("legal"); - assert!(!reads(&other), "{other} is readable under {granted:?}"); - } - - #[test] - fn a_recovery_passphrase_does_not_survive_formatting() { - let r = Recovery { - value: "SUPER_SECRET_PASSPHRASE".to_owned(), - }; - let rendered = format!("{r:?}"); - assert!(!rendered.contains("SUPER_SECRET_PASSPHRASE"), "{rendered}"); - assert!(rendered.contains(""), "{rendered}"); - } - - #[test] - fn a_traversal_is_refused_by_the_recovery_path_too() { - assert!(recovery_path("../argus", "main").is_err()); - assert!(recovery_path("atlas", "../main").is_err()); - assert!(recovery_path("a-b_C9", "d-e_F0").is_ok()); - } - #[test] fn the_sender_token_lands_under_the_hive_prefix_the_grant_covers() { // Spelled out rather than rebuilt from the same pieces the code uses,