aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md5
-rw-r--r--docs/example.toml5
-rw-r--r--src/access/registry.rs72
-rw-r--r--src/config/mod.rs15
-rw-r--r--src/main.rs5
-rw-r--r--src/simtest/mod.rs2
-rw-r--r--src/simtest/policy.rs4
-rw-r--r--src/swarm/bootstrap.rs11
8 files changed, 98 insertions, 21 deletions
diff --git a/docs/access.md b/docs/access.md
index a182266..d81f115 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -38,7 +38,10 @@ password hashes: every member reads the document).
A swarm gets a registry from its start if its genesis node is given its first users,
`--bootstrap-users`: it records them, with the swarm ID, in its first change, each with
everything (`[]` and `["_continuum"]`). Other users, of its `--htpasswd` or any node's,
-may do nothing until added (the genesis node warns of those of its `--htpasswd`).
+may do nothing until added (the genesis node warns of those of its `--htpasswd`). With
+`--bootstrap-keys alice=ssh-ed25519\ AAAA…\ laptop` it records their keys too, so they
+log in by key and sign values from the start: a swarm whose first user then writes the
+policy (Values signed by their users, below) is strict from its start.
Without it, the swarm has no registry: its users are all alike (A swarm without a
registry, below), until someone gives it one. `--protocol-writers` counts in
both: without a registry, as who may write protocol data; with one, to set it right
diff --git a/docs/example.toml b/docs/example.toml
index 034976f..d2d9a67 100644
--- a/docs/example.toml
+++ b/docs/example.toml
@@ -115,6 +115,11 @@ htpasswd = "/etc/continuum/htpasswd"
# and only those in protocol-writers write protocol data.
# bootstrap-users = ["alice"]
+# 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.
+# bootstrap-keys = ["alice=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… laptop"]
+
# How log lines look: text (timestamped), journal (for systemd's journal: no timestamp or
# colours, the level as each line's priority, for journalctl -p), or auto: journal when
# stdout goes to the journal, else text.
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