v0.0.25 - Add --allow-all flag and short aliases for CLI options
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: "Brief description of what this command does"
|
description: "This command increments the version number and then adds, commits and pushes to the repo."
|
||||||
---
|
---
|
||||||
|
|
||||||
Run increment_and_push.sh, and supply a good git commit message. For example:
|
Run increment_and_push.sh, and supply a good git commit message. For example:
|
||||||
|
|||||||
12
Makefile
12
Makefile
@@ -37,10 +37,11 @@ TEST_POLICY_TARGET := $(BUILD_DIR)/test_policy
|
|||||||
TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
|
TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
|
||||||
TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
|
TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
|
||||||
TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope
|
TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope
|
||||||
|
TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth
|
||||||
EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
|
EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
|
||||||
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client
|
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client
|
||||||
|
|
||||||
.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope examples test-client clean
|
.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean
|
||||||
|
|
||||||
all: dev
|
all: dev
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ static-arm64:
|
|||||||
chmod +x ./build_static.sh
|
chmod +x ./build_static.sh
|
||||||
./build_static.sh --arch arm64
|
./build_static.sh --arch arm64
|
||||||
|
|
||||||
test: lib test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-client
|
test: lib test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client
|
||||||
|
|
||||||
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
||||||
./$(TEST_INTEGRATION_TARGET)
|
./$(TEST_INTEGRATION_TARGET)
|
||||||
@@ -94,6 +95,9 @@ test-socket-name: $(TEST_SOCKET_NAME_TARGET)
|
|||||||
test-auth-envelope: $(TEST_AUTH_ENVELOPE_TARGET)
|
test-auth-envelope: $(TEST_AUTH_ENVELOPE_TARGET)
|
||||||
./$(TEST_AUTH_ENVELOPE_TARGET)
|
./$(TEST_AUTH_ENVELOPE_TARGET)
|
||||||
|
|
||||||
|
test-qrexec-auth: $(TEST_QREXEC_AUTH_TARGET) $(TARGET_DEV)
|
||||||
|
./$(TEST_QREXEC_AUTH_TARGET)
|
||||||
|
|
||||||
test-client: examples
|
test-client: examples
|
||||||
|
|
||||||
examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET)
|
examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET)
|
||||||
@@ -134,6 +138,10 @@ $(TEST_AUTH_ENVELOPE_TARGET): $(TEST_DIR)/test_auth_envelope.c $(SRC_DIR)/auth_e
|
|||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_auth_envelope.c $(SRC_DIR)/auth_envelope.c -o $(TEST_AUTH_ENVELOPE_TARGET) $(LDFLAGS)
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_auth_envelope.c $(SRC_DIR)/auth_envelope.c -o $(TEST_AUTH_ENVELOPE_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
|
$(TEST_QREXEC_AUTH_TARGET): $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envelope.c
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envelope.c -o $(TEST_QREXEC_AUTH_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
$(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c
|
$(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(CC) $(CFLAGS) -I$(CLIENT_DIR) $(EXAMPLES_DIR)/get_public_key_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS)
|
$(CC) $(CFLAGS) -I$(CLIENT_DIR) $(EXAMPLES_DIR)/get_public_key_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS)
|
||||||
|
|||||||
@@ -61,7 +61,17 @@ In server mode:
|
|||||||
|
|
||||||
This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX).
|
This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX).
|
||||||
|
|
||||||
### 2.4 TCP mode authentication (required)
|
### 2.4 qrexec authentication posture (`--auth`)
|
||||||
|
|
||||||
|
qrexec now supports configurable auth-envelope handling:
|
||||||
|
|
||||||
|
- `--listen qrexec --auth off` (default): legacy behavior. Requests are authorized as `qubes:<vm-name>` only.
|
||||||
|
- `--listen qrexec --auth optional`: if the request includes an `auth` field, it is verified using the same auth envelope rules as TCP. On success, caller identity is upgraded to `qubes:<vm-name>+pubkey:<hex>`.
|
||||||
|
- `--listen qrexec --auth required`: every request must carry a valid `auth` envelope, and caller identity is `qubes:<vm-name>+pubkey:<hex>`.
|
||||||
|
|
||||||
|
When `--auth optional` is used, a malformed or invalid `auth` object is rejected with auth-layer errors (`2010..2017`) rather than silently falling back to `qubes:<vm-name>`.
|
||||||
|
|
||||||
|
### 2.5 TCP mode authentication (required)
|
||||||
|
|
||||||
For TCP transport, requests MUST include an `auth` object containing a signed Nostr-style event envelope.
|
For TCP transport, requests MUST include an `auth` object containing a signed Nostr-style event envelope.
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ Still missing for complete Qubes integration:
|
|||||||
- qrexec policy in dom0 remains first enforcement boundary.
|
- qrexec policy in dom0 remains first enforcement boundary.
|
||||||
- `n_signer` policy/approval remains second boundary.
|
- `n_signer` policy/approval remains second boundary.
|
||||||
|
|
||||||
|
### 3.4 Optional per-program identity inside one qube (`--auth optional`)
|
||||||
|
|
||||||
|
qrexec now supports an optional auth-envelope layer:
|
||||||
|
|
||||||
|
- `nsigner --listen qrexec --auth off` (default): identity remains `qubes:<source-vm>`.
|
||||||
|
- `nsigner --listen qrexec --auth optional`: if a caller includes a valid auth envelope, identity becomes `qubes:<source-vm>+pubkey:<hex>`.
|
||||||
|
- `nsigner --listen qrexec --auth required`: all requests must carry valid envelopes and identity is always `qubes:<source-vm>+pubkey:<hex>`.
|
||||||
|
|
||||||
|
This enables per-program approvals inside a single caller qube while preserving existing behavior for legacy callers that do not emit auth envelopes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Required implementation tasks
|
## 4. Required implementation tasks
|
||||||
@@ -102,6 +112,12 @@ In qrexec mode, default prompt behavior is now:
|
|||||||
|
|
||||||
This replaces the previous permissive `PROMPT_NEVER` temporary setting.
|
This replaces the previous permissive `PROMPT_NEVER` temporary setting.
|
||||||
|
|
||||||
|
If qrexec auth envelopes are enabled (`--auth optional` or `--auth required`), approved callers can also be recorded as composite identities:
|
||||||
|
|
||||||
|
- `qubes:<vm>+pubkey:<hex>`
|
||||||
|
|
||||||
|
This gives independent approval rows for distinct programs running in the same source qube.
|
||||||
|
|
||||||
## 4.4 Client helper examples ✅ Implemented
|
## 4.4 Client helper examples ✅ Implemented
|
||||||
|
|
||||||
Added:
|
Added:
|
||||||
@@ -135,6 +151,7 @@ Includes:
|
|||||||
- from caller qube, invoke test request (`get_public_key`)
|
- from caller qube, invoke test request (`get_public_key`)
|
||||||
- confirm signer qube receives request
|
- confirm signer qube receives request
|
||||||
- confirm activity log displays `qubes:<source-vm>` caller prefix
|
- confirm activity log displays `qubes:<source-vm>` caller prefix
|
||||||
|
- with `--auth optional`, confirm envelope-enabled clients show `qubes:<source-vm>+pubkey:<hex>`
|
||||||
- validate deny behavior from unauthorized qube
|
- validate deny behavior from unauthorized qube
|
||||||
|
|
||||||
## 5.4 Failure checks
|
## 5.4 Failure checks
|
||||||
|
|||||||
@@ -94,12 +94,13 @@ An identity is a tagged caller record built from the transport. Different transp
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `unix_peer` | `uid`, `pid` from `SO_PEERCRED` on an abstract Unix socket. | Kernel — the client cannot forge it. |
|
| `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. |
|
| `qubes` | source qube name from `QREXEC_REMOTE_DOMAIN`. | Qubes RPC framework. |
|
||||||
|
| `qubes_pubkey` | source qube + verified auth-envelope pubkey (`qubes:<vm>+pubkey:<hex>`). | Qubes RPC framework + application-layer signature verification. |
|
||||||
| `tcp_local` | Loopback address of caller. | TCP socket peer address — opt-in transport. |
|
| `tcp_local` | Loopback address of caller. | TCP socket peer address — opt-in transport. |
|
||||||
| `tcp_remote` | Address plus an authenticated pubkey (planned). | Application-layer authentication. |
|
| `tcp_remote` | Verified auth-envelope pubkey (`pubkey:<hex>`). | Application-layer authentication. |
|
||||||
| `fips` | Peer npub from a NIP-46 style flow (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. |
|
| `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.
|
Identity quality varies. `unix_peer` and `qubes` are vouched for by the kernel or hypervisor — strong. `qubes_pubkey` is stronger still for multi-program qubes because it combines hypervisor-vouched source-qube identity with a per-program cryptographic identity. `tcp_local` and `tcp_remote` rely on transport/app-layer checks and are only as strong as deployment choices.
|
||||||
|
|
||||||
### Index (or path) — *which key*
|
### Index (or path) — *which key*
|
||||||
|
|
||||||
@@ -423,7 +424,8 @@ Different transports give different identity quality. The wire format (4-byte le
|
|||||||
### 11.2 Stdio / qrexec mode
|
### 11.2 Stdio / qrexec mode
|
||||||
|
|
||||||
- One framed request, one framed response, then exit.
|
- One framed request, one framed response, then exit.
|
||||||
- Identity: `qubes:<vm-name>` from `QREXEC_REMOTE_DOMAIN`, vouched for by Qubes.
|
- Identity defaults to `qubes:<vm-name>` from `QREXEC_REMOTE_DOMAIN`, vouched for by Qubes.
|
||||||
|
- With `--listen qrexec --auth optional|required`, requests carrying a valid auth envelope are upgraded to `qubes:<vm-name>+pubkey:<hex>`.
|
||||||
- Risk surface: only what Qubes RPC policy permits. See [`packaging/qubes/`](../packaging/qubes/).
|
- Risk surface: only what Qubes RPC policy permits. See [`packaging/qubes/`](../packaging/qubes/).
|
||||||
|
|
||||||
### 11.3 TCP (advanced / opt-in)
|
### 11.3 TCP (advanced / opt-in)
|
||||||
|
|||||||
271
plans/auth_envelope_other_transports.md
Normal file
271
plans/auth_envelope_other_transports.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# Plan: Where else does the Schnorr auth envelope earn its keep?
|
||||||
|
|
||||||
|
Status: **draft v1 — design exploration, for review**
|
||||||
|
|
||||||
|
> **Origin.** [`plans/caller_token_identity.md`](caller_token_identity.md) introduced a per-request, BIP-340-Schnorr-signed *auth envelope* (Nostr kind 27235) that gives the TCP transport an application-layer identity. The TCP plan was deliberate about scope: AF_UNIX and qrexec keep their kernel/hypervisor identities, stdio inherits them, and the envelope is gated to TCP only. This document asks the next obvious question — **for which other transports is the same envelope actually useful, and where is it noise or worse?** — and proposes a small, conservative extension.
|
||||||
|
|
||||||
|
The thesis up front: **the envelope is a tool for binding a stable program identity to a stream that the OS/hypervisor cannot itself attribute to a program**. Anywhere the OS already names the caller better than a pubkey could, the envelope adds attack surface for no gain. Anywhere the OS names a *machine* (uid, qube name) but cannot tell us *which program inside that machine*, the envelope earns its keep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The framework: a 2x2 of what the substrate vouches for
|
||||||
|
|
||||||
|
Every transport `n_signer` supports today, or might support tomorrow, can be classified along two axes:
|
||||||
|
|
||||||
|
| | Substrate names a **principal** (uid, qube, account) | Substrate does **not** name a principal |
|
||||||
|
|---|---|---|
|
||||||
|
| **Substrate names a specific program** | A. Both. Strongest. | (impossible — naming a program implies naming its principal) |
|
||||||
|
| **Substrate names only a machine/account, not a program** | B. Principal-only. The "which program" gap. | C. Anonymous pipe. |
|
||||||
|
|
||||||
|
- **A** is hypothetical for our context. Not even AF_UNIX gives us this — `SO_PEERCRED.pid` is UX, not identity, because PIDs are reusable and the kernel does not bind PID to executable.
|
||||||
|
- **B** is where AF_UNIX, qrexec, and stdio-via-qrexec actually live today. We learn `uid:1000` or `qubes:foo`, but two different binaries running as the same uid in the same qube look identical.
|
||||||
|
- **C** is plain TCP, anonymous pipes, USB serial, Bluetooth — substrates that say only "some bytes arrived from somewhere."
|
||||||
|
|
||||||
|
**The auth envelope's job is to upgrade B → A and C → A** by letting the program-with-a-keypair speak for itself, on top of whatever the substrate already provides. It does *not* replace substrate identity; it composes with it.
|
||||||
|
|
||||||
|
This re-framing is the whole answer to the user's question. Every transport below is just a worked example of which cell it lives in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Per-transport verdict
|
||||||
|
|
||||||
|
| Transport | Substrate identity | Cell | Envelope verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AF_UNIX (current) | `SO_PEERCRED.uid` (kernel-attested) | B | **Optional, off by default.** Adds program-level identity inside one uid; useful for multi-program desktops, redundant for single-user vault qubes. |
|
||||||
|
| qrexec (current) | `QREXEC_REMOTE_DOMAIN` (Xen-attested) | B | **Optional, off by default, but the most interesting case.** Solves the "many tools in one caller qube share one approval row" problem and the cross-qube state continuity question. See §3. |
|
||||||
|
| stdio (current, interactive) | inherited uid of launching shell | B | **Off.** The user is at the terminal; substrate identity is the human, and the envelope cannot improve on that. |
|
||||||
|
| stdio (current, qrexec-launched) | `QREXEC_REMOTE_DOMAIN` from env | B | **Same as qrexec row.** stdio over qrexec is qrexec by another name. |
|
||||||
|
| stdio (anonymous pipe, e.g. systemd socket-activated, or shelled out from a daemon) | none | C | **Required if used in production.** No principal at all otherwise. See §4. |
|
||||||
|
| TCP (implemented v2) | peer addr (forgeable beyond loopback) | C | **Required, on every request.** Already shipped; this plan does not modify it. |
|
||||||
|
| FIPS / serial-over-USB (planned) | device path; no principal | C | **Required.** Same reasoning as TCP — substrate provides confidentiality of the wire (in FIPS's case) but no caller identity. |
|
||||||
|
| Bluetooth / NFC (hypothetical) | MAC address (forgeable) | C | **Required.** |
|
||||||
|
|
||||||
|
The verdict column collapses to three modes — **off**, **optional** (accepted if present, ignored if absent), **required** (rejected if absent) — selectable per listener.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The qrexec story (the user's actual question)
|
||||||
|
|
||||||
|
Qubes is the case where the envelope buys the most, and where it most clearly solves a problem the user has named: *"keeping state between qubes is difficult."* Two distinct sub-problems hide under that phrase. The envelope addresses one of them cleanly and the other partially.
|
||||||
|
|
||||||
|
### 3.1 Sub-problem 1: many programs, one approval row
|
||||||
|
|
||||||
|
A typical caller qube — say `personal` — runs nostr_terminal, a browser extension, a CLI scratch tool, and a backup script. Today they all show up to the signer as `qubes:personal`. The user approves once, and from then on **every program in that qube is a signer for that nostr_index**. There is no way to express "the browser extension may sign kind-1 events but the CLI scratch tool may not." Approval granularity is one-per-qube, which is too coarse for any caller qube that holds more than one tool.
|
||||||
|
|
||||||
|
With the envelope optional-on for qrexec:
|
||||||
|
|
||||||
|
- nostr_terminal in `personal` signs envelopes with its caller pubkey `Pk_term`. Browser extension signs with `Pk_ext`. The signer records *two distinct caller_ids* — `qubes:personal+pubkey:<Pk_term>` and `qubes:personal+pubkey:<Pk_ext>` (composite form, see §6) — and prompts once per program.
|
||||||
|
- Programs that send no envelope keep the legacy `qubes:personal` identity and the legacy approval row. Backwards compatible.
|
||||||
|
- The user gets per-program revocation: removing the approval for `Pk_ext` does not affect nostr_terminal, even though both run inside `personal`.
|
||||||
|
|
||||||
|
This is the same connection-churn fix the TCP plan delivered, applied to a different ambiguity: not "same network address, different process" but "same qube, different program." The fix is identical because the underlying gap is identical — the substrate names something coarser than the unit we want to authorize.
|
||||||
|
|
||||||
|
### 3.2 Sub-problem 2: state continuity between qubes
|
||||||
|
|
||||||
|
In Qubes, programs that want shared identity across qubes — say a tool the user runs in both `personal` and `work`, or a tool installed in a disposable that is recreated each launch — cannot use any of the usual "save a token to disk" tricks because the qubes are separate state domains. The qrexec policy can route the call but cannot share secrets.
|
||||||
|
|
||||||
|
The envelope does not magically share state across qubes — but **it makes the question well-formed**. Two patterns become available:
|
||||||
|
|
||||||
|
- **Per-qube installation, distinct identity.** Tool `T` installed in `personal` generates its own keypair on first run, stored in that qube. Tool `T` in `work` generates a different keypair. The signer sees two callers; the user approves each. This is exactly what the security model wants — the qubes are *different trust domains*, so they should have different identities, full stop.
|
||||||
|
- **Per-tool identity, shared across qubes via deliberate seed copy.** A power user who genuinely wants "the same identity for nostr_terminal in `personal` and `work`" can copy nostr_terminal's seed (or derived caller key) between the two qubes themselves, using Qubes' usual file-copy primitives. The signer then sees one caller identity from two qubes. Whether that is a good idea is a user-policy decision; the protocol simply tolerates it.
|
||||||
|
|
||||||
|
The disposable-VM case is the most interesting. A disposable that re-rolls its caller keypair on every boot will re-prompt the signer every boot — which is correct behavior. *"This is a fresh untrusted environment"* is exactly what a disposable is. If the user wants disposable-but-recognized, they are asking for the per-tool seed pattern above and have to accept the tradeoff that the seed material outlives the disposable.
|
||||||
|
|
||||||
|
The envelope does not solve cross-qube state by itself. What it does is **give the user a stable, signer-visible name to make their state-management decisions against** — instead of forcing every cross-qube identity question through the qube-name primitive, which is too coarse to express the question.
|
||||||
|
|
||||||
|
### 3.3 Why "optional, off by default" rather than "required"
|
||||||
|
|
||||||
|
Required envelopes for qrexec would break every existing caller (shell scripts, the existing client examples in [`documents/qubes_client_examples.md`](../documents/qubes_client_examples.md)) and force every Qubes caller to grow Schnorr-signing capability before it can talk to the signer. That is too high a cost for a transport whose substrate identity is already strong.
|
||||||
|
|
||||||
|
Optional means:
|
||||||
|
|
||||||
|
- Old callers keep working unchanged. Their `caller_id` stays `qubes:<vm>`.
|
||||||
|
- New callers that *want* per-program approvals send envelopes and get `qubes:<vm>+pubkey:<hex>`.
|
||||||
|
- Operators who want to enforce envelope-on-qrexec for a hardened deployment can flip a flag (`--qrexec-require-auth`).
|
||||||
|
|
||||||
|
The default-off posture matches the TCP plan's principle: *give the weakest substrate the strongest application-layer identity.* qrexec is not the weakest; it is the second-strongest. It earns the upgrade only where the operator opts in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The stdio story
|
||||||
|
|
||||||
|
stdio mode in `n_signer` has three real-world spawning patterns:
|
||||||
|
|
||||||
|
1. **Interactive: a user runs `nsigner --listen stdio` in a terminal and pipes a request in.** Substrate identity is the user's own uid. The user is physically present at the prompt anyway. **Envelope adds nothing.** Off.
|
||||||
|
|
||||||
|
2. **qrexec service: stdio with `QREXEC_REMOTE_DOMAIN` set.** Already covered as the qrexec row in §2. Same verdict as qrexec.
|
||||||
|
|
||||||
|
3. **Spawned by another program over an anonymous pipe.** Daemon `D` execs `nsigner --listen stdio` and pipes JSON-RPC over the inherited fds. The signer sees only `STDIN_FILENO`; there is no `SO_PEERCRED` (no socket), no `QREXEC_REMOTE_DOMAIN`, no peer address. Today the signer falls back to its own uid as caller_id, which is to say *the signer is its own caller* — meaningless as identity. **This is cell C.** If anyone is using this pattern, the envelope is the only available identity primitive.
|
||||||
|
|
||||||
|
The third pattern is rare but not hypothetical. systemd socket-activated services, container init wrappers, and "supervisor that spawns one signer process per request" deployments all fit. Today they are silently insecure (any program in the same uid can pipe a request in and be `caller_id = uid:1000`, indistinguishable from any other). With envelope-required for anonymous-pipe stdio, the daemon must hold a keypair and identify itself, and the signer can record per-daemon approvals.
|
||||||
|
|
||||||
|
The detection rule is mechanical: if the listen mode is stdio AND `QREXEC_REMOTE_DOMAIN` is unset AND `SO_PEERCRED` is unavailable on stdin (it is not a socket), classify the stream as anonymous-pipe stdio and require the envelope. Two existing pieces of code already participate in this branch: [`server_get_caller`](../src/server.c:1181) for the QREXEC env check, and the listen-mode plumbing in [`src/main.c:1043`](../src/main.c:1043).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The AF_UNIX story (briefly)
|
||||||
|
|
||||||
|
AF_UNIX is the strongest substrate today and the one most likely to be misjudged. It is tempting to say "uid is enough." For single-user vault qubes it is. For shared workstations or for the per-program-revocation use case described in §3.1, it is not.
|
||||||
|
|
||||||
|
The right disposition for AF_UNIX is identical to qrexec: **optional envelope, off by default, on-flag to require**. Same composite caller_id form. The only difference is that the prompt UX should still surface `pid=N (exe=/path)` from `SO_PEERCRED` as decoration alongside the npub, because on a local machine the user can `ps -p N` to verify in a way they cannot for a remote pubkey.
|
||||||
|
|
||||||
|
This was [`plans/caller_token_identity.md`](caller_token_identity.md) §13 open question 5 ("should AF_UNIX accept an envelope as additional identity?") with the v2 default of "no." This document **revisits that default** and proposes "yes, but optional." See §6 and §8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Composite caller_id form
|
||||||
|
|
||||||
|
When envelope is **optional and present** on a non-TCP transport, the caller_id needs to combine substrate identity and pubkey identity. The proposed form:
|
||||||
|
|
||||||
|
```
|
||||||
|
qubes:personal+pubkey:abc123…def
|
||||||
|
uid:1000+pubkey:abc123…def
|
||||||
|
anon-stdio+pubkey:abc123…def ; for case §4.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
|
||||||
|
- **Lexicographic prefix matches substrate-only callers.** A policy entry for `qubes:personal` does **not** match `qubes:personal+pubkey:…` — they are distinct strings, distinct rows. This is intentional: opting into envelope auth means opting into a new, separate identity, not "same identity but with extra proof."
|
||||||
|
- **The plus character is illegal in every existing caller_id form** (uids are digits, pubkeys are hex, qube names are alphanumeric+hyphen). Using `+` as separator avoids ambiguity.
|
||||||
|
- **Width.** `caller_id` was widened to 80 bytes in v2 of the TCP plan ([`plans/caller_token_identity.md:181`](caller_token_identity.md:181)). The composite form needs `len("qubes:") + 64 + len("+pubkey:") + 64 + 1` ≈ 144 bytes. Bumps to 160 to round.
|
||||||
|
|
||||||
|
Approvals attach to the composite string. A user who approves the composite form is approving "this program *running in* this qube," not "this program anywhere" and not "this qube's anything." That is the correct semantics for the per-program-revocation use case.
|
||||||
|
|
||||||
|
For TCP — where substrate identity does not exist — the form remains the bare `pubkey:<hex>` from v2. No change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Wire-protocol changes (small)
|
||||||
|
|
||||||
|
The existing envelope already carries everything needed; only the verification gate changes per transport.
|
||||||
|
|
||||||
|
### 7.1 Per-listener auth posture
|
||||||
|
|
||||||
|
Three settings, one per listener:
|
||||||
|
|
||||||
|
| Posture | Behavior on receipt |
|
||||||
|
|---|---|
|
||||||
|
| `off` | `auth` field, if present, is **ignored**. caller_id is substrate-only. Default for AF_UNIX, qrexec, interactive stdio. |
|
||||||
|
| `optional` | If `auth` is present and verifies → composite caller_id. If `auth` is absent → substrate-only caller_id. If `auth` is present and fails verification → reject. Operator opt-in for AF_UNIX/qrexec/stdio. |
|
||||||
|
| `required` | `auth` must be present and verify. Default for TCP, FIPS-serial, anonymous-pipe stdio. |
|
||||||
|
|
||||||
|
CLI plumbing extends what the TCP plan already added:
|
||||||
|
|
||||||
|
```
|
||||||
|
--listen unix --auth off # default
|
||||||
|
--listen unix --auth optional
|
||||||
|
--listen qrexec --auth optional
|
||||||
|
--listen stdio --auth required # for anon-pipe deployments
|
||||||
|
--listen tcp:... --auth required # only legal value for tcp; kept explicit
|
||||||
|
```
|
||||||
|
|
||||||
|
The auth-skew-seconds and nonce cache are listener-context state; they already exist in `server_ctx_t` from v2 and are reused unmodified.
|
||||||
|
|
||||||
|
### 7.2 The envelope itself: zero changes
|
||||||
|
|
||||||
|
Same kind 27235, same three required tags, same JCS body hash, same Schnorr verification primitive [`nostr_verify_event_signature`](../resources/nostr_core_lib/nostr_core/nip001.h:20). A client that signs envelopes for TCP today can sign them for qrexec tomorrow with no code change. This is the single most important property of the proposal — it preserves the v2 envelope as a portable, transport-independent artifact.
|
||||||
|
|
||||||
|
### 7.3 Replay cache scoping
|
||||||
|
|
||||||
|
One subtle question: is the nonce cache per-listener or process-global? Process-global is simpler and strictly stricter (an envelope replayed across two listeners is also rejected). Per-listener is more permissive but has no real attack-model justification. **Default: process-global**, unchanged from v2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Decision matrix and recommended defaults
|
||||||
|
|
||||||
|
| Transport | Default posture | Operator can flip to | Rationale |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AF_UNIX | off | optional, required | uid is strong; envelope is for power users who need per-program granularity. |
|
||||||
|
| qrexec | off | optional, required | qube is strong; envelope solves §3.1 for users who hit it. |
|
||||||
|
| stdio (interactive) | off | (locked off) | Envelope cannot improve on a human at the terminal. |
|
||||||
|
| stdio (qrexec-launched) | inherits qrexec posture | (inherits) | Same substrate. |
|
||||||
|
| stdio (anonymous pipe) | required | (locked required) | No substrate identity exists; refusing is the only safe default. |
|
||||||
|
| TCP | required | (locked required) | Already v2 default. Unchanged. |
|
||||||
|
| FIPS / USB-serial (planned) | required | (locked required) | Same as TCP — wire identity is fictional. |
|
||||||
|
|
||||||
|
"Locked" rows reflect transports where flipping the posture would re-introduce the gap the envelope was designed to close, and the CLI rejects the change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation order
|
||||||
|
|
||||||
|
Each step is independently mergeable, gated behind the per-listener `--auth` flag default of `off` for the new transports so existing callers see no behavior change.
|
||||||
|
|
||||||
|
1. **Refactor: lift the v2 auth gate out of the TCP-specific branch in [`server_handle_one`](../src/server.c:1192) into a transport-agnostic helper** parameterized by posture. No semantic change yet; TCP listener still passes `required`.
|
||||||
|
2. **Add the `--auth {off|optional|required}` CLI flag** with per-listener storage on `server_ctx_t`. Default off for unix/qrexec, required for tcp, off-locked for interactive stdio, required-locked for anonymous-pipe stdio.
|
||||||
|
3. **Add anonymous-pipe-stdio detection.** In [`server_get_caller`](../src/server.c:1181), when listen mode is stdio and `QREXEC_REMOTE_DOMAIN` is unset, probe `SO_PEERCRED` on stdin; on failure mark the stream as anonymous-pipe and require auth.
|
||||||
|
4. **Composite caller_id format** in [`caller_identity_t`](../src/server.c:404). Widen to 160. Add a small helper `caller_id_compose(buf, sz, substrate_kind, substrate_value, pubkey_hex)`.
|
||||||
|
5. **`--preapprove` extension.** Accept the composite form (`caller=qubes:personal+pubkey:<hex>`, `caller=uid:1000+pubkey:<hex>`). Existing forms stay valid.
|
||||||
|
6. **Tests.**
|
||||||
|
- Optional-mode AF_UNIX accepting both with-envelope and without-envelope requests, producing two distinct caller_ids.
|
||||||
|
- Optional-mode qrexec with two distinct pubkeys from one source qube → two prompts, two approvals, independent revocation.
|
||||||
|
- Anonymous-pipe-stdio rejects no-envelope requests with code 2014.
|
||||||
|
- TCP behavior unchanged (regression check on v2).
|
||||||
|
7. **Documentation.**
|
||||||
|
- [`documents/SECURITY.md`](../documents/SECURITY.md) §3 identity table: add the composite kinds; clarify that envelope is now a portable identity layer, not a TCP-only feature.
|
||||||
|
- [`documents/QUBES_OS.md`](../documents/QUBES_OS.md): add a §3.4 on per-program identity within a qube and the disposable-VM tradeoff.
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md): note that any client may attach an envelope on any transport; the signer's posture decides whether it is honored or required.
|
||||||
|
|
||||||
|
## 10. What this plan deliberately does not do
|
||||||
|
|
||||||
|
- **Does not change TCP behavior.** The v2 contract on TCP — required envelope, every request — is preserved exactly.
|
||||||
|
- **Does not introduce a new envelope format, kind, or canonicalization.** Reuses kind 27235 and the JCS body-hash rule from v2 verbatim.
|
||||||
|
- **Does not propose persistent state** anywhere. Approvals stay in RAM; no caller registry on disk; the §1 "crash equals total wipe" property of [`documents/SECURITY.md:19`](../documents/SECURITY.md:19) is unchanged.
|
||||||
|
- **Does not invent an answer to cross-qube state continuity.** It articulates the question and gives the user a primitive (a stable pubkey-named caller) against which to make their own state decisions.
|
||||||
|
- **Does not add transport encryption** for any substrate. Confidentiality is the substrate's job (FIPS, qrexec, AF_UNIX with abstract namespace, loopback). The envelope is auth, not encryption.
|
||||||
|
|
||||||
|
## 11. Open questions for review
|
||||||
|
|
||||||
|
1. **Is the composite caller_id form (`substrate+pubkey:...`) the right disposition, or should the envelope, when present, *override* substrate identity entirely (replacing rather than composing)?** The override form is simpler — one identity, not two — but it discards information the user might want (the npub is verified; the qube name is hypervisor-attested; both data points are useful at the prompt). Default: compose.
|
||||||
|
2. **Should anonymous-pipe stdio be required or rejected outright?** A reasonable case can be made that `nsigner --listen stdio` over a non-tty, non-socket fd is just *misuse* and should refuse to start rather than silently demand envelopes. Default: require envelope; allow operator to disable the listen mode entirely if undesired.
|
||||||
|
3. **Should the `--auth optional` setting on AF_UNIX/qrexec be a per-listener flag (today's proposal) or per-method (e.g. envelope required for `sign_event` but not for `get_public_key`)?** Per-method adds policy surface for limited gain; substrate identity is already the per-method gate via the existing role table. Default: per-listener only.
|
||||||
|
4. **Does the v2 nonce cache size of 1024 still suffice when up to four listeners may share it?** Yes — the bound is "envelopes per skew window," not "listeners." Confirmed sufficient. No change.
|
||||||
|
5. **Should `qubes:<vm>+pubkey:<hex>` and `pubkey:<hex>` (TCP) for the same pubkey be considered the same caller for `--preapprove` convenience?** No. They are different identities by construction (different substrates, different threat models). An operator who wants both pre-approved writes both lines. Default: keep them distinct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Mermaid: revised per-transport decision tree
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Request arrives on a listener] --> B{listener auth posture}
|
||||||
|
B -- off --> C[ignore any auth field]
|
||||||
|
C --> D[caller_id = substrate-only]
|
||||||
|
B -- optional --> E{auth field present?}
|
||||||
|
E -- no --> D
|
||||||
|
E -- yes --> F[verify envelope per v2 rules]
|
||||||
|
F -- fail --> G[reject 2010-2017]
|
||||||
|
F -- ok --> H[caller_id = substrate + pubkey:hex]
|
||||||
|
B -- required --> I{auth field present?}
|
||||||
|
I -- no --> J[reject 2014 auth_envelope_required]
|
||||||
|
I -- yes --> F
|
||||||
|
D --> K[normal policy_check]
|
||||||
|
H --> K
|
||||||
|
K --> L{verdict}
|
||||||
|
L -- ALLOW --> M[dispatch RPC]
|
||||||
|
L -- DENY --> N[2001 policy_denied]
|
||||||
|
L -- PROMPT --> O[approve / save / register caller_id]
|
||||||
|
O --> M
|
||||||
|
```
|
||||||
|
|
||||||
|
## Appendix B — Worked example: per-program identity inside a qube
|
||||||
|
|
||||||
|
Caller qube `personal` runs two tools:
|
||||||
|
|
||||||
|
- nostr_terminal, holding caller key `Pk_term`.
|
||||||
|
- A custom shell script, no keypair.
|
||||||
|
|
||||||
|
Operator starts the signer with:
|
||||||
|
|
||||||
|
```
|
||||||
|
nsigner --listen qrexec --auth optional \
|
||||||
|
--preapprove caller=qubes:personal+pubkey:Pk_term,role=main
|
||||||
|
```
|
||||||
|
|
||||||
|
First request from nostr_terminal carries an envelope signed by `Pk_term`. Signer verifies, builds caller_id `qubes:personal+pubkey:Pk_term`, hits the pre-approval, signs without prompting.
|
||||||
|
|
||||||
|
First request from the shell script carries no envelope. Signer skips verification, builds caller_id `qubes:personal`, finds no approval, prompts the user. User can approve `[a]` for the shell script independently of nostr_terminal.
|
||||||
|
|
||||||
|
Later, the user wants to revoke the shell script's access (a future TUI hotkey). They remove the `qubes:personal` row. nostr_terminal continues to work because its row `qubes:personal+pubkey:Pk_term` is untouched. **Per-program revocation, achieved without any change to qrexec policy or the qube boundary.**
|
||||||
|
|
||||||
|
This is the concrete answer to the user's "is this useful for other transports" question — yes, in qrexec specifically, where it converts qube-granularity approvals into program-granularity approvals while leaving the qube boundary itself unchanged.
|
||||||
277
plans/docker_runtime_image.md
Normal file
277
plans/docker_runtime_image.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# Plan: Runtime Docker image for n_signer with TCP + auth-envelope onboarding
|
||||||
|
|
||||||
|
Status: **draft v1 — for review**
|
||||||
|
|
||||||
|
This plan documents a path for new users to download and run [`n_signer`](../README.md) inside a Docker container with minimal friction, while preserving the project's existing security posture (zero filesystem footprint at runtime, mlock'd in-memory secrets, deny-by-default approvals, crash-equals-total-wipe). It complements — and does not replace — the native binary distribution.
|
||||||
|
|
||||||
|
The companion work breakdown is tracked as todos in this task; this document captures the rationale and architectural decisions behind those todos.
|
||||||
|
|
||||||
|
Related plans and references:
|
||||||
|
|
||||||
|
- [`plans/caller_token_identity.md`](caller_token_identity.md) — the auth envelope spec that makes TCP a first-class authenticated transport.
|
||||||
|
- [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md) — deferred relay-based transport that would offer a Docker-friendly future alternative.
|
||||||
|
- [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) — current build-only Dockerfile that this plan extends.
|
||||||
|
- [`build_static.sh`](../build_static.sh) — host-side wrapper around the build image.
|
||||||
|
- [`increment_and_push.sh`](../increment_and_push.sh) — existing release flow that this plan extends.
|
||||||
|
- [`src/server.c`](../src/server.c) — current transport implementation (`unix`, `stdio`, `qrexec`, `tcp:HOST:PORT`).
|
||||||
|
- [`src/auth_envelope.c`](../src/auth_envelope.c) — verifier of NIP-01-shaped `kind:27235` auth envelopes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this plan exists
|
||||||
|
|
||||||
|
Today a new user has three realistic on-ramps to `n_signer`:
|
||||||
|
|
||||||
|
1. Download the static binary from a release and run it natively.
|
||||||
|
2. Clone the repo and rebuild via [`build_static.sh`](../build_static.sh), which itself uses Docker as a build environment but produces a host binary.
|
||||||
|
3. Hand-roll a `Dockerfile` and `docker run` invocation around the static binary.
|
||||||
|
|
||||||
|
None of those produce a one-command `docker pull` + `docker run` experience. [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) ends with `FROM scratch` and copies the binary out — it is a build artifact, not a runnable image. This plan closes that gap.
|
||||||
|
|
||||||
|
The Docker path also gives users defense-in-depth that the native path cannot offer: PID-namespace isolation prevents same-user processes on the host from `ptrace`'ing the signer, mount-namespace isolation hides the binary, default seccomp shrinks the kernel attack surface, and `--cap-drop ALL` plus `--security-opt no-new-privileges` constrain a hypothetical compromise of the signer itself. None of this defends against host root, kernel exploits, or side channels — that boundary requires a VM-class isolation like the Qubes path described in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **One-command run.** A new user with Docker installed should be able to `docker pull` and `docker run` to get a working interactive `n_signer` TUI, with no source checkout and no manual binary placement.
|
||||||
|
2. **Hardened by default.** The recommended invocation in the documentation must include `--read-only`, `--cap-drop ALL`, `--security-opt no-new-privileges`, resource limits, and `127.0.0.1`-only port publishing. Users who copy-paste the docs land on the safer configuration without having to think.
|
||||||
|
3. **TCP is the supported transport for the image.** Out of the four current transports, TCP is the only one that crosses the container/host network-namespace boundary cleanly today, and it is the only one with a working caller-authentication story per [`plans/caller_token_identity.md`](caller_token_identity.md). The image's `ENTRYPOINT` reflects this.
|
||||||
|
4. **No new transport code in v1.** This plan uses transports that already exist in [`src/server.c`](../src/server.c). Adding a `unix-path` transport variant for bind-mounted UNIX sockets is a separate plan that this one explicitly does not depend on.
|
||||||
|
5. **Preserve the runtime invariants.** Zero filesystem footprint at runtime, mlock'd secrets, crash-equals-total-wipe, and human-attended TUI all carry over. The image runs `--read-only`; no state files; SIGTERM from `docker stop` triggers the same wipe path as Ctrl-C in a terminal.
|
||||||
|
6. **Verifiable provenance.** Pulled images must be cryptographically verifiable to a release tag, with an SBOM and vulnerability scan attached as release assets so users can inspect what they are running.
|
||||||
|
7. **No regression for the native path.** The static-binary distribution and the `build_static.sh` build flow continue to work unchanged. The Docker image is an *additional* on-ramp, not a replacement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- **No multi-user, long-running daemon mode.** The TUI-attended, foreground-only design from [`README.md`](../README.md) is preserved. The image is run with `-it`, attached to a terminal. Background `docker run -d` is explicitly unsupported and will be documented as such.
|
||||||
|
- **No bundled clients.** The image contains only the signer binary. Clients (browser extension, native apps, examples) ship separately.
|
||||||
|
- **No persistent volume.** No mnemonic-on-disk, no policy-on-disk. The signer's "no runtime config or state files" rule from [`README.md`](../README.md) applies unchanged inside the container.
|
||||||
|
- **No network exposure beyond loopback by default.** The documented invocation publishes ports to `127.0.0.1` only. LAN/WAN exposure is out of scope and would require a separate threat model plus TLS termination, which is its own plan.
|
||||||
|
- **No NIP-46 relay transport.** That remains deferred per [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md).
|
||||||
|
- **No host-UNIX-socket bridging.** Bind-mounting a UNIX socket from the container to the host is appealing for UID-based identity but requires a `--listen unix-path:` mode that does not exist today. Tracked as a follow-up note (todo 20), out of scope here.
|
||||||
|
- **No image-internal privilege separation.** The signer runs as a single non-root user inside the container; further compartmentalization (separate uid for TUI vs network handling) is not in scope for v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Threat model delta vs. the native binary
|
||||||
|
|
||||||
|
The Docker image inherits the same in-process threat model as the native binary (mlock'd secrets, no disk artifacts, deny-by-default approvals). It adds:
|
||||||
|
|
||||||
|
| Boundary | Native terminal | Docker image (this plan) |
|
||||||
|
|---|---|---|
|
||||||
|
| Same-UID host process reads signer memory | Vulnerable to `ptrace`, `/proc/<pid>/mem` | Blocked by PID namespace |
|
||||||
|
| Compromised signer escalates on host | Has full UID privileges on host | Constrained by `--cap-drop ALL`, `--security-opt no-new-privileges`, default seccomp |
|
||||||
|
| Compromised signer writes persistent files | Can write anywhere UID has access | `--read-only` rootfs blocks writes; no volumes mounted |
|
||||||
|
| Core dump leaks key material | Possible if `ulimit` or systemd allows | `--read-only` plus no writable paths means no core file path |
|
||||||
|
| Host root reads signer memory | Unconstrained | Still unconstrained — `nsenter`, `docker exec`, host `/proc` all work. **Documented as a non-defense.** |
|
||||||
|
| Kernel exploit | Full host compromise | Full host compromise — shared kernel. **Documented as a non-defense.** |
|
||||||
|
| Network reachability | Bound by socket file perms or abstract namespace | Bound by Docker port publishing; `127.0.0.1` only by docs |
|
||||||
|
| TCP caller identity | Auth envelope (per [`plans/caller_token_identity.md`](caller_token_identity.md)) | Same — unchanged inside the container |
|
||||||
|
|
||||||
|
The user-facing security message in [`documents/SECURITY.md`](../documents/SECURITY.md) gets a small subsection (todo 12) calling out exactly this — what Docker buys you and what it does not — so users do not over-trust container isolation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Image architecture
|
||||||
|
|
||||||
|
### 5.1 Multi-stage Dockerfile
|
||||||
|
|
||||||
|
Extend [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) with a third stage that produces a runnable image. The existing two stages — `builder` and the `FROM scratch AS output` extraction stage — are preserved unchanged so [`build_static.sh`](../build_static.sh) keeps working.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[Stage: builder<br/>alpine:3.19 + toolchain] --> B[Stage: output<br/>FROM scratch<br/>used by build_static.sh]
|
||||||
|
A --> C[Stage: runtime<br/>FROM scratch<br/>ENTRYPOINT nsigner]
|
||||||
|
B -.-> D[host binary<br/>build/nsigner_static_*]
|
||||||
|
C -.-> E[OCI image<br/>nsigner:vX.Y.Z]
|
||||||
|
|
||||||
|
style C fill:#9f9
|
||||||
|
style E fill:#9f9
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime stage:
|
||||||
|
|
||||||
|
- Starts `FROM scratch` (no shell, no libc, no busybox — the binary is fully static MUSL).
|
||||||
|
- Copies in the stripped static binary as `/nsigner`.
|
||||||
|
- Sets `ENTRYPOINT ["/nsigner"]` and a default `CMD` of `["--listen", "tcp:0.0.0.0:7777"]`. Users can override CMD to pick another transport, but the ENTRYPOINT remains fixed at the binary.
|
||||||
|
- Declares OCI image labels (todo 3) — source URL, version, revision (git SHA), licenses, title, description — sourced from the existing `NSIGNER_VERSION` macro that [`increment_and_push.sh`](../increment_and_push.sh) already maintains.
|
||||||
|
- Runs as a non-root UID. Because `FROM scratch` has no `/etc/passwd`, this is done with a numeric UID via `USER 65532`. The static binary has no UID-name dependencies.
|
||||||
|
- `EXPOSE 7777` for documentation only (does not affect runtime).
|
||||||
|
|
||||||
|
### 5.2 Default `--listen` choice
|
||||||
|
|
||||||
|
The image defaults to `--listen tcp:0.0.0.0:7777`. Rationale (todo 15):
|
||||||
|
|
||||||
|
- It is the only existing transport that crosses the container netns boundary without `--network=host` (which would defeat the isolation we are running Docker for).
|
||||||
|
- It is the only transport with a working caller-authentication story today, via the auth envelope verified in [`src/auth_envelope.c`](../src/auth_envelope.c) and required for TCP per [`src/server.c`](../src/server.c) lines around 1313.
|
||||||
|
- `0.0.0.0` inside the container is safe **only** when the host publishes to `127.0.0.1`. The documentation makes this binding explicit and warns against `-p 7777:7777` (which publishes to all host interfaces). Todo 16 covers this in detail.
|
||||||
|
- Users who want stdio (one-shot) or qrexec (Qubes-specific) override `CMD` themselves; the default targets the dominant Docker-on-laptop case.
|
||||||
|
|
||||||
|
### 5.3 Image size and layers
|
||||||
|
|
||||||
|
Static MUSL binary, stripped, is roughly single-digit MB. The runtime image will be that plus the OCI metadata — well under 10 MB. `docker pull` is a few seconds on any reasonable connection. This is documented as a property, not just a coincidence, because it directly supports the "easy for new users" goal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Build, tag, and publish flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Dev[Developer<br/>./increment_and_push.sh -r] --> Bump[Bump version macros]
|
||||||
|
Bump --> Tag[git tag vX.Y.Z]
|
||||||
|
Tag --> Build[Build static binaries<br/>x86_64 + arm64]
|
||||||
|
Build --> Test[Run make test inside builder]
|
||||||
|
Test --> Img[Build runtime image<br/>buildx multiarch]
|
||||||
|
Img --> Sbom[Generate SBOM + vuln scan]
|
||||||
|
Sbom --> Sign[cosign sign image]
|
||||||
|
Sign --> Push[Push image and assets<br/>to registry]
|
||||||
|
Push --> Release[Create Gitea release<br/>with binary, sbom, image digest, verify cmd]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 Registry choice
|
||||||
|
|
||||||
|
Decision pending (todo 4). Three viable destinations:
|
||||||
|
|
||||||
|
1. **The existing Gitea registry at `git.laantungir.net`.** Aligns with the existing release flow that already uses `~/.gitea_token`. Self-hosted, under project control. Downside: less familiar to new users; they may need to log in to pull.
|
||||||
|
2. **GitHub Container Registry (GHCR).** Free, public, well-known, integrates with cosign keyless signing using GitHub OIDC. Requires a GitHub mirror.
|
||||||
|
3. **Docker Hub.** Most familiar to new users. Rate limits on anonymous pulls. Requires a Docker Hub account and token.
|
||||||
|
|
||||||
|
The plan recommends GHCR as primary because keyless cosign signing dramatically simplifies the trust story for new users (`cosign verify ghcr.io/...` with no key management). The Gitea registry is a viable alternative if the project prefers to stay self-hosted; in that case, key-based cosign signing replaces keyless. Either way, the choice is documented up front so users know where to pull from.
|
||||||
|
|
||||||
|
### 6.2 Multi-arch
|
||||||
|
|
||||||
|
Built via `docker buildx build --platform linux/amd64,linux/arm64`. The existing builder stage already handles both architectures (it picks `libnostr_core_x64.a` vs `libnostr_core_arm64.a` based on `uname -m`). A `Makefile` target (todo 2) wraps this so the developer command is `make docker-image-multiarch`.
|
||||||
|
|
||||||
|
### 6.3 Tags
|
||||||
|
|
||||||
|
- `vX.Y.Z` — immutable, matches git tag and binary release.
|
||||||
|
- `latest` — moves to newest stable release.
|
||||||
|
- `vX` and `vX.Y` — moving tags for users who want major/minor pinning.
|
||||||
|
|
||||||
|
All tags are signed; `latest` is signed at each release.
|
||||||
|
|
||||||
|
### 6.4 Provenance
|
||||||
|
|
||||||
|
Every release attaches:
|
||||||
|
|
||||||
|
- Image digest (`sha256:...`) in release notes.
|
||||||
|
- `cosign verify` command line as a copy-pasteable block.
|
||||||
|
- SBOM in SPDX or CycloneDX format (syft) as a release asset and as an OCI attestation attached to the image.
|
||||||
|
- Vulnerability scan output (grype or trivy) as a release asset.
|
||||||
|
|
||||||
|
This makes "is the image I pulled the one the project published?" a one-command answer. Todos 6 and 7 cover this.
|
||||||
|
|
||||||
|
### 6.5 Hooks into the existing release script
|
||||||
|
|
||||||
|
[`increment_and_push.sh`](../increment_and_push.sh) currently:
|
||||||
|
|
||||||
|
1. Bumps the version macros in `src/main.h` or `src/main.c`.
|
||||||
|
2. Tags the commit.
|
||||||
|
3. Builds x86_64 and arm64 static binaries.
|
||||||
|
4. Verifies `--version` of each binary matches the new tag.
|
||||||
|
5. Creates a Gitea release and uploads the binaries and a source tarball.
|
||||||
|
|
||||||
|
The image flow plugs in between steps 4 and 5 (todo 5):
|
||||||
|
|
||||||
|
5a. Build the runtime image for both architectures via buildx.
|
||||||
|
5b. Generate SBOM and vuln scan.
|
||||||
|
5c. Sign with cosign.
|
||||||
|
5d. Push the image and signatures to the chosen registry.
|
||||||
|
5e. Capture the resulting digest for the release notes.
|
||||||
|
|
||||||
|
Each of these steps is gated on a credential being present, mirroring the existing `~/.gitea_token` check. A developer without registry credentials still gets a successful binary release; the image steps emit warnings and skip. This keeps the local release flow usable in offline or partial-credential setups.
|
||||||
|
|
||||||
|
### 6.6 CI integration
|
||||||
|
|
||||||
|
A CI smoke test (todo 13) boots the just-built runtime image with the default `--listen tcp:0.0.0.0:7777`, drives a single `get_public_key` request from a host client over the published port, and asserts a clean shutdown. This catches regressions where the binary builds but cannot actually serve TCP from inside a container — for example, a future code change that defaults to abstract sockets in a way that breaks netns separation.
|
||||||
|
|
||||||
|
The existing `make test` suite is also run inside the builder stage (todo 14) before the artifact is extracted, so we have a tested-then-shipped guarantee on the binary that ends up in the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. New-user runbook (target end-state)
|
||||||
|
|
||||||
|
This is the experience the plan is optimizing for. After the work is done, the [`documents/DOCKER.md`](../documents/DOCKER.md) (todo 8) opens with effectively this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Verify the image you are about to run
|
||||||
|
cosign verify ghcr.io/laantungir/nsigner:v0.X.Y \
|
||||||
|
--certificate-identity-regexp 'https://github.com/laantungir/n_signer' \
|
||||||
|
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||||
|
|
||||||
|
# 2. Run with hardened defaults
|
||||||
|
docker run -it --rm \
|
||||||
|
--read-only \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--security-opt no-new-privileges \
|
||||||
|
--pids-limit 64 \
|
||||||
|
--memory 128m \
|
||||||
|
-p 127.0.0.1:7777:7777 \
|
||||||
|
ghcr.io/laantungir/nsigner:v0.X.Y
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI then comes up exactly as it does for the native binary — mnemonic prompt, role table, status display, approval prompts. From the user's perspective, the only differences are the longer command line and the fact that pressing Ctrl-C now also stops the container.
|
||||||
|
|
||||||
|
A second documentation block (todo 9) walks through writing a TCP client that builds a `kind:27235` auth envelope per [`plans/caller_token_identity.md`](caller_token_identity.md), signs it with BIP-340 Schnorr, and gets approved exactly once at the TUI. A worked example client is added to [`examples/`](../examples) (todo 10) so users have a copy-pasteable starting point that they can run on the host against the containerized signer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks and decisions
|
||||||
|
|
||||||
|
### 8.1 TCP-only is a real limitation
|
||||||
|
|
||||||
|
UID-based identity is preserved on AF_UNIX abstract sockets, which is a strong primitive in the native deployment. In Docker, that primitive is gone — abstract sockets do not cross netns. This plan accepts that and shifts to pubkey-based identity via the auth envelope. The trade-off is documented; users who specifically want UID-based identity stay on the native binary, or wait for the future `unix-path` transport plan (todo 20) that would let them bind-mount a pathname socket and recover `SO_PEERCRED.uid`. UID identity inside the container is also affected by Docker user-namespace remapping, which is another reason this plan does not pretend AF_UNIX inside a container is equivalent to AF_UNIX on the host.
|
||||||
|
|
||||||
|
### 8.2 `0.0.0.0` inside the container is unforgiving of misconfiguration
|
||||||
|
|
||||||
|
If a user copies the image's default `CMD` but writes `-p 7777:7777` (forgetting the `127.0.0.1:` prefix), they expose the signer to any host on the network. This is documented prominently (todo 16) with the explicit warning. We considered defaulting the CMD to `tcp:127.0.0.1:7777`, but that breaks Docker's port-publishing model — the host-side `127.0.0.1:7777` proxy needs to talk to the container's network interface, which is not loopback. So the safer-looking default is actually broken. The chosen approach (default `0.0.0.0` inside, document loopback publishing on the host) is the only one that works with Docker's networking semantics, but it requires that warning.
|
||||||
|
|
||||||
|
### 8.3 TUI under `docker run -it` may have edge cases
|
||||||
|
|
||||||
|
The signer's TUI uses raw terminal input for keystroke approvals, ANSI status redraws, and signal-driven shutdown. Most of this works under `docker run -it` but the plan reserves a verification step (todo 19) to confirm:
|
||||||
|
|
||||||
|
- `a` / `y` / `n` / `q` / `l` keystrokes deliver the same response as on a native terminal.
|
||||||
|
- ANSI redraws do not get garbled by Docker's TTY layer.
|
||||||
|
- `docker stop` (which sends SIGTERM, then SIGKILL after grace) triggers the same total-state-wipe path as Ctrl-C, including mnemonic zeroization.
|
||||||
|
- `docker kill` (SIGKILL only) leaves no recoverable state — by the existing crash-equals-wipe property, this should already hold, but the plan explicitly tests it.
|
||||||
|
|
||||||
|
Any deviation discovered is documented as a caveat in [`documents/DOCKER.md`](../documents/DOCKER.md), not silently worked around.
|
||||||
|
|
||||||
|
### 8.4 Host root is not blocked
|
||||||
|
|
||||||
|
The plan documents this as a non-defense. Users whose threat model includes "another user on the same host has root" should not assume Docker isolates against that. They should run on a system where they trust root, or move to a Qubes-style VM compartment as documented in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md). This is not a flaw in the plan; it is a property of containers that is easy to misunderstand and therefore worth stating up front.
|
||||||
|
|
||||||
|
### 8.5 Image sprawl across registries
|
||||||
|
|
||||||
|
If both Gitea and GHCR end up hosting the image, users may pull the wrong one or skip signature verification because "the other registry's image is the same anyway." The decision in §6.1 (one primary registry) is made specifically to avoid this. A secondary mirror is acceptable only if it carries the same digests and the same cosign signatures, and the documentation makes the mirror status explicit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Out of scope, explicitly
|
||||||
|
|
||||||
|
Listed so the plan's scope is clear:
|
||||||
|
|
||||||
|
- TLS termination for TCP (a reverse-proxy-in-container pattern).
|
||||||
|
- Remote signing (binding TCP to non-loopback). Both require a separate threat model and are deferred.
|
||||||
|
- A `--listen unix-path:` transport that would let Docker users bind-mount a pathname socket. Tracked as todo 20 for a follow-up plan.
|
||||||
|
- A NIP-46 relay transport. Deferred per [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md).
|
||||||
|
- Reproducible builds in the strict bit-for-bit sense. The static-binary build is already close to deterministic, but full reproducibility (matching digests across rebuilds at a given tag) is its own engineering problem and is not promised here.
|
||||||
|
- Distro packages (deb, rpm, Homebrew). The native binary remains a release asset; OS-native packaging is its own track.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Acceptance criteria
|
||||||
|
|
||||||
|
The plan is considered delivered when **all** of the following are true:
|
||||||
|
|
||||||
|
1. A new user with only Docker installed can pull and run the image with one `docker run` command from [`documents/DOCKER.md`](../documents/DOCKER.md) and see the same TUI they would see from the native binary.
|
||||||
|
2. `cosign verify` against the published image passes for the chosen registry, and the verification command is in the release notes for that tag.
|
||||||
|
3. SBOM and vulnerability scan output are attached to the release.
|
||||||
|
4. Pressing Ctrl-C in the attached terminal, and `docker stop` from another shell, both result in the signer process exiting cleanly with the existing zeroization path having run. No leftover state on the container's writable layer (which is `--read-only` anyway).
|
||||||
|
5. A TCP client running on the host, using the example added in todo 10, can complete one `get_public_key` round trip after a single `[a]` approval at the TUI, and a second request from the same client pubkey is served silently.
|
||||||
|
6. The CI smoke test (todo 13) is green on every PR that touches `Dockerfile.alpine-musl`, the runtime stage, or `src/server.c`.
|
||||||
|
7. [`README.md`](../README.md) links to [`documents/DOCKER.md`](../documents/DOCKER.md) from a "Run in Docker" quickstart section, and [`documents/SECURITY.md`](../documents/SECURITY.md) carries the Docker-specific subsection from todo 12.
|
||||||
|
8. The native binary distribution and `make static` flow produce identical artifacts to before this plan landed. No regression.
|
||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -396,7 +396,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -389,7 +389,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
83
src/main.c
83
src/main.c
@@ -234,7 +234,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -395,13 +395,17 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
|||||||
#define NSIGNER_LISTEN_QREXEC 2
|
#define NSIGNER_LISTEN_QREXEC 2
|
||||||
#define NSIGNER_LISTEN_TCP 3
|
#define NSIGNER_LISTEN_TCP 3
|
||||||
|
|
||||||
|
#define NSIGNER_AUTH_OFF 0
|
||||||
|
#define NSIGNER_AUTH_OPTIONAL 1
|
||||||
|
#define NSIGNER_AUTH_REQUIRED 2
|
||||||
|
|
||||||
/* Caller identity */
|
/* Caller identity */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
int kind;
|
int kind;
|
||||||
char caller_id[80]; /* "uid:<n>" or "qubes:<vm>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" or "qubes:<vm>" */
|
||||||
char source_qube[64];
|
char source_qube[64];
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
@@ -416,12 +420,16 @@ typedef struct {
|
|||||||
dispatcher_ctx_t *dispatcher;
|
dispatcher_ctx_t *dispatcher;
|
||||||
policy_table_t *policy;
|
policy_table_t *policy;
|
||||||
int socket_name_explicit;
|
int socket_name_explicit;
|
||||||
|
int auth_mode;
|
||||||
|
int auth_skew_seconds;
|
||||||
} server_ctx_t;
|
} server_ctx_t;
|
||||||
|
|
||||||
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
int listen_mode,
|
int listen_mode,
|
||||||
|
int auth_mode,
|
||||||
|
int auth_skew_seconds,
|
||||||
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
/* Start listening. Returns 0 on success, -1 on error. */
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
@@ -468,8 +476,8 @@ int socket_name_random(char *out, size_t out_len);
|
|||||||
/* Version information (auto-updated by build/version tooling) */
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
#define NSIGNER_VERSION_MAJOR 0
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
#define NSIGNER_VERSION_MINOR 0
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
#define NSIGNER_VERSION_PATCH 24
|
#define NSIGNER_VERSION_PATCH 25
|
||||||
#define NSIGNER_VERSION "v0.0.24"
|
#define NSIGNER_VERSION "v0.0.25"
|
||||||
|
|
||||||
|
|
||||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
@@ -477,6 +485,7 @@ int socket_name_random(char *out, size_t out_len);
|
|||||||
int transport_send_framed(int fd, const char *payload);
|
int transport_send_framed(int fd, const char *payload);
|
||||||
int transport_recv_framed(int fd, char **out_payload, size_t max_size);
|
int transport_recv_framed(int fd, char **out_payload, size_t max_size);
|
||||||
|
|
||||||
|
#include "auth_envelope.h"
|
||||||
#include <nostr_core/nostr_common.h>
|
#include <nostr_core/nostr_common.h>
|
||||||
|
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
@@ -565,8 +574,9 @@ static int connect_abstract_socket(const char *name) {
|
|||||||
static void print_usage(const char *program_name) {
|
static void print_usage(const char *program_name) {
|
||||||
printf("nsigner - single-binary signer program\n");
|
printf("nsigner - single-binary signer program\n");
|
||||||
printf("Usage:\n");
|
printf("Usage:\n");
|
||||||
printf(" %s [--socket-name|--name|-n <name>] [--listen <unix|stdio|qrexec|tcp:HOST:PORT>]\n", program_name);
|
printf(" %s [--socket-name|--name|-n <name>] [--listen|-l <unix|stdio|qrexec|tcp:HOST:PORT>]\n", program_name);
|
||||||
printf(" [--preapprove <SPEC>]...\n");
|
printf(" [--preapprove|-p <SPEC>]... [--auth|-a <off|optional|required>]\n");
|
||||||
|
printf(" [--allow-all|-A]\n");
|
||||||
printf(" Run signer server (unix mode has TUI)\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 '<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 [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin\n", program_name);
|
||||||
@@ -574,8 +584,12 @@ static void print_usage(const char *program_name) {
|
|||||||
printf(" %s --help\n", program_name);
|
printf(" %s --help\n", program_name);
|
||||||
printf(" %s --version\n", program_name);
|
printf(" %s --version\n", program_name);
|
||||||
printf("\nOptions:\n");
|
printf("\nOptions:\n");
|
||||||
printf(" --preapprove SPEC Pre-approve a caller for a role (repeatable)\n");
|
printf(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT\n");
|
||||||
|
printf(" --preapprove, -p SPEC\n");
|
||||||
|
printf(" Pre-approve a caller for a role (repeatable)\n");
|
||||||
printf(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>\n");
|
printf(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>\n");
|
||||||
|
printf(" --auth, -a MODE Auth envelope policy per listener: off|optional|required\n");
|
||||||
|
printf(" --allow-all, -A Allow all policy prompts for this server session\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
static int extract_nsigner_socket_from_proc_line(const char *line,
|
static int extract_nsigner_socket_from_proc_line(const char *line,
|
||||||
@@ -1018,6 +1032,9 @@ int main(int argc, char *argv[]) {
|
|||||||
int argi = 1;
|
int argi = 1;
|
||||||
const char *preapprove_specs[POLICY_MAX_ENTRIES];
|
const char *preapprove_specs[POLICY_MAX_ENTRIES];
|
||||||
int preapprove_count = 0;
|
int preapprove_count = 0;
|
||||||
|
int auth_mode = NSIGNER_AUTH_OFF;
|
||||||
|
int auth_skew_seconds = AUTH_DEFAULT_SKEW_SECONDS;
|
||||||
|
int allow_all = 0;
|
||||||
|
|
||||||
while (argi < argc) {
|
while (argi < argc) {
|
||||||
if (strcmp(argv[argi], "--socket-name") == 0 ||
|
if (strcmp(argv[argi], "--socket-name") == 0 ||
|
||||||
@@ -1032,7 +1049,7 @@ int main(int argc, char *argv[]) {
|
|||||||
argi += 2;
|
argi += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (strcmp(argv[argi], "--listen") == 0) {
|
if (strcmp(argv[argi], "--listen") == 0 || strcmp(argv[argi], "-l") == 0) {
|
||||||
if (argi + 1 >= argc) {
|
if (argi + 1 >= argc) {
|
||||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -1053,7 +1070,25 @@ int main(int argc, char *argv[]) {
|
|||||||
argi += 2;
|
argi += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (strcmp(argv[argi], "--preapprove") == 0) {
|
if (strcmp(argv[argi], "--auth") == 0 || strcmp(argv[argi], "-a") == 0) {
|
||||||
|
if (argi + 1 >= argc) {
|
||||||
|
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (strcmp(argv[argi + 1], "off") == 0) {
|
||||||
|
auth_mode = NSIGNER_AUTH_OFF;
|
||||||
|
} else if (strcmp(argv[argi + 1], "optional") == 0) {
|
||||||
|
auth_mode = NSIGNER_AUTH_OPTIONAL;
|
||||||
|
} else if (strcmp(argv[argi + 1], "required") == 0) {
|
||||||
|
auth_mode = NSIGNER_AUTH_REQUIRED;
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "Invalid --auth mode: %s (expected off|optional|required)\n", argv[argi + 1]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
argi += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strcmp(argv[argi], "--preapprove") == 0 || strcmp(argv[argi], "-p") == 0) {
|
||||||
if (argi + 1 >= argc) {
|
if (argi + 1 >= argc) {
|
||||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -1066,6 +1101,11 @@ int main(int argc, char *argv[]) {
|
|||||||
argi += 2;
|
argi += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (strcmp(argv[argi], "--allow-all") == 0 || strcmp(argv[argi], "-A") == 0) {
|
||||||
|
allow_all = 1;
|
||||||
|
argi += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,6 +1193,9 @@ int main(int argc, char *argv[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_test_overrides(&policy);
|
apply_test_overrides(&policy);
|
||||||
|
if (allow_all) {
|
||||||
|
server_set_prompt_always_allow(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (nostr_init() != 0) {
|
if (nostr_init() != 0) {
|
||||||
fprintf(stderr, "Failed to initialize crypto subsystem\n");
|
fprintf(stderr, "Failed to initialize crypto subsystem\n");
|
||||||
@@ -1191,7 +1234,27 @@ int main(int argc, char *argv[]) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
server_init(&server, listen_target, socket_name_explicit, listen_mode, &dispatcher, &policy);
|
if ((listen_mode == NSIGNER_LISTEN_UNIX || listen_mode == NSIGNER_LISTEN_STDIO) &&
|
||||||
|
auth_mode != NSIGNER_AUTH_OFF) {
|
||||||
|
fprintf(stderr, "--auth is currently supported only with --listen qrexec (optional/required) and tcp (required)\n");
|
||||||
|
crypto_wipe(&key_store);
|
||||||
|
nostr_cleanup();
|
||||||
|
mnemonic_unload(&mnemonic);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listen_mode == NSIGNER_LISTEN_TCP) {
|
||||||
|
auth_mode = NSIGNER_AUTH_REQUIRED;
|
||||||
|
}
|
||||||
|
|
||||||
|
server_init(&server,
|
||||||
|
listen_target,
|
||||||
|
socket_name_explicit,
|
||||||
|
listen_mode,
|
||||||
|
auth_mode,
|
||||||
|
auth_skew_seconds,
|
||||||
|
&dispatcher,
|
||||||
|
&policy);
|
||||||
if (server_start(&server) != 0) {
|
if (server_start(&server) != 0) {
|
||||||
if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
||||||
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
|
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -387,7 +387,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -386,7 +386,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
158
src/server.c
158
src/server.c
@@ -234,7 +234,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -397,13 +397,17 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
|||||||
#define NSIGNER_LISTEN_QREXEC 2
|
#define NSIGNER_LISTEN_QREXEC 2
|
||||||
#define NSIGNER_LISTEN_TCP 3
|
#define NSIGNER_LISTEN_TCP 3
|
||||||
|
|
||||||
|
#define NSIGNER_AUTH_OFF 0
|
||||||
|
#define NSIGNER_AUTH_OPTIONAL 1
|
||||||
|
#define NSIGNER_AUTH_REQUIRED 2
|
||||||
|
|
||||||
/* Caller identity */
|
/* Caller identity */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
int kind;
|
int kind;
|
||||||
char caller_id[80]; /* "uid:<n>", "qubes:<vm>", or "pubkey:<hex>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>", "qubes:<vm>", or "pubkey:<hex>" */
|
||||||
char source_qube[64];
|
char source_qube[64];
|
||||||
int auth_present;
|
int auth_present;
|
||||||
char auth_pubkey_hex[65];
|
char auth_pubkey_hex[65];
|
||||||
@@ -421,12 +425,16 @@ typedef struct {
|
|||||||
dispatcher_ctx_t *dispatcher;
|
dispatcher_ctx_t *dispatcher;
|
||||||
policy_table_t *policy;
|
policy_table_t *policy;
|
||||||
int socket_name_explicit;
|
int socket_name_explicit;
|
||||||
|
int auth_mode;
|
||||||
|
int auth_skew_seconds;
|
||||||
} server_ctx_t;
|
} server_ctx_t;
|
||||||
|
|
||||||
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
int listen_mode,
|
int listen_mode,
|
||||||
|
int auth_mode,
|
||||||
|
int auth_skew_seconds,
|
||||||
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
/* Start listening. Returns 0 on success, -1 on error. */
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
@@ -871,6 +879,43 @@ static int extract_request_id_compact(const char *json, char *out_id, size_t out
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int request_has_auth_field(const char *json) {
|
||||||
|
cJSON *root;
|
||||||
|
cJSON *auth;
|
||||||
|
int has_auth;
|
||||||
|
|
||||||
|
if (json == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
root = cJSON_Parse(json);
|
||||||
|
if (root == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = cJSON_GetObjectItemCaseSensitive(root, "auth");
|
||||||
|
has_auth = (auth != NULL) ? 1 : 0;
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return has_auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void caller_id_compose_with_pubkey(char *out,
|
||||||
|
size_t out_sz,
|
||||||
|
int caller_kind,
|
||||||
|
const char *source_qube,
|
||||||
|
const char *pubkey_hex) {
|
||||||
|
if (out == NULL || out_sz == 0 || pubkey_hex == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caller_kind == NSIGNER_LISTEN_QREXEC && source_qube != NULL && source_qube[0] != '\0') {
|
||||||
|
(void)snprintf(out, out_sz, "qubes:%s+pubkey:%s", source_qube, pubkey_hex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)snprintf(out, out_sz, "pubkey:%s", pubkey_hex);
|
||||||
|
}
|
||||||
|
|
||||||
static int extract_method_and_selector(const char *json,
|
static int extract_method_and_selector(const char *json,
|
||||||
char *method,
|
char *method,
|
||||||
size_t method_sz,
|
size_t method_sz,
|
||||||
@@ -934,6 +979,8 @@ static int extract_method_and_selector(const char *json,
|
|||||||
|
|
||||||
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
int listen_mode,
|
int listen_mode,
|
||||||
|
int auth_mode,
|
||||||
|
int auth_skew_seconds,
|
||||||
dispatcher_ctx_t *dispatcher, policy_table_t *policy) {
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy) {
|
||||||
if (ctx == NULL) {
|
if (ctx == NULL) {
|
||||||
return;
|
return;
|
||||||
@@ -951,6 +998,8 @@ void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_exp
|
|||||||
ctx->dispatcher = dispatcher;
|
ctx->dispatcher = dispatcher;
|
||||||
ctx->policy = policy;
|
ctx->policy = policy;
|
||||||
ctx->socket_name_explicit = socket_name_explicit ? 1 : 0;
|
ctx->socket_name_explicit = socket_name_explicit ? 1 : 0;
|
||||||
|
ctx->auth_mode = auth_mode;
|
||||||
|
ctx->auth_skew_seconds = (auth_skew_seconds > 0) ? auth_skew_seconds : AUTH_DEFAULT_SKEW_SECONDS;
|
||||||
if (!g_auth_nonce_cache_inited) {
|
if (!g_auth_nonce_cache_inited) {
|
||||||
auth_nonce_cache_init(&g_auth_nonce_cache);
|
auth_nonce_cache_init(&g_auth_nonce_cache);
|
||||||
g_auth_nonce_cache_inited = 1;
|
g_auth_nonce_cache_inited = 1;
|
||||||
@@ -1310,51 +1359,72 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
|||||||
auth_pubkey[0] = '\0';
|
auth_pubkey[0] = '\0';
|
||||||
auth_label[0] = '\0';
|
auth_label[0] = '\0';
|
||||||
|
|
||||||
if (caller.kind == NSIGNER_LISTEN_TCP) {
|
{
|
||||||
if (auth_envelope_verify_request(request,
|
int auth_required = 0;
|
||||||
&g_auth_nonce_cache,
|
int auth_optional = 0;
|
||||||
AUTH_DEFAULT_SKEW_SECONDS,
|
int has_auth_field = 0;
|
||||||
auth_pubkey,
|
|
||||||
sizeof(auth_pubkey),
|
if (caller.kind == NSIGNER_LISTEN_TCP) {
|
||||||
auth_label,
|
auth_required = 1;
|
||||||
sizeof(auth_label),
|
} else if (caller.kind == NSIGNER_LISTEN_QREXEC) {
|
||||||
&auth_err_code,
|
if (ctx->auth_mode == NSIGNER_AUTH_REQUIRED) {
|
||||||
&auth_err_msg) != 0) {
|
auth_required = 1;
|
||||||
(void)extract_request_id_compact(request, request_id, sizeof(request_id));
|
} else if (ctx->auth_mode == NSIGNER_AUTH_OPTIONAL) {
|
||||||
if (auth_err_msg == NULL) {
|
auth_optional = 1;
|
||||||
auth_err_msg = "auth_envelope_malformed";
|
|
||||||
}
|
}
|
||||||
{
|
|
||||||
char errbuf[256];
|
|
||||||
(void)snprintf(errbuf,
|
|
||||||
sizeof(errbuf),
|
|
||||||
"{\"id\":\"%s\",\"error\":{\"code\":%d,\"message\":\"%s\"}}",
|
|
||||||
request_id,
|
|
||||||
(auth_err_code != 0) ? auth_err_code : AUTH_ERR_ENVELOPE_MALFORMED,
|
|
||||||
auth_err_msg);
|
|
||||||
response = strdup(errbuf);
|
|
||||||
}
|
|
||||||
if (response != NULL) {
|
|
||||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
|
||||||
free(response);
|
|
||||||
}
|
|
||||||
free(request);
|
|
||||||
if (client_fd != STDIN_FILENO) {
|
|
||||||
close(client_fd);
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
caller.auth_present = 1;
|
has_auth_field = request_has_auth_field(request);
|
||||||
json_copy_string(caller.auth_pubkey_hex,
|
if (auth_required || (auth_optional && has_auth_field)) {
|
||||||
sizeof(caller.auth_pubkey_hex),
|
if (auth_envelope_verify_request(request,
|
||||||
auth_pubkey,
|
&g_auth_nonce_cache,
|
||||||
"");
|
ctx->auth_skew_seconds,
|
||||||
json_copy_string(caller.auth_label,
|
auth_pubkey,
|
||||||
sizeof(caller.auth_label),
|
sizeof(auth_pubkey),
|
||||||
auth_label,
|
auth_label,
|
||||||
"");
|
sizeof(auth_label),
|
||||||
(void)snprintf(caller.caller_id, sizeof(caller.caller_id), "pubkey:%s", auth_pubkey);
|
&auth_err_code,
|
||||||
|
&auth_err_msg) != 0) {
|
||||||
|
(void)extract_request_id_compact(request, request_id, sizeof(request_id));
|
||||||
|
if (auth_err_msg == NULL) {
|
||||||
|
auth_err_msg = "auth_envelope_malformed";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
char errbuf[256];
|
||||||
|
(void)snprintf(errbuf,
|
||||||
|
sizeof(errbuf),
|
||||||
|
"{\"id\":\"%s\",\"error\":{\"code\":%d,\"message\":\"%s\"}}",
|
||||||
|
request_id,
|
||||||
|
(auth_err_code != 0) ? auth_err_code : AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
auth_err_msg);
|
||||||
|
response = strdup(errbuf);
|
||||||
|
}
|
||||||
|
if (response != NULL) {
|
||||||
|
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||||
|
free(response);
|
||||||
|
}
|
||||||
|
free(request);
|
||||||
|
if (client_fd != STDIN_FILENO) {
|
||||||
|
close(client_fd);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
caller.auth_present = 1;
|
||||||
|
json_copy_string(caller.auth_pubkey_hex,
|
||||||
|
sizeof(caller.auth_pubkey_hex),
|
||||||
|
auth_pubkey,
|
||||||
|
"");
|
||||||
|
json_copy_string(caller.auth_label,
|
||||||
|
sizeof(caller.auth_label),
|
||||||
|
auth_label,
|
||||||
|
"");
|
||||||
|
caller_id_compose_with_pubkey(caller.caller_id,
|
||||||
|
sizeof(caller.caller_id),
|
||||||
|
caller.kind,
|
||||||
|
caller.source_qube,
|
||||||
|
auth_pubkey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) {
|
if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) {
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -386,7 +386,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -390,7 +390,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -386,7 +386,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -390,7 +390,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
@@ -527,6 +527,14 @@ int main(void) {
|
|||||||
check_condition("parse_preapprove_spec maps nonzero nostr_index role", strcmp(parsed.roles[0], "nostr_idx_2") == 0);
|
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);
|
check_condition("parse_preapprove_spec stores nonzero nostr_index", parsed_nostr_index == 2);
|
||||||
|
|
||||||
|
rc = parse_preapprove_spec("caller=qubes:personal+pubkey:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef,role=main",
|
||||||
|
&parsed,
|
||||||
|
&parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec accepts composite qubes+pubkey caller", rc == 0);
|
||||||
|
check_condition("parse_preapprove_spec stores composite caller",
|
||||||
|
strcmp(parsed.caller,
|
||||||
|
"qubes:personal+pubkey:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") == 0);
|
||||||
|
|
||||||
rc = parse_preapprove_spec("role=main", &parsed, &parsed_nostr_index);
|
rc = parse_preapprove_spec("role=main", &parsed, &parsed_nostr_index);
|
||||||
check_condition("parse_preapprove_spec rejects missing caller", rc == -1);
|
check_condition("parse_preapprove_spec rejects missing caller", rc == -1);
|
||||||
rc = parse_preapprove_spec("caller=uid:1000", &parsed, &parsed_nostr_index);
|
rc = parse_preapprove_spec("caller=uid:1000", &parsed, &parsed_nostr_index);
|
||||||
|
|||||||
369
tests/test_qrexec_auth.c
Normal file
369
tests/test_qrexec_auth.c
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cJSON.h>
|
||||||
|
#include <nostr_core/nostr_common.h>
|
||||||
|
|
||||||
|
#include "../src/auth_envelope.h"
|
||||||
|
|
||||||
|
#define MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int write_full(int fd, const void *buf, size_t len) {
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = write(fd, p + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int send_framed(int fd, const char *payload) {
|
||||||
|
uint32_t len = (uint32_t)strlen(payload);
|
||||||
|
uint32_t be_len = htonl(len);
|
||||||
|
|
||||||
|
if (write_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (write_full(fd, payload, len) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int recv_framed(int fd, char **out_payload) {
|
||||||
|
unsigned char *buf = NULL;
|
||||||
|
size_t used = 0;
|
||||||
|
size_t cap = 0;
|
||||||
|
|
||||||
|
if (out_payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*out_payload = NULL;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
unsigned char chunk[1024];
|
||||||
|
ssize_t n = read(fd, chunk, sizeof(chunk));
|
||||||
|
if (n == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (used + (size_t)n > cap) {
|
||||||
|
size_t new_cap = (cap == 0) ? 2048 : cap * 2;
|
||||||
|
while (new_cap < used + (size_t)n) {
|
||||||
|
new_cap *= 2;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
unsigned char *tmp = (unsigned char *)realloc(buf, new_cap);
|
||||||
|
if (tmp == NULL) {
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
buf = tmp;
|
||||||
|
cap = new_cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(buf + used, chunk, (size_t)n);
|
||||||
|
used += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (used < 4) {
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
size_t off;
|
||||||
|
for (off = 0; off + 4 <= used; ++off) {
|
||||||
|
uint32_t be_len;
|
||||||
|
uint32_t len;
|
||||||
|
|
||||||
|
memcpy(&be_len, buf + off, sizeof(be_len));
|
||||||
|
len = ntohl(be_len);
|
||||||
|
if (len == 0 || len > MAX_MSG_SIZE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (off + 4U + (size_t)len > used) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (buf[off + 4] != '{') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
char *payload = (char *)malloc((size_t)len + 1U);
|
||||||
|
if (payload == NULL) {
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
memcpy(payload, buf + off + 4, (size_t)len);
|
||||||
|
payload[len] = '\0';
|
||||||
|
*out_payload = payload;
|
||||||
|
free(buf);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *build_auth_request(const unsigned char privkey[32],
|
||||||
|
const char *req_id,
|
||||||
|
const char *method) {
|
||||||
|
cJSON *root = NULL;
|
||||||
|
cJSON *params = NULL;
|
||||||
|
cJSON *auth = NULL;
|
||||||
|
char *printed = NULL;
|
||||||
|
|
||||||
|
params = cJSON_CreateArray();
|
||||||
|
if (params == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
cJSON_AddItemToArray(params, cJSON_CreateString(""));
|
||||||
|
|
||||||
|
if (auth_envelope_build_for_request(req_id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
privkey,
|
||||||
|
"qrexec-test",
|
||||||
|
time(NULL),
|
||||||
|
&auth) != 0) {
|
||||||
|
cJSON_Delete(params);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
root = cJSON_CreateObject();
|
||||||
|
if (root == NULL) {
|
||||||
|
cJSON_Delete(params);
|
||||||
|
cJSON_Delete(auth);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddStringToObject(root, "id", req_id);
|
||||||
|
cJSON_AddStringToObject(root, "method", method);
|
||||||
|
cJSON_AddItemToObject(root, "params", params);
|
||||||
|
cJSON_AddItemToObject(root, "auth", auth);
|
||||||
|
|
||||||
|
printed = cJSON_PrintUnformatted(root);
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return printed;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int run_qrexec_once(const char *request_json, char **out_response) {
|
||||||
|
int in_pipe[2];
|
||||||
|
int out_pipe[2];
|
||||||
|
pid_t child;
|
||||||
|
int status;
|
||||||
|
const char *mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n";
|
||||||
|
|
||||||
|
if (out_response == NULL || request_json == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*out_response = NULL;
|
||||||
|
|
||||||
|
if (pipe(in_pipe) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (pipe(out_pipe) != 0) {
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(in_pipe[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
child = fork();
|
||||||
|
if (child < 0) {
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
close(out_pipe[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child == 0) {
|
||||||
|
int null_fd = open("/dev/null", O_WRONLY);
|
||||||
|
dup2(in_pipe[0], STDIN_FILENO);
|
||||||
|
dup2(out_pipe[1], STDOUT_FILENO);
|
||||||
|
if (null_fd >= 0) {
|
||||||
|
dup2(null_fd, STDERR_FILENO);
|
||||||
|
close(null_fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
close(out_pipe[1]);
|
||||||
|
|
||||||
|
(void)setenv("QREXEC_REMOTE_DOMAIN", "personal", 1);
|
||||||
|
|
||||||
|
execl("./build/nsigner",
|
||||||
|
"./build/nsigner",
|
||||||
|
"--listen", "qrexec",
|
||||||
|
"--auth", "optional",
|
||||||
|
"--preapprove", "caller=qubes:personal,role=main",
|
||||||
|
(char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(out_pipe[1]);
|
||||||
|
|
||||||
|
if (write_full(in_pipe[1], mnemonic, strlen(mnemonic)) != 0) {
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
(void)kill(child, SIGKILL);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give prompt_load_mnemonic()/fgets() time to consume only the mnemonic line
|
||||||
|
* before we write framed request bytes to the same stdin stream. */
|
||||||
|
{
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = 0;
|
||||||
|
ts.tv_nsec = 250 * 1000 * 1000;
|
||||||
|
(void)nanosleep(&ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send_framed(in_pipe[1], request_json) != 0) {
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
(void)kill(child, SIGKILL);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
close(in_pipe[1]);
|
||||||
|
|
||||||
|
if (recv_framed(out_pipe[0], out_response) != 0) {
|
||||||
|
close(out_pipe[0]);
|
||||||
|
(void)kill(child, SIGKILL);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(out_pipe[0]);
|
||||||
|
if (waitpid(child, &status, 0) <= 0) {
|
||||||
|
free(*out_response);
|
||||||
|
*out_response = NULL;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
char *resp = NULL;
|
||||||
|
char *auth_req = NULL;
|
||||||
|
unsigned char privkey[32] = {
|
||||||
|
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
|
||||||
|
};
|
||||||
|
|
||||||
|
check_condition("nostr_init for auth signing", nostr_init() == 0);
|
||||||
|
|
||||||
|
/* no auth => legacy qubes:personal preapprove should match */
|
||||||
|
if (run_qrexec_once("{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}", &resp) == 0) {
|
||||||
|
check_condition("qrexec optional/no-auth still allows legacy qubes caller preapprove",
|
||||||
|
strstr(resp, "\"result\":") != NULL);
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
} else {
|
||||||
|
check_condition("qrexec optional/no-auth request roundtrip", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_req = build_auth_request(privkey, "2", "get_public_key");
|
||||||
|
check_condition("build qrexec auth request", auth_req != NULL);
|
||||||
|
if (auth_req != NULL) {
|
||||||
|
if (run_qrexec_once(auth_req, &resp) == 0) {
|
||||||
|
check_condition("qrexec optional/auth request uses composite identity and misses legacy preapprove",
|
||||||
|
strstr(resp, "\"code\":2001") != NULL && strstr(resp, "policy_denied") != NULL);
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
} else {
|
||||||
|
check_condition("qrexec optional/auth request roundtrip", 0);
|
||||||
|
}
|
||||||
|
free(auth_req);
|
||||||
|
auth_req = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_req = build_auth_request(privkey, "3", "get_public_key");
|
||||||
|
check_condition("build second qrexec auth request", auth_req != NULL);
|
||||||
|
if (auth_req != NULL) {
|
||||||
|
cJSON *root = cJSON_Parse(auth_req);
|
||||||
|
char *tampered = NULL;
|
||||||
|
cJSON *method_item;
|
||||||
|
|
||||||
|
if (root != NULL) {
|
||||||
|
method_item = cJSON_GetObjectItemCaseSensitive(root, "method");
|
||||||
|
if (cJSON_IsString(method_item)) {
|
||||||
|
cJSON_SetValuestring(method_item, "sign_event");
|
||||||
|
}
|
||||||
|
tampered = cJSON_PrintUnformatted(root);
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tampered != NULL && run_qrexec_once(tampered, &resp) == 0) {
|
||||||
|
check_condition("qrexec optional/malformed-auth rejects with auth-layer code",
|
||||||
|
strstr(resp, "\"code\":2015") != NULL && strstr(resp, "auth_envelope_mismatch") != NULL);
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
} else {
|
||||||
|
check_condition("qrexec optional/malformed-auth request roundtrip", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(tampered);
|
||||||
|
free(auth_req);
|
||||||
|
}
|
||||||
|
|
||||||
|
nostr_cleanup();
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -232,7 +232,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -387,7 +387,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 80
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -384,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[80]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
Reference in New Issue
Block a user