346 lines
14 KiB
Rust
346 lines
14 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 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_shared_hives`] 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"))
|
|
}
|
|
|
|
/// The path holding the swarm appservice's registration: the `as_token` and
|
|
/// `hs_token` the homeserver loads.
|
|
///
|
|
/// The store is the source of truth for both tokens. The matrix container keeps a
|
|
/// copy on disk that the homeserver starts from, and derives
|
|
/// [`swarm_appservice_token_path`] from the `as_token` here for
|
|
/// `swarm-controller`. It sits beside that path, under the one kind no hive's
|
|
/// policy reads, for the same reason.
|
|
///
|
|
/// # Errors
|
|
/// Never in practice: the name segment is a constant. The `Result` is
|
|
/// [`principal_prefix`]'s.
|
|
pub fn swarm_appservice_registration_path() -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Controller, CONTROLLER)?;
|
|
Ok(format!("{prefix}/matrix/appservice-registration"))
|
|
}
|
|
|
|
/// What [`swarm_appservice_registration_path`] holds. The registration's
|
|
/// namespaces are configuration rendered by nix, not part of it.
|
|
///
|
|
/// No `Debug` derive: both fields authenticate to the homeserver.
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Registration {
|
|
/// The token the appservice presents to the homeserver.
|
|
pub as_token: String,
|
|
/// The token the homeserver presents to the appservice.
|
|
pub hs_token: String,
|
|
}
|
|
|
|
impl std::fmt::Debug for Registration {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("Registration")
|
|
.field("as_token", &"<redacted>")
|
|
.field("hs_token", &"<redacted>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// 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::AgentCredential::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::effective_agent("atlas");
|
|
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_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 the_swarm_appservice_registration_lands_beside_its_token() {
|
|
assert_eq!(
|
|
swarm_appservice_registration_path().expect("a constant segment"),
|
|
"swarm/controller/swarm-controller/matrix/appservice-registration"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_registration_debug_prints_no_token() {
|
|
let r = Registration {
|
|
as_token: "as-secret".to_owned(),
|
|
hs_token: "hs-secret".to_owned(),
|
|
};
|
|
let shown = format!("{r:?}");
|
|
assert!(
|
|
!shown.contains("as-secret") && !shown.contains("hs-secret"),
|
|
"{shown}"
|
|
);
|
|
assert!(shown.contains("Registration"), "{shown}");
|
|
}
|
|
|
|
#[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::effective_hive("pr1ma");
|
|
// 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));
|
|
for path in [
|
|
swarm_appservice_token_path().expect("a constant segment"),
|
|
swarm_appservice_registration_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"}"#);
|
|
}
|
|
}
|