v0.0.16 - Implement deny-by-default approval model with on-demand nostr_index derivation and --preapprove CLI flag
This commit is contained in:
7
.roo/commands/push_release.md
Normal file
7
.roo/commands/push_release.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: "This increments our git tag and version number, along with compiling and uploading a new release to our gitea page."
|
||||
---
|
||||
|
||||
Run increment_and_push.sh -r -p, and supply a good git commit message. For example:
|
||||
|
||||
./increment_and_push.sh "Fixed the bug with nip05 implementation"
|
||||
514
documents/SECURITY.md
Normal file
514
documents/SECURITY.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# SECURITY.md
|
||||
|
||||
This document explains how `n_signer` enforces security, written so someone new can understand the model end-to-end without reading the source.
|
||||
|
||||
If you only read one section, read [§3 The three concepts](#3-the-three-concepts-identity-index-approval) and [§4 The deny-by-default rule](#4-the-deny-by-default-rule).
|
||||
|
||||
> This document describes the security model as implemented in the current release. Design rationale is in [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. What `n_signer` is, in security terms
|
||||
|
||||
`n_signer` is a **single foreground program** that holds a BIP-39 mnemonic in locked memory and signs requests on behalf of local clients. It is not a daemon, not a service, and not a key-management database.
|
||||
|
||||
The security posture is intentionally minimalist:
|
||||
|
||||
- **One process. One terminal. One human.** The terminal is the trust anchor and the only out-of-band approval surface.
|
||||
- **No persistence.** Nothing about the running session is written to disk: no config, no logs, no PID files, no socket pathnames, no state recovery.
|
||||
- **Crash equals total wipe.** All sensitive state — mnemonic, derived keys, the policy table, activity buffer — exists only in process RAM (`mlock`'d where applicable) and is unrecoverable after the process ends.
|
||||
- **Always-attended operation.** Every first request from a previously unknown caller requires an explicit human approval at the terminal, unless the caller was pre-approved at startup by the OS distribution. Human presence is part of the threat model, not an inconvenience.
|
||||
|
||||
Authoritative behavior reference: [`README.md`](../README.md). Implementation roadmap: [`plans/nsigner.md`](../plans/nsigner.md). Approval model plan: [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md). Wire contract for clients: [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md).
|
||||
|
||||
---
|
||||
|
||||
## 2. Threat model
|
||||
|
||||
### 2.1 What `n_signer` is designed to defend against
|
||||
|
||||
- **Untrusted local clients** asking the signer to perform operations they should not be allowed to perform.
|
||||
- **Drive-by signing** — an unattended program quietly producing signatures the user did not consent to.
|
||||
- **Accidental misuse** — a buggy client connecting to the wrong signer instance, or asking for an identity it should not be using.
|
||||
- **Post-mortem key recovery** — someone obtaining the disk after a crash, kill, or shutdown and recovering signing keys.
|
||||
|
||||
### 2.2 What `n_signer` does **not** defend against
|
||||
|
||||
- A compromised kernel or hypervisor (`mlock` cannot save you).
|
||||
- Physical access while the program is running and the terminal is unlocked.
|
||||
- A malicious user who knows the mnemonic — `n_signer` is a runtime gatekeeper, not a vault.
|
||||
- Side channels (timing, power, microarchitectural) that target the underlying crypto libraries.
|
||||
- Network adversaries when transports without authentication are explicitly enabled (e.g. `tcp_local`).
|
||||
- An OS distribution that ships a malicious `--preapprove` list in its system unit files. The OS is in scope of trust if it pre-approves anything; see [§7](#7-pre-approvals-the-os-distribution-pathway).
|
||||
|
||||
### 2.3 Trust anchors
|
||||
|
||||
| Anchor | Why it is trusted |
|
||||
|---|---|
|
||||
| The terminal `n_signer` is attached to | The user types the mnemonic into it and answers approval prompts on it. If the terminal is compromised, the session is compromised. |
|
||||
| The kernel and `mlock` / `getrandom` syscalls | Used for in-memory protection and entropy. |
|
||||
| The static binary itself | Built reproducibly via [`build_static.sh`](../build_static.sh) and shipped as one musl-static artifact. |
|
||||
| The launcher that started `n_signer` | Whoever ran the process chose the `--preapprove` flags. In interactive use that's the human; in `n_OS_tr` boot it's the OS init system. The launcher's integrity is part of the trust chain. |
|
||||
|
||||
Everything else — clients, other processes, other users, remote callers — is **untrusted by default** and must clear an approval check.
|
||||
|
||||
---
|
||||
|
||||
## 3. The three concepts: identity, index, approval
|
||||
|
||||
This is the entire user-facing security model. There are exactly three things to know.
|
||||
|
||||
```text
|
||||
+----------------+ +-----------------------+ +---------------+
|
||||
| IDENTITY | | INDEX (or PATH) | | APPROVAL |
|
||||
| who is asking | +---- | which key to use | ----+ | is this OK? |
|
||||
+----------------+ | +-----------------------+ | +---------------+
|
||||
| |
|
||||
| request resolves to |
|
||||
+-------> (identity, index/path) -----+
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| Approved entry exists? |
|
||||
+----------------------------+
|
||||
|yes |no
|
||||
v v
|
||||
+-----------------+ +----------------+
|
||||
| Sign / encrypt | | Prompt human |
|
||||
+-----------------+ +----------------+
|
||||
|
|
||||
[y]/[a] | [n]
|
||||
allow now | deny
|
||||
/save |
|
||||
v v
|
||||
+-----------------+ +----------------+
|
||||
| Sign / encrypt | | policy_denied |
|
||||
+-----------------+ +----------------+
|
||||
```
|
||||
|
||||
### Identity — *who is asking*
|
||||
|
||||
An identity is a tagged caller record built from the transport. Different transports produce different identity tags; the signer never invents identity, it always reads it from the kernel or framework.
|
||||
|
||||
| Kind | What it carries | Source of truth |
|
||||
|---|---|---|
|
||||
| `unix_peer` | `uid`, `pid` from `SO_PEERCRED` on an abstract Unix socket. | Kernel — the client cannot forge it. |
|
||||
| `qubes` | source qube name from `QREXEC_REMOTE_DOMAIN`. | Qubes RPC framework. |
|
||||
| `tcp_local` | Loopback address of caller. | TCP socket peer address — opt-in transport. |
|
||||
| `tcp_remote` | Address plus an authenticated pubkey (planned). | Application-layer authentication. |
|
||||
| `fips` | Peer npub from a NIP-46 style flow (planned). | Application-layer authentication. |
|
||||
| `usb_serial` | Device path plus an asserted caller (planned). | Trust-on-first-use. |
|
||||
|
||||
Identity quality varies. `unix_peer` and `qubes` are vouched for by the kernel or hypervisor — strong. `tcp_local` and `tcp_remote` are transport-asserted only — only as good as the human at the terminal who chose to approve them.
|
||||
|
||||
### Index (or path) — *which key*
|
||||
|
||||
The signer can derive many keys from one mnemonic. Clients select one in two equivalent ways:
|
||||
|
||||
- `nostr_index = N` — shorthand for the Nostr derivation `m/44'/1237'/N'/0/0` per NIP-06.
|
||||
- `role_path = "<full BIP-32 path>"` — used for non-Nostr key trees (Bitcoin, SSH, etc.). Pre-registered only; see [§9.2](#92-pre-registration-of-role_path).
|
||||
|
||||
The three legacy selector words from the wire protocol — `role`, `nostr_index`, `role_path` — all resolve to a single internal record. From the user's perspective, what matters is "which Nostr identity (by index)" or "which advanced derivation (by path)."
|
||||
|
||||
|
||||
### Approval — *is this OK?*
|
||||
|
||||
An approval is an in-memory entry that says: *"this identity may use this index/path."* Approvals come from exactly two sources:
|
||||
|
||||
1. **The human at the prompt.** When a request arrives that has no matching approval, the signer blocks the request and asks the user. On `[a]` ("allow this caller for this index/path for the rest of the session") the signer adds a session approval. On `[y]` it allows once without saving. On `[n]` it denies.
|
||||
2. **Pre-approvals.** When the OS distribution starts `n_signer` with `--preapprove caller=...,nostr_index=...` flags, those become approvals that exist before any request arrives. They never trigger prompts. See [§7](#7-pre-approvals-the-os-distribution-pathway).
|
||||
|
||||
Approvals are stored in an in-memory table and vanish on process exit. There is no persistent approval database.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. The deny-by-default rule
|
||||
|
||||
`n_signer` denies any request whose `(identity, index)` pair has no approval. Period.
|
||||
|
||||
There is no special case for "same-uid" callers. There is no implicit grant for the user running their own client. Every first request from every caller goes through an approval — either granted by the user at the prompt, or pre-approved by the OS distribution at startup.
|
||||
|
||||
This is the single most important property:
|
||||
|
||||
- It removes the assumption that "same-uid means trusted" — an assumption that was always more convenience than security.
|
||||
- It collapses several mechanisms (policy, role pre-registration, the `[a]` flag) into one (the in-memory approval table).
|
||||
- It makes the audit trail trivial: every signed operation maps to either a pre-approval declaration or a logged human approval.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5. The two checks every request must pass
|
||||
|
||||
Every signing request runs through this short pipeline. Failure at any step returns an error response and is logged.
|
||||
|
||||
```text
|
||||
+----------------------------------------------------------------+
|
||||
| 1. CLIENT |
|
||||
| sends length-prefixed JSON-RPC request |
|
||||
+----------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------------------------------------------+
|
||||
| 2. TRANSPORT |
|
||||
| - reads framed bytes off the socket |
|
||||
| - builds caller_identity_t (uid / qubes vm / tcp peer / ...)|
|
||||
+----------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------------------------------------------+
|
||||
| 3. RESOLVE INDEX |
|
||||
| - parse selector options from request |
|
||||
| - resolve to a specific (index or path) |
|
||||
| - reject ambiguous selectors |
|
||||
+----------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------------------------------------------+
|
||||
| 4. APPROVAL CHECK |
|
||||
| - is there an approval for (this identity, this index)? |
|
||||
| yes -> ALLOW |
|
||||
| no -> PROMPT human (or DENY if non-interactive) |
|
||||
| - on [a], append a session approval and continue |
|
||||
| - on [y], continue this request only |
|
||||
| - on [n] / EOF, deny |
|
||||
+----------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------------------------------------------+
|
||||
| 5. ENFORCEMENT (verb x purpose x curve) |
|
||||
| - is this verb compatible with the key's declared purpose? |
|
||||
| - reject with purpose_mismatch / curve_mismatch otherwise |
|
||||
+----------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------------------------------------------+
|
||||
| 6. KEY DERIVATION + SIGN |
|
||||
| - derive key from mnemonic at the resolved path |
|
||||
| - perform crypto operation |
|
||||
| - return signed result |
|
||||
+----------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------------------------------------------+
|
||||
| 7. RESPONSE FRAMED + SENT |
|
||||
| - activity log line appended with the approval source label |
|
||||
+----------------------------------------------------------------+
|
||||
```
|
||||
|
||||
There are two independent gates: the **approval check** (does this caller have permission to use this key?) and the **enforcement check** (is this operation compatible with this key?). Both must pass.
|
||||
|
||||
### 5.1 Approval check — *does this caller have permission?*
|
||||
|
||||
Implemented as a lookup in the in-memory approval table. First match wins; the catch-all is `*` → deny. No approval entry, no signature.
|
||||
|
||||
The approval check answers *who* and *which key*. It does not check what the request will do with the key — that is the enforcement check.
|
||||
|
||||
### 5.2 Enforcement check — *is this operation compatible with this key?*
|
||||
|
||||
The signer enforces a strict `(verb, purpose, curve)` matrix:
|
||||
|
||||
| Verb | Required purpose | Required curve |
|
||||
|---|---|---|
|
||||
| `sign_event` | `nostr` | `secp256k1` |
|
||||
| `get_public_key` | `nostr` | `secp256k1` |
|
||||
| `nip04_encrypt` / `nip04_decrypt` | `nostr` | `secp256k1` |
|
||||
| `nip44_encrypt` / `nip44_decrypt` | `nostr` | `secp256k1` |
|
||||
| Any other verb | rejected | rejected |
|
||||
|
||||
A pre-approval to use a Bitcoin-purposed key for `sign_event` does **not** override the enforcement matrix. The approval grants access to the key; enforcement still gates the verb. **Fail-closed**: unknown verbs are rejected, never passed through.
|
||||
|
||||
This is the layer that prevents (for example) a `bitcoin/secp256k1` key from being used to sign a Nostr event even if some pre-approval entry mistakenly named it. The key's *purpose* is part of its identity; you cannot reuse it across domains.
|
||||
|
||||
Test coverage: [`tests/test_enforcement.c`](../tests/test_enforcement.c).
|
||||
|
||||
---
|
||||
|
||||
## 6. The interactive approval prompt
|
||||
|
||||
When a request has no matching approval and `n_signer` is running attached to a terminal, the signer blocks the request, prints a prompt, and waits for a local keystroke.
|
||||
|
||||
Target prompt format:
|
||||
|
||||
```
|
||||
Approval required
|
||||
caller: qubes:nostr-relay
|
||||
verb: sign_event
|
||||
index: nostr_index=0
|
||||
purpose: nostr / secp256k1
|
||||
|
||||
[y] allow once
|
||||
[a] allow this caller for index 0 for the rest of the session
|
||||
[n] deny
|
||||
```
|
||||
|
||||
The keys mean:
|
||||
|
||||
- `[y]` — Allow this single request. No approval is saved; the next request from the same caller will prompt again.
|
||||
- `[a]` — Allow, and add a session approval so the same `(caller, index)` pair is silently allowed for the rest of the session.
|
||||
- `[n]` — Deny this request. Returns `policy_denied` to the client.
|
||||
- EOF / non-interactive — Treated as deny. There is no implicit "allow when nobody is at the terminal" mode. OS distributions that need silent operation use [`--preapprove`](#7-pre-approvals-the-os-distribution-pathway).
|
||||
|
||||
### 6.1 What `[a]` does and does not do
|
||||
|
||||
- It approves the **specific caller** (e.g. `qubes:nostr-relay`).
|
||||
- It approves the **specific index/path** that triggered the prompt.
|
||||
- It does **not** approve other callers.
|
||||
- It does **not** approve other indices for the same caller — a different `nostr_index` from the same caller will prompt again.
|
||||
- It does **not** persist past the process lifetime.
|
||||
|
||||
|
||||
### 6.2 New-identity warning
|
||||
|
||||
When a request specifies an unregistered `nostr_index`, the prompt says so plainly:
|
||||
|
||||
```
|
||||
Approval required
|
||||
caller: qubes:nostr-relay
|
||||
verb: get_public_key
|
||||
index: nostr_index=7 (NEW IDENTITY — will be derived if approved)
|
||||
|
||||
[y] allow once [a] allow caller for this new identity [n] deny
|
||||
```
|
||||
|
||||
A new identity is just a new derivation of the same mnemonic. It is not a separate key file or a separate seed; it is `m/44'/1237'/7'/0/0` of the existing mnemonic. The user should still see explicitly that they are *creating* a previously-unused identity.
|
||||
|
||||
### 6.3 Race-condition behavior
|
||||
|
||||
Requests are served one at a time. If a malicious client tries to flood requests during a prompt, only the request being prompted is held; subsequent requests queue or are dropped per the transport's framing. The prompt always names the request being approved. It is not possible for a queued request to "ride along" on an approval intended for an earlier request.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-approvals: the OS distribution pathway
|
||||
|
||||
For deployments where multiple system services need keys at boot — `n_OS_tr` and similar — the OS distribution starts `n_signer` with one `--preapprove` flag per service:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=/usr/bin/n_signer \
|
||||
--preapprove caller=qubes:nostr-relay,role=main \
|
||||
--preapprove caller=qubes:dm-handler,nostr_index=1 \
|
||||
--preapprove caller=qubes:contacts,nostr_index=2 \
|
||||
--preapprove caller=qubes:zaps,nostr_index=3
|
||||
```
|
||||
|
||||
At startup, `n_signer`:
|
||||
|
||||
1. Parses each flag into an approval entry with prompt mode `NEVER`.
|
||||
2. Auto-derives any keys those entries depend on (so services don't see "ready" until keys exist).
|
||||
3. Writes one log line per pre-approval to stderr so the OS unit's journal records what was authorized.
|
||||
4. Begins listening normally. Pre-approved requests are served silently; everything else still hits the prompt or the catch-all deny.
|
||||
|
||||
### 7.1 What pre-approvals can do
|
||||
|
||||
- Allow a specific named caller to use a specific named key for any verb that key's purpose/curve permits.
|
||||
- Auto-create a Nostr identity at a specified `nostr_index` if it does not yet exist.
|
||||
- Be inspected: anyone reading the systemd unit (or `ps` output) can see exactly what was authorized.
|
||||
|
||||
### 7.2 What pre-approvals cannot do
|
||||
|
||||
- **Cannot deny.** Pre-approvals only widen authorization, never narrow it. The user at the terminal is always the source of denial. (The plan deliberately rejects "preapprove deny" entries; see [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) §C.3.)
|
||||
- **Cannot wildcard the caller.** Each entry must name exactly one caller. There is no `qubes:*` rule. The OS distribution must know who its services are.
|
||||
- **Cannot bypass enforcement.** A pre-approval to use a Nostr key for a hypothetical Bitcoin verb still fails the enforcement matrix in [§5.2](#52-enforcement-check--is-this-operation-compatible-with-this-key).
|
||||
- **Cannot pre-register `role_path` derivations.** Only `role=<name>` (an existing role) and `nostr_index=<n>` are allowed in pre-approval specs. Free-form `role_path` derivations remain pre-registration-only by code path. See [§9.2](#92-pre-registration-of-role_path).
|
||||
|
||||
### 7.3 Trust implications
|
||||
|
||||
Pre-approvals shift the trust boundary. Whoever wrote the systemd unit now decides what the signer will allow without asking. Concretely:
|
||||
|
||||
- The unit file's integrity matters. If an attacker can rewrite it, they can pre-approve themselves.
|
||||
- The OS launcher's identity-stamping must be trustworthy. If something else can spoof a `qubes:nostr-relay` tag, it inherits that pre-approval.
|
||||
- The user retains final authority at the running terminal. Anything *not* pre-approved still goes through the human-approved prompt.
|
||||
|
||||
In `n_OS_tr` these properties are part of the OS itself: the unit files are owned by root, the qrexec framework stamps caller identity at the hypervisor level, and the user's terminal is the ultimate arbiter. The trust chain is short and visible.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 8. The audit log
|
||||
|
||||
Every served (or denied) request appends a line to the in-memory activity buffer that the running TUI displays. Each line includes the **source label** that explains why the verdict was reached.
|
||||
|
||||
Target line format:
|
||||
|
||||
```
|
||||
[2026-05-04 16:03:11] qubes:nostr-relay sign_event(nostr_index=0) ALLOWED:preapprove
|
||||
[2026-05-04 16:03:14] uid:1000 sign_event(nostr_index=0) ALLOWED:session-grant
|
||||
[2026-05-04 16:03:18] tcp:[::1]:54122 get_public_key(nostr_index=7) ALLOWED:prompt
|
||||
[2026-05-04 16:03:21] uid:1001 sign_event(nostr_index=0) DENIED:no-match
|
||||
```
|
||||
|
||||
Source labels:
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| `:preapprove` | Approval came from a `--preapprove` startup flag. |
|
||||
| `:session-grant` | Approval was added earlier this session by a prompt `[a]`. |
|
||||
| `:prompt` | Approval was given just now by the user pressing `[y]` or `[a]` at the prompt. |
|
||||
| `:no-match` | No approval existed and (in non-interactive mode) the request was denied. |
|
||||
|
||||
This makes forensic review fast: every signed operation maps to either a pre-approval (audit the unit file), a session grant (audit the session's earlier prompt activity), or a fresh prompt approval (audit human attention at that moment).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 9. The role abstraction (internal plumbing)
|
||||
|
||||
Internally `n_signer` uses a structure called a **role** to bundle three things together:
|
||||
|
||||
- A derivation path (BIP-32).
|
||||
- A purpose label (`nostr`, `bitcoin`, `ssh`, `age`).
|
||||
- A curve label (`secp256k1`, `ed25519`, `x25519`).
|
||||
|
||||
Roles are the unit of *enforcement* (the matrix in [§5.2](#52-enforcement-check--is-this-operation-compatible-with-this-key) is keyed on role purpose and curve) and the unit of *audit* (every log line names the role used).
|
||||
|
||||
### 9.1 Why roles exist as plumbing rather than user-facing concept
|
||||
|
||||
- A future where one mnemonic produces Nostr, Bitcoin, SSH, and age keys requires *something* to track per-key purpose. Roles are that thing. Keeping them in the codebase preserves the option without forcing the user to think about them today.
|
||||
- Two different identities can use the same role (the same key). The role is the audit unit shared between them, so the activity log is consistent regardless of caller.
|
||||
- Enforcement against a role's declared purpose/curve is a defense-in-depth: a Nostr key cannot be coerced into producing a Bitcoin signature even if some approval entry mistakenly named it.
|
||||
|
||||
A user reading prompts and the activity log will see "index 0" and "index 7" most of the time, not "role main" or "role nostr_idx_7." Roles surface as labels only in advanced views and the source code.
|
||||
|
||||
### 9.2 Pre-registration of `role_path`
|
||||
|
||||
The `role_path` selector lets a client specify a BIP-32 path directly, including paths outside the Nostr derivation scheme. These remain **pre-registration-only**:
|
||||
|
||||
- The signer ships with an empty `role_path` table.
|
||||
- Operators who want non-Nostr keys must register them at startup (mechanism TBD).
|
||||
- The interactive prompt does **not** auto-derive `role_path` keys, even with `[a]`. The narrowing in [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) §5 explicitly applies only to `nostr_index`.
|
||||
|
||||
The reasoning is that `role_path` is free-form. A malicious or buggy client could request paths that overlap with sensitive domains (bitcoin wallet roots, SSH host keys, age recipients). Auto-derivation in those domains would be a footgun. `nostr_index` is locked to a specific scheme with locked purpose and curve, so auto-derivation there is safe to gate on a single human keystroke.
|
||||
|
||||
### 9.3 Role table size limits
|
||||
|
||||
`ROLE_TABLE_MAX_ENTRIES` caps the number of distinct roles per session. The plan raises this to 256. Overflow is reported as a clear `role_table_full` error rather than silent truncation or memory unsafety.
|
||||
|
||||
---
|
||||
|
||||
## 10. Memory safety and zeroization
|
||||
|
||||
### 10.1 Locked memory
|
||||
|
||||
- The mnemonic and derived private keys live in `mlock`'d allocations managed by [`src/secure_mem.c`](../src/secure_mem.c).
|
||||
- These pages are pinned in RAM (no swap to disk) for the process lifetime.
|
||||
- On normal shutdown (`q`, `SIGINT`, `SIGTERM`), buffers are explicitly zeroized before unmap.
|
||||
|
||||
### 10.2 Crash semantics
|
||||
|
||||
If the process dies abnormally, the kernel reclaims its pages. Anything previously `mlock`'d is not flushed to disk because it never could be. There is no swap residue to scrape, no core dump containing keys (if your environment disables core dumps for setuid-equivalent processes — verify locally), and no recovery path.
|
||||
|
||||
This is the meaning of "crash equals total wipe is a security feature" in [`plans/nsigner.md:154`](../plans/nsigner.md:154).
|
||||
|
||||
### 10.3 No persistence by design
|
||||
|
||||
There is no file under `~/.nsigner`, no `/var/lib/nsigner`, no `~/.config/nsigner`, no approval database. The signer is a process whose authority begins when you type the mnemonic and ends when you quit.
|
||||
|
||||
If you want something durable: store the mnemonic on paper or hardware, not in `n_signer`.
|
||||
|
||||
The only file inputs are `--preapprove` flags from the launcher's command line, evaluated once at startup and never re-read. There is no live config reload.
|
||||
|
||||
---
|
||||
|
||||
## 11. Transports and their identity quality
|
||||
|
||||
Different transports give different identity quality. The wire format (4-byte length prefix + JSON-RPC) and the security checks ([§5](#5-the-two-checks-every-request-must-pass)) are identical across transports.
|
||||
|
||||
### 11.1 Abstract namespace Unix sockets (default)
|
||||
|
||||
- Address: `@nsigner_<word1>_<word2>` per [`plans/nsigner.md:96`](../plans/nsigner.md:96).
|
||||
- Identity: kernel-vouched `uid`, `pid`. Cannot be forged by the client.
|
||||
- Risk surface: any local process on the same host. Deny-by-default + per-(caller, index) approvals handle this.
|
||||
|
||||
### 11.2 Stdio / qrexec mode
|
||||
|
||||
- One framed request, one framed response, then exit.
|
||||
- Identity: `qubes:<vm-name>` from `QREXEC_REMOTE_DOMAIN`, vouched for by Qubes.
|
||||
- Risk surface: only what Qubes RPC policy permits. See [`packaging/qubes/`](../packaging/qubes/).
|
||||
|
||||
### 11.3 TCP (advanced / opt-in)
|
||||
|
||||
- Loopback addresses produce `tcp_local` identities. Remote addresses produce `tcp_remote` identities.
|
||||
- Identity is **transport-asserted only**. The TCP peer address proves a route, not a principal.
|
||||
- For TCP transports the **interactive approval prompt is the real authentication mechanism**. Do not enable TCP transports without a trustworthy local terminal.
|
||||
|
||||
### 11.4 Future transports
|
||||
|
||||
The roadmap ([`plans/nsigner.md:178`](../plans/nsigner.md:178)) describes additional transports (USB serial, FIPS-style relay). The contract is:
|
||||
|
||||
- The transport produces a `caller_identity_t`.
|
||||
- The same approval / enforcement / dispatch stack runs unchanged.
|
||||
- New transports add new identity *kinds*, not new bypass paths.
|
||||
|
||||
---
|
||||
|
||||
## 12. Operational guidance
|
||||
|
||||
### 12.1 Running interactively (typical desktop use)
|
||||
|
||||
- Start `n_signer` only on a terminal you control. No `--preapprove` flags.
|
||||
- Type or generate the mnemonic. Generated mnemonics are shown once with no confirmation step ([`plans/nsigner.md:84`](../plans/nsigner.md:84)) — write them down before pressing a key.
|
||||
- Stay near the terminal for the first couple of minutes after launch. New clients will prompt for approval. Press `[a]` to grant for the session.
|
||||
- Once your usual clients are approved, prompts stop appearing.
|
||||
|
||||
### 12.2 Running as a system signer (e.g. `n_OS_tr`)
|
||||
|
||||
- Configure pre-approvals in the systemd (or equivalent) unit file. One `--preapprove` flag per service.
|
||||
- Verify each pre-approval's caller identity exactly matches what the launcher actually produces. Mismatches cause silent deny (`:no-match` in the log).
|
||||
- Treat the unit file as security-sensitive. Restrict write access to root or its equivalent.
|
||||
- Keep a terminal attached or piped to where prompts can still be seen. Anything not pre-approved still prompts.
|
||||
|
||||
### 12.3 Stopping
|
||||
|
||||
- Press `q` for clean shutdown.
|
||||
- `Ctrl+C` and `SIGTERM` are equivalent — they zeroize and exit.
|
||||
- Closing the terminal is also equivalent. There is no "detach" mode by design.
|
||||
|
||||
### 12.4 Auditing
|
||||
|
||||
- The activity buffer in the running TUI is the session log. It does not survive the process.
|
||||
- For persistent audit, run `n_signer` under a logging supervisor that captures stderr/stdout, knowing the activity buffer reflects post-approval decisions only.
|
||||
|
||||
### 12.5 Rotating
|
||||
|
||||
- "Rotate" in `n_signer` means: quit, change mnemonic source, restart. There is no in-place rotation because there is no persistent state to migrate.
|
||||
|
||||
---
|
||||
|
||||
## 13. Relationship to the current codebase
|
||||
|
||||
All stages from [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) are implemented in the current codebase. This document therefore describes shipped behavior, not a future target state.
|
||||
|
||||
---
|
||||
|
||||
## 14. Things that look like bugs but are security features
|
||||
|
||||
| Symptom | Reason |
|
||||
|---|---|
|
||||
| First request from a new client prompts even though it's the same uid | Deny-by-default. Same-uid trust is gone. Press `[a]` once and you won't see prompts from this client again this session. |
|
||||
| Killing the process loses all approvals and key derivations | Crash-equals-wipe. Restart with the same mnemonic to rebuild deterministically; re-approve clients. |
|
||||
| `purpose_mismatch` when calling `sign_event` against a non-Nostr key | Roles are scoped by purpose. Approvals do not bypass enforcement. |
|
||||
| `unknown_role` for an unregistered `role_path` | `role_path` keys are pre-registration-only by design. Use `nostr_index` for ad-hoc Nostr identities. |
|
||||
| Pre-approval entry doesn't match incoming requests | Caller identity must match exactly — no wildcards. Verify the launcher tags requests the way the unit file expects. |
|
||||
| `role=unknown` in the activity log (current code) | Selector did not resolve. Future versions will prompt to create the identity; current code rejects with `unknown_role` after policy passes. |
|
||||
|
||||
---
|
||||
|
||||
## 15. Quick reference — the security guarantee in one paragraph
|
||||
|
||||
`n_signer` will never sign with a key whose use has not been explicitly approved — either by a human keystroke at the running terminal or by a `--preapprove` flag declared at startup. It will never use a key outside its declared `(purpose, curve)` scope. It will never expose a private key to a client. It will never write key material to disk. Every request is checked twice: once for approval, once for enforcement. Default policy is deny. Approvals vanish on process exit.
|
||||
|
||||
If any of these statements becomes false in code, that is a security bug worth filing immediately.
|
||||
|
||||
---
|
||||
|
||||
## 16. References
|
||||
|
||||
- [`README.md`](../README.md) — authoritative behavior spec.
|
||||
- [`plans/nsigner.md`](../plans/nsigner.md) — root design plan and decisions log.
|
||||
- [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) — the approval model plan this document tracks.
|
||||
- [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md) — wire contract for clients.
|
||||
- [`documents/QUBES_OS.md`](QUBES_OS.md) — Qubes RPC integration.
|
||||
- [`documents/FIPS_DEPLOYMENT.md`](FIPS_DEPLOYMENT.md) — FIPS-mode deployment notes.
|
||||
- [`plans/seed_phrase_uses.md`](../plans/seed_phrase_uses.md) — what one mnemonic can become.
|
||||
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c), [`src/enforcement.c`](../src/enforcement.c) — the security-related code.
|
||||
357
plans/deny_by_default_approvals.md
Normal file
357
plans/deny_by_default_approvals.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Plan: Deny-by-default approvals + on-demand role derivation
|
||||
|
||||
Status: **implemented**
|
||||
|
||||
This plan replaces several entangled mechanisms (same-uid auto-allow, role pre-registration as a hard rule, global `[a]` flag) with a single, coherent approval model. It also addresses the `n_OS_tr` system-services-at-boot scenario.
|
||||
|
||||
If implemented, this plan supersedes:
|
||||
|
||||
- The same-uid auto-allow rule in [`policy_init_default()`](../src/policy.c:551).
|
||||
- The "role_path / nostr_index must be pre-registered" rule recorded in [`plans/nsigner.md:159`](nsigner.md:159) (the rule is *narrowed*, not removed — see §5).
|
||||
- The global `g_prompt_always_allow` flag in [`src/server.c:469`](../src/server.c:469) as the meaning of prompt-`[a]`. The running-TUI `[a]` hotkey is unaffected (or removed; see §11).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **Deny-by-default.** No client can sign or get a public key without an explicit human approval at the terminal **or** an explicit pre-approval declared at startup.
|
||||
2. **One approval mechanism.** Both the runtime prompt (human approves a request) and the boot-time pre-approval (OS-level config) populate the **same** in-memory policy table. There is exactly one path through the dispatcher that determines whether to sign.
|
||||
3. **On-demand role creation.** When a request specifies an unregistered `nostr_index`, the prompt path may auto-derive a fresh role at that index *only after* the human (or a startup pre-approval) consents. Pre-registration of `role_path` keys remains required.
|
||||
4. **Boot-time ergonomics for `n_OS_tr`.** OS-level system services that need keys at boot can be pre-approved by the OS distribution via CLI flags, with no prompts and no on-disk runtime config files.
|
||||
5. **Auditable approval source.** The activity log distinguishes between approvals granted at runtime by the user, approvals loaded from startup pre-approvals, and approvals already in place from prior session activity.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- This plan does **not** add per-verb scoping at the prompt. `[a]` means "this caller may use this role for all verbs the role's purpose/curve permits." Per-verb scoping is a future iteration.
|
||||
- This plan does **not** add persistent (across-restart) policy storage. Approvals still vanish on process exit.
|
||||
- This plan does **not** introduce capability tokens or any new authentication primitive. Identity remains transport-derived.
|
||||
- This plan does **not** change the wire protocol. Clients see the same JSON-RPC request/response shape.
|
||||
|
||||
## 3. The new model in one paragraph
|
||||
|
||||
The signer maintains an in-memory policy table whose entries are tuples of `(caller_id, role, prompt_mode)`. At startup, the table is empty except for entries declared via `--preapprove` CLI flags (set by `n_OS_tr` system unit files for boot-required services). Every request without a matching policy entry triggers an interactive prompt at the terminal. If the user picks `[a]`, a new policy entry for `(this caller, this role)` is appended to the table and any future request matching that tuple is served silently. If the request specifies an unregistered `nostr_index`, the consent path also derives a fresh role at that index. The `*` catch-all is `PROMPT_DENY` for any caller that hasn't been explicitly added.
|
||||
|
||||
## 4. Concept renaming and positioning
|
||||
|
||||
The user-facing language collapses to three concepts:
|
||||
|
||||
| User-facing term | Internal mapping |
|
||||
|---|---|
|
||||
| **Identity** | `caller_identity_t` (uid, qubes vm name, tcp peer, etc.) |
|
||||
| **Index / Path** | `nostr_index` shorthand or full `role_path` for advanced use |
|
||||
| **Approval** | A policy table entry with `prompt_mode = PROMPT_NEVER` |
|
||||
|
||||
The word **role** is retained internally (the role table, the [`role_entry_t`](../src/role_table.c:103) struct, derivation paths) but de-emphasized in user-facing prompts and docs. A user reading a prompt sees:
|
||||
|
||||
```
|
||||
Approval required
|
||||
caller: qubes:nostr-relay
|
||||
verb: sign_event
|
||||
index: nostr_index=0 (resolves to the default identity)
|
||||
```
|
||||
|
||||
instead of `role: main`. Internally the resolved role is still recorded for audit.
|
||||
|
||||
## 5. The narrowed pre-registration rule
|
||||
|
||||
The original rule was: **all** selectors must resolve to a pre-registered role.
|
||||
|
||||
The new rule is:
|
||||
|
||||
> Pre-registration is required for **`role_path`** selectors. For **`nostr_index`** selectors, an unregistered index is a valid prompt trigger, and the role for that index is derived on the fly **after** explicit consent (prompt or `--preapprove`).
|
||||
|
||||
Rationale:
|
||||
|
||||
- `nostr_index` paths follow a fixed scheme (`m/44'/1237'/<n>'/0/0`) with locked purpose (`nostr`) and curve (`secp256k1`). Auto-derivation cannot accidentally produce a key that mixes purposes. The risk is solely "this is a fresh nostr identity"; the human at the prompt can answer that.
|
||||
- `role_path` is free-form and can address any BIP-32 path, including paths that overlap with bitcoin/SSH/age domains. Auto-derivation here would let a malicious or buggy client coerce the signer into producing keys whose purpose the human can't easily verify. We keep that locked.
|
||||
- This narrowing is the smallest crack we can open in the original rule that still solves the user's `nostr_index: 1` use case.
|
||||
|
||||
[`plans/nsigner.md:159`](nsigner.md:159) ("`role_path` must be pre-registered") remains true. The `nostr_index` clause becomes new and explicit.
|
||||
|
||||
## 6. The four pieces of work
|
||||
|
||||
### Piece A — Deny-by-default policy
|
||||
|
||||
#### A.1 Default policy table
|
||||
|
||||
[`policy_init_default()`](../src/policy.c:551) currently inserts two entries:
|
||||
|
||||
1. `caller = "uid:<owner>"` → `PROMPT_NEVER` (auto-allow same-uid)
|
||||
2. `caller = "*"` → `PROMPT_DENY`
|
||||
|
||||
The new default inserts only:
|
||||
|
||||
1. `caller = "*"` → `PROMPT_PROMPT`
|
||||
|
||||
There is no same-uid special case. Even the user's own client triggers a prompt the first time it connects.
|
||||
|
||||
If `--preapprove` flags were given at startup, those entries are appended **before** the catch-all `*` so they win first-match. See §C.
|
||||
|
||||
#### A.2 Removal of `g_prompt_always_allow` as a *prompt outcome*
|
||||
|
||||
The flag is currently set to 1 by both:
|
||||
|
||||
- The interactive prompt's `[a]` outcome ([`src/server.c:684`](../src/server.c:684))
|
||||
- The running-TUI `[a]` hotkey ([`src/main.c:974`](../src/main.c:974), [`src/main.c:1239`](../src/main.c:1239))
|
||||
|
||||
The prompt's `[a]` no longer flips the flag. Instead it appends a per-`(caller, role)` policy entry. See §B.
|
||||
|
||||
The running-TUI `[a]` hotkey can either be:
|
||||
|
||||
- Removed, since it is now redundant with explicit per-caller entries, **or**
|
||||
- Retained as a debugging/panic mode under a non-default name, e.g. `[A]` (capital) for "auto-approve everything regardless of caller; for development only."
|
||||
|
||||
The plan recommends retaining it as a deliberately ugly, capital-letter hotkey so its destructive scope is visible. Default-off; activates only on explicit press.
|
||||
|
||||
#### A.3 Wire-level effect
|
||||
|
||||
A previously-unknown caller making a first request observes:
|
||||
|
||||
1. Length-prefixed request lands.
|
||||
2. Selector resolves (or fails — see §B).
|
||||
3. Policy lookup returns `POLICY_PROMPT` because no matching entry exists.
|
||||
4. Server blocks the request, prints the prompt to the controlling terminal, waits for keystroke.
|
||||
5. On `[a]`, server appends `(caller, role) → PROMPT_NEVER` and serves the request.
|
||||
6. On `[y]`, server serves once without appending.
|
||||
7. On `[n]`, server returns `policy_denied` without appending.
|
||||
8. On EOF / non-interactive: server returns `policy_denied`. There is no implicit allow path for non-interactive sessions; OS distributions must use `--preapprove`.
|
||||
|
||||
### Piece B — On-demand role derivation at the prompt
|
||||
|
||||
#### B.1 Failure path today
|
||||
|
||||
When `selector_resolve()` returns `SELECTOR_ERR_NOT_FOUND` for an unregistered `nostr_index`, the request currently dies in the dispatcher with `unknown_role` (error 1002). The user observes nothing in the terminal.
|
||||
|
||||
#### B.2 New failure path
|
||||
|
||||
The server's request-handling loop checks selector resolution **before** invoking the dispatcher. The new control flow:
|
||||
|
||||
```
|
||||
1. Receive request, parse method + selector.
|
||||
2. Try selector_resolve():
|
||||
a. SELECTOR_OK -> proceed to step 3 with the resolved role.
|
||||
b. SELECTOR_ERR_NOT_FOUND on nostr_index -> proceed to step 3 with
|
||||
a PENDING_DERIVATION marker holding the requested index.
|
||||
c. SELECTOR_ERR_NOT_FOUND on role_path -> reject with unknown_role.
|
||||
d. SELECTOR_ERR_AMBIGUOUS -> reject with ambiguous_role_selector.
|
||||
e. SELECTOR_ERR_NO_DEFAULT -> reject with internal_error
|
||||
(this should never happen if main role is always present).
|
||||
3. Determine policy: ALLOW / DENY / PROMPT.
|
||||
- For PENDING_DERIVATION cases, the prompt displays the requested
|
||||
index and clearly states "this will create a new identity."
|
||||
4. On PROMPT, ask human; on [a] or [y], proceed; on [n], reject.
|
||||
5. On approval to proceed:
|
||||
a. If PENDING_DERIVATION, register a role for that index in the
|
||||
role_table now. Synthesize a name like nostr_idx_7. Trigger
|
||||
key derivation for the new role.
|
||||
b. If [a] (not [y]), append a session policy entry for
|
||||
(caller, resolved-role-name) with PROMPT_NEVER.
|
||||
6. Invoke the dispatcher with the resolved role.
|
||||
7. Send response, append activity log line including approval source.
|
||||
```
|
||||
|
||||
#### B.3 Auto-registered role naming
|
||||
|
||||
A role auto-registered for `nostr_index = N` is named `nostr_idx_N` by the signer.
|
||||
|
||||
- Predictable, so humans reading the activity log can correlate.
|
||||
- Will not collide with the built-in `main` (which is `nostr_idx_0` by index but `main` by name; the role table allows lookups by either).
|
||||
- Does not require user input at the prompt. Naming UX is a future iteration.
|
||||
|
||||
#### B.4 Role-table sizing
|
||||
|
||||
[`ROLE_TABLE_MAX_ENTRIES`](../src/role_table.c) is currently small. With on-demand derivation a single session might accumulate dozens of `nostr_idx_*` entries. Bump the constant to an explicit budget (suggest 256). On overflow, return a clear error like `role_table_full` rather than crashing.
|
||||
|
||||
#### B.5 Persistence
|
||||
|
||||
Same as everything else in n_signer: derived roles vanish on process exit. The next session re-derives `nostr_idx_7` from the same mnemonic, getting the same key. This is normal and intentional.
|
||||
|
||||
### Piece C — `--preapprove` CLI flag
|
||||
|
||||
#### C.1 Flag syntax
|
||||
|
||||
```
|
||||
--preapprove <SPEC>
|
||||
```
|
||||
|
||||
`SPEC` is a comma-separated list of `key=value` pairs. Required keys:
|
||||
|
||||
- `caller=<id>` — exact caller identity to match. Format depends on transport:
|
||||
- `uid:1000`
|
||||
- `qubes:work-vm`
|
||||
- `tcp:[fdb8::1]:0` (port 0 means "any port")
|
||||
- `qubes:*` is **not** supported. Wildcards are intentionally restricted to the catch-all entry only.
|
||||
- One of:
|
||||
- `role=<name>` — match the role by name. Useful when the OS knows it wants `main`.
|
||||
- `nostr_index=<n>` — match by index. Equivalent to `role=nostr_idx_<n>` after auto-derivation.
|
||||
|
||||
Optional keys (deferred for now, but reserved):
|
||||
|
||||
- `verbs=<list>` — comma-separated verb list. Default is `*`.
|
||||
- `purposes=<list>` — comma-separated purpose list. Default is `*`.
|
||||
|
||||
Multiple `--preapprove` flags are allowed; each becomes a separate policy entry.
|
||||
|
||||
#### C.2 Example: `n_OS_tr` boot
|
||||
|
||||
`n_OS_tr`'s systemd unit file:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=/usr/bin/n_signer \
|
||||
--preapprove caller=qubes:nostr-relay,role=main \
|
||||
--preapprove caller=qubes:dm-handler,nostr_index=1 \
|
||||
--preapprove caller=qubes:contacts,nostr_index=2 \
|
||||
--preapprove caller=qubes:zaps,nostr_index=3
|
||||
```
|
||||
|
||||
At startup, n_signer:
|
||||
|
||||
1. Parses each `--preapprove` flag into a `policy_entry_t` with `prompt = PROMPT_NEVER`.
|
||||
2. For each entry that references an unregistered `nostr_index`, auto-derives the role *immediately at startup* (not lazily — we want all key derivation to happen before services start signaling readiness).
|
||||
3. Inserts each policy entry into the table **before** the catch-all `*` deny.
|
||||
4. Logs each pre-approval to stderr at boot so the systemd unit's journal records what was preapproved.
|
||||
|
||||
When `qubes:nostr-relay` connects and asks for `sign_event` against `role=main`, `policy_check()` matches the preapprove entry, returns `POLICY_ALLOW`, and the request is served with no prompt.
|
||||
|
||||
When some other caller (say, a misconfigured systemd service) connects, the catch-all matches and the request is denied (or prompts, in interactive mode — see §C.4).
|
||||
|
||||
#### C.3 What `--preapprove` cannot do
|
||||
|
||||
- **Cannot deny.** Deny entries can only come from defaults or the prompt's `[n]` (if we ever add caller-deny). Pre-approve only widens.
|
||||
- **Cannot match wildcards on caller.** Each entry must specify exactly one caller_id. The OS distribution is responsible for knowing exactly who its services are.
|
||||
- **Cannot bypass purpose/curve enforcement.** A pre-approval to use `role=main` for `verb=sign_btc_tx` (a hypothetical bitcoin verb) still fails enforcement because `main`'s purpose is `nostr`. The pre-approval grants access to the role; enforcement still gates the verb.
|
||||
|
||||
#### C.4 Interaction with interactive mode
|
||||
|
||||
The same binary supports both:
|
||||
|
||||
- `n_signer` with no `--preapprove` flags → pure deny-by-default, every first request prompts.
|
||||
- `n_signer --preapprove ...` → starts with the listed entries already in the policy table.
|
||||
|
||||
Both modes use exactly the same code path. There is no separate "system mode" toggle.
|
||||
|
||||
#### C.5 Auditability
|
||||
|
||||
When a pre-approval matches, the activity log line includes the source:
|
||||
|
||||
```
|
||||
qubes:nostr-relay sign_event(role=main) ALLOWED:preapprove
|
||||
```
|
||||
|
||||
So an operator reviewing the log can tell which decisions came from boot config vs. runtime user approvals.
|
||||
|
||||
#### C.6 Validation
|
||||
|
||||
Bad `--preapprove` syntax fails fast at startup with a clear error message. n_signer refuses to start. This avoids the "I thought I configured this but typoed it" silent-degrade problem.
|
||||
|
||||
### Piece D — Audit-log labeling of approval source
|
||||
|
||||
#### D.1 The four label values
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| `:preapprove` | Matched a policy entry installed via `--preapprove` at startup. |
|
||||
| `:session-grant` | Matched a policy entry created earlier in this session by a prompt `[a]`. |
|
||||
| `:prompt` | Just-now approved by the user pressing `[y]` or `[a]` at the prompt. |
|
||||
| `:auto-allow` | Matched the running-TUI `[a]` panic mode (if retained). |
|
||||
|
||||
A line in the activity log under the new model:
|
||||
|
||||
```
|
||||
[2026-05-04 16:03:11] qubes:nostr-relay sign_event(role=main) ALLOWED:preapprove
|
||||
[2026-05-04 16:03:14] uid:1000 sign_event(role=main) ALLOWED:session-grant
|
||||
[2026-05-04 16:03:18] tcp:[::1]:54122 get_public_key(role=nostr_idx_7) ALLOWED:prompt
|
||||
[2026-05-04 16:03:21] uid:1001 sign_event(role=main) DENIED:no-match
|
||||
```
|
||||
|
||||
#### D.2 Implementation
|
||||
|
||||
The label is a small enum threaded from `policy_check()` (which returns the matching entry's source tag) and from `prompt_for_policy_decision()` (which returns `:prompt`). `server_handle_one()` includes the label in the activity-log line.
|
||||
|
||||
#### D.3 Visibility
|
||||
|
||||
The label appears in:
|
||||
|
||||
- The TUI's running activity buffer.
|
||||
- The structured activity event passed to the `server_activity_cb` callback (so external observers — e.g. a future syslog feeder — can also see the source).
|
||||
|
||||
#### D.4 No on-disk persistence
|
||||
|
||||
The activity buffer remains in-memory only. If `n_OS_tr` wants persistent audit, it forwards the activity callback to its own logging subsystem outside of n_signer's process.
|
||||
|
||||
## 7. Code change inventory
|
||||
|
||||
| Module | Change |
|
||||
|---|---|
|
||||
| [`src/policy.c`](../src/policy.c) | `policy_init_default()` collapses to one entry. `policy_check()` extends to return the matching entry's source label. New helper `policy_table_add_at_position()` to insert before the catch-all. |
|
||||
| [`src/server.c`](../src/server.c) | `prompt_for_policy_decision()` rewritten: appends a policy entry on `[a]`, no longer flips the global flag. New pre-derivation control flow in `server_handle_one()`. New CLI parser for `--preapprove`. |
|
||||
| [`src/role_table.c`](../src/role_table.c) | Bump `ROLE_TABLE_MAX_ENTRIES` to 256. New `role_table_register_nostr_index(int n)` helper that creates a `nostr_idx_<n>` entry and triggers derivation. |
|
||||
| [`src/key_store.c`](../src/key_store.c) | Add a one-role derivation path so newly-registered roles can be derived without re-deriving the whole table. |
|
||||
| [`src/dispatcher.c`](../src/dispatcher.c) | No semantic change — dispatcher continues to assume the role exists. The server makes that true before invoking. |
|
||||
| [`src/main.c`](../src/main.c) | CLI parsing for `--preapprove`. Remove or restrict the running-TUI `[a]` hotkey. Log preapproves to stderr at boot. |
|
||||
| [`tests/test_policy.c`](../tests/test_policy.c) | New tests: deny-by-default, prompt-`[a]` appends entry, preapprove flag parsing, source-label propagation. |
|
||||
| [`tests/test_dispatcher.c`](../tests/test_dispatcher.c) | New tests: unregistered nostr_index path with consent-required gating. |
|
||||
| [`tests/test_integration.c`](../tests/test_integration.c) | New scenario: `--preapprove` allows scripted client; without it, scripted client gets denied. |
|
||||
| [`documents/SECURITY.md`](../documents/SECURITY.md) | Rewritten in identity / index / approval terms (separate task after this plan ships). |
|
||||
| [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) | Add a note about possible `policy_denied` on first contact in interactive mode; pointer to `--preapprove` for system services. |
|
||||
| [`plans/nsigner.md`](nsigner.md) | Decisions log entry referencing this plan. The `role_path` pre-registration rule remains; the `nostr_index` clause becomes new. |
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
Tests must cover at minimum:
|
||||
|
||||
1. **Default deny.** A startup with no `--preapprove` flags receives a request from any caller and (in non-interactive mode) returns `policy_denied`.
|
||||
2. **Prompt `[a]` appends entry.** Inject a non-interactive prompt-default of `ALLOW`, observe a policy entry added, verify a second matching request is served without re-prompting.
|
||||
3. **Prompt `[y]` does not append entry.** Same fixture, second request still triggers the prompt path.
|
||||
4. **`--preapprove` with `role=main`.** Request from named caller succeeds without prompt; request from any other caller is denied.
|
||||
5. **`--preapprove` with `nostr_index=N` for unregistered N.** Role is auto-derived at startup; request succeeds; activity log shows `:preapprove`.
|
||||
6. **`--preapprove` cannot bypass enforcement.** Pre-approve `(caller=X, role=main)` and request a verb that violates purpose/curve. Response is `purpose_mismatch` or `curve_mismatch`.
|
||||
7. **Unknown `role_path` still rejected.** Even with consent, an unregistered `role_path` returns `unknown_role`. (The narrowing is `nostr_index`-only.)
|
||||
8. **Audit-log labels.** Each path produces the expected source tag.
|
||||
9. **Multi-instance isolation.** Two n_signers with different `--preapprove` lists serve different requests; cross-instance requests are denied as expected.
|
||||
10. **Role table overflow.** Force more than `ROLE_TABLE_MAX_ENTRIES` derivations and observe `role_table_full` instead of corruption.
|
||||
|
||||
## 9. Backwards-compatibility implications
|
||||
|
||||
- **Existing scripts that rely on same-uid auto-allow break.** They now hit the prompt (interactive) or get denied (non-interactive). Migration path: add `--preapprove caller=uid:1000,role=main` to the startup invocation. We document this prominently.
|
||||
- **Interactive desktop users see a prompt the first time their client connects, where today they don't.** This is the intended behavior change. We document it in the README.
|
||||
- **Wire protocol unchanged.** No client-side changes required.
|
||||
- **Existing `[a]` running-TUI hotkey may change semantics or disappear.** This is the most disruptive UI change for someone who actually uses the panic-allow today. Document the change in the release notes.
|
||||
|
||||
## 10. Rollout staging
|
||||
|
||||
This is too much to land in one commit. Suggested order:
|
||||
|
||||
1. **Stage 1 — deny-by-default + prompt `[a]` appends entry.** Removes the same-uid auto-allow, makes the prompt the source of session-grants. No CLI flag yet. Headless scripts break; we accept that for now and document the workaround as "use stage 2."
|
||||
2. **Stage 2 — `--preapprove` CLI flag.** Restores headless ergonomics for OS distributions like `n_OS_tr`.
|
||||
3. **Stage 3 — on-demand role derivation at the prompt.** Closes the user-visible `(unknown)` failure mode.
|
||||
4. **Stage 4 — audit-log labels and structured activity output.** Forensics + observability.
|
||||
5. **Stage 5 — `documents/SECURITY.md` rewrite.** After the code is settled.
|
||||
|
||||
Each stage is a self-contained release.
|
||||
|
||||
## 11. Open decisions to confirm before coding
|
||||
|
||||
- **Q1.** Keep the running-TUI `[a]` hotkey as a panic-allow under a different visual treatment, or remove it entirely? Plan recommends keep, capitalized, default-off.
|
||||
- **Q2.** Is `nostr_idx_<n>` an acceptable auto-naming convention, or do we want to ask the user for a name in the prompt? Plan recommends auto-name now, prompt-name later as iteration.
|
||||
- **Q3.** Should the prompt's "this will create a new identity" warning differ visually from a normal approval prompt? Plan recommends a clearly distinct extra line in the prompt body.
|
||||
- **Q4.** What is the ceiling for `ROLE_TABLE_MAX_ENTRIES`? Plan recommends 256 with a fail-loud overflow.
|
||||
- **Q5.** Do we want a TUI panel showing the active session approvals (helpful for the user to see what they've granted)? Plan recommends yes, smallest possible: caller + role + source label. Could be deferred to a later stage.
|
||||
|
||||
## 12. After this plan lands: deferred work
|
||||
|
||||
- Per-verb scoping at the prompt (the user wants `[a]` to mean "all verbs for this role" but a future `[c]` to mean "let me pick"). Deferred per the user's instruction.
|
||||
- Approval revocation hotkeys (`[k]` to clear all session-grants, `[K]` to clear one).
|
||||
- Approvals visible-and-editable as a TUI panel.
|
||||
- Persistent (cross-restart) approvals — explicitly out of scope; n_signer remains memory-only.
|
||||
- `role_path` auto-derivation under prompt — explicitly out of scope; the security argument in §5 stands.
|
||||
- Capability tokens for cross-machine transports (FIPS, USB serial) — separate plan, separate threat model.
|
||||
|
||||
## 13. References
|
||||
|
||||
- [`README.md`](../README.md) — authoritative behavior spec.
|
||||
- [`plans/nsigner.md`](nsigner.md) — root design plan; pre-registration rule at line 159.
|
||||
- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) — wire contract.
|
||||
- [`documents/SECURITY.md`](../documents/SECURITY.md) — security model document; will be rewritten when this plan lands.
|
||||
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c) — code that changes.
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -241,6 +241,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -251,6 +257,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -279,7 +286,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -312,6 +320,9 @@ typedef struct {
|
||||
* Returns number of keys derived, or -1 on error. */
|
||||
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||
|
||||
/* Derive key for exactly one role index. Returns 0 on success, -1 on error. */
|
||||
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index);
|
||||
|
||||
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||
|
||||
@@ -520,6 +531,74 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
|
||||
return derived_count;
|
||||
}
|
||||
|
||||
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index) {
|
||||
role_entry_t *role;
|
||||
derived_key_t *dst;
|
||||
unsigned char priv[32];
|
||||
unsigned char pub[32];
|
||||
|
||||
if (store == NULL || table == NULL || mnemonic == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!mnemonic_is_loaded(mnemonic)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (role_index < 0 || role_index >= table->count || role_index >= ROLE_TABLE_MAX_ENTRIES) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
role = &table->entries[role_index];
|
||||
dst = &store->keys[role_index];
|
||||
|
||||
role->derived = 0;
|
||||
role->pubkey_hex[0] = '\0';
|
||||
|
||||
secure_buf_free(&dst->private_key);
|
||||
secure_memzero(dst->public_key, sizeof(dst->public_key));
|
||||
secure_memzero(dst->pubkey_hex, sizeof(dst->pubkey_hex));
|
||||
secure_memzero(dst->npub, sizeof(dst->npub));
|
||||
dst->valid = 0;
|
||||
|
||||
if (role->purpose != PURPOSE_NOSTR ||
|
||||
role->curve != CURVE_SECP256K1 ||
|
||||
role->selector_type != SELECTOR_NOSTR_INDEX) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (secure_buf_alloc(&dst->private_key, 32) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic), role->nostr_index, priv, pub) != 0) {
|
||||
secure_memzero(priv, sizeof(priv));
|
||||
secure_memzero(pub, sizeof(pub));
|
||||
secure_buf_free(&dst->private_key);
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(dst->private_key.data, priv, 32);
|
||||
memcpy(dst->public_key, pub, 32);
|
||||
|
||||
nostr_bytes_to_hex(pub, 32, dst->pubkey_hex);
|
||||
dst->npub[0] = '\0';
|
||||
(void)nostr_key_to_bech32(pub, "npub", dst->npub);
|
||||
|
||||
strncpy(role->pubkey_hex, dst->pubkey_hex, sizeof(role->pubkey_hex) - 1);
|
||||
role->pubkey_hex[sizeof(role->pubkey_hex) - 1] = '\0';
|
||||
role->derived = 1;
|
||||
|
||||
dst->valid = 1;
|
||||
if (store->count < table->count) {
|
||||
store->count = table->count;
|
||||
}
|
||||
|
||||
secure_memzero(priv, sizeof(priv));
|
||||
secure_memzero(pub, sizeof(pub));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index) {
|
||||
if (store == NULL || role_index < 0 || role_index >= ROLE_TABLE_MAX_ENTRIES) {
|
||||
return NULL;
|
||||
|
||||
109
src/main.c
109
src/main.c
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -151,6 +151,9 @@ const char *role_purpose_to_str(role_purpose_t p);
|
||||
/* Curve enum to string */
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
|
||||
@@ -241,6 +244,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -251,6 +260,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -274,12 +284,19 @@ void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
|
||||
/* Insert an entry before the final catch-all rule. */
|
||||
int policy_table_insert_before_last(policy_table_t *table, const policy_entry_t *entry);
|
||||
|
||||
/* Parse a --preapprove spec into a policy entry. */
|
||||
int parse_preapprove_spec(const char *spec, policy_entry_t *out_entry, int *out_nostr_index);
|
||||
|
||||
/*
|
||||
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -451,8 +468,8 @@ int socket_name_random(char *out, size_t out_len);
|
||||
/* Version information (auto-updated by build/version tooling) */
|
||||
#define NSIGNER_VERSION_MAJOR 0
|
||||
#define NSIGNER_VERSION_MINOR 0
|
||||
#define NSIGNER_VERSION_PATCH 15
|
||||
#define NSIGNER_VERSION "v0.0.15"
|
||||
#define NSIGNER_VERSION_PATCH 16
|
||||
#define NSIGNER_VERSION "v0.0.16"
|
||||
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
@@ -549,12 +566,16 @@ static void print_usage(const char *program_name) {
|
||||
printf("nsigner - single-binary signer program\n");
|
||||
printf("Usage:\n");
|
||||
printf(" %s [--socket-name|--name|-n <name>] [--listen <unix|stdio|qrexec|tcp:HOST:PORT>]\n", program_name);
|
||||
printf(" [--preapprove <SPEC>]...\n");
|
||||
printf(" Run signer server (unix mode has TUI)\n");
|
||||
printf(" %s [--socket-name|--name|-n <name>] client '<json>' Send JSON-RPC request\n", program_name);
|
||||
printf(" %s [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin\n", program_name);
|
||||
printf(" %s list List running nsigner abstract sockets\n", program_name);
|
||||
printf(" %s --help\n", program_name);
|
||||
printf(" %s --version\n", program_name);
|
||||
printf("\nOptions:\n");
|
||||
printf(" --preapprove SPEC Pre-approve a caller for a role (repeatable)\n");
|
||||
printf(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>\n");
|
||||
}
|
||||
|
||||
static int extract_nsigner_socket_from_proc_line(const char *line,
|
||||
@@ -807,7 +828,7 @@ static void render_status(const role_table_t *role_table,
|
||||
}
|
||||
|
||||
printf("\nHotkeys\n-------\n");
|
||||
printf("q/x quit l lock/reunlock r refresh a toggle auto-approve(prompt): %s\n",
|
||||
printf("q/x quit l lock/reunlock r refresh A toggle auto-approve(prompt): %s\n",
|
||||
g_auto_approve ? "ON" : "OFF");
|
||||
fflush(stdout);
|
||||
}
|
||||
@@ -969,8 +990,8 @@ static void apply_test_overrides(policy_table_t *policy) {
|
||||
if (hotkeys != NULL) {
|
||||
const char *p = hotkeys;
|
||||
while (*p != '\0') {
|
||||
char ch = (char)tolower((unsigned char)*p);
|
||||
if (ch == 'a') {
|
||||
char ch = *p;
|
||||
if (ch == 'A') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
}
|
||||
@@ -995,6 +1016,8 @@ int main(int argc, char *argv[]) {
|
||||
int listen_mode = NSIGNER_LISTEN_UNIX;
|
||||
const char *listen_target = NSIGNER_DEFAULT_SOCKET_NAME;
|
||||
int argi = 1;
|
||||
const char *preapprove_specs[POLICY_MAX_ENTRIES];
|
||||
int preapprove_count = 0;
|
||||
|
||||
while (argi < argc) {
|
||||
if (strcmp(argv[argi], "--socket-name") == 0 ||
|
||||
@@ -1030,6 +1053,19 @@ int main(int argc, char *argv[]) {
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--preapprove") == 0) {
|
||||
if (argi + 1 >= argc) {
|
||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||
return 1;
|
||||
}
|
||||
if (preapprove_count >= POLICY_MAX_ENTRIES) {
|
||||
fprintf(stderr, "Too many --preapprove entries (max %d)\n", POLICY_MAX_ENTRIES);
|
||||
return 1;
|
||||
}
|
||||
preapprove_specs[preapprove_count++] = argv[argi + 1];
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1083,6 +1119,41 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
memset(&key_store, 0, sizeof(key_store));
|
||||
|
||||
owner_uid = getuid();
|
||||
policy_init_default(&policy, owner_uid);
|
||||
|
||||
for (int i = 0; i < preapprove_count; ++i) {
|
||||
policy_entry_t entry;
|
||||
int parsed_nostr_index = -1;
|
||||
|
||||
if (parse_preapprove_spec(preapprove_specs[i], &entry, &parsed_nostr_index) != 0) {
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
|
||||
entry.source = POLICY_SOURCE_PREAPPROVE;
|
||||
|
||||
if (parsed_nostr_index >= 0 && role_table_find_by_nostr_index(&role_table, parsed_nostr_index) == NULL) {
|
||||
if (role_table_register_nostr_index(&role_table, parsed_nostr_index) != 0) {
|
||||
fprintf(stderr,
|
||||
"ERROR: failed to register role for --preapprove nostr_index=%d\n",
|
||||
parsed_nostr_index);
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (policy_table_insert_before_last(&policy, &entry) != 0) {
|
||||
fprintf(stderr, "ERROR: failed to insert --preapprove policy entry (table full): %s\n", preapprove_specs[i]);
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[PREAPPROVE] caller=%s role=%s\n", entry.caller, entry.roles[0]);
|
||||
}
|
||||
|
||||
apply_test_overrides(&policy);
|
||||
|
||||
if (nostr_init() != 0) {
|
||||
fprintf(stderr, "Failed to initialize crypto subsystem\n");
|
||||
mnemonic_unload(&mnemonic);
|
||||
@@ -1099,20 +1170,6 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
dispatcher_init(&dispatcher, &role_table, &mnemonic, &key_store);
|
||||
|
||||
owner_uid = getuid();
|
||||
if (listen_mode == NSIGNER_LISTEN_QREXEC || listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
policy_entry_t e;
|
||||
policy_table_init(&policy);
|
||||
memset(&e, 0, sizeof(e));
|
||||
strncpy(e.caller, "*", sizeof(e.caller) - 1);
|
||||
e.prompt = PROMPT_EVERY_REQUEST;
|
||||
(void)policy_table_add(&policy, &e);
|
||||
} else {
|
||||
policy_init_default(&policy, owner_uid);
|
||||
}
|
||||
|
||||
apply_test_overrides(&policy);
|
||||
|
||||
if (listen_mode == NSIGNER_LISTEN_UNIX && !socket_name_explicit) {
|
||||
if (socket_name_random(generated_socket_name, sizeof(generated_socket_name)) != 0) {
|
||||
fprintf(stderr, "Failed to generate random socket name\n");
|
||||
@@ -1230,16 +1287,16 @@ int main(int argc, char *argv[]) {
|
||||
if (prc > 0 && (pfds[1].revents & POLLIN)) {
|
||||
char ch = '\0';
|
||||
if (read(STDIN_FILENO, &ch, 1) > 0) {
|
||||
ch = (char)tolower((unsigned char)ch);
|
||||
if (ch == 'q' || ch == 'x') {
|
||||
char lower = (char)tolower((unsigned char)ch);
|
||||
if (lower == 'q' || lower == 'x') {
|
||||
g_running = 0;
|
||||
} else if (ch == 'r') {
|
||||
} else if (lower == 'r') {
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
} else if (ch == 'a') {
|
||||
} else if (ch == 'A') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
} else if (ch == 'l') {
|
||||
} else if (lower == 'l') {
|
||||
printf("\n[lock] Session locked. Re-enter mnemonic to unlock.\n");
|
||||
fflush(stdout);
|
||||
crypto_wipe(&key_store);
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
173
src/policy.c
173
src/policy.c
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -440,7 +448,9 @@ int socket_name_random(char *out, size_t out_len);
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static void copy_str(char *dst, size_t dst_sz, const char *src) {
|
||||
@@ -540,6 +550,112 @@ const char *prompt_mode_to_str(prompt_mode_t m) {
|
||||
}
|
||||
}
|
||||
|
||||
int parse_preapprove_spec(const char *spec, policy_entry_t *out_entry, int *out_nostr_index) {
|
||||
char *spec_copy;
|
||||
char *token;
|
||||
const char *caller = NULL;
|
||||
const char *role = NULL;
|
||||
int nostr_index = -1;
|
||||
int has_role = 0;
|
||||
int has_nostr_index = 0;
|
||||
|
||||
if (out_nostr_index != NULL) {
|
||||
*out_nostr_index = -1;
|
||||
}
|
||||
|
||||
if (spec == NULL || out_entry == NULL) {
|
||||
fprintf(stderr, "ERROR: invalid --preapprove arguments\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
spec_copy = (char *)malloc(strlen(spec) + 1);
|
||||
if (spec_copy == NULL) {
|
||||
fprintf(stderr, "ERROR: out of memory parsing --preapprove spec: %s\n", spec);
|
||||
return -1;
|
||||
}
|
||||
memcpy(spec_copy, spec, strlen(spec) + 1);
|
||||
|
||||
token = strtok(spec_copy, ",");
|
||||
while (token != NULL) {
|
||||
char *eq = strchr(token, '=');
|
||||
if (eq == NULL || eq == token || eq[1] == '\0') {
|
||||
fprintf(stderr, "ERROR: invalid --preapprove key=value field: %s\n", spec);
|
||||
free(spec_copy);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*eq = '\0';
|
||||
if (strcmp(token, "caller") == 0) {
|
||||
caller = eq + 1;
|
||||
} else if (strcmp(token, "role") == 0) {
|
||||
role = eq + 1;
|
||||
has_role = 1;
|
||||
} else if (strcmp(token, "nostr_index") == 0) {
|
||||
char *endptr = NULL;
|
||||
long val;
|
||||
|
||||
errno = 0;
|
||||
val = strtol(eq + 1, &endptr, 10);
|
||||
if (errno != 0 || endptr == (eq + 1) || *endptr != '\0' || val < 0) {
|
||||
fprintf(stderr, "ERROR: --preapprove spec has invalid nostr_index= value: %s\n", spec);
|
||||
free(spec_copy);
|
||||
return -1;
|
||||
}
|
||||
nostr_index = (int)val;
|
||||
has_nostr_index = 1;
|
||||
} else {
|
||||
fprintf(stderr, "ERROR: unknown --preapprove field '%s' in spec: %s\n", token, spec);
|
||||
free(spec_copy);
|
||||
return -1;
|
||||
}
|
||||
|
||||
token = strtok(NULL, ",");
|
||||
}
|
||||
|
||||
if (caller == NULL) {
|
||||
fprintf(stderr, "ERROR: --preapprove spec missing 'caller=' field: %s\n", spec);
|
||||
free(spec_copy);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!has_role && !has_nostr_index) {
|
||||
fprintf(stderr, "ERROR: --preapprove spec must include 'role=' or 'nostr_index=': %s\n", spec);
|
||||
free(spec_copy);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (has_role && has_nostr_index) {
|
||||
fprintf(stderr, "ERROR: --preapprove spec has both 'role=' and 'nostr_index=' (use one): %s\n", spec);
|
||||
free(spec_copy);
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(out_entry, 0, sizeof(*out_entry));
|
||||
strncpy(out_entry->caller, caller, sizeof(out_entry->caller) - 1);
|
||||
out_entry->caller[sizeof(out_entry->caller) - 1] = '\0';
|
||||
|
||||
if (has_role) {
|
||||
strncpy(out_entry->roles[0], role, sizeof(out_entry->roles[0]) - 1);
|
||||
out_entry->roles[0][sizeof(out_entry->roles[0]) - 1] = '\0';
|
||||
} else {
|
||||
if (nostr_index == 0) {
|
||||
strncpy(out_entry->roles[0], "main", sizeof(out_entry->roles[0]) - 1);
|
||||
out_entry->roles[0][sizeof(out_entry->roles[0]) - 1] = '\0';
|
||||
} else {
|
||||
(void)snprintf(out_entry->roles[0], sizeof(out_entry->roles[0]), "nostr_idx_%d", nostr_index);
|
||||
}
|
||||
if (out_nostr_index != NULL) {
|
||||
*out_nostr_index = nostr_index;
|
||||
}
|
||||
}
|
||||
|
||||
out_entry->role_count = 1;
|
||||
out_entry->prompt = PROMPT_NEVER;
|
||||
|
||||
free(spec_copy);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void policy_table_init(policy_table_t *table) {
|
||||
if (table == NULL) {
|
||||
return;
|
||||
@@ -549,8 +665,9 @@ void policy_table_init(policy_table_t *table) {
|
||||
}
|
||||
|
||||
void policy_init_default(policy_table_t *table, uid_t owner_uid) {
|
||||
policy_entry_t allow_owner;
|
||||
policy_entry_t deny_all;
|
||||
policy_entry_t prompt_all;
|
||||
|
||||
(void)owner_uid;
|
||||
|
||||
if (table == NULL) {
|
||||
return;
|
||||
@@ -558,15 +675,11 @@ void policy_init_default(policy_table_t *table, uid_t owner_uid) {
|
||||
|
||||
policy_table_init(table);
|
||||
|
||||
memset(&allow_owner, 0, sizeof(allow_owner));
|
||||
(void)snprintf(allow_owner.caller, sizeof(allow_owner.caller), "uid:%u", (unsigned int)owner_uid);
|
||||
allow_owner.prompt = PROMPT_NEVER;
|
||||
(void)policy_table_add(table, &allow_owner);
|
||||
|
||||
memset(&deny_all, 0, sizeof(deny_all));
|
||||
copy_str(deny_all.caller, sizeof(deny_all.caller), "*");
|
||||
deny_all.prompt = PROMPT_DENY;
|
||||
(void)policy_table_add(table, &deny_all);
|
||||
memset(&prompt_all, 0, sizeof(prompt_all));
|
||||
copy_str(prompt_all.caller, sizeof(prompt_all.caller), "*");
|
||||
prompt_all.prompt = PROMPT_EVERY_REQUEST;
|
||||
prompt_all.source = POLICY_SOURCE_DEFAULT;
|
||||
(void)policy_table_add(table, &prompt_all);
|
||||
}
|
||||
|
||||
int policy_table_add(policy_table_t *table, const policy_entry_t *entry) {
|
||||
@@ -583,10 +696,34 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int policy_table_insert_before_last(policy_table_t *table, const policy_entry_t *entry) {
|
||||
if (table == NULL || entry == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (table->count >= POLICY_MAX_ENTRIES) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (table->count == 0) {
|
||||
return policy_table_add(table, entry);
|
||||
}
|
||||
|
||||
table->entries[table->count] = table->entries[table->count - 1];
|
||||
table->entries[table->count - 1] = *entry;
|
||||
table->count++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose) {
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source) {
|
||||
int i;
|
||||
|
||||
if (out_source != NULL) {
|
||||
*out_source = POLICY_SOURCE_DEFAULT;
|
||||
}
|
||||
|
||||
if (table == NULL || caller_id == NULL || verb == NULL || role_name == NULL || purpose == NULL) {
|
||||
return POLICY_NO_MATCH;
|
||||
}
|
||||
@@ -618,12 +755,20 @@ int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
continue;
|
||||
}
|
||||
|
||||
if (out_source != NULL) {
|
||||
*out_source = entry->source;
|
||||
}
|
||||
|
||||
if (entry->prompt == PROMPT_DENY) {
|
||||
return POLICY_DENY;
|
||||
}
|
||||
if (entry->prompt == PROMPT_NEVER) {
|
||||
return POLICY_ALLOW;
|
||||
}
|
||||
|
||||
if (out_source != NULL) {
|
||||
*out_source = POLICY_SOURCE_DEFAULT;
|
||||
}
|
||||
return POLICY_PROMPT;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -149,6 +149,9 @@ const char *role_purpose_to_str(role_purpose_t p);
|
||||
/* Curve enum to string */
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
|
||||
@@ -239,6 +242,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +258,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +287,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -440,6 +451,7 @@ int socket_name_random(char *out, size_t out_len);
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int str_eq(const char *a, const char *b) {
|
||||
@@ -533,6 +545,36 @@ role_entry_t *role_table_get_default(role_table_t *table) {
|
||||
return role_table_find_by_name(table, "main");
|
||||
}
|
||||
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index) {
|
||||
role_entry_t role;
|
||||
|
||||
if (table == NULL || nostr_index < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (role_table_find_by_nostr_index(table, nostr_index) != NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
memset(&role, 0, sizeof(role));
|
||||
|
||||
if (nostr_index == 0) {
|
||||
strncpy(role.name, "main", sizeof(role.name) - 1);
|
||||
} else {
|
||||
(void)snprintf(role.name, sizeof(role.name), "nostr_idx_%d", nostr_index);
|
||||
}
|
||||
|
||||
strncpy(role.purpose_str, "nostr", sizeof(role.purpose_str) - 1);
|
||||
strncpy(role.curve_str, "secp256k1", sizeof(role.curve_str) - 1);
|
||||
role.purpose = role_purpose_from_str(role.purpose_str);
|
||||
role.curve = role_curve_from_str(role.curve_str);
|
||||
role.selector_type = SELECTOR_NOSTR_INDEX;
|
||||
role.nostr_index = nostr_index;
|
||||
role.derived = 0;
|
||||
|
||||
return role_table_add(table, &role);
|
||||
}
|
||||
|
||||
role_purpose_t role_purpose_from_str(const char *s) {
|
||||
if (str_eq(s, "nostr")) {
|
||||
return PURPOSE_NOSTR;
|
||||
|
||||
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -241,6 +241,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -251,6 +257,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -279,7 +286,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
157
src/server.c
157
src/server.c
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -151,6 +151,9 @@ const char *role_purpose_to_str(role_purpose_t p);
|
||||
/* Curve enum to string */
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
|
||||
@@ -241,6 +244,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -251,6 +260,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -260,10 +270,11 @@ typedef struct {
|
||||
} policy_table_t;
|
||||
|
||||
/* Policy check result */
|
||||
#define POLICY_ALLOW 0
|
||||
#define POLICY_DENY -1
|
||||
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||
#define POLICY_ALLOW 0
|
||||
#define POLICY_ALLOW_SESSION 3 /* allow + save as session grant */
|
||||
#define POLICY_DENY -1
|
||||
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||
|
||||
/* Initialize policy table */
|
||||
void policy_table_init(policy_table_t *table);
|
||||
@@ -274,12 +285,16 @@ void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
|
||||
/* Insert an entry before the final catch-all rule. */
|
||||
int policy_table_insert_before_last(policy_table_t *table, const policy_entry_t *entry);
|
||||
|
||||
/*
|
||||
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -312,6 +327,9 @@ typedef struct {
|
||||
* Returns number of keys derived, or -1 on error. */
|
||||
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||
|
||||
/* Derive key for exactly one role index. Returns 0 on success, -1 on error. */
|
||||
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index);
|
||||
|
||||
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||
|
||||
@@ -635,7 +653,8 @@ void server_set_noninteractive_prompt_default(int decision) {
|
||||
static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
const char *method,
|
||||
const char *role_name,
|
||||
const char *purpose) {
|
||||
const char *purpose,
|
||||
int pending_derivation) {
|
||||
int ch;
|
||||
|
||||
if (g_prompt_always_allow) {
|
||||
@@ -669,7 +688,10 @@ static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
printf("method: %s\n", (method != NULL) ? method : "unknown");
|
||||
printf("role: %s\n", (role_name != NULL) ? role_name : "unknown");
|
||||
printf("purpose: %s\n", (purpose != NULL) ? purpose : "unknown");
|
||||
printf("[y] allow once [n] deny [a] always allow this session\n> ");
|
||||
if (pending_derivation) {
|
||||
printf(" ** NEW IDENTITY — will be derived if approved **\n");
|
||||
}
|
||||
printf("[y] allow once [n] deny [a] allow this caller+role for session\n> ");
|
||||
fflush(stdout);
|
||||
|
||||
ch = getchar();
|
||||
@@ -682,8 +704,7 @@ static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
|
||||
ch = tolower(ch);
|
||||
if (ch == 'a') {
|
||||
g_prompt_always_allow = 1;
|
||||
return POLICY_ALLOW;
|
||||
return POLICY_ALLOW_SESSION;
|
||||
}
|
||||
if (ch == 'y') {
|
||||
return POLICY_ALLOW;
|
||||
@@ -1173,9 +1194,15 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
char purpose[ROLE_PURPOSE_MAX];
|
||||
selector_request_t selector_req;
|
||||
role_entry_t *role = NULL;
|
||||
int selector_rc;
|
||||
int pchk;
|
||||
policy_source_t policy_src = POLICY_SOURCE_DEFAULT;
|
||||
int pending_derivation = 0;
|
||||
int hard_selector_error = 0;
|
||||
int derivation_error = 0;
|
||||
char activity[256];
|
||||
const char *verdict = "DENIED";
|
||||
const char *source_label = "no-match";
|
||||
|
||||
if (ctx == NULL || ctx->listen_fd < 0 || ctx->dispatcher == NULL || ctx->policy == NULL) {
|
||||
return -1;
|
||||
@@ -1221,41 +1248,127 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
json_copy_string(purpose, sizeof(purpose), "unknown", "unknown");
|
||||
|
||||
if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) {
|
||||
if (ctx->dispatcher->role_table != NULL &&
|
||||
selector_resolve(&selector_req, ctx->dispatcher->role_table, &role) == SELECTOR_OK &&
|
||||
role != NULL) {
|
||||
json_copy_string(role_name, sizeof(role_name), role->name, "main");
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
|
||||
if (ctx->dispatcher->role_table != NULL) {
|
||||
selector_rc = selector_resolve(&selector_req, ctx->dispatcher->role_table, &role);
|
||||
if (selector_rc == SELECTOR_OK && role != NULL) {
|
||||
json_copy_string(role_name, sizeof(role_name), role->name, "main");
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
|
||||
} else if (selector_rc == SELECTOR_ERR_NOT_FOUND && selector_req.has_nostr_index) {
|
||||
pending_derivation = 1;
|
||||
if (selector_req.nostr_index == 0) {
|
||||
json_copy_string(role_name, sizeof(role_name), "main", "main");
|
||||
} else {
|
||||
(void)snprintf(role_name, sizeof(role_name), "nostr_idx_%d", selector_req.nostr_index);
|
||||
}
|
||||
json_copy_string(purpose, sizeof(purpose), "nostr", "nostr");
|
||||
} else if (selector_rc == SELECTOR_ERR_AMBIGUOUS ||
|
||||
selector_rc == SELECTOR_ERR_NOT_FOUND ||
|
||||
selector_rc == SELECTOR_ERR_NO_DEFAULT) {
|
||||
hard_selector_error = selector_rc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pchk = policy_check(ctx->policy, caller.caller_id, method, role_name, purpose);
|
||||
if (pchk == POLICY_PROMPT) {
|
||||
pchk = prompt_for_policy_decision(&caller, method, role_name, purpose);
|
||||
if (hard_selector_error == SELECTOR_ERR_AMBIGUOUS) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":1001,\"message\":\"ambiguous_role_selector\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == SELECTOR_ERR_NO_DEFAULT) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":1003,\"message\":\"no_default_role\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == SELECTOR_ERR_NOT_FOUND) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":1002,\"message\":\"unknown_role\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else {
|
||||
pchk = policy_check(ctx->policy, caller.caller_id, method, role_name, purpose, &policy_src);
|
||||
}
|
||||
|
||||
if (pchk == POLICY_ALLOW) {
|
||||
if (pchk == POLICY_PROMPT) {
|
||||
pchk = prompt_for_policy_decision(&caller, method, role_name, purpose, pending_derivation);
|
||||
if (pchk == POLICY_ALLOW_SESSION) {
|
||||
policy_entry_t grant;
|
||||
|
||||
memset(&grant, 0, sizeof(grant));
|
||||
strncpy(grant.caller, caller.caller_id, sizeof(grant.caller) - 1);
|
||||
strncpy(grant.roles[0], role_name, ROLE_NAME_MAX - 1);
|
||||
grant.role_count = 1;
|
||||
grant.prompt = PROMPT_NEVER;
|
||||
grant.source = POLICY_SOURCE_SESSION_GRANT;
|
||||
if (policy_table_insert_before_last(ctx->policy, &grant) != 0) {
|
||||
pchk = POLICY_DENY;
|
||||
} else {
|
||||
pchk = POLICY_ALLOW;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pchk == POLICY_ALLOW && pending_derivation) {
|
||||
role_entry_t *new_role;
|
||||
int role_index;
|
||||
|
||||
if (ctx->dispatcher == NULL ||
|
||||
ctx->dispatcher->role_table == NULL ||
|
||||
ctx->dispatcher->key_store == NULL ||
|
||||
ctx->dispatcher->mnemonic == NULL ||
|
||||
role_table_register_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index) != 0) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
new_role = role_table_find_by_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index);
|
||||
if (new_role == NULL) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]);
|
||||
if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count ||
|
||||
crypto_derive_one(ctx->dispatcher->key_store,
|
||||
ctx->dispatcher->role_table,
|
||||
ctx->dispatcher->mnemonic,
|
||||
role_index) != 0) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
json_copy_string(role_name, sizeof(role_name), new_role->name, role_name);
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pchk == POLICY_ALLOW && !derivation_error) {
|
||||
verdict = "ALLOWED";
|
||||
response = dispatcher_handle_request(ctx->dispatcher, request);
|
||||
if (response == NULL) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32603,\"message\":\"internal_error\"}}");
|
||||
}
|
||||
} else if (pchk == POLICY_ALLOW && derivation_error) {
|
||||
verdict = "DENIED";
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32603,\"message\":\"internal_error\"}}");
|
||||
} else {
|
||||
verdict = "DENIED";
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2001,\"message\":\"policy_denied\"}}");
|
||||
if (response == NULL) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2001,\"message\":\"policy_denied\"}}");
|
||||
}
|
||||
}
|
||||
|
||||
if (response != NULL) {
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
}
|
||||
|
||||
if (pchk == POLICY_ALLOW && policy_src == POLICY_SOURCE_PREAPPROVE) {
|
||||
source_label = "preapprove";
|
||||
} else if (pchk == POLICY_ALLOW && policy_src == POLICY_SOURCE_SESSION_GRANT) {
|
||||
source_label = "session-grant";
|
||||
} else if (pchk == POLICY_ALLOW) {
|
||||
source_label = "prompt";
|
||||
} else {
|
||||
source_label = "no-match";
|
||||
}
|
||||
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s",
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
verdict);
|
||||
verdict,
|
||||
source_label);
|
||||
|
||||
if (cb != NULL) {
|
||||
cb(activity, cb_data);
|
||||
|
||||
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -241,6 +241,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -251,6 +257,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -279,7 +286,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -149,6 +149,9 @@ const char *role_purpose_to_str(role_purpose_t p);
|
||||
/* Curve enum to string */
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
|
||||
@@ -239,6 +242,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +258,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +287,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -310,6 +321,9 @@ typedef struct {
|
||||
* Returns number of keys derived, or -1 on error. */
|
||||
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||
|
||||
/* Derive key for exactly one role index. Returns 0 on success, -1 on error. */
|
||||
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index);
|
||||
|
||||
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||
|
||||
@@ -515,6 +529,9 @@ int main(void) {
|
||||
role_table_add(&table, &main_role);
|
||||
role_table_add(&table, &ssh_role);
|
||||
|
||||
check_condition("role_table_register_nostr_index adds index 9",
|
||||
role_table_register_nostr_index(&table, 9) == 0 && role_table_find_by_nostr_index(&table, 9) != NULL);
|
||||
|
||||
mnemonic_init(&mnemonic);
|
||||
mnemonic_load(&mnemonic, valid_12);
|
||||
|
||||
@@ -527,6 +544,19 @@ int main(void) {
|
||||
derived = crypto_derive_all(&key_store, &table, &mnemonic);
|
||||
check_condition("crypto_derive_all derives at least one key", derived >= 1);
|
||||
|
||||
{
|
||||
role_entry_t *r9 = role_table_find_by_nostr_index(&table, 9);
|
||||
int idx9 = (r9 != NULL) ? (int)(r9 - &table.entries[0]) : -1;
|
||||
|
||||
check_condition("new role idx9 exists before derive_one", idx9 >= 0);
|
||||
if (idx9 >= 0) {
|
||||
check_condition("crypto_derive_one derives newly registered role",
|
||||
crypto_derive_one(&key_store, &table, &mnemonic, idx9) == 0 &&
|
||||
table.entries[idx9].derived == 1 &&
|
||||
crypto_get_pubkey_hex(&key_store, idx9) != NULL);
|
||||
}
|
||||
}
|
||||
|
||||
/* 1. Valid get_public_key no selector -> default main, real pubkey */
|
||||
resp = dispatcher_handle_request(&dispatcher,
|
||||
"{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}");
|
||||
@@ -603,7 +633,7 @@ int main(void) {
|
||||
crypto_wipe(&key_store);
|
||||
nostr_cleanup();
|
||||
|
||||
printf("%d/12 tests passed\n", g_passes);
|
||||
printf("%d/%d tests passed\n", g_passes, g_total);
|
||||
|
||||
return (g_passes == 12 && g_total == 12) ? 0 : 1;
|
||||
return (g_passes == g_total) ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -241,6 +241,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -251,6 +257,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -279,7 +286,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -266,18 +273,25 @@ typedef struct {
|
||||
/* Initialize policy table */
|
||||
void policy_table_init(policy_table_t *table);
|
||||
|
||||
/* Initialize default policy: allow same-uid, deny others */
|
||||
/* Initialize default policy table entries. */
|
||||
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||
|
||||
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
|
||||
/* Insert an entry before the final catch-all rule. */
|
||||
int policy_table_insert_before_last(policy_table_t *table, const policy_entry_t *entry);
|
||||
|
||||
/* Parse a --preapprove spec into a policy entry. */
|
||||
int parse_preapprove_spec(const char *spec, policy_entry_t *out_entry, int *out_nostr_index);
|
||||
|
||||
/*
|
||||
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -486,7 +500,9 @@ static void add_single_entry(policy_table_t *table,
|
||||
|
||||
int main(void) {
|
||||
policy_table_t table;
|
||||
policy_entry_t parsed;
|
||||
int rc;
|
||||
int parsed_nostr_index;
|
||||
uid_t uid = getuid();
|
||||
char same_uid[64];
|
||||
char other_uid[64];
|
||||
@@ -494,74 +510,177 @@ int main(void) {
|
||||
(void)snprintf(same_uid, sizeof(same_uid), "uid:%u", (unsigned int)uid);
|
||||
(void)snprintf(other_uid, sizeof(other_uid), "uid:%u", (unsigned int)(uid + 1U));
|
||||
|
||||
policy_init_default(&table, uid);
|
||||
rc = policy_check(&table, same_uid, "sign_event", "main", "nostr");
|
||||
check_condition("policy_init_default allows same uid", rc == POLICY_ALLOW);
|
||||
rc = parse_preapprove_spec("caller=uid:1000,role=main", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec accepts caller+role", rc == 0);
|
||||
check_condition("parse_preapprove_spec caller set", strcmp(parsed.caller, "uid:1000") == 0);
|
||||
check_condition("parse_preapprove_spec role set", strcmp(parsed.roles[0], "main") == 0);
|
||||
check_condition("parse_preapprove_spec role_count=1", parsed.role_count == 1);
|
||||
check_condition("parse_preapprove_spec prompt=never", parsed.prompt == PROMPT_NEVER);
|
||||
check_condition("parse_preapprove_spec nostr_index unchanged for role", parsed_nostr_index == -1);
|
||||
|
||||
rc = policy_check(&table, other_uid, "sign_event", "main", "nostr");
|
||||
check_condition("policy_init_default denies different uid", rc == POLICY_DENY);
|
||||
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=0", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec accepts caller+nostr_index", rc == 0);
|
||||
check_condition("parse_preapprove_spec nostr_index=0 maps to main", strcmp(parsed.roles[0], "main") == 0);
|
||||
check_condition("parse_preapprove_spec returns parsed nostr_index", parsed_nostr_index == 0);
|
||||
|
||||
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=2", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec maps nonzero nostr_index role", strcmp(parsed.roles[0], "nostr_idx_2") == 0);
|
||||
check_condition("parse_preapprove_spec stores nonzero nostr_index", parsed_nostr_index == 2);
|
||||
|
||||
rc = parse_preapprove_spec("role=main", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec rejects missing caller", rc == -1);
|
||||
rc = parse_preapprove_spec("caller=uid:1000", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec rejects missing role and nostr_index", rc == -1);
|
||||
rc = parse_preapprove_spec("caller=uid:1000,role=main,nostr_index=0", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec rejects both role and nostr_index", rc == -1);
|
||||
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=-1", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec rejects negative nostr_index", rc == -1);
|
||||
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=abc", &parsed, &parsed_nostr_index);
|
||||
check_condition("parse_preapprove_spec rejects non-numeric nostr_index", rc == -1);
|
||||
|
||||
policy_init_default(&table, uid);
|
||||
rc = policy_check(&table, same_uid, "sign_event", "main", "nostr", NULL);
|
||||
check_condition("policy_init_default prompts same uid", rc == POLICY_PROMPT);
|
||||
|
||||
rc = policy_check(&table, other_uid, "sign_event", "main", "nostr", NULL);
|
||||
check_condition("policy_init_default prompts different uid", rc == POLICY_PROMPT);
|
||||
|
||||
/* Insert before catch-all: matching caller allowed, non-matching still prompted */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "*", NULL, NULL, NULL, PROMPT_EVERY_REQUEST);
|
||||
{
|
||||
policy_entry_t grant;
|
||||
memset(&grant, 0, sizeof(grant));
|
||||
strncpy(grant.caller, "uid:1000", sizeof(grant.caller) - 1);
|
||||
strncpy(grant.roles[0], "main", sizeof(grant.roles[0]) - 1);
|
||||
grant.role_count = 1;
|
||||
grant.prompt = PROMPT_NEVER;
|
||||
rc = policy_table_insert_before_last(&table, &grant);
|
||||
check_condition("policy_table_insert_before_last succeeds", rc == 0);
|
||||
}
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("insert_before_last matching caller returns POLICY_ALLOW", rc == POLICY_ALLOW);
|
||||
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("insert_before_last non-matching caller returns POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||
|
||||
/* Preapprove parse + insert: matching caller allowed, non-matching prompted */
|
||||
policy_init_default(&table, uid);
|
||||
rc = parse_preapprove_spec("caller=uid:1000,role=main", &parsed, &parsed_nostr_index);
|
||||
check_condition("preapprove parse for policy insert succeeds", rc == 0);
|
||||
rc = policy_table_insert_before_last(&table, &parsed);
|
||||
check_condition("preapprove insert_before_last succeeds", rc == 0);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("preapprove allows matching caller+role", rc == POLICY_ALLOW);
|
||||
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("preapprove leaves non-matching caller at POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||
|
||||
/* Exact caller, verb, role, purpose + never => allow */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("exact match returns POLICY_ALLOW", rc == POLICY_ALLOW);
|
||||
|
||||
/* Wildcard caller + deny => deny */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "*", "sign_event", "main", "nostr", PROMPT_DENY);
|
||||
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("wildcard caller with deny returns POLICY_DENY", rc == POLICY_DENY);
|
||||
|
||||
/* Caller no match => POLICY_NO_MATCH */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
|
||||
rc = policy_check(&table, "uid:9999", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:9999", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("caller mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||
|
||||
/* Verb not in list => no match */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "get_public_key", "main", "nostr", PROMPT_NEVER);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("verb mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||
|
||||
/* Role not in list => no match */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "throwaway", "nostr", PROMPT_NEVER);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("role mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||
|
||||
/* Purpose not in list => no match */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "main", "bitcoin", PROMPT_NEVER);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("purpose mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||
|
||||
/* Empty verbs list means all verbs */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", NULL, "main", "nostr", PROMPT_NEVER);
|
||||
rc = policy_check(&table, "uid:1000", "nip44_encrypt", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "nip44_encrypt", "main", "nostr", NULL);
|
||||
check_condition("empty verbs list matches any verb", rc == POLICY_ALLOW);
|
||||
|
||||
/* prompt=first_per_boot => POLICY_PROMPT */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_FIRST_PER_BOOT);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("first_per_boot returns POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||
|
||||
/* prompt=every_request => POLICY_PROMPT */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_EVERY_REQUEST);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("every_request returns POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||
|
||||
/* First matching entry wins */
|
||||
policy_table_init(&table);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_DENY);
|
||||
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr");
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||
check_condition("first matching entry wins", rc == POLICY_DENY);
|
||||
|
||||
/* Verify out_source for preapprove, session-grant, and default catch-all */
|
||||
policy_table_init(&table);
|
||||
{
|
||||
policy_entry_t pre;
|
||||
policy_entry_t sess;
|
||||
policy_entry_t prompt_all;
|
||||
policy_source_t src = POLICY_SOURCE_DEFAULT;
|
||||
|
||||
memset(&pre, 0, sizeof(pre));
|
||||
strncpy(pre.caller, "uid:1000", sizeof(pre.caller) - 1);
|
||||
strncpy(pre.roles[0], "main", sizeof(pre.roles[0]) - 1);
|
||||
pre.role_count = 1;
|
||||
pre.prompt = PROMPT_NEVER;
|
||||
pre.source = POLICY_SOURCE_PREAPPROVE;
|
||||
rc = policy_table_add(&table, &pre);
|
||||
check_condition("source test preapprove entry add", rc == 0);
|
||||
|
||||
memset(&sess, 0, sizeof(sess));
|
||||
strncpy(sess.caller, "uid:1001", sizeof(sess.caller) - 1);
|
||||
strncpy(sess.roles[0], "main", sizeof(sess.roles[0]) - 1);
|
||||
sess.role_count = 1;
|
||||
sess.prompt = PROMPT_NEVER;
|
||||
sess.source = POLICY_SOURCE_SESSION_GRANT;
|
||||
rc = policy_table_add(&table, &sess);
|
||||
check_condition("source test session entry add", rc == 0);
|
||||
|
||||
memset(&prompt_all, 0, sizeof(prompt_all));
|
||||
strncpy(prompt_all.caller, "*", sizeof(prompt_all.caller) - 1);
|
||||
prompt_all.prompt = PROMPT_EVERY_REQUEST;
|
||||
prompt_all.source = POLICY_SOURCE_DEFAULT;
|
||||
rc = policy_table_add(&table, &prompt_all);
|
||||
check_condition("source test default catch-all add", rc == 0);
|
||||
|
||||
src = POLICY_SOURCE_DEFAULT;
|
||||
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", &src);
|
||||
check_condition("policy_check reports preapprove source", rc == POLICY_ALLOW && src == POLICY_SOURCE_PREAPPROVE);
|
||||
|
||||
src = POLICY_SOURCE_DEFAULT;
|
||||
rc = policy_check(&table, "uid:1001", "sign_event", "main", "nostr", &src);
|
||||
check_condition("policy_check reports session-grant source", rc == POLICY_ALLOW && src == POLICY_SOURCE_SESSION_GRANT);
|
||||
|
||||
src = POLICY_SOURCE_PREAPPROVE;
|
||||
rc = policy_check(&table, "uid:9999", "sign_event", "main", "nostr", &src);
|
||||
check_condition("policy_check resets source for prompt/default", rc == POLICY_PROMPT && src == POLICY_SOURCE_DEFAULT);
|
||||
}
|
||||
|
||||
printf("%d/%d tests passed\n", g_passes, g_total);
|
||||
return (g_passes == g_total) ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -149,6 +149,9 @@ const char *role_purpose_to_str(role_purpose_t p);
|
||||
/* Curve enum to string */
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
|
||||
@@ -239,6 +242,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +258,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +287,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
@@ -530,40 +541,28 @@ int main(void) {
|
||||
rc = role_table_add(&table, &e_main);
|
||||
check_condition("duplicate name rejection returns -2", rc == -2);
|
||||
|
||||
role_table_init(&table);
|
||||
rc = role_table_register_nostr_index(&table, 7);
|
||||
check_condition("register nostr_index=7 returns 0", rc == 0);
|
||||
found = role_table_find_by_nostr_index(&table, 7);
|
||||
check_condition("register nostr_index=7 creates role", found != NULL);
|
||||
check_condition("registered role name is nostr_idx_7", found != NULL && strcmp(found->name, "nostr_idx_7") == 0);
|
||||
check_condition("registered role purpose nostr", found != NULL && found->purpose == PURPOSE_NOSTR);
|
||||
check_condition("registered role curve secp256k1", found != NULL && found->curve == CURVE_SECP256K1);
|
||||
check_condition("registered role selector type nostr_index", found != NULL && found->selector_type == SELECTOR_NOSTR_INDEX);
|
||||
|
||||
rc = role_table_register_nostr_index(&table, 7);
|
||||
check_condition("register nostr_index=7 idempotent second call", rc == 0);
|
||||
check_condition("register nostr_index=7 idempotent no duplicate", table.count == 1);
|
||||
|
||||
role_table_init(&table);
|
||||
for (i = 0; i < ROLE_TABLE_MAX_ENTRIES; ++i) {
|
||||
role_entry_t e;
|
||||
char name_buf[ROLE_NAME_MAX];
|
||||
|
||||
memset(&e, 0, sizeof(e));
|
||||
snprintf(name_buf, sizeof(name_buf), "role_%d", i);
|
||||
strncpy(e.name, name_buf, sizeof(e.name) - 1);
|
||||
strncpy(e.purpose_str, "nostr", sizeof(e.purpose_str) - 1);
|
||||
strncpy(e.curve_str, "secp256k1", sizeof(e.curve_str) - 1);
|
||||
e.purpose = PURPOSE_NOSTR;
|
||||
e.curve = CURVE_SECP256K1;
|
||||
e.selector_type = SELECTOR_NOSTR_INDEX;
|
||||
e.nostr_index = i;
|
||||
|
||||
rc = role_table_add(&table, &e);
|
||||
check_condition("fill table role add returns 0", rc == 0);
|
||||
rc = role_table_register_nostr_index(&table, i);
|
||||
check_condition("fill table via register_nostr_index returns 0", rc == 0);
|
||||
}
|
||||
|
||||
{
|
||||
role_entry_t overflow;
|
||||
|
||||
memset(&overflow, 0, sizeof(overflow));
|
||||
strncpy(overflow.name, "overflow", sizeof(overflow.name) - 1);
|
||||
strncpy(overflow.purpose_str, "nostr", sizeof(overflow.purpose_str) - 1);
|
||||
strncpy(overflow.curve_str, "secp256k1", sizeof(overflow.curve_str) - 1);
|
||||
overflow.purpose = PURPOSE_NOSTR;
|
||||
overflow.curve = CURVE_SECP256K1;
|
||||
overflow.selector_type = SELECTOR_NOSTR_INDEX;
|
||||
overflow.nostr_index = 999;
|
||||
|
||||
rc = role_table_add(&table, &overflow);
|
||||
check_condition("table full rejection returns -1", rc == -1);
|
||||
}
|
||||
rc = role_table_register_nostr_index(&table, ROLE_TABLE_MAX_ENTRIES + 7);
|
||||
check_condition("register_nostr_index table full returns -1", rc == -1);
|
||||
|
||||
if (g_failures == 0) {
|
||||
printf("ALL TESTS PASSED\n");
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||
typedef enum {
|
||||
@@ -239,6 +239,12 @@ typedef enum {
|
||||
PROMPT_DENY
|
||||
} prompt_mode_t;
|
||||
|
||||
typedef enum {
|
||||
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||
} policy_source_t;
|
||||
|
||||
/* A single policy entry */
|
||||
typedef struct {
|
||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||
@@ -249,6 +255,7 @@ typedef struct {
|
||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||
int purpose_count;
|
||||
prompt_mode_t prompt;
|
||||
policy_source_t source;
|
||||
} policy_entry_t;
|
||||
|
||||
/* Policy table */
|
||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose);
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Parse prompt mode from string */
|
||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||
|
||||
Reference in New Issue
Block a user