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:
parent
66c3138dd1
commit
0dd807062c
2 changed files with 115 additions and 113 deletions
|
|
@ -546,13 +546,10 @@ fn write_matrix_token(
|
|||
homeserver: Option<&str>,
|
||||
) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
let filename = match account {
|
||||
None => "matrix-token".to_owned(),
|
||||
Some(a) => {
|
||||
validate_name_chars(a)?;
|
||||
format!("matrix-token-{a}")
|
||||
}
|
||||
};
|
||||
if let Some(a) = account {
|
||||
validate_account_name(a)?;
|
||||
}
|
||||
let filename = matrix_token_filename(account);
|
||||
let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?;
|
||||
if let (Some(a), Some(hs)) = (account, homeserver) {
|
||||
let meta = serde_json::to_string(&MatrixAccountSidecar { homeserver: hs })
|
||||
|
|
@ -1364,6 +1361,20 @@ fn ensure_plain_filename(who: &str, filename: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Basename an agent's matrix token is written under — `matrix-token` for the
|
||||
/// hive's own account, `matrix-token-<account>` for an extra one.
|
||||
///
|
||||
/// Half of an agreement whose other half is a `systemd.paths` glob in
|
||||
/// `nix/agent-modules/matrix.nix`, which no test here can reach: a name that
|
||||
/// stopped matching `matrix-token*` would land a credential the agent's daemon
|
||||
/// never wakes for — no error, just a token that silently never arrives.
|
||||
fn matrix_token_filename(account: Option<&str>) -> String {
|
||||
match account {
|
||||
None => "matrix-token".to_owned(),
|
||||
Some(a) => format!("matrix-token-{a}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the content is written under before being renamed onto `filename`.
|
||||
/// The leading dot is load-bearing rather than tidy: `nix/agent-modules/
|
||||
/// matrix.nix` starts the agent's matrix daemon on the glob `matrix-token*`,
|
||||
|
|
@ -2797,6 +2808,29 @@ fn validate_name_chars(name: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a matrix account name, which is a wider charset than
|
||||
/// [`validate_name_chars`] allows on purpose.
|
||||
///
|
||||
/// An agent name is an `Ident` and lowercase by design. An account name is an
|
||||
/// attribute name in `hyperhive.matrixAccounts`, typed `attrsOf` with no
|
||||
/// charset constraint, so `Ops_Relay9` is a key an operator may already have
|
||||
/// written. `swarm_secret_client::path::checked_segment` accepts exactly this
|
||||
/// set for the same name in the secret store — the two must agree, or a
|
||||
/// credential reads out of the store and then fails to land on disk.
|
||||
///
|
||||
/// Still a single plain component: no `/`, no `.`, no whitespace, so it cannot
|
||||
/// climb out of the agent's state dir or name the dir itself.
|
||||
fn validate_account_name(name: &str) -> Result<()> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
bail!("invalid account name {name:?}: must be non-empty [A-Za-z0-9_-]");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a bind-mount path: must be absolute, non-empty, and contain
|
||||
/// no newlines, null bytes, double-quotes, or colons.
|
||||
///
|
||||
|
|
@ -3061,8 +3095,9 @@ mod tests {
|
|||
use super::{
|
||||
BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement,
|
||||
clear_runner_credentials, contains_secret_shaped_run, git_overlay_flags,
|
||||
limits_dropin_body, partial_name, redact_secret_line, remove_marker_in, single_output_path,
|
||||
toplevel_attr, write_agent_dir_file, write_state_file_nofollow,
|
||||
limits_dropin_body, matrix_token_filename, partial_name, redact_secret_line,
|
||||
remove_marker_in, single_output_path, toplevel_attr, validate_account_name,
|
||||
write_agent_dir_file, write_state_file_nofollow,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
|
@ -3403,6 +3438,39 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Ported from `hive-c0re`'s `credential.rs`, which used to build this path
|
||||
/// itself. The controls are the load-bearing half: an account name is an
|
||||
/// attrset key in `hyperhive.matrixAccounts`, so uppercase and underscore
|
||||
/// are names an operator can already have written, and the secret store
|
||||
/// accepts exactly this set for the same name. A validator narrower than
|
||||
/// the store's reads a credential out and then refuses to land it.
|
||||
#[test]
|
||||
fn an_account_name_is_checked_against_the_same_charset_the_store_uses() {
|
||||
for bad in ["../argus", "a/b", "a b", "a.b", ""] {
|
||||
assert!(
|
||||
validate_account_name(bad).is_err(),
|
||||
"account {bad:?} must be refused"
|
||||
);
|
||||
}
|
||||
assert!(validate_account_name("ops-relay").is_ok());
|
||||
assert!(validate_account_name("Ops_Relay9").is_ok());
|
||||
}
|
||||
|
||||
/// Pinned here because here is where the name is decided. `hive-c0re` used
|
||||
/// to assert this against a path helper of its own, which stopped deciding
|
||||
/// anything the moment credential delivery started routing through this
|
||||
/// process — a test that would have kept passing while the real filename
|
||||
/// drifted.
|
||||
#[test]
|
||||
fn a_matrix_token_is_named_for_the_glob_the_daemon_watches() {
|
||||
assert_eq!(matrix_token_filename(None), "matrix-token");
|
||||
assert_eq!(matrix_token_filename(Some("ccc")), "matrix-token-ccc");
|
||||
// And the temp the publish goes through must not match that same glob,
|
||||
// which is only checkable now that both names are built in one place.
|
||||
let tmp = partial_name(&matrix_token_filename(Some("ccc")));
|
||||
assert!(!tmp.starts_with("matrix-token"), "got {tmp}");
|
||||
}
|
||||
|
||||
/// The temp name is the whole reason the rename is safe to watch: a
|
||||
/// `systemd.path` unit globbing `matrix-token*` would fire on a temp that
|
||||
/// merely suffixed the real name, on exactly the empty file the rename
|
||||
|
|
|
|||
Loading…
Reference in a new issue