8.9 KiB
Plan: Wire Policy Enforcement into the Server Loop
Problem
The Rust nsigner library modules are complete and tested (92 tests pass), but the server loop in server.rs accepts connections, reads requests, dispatches them, and sends responses without any policy enforcement. The policy: &mut PolicyTable parameter is accepted but never used. This means the daemon would sign anything for anyone without prompting — it is not an "attended signer."
What the C version does (server.c)
The C server_handle_one() implements a full security pipeline before dispatching:
-
Caller identification (
server_get_caller()):- Unix socket:
SO_PEERCRED→uid:<uid> - TCP:
getpeername()→tcp:<ip>:<port> - stdio/qrexec:
QREXEC_REMOTE_DOMAINenv →qubes:<domain>oruid:<uid>
- Unix socket:
-
Auth envelope verification (if
--auth optional|required):- Extracts
authfield from JSON-RPC request - Verifies NIP-42-style event signature, timestamp skew, replay protection
- Composes caller_id as
pubkey:<hex>for authenticated callers
- Extracts
-
Selector resolution (
extract_method_and_selector()+selector_resolve()):- Parses
method,role,role_path,index,nostr_indexfrom request - Resolves against role table, handles fixed-path vs template roles
- Detects
pending_derivation(new identity that will be derived if approved)
- Parses
-
Policy check (
policy_check_with_role()):- Role-as-password: if
role.requires_approval == 0, allow immediately - Otherwise check policy table entries (caller, verb, role, purpose, algorithm, index range)
- Returns
Allow,Deny,Prompt, orNoMatch
- Role-as-password: if
-
Approval prompt (
prompt_for_policy_decision()):- If
Prompt, shows TUI prompt with caller, method, role, purpose - Returns
Allow,Deny,AllowSessionVerb, orAllowSessionAll - Session grants are inserted into the policy table for subsequent requests
- If
-
Pending derivation (if approved and
pending_derivation):- Derives the key for the requested role/index before dispatching
-
Dispatch — only if all checks pass
Current Rust state
| Component | Status | Notes |
|---|---|---|
policy.rs |
✅ Complete | PolicyTable, check(), check_with_role(), check_algorithm(), parse_preapprove_spec() |
auth_envelope.rs |
✅ Complete | AuthNonceCache, verify_request() |
selector.rs |
✅ Complete | SelectorRequest, selector_resolve() |
tui.rs |
✅ Complete | approval_prompt() with y/n/e/a |
server.rs |
❌ Missing | handle_one() accepts policy but never uses it |
main.rs |
⚠️ Partial | Creates PolicyTable, adds preapprove entries, but no session grant support |
Implementation plan
Step 1: Add caller identification to server.rs
Add a CallerIdentity struct and identify_caller() function:
pub struct CallerIdentity {
pub uid: u32,
pub gid: u32,
pub pid: u32,
pub kind: ListenMode,
pub caller_id: String, // "uid:1000", "tcp:127.0.0.1:8080", "qubes:work"
pub source_qube: String, // qrexec only
pub auth_present: bool,
pub auth_pubkey_hex: String,
pub auth_label: String,
}
- Unix:
getsockopt(SO_PEERCRED)vialibc::getsockopton theUnixStreamfd - TCP:
getpeername()viaTcpStream::peer_addr() - stdio/qrexec:
std::env::var("QREXEC_REMOTE_DOMAIN")
Step 2: Add auth envelope verification to server.rs
In handle_one(), after reading the request but before dispatch:
if self.auth_mode != AuthMode::Off {
let mut cache = AuthNonceCache::new(); // or store in ServerContext
match auth_envelope::verify_request(&request, &mut cache, self.auth_skew_seconds) {
Ok((pubkey, label)) => {
caller.auth_present = true;
caller.auth_pubkey_hex = pubkey;
caller.auth_label = label;
caller.caller_id = format!("pubkey:{}", pubkey);
}
Err((code, msg)) => {
if self.auth_mode == AuthMode::Required {
return Ok(send_auth_error(code, msg));
}
// Optional: continue without auth
}
}
}
Step 3: Add selector extraction and policy check to server.rs
Before calling dispatcher::handle_request():
// Extract method and selector from request JSON
let (method, selector_req) = extract_method_and_selector(&request)?;
// Resolve selector against role table
let role_index = selector_resolve(&selector_req, dispatcher.role_table)?;
let role = &dispatcher.role_table.entries[role_index];
// Check policy
let (result, source) = policy.check_with_role(
&caller.caller_id,
method,
&role.name,
role.purpose_str(),
Some(role),
);
match result {
PolicyResult::Allow => { /* proceed to dispatch */ }
PolicyResult::Deny => { /* send policy_denied error */ }
PolicyResult::Prompt => {
let decision = tui::approval_prompt(&caller.caller_id, method, &role.name, role.purpose_str());
match decision {
PolicyResult::Allow => { /* proceed */ }
PolicyResult::AllowSessionVerb => {
// Insert session grant into policy table
policy.insert_session_grant(&caller.caller_id, method, &role.name);
/* proceed */
}
PolicyResult::AllowSessionAll => {
policy.insert_session_grant_all(&caller.caller_id, &role.name);
/* proceed */
}
_ => { /* deny */ }
}
}
_ => { /* deny */ }
}
Step 4: Add session grant support to policy.rs
Add methods to insert session grants:
impl PolicyTable {
/// Insert a session grant for caller+role+verb.
pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), NsignerError>;
/// Insert a session grant for caller+role (all verbs).
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), NsignerError>;
}
These create PolicyEntry with source: PolicySource::SessionGrant and prompt: PromptMode::Never, inserted before the catch-all deny rule.
Step 5: Add extract_method_and_selector() helper
Port the C function that parses a JSON-RPC request to extract:
method(string)role(from last params object)role_path(from last params object)index(from last params object)nostr_index(from last params object)
Returns (method, SelectorRequest).
Step 6: Add pending_derivation support
When the selector resolves to a role that hasn't been derived yet (or a template role with a new index), set pending_derivation = true. After policy approval, derive the key before dispatching:
if pending_derivation {
key_store.derive_one(role_table, mnemonic, role_index)?;
}
Step 7: Add --allow-all flag support
In main.rs, when cli.allow_all is true, set a global flag that makes approval_prompt() return Allow immediately (matching C's g_prompt_always_allow).
Step 8: Add policy to DispatcherContext or pass separately
The dispatcher currently doesn't know about policy. Options:
- Option A: Pass
&mut PolicyTabletohandle_request()— changes dispatcher API - Option B: Do policy check in
server.rsbefore calling dispatcher — cleaner separation
Recommendation: Option B. The server is the security boundary; the dispatcher is just a router.
Step 9: Integration tests
Add tests in tests/ that:
- Start a server on a Unix socket with a test mnemonic
- Connect a client and send a
get_inforequest (should work without policy) - Send a
nostr_get_public_keyrequest without preapproval (should prompt/deny) - Send with preapproval (should allow)
- Test session grants (approve once, second request should not prompt)
File changes
| File | Changes |
|---|---|
src/server.rs |
Add CallerIdentity, identify_caller(), auth envelope check, selector extraction, policy check, approval prompt call, pending derivation |
src/policy.rs |
Add insert_session_grant(), insert_session_grant_all() |
src/main.rs |
Add --allow-all flag handling, pass AuthNonceCache to server |
src/tui.rs |
Add prompt_always_allow flag support |
src/dispatcher.rs |
No changes needed (policy stays in server) |
tests/integration.rs |
New file: end-to-end server+client tests |
Verification
After implementation:
cargo test— all existing tests still passcargo test --test integration— new integration tests pass- Manual test: start server, connect client, verify prompt appears for unapproved requests
- Manual test: verify preapproved requests skip prompt
- Manual test: verify session grants work (approve once, no re-prompt)