Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/matrix.rs
atlas 45568f6e60 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).
2026-10-14 02:09:57 +02:00

362 lines
15 KiB
Rust

//! The matrix agreement: where an account's credential lives in the store, and
//! what the object at that path holds.
//!
//! Both halves are one agreement and neither end of it is senior, so they are
//! stated together here rather than split between the path module and the
//! client. Nothing in [`crate::client`] knows this shape — it moves whatever
//! type a caller names — so a second kind of swarm secret gets its own module
//! beside this one instead of another field on a shared struct.
use serde::{Deserialize, Serialize};
use crate::{
Error,
path::{Kind, checked_segment, principal_prefix},
};
/// The path holding `agent`'s token for the external matrix account `account`.
///
/// # Errors
/// [`Error::PathSegment`] when either name contains anything but
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
/// another agent's secret.
pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
checked_segment("account", account)?;
Ok(format!("{}/{account}", accounts_dir(agent)?))
}
/// The directory every one of `agent`'s [`account_path`]s is under, in the
/// form [`crate::SecretStore::list`] takes.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Agent, agent)?;
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<String, Error> {
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", &"<redacted>")
.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.
///
/// **Derived from the hive's name, which is what makes it one account per
/// hive.** It used to be the bare constant `hive`: one swarm runs one
/// homeserver, so every hive on it logged in as the same `@hive:` and the
/// homeserver could not tell them apart — no attribution, and no way to revoke
/// one hive without revoking all of them.
///
/// The prefix is kept so the account still reads as a hive's rather than an
/// agent's. Both `swarm-controller` and hive-c0re derive it here, so the two
/// cannot disagree on which account a hive is.
///
/// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and
/// the appservice namespace regex admits that charset, so there is no
/// rejection arm to write. [`sender_token_path`] below does the checking, and
/// it is the one that interpolates into a store path.
#[must_use]
pub fn hive_localpart(hive: &str) -> String {
format!("hive-{hive}")
}
/// The path holding `hive`'s matrix sender account's homeserver access token.
///
/// Keyed per **hive**, like [`crate::queue::agent_client_path`]. Under
/// [`Kind::Hive`] the grant that reaches it is the hive's **own** stanza,
/// `secret/data/swarm/hives/<this hive>/*`, which interpolates the name: hive
/// `alpha` reads its own token and gets a 403 on `beta`'s.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
/// which is what keeps one hive's name from addressing another hive's secret.
pub fn sender_token_path(hive: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Hive, hive)?;
Ok(format!("{prefix}/matrix/sender-token"))
}
/// The name segment of the controller's own subtree, and the cert-auth role it
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
pub(crate) const CONTROLLER: &str = "swarm-controller";
/// The path holding the **swarm's** appservice token: the one registration
/// on the swarm's homeserver that is not a hive's, whose sender is promoted
/// to homeserver admin at boot.
///
/// The matrix container mints it and publishes it here; `swarm-controller`
/// reads it to create agents' accounts. Under [`Kind::Controller`] because
/// [`crate::policy::render`] grants no hive that kind: every hive reads
/// `agents/*` and its own `hives/<hive>/*`, and nothing else.
///
/// # Errors
/// Never in practice: the name segment is a constant. The `Result` is
/// [`principal_prefix`]'s.
pub fn swarm_appservice_token_path() -> Result<String, Error> {
let prefix = principal_prefix(Kind::Controller, CONTROLLER)?;
Ok(format!("{prefix}/matrix/appservice-token"))
}
/// What an account's path holds: the token, plus the homeserver it belongs to.
///
/// The homeserver rides with the token because the agent's daemon learns both
/// from this one object: an account it finds by listing [`accounts_dir`] has
/// no other place its homeserver is written down.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Credential {
/// Named `value` like [`crate::forge::Credential::value`] and
/// [`crate::queue::Credential::value`], so a nix-side reader spells
/// `bao kv get -field=value` for every kind.
pub value: String,
/// Absent on every object written before this field existed, and KV2 keeps
/// those versions forever. **`Option` is what tolerates that** — serde
/// decodes a missing field to `None` for an optional type, so the type is
/// the compatibility guarantee and changing it to a bare `String` is what
/// would break every stored credential at once.
///
/// `skip_serializing_if` is doing separate work: without it a token-only
/// credential serialises `"homeserver":null` rather than the
/// `{"value":…}` shape the objects written before this field have.
#[serde(skip_serializing_if = "Option::is_none")]
pub homeserver: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_well_formed_pair_lands_under_the_agent_prefix() {
let p = account_path("atlas", "ops-relay").expect("both segments are legal");
// Spelled out rather than rebuilt from the same pieces the code uses:
// a test that composes `ROOT` and `Kind::Agent` would keep passing
// through a rename that moves every stored credential.
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("<redacted>"), "{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,
// for the reason above — and with a second job here: the hive's own
// policy grants read on `secret/data/swarm/hives/<this hive>/*`, so
// this exact string is what makes the path reachable at all.
assert_eq!(
sender_token_path("pr1ma").expect("a plain name is legal"),
"swarm/hives/pr1ma/matrix/sender-token"
);
}
#[test]
fn one_hives_sender_token_is_not_another_hives() {
// The property the per-hive path exists for: two hives never name the
// same object, so the `hives/<name>/*` grant that reaches one cannot
// reach the other.
let a = sender_token_path("alpha").expect("legal");
let b = sender_token_path("beta").expect("legal");
assert_ne!(a, b);
}
#[test]
fn a_traversal_in_the_hive_name_is_refused_by_the_sender_path_too() {
let e = sender_token_path("../beta").expect_err("a traversal is not legal");
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
}
#[test]
fn the_localpart_is_derived_from_the_hive_name() {
// The literal is the point: an existing hive's account on the
// homeserver carries this localpart, so a change here strands it.
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
assert_ne!(
hive_localpart("pr1ma"),
hive_localpart("secunda"),
"two hives must not land on one account"
);
// And it is never the bare `hive` that used to be shared swarm-wide.
assert_ne!(hive_localpart("pr1ma"), "hive");
}
#[test]
fn the_swarm_appservice_token_lands_under_the_controller() {
assert_eq!(
swarm_appservice_token_path().expect("a constant segment"),
"swarm/controller/swarm-controller/matrix/appservice-token"
);
}
#[test]
fn no_hive_policy_reaches_the_swarm_appservice_token() {
// 🩸 The swarm's sender is a homeserver admin, so its token must not
// be under any stanza a hive's policy renders. Checked against the
// rendered document rather than against the prefix, because the
// document is what the store enforces.
let doc = crate::policy::render("pr1ma").expect("a plain name is legal");
// Every stanza is `path "secret/data/<prefix>*" { … }`.
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 path = swarm_appservice_token_path().expect("a constant segment");
assert!(!reads(&path), "{path} is readable under {granted:?}");
// The control: an agent's account path IS under a hive stanza, so the
// assertion above can fail at all.
let agent = account_path("atlas", "main").expect("legal");
assert!(
reads(&agent),
"{agent} should be readable under {granted:?}"
);
}
#[test]
fn a_segment_cannot_escape_its_own_directory() {
// Each of these is a *different* way to address another agent's tree,
// and the last two are the ones a charset check catches but a
// `contains("..")` check does not.
for bad in [
"../argus",
"atlas/../argus",
"atlas/matrix",
"a b",
"a.b",
"",
] {
assert!(
account_path(bad, "ops-relay").is_err(),
"agent segment {bad:?} must be refused"
);
assert!(
account_path("atlas", bad).is_err(),
"account segment {bad:?} must be refused"
);
}
}
#[test]
fn the_legal_charset_is_actually_reachable() {
// The control for the test above: if `checked_segment` rejected
// everything, the escape cases would pass for the wrong reason.
assert!(account_path("a-b_C9", "d-e_F0").is_ok());
}
/// KV2 keeps every prior version, so objects written before `homeserver`
/// existed are still readable and still get decoded by this type. What
/// tolerates the absence is the field being `Option`, not any attribute:
/// making it a bare `String` would not surface as a migration, it would
/// surface as every previously-stored credential becoming unreadable at
/// once.
#[test]
fn a_credential_stored_before_the_homeserver_field_still_decodes() {
let old: Credential = serde_json::from_str(r#"{"value":"t"}"#)
.expect("an object written by the previous version must still decode");
assert_eq!(old.value, "t");
assert_eq!(old.homeserver, None);
// Control: the field is genuinely read when present, so the arm above
// is about absence being tolerated rather than the field being ignored.
let new: Credential = serde_json::from_str(r#"{"value":"t","homeserver":"https://hs"}"#)
.expect("an object with the field decodes too");
assert_eq!(new.homeserver.as_deref(), Some("https://hs"));
}
#[test]
fn the_value_field_matches_what_the_nix_reader_asks_for() {
// The literal is the point: a nix-side reader spells the field as
// `bao kv get -field=value` in a file no Rust test can reach, so the
// name is pinned here rather than derived from the struct.
//
// It doubles as the compatibility control for `homeserver`: a
// token-only credential must still serialise to exactly these bytes,
// with no `homeserver` key at all, so adding the field cannot change
// what such a reader gets.
let json = serde_json::to_string(&Credential {
value: "t".to_owned(),
homeserver: None,
})
.expect("a struct of one String serialises");
assert_eq!(json, r#"{"value":"t"}"#);
}
}