From ce3e029b3db9eed1668dbbb8d60d971deda8c44a Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 18 Jul 2026 16:48:35 +0200 Subject: [PATCH] Add guided node enrollment --- README.md | 1 + crates/geth-cli/src/lib.rs | 111 +++++++++- crates/geth-control/src/lib.rs | 34 ++- crates/geth-node/src/lib.rs | 307 ++++++++++++++++++++++++++ crates/geth-node/src/local_control.rs | 23 ++ docs/architecture.md | 6 +- docs/roadmap.md | 3 + docs/user-workflows.md | 23 +- 8 files changed, 490 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index eff56f5..d76dab2 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ The bootstrap implementation provides: - `geth backup restore --target-home ` - `geth node id` - `geth node list` +- `geth node enroll join --admin-key --node-name ` - `geth node enroll request --node-name --capability [--out ]` - `geth node enroll submit [--request-id |--path ]` - `geth node enroll import ` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 4702249..98483e3 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -174,21 +174,26 @@ const GUIDE_ENROLLMENT: &str = r#"Add another node/device: On the new node: geth init - geth keychain init --admin-key ~/.ssh/id_ed25519_sk.pub - geth peer import /tmp/owner.peer.json - geth node enroll request --node-name workstation --out /tmp/workstation-enrollment.json - geth node enroll submit owner --path /tmp/workstation-enrollment.json + geth daemon install + geth node enroll join /tmp/owner.peer.json \ + --admin-key /tmp/owner-admin.pub \ + --node-name workstation On the owner/YubiKey machine: geth node enroll list geth node enroll approve --signing-key ~/.ssh/id_ed25519_sk Back on the new node: - geth sync now owner - geth node list + geth node enroll sync + geth keychain status Enrollment approval records signed keychain/auth operations. Discovery and peer -cards alone never grant trust or capabilities. +cards alone never grant trust or capabilities. The join command combines peer +import, explicit admin public-key trust bootstrap, signed request creation, and +Iroh submission; it does not approve the request. Verify the admin public key +through a separate trusted channel before using it. For offline transfer or +recovery, the lower-level `request`, `submit`, and `import` commands remain +available. "#; const GUIDE_KEYS: &str = r#"Key terminology: @@ -819,6 +824,23 @@ pub enum NodeCommand { #[derive(Debug, Subcommand)] pub enum NodeEnrollCommand { + /// Import an owner peer card, create a request, and submit it in one step + #[command( + after_help = "Example:\n geth node enroll join owner.peer.json --admin-key owner-admin.pub --node-name workstation --capability resource:kv:preferences=kv.read\n\nThe explicitly supplied admin public key bootstraps trust for later approval sync. Peer-card import supplies candidate routing metadata only. Owner approval remains a separate, admin-signed action." + )] + Join { + peer_card_path: PathBuf, + #[arg(long)] + admin_key: PathBuf, + #[arg(long)] + node_name: String, + #[arg(long = "capability")] + capabilities: Vec, + #[arg(long)] + reason: Option, + #[arg(long)] + out: Option, + }, /// Create a signed enrollment request on the new node #[command( after_help = "Examples:\n geth node enroll request --node-name workstation --out workstation.enroll.json\n geth node enroll request --node-name ci-runner --capability resource:kv:builds=kv.read" @@ -1639,6 +1661,18 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> { ("geth node enroll request", "capabilities") => { Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one") } + ("geth node enroll join", "peer_card_path") => { + Some("Signed owner peer-card JSON file to import as an untrusted candidate") + } + ("geth node enroll join", "admin_key") => { + Some("Owner OpenSSH admin public key used as the explicit trust anchor") + } + ("geth node enroll join", "capabilities") => { + Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one") + } + ("geth node enroll join" | "geth node enroll request", "out") => { + Some("Optional path for a portable enrollment-request JSON copy") + } ("geth kv create" | "geth kv set" | "geth kv get" | "geth kv sync", "name") => { Some("Name of the KV store") } @@ -2244,6 +2278,21 @@ fn request_for_command(command: Command) -> Result { Command::Node { command: NodeCommand::Enroll { command }, } => match command { + NodeEnrollCommand::Join { + peer_card_path, + admin_key, + node_name, + capabilities, + reason, + out, + } => ControlRequest::NodeEnrollJoin { + peer_card_path, + admin_key_path: admin_key, + node_name, + capabilities, + reason, + out, + }, NodeEnrollCommand::Request { node_name, capabilities, @@ -4397,6 +4446,28 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } println!("note: {note}"); } + ControlResponse::NodeEnrollmentJoined { + peer, + request, + out, + owner_node_id, + admin_key_id, + accepted, + note, + } => { + println!("imported owner candidate: {}", peer.card.node_id); + println!("trust: candidate-only"); + println!("admin trust anchor: {admin_key_id}"); + println!("node enrollment request: {}", request.id); + println!("requested_name: {}", request.requested_node_name); + if let Some(out) = out { + println!("written: {}", out.display()); + } + println!("submitted_to: {owner_node_id}"); + println!("accepted: {accepted}"); + println!("next: the owner reviews and approves this request with an admin key"); + println!("note: {note}"); + } ControlResponse::NodeEnrollmentSubmitted { request_id, owner_node_id, @@ -5349,6 +5420,32 @@ mod tests { assert_eq!(config.sync.live_sync_interval_ms, 500); } + #[test] + fn guided_enrollment_join_parses_the_common_new_node_workflow() { + let parsed = Cli::try_parse_from([ + "geth", + "node", + "enroll", + "join", + "owner.peer.json", + "--admin-key", + "owner-admin.pub", + "--node-name", + "workstation", + "--capability", + "resource:kv:preferences=kv.read", + ]) + .expect("parse guided enrollment"); + assert!(matches!( + parsed.command, + Command::Node { + command: NodeCommand::Enroll { + command: NodeEnrollCommand::Join { .. } + } + } + )); + } + #[tokio::test] async fn ephemeral_daemon_rejects_an_explicit_persistent_home() { let parsed = Cli::try_parse_from([ diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index eabdfa8..67936e0 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -203,6 +203,14 @@ pub enum ControlRequest { reason: Option, out: Option, }, + NodeEnrollJoin { + peer_card_path: PathBuf, + admin_key_path: PathBuf, + node_name: String, + capabilities: Vec, + reason: Option, + out: Option, + }, NodeEnrollSubmit { owner_node: String, request_id: Option, @@ -886,6 +894,15 @@ pub enum ControlResponse { out: Option, note: String, }, + NodeEnrollmentJoined { + peer: DiscoveredPeer, + request: NodeEnrollmentRequest, + out: Option, + owner_node_id: String, + admin_key_id: String, + accepted: bool, + note: String, + }, NodeEnrollmentSubmitted { request_id: String, owner_node_id: String, @@ -1817,8 +1834,8 @@ mod tests { use super::*; use serde_json::{Map, Value, json}; - const CONTROL_REQUEST_VARIANTS: usize = 122; - const CONTROL_RESPONSE_VARIANTS: usize = 114; + const CONTROL_REQUEST_VARIANTS: usize = 123; + const CONTROL_RESPONSE_VARIANTS: usize = 115; const PEER_CONTROL_REQUEST_VARIANTS: usize = 19; const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20; const PIPE_WIRE_REQUEST_VARIANTS: usize = 3; @@ -2518,6 +2535,19 @@ mod tests { request ); + let request = ControlRequest::NodeEnrollJoin { + peer_card_path: PathBuf::from("owner.peer.json"), + admin_key_path: PathBuf::from("owner-admin.pub"), + node_name: "workstation".to_owned(), + capabilities: vec!["resource:ssh-proxy:local=ssh_proxy.connect".to_owned()], + reason: Some("new machine".to_owned()), + out: Some(PathBuf::from("enrollment.json")), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let request = ControlRequest::NodeEnrollSync { owner_node: "owner-laptop".to_owned(), }; diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 17ba0d6..0fd3429 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -3516,6 +3516,142 @@ async fn node_enrollment_submit_to_peer( } } +async fn node_enrollment_join_owner( + node: &LocalNode, + peer_card_path: PathBuf, + admin_key_path: PathBuf, + node_name: String, + capabilities: Vec, + reason: Option, + out: Option, +) -> Result { + let (discovered, request, admin_key_id) = prepare_node_enrollment_join( + node, + &peer_card_path, + &admin_key_path, + node_name, + capabilities, + reason, + out.as_deref(), + )?; + let owner_node = discovered.card.node_id.to_string(); + let submission = node_enrollment_submit_to_peer( + node, + &owner_node, + Some(request.id.to_string()), + None, + ) + .await + .map_err(|error| { + NodeError::IrohPeer(format!( + "imported owner candidate and created enrollment request {}, but submission failed: {error}\nnext: retry with `geth node enroll submit {owner_node} --request-id {}`", + request.id, request.id + )) + })?; + let ControlResponse::NodeEnrollmentSubmitted { + owner_node_id, + accepted, + note, + .. + } = submission + else { + return Err(NodeError::IrohPeer( + "owner returned an unexpected enrollment response".to_owned(), + )); + }; + Ok(ControlResponse::NodeEnrollmentJoined { + peer: discovered, + request, + out, + owner_node_id, + admin_key_id, + accepted, + note: format!( + "{}; {note}; owner approval with an admin signing key is still required", + discovery_is_untrusted_note() + ), + }) +} + +fn prepare_node_enrollment_join( + node: &LocalNode, + peer_card_path: &Path, + admin_key_path: &Path, + node_name: String, + capabilities: Vec, + reason: Option, + out: Option<&Path>, +) -> Result<(DiscoveredPeer, NodeEnrollmentRequest, String), NodeError> { + let admin_public_key = std::fs::read_to_string(admin_key_path)?; + if admin_public_key.trim().is_empty() { + return Err(NodeError::IrohPeer( + "owner admin public key file is empty".to_owned(), + )); + } + validate_openssh_public_key_text(&admin_public_key)?; + let admin_key_id = ssh_public_key_fingerprint(&admin_public_key); + let card_json = std::fs::read_to_string(peer_card_path)?; + let card: PeerCard = serde_json::from_str(&card_json)?; + card.validate_candidate()?; + let discovered = DiscoveredPeer::candidate( + card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::Imported, + )?; + let store = Store::open(&node.paths.metadata_db())?; + let admin_key = KeyId::new(admin_key_id.clone()); + let keychain = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); + if !keychain.admin_keys.contains(&admin_key) { + handle_request( + node, + ControlRequest::KeychainInit { + admin_key_path: Some(admin_key_path.to_path_buf()), + signing_key_path: None, + }, + )?; + } + store.upsert_peer_card(&StoredPeerCard { + peer_id: card.node_id.to_string(), + card_json: serde_json::to_string(&card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + + let mut request = create_node_enrollment_request(node, node_name, capabilities, reason)?; + sign_node_enrollment_request(node, &mut request)?; + store_node_enrollment_request(&store, &request)?; + if let Some(path) = out { + write_node_enrollment_request_file(path, &request)?; + } + Ok((discovered, request, admin_key_id)) +} + +fn validate_openssh_public_key_text(public_key: &str) -> Result<(), NodeError> { + let mut fields = public_key.split_whitespace(); + let key_type = fields.next().unwrap_or_default(); + let encoded = fields.next().unwrap_or_default(); + let recognized_type = key_type.starts_with("ssh-") + || key_type.starts_with("ecdsa-sha2-") + || key_type.starts_with("sk-"); + if !recognized_type || encoded.is_empty() { + return Err(NodeError::IrohPeer( + "owner admin key must be an OpenSSH public key, for example `ssh-ed25519 AAAA...`" + .to_owned(), + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded)) + .map_err(|_| { + NodeError::IrohPeer("owner admin key contains invalid base64 key data".to_owned()) + })?; + if decoded.is_empty() { + return Err(NodeError::IrohPeer( + "owner admin key contains empty key data".to_owned(), + )); + } + Ok(()) +} + async fn node_enrollment_sync_from_owner( node: &LocalNode, owner_node: &str, @@ -7571,6 +7707,7 @@ pub fn handle_request( | ControlRequest::SshAdminShell { .. } | ControlRequest::KeychainSync { .. } | ControlRequest::AuthSync { .. } + | ControlRequest::NodeEnrollJoin { .. } | ControlRequest::NodeEnrollSubmit { .. } | ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ModuleStub { module, command } => { @@ -13823,6 +13960,176 @@ mod tests { right_endpoint.shutdown().await; } + #[tokio::test] + async fn guided_enrollment_imports_creates_and_submits_in_one_request() { + if skip_iroh_integration_tests() { + eprintln!("skipping Iroh integration test because GETH_TEST_SKIP_IROH is set"); + return; + } + let requester_home = tempfile::tempdir().expect("requester home"); + let owner_home = tempfile::tempdir().expect("owner home"); + let requester_paths = GethPaths::from_home(requester_home.path()); + let owner_paths = GethPaths::from_home(owner_home.path()); + let mut requester = init_node(&requester_paths).expect("init requester"); + let mut owner = init_node(&owner_paths).expect("init owner"); + write_offline_iroh_config(&requester_paths); + write_offline_iroh_config(&owner_paths); + + let Some(requester_endpoint) = start_daemon_iroh_endpoint(&mut requester) + .await + .expect("requester iroh") + else { + eprintln!("skipping guided enrollment; requester endpoint unavailable"); + return; + }; + let Some(owner_endpoint) = start_daemon_iroh_endpoint(&mut owner) + .await + .expect("owner iroh") + else { + eprintln!("skipping guided enrollment; owner endpoint unavailable"); + requester_endpoint.shutdown().await; + return; + }; + spawn_iroh_control_accept_loop(owner.clone(), owner_endpoint.clone()); + + let exported = handle_request_async(&owner, ControlRequest::PeerCardExport { out: None }) + .await + .expect("export owner peer card"); + let owner_card = match exported { + ControlResponse::PeerCardExported { card, .. } => card, + other => panic!("unexpected peer export response: {other:?}"), + }; + let card_path = requester_home.path().join("owner.peer.json"); + std::fs::write( + &card_path, + serde_json::to_string_pretty(&owner_card).expect("card json"), + ) + .expect("write owner peer card"); + let admin_key_path = requester_home.path().join("owner-admin.pub"); + std::fs::write( + &admin_key_path, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n", + ) + .expect("write owner admin public key"); + + let joined = handle_request_async( + &requester, + ControlRequest::NodeEnrollJoin { + peer_card_path: card_path, + admin_key_path, + node_name: "workstation".to_owned(), + capabilities: vec!["resource:kv:preferences=kv.read".to_owned()], + reason: Some("new workstation".to_owned()), + out: None, + }, + ) + .await + .expect("guided enrollment"); + let request_id = match joined { + ControlResponse::NodeEnrollmentJoined { + request, + owner_node_id, + admin_key_id, + accepted, + peer, + .. + } => { + assert!(accepted); + assert_eq!(owner_node_id, owner.node_id.to_string()); + assert_eq!(peer.card.node_id.to_string(), owner.node_id); + assert!(admin_key_id.starts_with("ssh:blake3:")); + request.id.to_string() + } + other => panic!("unexpected guided enrollment response: {other:?}"), + }; + + let received = handle_request(&owner, ControlRequest::NodeEnrollList { status: None }) + .expect("list owner requests"); + match received { + ControlResponse::NodeEnrollmentList { requests, .. } => { + assert!( + requests + .iter() + .any(|request| request.id.to_string() == request_id) + ); + } + other => panic!("unexpected enrollment list response: {other:?}"), + } + + requester_endpoint.shutdown().await; + owner_endpoint.shutdown().await; + } + + #[test] + fn guided_enrollment_preparation_records_explicit_trust_peer_and_request() { + assert!(validate_openssh_public_key_text("not-a-public-key").is_err()); + let home = tempfile::tempdir().expect("requester home"); + let paths = GethPaths::from_home(home.path()); + let requester = init_node(&paths).expect("init requester"); + let owner_key = AgentKey::generate(); + let owner_card = PeerCard::signed( + "node:owner".into(), + &owner_key, + vec![EndpointCandidate { + endpoint_id: "endpoint:owner".to_owned(), + relay_url: None, + direct_addresses: Vec::new(), + source: DiscoverySource::Manual, + }], + UnixMillis(1), + ) + .expect("owner peer card"); + let card_path = home.path().join("owner.peer.json"); + std::fs::write( + &card_path, + serde_json::to_string_pretty(&owner_card).expect("card json"), + ) + .expect("write card"); + let admin_key_path = home.path().join("owner-admin.pub"); + std::fs::write( + &admin_key_path, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n", + ) + .expect("write admin key"); + let request_path = home.path().join("request.json"); + + let (peer, request, admin_key_id) = prepare_node_enrollment_join( + &requester, + &card_path, + &admin_key_path, + "workstation".to_owned(), + vec!["resource:kv:preferences=kv.read".to_owned()], + Some("new workstation".to_owned()), + Some(&request_path), + ) + .expect("prepare guided enrollment"); + assert_eq!(peer.card.node_id.to_string(), "node:owner"); + assert!(admin_key_id.starts_with("ssh:blake3:")); + assert_eq!(request.requested_node_name, "workstation"); + assert!(request.provenance.is_some()); + assert!(request_path.is_file()); + + let store = Store::open(&paths.metadata_db()).expect("open store"); + assert!( + store + .get_peer_card("node:owner") + .expect("get peer") + .is_some() + ); + assert!( + store + .get_node_enrollment_request(request.id.as_str()) + .expect("get request") + .is_some() + ); + let status = + handle_request(&requester, ControlRequest::KeychainStatus).expect("keychain status"); + match status { + ControlResponse::KeychainStatus(status) => assert_eq!(status.admin_keys, 1), + other => panic!("unexpected keychain status: {other:?}"), + } + } + fn test_tree(entries: Vec) -> CasTreeObject { CasTreeObject { version: geth_cas::CAS_TREE_OBJECT_VERSION, diff --git a/crates/geth-node/src/local_control.rs b/crates/geth-node/src/local_control.rs index 6babaa0..9572dbd 100644 --- a/crates/geth-node/src/local_control.rs +++ b/crates/geth-node/src/local_control.rs @@ -81,6 +81,25 @@ async fn route_peer_family( request_id, path, } => node_enrollment_submit_to_peer(node, &owner_node, request_id, path).await, + ControlRequest::NodeEnrollJoin { + peer_card_path, + admin_key_path, + node_name, + capabilities, + reason, + out, + } => { + node_enrollment_join_owner( + node, + peer_card_path, + admin_key_path, + node_name, + capabilities, + reason, + out, + ) + .await + } ControlRequest::NodeEnrollSync { owner_node } => { node_enrollment_sync_from_owner(node, &owner_node).await } @@ -266,6 +285,9 @@ pub(crate) fn control_request_trace_fields(request: &ControlRequest) -> ControlT trace.peer_node = node.clone(); trace.stream = Some("all".to_owned()); } + ControlRequest::NodeEnrollJoin { .. } => { + trace.stream = Some("node-enrollment".to_owned()); + } ControlRequest::NodeGrant { node, resource, @@ -450,6 +472,7 @@ fn control_request_command(request: &ControlRequest) -> &'static str { ControlRequest::ResourceCreate { .. } => "resource.create", ControlRequest::KeychainSync { .. } => "keychain.sync", ControlRequest::AuthSync { .. } => "auth.sync", + ControlRequest::NodeEnrollJoin { .. } => "node.enroll.join", ControlRequest::SyncStatus => "sync.status", ControlRequest::SyncNow { .. } => "sync.now", ControlRequest::AuthExplain { .. } => "auth.explain", diff --git a/docs/architecture.md b/docs/architecture.md index cab84e3..059d2f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -473,7 +473,11 @@ sigchain design. This is currently a pull-based signed operation log, not a CRDT or Keyhive-style convergent authority. New devices can use the node enrollment flow instead of hand-editing keychain -state. `geth node enroll request` creates a canonical, agent-key-signed request +state. `geth node enroll join` explicitly imports an owner admin public key as +the new node's trust anchor, imports the signed peer card only as untrusted +candidate routing metadata, creates the request, and submits it over Iroh. It +does not grant trust to the peer card or approve the requesting node. The lower +level `geth node enroll request` creates a canonical, agent-key-signed request containing the requesting node ID, agent ID, requested node name, optional Iroh endpoint, and requested resource capabilities. The request can be submitted over Iroh to an imported owner peer or moved as a JSON file to the owner machine. diff --git a/docs/roadmap.md b/docs/roadmap.md index f793aec..0739387 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -597,6 +597,9 @@ resource-scoped capability decisions. cursors instead of blindly re-requesting the full log on every tick. - `[x]` `geth node enroll request` creates an agent-key-signed enrollment request with requested node name and capabilities. + - `[x]` `geth node enroll join` combines explicit admin-key trust bootstrap, + candidate-only peer-card import, request creation, and Iroh submission while + leaving owner approval as a separate admin-signed step. - `[x]` `geth node enroll submit/import/list` moves pending enrollment requests over Iroh or JSON file for owner review. - `[x]` `geth node enroll approve --signing-key` records signed keychain ops diff --git a/docs/user-workflows.md b/docs/user-workflows.md index 831587d..085d48d 100644 --- a/docs/user-workflows.md +++ b/docs/user-workflows.md @@ -129,14 +129,15 @@ discovery or a peer card as proof of trust. geth peer export --out owner.peer.json ``` -2. On the new node, initialize, import the card, and submit a request: +2. Transfer the owner's OpenSSH admin public key over a trusted channel. On the + new node, initialize and run the guided join: ```sh geth init geth daemon install - geth peer import owner.peer.json - geth node enroll request --node-name workstation --out workstation.enroll.json - geth node enroll submit owner-laptop --path workstation.enroll.json + geth node enroll join owner.peer.json \ + --admin-key owner-admin.pub \ + --node-name workstation ``` 3. On the owner node, review and approve with the admin key: @@ -149,13 +150,19 @@ discovery or a peer card as proof of trust. 4. On the new node, pull and inspect the approved state: ```sh - geth sync now owner-laptop - geth wait sync owner-laptop + geth node enroll sync + geth wait sync geth node list ``` -Peer-card import only supplies signed endpoint metadata. The owner-signed -keychain and authorization operations are what create trust and capabilities. +The explicit admin public key bootstraps the trust anchor needed to verify the +later approval sync; verify that key through a separate trusted channel. The +peer card only supplies signed candidate endpoint metadata. `join` combines +peer import, request creation, and submission, but cannot approve itself. The +owner-signed keychain and authorization operations are what create the device +binding and capabilities. The lower-level `peer import` and `node enroll +request|submit|import` commands remain available for offline handoff and +recovery. ## Move A Blob Between Nodes