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

@ -35,6 +35,41 @@ pub fn accounts_dir(agent: &str) -> Result<String, Error> {
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.
@ -132,6 +167,55 @@ 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("<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,