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:
parent
bff605035c
commit
45568f6e60
11 changed files with 615 additions and 23 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue