Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth/src/xkey.rs
atlas 8a4bb97377 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.
2026-10-06 14:53:33 +02:00

283 lines
11 KiB
Rust

//! The callout xkey: the curve keypair `nats-server` seals every auth request
//! to, and that this responder seals every reply back with.
//!
//! Both halves live in the secret store, and only the `swarm-nats-auth` role
//! reads either. The seed is at [`SEED_PATH`]; the public half is at
//! [`PUBLIC_PATH`], and the queue's host renders it into `auth_callout.xkey`
//! from there.
//!
//! [`ensure`] creates the pair when the seed is absent and otherwise only
//! reads it. [`load`] only ever reads, and is what the responder uses: a
//! responder that minted a new pair on its own start would hold a key the
//! running server does not know, and every request would fail to open.
use anyhow::{Context, Result, anyhow};
use nkeys::XKey;
use crate::keystore::KeyStore;
/// The seed's path under the store's KV mount. The nix half is
/// `xkeySeedLeaf` in `nix/host-modules/swarm-bao.nix`.
pub const SEED_PATH: &str = "swarm/services/swarm-nats/callout/xkey-seed";
/// The public half's path under the store's KV mount. The nix half is
/// `xkeyPublicLeaf` in `nix/host-modules/swarm-bao.nix`, and the fetch in
/// `nix/host-modules/swarm-nats.nix` reads it.
pub const PUBLIC_PATH: &str = "swarm/public/swarm-nats/callout-xkey";
/// The header `nats-server` sets on a request it sealed. Its value is the
/// server's own public xkey, which the reply is sealed to.
pub const SERVER_XKEY_HEADER: &str = "Nats-Server-Xkey";
/// The stored pair, created first when no seed is stored.
///
/// Safe to run from two places at once. Each writes with a create that fails
/// once the path holds anything, so one seed lands, and the run whose write
/// failed reads that one back. The public half is written the same way, and
/// is written even when the seed already exists, so a run that stopped
/// between the two writes is completed by the next.
///
/// # Errors
/// When the store cannot be read or written, when the stored seed is not a
/// curve seed, and when the stored public half is not the seed's.
pub async fn ensure(store: &impl KeyStore) -> Result<XKey> {
let key = match store.read(SEED_PATH).await? {
Some(seed) => from_seed(&seed)?,
None => create_seed(store).await?,
};
publish(store, &key.public_key()).await?;
Ok(key)
}
/// The stored pair. Never writes.
///
/// # Errors
/// When the store cannot be read, holds no seed, or holds one that is not a
/// curve seed.
pub async fn load(store: &impl KeyStore) -> Result<XKey> {
let seed = store.read(SEED_PATH).await?.with_context(|| {
format!("nothing is stored at {SEED_PATH}; swarm-bao-nats-callout-xkey creates it")
})?;
from_seed(&seed)
}
async fn create_seed(store: &impl KeyStore) -> Result<XKey> {
let fresh = XKey::new();
let seed = fresh
.seed()
.map_err(|_| anyhow!("a freshly generated xkey has no seed"))?;
match store.create(SEED_PATH, &seed).await {
Ok(()) => {
tracing::info!(public_key = %fresh.public_key(), "created the callout xkey");
Ok(fresh)
}
Err(e) => match store.read(SEED_PATH).await? {
Some(theirs) => from_seed(&theirs),
None => Err(e),
},
}
}
async fn publish(store: &impl KeyStore, public: &str) -> Result<()> {
let stored = match store.read(PUBLIC_PATH).await? {
Some(stored) => stored,
None => match store.create(PUBLIC_PATH, public).await {
Ok(()) => return Ok(()),
Err(e) => store.read(PUBLIC_PATH).await?.ok_or(e)?,
},
};
anyhow::ensure!(
stored == public,
"{PUBLIC_PATH} holds a public key that is not the one for the seed at {SEED_PATH}"
);
Ok(())
}
/// Never carries the parse error: it is about the bytes of the seed.
fn from_seed(seed: &str) -> Result<XKey> {
XKey::from_seed(seed.trim()).map_err(|_| anyhow!("{SEED_PATH} does not hold a curve seed"))
}
/// `payload` opened with `own`, and the server key `server_key` names, which
/// the reply is sealed to.
///
/// # Errors
/// When there is no [`SERVER_XKEY_HEADER`], when it is not a public xkey, and
/// when the payload does not open with `own`.
pub fn open(own: &XKey, server_key: Option<&str>, payload: &[u8]) -> Result<(XKey, Vec<u8>)> {
let server_key = server_key
.with_context(|| format!("the request is not sealed: no {SERVER_XKEY_HEADER}"))?;
let server = XKey::from_public_key(server_key)
.map_err(|_| anyhow!("{SERVER_XKEY_HEADER} is not a public xkey"))?;
let opened = own
.open(payload, &server)
.map_err(|_| anyhow!("the request does not open with this responder's xkey"))?;
Ok((server, opened))
}
/// `reply` sealed by `own` to `server`.
///
/// # Errors
/// When sealing fails, which it does only for a key with no seed.
pub fn seal(own: &XKey, server: &XKey, reply: &[u8]) -> Result<Vec<u8>> {
own.seal(reply, server)
.map_err(|_| anyhow!("sealing the reply"))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::keystore::tests::Fake;
fn seed_of(key: &XKey) -> String {
key.seed().expect("a generated key has a seed")
}
#[tokio::test]
async fn an_empty_store_gets_a_seed_and_its_public_half() {
let store = Fake::default();
let key = ensure(&store).await.expect("ensured");
assert_eq!(store.get(SEED_PATH), Some(seed_of(&key)));
assert_eq!(store.get(PUBLIC_PATH), Some(key.public_key()));
assert!(key.public_key().starts_with('X'), "a curve public key");
assert_eq!(store.writes(), 2);
}
#[tokio::test]
async fn an_existing_pair_is_only_read() {
let existing = XKey::new();
let store = Fake::holding(&[
(SEED_PATH, &seed_of(&existing)),
(PUBLIC_PATH, &existing.public_key()),
]);
let key = ensure(&store).await.expect("ensured");
assert_eq!(key.public_key(), existing.public_key());
assert_eq!(store.writes(), 0);
}
/// A run that stopped between its two writes left a seed and no public
/// half; the next one writes the public half for that seed.
#[tokio::test]
async fn an_existing_seed_without_its_public_half_gets_one() {
let existing = XKey::new();
let store = Fake::holding(&[(SEED_PATH, &seed_of(&existing))]);
let key = ensure(&store).await.expect("ensured");
assert_eq!(key.public_key(), existing.public_key());
assert_eq!(store.get(PUBLIC_PATH), Some(existing.public_key()));
assert_eq!(store.writes(), 1);
}
/// Two starts read an empty store at once and both generate; the one
/// whose create fails takes the seed that landed.
#[tokio::test]
async fn a_start_that_loses_the_create_race_takes_the_winners_seed() {
let winner = XKey::new();
let store = Fake::default();
store.rival(SEED_PATH, &seed_of(&winner));
store.rival(PUBLIC_PATH, &winner.public_key());
let key = ensure(&store).await.expect("ensured");
assert_eq!(key.public_key(), winner.public_key());
assert_eq!(store.get(SEED_PATH), Some(seed_of(&winner)));
assert_eq!(store.get(PUBLIC_PATH), Some(winner.public_key()));
assert_eq!(store.writes(), 0);
}
#[tokio::test]
async fn a_public_half_that_is_not_the_seeds_is_refused() {
let existing = XKey::new();
let store = Fake::holding(&[
(SEED_PATH, &seed_of(&existing)),
(PUBLIC_PATH, &XKey::new().public_key()),
]);
assert!(ensure(&store).await.is_err());
}
#[tokio::test]
async fn a_seed_that_is_not_a_curve_seed_is_refused_without_quoting_it() {
let account = nkeys::KeyPair::new_account();
let not_curve = account.seed().expect("a seed");
let store = Fake::holding(&[(SEED_PATH, &not_curve)]);
let e = ensure(&store).await.expect_err("an account seed");
assert!(!format!("{e:#}").contains(&not_curve), "{e:#}");
let e = load(&store).await.expect_err("an account seed");
assert!(!format!("{e:#}").contains(&not_curve), "{e:#}");
}
#[tokio::test]
async fn loading_reads_the_stored_seed_and_never_creates_one() {
let store = Fake::default();
assert!(load(&store).await.is_err());
assert_eq!(store.writes(), 0);
assert_eq!(store.get(SEED_PATH), None);
let existing = XKey::new();
store.put(SEED_PATH, &seed_of(&existing));
let key = load(&store).await.expect("loaded");
assert_eq!(key.public_key(), existing.public_key());
}
pub(crate) fn request_jwt() -> Vec<u8> {
let b = |s: &str| data_encoding::BASE64URL_NOPAD.encode(s.as_bytes());
format!(
"{}.{}.{}",
b("{}"),
b(r#"{"nats":{"connect_opts":{"auth_token":"t0k"},"user_nkey":"UCLIENT","server_id":{"id":"NSERVER"}}}"#),
b("sig"),
)
.into_bytes()
}
/// The exchange as the server runs it: it seals the request to our public
/// key under a key of its own, which it names in the header, and opens
/// our reply with that same key.
#[test]
fn a_sealed_request_opens_and_its_grant_is_sealed_back_to_the_server() {
let own = XKey::new();
let server = XKey::new();
let own_public = XKey::from_public_key(&own.public_key()).expect("public");
let sealed = server.seal(&request_jwt(), &own_public).expect("sealed");
let (server_key, opened) = open(&own, Some(&server.public_key()), &sealed).expect("opens");
assert_eq!(server_key.public_key(), server.public_key());
let req = crate::request::decode(&opened).expect("a request");
assert_eq!(req.user_nkey, "UCLIENT");
let issuer = nkeys::KeyPair::new_account();
let permissions = crate::policy::Permissions {
publish: vec!["$KV.hive-status.alpha".to_owned()],
subscribe: None,
};
let token = crate::respond::grant(
&issuer,
"APP",
&req.server_id.id,
&req.user_nkey,
&permissions,
);
let reply = seal(&own, &server_key, token.as_bytes()).expect("sealed");
assert_ne!(reply, token.as_bytes(), "the reply leaves sealed");
let read_by_server = server
.open(&reply, &own_public)
.expect("the server opens it");
assert_eq!(read_by_server, token.as_bytes());
}
#[test]
fn a_request_without_the_header_is_refused() {
let own = XKey::new();
let e = open(&own, None, &request_jwt()).expect_err("no header");
assert!(format!("{e:#}").contains(SERVER_XKEY_HEADER), "{e:#}");
}
#[test]
fn a_request_sealed_to_another_key_does_not_open() {
let own = XKey::new();
let server = XKey::new();
let someone_else = XKey::from_public_key(&XKey::new().public_key()).expect("public");
let sealed = server.seal(&request_jwt(), &someone_else).expect("sealed");
assert!(open(&own, Some(&server.public_key()), &sealed).is_err());
// The header present over a payload that was never sealed.
assert!(open(&own, Some(&server.public_key()), &request_jwt()).is_err());
assert!(open(&own, Some("not-a-key"), &sealed).is_err());
}
}