hive-c0re: deliver a matrix credential through hive-priv, not by writing it

`deliver` read the value out of the secret store and wrote it itself, as the
`hive-core` user, at 0600. The file lands in a directory owned by the agent,
so it arrived owned by `hive-core` — the agent's matrix daemon woke on it
appearing and could not read its own credential. `priv_client::write_agent_
matrix_token` already existed and already had two callers; this was the one
path that did not use it.

hive-priv now owns the filename too, so the name the daemon's path unit globs
for is decided in one place instead of being built identically in two.

That move exposed a disagreement worth fixing rather than routing around.
The secret store accepts `[A-Za-z0-9_-]` for an account name; hive-priv's
`validate_name_chars` accepts lowercase, digits and hyphen only. An account is
an attribute name in `hyperhive.matrixAccounts`, typed `attrsOf` with no
charset constraint, so `Ops_Relay9` is a key an operator can already have
written — and it would have read out of the store and then failed to land.

So hive-priv grows `validate_account_name` rather than widening the existing
one: an agent name is an `Ident` and lowercase by design, an account name is an
attrset key, and one validator serving two name domains is what let them drift.

The test that caught this came from `credential.rs`, which used to build the
path. It moves to hive-priv with both of its controls intact, because the
controls are the point — they assert which names must be ACCEPTED, and a
validator narrower than the store's passes every rejection case. A second
moved test pins the `matrix-token` prefix where the name is now built; the
old one would have kept passing while asserting a function that no longer
decided anything.

Refs #3726
This commit is contained in:
atlas 2026-09-07 23:58:11 +02:00
commit 0dd807062c
2 changed files with 115 additions and 113 deletions

View file

