Files
signer/plans/policy_enforcement_plan.md
T

221 lines
8.9 KiB
Markdown

# 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`](src/server.rs:117) 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`](src/policy.rs) | ✅ Complete | `PolicyTable`, `check()`, `check_with_role()`, `check_algorithm()`, `parse_preapprove_spec()` |
| [`auth_envelope.rs`](src/auth_envelope.rs) | ✅ Complete | `AuthNonceCache`, `verify_request()` |
| [`selector.rs`](src/selector.rs) | ✅ Complete | `SelectorRequest`, `selector_resolve()` |
| [`tui.rs`](src/tui.rs) | ✅ Complete | `approval_prompt()` with y/n/e/a |
| [`server.rs`](src/server.rs) | ❌ **Missing** | `handle_one()` accepts `policy` but never uses it |
| [`main.rs`](src/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:
```rust
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:
```rust
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()`:
```rust
// 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:
```rust
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:
```rust
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`](src/server.rs) | Add `CallerIdentity`, `identify_caller()`, auth envelope check, selector extraction, policy check, approval prompt call, pending derivation |
| [`src/policy.rs`](src/policy.rs) | Add `insert_session_grant()`, `insert_session_grant_all()` |
| [`src/main.rs`](src/main.rs) | Add `--allow-all` flag handling, pass `AuthNonceCache` to server |
| [`src/tui.rs`](src/tui.rs) | Add `prompt_always_allow` flag support |
| [`src/dispatcher.rs`](src/dispatcher.rs) | No changes needed (policy stays in server) |
| [`tests/integration.rs`](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)