Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats-auth: read the callout issuer and user seeds from the store

The responder loads the issuer and callout-user seeds from the secret
store under its own role, beside the xkey, and no longer takes
--issuer-seed-file / --user-seed-file.

`ensure-xkey` becomes `ensure-callout-keys`: it ensures the xkey as before,
and for each nkey creates the seed create-only when the store holds none,
from --import-{issuer,user}-seed-file when that file is present, so a key
kept on disk keeps its public half. Once a read of the store returns a seed,
the import file is removed; when that seed differs from the file the
store's is kept and a warning names the file. The public halves are
published create-only at swarm/public/swarm-nats/callout-{issuer,user}.

The KeyStore trait and its test double move to `keystore` so both key
modules share them.
This commit is contained in:
atlas 2026-10-06 10:13:34 +02:00 • committed by mara
commit 8a4bb97377
5 changed files with 610 additions and 174 deletions

View file

@ -16,8 +16,9 @@
//!
//! Every request is opened with the callout xkey, and its reply is sealed
//! back to the server; see `xkey`. A request that does not open gets no
//! reply: the server this answers always seals. The `ensure-xkey` verb
//! creates that key in the secret store, and runs before the server does.
//! reply: the server this answers always seals. The `ensure-callout-keys`
//! verb creates that key and the two nkeys (see `nkey`) in the secret store,
//! and runs before the server does.
//!
//! # Secrets
//!
@ -25,8 +26,8 @@
//! previously learned the hard way here: a value in nix config is rendered into
//! the world-readable store, and a value in `argv` is readable by anyone via
//! `/proc/<pid>/cmdline`, which is `0444`. Paths are not secrets, so passing
//! them as flags is fine. The xkey seed is the exception to files: it is read
//! from the secret store, at the path `xkey` names.
//! them as flags is fine. The callout's three seeds are not files: they are
//! read from the secret store, at the paths `xkey` and `nkey` name.
use std::path::PathBuf;
@ -36,6 +37,8 @@ use futures_util::StreamExt;
mod agent_token;
mod introspect;
mod keystore;
mod nkey;
mod policy;
mod request;
mod respond;
@ -62,12 +65,23 @@ struct Cli {
#[derive(Debug, Subcommand)]
enum Command {
/// Create the callout xkey in the secret store when it holds none, and
/// check that its public half is stored beside it. Needs no NATS server.
EnsureXkey {
/// Create the callout xkey, issuer and user keys in the secret store when
/// it holds none, and check that each public half is stored beside its
/// seed. Needs no NATS server.
EnsureCalloutKeys {
/// Role on the store's `cert` auth mount to log in as.
#[arg(long, default_value = "swarm-nats-auth")]
store_cert_role: String,
/// Issuer seed to store when the store holds none. Removed once the
/// store holds an issuer seed. Absent or empty, a new key is made.
#[arg(long)]
import_issuer_seed_file: Option<PathBuf>,
/// User seed to store when the store holds none, handled as
/// `--import-issuer-seed-file` is.
#[arg(long)]
import_user_seed_file: Option<PathBuf>,
},
}
@ -77,16 +91,6 @@ struct Args {
#[arg(long, default_value = "nats://127.0.0.1:4222")]
nats_url: String,
/// Path to the seed of the callout-exempt user this responder connects as.
/// Its public half is `services.hyperhive.deploy.nats.calloutUserPublicKey`.
#[arg(long)]
user_seed_file: PathBuf,
/// Path to the account signing seed used to sign issued user JWTs. Its
/// public half is `services.hyperhive.deploy.nats.calloutIssuerPublicKey`.
#[arg(long)]
issuer_seed_file: PathBuf,
/// Authelia's OIDC introspection endpoint.
#[arg(long)]
introspection_url: String,
@ -156,7 +160,7 @@ struct Args {
agent_token_publish_subjects: Vec<String>,
/// Role on the secret store's `cert` auth mount this responder logs in
/// as to read the callout xkey and agent credentials. The store is found
/// as to read the callout keys and agent credentials. The store is found
/// through the `BAO_*` environment, which the responder does not start
/// without.
#[arg(long, default_value = "swarm-nats-auth")]
@ -165,10 +169,8 @@ struct Args {
/// Read a secret file and strip surrounding whitespace.
///
/// The trim matters: an `echo`-created seed file ends in a newline, and an
/// nkey seed with a trailing byte is not a seed — it fails at parse with a
/// message about encoding rather than about the file, which sends you looking
/// in the wrong place. The value is never logged, and the error deliberately
/// The trim matters: an `echo`-created file ends in a newline, which is not
/// part of the secret. The value is never logged, and the error deliberately
/// names only the path.
fn read_secret(path: &std::path::Path) -> anyhow::Result<String> {
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
@ -194,7 +196,21 @@ async fn main() -> anyhow::Result<()> {
.init();
let cli = Cli::parse();
match (cli.command, cli.responder) {
(Some(Command::EnsureXkey { store_cert_role }), _) => ensure_xkey(&store_cert_role).await,
(
Some(Command::EnsureCalloutKeys {
store_cert_role,
import_issuer_seed_file,
import_user_seed_file,
}),
_,
) => {
ensure_callout_keys(
&store_cert_role,
import_issuer_seed_file.as_deref(),
import_user_seed_file.as_deref(),
)
.await
}
(None, Some(args)) => serve(&args).await,
(None, None) => unreachable!("clap requires the responder's flags when no verb is given"),
}
@ -222,25 +238,13 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
)?
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?;
let http = reqwest::Client::new();
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)
.context("parse the account signing seed")?;
let settings = swarm_secret_client::client::Settings::from_env()
.context("reading the secret store's address and this responder's identity")?;
// Before the connect: a responder that cannot open a sealed request must
// not start answering. Read, never created here; see `xkey`.
let callout_xkey = xkey::load(
&swarm_secret_client::SecretStore::connect(
&settings,
&args.store_cert_role,
swarm_secret_client::client::DEFAULT_CERT_MOUNT,
)
.await
.context("logging in to the secret store for the callout xkey")?,
)
.await?;
tracing::info!(public_key = %callout_xkey.public_key(), "loaded the callout xkey");
// Before the connect: a responder that cannot open a sealed request, sign
// a grant or authenticate to the queue must not start answering.
let (callout_xkey, issuer, user_seed) =
load_callout_keys(&settings, &args.store_cert_role).await?;
let store = agent_token::Store::new(settings, args.store_cert_role.clone());
let user_seed = read_secret(&args.user_seed_file)?;
let client = async_nats::ConnectOptions::with_nkey(user_seed)
.name("swarm-nats-auth")
.connect(&args.nats_url)
@ -361,8 +365,37 @@ fn open_request(
}
}
/// The `ensure-xkey` verb: see [`xkey::ensure`].
async fn ensure_xkey(store_cert_role: &str) -> anyhow::Result<()> {
/// The callout xkey, the issuer key, and the user key's seed, read from the
/// store and never created here; see `xkey` and `nkey`.
async fn load_callout_keys(
settings: &swarm_secret_client::client::Settings,
store_cert_role: &str,
) -> anyhow::Result<(nkeys::XKey, nkeys::KeyPair, String)> {
let keys = swarm_secret_client::SecretStore::connect(
settings,
store_cert_role,
swarm_secret_client::client::DEFAULT_CERT_MOUNT,
)
.await
.context("logging in to the secret store for the callout keys")?;
let callout_xkey = xkey::load(&keys).await?;
tracing::info!(public_key = %callout_xkey.public_key(), "loaded the callout xkey");
let issuer = nkey::load(&keys, &nkey::ISSUER).await?;
tracing::info!(public_key = %issuer.public_key(), "loaded the callout issuer key");
let user = nkey::load(&keys, &nkey::USER).await?;
tracing::info!(public_key = %user.public_key(), "loaded the callout user key");
let user_seed = user
.seed()
.map_err(|_| anyhow::anyhow!("the callout user key has no seed"))?;
Ok((callout_xkey, issuer, user_seed))
}
/// The `ensure-callout-keys` verb: see [`xkey::ensure`] and [`nkey::ensure`].
async fn ensure_callout_keys(
store_cert_role: &str,
import_issuer_seed_file: Option<&std::path::Path>,
import_user_seed_file: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let store = swarm_secret_client::SecretStore::from_env(store_cert_role)
.await
.context("logging in to the secret store")?;
@ -372,6 +405,17 @@ async fn ensure_xkey(store_cert_role: &str) -> anyhow::Result<()> {
path = xkey::PUBLIC_PATH,
"the callout xkey is in the secret store"
);
for (pair, import) in [
(&nkey::ISSUER, import_issuer_seed_file),
(&nkey::USER, import_user_seed_file),
] {
let key = nkey::ensure(&store, pair, import).await?;
tracing::info!(
public_key = %key.public_key(),
path = pair.public_path,
"the {} key is in the secret store", pair.name
);
}
Ok(())
}
@ -440,10 +484,6 @@ mod tests {
fn the_units_own_argument_vector_parses() {
let args = responder([
"swarm-nats-auth",
"--user-seed-file",
"/run/credentials/callout-user.seed",
"--issuer-seed-file",
"/run/credentials/issuer.seed",
"--client-secret-file",
"/run/credentials/oidc-client.secret",
"--introspection-url",
@ -503,10 +543,6 @@ mod tests {
fn an_agent_suffix_without_a_leading_dash_parses_too() {
let args = responder([
"swarm-nats-auth",
"--user-seed-file",
"/run/credentials/callout-user.seed",
"--issuer-seed-file",
"/run/credentials/issuer.seed",
"--client-secret-file",
"/run/credentials/oidc-client.secret",
"--introspection-url",
@ -524,10 +560,6 @@ mod tests {
fn the_default_suffix_is_what_the_module_passes() {
let args = responder([
"swarm-nats-auth",
"--user-seed-file",
"/run/credentials/callout-user.seed",
"--issuer-seed-file",
"/run/credentials/issuer.seed",
"--client-secret-file",
"/run/credentials/oidc-client.secret",
"--introspection-url",
@ -543,16 +575,37 @@ mod tests {
fn the_ensure_verb_parses_as_the_unit_runs_it() {
let cli = Cli::try_parse_from([
"swarm-nats-auth",
"ensure-xkey",
"ensure-callout-keys",
"--store-cert-role",
"swarm-nats-auth",
"--import-issuer-seed-file",
"/var/lib/swarm-nats-callout/issuer.seed",
"--import-user-seed-file",
"/var/lib/swarm-nats-callout/callout-user.seed",
])
.expect("the verb needs no responder flags");
assert!(cli.responder.is_none());
assert!(matches!(
cli.command,
Some(Command::EnsureXkey { ref store_cert_role }) if store_cert_role == "swarm-nats-auth"
));
let Some(Command::EnsureCalloutKeys {
store_cert_role,
import_issuer_seed_file,
import_user_seed_file,
}) = cli.command
else {
panic!("the verb parses as itself");
};
assert_eq!(store_cert_role, "swarm-nats-auth");
assert_eq!(
import_issuer_seed_file.as_deref(),
Some(std::path::Path::new(
"/var/lib/swarm-nats-callout/issuer.seed"
))
);
assert_eq!(
import_user_seed_file.as_deref(),
Some(std::path::Path::new(
"/var/lib/swarm-nats-callout/callout-user.seed"
))
);
}
/// A bare invocation is neither: the responder's flags stay required
@ -566,9 +619,9 @@ mod tests {
fn the_verb_does_not_take_the_responders_flags() {
Cli::try_parse_from([
"swarm-nats-auth",
"--user-seed-file",
"/run/credentials/callout-user.seed",
"ensure-xkey",
"--client-secret-file",
"/run/credentials/oidc-client.secret",
"ensure-callout-keys",
])
.expect_err("a verb and the responder's flags conflict");
}