Files
signer/plans/policy_enforcement_plan.md

8.9 KiB

Plan: Wire Policy Enforcement into the Server Loop

Problem

The Rust signer 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:

  1. Caller identification (server_get_caller()):

    • Unix socket: SO_PEERCRED → uid:<uid>
    • TCP: getpeername() → tcp:<ip>:<port>
    • stdio/qrexec: QREXEC_REMOTE_DOMAIN env → qubes:<domain> or uid:<uid>
  2. Auth envelope verification (if --auth optional|required):

    • Extracts auth field from JSON-RPC request
    • Verifies NIP-42-style event signature, timestamp skew, replay protection
    • Composes caller_id as pubkey:<hex> for authenticated callers
  3. Selector resolution (extract_method_and_selector() + selector_resolve()):

    • Parses method, role, role_path, index, nostr_index from request
    • Resolves against role table, handles fixed-path vs template roles
    • Detects pending_derivation (new identity that will be derived if approved)
  4. 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, or NoMatch
  5. Approval prompt (prompt_for_policy_decision()):

    • If Prompt, shows TUI prompt with caller, method, role, purpose
    • Returns Allow, Deny, AllowSessionVerb, or AllowSessionAll
    • Session grants are inserted into the policy table for subsequent requests
  6. Pending derivation (if approved and pending_derivation):

    • Derives the key for the requested role/index before dispatching
  7. 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) via libc::getsockopt on the UnixStream fd
  • TCP: getpeername() via TcpStream::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<(), SignerError>;

    /// Insert a session grant for caller+role (all verbs).
    pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), SignerError>;
}

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 PolicyTable to handle_request() — changes dispatcher API
  • Option B: Do policy check in server.rs before 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:

  1. Start a server on a Unix socket with a test mnemonic
  2. Connect a client and send a get_info request (should work without policy)
  3. Send a nostr_get_public_key request without preapproval (should prompt/deny)
  4. Send with preapproval (should allow)
  5. 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:

  1. cargo test — all existing tests still pass
  2. cargo test --test integration — new integration tests pass
  3. Manual test: start server, connect client, verify prompt appears for unapproved requests
  4. Manual test: verify preapproved requests skip prompt
  5. Manual test: verify session grants work (approve once, no re-prompt)