341 lines
13 KiB
Rust
341 lines
13 KiB
Rust
//! Integration tests — end-to-end server + client over Unix socket.
|
|
//!
|
|
//! These tests verify the full security pipeline:
|
|
//! caller identification → policy check → approval → dispatch.
|
|
|
|
use nsigner::{
|
|
alg_cache::AlgorithmKeyCache,
|
|
dispatcher::DispatcherContext,
|
|
key_store::KeyStore,
|
|
mnemonic::MnemonicState,
|
|
policy::{parse_preapprove_spec, PolicyTable},
|
|
role_table::{RoleCurve, RolePurpose, RoleTable},
|
|
server::{AuthMode, ListenMode, ServerContext},
|
|
};
|
|
use std::os::unix::net::UnixListener;
|
|
use std::time::Duration;
|
|
|
|
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
|
|
/// Set up a server context with a test mnemonic and role table.
|
|
fn setup_server(socket_name: &str) -> (
|
|
ServerContext,
|
|
RoleTable,
|
|
MnemonicState,
|
|
KeyStore,
|
|
AlgorithmKeyCache,
|
|
) {
|
|
let mut mnemonic = MnemonicState::new();
|
|
mnemonic.load(TEST_MNEMONIC).unwrap();
|
|
|
|
let mut role_table = RoleTable::new();
|
|
role_table
|
|
.register_role_path(
|
|
"main",
|
|
"m/44'/1237'/0'/0/0",
|
|
RolePurpose::Nostr,
|
|
RoleCurve::Secp256k1,
|
|
-1, -1, -1, &[],
|
|
)
|
|
.unwrap();
|
|
|
|
let mut key_store = KeyStore::new();
|
|
key_store.derive_all(&mut role_table, &mnemonic).unwrap();
|
|
|
|
let mut server = ServerContext::new(socket_name, ListenMode::Unix, AuthMode::Off);
|
|
server.start().unwrap();
|
|
|
|
(server, role_table, mnemonic, key_store, AlgorithmKeyCache::new())
|
|
}
|
|
|
|
/// Run the server poll loop in a background thread.
|
|
///
|
|
/// Returns a handle and a stop flag. Set the stop flag to `true` to
|
|
/// terminate the loop (the thread will exit on the next iteration).
|
|
fn spawn_server_loop(
|
|
mut server: ServerContext,
|
|
mut role_table: RoleTable,
|
|
mnemonic: MnemonicState,
|
|
mut key_store: KeyStore,
|
|
mut alg_cache: AlgorithmKeyCache,
|
|
mut policy: PolicyTable,
|
|
) -> (std::thread::JoinHandle<()>, std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
|
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let stop_clone = stop.clone();
|
|
|
|
let handle = std::thread::spawn(move || {
|
|
while server.running && !stop_clone.load(std::sync::atomic::Ordering::SeqCst) {
|
|
let mut dispatcher = DispatcherContext {
|
|
role_table: &mut role_table,
|
|
mnemonic: &mnemonic,
|
|
key_store: &mut key_store,
|
|
alg_key_cache: &mut alg_cache,
|
|
};
|
|
match server.handle_one(&mut dispatcher, &mut policy) {
|
|
Ok(true) => {}
|
|
Ok(false) => {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
|
|
(handle, stop)
|
|
}
|
|
|
|
/// Send a framed request to a Unix socket and return the response.
|
|
fn send_request(socket_name: &str, request: &str) -> String {
|
|
let mut stream = nsigner::transport::connect_abstract_unix(socket_name).unwrap();
|
|
nsigner::transport::send_framed(&mut stream, request).unwrap();
|
|
nsigner::transport::recv_framed(&mut stream).unwrap()
|
|
}
|
|
|
|
/// Wait for the server socket to be ready.
|
|
fn wait_for_server(socket_name: &str, attempts: u32) {
|
|
for _ in 0..attempts {
|
|
if nsigner::transport::connect_abstract_unix(socket_name).is_ok() {
|
|
return;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
panic!("server socket {} not ready", socket_name);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_info_no_policy_needed() {
|
|
let socket_name = format!("nsigner_test_info_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let mut policy = PolicyTable::new();
|
|
policy.init_default(unsafe { libc::getuid() });
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// get_info is metadata — should work without policy
|
|
let resp = send_request(&socket_name, r#"{"id":"1","method":"get_info","params":[]}"#);
|
|
assert!(resp.contains("\"result\""), "get_info failed: {}", resp);
|
|
|
|
// Cleanup: connect to unblock, then stop
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_role_as_password_allows_without_approval() {
|
|
let socket_name = format!("nsigner_test_deny_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
// Policy: catch-all deny (no same-uid prompt entry)
|
|
let mut policy = PolicyTable::new();
|
|
let mut catch_all = nsigner::policy::PolicyEntry::default();
|
|
catch_all.caller = "*".to_string();
|
|
catch_all.prompt = nsigner::policy::PromptMode::Deny;
|
|
policy.add(catch_all).unwrap();
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// The default "main" role has requires_approval=false (role-as-password),
|
|
// so knowing the role name is sufficient — no policy check, no prompt.
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"2","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("\"result\""), "role-as-password should allow, got: {}", resp);
|
|
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_nostr_get_public_key_allowed_with_preapprove() {
|
|
let socket_name = format!("nsigner_test_allow_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let mut policy = PolicyTable::new();
|
|
policy.init_default(unsafe { libc::getuid() });
|
|
|
|
// Preapprove the caller for nostr_get_public_key on main
|
|
let entry = parse_preapprove_spec(
|
|
&format!(
|
|
"caller=uid:{},role=main,verb=nostr_get_public_key",
|
|
unsafe { libc::getuid() }
|
|
),
|
|
)
|
|
.unwrap();
|
|
policy.insert_before_last(entry).unwrap();
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"3","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
|
|
// Result is a plain hex pubkey string (64 hex chars)
|
|
assert!(resp.contains("e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"),
|
|
"expected pubkey in result, got: {}", resp);
|
|
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_role_returns_selector_error() {
|
|
let socket_name = format!("nsigner_test_unknown_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let mut policy = PolicyTable::new();
|
|
policy.init_default(unsafe { libc::getuid() });
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Unknown role should return unknown_role error before policy check
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"4","method":"nostr_get_public_key","params":[{"role":"nonexistent","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("unknown_role"), "expected unknown_role, got: {}", resp);
|
|
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_ed25519_sign_denied_without_approval() {
|
|
let socket_name = format!("nsigner_test_alg_deny_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
// Catch-all deny
|
|
let mut policy = PolicyTable::new();
|
|
let mut catch_all = nsigner::policy::PolicyEntry::default();
|
|
catch_all.caller = "*".to_string();
|
|
catch_all.prompt = nsigner::policy::PromptMode::Deny;
|
|
policy.add(catch_all).unwrap();
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
let msg_hex = hex::encode(b"hello");
|
|
let req = format!(
|
|
r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
|
|
msg_hex
|
|
);
|
|
let resp = send_request(&socket_name, &req);
|
|
assert!(resp.contains("policy_denied"), "expected policy_denied, got: {}", resp);
|
|
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_ed25519_sign_allowed_with_preapprove() {
|
|
let socket_name = format!("nsigner_test_alg_allow_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let mut policy = PolicyTable::new();
|
|
policy.init_default(unsafe { libc::getuid() });
|
|
|
|
// Preapprove algorithm-based sign
|
|
let entry = parse_preapprove_spec(&format!(
|
|
"caller=uid:{},algorithm=ed25519,index=0-4,verb=sign",
|
|
unsafe { libc::getuid() }
|
|
))
|
|
.unwrap();
|
|
policy.insert_before_last(entry).unwrap();
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
let msg_hex = hex::encode(b"hello");
|
|
let req = format!(
|
|
r#"{{"id":"6","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
|
|
msg_hex
|
|
);
|
|
let resp = send_request(&socket_name, &req);
|
|
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
|
|
assert!(resp.contains("signature"), "expected signature in result: {}", resp);
|
|
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_grant_flow() {
|
|
let socket_name = format!("nsigner_test_session_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let mut policy = PolicyTable::new();
|
|
policy.init_default(unsafe { libc::getuid() });
|
|
|
|
// Simulate an approval that grants a session: insert a session grant
|
|
// for the caller (as if the user pressed 'e' at the prompt).
|
|
let caller_id = format!("uid:{}", unsafe { libc::getuid() });
|
|
policy
|
|
.insert_session_grant(&caller_id, "nostr_get_public_key", "main")
|
|
.unwrap();
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// First request: allowed by session grant
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"7","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
|
|
|
|
// Second request: still allowed (session grant persists)
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"8","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
|
|
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_allow_all_flag_skips_prompt() {
|
|
let socket_name = format!("nsigner_test_allowall_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
// Set --allow-all equivalent
|
|
nsigner::tui::set_prompt_always_allow(true);
|
|
|
|
let mut policy = PolicyTable::new();
|
|
policy.init_default(unsafe { libc::getuid() });
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Same-uid would normally prompt — but --allow-all auto-approves
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"9","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("\"result\""), "expected success with allow-all, got: {}", resp);
|
|
|
|
// Reset flag
|
|
nsigner::tui::set_prompt_always_allow(false);
|
|
|
|
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
// Keep UnixListener import used (for potential future filesystem socket tests)
|
|
#[allow(dead_code)]
|
|
fn _unused(_l: UnixListener) {} |