diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | b6f815882a6d4a32c1198bd3666edaeaf1f7bca3 (patch) | |
| tree | 5beab4b31fd4511446c8e3a7445412ce7f33f97a /src | |
| parent | e696d2f40636a6613eddfb1a3afdf87aa7ffdedd (diff) | |
access: --bootstrap-keys
The first users' SSH keys, recorded at genesis with their entries, so a
swarm can be strict from its start.
Auth plan M7.
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/registry.rs | 72 | ||||
| -rw-r--r-- | src/config/mod.rs | 15 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 2 | ||||
| -rw-r--r-- | src/simtest/policy.rs | 4 | ||||
| -rw-r--r-- | src/swarm/bootstrap.rs | 11 |
6 files changed, 89 insertions, 20 deletions
diff --git a/src/access/registry.rs b/src/access/registry.rs index 618402b..4ee51ff 100644 --- a/src/access/registry.rs +++ b/src/access/registry.rs @@ -180,15 +180,48 @@ pub(crate) fn capability(by: &str, since: u64, path: &[String], rights: &[Right] } /// The first users of a new swarm, as its genesis node records them (`first_users`), -/// each with everything (`all_caps`). -pub(crate) type FirstUsers = Vec<String>; +/// each with everything (`all_caps`), and with its keys, by name (`User::keys`). +pub(crate) type FirstUsers = BTreeMap<String, BTreeMap<String, String>>; -/// The first users of a new swarm, from the genesis node's `--bootstrap-users`, sorted. -/// None without: then the swarm has no registry, and its users are all alike -/// (`access.md`, A swarm without a registry). -pub(crate) fn first_users(bootstrap_users: &[String]) -> FirstUsers { - let users: std::collections::BTreeSet<&String> = bootstrap_users.iter().collect(); - users.into_iter().cloned().collect() +/// The first users of a new swarm, from the genesis node's `--bootstrap-users`, with +/// their keys from `--bootstrap-keys` (`USER=ssh-ed25519 <base64> [comment]`, named by +/// the comment if it is a name, else `key1`, `key2`…). None without: then the swarm has +/// no registry, and its users are all alike (`access.md`, A swarm without a registry). +/// A key of a user not among them, or that isn't one, is refused, saying why. +pub(crate) fn first_users( + bootstrap_users: &[String], + bootstrap_keys: &[String], +) -> Result<FirstUsers, String> { + let mut users: FirstUsers = bootstrap_users + .iter() + .map(|name| (name.clone(), BTreeMap::new())) + .collect(); + for item in bootstrap_keys { + let Some((name, line)) = item.split_once('=') else { + return Err(format!( + "bootstrap-keys: {item:?} isn't USER=ssh-ed25519 <base64> [comment]" + )); + }; + let name = name.trim(); + let keys = users + .get_mut(name) + .ok_or_else(|| format!("bootstrap-keys: {name:?} isn't among bootstrap-users"))?; + let key = super::sshsig::PublicKey::parse(line) + .map_err(|why| format!("bootstrap-keys: {name}'s key: {why}"))?; + let comment = line + .split_whitespace() + .nth(2) + .filter(|comment| valid_name(comment)); + let id = match comment { + Some(comment) if !keys.contains_key(comment) => comment.to_owned(), + _ => (1..) + .map(|n| format!("key{n}")) + .find(|id| !keys.contains_key(id)) + .expect("a free name"), + }; + keys.insert(id, key.line()); + } + Ok(users) } /// Everything, as a first user has it (`first_users`): the swarm's data and protocol @@ -281,12 +314,31 @@ mod tests { #[test] fn the_first_users() { let names = |list: &[&str]| list.iter().map(|n| (*n).to_owned()).collect::<Vec<_>>(); + let users = first_users(&names(&["bob", "alice", "bob"]), &[]).unwrap(); assert_eq!( - first_users(&names(&["bob", "alice", "bob"])), + users.keys().cloned().collect::<Vec<_>>(), names(&["alice", "bob"]) ); // Without bootstrap users, no registry. - assert!(first_users(&[]).is_empty()); + assert!(first_users(&[], &[]).unwrap().is_empty()); + // Their keys: by the comment if it is a name, else numbered. + let key = super::super::sshsig::PublicKey([4; 32]).line(); + let keyed = first_users( + &names(&["alice"]), + &[ + format!("alice={key} laptop"), + format!("alice={key}"), + format!("alice={key} not/a/name"), + ], + ) + .unwrap(); + assert_eq!( + keyed["alice"].keys().cloned().collect::<Vec<_>>(), + names(&["key1", "key2", "laptop"]) + ); + assert!(first_users(&names(&["alice"]), &[format!("bob={key}")]).is_err()); + assert!(first_users(&names(&["alice"]), &["alice=ssh-rsa AAAA".to_owned()]).is_err()); + assert!(first_users(&names(&["alice"]), &[key]).is_err()); let all = Caps::new( all_caps() .into_iter() diff --git a/src/config/mod.rs b/src/config/mod.rs index 0da7ed7..9d918f7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -159,6 +159,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "With genesis: give the new swarm a user registry (docs/access.md), these its first users, each may do everything, protocol data and managing users included; other users may do nothing until added. Unset, the swarm has no registry: its users are all alike, and only those in protocol-writers write protocol data.", ), option( + "bootstrap-keys", + Kind::List, + "USER=KEY,[USER=KEY...]", + None, + "With bootstrap-users: their SSH keys, as authorized_keys lines (ssh-ed25519 <base64> [comment], named by the comment), recorded in their entries: to log in by key and sign values (docs/access.md), so a swarm can be strict from its start.", + ), + option( "log-format", Kind::Text, "auto|text|journal", @@ -572,6 +579,8 @@ pub(crate) struct Settings { #[serde(default)] pub(crate) bootstrap_users: Vec<String>, #[serde(default)] + pub(crate) bootstrap_keys: Vec<String>, + #[serde(default)] pub(crate) cors_origins: Vec<String>, #[serde(default)] pub(crate) sign_every: crate::document::signatures::SignEvery, @@ -830,6 +839,11 @@ impl Settings { { bail!("bootstrap-users: {bad:?} isn't a user name (1 to 64 of A-Z a-z 0-9 . _ @ -)"); } + if let Err(why) = + crate::access::registry::first_users(&self.bootstrap_users, &self.bootstrap_keys) + { + bail!("{why}"); + } if self.genesis && self.recover_from.is_some() { bail!("recover-from starts the swarm from a backup; it can't be combined with genesis"); } @@ -979,6 +993,7 @@ impl Settings { ("swarm-key-create", self.swarm_key_create), ("protocol-writers", !self.protocol_writers.is_empty()), ("bootstrap-users", !self.bootstrap_users.is_empty()), + ("bootstrap-keys", !self.bootstrap_keys.is_empty()), ("alias", self.alias.is_some()), ("dashboard-url", self.dashboard_url.is_some()), ("debug-api", self.debug_api), diff --git a/src/main.rs b/src/main.rs index 24f0556..105266b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,9 +100,10 @@ async fn main() -> Result<()> { // A new swarm's first users, if it is to have a user registry; the users of the // htpasswd file that aren't among them may do nothing until they are added. let first_users = if args.genesis { - access::registry::first_users(&args.bootstrap_users) + access::registry::first_users(&args.bootstrap_users, &args.bootstrap_keys) + .map_err(anyhow::Error::msg)? } else { - Vec::new() + Default::default() }; if !first_users.is_empty() && let Some(path) = args.htpasswd.as_deref() diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index 1dc60ea..7fdc3f0 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -214,7 +214,7 @@ impl Sim { blob_chunk_bytes: 64 * 1024, swarm_key: None, protocol_writers: HashSet::new(), - first_users: Vec::new(), + first_users: Default::default(), // Under the identity layer, its key signs as a node's does. node_key: self.identity.then(|| Arc::new(sim_key(id))), sign_every: Default::default(), diff --git a/src/simtest/policy.rs b/src/simtest/policy.rs index b56766b..d425ecf 100644 --- a/src/simtest/policy.rs +++ b/src/simtest/policy.rs @@ -46,7 +46,7 @@ async fn refused(node: &AppState) -> usize { async fn a_swarm_judging_every_change() { let sim = Sim::with_identity(71); let a = sim.start_with("a", &[], Start::Genesis, |config| { - config.first_users = vec!["alice".to_owned()]; + config.first_users = [("alice".to_owned(), Default::default())].into(); }); let b = sim.start("b", &["a"], Start::Join); let c = sim.start("c", &["a"], Start::Join); @@ -98,7 +98,7 @@ async fn a_swarm_judging_every_change() { async fn a_remake_under_the_policy() { let sim = Sim::with_identity(72); let a = sim.start_with("a", &[], Start::Genesis, |config| { - config.first_users = vec!["alice".to_owned()]; + config.first_users = [("alice".to_owned(), Default::default())].into(); }); let b = sim.start("b", &["a"], Start::Join); let c = sim.start("c", &["a"], Start::Join); diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs index 10ad412..98e273a 100644 --- a/src/swarm/bootstrap.rs +++ b/src/swarm/bootstrap.rs @@ -100,12 +100,13 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start) let users: Vec<(Vec<String>, JsonValue)> = state .first_users .iter() - .map(|name| { + .map(|(name, keys)| { let caps = crate::access::registry::all_caps(); - ( - crate::access::registry::user_path(name), - crate::access::registry::entry(&by, since, &caps), - ) + let mut entry = crate::access::registry::entry(&by, since, &caps); + if !keys.is_empty() { + entry["keys"] = serde_json::json!(keys); + } + (crate::access::registry::user_path(name), entry) }) .collect(); state |
