diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 5b626fa..bedbded 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -54,6 +54,28 @@ fn generate_ssh_key(path: &std::path::Path) { assert!(status.success()); } +fn sign_payload_with_ssh_key( + dir: &std::path::Path, + stem: &str, + private_key_path: &std::path::Path, + namespace: &str, + payload: &[u8], +) -> Vec { + let payload_path = dir.join(format!("{stem}.payload")); + std::fs::write(&payload_path, payload).expect("write signing payload"); + let output = geth_ssh_identity::sign_command(private_key_path, namespace, &payload_path) + .output() + .expect("run ssh-keygen sign"); + assert!( + output.status.success(), + "ssh-keygen sign stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let signature_path = + std::path::Path::new(&format!("{}.sig", payload_path.display())).to_path_buf(); + std::fs::read(signature_path).expect("read signature") +} + fn wait_for_socket(path: &std::path::Path) { let started = Instant::now(); while started.elapsed() < Duration::from_secs(5) { @@ -993,6 +1015,273 @@ fn invalidly_signed_keychain_and_auth_ops_are_rejected_during_peer_sync() { ); } +#[test] +fn conflicting_keychain_and_auth_ops_are_rejected_during_peer_sync() { + if !ssh_keygen_available() { + return; + } + + 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 admin_key_path = left_home.path().join("admin_ed25519"); + generate_ssh_key(&admin_key_path); + let admin_public_key_path = admin_key_path.with_extension("pub"); + let admin_public_key = + std::fs::read_to_string(&admin_public_key_path).expect("read admin public key"); + let signer = geth_types::KeyId::new(geth_ssh_identity::ssh_public_key_fingerprint( + &admin_public_key, + )); + + 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 trust_admin = run_geth( + left_home.path(), + &[ + "keychain", + "init", + "--admin-key", + admin_public_key_path + .to_str() + .expect("admin public key path"), + ], + ); + assert!( + trust_admin.status.success(), + "trust admin stderr: {}", + String::from_utf8_lossy(&trust_admin.stderr) + ); + + 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 keychain_op_id = geth_types::AuthOpId::new("keychain-op:conflict"); + let local_keychain_op = geth_keychain::KeychainOp { + id: keychain_op_id.clone(), + created_at: geth_types::UnixMillis(30), + kind: geth_keychain::KeychainOpKind::UserAdd { + user: geth_types::UserId::new("user:local-conflict"), + name: "Local Conflict".to_owned(), + }, + }; + let remote_keychain_op = geth_keychain::KeychainOp { + id: keychain_op_id.clone(), + created_at: geth_types::UnixMillis(31), + kind: geth_keychain::KeychainOpKind::UserAdd { + user: geth_types::UserId::new("user:remote-conflict"), + name: "Remote Conflict".to_owned(), + }, + }; + let auth_op_id = geth_types::AuthOpId::new("auth-op:conflict"); + let local_auth_op = geth_auth::AuthOp { + id: auth_op_id.clone(), + resource: geth_types::ResourceId::new("resource:ssh-proxy:local"), + created_at: geth_types::UnixMillis(32), + kind: geth_auth::AuthOpKind::GrantCreate { + grant_id: "grant:local-conflict".to_owned(), + principal: geth_types::PrincipalId::new("node:local-conflict"), + capabilities: vec![geth_types::Capability::new("ssh_proxy.connect")], + }, + }; + let remote_auth_op = geth_auth::AuthOp { + id: auth_op_id.clone(), + resource: geth_types::ResourceId::new("resource:ssh-proxy:local"), + created_at: geth_types::UnixMillis(33), + kind: geth_auth::AuthOpKind::GrantCreate { + grant_id: "grant:remote-conflict".to_owned(), + principal: geth_types::PrincipalId::new("node:remote-conflict"), + capabilities: vec![geth_types::Capability::new("ssh_proxy.admin_shell")], + }, + }; + + let left_paths = geth_config::GethPaths::from_home(left_home.path()); + let right_paths = geth_config::GethPaths::from_home(right_home.path()); + let left_store = geth_store::Store::open(&left_paths.metadata_db()).expect("open left store"); + let right_store = + geth_store::Store::open(&right_paths.metadata_db()).expect("open right store"); + left_store + .insert_keychain_op(&geth_store::StoredKeychainOp { + op_id: local_keychain_op.id.to_string(), + op_json: serde_json::to_string(&local_keychain_op).expect("encode local keychain op"), + created_at_ms: local_keychain_op.created_at.0, + }) + .expect("insert local keychain op"); + left_store + .insert_auth_op(&geth_store::StoredAuthOp { + op_id: local_auth_op.id.to_string(), + resource_id: local_auth_op.resource.to_string(), + op_json: serde_json::to_string(&local_auth_op).expect("encode local auth op"), + created_at_ms: local_auth_op.created_at.0, + }) + .expect("insert local auth op"); + right_store + .insert_keychain_op(&geth_store::StoredKeychainOp { + op_id: remote_keychain_op.id.to_string(), + op_json: serde_json::to_string(&remote_keychain_op).expect("encode remote keychain op"), + created_at_ms: remote_keychain_op.created_at.0, + }) + .expect("insert remote keychain op"); + let keychain_signature = sign_payload_with_ssh_key( + right_home.path(), + "conflicting-keychain-op", + &admin_key_path, + geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE, + &geth_keychain::keychain_signing_payload(&remote_keychain_op) + .expect("keychain signing payload"), + ); + right_store + .insert_keychain_signature(&geth_store::StoredKeychainSignature { + op_id: remote_keychain_op.id.to_string(), + signer: signer.to_string(), + signer_public_key: admin_public_key.clone(), + namespace: geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), + signature: keychain_signature, + created_at_ms: 34, + }) + .expect("insert remote keychain signature"); + right_store + .insert_auth_op(&geth_store::StoredAuthOp { + op_id: remote_auth_op.id.to_string(), + resource_id: remote_auth_op.resource.to_string(), + op_json: serde_json::to_string(&remote_auth_op).expect("encode remote auth op"), + created_at_ms: remote_auth_op.created_at.0, + }) + .expect("insert remote auth op"); + let auth_signature = sign_payload_with_ssh_key( + right_home.path(), + "conflicting-auth-op", + &admin_key_path, + geth_auth::AUTH_SIGNATURE_NAMESPACE, + &geth_auth::auth_signing_payload(&remote_auth_op).expect("auth signing payload"), + ); + right_store + .insert_auth_signature(&geth_store::StoredAuthSignature { + op_id: remote_auth_op.id.to_string(), + signer: signer.to_string(), + signer_public_key: admin_public_key.clone(), + namespace: geth_auth::AUTH_SIGNATURE_NAMESPACE.to_owned(), + signature: auth_signature, + created_at_ms: 35, + }) + .expect("insert remote auth signature"); + + 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_store = geth_store::Store::open(&left_paths.metadata_db()).expect("open left store"); + let stored_keychain_op = left_store + .list_keychain_ops() + .expect("list left keychain ops") + .into_iter() + .find(|op| op.op_id == keychain_op_id.as_str()) + .expect("local keychain op remains"); + assert_eq!( + stored_keychain_op.op_json, + serde_json::to_string(&local_keychain_op).expect("encode expected local keychain op") + ); + let stored_auth_op = left_store + .list_auth_ops() + .expect("list left auth ops") + .into_iter() + .find(|op| op.op_id == auth_op_id.as_str()) + .expect("local auth op remains"); + assert_eq!( + stored_auth_op.op_json, + serde_json::to_string(&local_auth_op).expect("encode expected local auth op") + ); +} + #[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 cc48d17..5e2fb25 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,7 +25,7 @@ Implementation order: keychain/auth operations are rejected and not imported. - `[x]` Add initial two-daemon tests proving invalidly signed replicated keychain/auth operations are rejected and not imported. - - `[ ]` Add tests proving conflicting replicated keychain/auth records do + - `[x]` Add tests proving 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 @@ -75,7 +75,7 @@ Implementation order: local trust or resource state. - `[x]` Tests assert invalidly signed replicated keychain/auth records do not mutate local trust or resource state. - - `[ ]` Tests assert conflicting replicated keychain/auth records do not + - `[x]` Tests assert conflicting replicated keychain/auth records do not mutate local trust or resource state. - `[~]` Remote authorization enforcement audit.