diff --git a/README.md b/README.md index aa25a92..6bc6b30 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,12 @@ geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk The enrollment flow for a new node is: ```sh -# On the new node: +# On the new node, initialize local state and trust the owner's admin public key: +geth init +geth daemon run +geth keychain init --admin-key ~/.ssh/id_ed25519_sk.pub + +# Then create a signed enrollment request: geth node enroll request \ --node-name workstation \ --capability resource:ssh-proxy:local=ssh_proxy.connect \ @@ -400,13 +405,16 @@ geth node enroll list --status pending geth node enroll approve --signing-key ~/.ssh/id_ed25519_sk # Back on the new node, pull signed identity and authorization state: -geth node enroll sync owner-laptop +geth sync now owner-laptop +geth sync status ``` Enrollment requests are signed by the requesting agent key. Approval records signed keychain operations for the new device/node/agent binding and signed auth -operations for requested resource capabilities. `geth node enroll sync` pulls -both signed logs from the owner node. +operations for requested resource capabilities. The requesting node must already +know the owner's admin public key so it can verify the signed operation logs +before importing them. `geth sync now` pulls both signed logs from the owner +node through the same path used by background live sync. `geth keychain sync ` pulls signed keychain operations from an imported peer over Iroh and rejects operations that do not have a valid OpenSSH signature diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index a554f11..c71bc0e 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -36,6 +36,24 @@ fn spawn_daemon(home: &std::path::Path) -> Child { .expect("spawn daemon") } +fn ssh_keygen_available() -> bool { + Command::new("ssh-keygen").arg("-?").output().is_ok() +} + +fn generate_ssh_key(path: &std::path::Path) { + let status = Command::new("ssh-keygen") + .arg("-q") + .arg("-t") + .arg("ed25519") + .arg("-N") + .arg("") + .arg("-f") + .arg(path) + .status() + .expect("generate ssh key"); + assert!(status.success()); +} + fn wait_for_socket(path: &std::path::Path) { let started = Instant::now(); while started.elapsed() < Duration::from_secs(5) { @@ -197,6 +215,262 @@ fn peer_ping_uses_daemon_owned_iroh_endpoint() { assert!(stdout.contains("candidate status does not grant resource capabilities")); } +#[test] +fn sync_now_completes_owner_approved_node_enrollment_flow() { + if !ssh_keygen_available() { + return; + } + + let owner_home = tempfile::tempdir().expect("owner tempdir"); + let requester_home = tempfile::tempdir().expect("requester tempdir"); + if !unix_sockets_available(owner_home.path()) || !unix_sockets_available(requester_home.path()) + { + return; + } + + let owner_key = owner_home.path().join("owner_ed25519"); + generate_ssh_key(&owner_key); + let owner_pub = owner_key.with_extension("pub"); + let owner_pub_arg = owner_pub.to_str().expect("owner pub path"); + let owner_key_arg = owner_key.to_str().expect("owner key path"); + + let owner_init = run_geth( + owner_home.path(), + &[ + "init", + "--admin-key", + owner_pub_arg, + "--signing-key", + owner_key_arg, + "--owner", + "Eric", + "--node-name", + "owner-laptop", + ], + ); + assert!( + owner_init.status.success(), + "owner init stderr: {}", + String::from_utf8_lossy(&owner_init.stderr) + ); + let requester_init = run_geth(requester_home.path(), &["init"]); + assert!( + requester_init.status.success(), + "requester init stderr: {}", + String::from_utf8_lossy(&requester_init.stderr) + ); + for home in [owner_home.path(), requester_home.path()] { + std::fs::write( + home.join("config.toml"), + "[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n", + ) + .expect("write config"); + } + + let mut owner_daemon = spawn_daemon(owner_home.path()); + let mut requester_daemon = spawn_daemon(requester_home.path()); + wait_for_socket(&owner_home.path().join("run/geth.sock")); + wait_for_socket(&requester_home.path().join("run/geth.sock")); + + let requester_trust = run_geth( + requester_home.path(), + &["keychain", "init", "--admin-key", owner_pub_arg], + ); + assert!( + requester_trust.status.success(), + "requester trust stderr: {}", + String::from_utf8_lossy(&requester_trust.stderr) + ); + + let owner_card_path = owner_home.path().join("owner-peer-card.json"); + let requester_card_path = requester_home.path().join("requester-peer-card.json"); + let owner_export = run_geth( + owner_home.path(), + &[ + "peer", + "export", + "--out", + owner_card_path.to_str().expect("owner card path"), + ], + ); + let requester_export = run_geth( + requester_home.path(), + &[ + "peer", + "export", + "--out", + requester_card_path.to_str().expect("requester card path"), + ], + ); + assert!( + owner_export.status.success(), + "owner export stderr: {}", + String::from_utf8_lossy(&owner_export.stderr) + ); + assert!( + requester_export.status.success(), + "requester export stderr: {}", + String::from_utf8_lossy(&requester_export.stderr) + ); + let owner_card_json = std::fs::read_to_string(&owner_card_path).expect("read owner card"); + let owner_card: geth_discovery::PeerCard = + serde_json::from_str(&owner_card_json).expect("decode owner card"); + let requester_card_json = + std::fs::read_to_string(&requester_card_path).expect("read requester card"); + let requester_card: geth_discovery::PeerCard = + serde_json::from_str(&requester_card_json).expect("decode requester card"); + + let import_owner = run_geth( + requester_home.path(), + &[ + "peer", + "import", + owner_card_path.to_str().expect("owner card path"), + ], + ); + let import_requester = run_geth( + owner_home.path(), + &[ + "peer", + "import", + requester_card_path.to_str().expect("requester card path"), + ], + ); + assert!( + import_owner.status.success(), + "requester import owner stderr: {}", + String::from_utf8_lossy(&import_owner.stderr) + ); + assert!( + import_requester.status.success(), + "owner import requester stderr: {}", + String::from_utf8_lossy(&import_requester.stderr) + ); + + let enroll_request = run_geth( + requester_home.path(), + &[ + "node", + "enroll", + "request", + "--node-name", + "workstation", + "--capability", + "resource:ssh-proxy:local=ssh_proxy.connect", + ], + ); + assert!( + enroll_request.status.success(), + "enroll request stderr: {}", + String::from_utf8_lossy(&enroll_request.stderr) + ); + let enroll_submit = run_geth( + requester_home.path(), + &["node", "enroll", "submit", owner_card.node_id.as_str()], + ); + assert!( + enroll_submit.status.success(), + "enroll submit stderr: {}", + String::from_utf8_lossy(&enroll_submit.stderr) + ); + assert!(String::from_utf8_lossy(&enroll_submit.stdout).contains("accepted: true")); + + let enroll_list = run_geth(owner_home.path(), &["node", "enroll", "list"]); + assert!( + enroll_list.status.success(), + "enroll list stderr: {}", + String::from_utf8_lossy(&enroll_list.stderr) + ); + let enroll_list_stdout = String::from_utf8_lossy(&enroll_list.stdout); + let request_id = enroll_list_stdout + .lines() + .find_map(|line| { + let mut fields = line.split('\t'); + let id = fields.next()?; + if id.starts_with("node-enrollment:") { + Some(id.to_owned()) + } else { + None + } + }) + .expect("request id in enrollment list"); + + let approve = run_geth( + owner_home.path(), + &[ + "node", + "enroll", + "approve", + &request_id, + "--signing-key", + owner_key_arg, + "--capability", + "resource:cas:local=cas.fetch", + ], + ); + assert!( + approve.status.success(), + "approve stderr: {}", + String::from_utf8_lossy(&approve.stderr) + ); + + let sync = run_geth( + requester_home.path(), + &["sync", "now", owner_card.node_id.as_str()], + ); + assert!( + sync.status.success(), + "sync now stderr: {}", + String::from_utf8_lossy(&sync.stderr) + ); + let sync_stdout = String::from_utf8_lossy(&sync.stdout); + assert!(sync_stdout.contains("keychain\tok")); + assert!(sync_stdout.contains("auth\tok")); + + let requester_nodes = run_geth(requester_home.path(), &["node", "list"]); + assert!( + requester_nodes.status.success(), + "requester node list stderr: {}", + String::from_utf8_lossy(&requester_nodes.stderr) + ); + let requester_nodes_stdout = String::from_utf8_lossy(&requester_nodes.stdout); + assert!(requester_nodes_stdout.contains("owner-laptop")); + assert!(requester_nodes_stdout.contains("workstation")); + + let explain = run_geth( + requester_home.path(), + &[ + "auth", + "explain", + requester_card.node_id.as_str(), + "resource:ssh-proxy:local", + "ssh_proxy.connect", + ], + ); + assert!( + explain.status.success(), + "auth explain stderr: {}", + String::from_utf8_lossy(&explain.stderr) + ); + assert!(String::from_utf8_lossy(&explain.stdout).contains("allowed: true")); + + let sync_status = run_geth(requester_home.path(), &["sync", "status"]); + let _ = owner_daemon.kill(); + let _ = requester_daemon.kill(); + let _ = owner_daemon.wait(); + let _ = requester_daemon.wait(); + + assert!( + sync_status.status.success(), + "sync status stderr: {}", + String::from_utf8_lossy(&sync_status.stderr) + ); + let sync_status_stdout = String::from_utf8_lossy(&sync_status.stdout); + assert!(sync_status_stdout.contains("peer:")); + assert!(sync_status_stdout.contains("keychain")); + assert!(sync_status_stdout.contains("auth")); +} + #[test] fn peer_card_export_import_and_list_are_candidate_only() { let source_home = tempfile::tempdir().expect("source tempdir"); diff --git a/docs/roadmap.md b/docs/roadmap.md index 45b60aa..c1f8bf0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,10 +16,10 @@ end-to-end test target for the intended personal mesh use cases. - `[~]` Two-node operator-flow test coverage. Acceptance criteria: - - `[ ]` A two-daemon test covers owner-rooted init, peer-card exchange, + - `[x]` A two-daemon test covers owner-rooted init, peer-card exchange, enrollment request submission, owner approval, `geth sync now`, and `geth sync status`. - - `[ ]` A two-daemon test proves approved keychain/auth state reaches the + - `[x]` A two-daemon test proves approved keychain/auth state reaches the enrolled node without using the old one-off `node enroll sync` shortcut. - `[ ]` Tests assert rejected/unsigned/conflicting replicated records do not mutate local trust or resource state.