From 0ffc950dc6283c4da35bd43f50dd6bc8a6f48542 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Fri, 22 May 2026 14:17:17 +0200 Subject: [PATCH] Test unsigned replicated ops are rejected --- Cargo.lock | 2 + crates/geth/Cargo.toml | 2 + crates/geth/tests/bootstrap.rs | 159 +++++++++++++++++++++++++++++++++ docs/roadmap.md | 12 ++- 4 files changed, 171 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 782461a..5114173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1041,12 +1041,14 @@ name = "geth" version = "0.1.0" dependencies = [ "anyhow", + "geth-auth", "geth-cas", "geth-cli", "geth-config", "geth-control", "geth-discovery", "geth-iroh", + "geth-keychain", "geth-node", "geth-ssh-identity", "geth-store", diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index 7329970..ce1438b 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -16,11 +16,13 @@ tracing-subscriber.workspace = true geth-cli = { path = "../geth-cli" } [dev-dependencies] +geth-auth = { path = "../geth-auth" } geth-cas = { path = "../geth-cas" } geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-discovery = { path = "../geth-discovery" } geth-iroh = { path = "../geth-iroh" } +geth-keychain = { path = "../geth-keychain" } geth-node = { path = "../geth-node" } geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 21ceb8c..10251f7 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -630,6 +630,165 @@ fn denied_remote_operations_do_not_mutate_serving_node_state() { assert!(denied_admin_stdout.contains("ssh admin shell denied")); } +#[test] +fn unsigned_keychain_and_auth_ops_are_rejected_during_peer_sync() { + let left_home = tempfile::tempdir().expect("left tempdir"); + let right_home = tempfile::tempdir().expect("right tempdir"); + if !unix_sockets_available(left_home.path()) || !unix_sockets_available(right_home.path()) { + return; + } + assert!(run_geth(left_home.path(), &["init"]).status.success()); + assert!(run_geth(right_home.path(), &["init"]).status.success()); + for home in [left_home.path(), right_home.path()] { + std::fs::write( + home.join("config.toml"), + "[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n", + ) + .expect("write config"); + } + + let mut left_daemon = spawn_daemon(left_home.path()); + let mut right_daemon = spawn_daemon(right_home.path()); + wait_for_socket(&left_home.path().join("run/geth.sock")); + wait_for_socket(&right_home.path().join("run/geth.sock")); + + let left_card_path = left_home.path().join("left-peer-card.json"); + let right_card_path = right_home.path().join("right-peer-card.json"); + assert!( + run_geth( + left_home.path(), + &[ + "peer", + "export", + "--out", + left_card_path.to_str().expect("left card path"), + ], + ) + .status + .success() + ); + assert!( + run_geth( + right_home.path(), + &[ + "peer", + "export", + "--out", + right_card_path.to_str().expect("right card path"), + ], + ) + .status + .success() + ); + let right_card_json = std::fs::read_to_string(&right_card_path).expect("read right card"); + let right_card: geth_discovery::PeerCard = + serde_json::from_str(&right_card_json).expect("decode right card"); + assert!( + run_geth( + left_home.path(), + &[ + "peer", + "import", + right_card_path.to_str().expect("right card path"), + ], + ) + .status + .success() + ); + assert!( + run_geth( + right_home.path(), + &[ + "peer", + "import", + left_card_path.to_str().expect("left card path"), + ], + ) + .status + .success() + ); + + let unsigned_keychain_op = geth_keychain::KeychainOp { + id: geth_types::AuthOpId::new("keychain-op:unsigned-user-add"), + created_at: geth_types::UnixMillis(10), + kind: geth_keychain::KeychainOpKind::UserAdd { + user: geth_types::UserId::new("user:unsigned"), + name: "Unsigned".to_owned(), + }, + }; + let unsigned_auth_op = geth_auth::AuthOp { + id: geth_types::AuthOpId::new("auth-op:unsigned-grant"), + resource: geth_types::ResourceId::new("resource:ssh-proxy:local"), + created_at: geth_types::UnixMillis(11), + kind: geth_auth::AuthOpKind::GrantCreate { + grant_id: "grant:unsigned".to_owned(), + principal: geth_types::PrincipalId::new("node:unsigned"), + capabilities: vec![geth_types::Capability::new("ssh_proxy.connect")], + }, + }; + let right_paths = geth_config::GethPaths::from_home(right_home.path()); + let right_store = + geth_store::Store::open(&right_paths.metadata_db()).expect("open right store"); + right_store + .insert_keychain_op(&geth_store::StoredKeychainOp { + op_id: unsigned_keychain_op.id.to_string(), + op_json: serde_json::to_string(&unsigned_keychain_op).expect("encode keychain op"), + created_at_ms: unsigned_keychain_op.created_at.0, + }) + .expect("insert unsigned keychain op"); + right_store + .insert_auth_op(&geth_store::StoredAuthOp { + op_id: unsigned_auth_op.id.to_string(), + resource_id: unsigned_auth_op.resource.to_string(), + op_json: serde_json::to_string(&unsigned_auth_op).expect("encode auth op"), + created_at_ms: unsigned_auth_op.created_at.0, + }) + .expect("insert unsigned auth op"); + + let keychain_sync = run_geth( + left_home.path(), + &["keychain", "sync", right_card.node_id.as_str()], + ); + let auth_sync = run_geth( + left_home.path(), + &["auth", "sync", right_card.node_id.as_str()], + ); + let _ = left_daemon.kill(); + let _ = right_daemon.kill(); + let _ = left_daemon.wait(); + let _ = right_daemon.wait(); + + assert!( + keychain_sync.status.success(), + "keychain sync stderr: {}", + String::from_utf8_lossy(&keychain_sync.stderr) + ); + assert!( + auth_sync.status.success(), + "auth sync stderr: {}", + String::from_utf8_lossy(&auth_sync.stderr) + ); + assert!(String::from_utf8_lossy(&keychain_sync.stdout).contains("invalid_ops_rejected: 1")); + assert!(String::from_utf8_lossy(&auth_sync.stdout).contains("invalid_ops_rejected: 1")); + + let left_paths = geth_config::GethPaths::from_home(left_home.path()); + let left_store = geth_store::Store::open(&left_paths.metadata_db()).expect("open left store"); + assert!( + !left_store + .list_keychain_ops() + .expect("list left keychain ops") + .iter() + .any(|op| op.op_id == unsigned_keychain_op.id.as_str()) + ); + assert!( + !left_store + .list_auth_ops() + .expect("list left auth ops") + .iter() + .any(|op| op.op_id == unsigned_auth_op.id.as_str()) + ); +} + #[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 d5a9364..6a76dc0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,8 +21,10 @@ Implementation order: - `[x]` Add initial two-daemon tests proving denied remote pubsub publish, remote pipe listen, and SSH admin shell requests do not mutate serving node state. - - `[ ]` Add tests proving unsigned, invalidly signed, and conflicting - replicated records do not mutate trust/resource state. + - `[x]` Add initial two-daemon tests proving unsigned replicated + keychain/auth operations are rejected and not imported. + - `[ ]` Add tests proving invalidly signed and conflicting replicated + keychain/auth records do not mutate trust/resource state. - `[ ]` Improve `auth explain` diagnostics enough for operators to distinguish discovered-only peers, missing endpoint bindings, missing grants, matching grants, revocations, and bearer access. @@ -67,8 +69,10 @@ Implementation order: `geth sync status`. - `[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. + - `[x]` Tests assert unsigned replicated keychain/auth records do not mutate + local trust or resource state. + - `[ ]` Tests assert invalidly signed/conflicting replicated keychain/auth + records do not mutate local trust or resource state. - `[~]` Remote authorization enforcement audit. Acceptance criteria: