//! `swarmctl bao` — initialise the swarm's secret store and bootstrap its //! granter, so the operator never copies the root token between commands. //! //! Every call goes through the `bao` CLI over HTTPS, so the store need not //! run on this host; the CLI's own `BAO_ADDR`, `BAO_CACERT` and //! `BAO_CLIENT_*` variables say where it is. Only [`grant_bootstrap`]'s last //! two steps (the token file and the unit restart) are local: they act on the //! host that runs `swarm-bao-granter-role`. //! //! The root token is handled so that it exists in exactly three places: the //! operator's terminal, this process's memory, and the `BAO_TOKEN` //! environment of one `bao` child at a time. It is never in a child's //! argument list (visible to every user through `ps`), never in this //! process's own environment, never in a file, and never in an error //! message. The child's environment is readable only by root through //! `/proc//environ`. use std::fs; use std::io::{self, IsTerminal as _, Write as _}; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use clap::Args; use serde::Deserialize; use zeroize::{Zeroize as _, Zeroizing}; use crate::path_from; /// The policy the 24h bootstrap token carries, and the policy the granter /// unit creates once it has used that token. Its presence is how a store is /// known to be bootstrapped already. const BOOTSTRAP_POLICY: &str = "bao-bootstrap"; const GRANTER_POLICY: &str = "bao-granter"; const GRANTER_UNIT: &str = "swarm-bao-granter-role"; /// How long `--and-grant` waits for a freshly initialised store to unseal /// itself. A `pkcs11` seal unseals within seconds; a `shamir` seal never does. const UNSEAL_WAIT: Duration = Duration::from_mins(1); #[derive(Args)] pub struct ToolArgs { /// `bao` binary to run. Defaults to `bao` on `PATH`, which on a host that /// runs the store is the wrapper that already carries the store's /// address, CA and client certificate. #[arg(long, value_name = "PATH")] bao_bin: Option, } #[derive(Args)] pub struct GrantArgs { /// Where to write the 24h bootstrap token (mode 0600) for /// `swarm-bao-granter-role` to pick up. Must be a path on the host running /// that unit. #[arg(long, value_name = "PATH")] bootstrap_token_file: Option, /// The `bao-bootstrap` policy file written to the store. #[arg(long, value_name = "PATH")] bootstrap_policy: Option, } #[derive(Args)] pub struct InitArgs { /// Right after initialising, bootstrap the granter in this same process, /// with the root token held only in memory. /// /// Waits for the store to unseal itself first, so the store must unseal /// itself (a `pkcs11` seal); with a `shamir` seal, unseal by hand and run /// `swarmctl bao grant-bootstrap`. #[arg(long)] and_grant: bool, #[command(flatten)] tool: ToolArgs, #[command(flatten)] grant: GrantArgs, } #[derive(Args)] pub struct GrantBootstrapArgs { #[command(flatten)] tool: ToolArgs, #[command(flatten)] grant: GrantArgs, } /// The programs this verb runs, as paths so that tests can substitute stubs. struct Tools { bao: PathBuf, systemctl: PathBuf, unseal_wait: Duration, } impl ToolArgs { fn tools(self) -> Tools { Tools { bao: self.bao_bin.unwrap_or_else(|| PathBuf::from("bao")), systemctl: PathBuf::from("systemctl"), unseal_wait: UNSEAL_WAIT, } } } struct GrantPaths { token_file: PathBuf, policy_file: PathBuf, } impl GrantArgs { fn resolve(self) -> Result { Ok(GrantPaths { token_file: path_from( self.bootstrap_token_file, "SWARMCTL_BAO_BOOTSTRAP_TOKEN_FILE", )?, policy_file: path_from(self.bootstrap_policy, "SWARMCTL_BAO_BOOTSTRAP_POLICY")?, }) } } /// `swarmctl bao init`. pub fn init(args: InitArgs) -> Result<()> { let grant = args.and_grant.then(|| args.grant.resolve()).transpose()?; init_with(&args.tool.tools(), grant.as_ref(), &mut io::stdout().lock()) } fn init_with(tools: &Tools, grant: Option<&GrantPaths>, out: &mut impl io::Write) -> Result<()> { let root_token = initialise(tools, out, &mut io::stderr().lock())?; let Some(paths) = grant else { eprintln!( "The store is initialised. Next, `swarmctl bao grant-bootstrap` bootstraps the \ granter." ); return Ok(()); }; // The keys are already on the operator's terminal, so a failure from here // on must say the init itself succeeded. let granted = wait_unsealed(tools) .and_then(|()| grant_bootstrap_with(tools, paths, &root_token)) .context( "the store is initialised and its keys and root token are printed above, but the \ granter bootstrap failed; once fixed, run `swarmctl bao grant-bootstrap`", )?; writeln!(out, "{}", granted.describe(paths)).context("writing the result to stdout") } /// `swarmctl bao grant-bootstrap`. pub fn grant_bootstrap(args: GrantBootstrapArgs) -> Result<()> { let paths = args.grant.resolve()?; let tools = args.tool.tools(); let root_token = read_root_token()?; let granted = grant_bootstrap_with(&tools, &paths, &root_token)?; println!("{}", granted.describe(&paths)); Ok(()) } /// The verbs above without their printing and prompting, for `swarmctl setup`. pub(crate) struct Bao { tools: Tools, } impl Bao { pub(crate) fn new() -> Self { Self { tools: ToolArgs { bao_bin: None }.tools(), } } /// Whether the nix module gave this host the bootstrap token path and /// policy file, which it does only where the store runs. pub(crate) fn grant_configured() -> bool { GrantArgs { bootstrap_token_file: None, bootstrap_policy: None, } .resolve() .is_ok() } /// `swarmctl bao init`, minus delivering the secrets. pub(crate) fn init(&self) -> Result { create_store(&self.tools) } pub(crate) fn wait_unsealed(&self) -> Result<()> { wait_unsealed(&self.tools) } /// `swarmctl bao grant-bootstrap` with `root` already in hand. Returns /// the sentence the verb prints. pub(crate) fn grant_bootstrap(&self, root: &str) -> Result { let paths = GrantArgs { bootstrap_token_file: None, bootstrap_policy: None, } .resolve()?; Ok(grant_bootstrap_with(&self.tools, &paths, root)?.describe(&paths)) } } /// What `bao operator init -format=json` prints, minus the fields not shown. /// /// Wiped on drop. The JSON parser and the child's output buffer pass through /// the heap on the way here, so this narrows how long the secrets sit in /// memory; it cannot make it zero. #[derive(Deserialize)] struct InitOutput { #[serde(default)] unseal_keys_b64: Vec, #[serde(default)] recovery_keys_b64: Vec, root_token: String, } impl Drop for InitOutput { fn drop(&mut self) { self.unseal_keys_b64.zeroize(); self.recovery_keys_b64.zeroize(); self.root_token.zeroize(); } } /// Initialises the store, writes the shares and the root token to `out`, and /// returns the root token for the caller to use once and drop. /// /// When `out` fails the store is initialised already and the secrets exist /// nowhere else, so they are written to `fallback` once before failing. fn initialise( tools: &Tools, out: &mut impl io::Write, fallback: &mut impl io::Write, ) -> Result> { let created = create_store(tools)?; eprintln!( "Record the keys and the root token below off this host now: swarmctl keeps none of \ them and cannot show them again." ); if let Err(stdout_err) = write_secrets(out, &created.parsed) { let delivered = write_secrets(fallback, &created.parsed); return Err(match delivered { Ok(()) => anyhow::anyhow!( "the store is now initialised, but its keys and root token could not be written \ to stdout ({stdout_err}); they were written to stderr instead, once: record \ them now" ), Err(stderr_err) => anyhow::anyhow!( "the store is now initialised, but its keys and root token could not be \ written to stdout ({stdout_err}) or to stderr ({stderr_err}) and are lost; \ see OpenBao's `operator rekey` and `operator generate-root` documentation \ for what a remaining key holder can recover" ), }); } Ok(created.root_token()) } /// A store this process just initialised, holding the only copy of its keys /// and root token. Wiped on drop, through [`InitOutput`]. pub(crate) struct Created { parsed: InitOutput, } impl Created { pub(crate) fn root_token(&self) -> Zeroizing { Zeroizing::new(self.parsed.root_token.clone()) } /// The keys and the root token, one labelled line each, as the operator /// is to record them. pub(crate) fn rendered(&self) -> Result> { let mut bytes = Zeroizing::new(Vec::with_capacity(4096)); write_secrets(&mut *bytes, &self.parsed).context("rendering the keys")?; let text = String::from_utf8(std::mem::take(&mut *bytes)) .map_err(|_| anyhow::anyhow!("the keys are not UTF-8"))?; Ok(Zeroizing::new(text)) } } /// Runs `bao operator init` against a store that is not initialised yet and /// reads what it printed. Prints nothing: delivering the secrets is the /// caller's. fn create_store(tools: &Tools) -> Result { let status = run(&mut tools.bao_cmd(&["operator", "init", "-status"]))?; match status.status.code() { Some(0) => bail!( "the secret store is already initialised; its keys and root token were printed \ when it was, and are not stored anywhere to print again" ), Some(2) => {} _ => bail!( "could not read the store's init status: {}", failure(&status, &[]) ), } let created = run(&mut tools.bao_cmd(&["operator", "init", "-format=json"]))?; let raw = Zeroizing::new(created.stdout); if !created.status.success() { bail!( "`bao operator init` failed: {}", failure_text(&created.stderr, &[]) ); } // The parser's own error can quote the value it choked on, so none of it // is passed on. let parsed: InitOutput = serde_json::from_slice(&raw).map_err(|_| { anyhow::anyhow!("`bao operator init` printed output that is not the expected JSON") })?; if parsed.unseal_keys_b64.is_empty() && parsed.recovery_keys_b64.is_empty() { bail!("`bao operator init` printed no unseal or recovery keys"); } Ok(Created { parsed }) } fn write_secrets(out: &mut impl io::Write, parsed: &InitOutput) -> io::Result<()> { for (i, key) in parsed.unseal_keys_b64.iter().enumerate() { writeln!(out, "Unseal key {}: {key}", i + 1)?; } for (i, key) in parsed.recovery_keys_b64.iter().enumerate() { writeln!(out, "Recovery key {}: {key}", i + 1)?; } writeln!(out, "Initial root token: {}", parsed.root_token)?; out.flush() } /// Polls until `bao status` reports an unsealed store (exit 0). fn wait_unsealed(tools: &Tools) -> Result<()> { let deadline = Instant::now() + tools.unseal_wait; loop { let status = run(&mut tools.bao_cmd(&["status"]))?; match status.status.code() { Some(0) => return Ok(()), Some(2) if Instant::now() < deadline => std::thread::sleep(Duration::from_secs(1)), Some(2) => bail!( "the store is still sealed after {}s; with a shamir seal, unseal it with \ `bao operator unseal` and run `swarmctl bao grant-bootstrap`", tools.unseal_wait.as_secs() ), _ => bail!( "could not read the store's seal status: {}", failure(&status, &[]) ), } } } #[derive(Debug)] enum Granted { AlreadyDone, Done, } impl Granted { fn describe(&self, paths: &GrantPaths) -> String { match self { Self::AlreadyDone => format!( "the granter is already bootstrapped (policy {GRANTER_POLICY} exists); nothing done" ), Self::Done => format!( "granter bootstrap started: grant token written to {}, {GRANTER_UNIT} restarted", paths.token_file.display() ), } } } /// The S3 bootstrap of `docs/1-getting-started/setup.md`: write the /// `bao-bootstrap` policy, mint the 24h grant token, place it for the granter /// unit and restart that unit. fn grant_bootstrap_with(tools: &Tools, paths: &GrantPaths, root: &str) -> Result { let policies = run(tools.bao_cmd(&["policy", "list"]).env("BAO_TOKEN", root))?; ensure(&policies, "bao policy list", &[root])?; if String::from_utf8_lossy(&policies.stdout) .lines() .any(|line| line.trim() == GRANTER_POLICY) { return Ok(Granted::AlreadyDone); } let write = run(tools .bao_cmd(&["policy", "write", BOOTSTRAP_POLICY]) .arg(&paths.policy_file) .env("BAO_TOKEN", root))?; ensure(&write, "bao policy write", &[root])?; let minted = run(tools .bao_cmd(&[ "token", "create", &format!("-policy={BOOTSTRAP_POLICY}"), "-ttl=24h", "-orphan", &format!("-display-name={BOOTSTRAP_POLICY}"), "-field=token", ]) .env("BAO_TOKEN", root))?; ensure(&minted, "bao token create", &[root])?; let minted = Zeroizing::new(minted.stdout); let grant_token = Zeroizing::new(String::from_utf8_lossy(&minted).trim().to_owned()); if grant_token.is_empty() { bail!("`bao token create` printed no token"); } let secrets = [root, grant_token.as_str()]; let contents = Zeroizing::new(format!("{}\n", *grant_token)); // `write_atomic` keeps an existing file's mode and owner; this one must be // ours and 0600 whatever was there. match fs::remove_file(&paths.token_file) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) => { return Err(e) .with_context(|| format!("removing the old {}", paths.token_file.display())); } } crate::write_atomic(&paths.token_file, &contents).with_context(|| { format!( "placing the bootstrap token at {}", paths.token_file.display() ) })?; let restart = run(Command::new(&tools.systemctl).args(["restart", GRANTER_UNIT]))?; ensure(&restart, "systemctl restart", &secrets)?; Ok(Granted::Done) } impl Tools { fn bao_cmd(&self, args: &[&str]) -> Command { let mut cmd = Command::new(&self.bao); cmd.args(args); cmd } } /// Runs `cmd` to completion with stdin closed. /// /// `BAO_TOKEN` is dropped from the child's inherited environment unless the /// caller sets it, so an ambient token in the operator's shell is never used /// by accident. Spawn errors name the program and nothing else. fn run(cmd: &mut Command) -> Result { let program = cmd.get_program().to_string_lossy().into_owned(); if !cmd.get_envs().any(|(key, _)| key == "BAO_TOKEN") { cmd.env_remove("BAO_TOKEN"); } cmd.stdin(Stdio::null()) .output() .with_context(|| format!("running {program}")) } /// Fails with the child's stderr, secrets replaced, when it did not succeed. fn ensure(out: &Output, what: &str, secrets: &[&str]) -> Result<()> { if out.status.success() { return Ok(()); } bail!("`{what}` failed: {}", failure(out, secrets)) } fn failure(out: &Output, secrets: &[&str]) -> String { format!("{} ({})", failure_text(&out.stderr, secrets), out.status) } /// A child's stderr as one trimmed line of text with every secret replaced. fn failure_text(stderr: &[u8], secrets: &[&str]) -> String { let mut text = String::from_utf8_lossy(stderr).trim().to_owned(); for secret in secrets.iter().filter(|s| !s.is_empty()) { text = text.replace(secret, "[redacted]"); } text } /// The root token: from a no-echo prompt on a terminal, otherwise the first /// line of stdin. Never from argv or the environment. fn read_root_token() -> Result> { let stdin = io::stdin(); let token = if stdin.is_terminal() { prompt_no_echo(&stdin, "Root token (input hidden): ")? } else { read_token_line(&mut stdin.lock())? }; if token.is_empty() { bail!("no root token was given"); } Ok(token) } pub(crate) fn prompt_no_echo(stdin: &io::Stdin, prompt: &str) -> Result> { use rustix::termios::{LocalModes, OptionalActions, tcgetattr, tcsetattr}; let saved = tcgetattr(stdin).context("reading terminal settings")?; let mut quiet = saved.clone(); quiet.local_modes.remove(LocalModes::ECHO); tcsetattr(stdin, OptionalActions::Flush, &quiet).context("turning terminal echo off")?; eprint!("{prompt}"); io::stderr().flush().ok(); let read = read_token_line(&mut stdin.lock()); // Restored before the error, if any, is looked at. let restored = tcsetattr(stdin, OptionalActions::Flush, &saved); eprintln!(); restored.context("restoring terminal echo")?; read } fn read_token_line(input: &mut impl io::BufRead) -> Result> { let mut line = Zeroizing::new(String::new()); input .read_line(&mut line) .context("reading the root token")?; Ok(Zeroizing::new(line.trim().to_owned())) } #[cfg(test)] mod tests { use std::fs; use std::os::unix::fs::PermissionsExt as _; use super::*; use crate::tests::write_executable; const ROOT: &str = "hvs.ROOTTOKEN"; const GRANT: &str = "hvs.GRANTTOKEN"; /// Stand-ins for `bao` and `systemctl` that log every call (argument list /// and the `BAO_TOKEN` they were given) and answer from files in `dir`. struct Fixture { dir: PathBuf, tools: Tools, paths: GrantPaths, } const STUB: &str = r#"#!/bin/sh D=$(dirname "$0") echo "ARGV $(basename "$0") $*" >> "$D/log" echo "ENV BAO_TOKEN=${BAO_TOKEN-}" >> "$D/log" case "$(basename "$0") $1 $2 $3" in "bao operator init -status") exit "$(cat "$D/init_status")" ;; "bao operator init -format=json") cat "$D/init.json"; exit 0 ;; "bao status "*) exit "$(cat "$D/seal_status")" ;; "bao policy list"*) if [ -e "$D/list_fails" ]; then echo "permission denied for token $BAO_TOKEN" >&2; exit 2; fi cat "$D/policies"; exit 0 ;; "bao policy write"*) exit 0 ;; "bao token create"*) echo "hvs.GRANTTOKEN"; exit 0 ;; "systemctl restart "*) if [ -e "$D/restart_fails" ]; then echo "unit failed, token file: $(cat "$D/grant.token")" >&2; exit 5; fi exit 0 ;; esac echo "unexpected call" >&2 exit 9 "#; impl Fixture { fn new(tag: &str) -> Self { let dir = std::env::temp_dir().join(format!("swarmctl-bao-{tag}-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("temp dir"); write_executable(&dir.join("bao"), STUB); write_executable(&dir.join("systemctl"), STUB); let fixture = Self { tools: Tools { bao: dir.join("bao"), systemctl: dir.join("systemctl"), unseal_wait: Duration::ZERO, }, paths: GrantPaths { token_file: dir.join("grant.token"), policy_file: dir.join("policy.hcl"), }, dir, }; fixture.set("init_status", "2"); fixture.set("seal_status", "0"); fixture.set("policies", "default\nroot\n"); fixture.set( "init.json", r#"{"unseal_keys_b64":["UK1","UK2"],"recovery_keys_b64":["RK1"],"root_token":"hvs.ROOTTOKEN"}"#, ); fixture } fn set(&self, name: &str, body: &str) { fs::write(self.dir.join(name), body).expect("write fixture file"); } fn log(&self) -> String { fs::read_to_string(self.dir.join("log")).unwrap_or_default() } /// The recorded argument lists, one per call, in order. fn calls(&self) -> Vec { self.log() .lines() .filter_map(|l| l.strip_prefix("ARGV ").map(str::to_owned)) .collect() } } impl Drop for Fixture { fn drop(&mut self) { fs::remove_dir_all(&self.dir).ok(); } } fn count(haystack: &str, needle: &str) -> usize { haystack.matches(needle).count() } #[test] fn init_prints_the_keys_and_the_root_token_once() { let f = Fixture::new("init-once"); let mut out = Vec::new(); let token = initialise(&f.tools, &mut out, &mut Vec::new()).expect("init succeeds"); let out = String::from_utf8(out).expect("utf8"); assert_eq!(&*token, ROOT); for line in [ "Unseal key 1: UK1", "Unseal key 2: UK2", "Recovery key 1: RK1", "Initial root token: hvs.ROOTTOKEN", ] { assert_eq!(count(&out, line), 1, "{line} in {out:?}"); } assert_eq!(count(&out, ROOT), 1, "the token appears exactly once"); // Presence control: the log does record what init was asked. assert_eq!( f.calls(), [ "bao operator init -status", "bao operator init -format=json" ] ); } #[test] fn init_hands_bao_no_token_in_argv_or_environment() { let f = Fixture::new("init-argv"); initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect("init succeeds"); let log = f.log(); assert!(!log.contains(ROOT), "{log}"); assert!( log.contains("ENV BAO_TOKEN=\n"), "control: ENV lines are logged: {log}" ); } #[test] fn init_refuses_an_initialised_store_without_initialising() { let f = Fixture::new("init-refuse"); f.set("init_status", "0"); let mut out = Vec::new(); let err = initialise(&f.tools, &mut out, &mut Vec::new()).expect_err("must refuse"); assert!( format!("{err:#}").contains("already initialised"), "{err:#}" ); assert!(out.is_empty(), "nothing is printed"); assert!( !f.log().contains("-format=json"), "init was not attempted: {}", f.log() ); } #[test] fn init_reports_an_unreadable_status_instead_of_initialising() { let f = Fixture::new("init-status-err"); f.set("init_status", "1"); let err = initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect_err("must stop"); assert!(format!("{err:#}").contains("init status"), "{err:#}"); assert!(!f.log().contains("-format=json"), "{}", f.log()); } #[test] fn a_malformed_init_reply_does_not_leak_its_contents() { let f = Fixture::new("init-garbled"); f.set( "init.json", r#"{"unseal_keys_b64":"hvs.LEAKED","root_token":"x"}"#, ); // Control: the parser's own error does quote the value. let raw = fs::read(f.dir.join("init.json")).expect("read"); let parser = serde_json::from_slice::(&raw) .err() .expect("rejects"); assert!(parser.to_string().contains("hvs.LEAKED"), "{parser}"); let err = initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect_err("must fail"); assert!(!format!("{err:#}").contains("hvs.LEAKED"), "{err:#}"); } #[test] fn grant_runs_the_bootstrap_steps_in_order_with_no_token_in_any_argv() { let f = Fixture::new("grant-order"); let granted = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect("grant succeeds"); assert!(matches!(granted, Granted::Done)); let policy = f.paths.policy_file.display(); assert_eq!( f.calls(), [ "bao policy list".to_owned(), format!("bao policy write bao-bootstrap {policy}"), "bao token create -policy=bao-bootstrap -ttl=24h -orphan \ -display-name=bao-bootstrap -field=token" .to_owned(), "systemctl restart swarm-bao-granter-role".to_owned(), ] ); let log = f.log(); for argv in f.calls() { assert!(!argv.contains(ROOT) && !argv.contains(GRANT), "{argv}"); } // Presence control: the root token did reach the three bao calls, by // the environment of the child alone. assert_eq!(count(&log, &format!("ENV BAO_TOKEN={ROOT}\n")), 3, "{log}"); let mode = fs::metadata(&f.paths.token_file) .expect("stat") .permissions() .mode() & 0o7777; assert_eq!(mode, 0o600); assert_eq!( fs::read_to_string(&f.paths.token_file).expect("read"), format!("{GRANT}\n") ); } /// A writer that refuses every write, as a closed pipe or full disk does. struct Broken; impl io::Write for Broken { fn write(&mut self, _: &[u8]) -> io::Result { Err(io::Error::other("broken pipe")) } fn flush(&mut self) -> io::Result<()> { Err(io::Error::other("broken pipe")) } } #[test] fn a_failing_stdout_falls_back_to_stderr_and_says_the_store_is_initialised() { let f = Fixture::new("stdout-fails"); let mut fallback = Vec::new(); let err = initialise(&f.tools, &mut Broken, &mut fallback).expect_err("must fail"); let text = format!("{err:#}"); assert!(text.contains("now initialised"), "{text}"); assert!(text.contains("written to stderr"), "{text}"); assert!(!text.contains(ROOT) && !text.contains("UK1"), "{text}"); let fallback = String::from_utf8(fallback).expect("utf8"); assert_eq!(count(&fallback, ROOT), 1, "{fallback}"); assert_eq!(count(&fallback, "Unseal key 1: UK1"), 1, "{fallback}"); } #[test] fn keys_that_neither_stdout_nor_stderr_accept_are_reported_lost() { let f = Fixture::new("both-fail"); let err = initialise(&f.tools, &mut Broken, &mut Broken).expect_err("must fail"); let text = format!("{err:#}"); assert!(text.contains("now initialised"), "{text}"); assert!(text.contains("are lost"), "{text}"); assert!(text.contains("rekey"), "{text}"); assert!(!text.contains(ROOT), "{text}"); } #[test] fn an_existing_world_readable_token_file_is_replaced_by_a_0600_one() { let f = Fixture::new("token-mode"); fs::write(&f.paths.token_file, "old\n").expect("seed"); fs::set_permissions(&f.paths.token_file, fs::Permissions::from_mode(0o644)).expect("chmod"); grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect("grant succeeds"); let mode = fs::metadata(&f.paths.token_file) .expect("stat") .permissions() .mode() & 0o7777; assert_eq!(mode, 0o600); assert_eq!( fs::read_to_string(&f.paths.token_file).expect("read"), format!("{GRANT}\n") ); } #[test] fn grant_on_a_bootstrapped_store_changes_nothing() { let f = Fixture::new("grant-again"); f.set("policies", "bao-granter\ndefault\nroot\n"); let granted = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect("exits ok"); assert!(matches!(granted, Granted::AlreadyDone)); assert_eq!(f.calls(), ["bao policy list"]); assert!(!f.paths.token_file.exists()); assert!(granted.describe(&f.paths).contains("already bootstrapped")); } #[test] fn a_failing_bao_call_does_not_put_the_root_token_in_the_error() { let f = Fixture::new("grant-fail-bao"); f.set("list_fails", ""); let err = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect_err("must fail"); let text = format!("{err:#}"); // The stub echoed the token on stderr; only the redaction keeps it out. assert!(text.contains("permission denied"), "{text}"); assert!(text.contains("[redacted]"), "{text}"); assert!(!text.contains(ROOT), "{text}"); } #[test] fn a_failing_restart_does_not_put_either_token_in_the_error() { let f = Fixture::new("grant-fail-restart"); f.set("restart_fails", ""); let err = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect_err("must fail"); let text = format!("{err:#}"); // The stub printed the grant token's file contents on stderr. assert!(text.contains("unit failed"), "{text}"); assert!(text.contains("[redacted]"), "{text}"); assert!(!text.contains(GRANT) && !text.contains(ROOT), "{text}"); } #[test] fn and_grant_initialises_then_bootstraps_in_one_process() { let f = Fixture::new("and-grant"); let mut out = Vec::new(); init_with(&f.tools, Some(&f.paths), &mut out).expect("succeeds"); let out = String::from_utf8(out).expect("utf8"); assert_eq!(count(&out, ROOT), 1, "{out}"); assert!(out.contains("granter bootstrap started"), "{out}"); let calls = f.calls(); assert_eq!( calls[..3], [ "bao operator init -status", "bao operator init -format=json", "bao status" ] ); assert_eq!(calls[3], "bao policy list"); assert_eq!( calls.last().map(String::as_str), Some("systemctl restart swarm-bao-granter-role") ); assert!(calls.iter().all(|c| !c.contains(ROOT)), "{calls:?}"); } #[test] fn init_without_and_grant_never_touches_the_granter() { let f = Fixture::new("init-only"); init_with(&f.tools, None, &mut Vec::new()).expect("succeeds"); assert_eq!(f.calls().len(), 2, "{:?}", f.calls()); } #[test] fn a_sealed_store_after_init_keeps_the_printed_token_and_leaks_nothing() { let f = Fixture::new("and-grant-sealed"); f.set("seal_status", "2"); let mut out = Vec::new(); let err = init_with(&f.tools, Some(&f.paths), &mut out).expect_err("sealed store"); let text = format!("{err:#}"); assert!(text.contains("printed above"), "{text}"); assert!(text.contains("still sealed"), "{text}"); assert!(!text.contains(ROOT), "{text}"); assert_eq!(count(&String::from_utf8(out).expect("utf8"), ROOT), 1); assert!( !f.calls().iter().any(|c| c.starts_with("bao policy")), "no grant attempted" ); } #[test] fn a_seal_status_error_is_not_read_as_sealed() { let f = Fixture::new("seal-error"); f.set("seal_status", "1"); let err = wait_unsealed(&f.tools).expect_err("must fail"); assert!(format!("{err:#}").contains("seal status"), "{err:#}"); } #[test] fn a_missing_bao_binary_names_the_binary() { let f = Fixture::new("no-bao"); let tools = Tools { bao: f.dir.join("absent"), systemctl: f.tools.systemctl.clone(), unseal_wait: Duration::ZERO, }; let err = grant_bootstrap_with(&tools, &f.paths, ROOT).expect_err("must fail"); let text = format!("{err:#}"); assert!(text.contains("absent"), "{text}"); assert!(!text.contains(ROOT), "{text}"); } #[test] fn the_root_token_is_the_first_line_of_a_piped_stdin() { let token = read_token_line(&mut "hvs.ROOTTOKEN\nsecond\n".as_bytes()).expect("reads"); assert_eq!(&*token, ROOT); let token = read_token_line(&mut " hvs.ROOTTOKEN ".as_bytes()).expect("reads"); assert_eq!(&*token, ROOT); let token = read_token_line(&mut "".as_bytes()).expect("reads"); assert!(token.is_empty()); } }