Watch
0
0
Fork
You've already forked hyperhive
0

matrix: back up agents' room keys with a controller-minted recovery passphrase

An agent's matrix encryption keys lived only in its local crypto store, so
`destroy --purge` or a primary-account token reset lost every encrypted
room's history with no way back (#5022).

swarm-controller now mints a random passphrase (32 bytes from the kernel's
CSPRNG, base64url) for every account listed under each agent's `matrix/`
directory, at `swarm/agents/<agent>/matrix-recovery/<account>`, in a
five-minute pass. The write is a cas=0 create after a read; a path that
holds anything is never replaced, and a create that loses a race counts as
done. The controller's existing `data/swarm/agents/*` grant covers the
write and the agent's own `agents/<agent>/*` read grant covers the read, so
no policy changes.

hive-matrix-daemon reads the passphrase when it brings an account up, after
cross-signing: with no backup on the homeserver it enables recovery with it
(backup + secret storage); with one there it recovers, which imports the
backup key and the self-signing key, and room keys then download through
`BackupDownloadStrategy::OneShot`. No passphrase yet logs at INFO and the
account comes up without backup; any failure logs WARN and never stops the
daemon.

Recovery runs after `ensure_cross_signing`, not before: on a new account
cross-signing must exist locally for `enable` to export it into secret
storage, and on an existing identity `bootstrap_cross_signing_if_needed` is
a no-op, leaving `recover` to import and sign.

Part of #5022 (S1+S2, with the S4 docs).
This commit is contained in:
atlas 2026-10-13 01:19:38 +02:00
commit 45568f6e60
11 changed files with 615 additions and 23 deletions

View file

@ -12,17 +12,20 @@
//! 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(EncryptionSettings::default())`.
//! Crypto keys are persisted in the sqlite store under `state_dir`
//! (survives container restarts, lost on `--purge`).
//! E2EE is enabled via `with_encryption_settings`. 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).
//! 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.
use std::path::Path;
@ -30,13 +33,13 @@ use anyhow::{Context, Result, anyhow};
use matrix_sdk::{
Client, SessionMeta, SessionTokens,
authentication::matrix::MatrixSession,
encryption::EncryptionSettings,
encryption::{BackupDownloadStrategy, EncryptionSettings},
ruma::{OwnedDeviceId, OwnedUserId},
};
use serde::Deserialize;
use tokio::fs;
use crate::credential::Token;
use crate::credential::{Passphrase, Token};
/// Sentinel returned when `build_and_restore` detects that the token is
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
@ -146,7 +149,10 @@ pub async fn build_and_restore(
let client = Client::builder()
.homeserver_url(homeserver)
.sqlite_store(state_dir, None)
.with_encryption_settings(EncryptionSettings::default())
.with_encryption_settings(EncryptionSettings {
backup_download_strategy: BackupDownloadStrategy::OneShot,
..EncryptionSettings::default()
})
.build()
.await
.with_context(|| format!("build matrix client for {homeserver}"))?;
@ -220,11 +226,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.
///
/// 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.
/// 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.
pub async fn ensure_cross_signing(client: &Client, account: &str) {
match client
.encryption()
@ -247,6 +253,88 @@ 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 created with a different key, which this does not replace — the
/// backup under it would be lost.
pub async fn ensure_recovery(client: &Client, account: &str, passphrase: &Passphrase) {
match recover_or_enable(client, passphrase).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 body of [`ensure_recovery`], with its outcome returned rather than
/// logged.
async fn recover_or_enable(client: &Client, passphrase: &Passphrase) -> Result<Backup> {
let encryption = client.encryption();
// A device restored from its own store reconnects to the backup in a
// background task; reading `are_enabled` before it finishes says no.
encryption.wait_for_e2ee_initialization_tasks().await;
let backups = encryption.backups();
if backups.are_enabled().await {
return Ok(Backup::AlreadyOn);
}
let recovery = encryption.recovery();
if backups
.fetch_exists_on_server()
.await
.context("asking the homeserver for the account's key backup")?
{
recovery
.recover(passphrase.expose())
.await
.context("recovering from the key backup")?;
Ok(Backup::Restored)
} else {
// The recovery key `enable` returns is not kept: the passphrase opens
// the same secret storage.
recovery
.enable()
.with_passphrase(passphrase.expose())
.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).