221 lines
8.9 KiB
Markdown
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)
|