//! 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::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 { 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 { 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> { 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 { 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()); } }