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.
283 lines
11 KiB
Rust
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, ¬_curve)]);
|
|
let e = ensure(&store).await.expect_err("an account seed");
|
|
assert!(!format!("{e:#}").contains(¬_curve), "{e:#}");
|
|
let e = load(&store).await.expect_err("an account seed");
|
|
assert!(!format!("{e:#}").contains(¬_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());
|
|
}
|
|
}
|