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

@ -1,5 +1,6 @@
//! The matrix access token as a value this process holds, and the two places
//! it comes from; and the accounts the store links to this agent.
//! it comes from; the accounts the store links to this agent; and each
//! account's key-backup recovery passphrase.
//!
//! 🩸 **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
@ -179,6 +180,73 @@ 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", &"<redacted>")
.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<Option<Self>> {
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<matrix::Recovery> = 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)]
@ -340,6 +408,24 @@ 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]