@ -2,53 +2,27 @@
//! store into that agent's own state dir.
//!
//! The controller publishes a [`CredentialNotice`] naming an agent and an
//! account; this reads the value out of the store and writes it where the
//! agent's matrix daemon already watches for it. Nothing here activates
//! anything: `nix/agent-modules/matrix.nix` has a `systemd.paths` unit
//! globbing `matrix-token*` inside that agent's own state dir, which
//! re-fires the daemon when a token appears, so arrival is the whole
//! trigger.
//! account; this reads the value out of the store and hands it to `hive-priv`,
//! which writes it where the agent's matrix daemon already watches. The write
//! goes through the privileged helper because the file lands in a directory
//! owned by the agent and has to be chowned to it — written from here it
//! arrives owned by `hive-core` and the agent cannot read its own credential.
//! `hive-priv` also builds the filename, so nothing in this module decides it.
//!
//! Nothing here activates anything: `nix/agent-modules/matrix.nix` has a
//! `systemd.paths` unit globbing `matrix-token*` inside that agent's own state
//! dir, which re-fires the daemon when a token appears, so arrival is the
//! whole trigger.
//!
//! 🔑 The notice carries no secret — see [`swarm_queue_client::credential_subject`]
//! for why that is a requirement rather than a preference. The value is read
//! from the store under this hive's own identity.
use std::os::unix::fs::PermissionsExt as _;
use std::path::PathBuf;
use anyhow::{Context, Result};
use hive_types::Ident;
use swarm_queue_client::CredentialNotice;
use swarm_secret_client::{SecretStore, path};
use crate::paths::agent_state_dir;
/// The basename every matrix token must start with.
///
/// `nix/agent-modules/matrix.nix` asserts the same prefix on every configured
/// `tokenFile`, because its path-watcher globs for it. A name written here
/// that did not match would land a file the daemon never notices — no error,
/// just a credential that silently never arrives.
const TOKEN_PREFIX: &str = "matrix-token";
/// Where `agent`'s credential for `account` is written.
///
/// Both names arrive off the queue and both become path components, so both
/// are refused here rather than trusted: `agent` by its [`Ident`] type, and
/// `account` by the same charset check the store path uses. They are checked
/// by different mechanisms because only one of them has a newtype — an account
/// name is an attribute name in `hyperhive.matrixAccounts`, so it is not an
/// `Ident` and cannot become one without narrowing what an operator may
/// configure.
///
/// # Errors
/// [`swarm_secret_client::Error::PathSegment`] when `account` is empty or
/// holds anything outside `[A-Za-z0-9_-]`.
pub fn token_path(agent: &Ident, account: &str) -> Result<PathBuf, swarm_secret_client::Error> {
path::checked_segment("account", account)?;
Ok(agent_state_dir(agent).join(format!("{TOKEN_PREFIX}-{account}")))
}
/// Read the credential `notice` names and write it into the agent's state dir.
///
/// `cert_role` is the role on the store's `cert` auth mount whose policy scopes
@ -73,62 +47,24 @@ pub async fn deliver(notice: &CredentialNotice, cert_role: &str) -> Result<()> {
.await
.with_context(|| format!("reading {secret_path} from the store"))?;
write_token(&token_path(&agent, &notice.account)?, &value)
}
/// Write `value` to `dest` at `0600`, atomically.
///
/// Atomic because the daemon's path-watcher fires on the file *appearing*: a
/// token written in place would be visible while still partial, and the daemon
/// would read a truncated credential exactly once, at the moment it is hardest
/// to reproduce. The temp name is dot-prefixed so it cannot match the
/// `matrix-token*` glob on its way past.
fn write_token(dest: &PathBuf, value: &str) -> Result<()> {
let dir = dest
.parent()
.context("a token path always has a parent state dir")?;
let name = dest
.file_name()
.context("a token path always has a file name")?;
let mut tmp = dir.join(".");
tmp.as_mut_os_string().push(name);
tmp.as_mut_os_string().push(".partial");
std::fs::write(&tmp, value).with_context(|| format!("writing {}", tmp.display()))?;
// Before the rename, so the file is never briefly readable by others under
// its final name.
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("restricting {}", tmp.display()))?;
std::fs::rename(&tmp, dest).with_context(|| format!("publishing {}", dest.display()))
// Through hive-priv rather than writing here: the file lands in a directory
// owned by the agent, and only root can chown it there. Written directly it
// arrives owned by `hive-core` at 0600 — the daemon wakes on it appearing
// and cannot read it. hive-priv also builds the filename, so the name the
// watcher globs for is decided in one place now.
crate::priv_client::write_agent_matrix_token(
agent.as_str(),
&value,
Some(&notice.account),
None,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_written_name_matches_the_glob_the_daemon_watches() {
// The other end of this agreement is an assertion in
// `nix/agent-modules/matrix.nix` and a `systemd.paths` glob — neither
// reachable from a Rust test, so the prefix is pinned here.
let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc")
.expect("a legal account name");
let name = p.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("matrix-token"), "got {name}");
assert_eq!(name, "matrix-token-ccc");
}
#[test]
fn the_partial_file_cannot_match_that_glob() {
// A temp name starting with `matrix-token` would be picked up
// mid-write; the dot prefix is what stops it.
let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc")
.expect("a legal account name");
let name = p.file_name().unwrap().to_str().unwrap();
let tmp = format!(".{name}.partial");
assert!(!tmp.starts_with("matrix-token"), "got {tmp}");
}
#[test]
fn a_name_that_could_address_another_agent_is_refused() {
// `path::matrix_account` owns this rule; asserted here because this is
@ -141,8 +77,8 @@ mod tests {
#[test]
fn an_agent_name_off_the_queue_must_pass_the_ident_parser_too() {
// Two independent refusals, not one restated: `path::matrix_account`
// guards the address in the *store*, `Ident` guards the address on
// *disk*, and `token_path` cannot be called without the second.
// guards the address in the *store*, and `Ident` guards the name this
// module hands to hive-priv, which builds the on-disk path from it.
for bad in ["../argus", "dmatrix/../argus", "Dmatrix", "d matrix", ""] {
assert!(Ident::parse(bad).is_err(), "{bad:?} must be refused");
}
@ -151,25 +87,23 @@ mod tests {
assert!(Ident::parse("dmatrix").is_ok());
}
/// The account half of that same rule, which the agent half's type does
/// not cover: an account name is an attr name in `hyperhive.matrixAccounts`
/// and so has no newtype to lean on. Without this, a second caller reaching
/// `token_path` without going through `path::matrix_account` first would
/// build a filename out of an unchecked name.
/// The account half of that same rule now lives where the filename is
/// built — `hive-priv`'s `validate_account_name`, asserted there with the
/// same arms and the same two controls. It is not restated here because
/// this module no longer builds the path.
#[test]
fn an_account_name_off_the_queue_is_refused_for_the_disk_path_too() {
let agent = Ident::parse("dmatrix").expect("a legal agent name");
for bad in ["../argus", "a/b", "a b", "a.b", ""] {
fn an_account_name_off_the_queue_is_refused_for_the_store_path() {
for bad in ["../argus", "a/b", "a b", ""] {
assert!(
token_path(&agent, bad).is_err(),
path::matrix_account("dmatrix", bad).is_err(),
"account {bad:?} must be refused"
);
}
// Controls: the legal charset stays reachable, so the loop above is
// not passing because everything is refused. Uppercase and underscore
// are deliberate — `matrixAccounts` is an attrset, so both are names
// an operator can already write.
assert!(token_path(&agent, "ops-relay").is_ok());
assert!(token_path(&agent, "Ops_Relay9").is_ok());
// Controls: the legal charset stays reachable, so the loop above is not
// passing because everything is refused. Uppercase and underscore are
// deliberate — `matrixAccounts` is an attrset, so both are names an
// operator can already write, and hive-priv must accept them too.
assert!(path::matrix_account("dmatrix", "ops-relay").is_ok());
assert!(path::matrix_account("dmatrix", "Ops_Relay9").is_ok());
}
}