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:
parent
1b18f6dcbd
commit
8a4bb97377
5 changed files with 610 additions and 174 deletions
365
swarm-nats-auth/src/nkey.rs
Normal file
365
swarm-nats-auth/src/nkey.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
//! The callout's two nkeys: the account key the responder signs every user
|
||||
//! JWT with ([`ISSUER`]), and the user key it connects to the queue as
|
||||
//! ([`USER`]).
|
||||
//!
|
||||
//! Each seed and its public half live in the secret store beside the xkey's,
|
||||
//! and only the `swarm-nats-auth` role reads them. The queue's host renders
|
||||
//! both public halves into `auth_callout` from there.
|
||||
//!
|
||||
//! [`ensure`] creates a pair when the store holds no seed, from the seed in an
|
||||
//! import file when one is given and present, and removes that file once the
|
||||
//! store holds a seed. When the store already holds a different seed, the
|
||||
//! store's is kept and the file is removed after a warning. [`load`] only ever
|
||||
//! reads, for the same reason as `xkey::load`.
|
||||
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use nkeys::{KeyPair, KeyPairType};
|
||||
|
||||
use crate::keystore::KeyStore;
|
||||
|
||||
/// One of the callout's nkeys: where its two halves are stored, and which
|
||||
/// kind of key it is.
|
||||
pub struct Pair {
|
||||
/// What logs and errors call it.
|
||||
pub name: &'static str,
|
||||
/// The seed's path under the store's KV mount.
|
||||
pub seed_path: &'static str,
|
||||
/// The public half's path under the store's KV mount.
|
||||
pub public_path: &'static str,
|
||||
kind: KeyPairType,
|
||||
}
|
||||
|
||||
/// The account key. The nix halves are `issuerSeedLeaf` and
|
||||
/// `issuerPublicLeaf` in `nix/host-modules/swarm-bao.nix`.
|
||||
pub const ISSUER: Pair = Pair {
|
||||
name: "callout issuer",
|
||||
seed_path: "swarm/services/swarm-nats/callout/issuer-seed",
|
||||
public_path: "swarm/public/swarm-nats/callout-issuer",
|
||||
kind: KeyPairType::Account,
|
||||
};
|
||||
|
||||
/// The callout-exempt user. The nix halves are `userSeedLeaf` and
|
||||
/// `userPublicLeaf` in `nix/host-modules/swarm-bao.nix`.
|
||||
pub const USER: Pair = Pair {
|
||||
name: "callout user",
|
||||
seed_path: "swarm/services/swarm-nats/callout/user-seed",
|
||||
public_path: "swarm/public/swarm-nats/callout-user",
|
||||
kind: KeyPairType::User,
|
||||
};
|
||||
|
||||
impl Pair {
|
||||
/// Never carries the parse error: it is about the bytes of the seed.
|
||||
fn parse_seed(&self, seed: &str, source: &str) -> Result<KeyPair> {
|
||||
KeyPair::from_seed(seed.trim())
|
||||
.ok()
|
||||
.filter(|key| key.key_pair_type() == self.kind)
|
||||
.ok_or_else(|| anyhow!("{source} does not hold a {} seed", self.name))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored pair, created first when no seed is stored.
|
||||
///
|
||||
/// The created seed is the one in `import` when that file exists and is not
|
||||
/// empty, so a key that was kept on disk keeps its public half. Once the store
|
||||
/// holds a seed, `import` is removed: after a warning when it holds a
|
||||
/// different seed, since the store's is the one in use.
|
||||
///
|
||||
/// Safe to run from two places at once, as `xkey::ensure` is.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the store cannot be read or written, when `import` cannot be read or
|
||||
/// removed, when the stored seed is not of this pair's kind, when the store
|
||||
/// holds none and `import` is not a seed of that kind, and when the stored
|
||||
/// public half is not the seed's.
|
||||
pub async fn ensure(store: &impl KeyStore, pair: &Pair, import: Option<&Path>) -> Result<KeyPair> {
|
||||
let key = match store.read(pair.seed_path).await? {
|
||||
Some(seed) => pair.parse_seed(&seed, pair.seed_path)?,
|
||||
None => create_seed(store, pair, import).await?,
|
||||
};
|
||||
publish(store, pair, &key.public_key()).await?;
|
||||
if let Some(import) = import {
|
||||
retire(store, pair, import).await?;
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// The stored pair. Never writes.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the store cannot be read, holds no seed, or holds one that is not of
|
||||
/// this pair's kind.
|
||||
pub async fn load(store: &impl KeyStore, pair: &Pair) -> Result<KeyPair> {
|
||||
let seed = store.read(pair.seed_path).await?.with_context(|| {
|
||||
format!(
|
||||
"nothing is stored at {}; swarm-bao-nats-callout-xkey creates it",
|
||||
pair.seed_path
|
||||
)
|
||||
})?;
|
||||
pair.parse_seed(&seed, pair.seed_path)
|
||||
}
|
||||
|
||||
/// The seed in `path`, or `None` when there is no such file or it is empty.
|
||||
fn read_import(path: &Path) -> Result<Option<String>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(raw) if raw.trim().is_empty() => Ok(None),
|
||||
Ok(raw) => Ok(Some(raw.trim().to_owned())),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_seed(store: &impl KeyStore, pair: &Pair, import: Option<&Path>) -> Result<KeyPair> {
|
||||
let imported = match import {
|
||||
Some(path) => read_import(path)?.map(|seed| (path, seed)),
|
||||
None => None,
|
||||
};
|
||||
let key = match &imported {
|
||||
Some((path, seed)) => pair.parse_seed(seed, &path.display().to_string())?,
|
||||
None => KeyPair::new(pair.kind.clone()),
|
||||
};
|
||||
let seed = key
|
||||
.seed()
|
||||
.map_err(|_| anyhow!("the {} key has no seed", pair.name))?;
|
||||
match store.create(pair.seed_path, &seed).await {
|
||||
Ok(()) => {
|
||||
if let Some((path, _)) = &imported {
|
||||
tracing::info!(
|
||||
public_key = %key.public_key(),
|
||||
from = %path.display(),
|
||||
"imported the {} seed into the store", pair.name
|
||||
);
|
||||
} else {
|
||||
tracing::info!(public_key = %key.public_key(), "created the {} seed", pair.name);
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => match store.read(pair.seed_path).await? {
|
||||
Some(theirs) => pair.parse_seed(&theirs, pair.seed_path),
|
||||
None => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish(store: &impl KeyStore, pair: &Pair, public: &str) -> Result<()> {
|
||||
let stored = match store.read(pair.public_path).await? {
|
||||
Some(stored) => stored,
|
||||
None => match store.create(pair.public_path, public).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => store.read(pair.public_path).await?.ok_or(e)?,
|
||||
},
|
||||
};
|
||||
anyhow::ensure!(
|
||||
stored == public,
|
||||
"{} holds a public key that is not the one for the seed at {}",
|
||||
pair.public_path,
|
||||
pair.seed_path
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes `import` once a read of the store, made after it, returns a seed
|
||||
/// of this pair's kind. Logs a warning first when that seed is not the one in
|
||||
/// the file.
|
||||
async fn retire(store: &impl KeyStore, pair: &Pair, import: &Path) -> Result<()> {
|
||||
let on_disk = match std::fs::read_to_string(import) {
|
||||
Ok(raw) => raw.trim().to_owned(),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e).with_context(|| format!("reading {}", import.display())),
|
||||
};
|
||||
let stored = store.read(pair.seed_path).await?.with_context(|| {
|
||||
format!(
|
||||
"{} is empty after it was ensured; keeping {}",
|
||||
pair.seed_path,
|
||||
import.display()
|
||||
)
|
||||
})?;
|
||||
pair.parse_seed(&stored, pair.seed_path)?;
|
||||
if !on_disk.is_empty() && stored.trim() != on_disk {
|
||||
tracing::warn!(
|
||||
file = %import.display(),
|
||||
path = pair.seed_path,
|
||||
"the store holds a different {} seed than this file; keeping the store's and removing the file",
|
||||
pair.name
|
||||
);
|
||||
}
|
||||
std::fs::remove_file(import).with_context(|| format!("removing {}", import.display()))?;
|
||||
tracing::info!(file = %import.display(), "removed the {} seed file", pair.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::keystore::tests::Fake;
|
||||
|
||||
fn seed_of(key: &KeyPair) -> String {
|
||||
key.seed().expect("a generated key has a seed")
|
||||
}
|
||||
|
||||
/// A file under a directory of its own, removed with the guard.
|
||||
struct Import {
|
||||
dir: PathBuf,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Import {
|
||||
fn new(test: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"swarm-nats-auth-nkey-{}-{test}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("a temp dir");
|
||||
let path = dir.join("seed");
|
||||
Self { dir, path }
|
||||
}
|
||||
|
||||
fn holding(test: &str, contents: &str) -> Self {
|
||||
let import = Self::new(test);
|
||||
std::fs::write(&import.path, contents).expect("written");
|
||||
import
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Import {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_empty_store_with_no_import_gets_a_new_pair_of_the_right_kind() {
|
||||
let store = Fake::default();
|
||||
let issuer = ensure(&store, &ISSUER, None).await.expect("ensured");
|
||||
let user = ensure(&store, &USER, None).await.expect("ensured");
|
||||
assert!(issuer.public_key().starts_with('A'), "an account key");
|
||||
assert!(user.public_key().starts_with('U'), "a user key");
|
||||
assert_eq!(store.get(ISSUER.seed_path), Some(seed_of(&issuer)));
|
||||
assert_eq!(store.get(ISSUER.public_path), Some(issuer.public_key()));
|
||||
assert_eq!(store.get(USER.seed_path), Some(seed_of(&user)));
|
||||
assert_eq!(store.get(USER.public_path), Some(user.public_key()));
|
||||
assert_eq!(store.writes(), 4);
|
||||
}
|
||||
|
||||
/// The key kept on disk keeps its public half, and the file is gone once
|
||||
/// the store holds the same seed.
|
||||
#[tokio::test]
|
||||
async fn an_import_file_is_stored_as_is_and_then_removed() {
|
||||
let existing = KeyPair::new_account();
|
||||
let import = Import::holding("imported", &format!("{}\n", seed_of(&existing)));
|
||||
let store = Fake::default();
|
||||
let key = ensure(&store, &ISSUER, Some(&import.path))
|
||||
.await
|
||||
.expect("ensured");
|
||||
assert_eq!(key.public_key(), existing.public_key());
|
||||
assert_eq!(store.get(ISSUER.seed_path), Some(seed_of(&existing)));
|
||||
assert_eq!(store.get(ISSUER.public_path), Some(existing.public_key()));
|
||||
assert!(!import.path.exists(), "the import file is removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_absent_or_empty_import_file_mints_a_new_key() {
|
||||
let absent = Import::new("absent");
|
||||
let store = Fake::default();
|
||||
ensure(&store, &USER, Some(&absent.path))
|
||||
.await
|
||||
.expect("ensured");
|
||||
assert!(store.get(USER.seed_path).is_some());
|
||||
|
||||
let empty = Import::holding("empty", "\n");
|
||||
let store = Fake::default();
|
||||
ensure(&store, &USER, Some(&empty.path))
|
||||
.await
|
||||
.expect("ensured");
|
||||
assert!(store.get(USER.seed_path).is_some());
|
||||
assert!(!empty.path.exists(), "the empty file is removed");
|
||||
}
|
||||
|
||||
/// The store's seed is the one in use, so it wins over a file that
|
||||
/// differs, and the file goes.
|
||||
#[tokio::test]
|
||||
async fn a_stored_seed_wins_over_a_different_import_file() {
|
||||
let stored = KeyPair::new_account();
|
||||
let store = Fake::holding(&[
|
||||
(ISSUER.seed_path, &seed_of(&stored)),
|
||||
(ISSUER.public_path, &stored.public_key()),
|
||||
]);
|
||||
let import = Import::holding("differs", &seed_of(&KeyPair::new_account()));
|
||||
let key = ensure(&store, &ISSUER, Some(&import.path))
|
||||
.await
|
||||
.expect("ensured");
|
||||
assert_eq!(key.public_key(), stored.public_key());
|
||||
assert_eq!(store.get(ISSUER.seed_path), Some(seed_of(&stored)));
|
||||
assert_eq!(store.writes(), 0);
|
||||
assert!(!import.path.exists(), "the differing file is removed");
|
||||
}
|
||||
|
||||
/// Lost the create to a start that stored another seed: that seed is
|
||||
/// kept, and the file still goes.
|
||||
#[tokio::test]
|
||||
async fn an_import_that_loses_the_create_race_takes_the_winners_seed() {
|
||||
let winner = KeyPair::new_user();
|
||||
let store = Fake::default();
|
||||
store.rival(USER.seed_path, &seed_of(&winner));
|
||||
let import = Import::holding("race", &seed_of(&KeyPair::new_user()));
|
||||
let key = ensure(&store, &USER, Some(&import.path))
|
||||
.await
|
||||
.expect("ensured");
|
||||
assert_eq!(key.public_key(), winner.public_key());
|
||||
assert_eq!(store.get(USER.public_path), Some(winner.public_key()));
|
||||
assert!(!import.path.exists());
|
||||
}
|
||||
|
||||
/// Nothing is stored and the file is not a seed of this kind: the file
|
||||
/// is the only copy there may be, so it is kept and nothing is stored.
|
||||
#[tokio::test]
|
||||
async fn an_import_of_the_wrong_kind_is_refused_and_kept_without_quoting_it() {
|
||||
let user_seed = seed_of(&KeyPair::new_user());
|
||||
let import = Import::holding("wrong-kind", &user_seed);
|
||||
let store = Fake::default();
|
||||
let e = ensure(&store, &ISSUER, Some(&import.path))
|
||||
.await
|
||||
.expect_err("a user seed is no issuer");
|
||||
assert!(!format!("{e:#}").contains(&user_seed), "{e:#}");
|
||||
assert!(import.path.exists(), "the file is kept");
|
||||
assert_eq!(store.writes(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stored_seed_of_the_wrong_kind_is_refused_without_quoting_it() {
|
||||
let account_seed = seed_of(&KeyPair::new_account());
|
||||
let store = Fake::holding(&[(USER.seed_path, &account_seed)]);
|
||||
let e = ensure(&store, &USER, None)
|
||||
.await
|
||||
.expect_err("an account seed");
|
||||
assert!(!format!("{e:#}").contains(&account_seed), "{e:#}");
|
||||
let e = load(&store, &USER).await.expect_err("an account seed");
|
||||
assert!(!format!("{e:#}").contains(&account_seed), "{e:#}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_public_half_that_is_not_the_seeds_is_refused() {
|
||||
let existing = KeyPair::new_user();
|
||||
let store = Fake::holding(&[
|
||||
(USER.seed_path, &seed_of(&existing)),
|
||||
(USER.public_path, &KeyPair::new_user().public_key()),
|
||||
]);
|
||||
assert!(ensure(&store, &USER, None).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loading_reads_the_stored_seed_and_never_creates_one() {
|
||||
let store = Fake::default();
|
||||
assert!(load(&store, &ISSUER).await.is_err());
|
||||
assert_eq!(store.writes(), 0);
|
||||
|
||||
let existing = KeyPair::new_account();
|
||||
store.put(ISSUER.seed_path, &seed_of(&existing));
|
||||
let key = load(&store, &ISSUER).await.expect("loaded");
|
||||
assert_eq!(key.public_key(), existing.public_key());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue