v0.0.6 - Tier-1 TCP listener + FIPS deployment documentation
This commit is contained in:
458
documents/CLIENT_IMPLEMENTATION.md
Normal file
458
documents/CLIENT_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# CLIENT_IMPLEMENTATION.md
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document is the client-integration spec for `nsigner`.
|
||||
|
||||
It is written for agent/tool authors implementing robust request flows against the local signer process.
|
||||
|
||||
---
|
||||
|
||||
## 2. Discovery and socket targeting
|
||||
|
||||
`nsigner` currently supports two transport families:
|
||||
|
||||
- Linux AF_UNIX **abstract namespace** sockets.
|
||||
- Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange.
|
||||
|
||||
For AF_UNIX:
|
||||
|
||||
- Socket names are exposed in `/proc/net/unix` with a leading `@`.
|
||||
- Typical runtime names: `@nsigner_hairy_dog`, `@nsigner_brave_canyon`.
|
||||
- Clients pass the socket name **without** `@` to CLI flags (example: `nsigner_hairy_dog`).
|
||||
|
||||
### 2.1 Discovery rules
|
||||
|
||||
Use one of these patterns:
|
||||
|
||||
1. Explicit target (recommended): pass `--socket-name` / `--name` / `-n`.
|
||||
2. Enumerate with `nsigner list` and select one.
|
||||
3. Auto-discovery only when exactly one signer is running.
|
||||
|
||||
### 2.2 Enumerating running signers
|
||||
|
||||
```bash
|
||||
nsigner list
|
||||
```
|
||||
|
||||
Expected output format (one per line):
|
||||
|
||||
```text
|
||||
@nsigner
|
||||
@nsigner_hairy_dog
|
||||
@nsigner_brave_canyon
|
||||
```
|
||||
|
||||
Clients should accept both `@nsigner` and `@nsigner_*` names.
|
||||
|
||||
### 2.3 Stdio / qrexec mode
|
||||
|
||||
In server mode:
|
||||
|
||||
- `nsigner --listen stdio`: reads exactly one framed request from stdin and writes one framed response to stdout.
|
||||
- `nsigner --listen qrexec`: same behavior, but caller identity may be tagged from `QREXEC_REMOTE_DOMAIN` as `qubes:<vm-name>`.
|
||||
|
||||
This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX).
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport framing
|
||||
|
||||
Signer requests/responses use a length-prefixed frame format over the socket:
|
||||
|
||||
- Prefix: 4-byte unsigned big-endian length `N`
|
||||
- Payload: `N` bytes UTF-8 JSON text
|
||||
- One JSON-RPC object per frame
|
||||
|
||||
### 3.1 Framing pseudocode
|
||||
|
||||
Write:
|
||||
|
||||
1. Serialize JSON to bytes
|
||||
2. Compute `len(payload)`
|
||||
3. Send `uint32_be(length)` then payload bytes
|
||||
|
||||
Read:
|
||||
|
||||
1. Read exactly 4 bytes
|
||||
2. Parse big-endian payload length
|
||||
3. Read exactly `length` bytes
|
||||
4. Parse JSON
|
||||
|
||||
Do not assume line-delimited JSON.
|
||||
|
||||
---
|
||||
|
||||
## 4. JSON-RPC contract
|
||||
|
||||
### 4.1 Request shape
|
||||
|
||||
```json
|
||||
{ "id": "1", "method": "get_public_key", "params": [] }
|
||||
```
|
||||
|
||||
Methods are NIP-46 style verbs.
|
||||
|
||||
### 4.2 Implemented methods
|
||||
|
||||
- `get_public_key`
|
||||
- `sign_event`
|
||||
- `nip04_encrypt`
|
||||
- `nip04_decrypt`
|
||||
- `nip44_encrypt`
|
||||
- `nip44_decrypt`
|
||||
|
||||
### 4.3 Selector options
|
||||
|
||||
The last param may include selector options:
|
||||
|
||||
- `role`
|
||||
- `nostr_index`
|
||||
- `role_path`
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. `role`
|
||||
2. `nostr_index`
|
||||
3. `role_path`
|
||||
4. default role `main`
|
||||
|
||||
Conflicting selector fields must be rejected as `ambiguous_role_selector`.
|
||||
|
||||
### 4.4 Selector example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "2",
|
||||
"method": "sign_event",
|
||||
"params": [
|
||||
"<event_json>",
|
||||
{ "role": "main" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling contract
|
||||
|
||||
Representative error names clients must handle:
|
||||
|
||||
- `invalid_request`
|
||||
- `method_not_found`
|
||||
- `ambiguous_role_selector`
|
||||
- `unknown_role`
|
||||
- `purpose_mismatch`
|
||||
- `curve_mismatch`
|
||||
- `unauthorized`
|
||||
- `approval_denied`
|
||||
- `internal_error`
|
||||
|
||||
### 5.1 Recovery guidance
|
||||
|
||||
- `invalid_request`: client bug or malformed payload; fix request and retry.
|
||||
- `method_not_found`: version mismatch; feature-detect and downgrade behavior.
|
||||
- `ambiguous_role_selector`: send exactly one selector strategy.
|
||||
- `unknown_role`: selector did not resolve; verify role inventory/config.
|
||||
- `purpose_mismatch` / `curve_mismatch`: selected key is incompatible with method; pick compatible selector.
|
||||
- `unauthorized`: caller identity disallowed by policy; do not blind-retry.
|
||||
- `approval_denied`: user rejected prompt; treat as final unless user initiates retry.
|
||||
- `internal_error`: bounded retry with backoff; surface diagnostics.
|
||||
|
||||
---
|
||||
|
||||
## 6. Approval semantics
|
||||
|
||||
Approval has two layers:
|
||||
|
||||
1. Policy/identity checks (caller and method authorization)
|
||||
2. User prompt (interactive allow/deny)
|
||||
|
||||
Important behavior:
|
||||
|
||||
- Passing identity checks does **not** bypass prompts.
|
||||
- Non-interactive/no-TTY contexts can resolve to deny by policy/test configuration.
|
||||
- Clients must treat `approval_denied` as a normal, expected outcome.
|
||||
|
||||
### 6.1 UX recommendation
|
||||
|
||||
If a signing call is denied, return control to the user and let them explicitly retry.
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-instance, concurrency, and timeouts
|
||||
|
||||
### 7.1 Multi-instance safety
|
||||
|
||||
- Multiple signers can coexist using different socket names.
|
||||
- Always pin requests to a selected signer name once chosen.
|
||||
- Avoid “discover per request” after initial bind in long-running clients.
|
||||
|
||||
### 7.2 Connection strategy
|
||||
|
||||
- Keep one connection per in-flight request path, or serialize requests if your client runtime is simple.
|
||||
- Validate response `id` correlation before completing promise/future.
|
||||
|
||||
### 7.3 Timeout guidance
|
||||
|
||||
Use separate timeouts:
|
||||
|
||||
- connect timeout (short)
|
||||
- write timeout (short)
|
||||
- read timeout (longer, because human approval may be required)
|
||||
|
||||
For prompt-requiring methods, use a read timeout that accounts for user interaction.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security expectations for clients
|
||||
|
||||
- Treat socket access as sensitive local capability.
|
||||
- Do not log plaintext secrets, private keys, or full decrypted payloads.
|
||||
- Redact request params for encrypt/decrypt methods in normal logs.
|
||||
- Validate method-level expectations before sending (selector + purpose compatibility).
|
||||
- Use least-privilege execution context for any process that can reach the signer socket.
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference code snippets
|
||||
|
||||
## 9.1 C (frame write/read skeleton)
|
||||
|
||||
```c
|
||||
#include <arpa/inet.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int write_all(int fd, const void *buf, size_t len) {
|
||||
const unsigned char *p = (const unsigned char *)buf;
|
||||
while (len > 0) {
|
||||
ssize_t n = write(fd, p, len);
|
||||
if (n <= 0) return -1;
|
||||
p += (size_t)n;
|
||||
len -= (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int read_all(int fd, void *buf, size_t len) {
|
||||
unsigned char *p = (unsigned char *)buf;
|
||||
while (len > 0) {
|
||||
ssize_t n = read(fd, p, len);
|
||||
if (n <= 0) return -1;
|
||||
p += (size_t)n;
|
||||
len -= (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int send_json_frame(int fd, const char *json) {
|
||||
uint32_t n = (uint32_t)strlen(json);
|
||||
uint32_t be = htonl(n);
|
||||
if (write_all(fd, &be, 4) != 0) return -1;
|
||||
if (write_all(fd, json, n) != 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
## 9.2 Python (Unix abstract socket + frame)
|
||||
|
||||
```python
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
|
||||
def send_rpc(socket_name: str, obj: dict) -> dict:
|
||||
payload = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||
frame = struct.pack(">I", len(payload)) + payload
|
||||
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.connect("\0" + socket_name) # abstract namespace
|
||||
s.sendall(frame)
|
||||
|
||||
hdr = recv_exact(s, 4)
|
||||
ln = struct.unpack(">I", hdr)[0]
|
||||
body = recv_exact(s, ln)
|
||||
return json.loads(body.decode("utf-8"))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def recv_exact(s: socket.socket, n: int) -> bytes:
|
||||
out = bytearray()
|
||||
while len(out) < n:
|
||||
chunk = s.recv(n - len(out))
|
||||
if not chunk:
|
||||
raise ConnectionError("unexpected EOF")
|
||||
out.extend(chunk)
|
||||
return bytes(out)
|
||||
```
|
||||
|
||||
## 9.3 TypeScript (Node.js net + frame)
|
||||
|
||||
```ts
|
||||
import net from "node:net";
|
||||
|
||||
export async function sendRpc(socketName: string, request: unknown): Promise<any> {
|
||||
const payload = Buffer.from(JSON.stringify(request), "utf8");
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(payload.length, 0);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ path: `\u0000${socketName}` });
|
||||
|
||||
let chunks: Buffer[] = [];
|
||||
let needed = 4;
|
||||
let mode: "header" | "body" = "header";
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.write(Buffer.concat([header, payload]));
|
||||
});
|
||||
|
||||
socket.on("data", (data) => {
|
||||
chunks.push(data);
|
||||
let buf = Buffer.concat(chunks);
|
||||
|
||||
while (buf.length >= needed) {
|
||||
const part = buf.subarray(0, needed);
|
||||
buf = buf.subarray(needed);
|
||||
|
||||
if (mode === "header") {
|
||||
needed = part.readUInt32BE(0);
|
||||
mode = "body";
|
||||
} else {
|
||||
socket.end();
|
||||
resolve(JSON.parse(part.toString("utf8")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
chunks = [buf];
|
||||
});
|
||||
|
||||
socket.on("error", reject);
|
||||
socket.on("end", () => {
|
||||
// no-op; resolution occurs when full body is parsed
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. End-to-end transcripts
|
||||
|
||||
## 10.1 Happy path: get public key
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "id": "1", "method": "get_public_key", "params": [] }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "id": "1", "result": "<hex_pubkey>" }
|
||||
```
|
||||
|
||||
## 10.2 Happy path: sign event with explicit role
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "2",
|
||||
"method": "sign_event",
|
||||
"params": ["<event_json>", { "role": "main" }]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "id": "2", "result": "<signed_event_json>" }
|
||||
```
|
||||
|
||||
## 10.3 Error path: unknown role
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "3",
|
||||
"method": "sign_event",
|
||||
"params": ["<event_json>", { "role": "does_not_exist" }]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "id": "3", "error": { "code": 1002, "message": "unknown_role" } }
|
||||
```
|
||||
|
||||
## 10.4 Error path: ambiguous selector
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "4",
|
||||
"method": "sign_event",
|
||||
"params": [
|
||||
"<event_json>",
|
||||
{ "role": "main", "nostr_index": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "id": "4", "error": { "message": "ambiguous_role_selector" } }
|
||||
```
|
||||
|
||||
## 10.5 Encrypt/decrypt round trip (NIP-44)
|
||||
|
||||
Encrypt request:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "5",
|
||||
"method": "nip44_encrypt",
|
||||
"params": ["<peer_pubkey>", "hello", { "role": "main" }]
|
||||
}
|
||||
```
|
||||
|
||||
Encrypt response:
|
||||
|
||||
```json
|
||||
{ "id": "5", "result": "<nip44_ciphertext>" }
|
||||
```
|
||||
|
||||
Decrypt request:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "6",
|
||||
"method": "nip44_decrypt",
|
||||
"params": ["<peer_pubkey>", "<nip44_ciphertext>", { "role": "main" }]
|
||||
}
|
||||
```
|
||||
|
||||
Decrypt response:
|
||||
|
||||
```json
|
||||
{ "id": "6", "result": "hello" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Compatibility notes
|
||||
|
||||
- If you are writing an autonomous agent client, pin to explicit socket name and explicit role selector.
|
||||
- Keep method support feature-detected (`method_not_found` fallback).
|
||||
- Treat approval as asynchronous human gating even for local calls.
|
||||
- Track and surface signer name, request id, and method for auditability.
|
||||
185
documents/FIPS_DEPLOYMENT.md
Normal file
185
documents/FIPS_DEPLOYMENT.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# FIPS_DEPLOYMENT.md
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This runbook covers a practical Tier-1 deployment of `nsigner` over a loopback TCP listener, with connectivity provided by FIPS as the network substrate.
|
||||
|
||||
Tier-1 objective:
|
||||
|
||||
- Keep `nsigner` transport simple (`--listen tcp:127.0.0.1:PORT` or `--listen tcp:[::1]:PORT`).
|
||||
- Use FIPS to carry traffic between peers.
|
||||
- Do not add FIPS runtime dependencies into `nsigner`.
|
||||
|
||||
Out of scope in this document:
|
||||
|
||||
- Remote non-loopback TCP exposure (`--allow-remote`, TLS) planned for later phase.
|
||||
- Automatic caller->npub enrichment from FIPS session metadata.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
Two cooperating layers:
|
||||
|
||||
1. **Signer process layer** (`nsigner`)
|
||||
- Listens on loopback TCP only.
|
||||
- Uses existing 4-byte big-endian framed JSON-RPC protocol.
|
||||
- Keeps existing policy/prompt behavior.
|
||||
|
||||
2. **Network substrate layer** (FIPS)
|
||||
- Establishes peer connectivity between nodes/qubes.
|
||||
- Carries application traffic to a local loopback endpoint on each side.
|
||||
|
||||
Conceptually:
|
||||
|
||||
`client app -> local FIPS endpoint -> FIPS mesh -> remote FIPS endpoint -> 127.0.0.1:PORT -> nsigner`
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisites
|
||||
|
||||
On signer host/qube:
|
||||
|
||||
- Built `nsigner` binary.
|
||||
- FIPS installed and running.
|
||||
- Local firewall policy that keeps signer listener local-only.
|
||||
|
||||
On caller host/qube:
|
||||
|
||||
- FIPS installed and peered with signer host/qube.
|
||||
- A client implementation that speaks `nsigner` framed JSON-RPC (see `documents/CLIENT_IMPLEMENTATION.md`).
|
||||
|
||||
Operational assumptions:
|
||||
|
||||
- Operator controls both endpoints.
|
||||
- Manual verification of peer identity is performed in FIPS tooling before enabling signer traffic.
|
||||
|
||||
---
|
||||
|
||||
## 4. Start signer in Tier-1 TCP mode
|
||||
|
||||
Run `nsigner` in loopback TCP listen mode:
|
||||
|
||||
```bash
|
||||
./build/nsigner --listen tcp:127.0.0.1:8080
|
||||
```
|
||||
|
||||
Or IPv6 loopback:
|
||||
|
||||
```bash
|
||||
./build/nsigner --listen tcp:[::1]:8080
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- Non-loopback values are rejected by design.
|
||||
- No TUI hotkey loop is required in TCP mode; process serves requests until terminated.
|
||||
- Caller identity is shown as a TCP endpoint descriptor in activity/prompt context.
|
||||
|
||||
---
|
||||
|
||||
## 5. FIPS substrate wiring pattern
|
||||
|
||||
Because FIPS deployment topologies vary, use this generic pattern:
|
||||
|
||||
1. Bind `nsigner` on loopback in signer environment.
|
||||
2. Configure FIPS service/forwarding so remote authenticated peer traffic is delivered to that loopback endpoint.
|
||||
3. On caller side, direct client traffic to the local FIPS ingress endpoint for that remote service.
|
||||
|
||||
Validation checklist:
|
||||
|
||||
- FIPS session is established between caller and signer nodes.
|
||||
- Transport path from caller -> signer loopback endpoint succeeds.
|
||||
- `nsigner` receives framed request and returns framed response.
|
||||
|
||||
---
|
||||
|
||||
## 6. Minimal validation flow
|
||||
|
||||
### 6.1 Liveness check
|
||||
|
||||
From caller side, send a framed `get_public_key` request through the FIPS-backed endpoint.
|
||||
|
||||
Request JSON:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"get_public_key","params":[]}
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{"id":"1","result":"<hex_pubkey>"}
|
||||
```
|
||||
|
||||
### 6.2 Signing check
|
||||
|
||||
Send `sign_event` with explicit role selector:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "2",
|
||||
"method": "sign_event",
|
||||
"params": ["<event_json>", {"role":"main"}]
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: signed event JSON in `result`.
|
||||
|
||||
### 6.3 Negative check (policy)
|
||||
|
||||
Trigger a request path that requires prompt/denial and confirm client handles policy denial as a normal result path.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security guardrails
|
||||
|
||||
- Keep listener loopback-only in Tier-1.
|
||||
- Do not expose signer port directly on LAN/WAN.
|
||||
- Keep FIPS peer allowlist tight; avoid broad trust domains.
|
||||
- Treat FIPS connectivity as transport, not authorization bypass.
|
||||
- Preserve interactive approval where required by policy.
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
### 8.1 `invalid tcp listen target`
|
||||
|
||||
Cause:
|
||||
- `--listen` argument does not match `tcp:HOST:PORT` or `tcp:[::1]:PORT`.
|
||||
|
||||
Fix:
|
||||
- Use explicit loopback host and valid numeric port.
|
||||
|
||||
### 8.2 `non-loopback TCP bind denied`
|
||||
|
||||
Cause:
|
||||
- Attempt to bind non-loopback target in Tier-1 mode.
|
||||
|
||||
Fix:
|
||||
- Switch to `127.x.x.x` or `::1` target.
|
||||
|
||||
### 8.3 Framing parse failures (`parse_error`)
|
||||
|
||||
Cause:
|
||||
- Client sent line-delimited/raw JSON instead of framed JSON.
|
||||
|
||||
Fix:
|
||||
- Send 4-byte big-endian length prefix followed by exact UTF-8 JSON payload bytes.
|
||||
|
||||
### 8.4 FIPS path up, signer path down
|
||||
|
||||
Cause:
|
||||
- FIPS session exists but forwarding/service mapping to signer loopback endpoint is missing.
|
||||
|
||||
Fix:
|
||||
- Verify substrate service routing config and local endpoint mapping.
|
||||
|
||||
---
|
||||
|
||||
## 9. Next hardening steps (post Tier-1)
|
||||
|
||||
- Add automated two-node validation script for operator smoke checks.
|
||||
- Add optional identity enrichment from FIPS session metadata (`peer_npub`).
|
||||
- Introduce remote TCP mode only with mandatory TLS + authenticated caller key flow.
|
||||
179
documents/QUBES_OS.md
Normal file
179
documents/QUBES_OS.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# QUBES_OS.md
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Run `n_signer` inside a dedicated Qubes OS qube (for example `vault`-like behavior), and let caller qubes access signing via qrexec with explicit policy control.
|
||||
|
||||
This doc outlines what must be implemented/packaged for a reliable Qubes deployment path.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current status (where we are now)
|
||||
|
||||
Implemented in current codebase:
|
||||
|
||||
- `nsigner` supports `--listen qrexec` and `--listen stdio`.
|
||||
- Framing is transport-agnostic and shared via length-prefixed JSON (`4-byte big-endian length + payload`).
|
||||
- In qrexec/stdio mode, server handles one framed request-response exchange.
|
||||
- Caller identity extraction supports `QREXEC_REMOTE_DOMAIN`, surfaced as `qubes:<source-vm>` when available.
|
||||
|
||||
Still missing for complete Qubes integration:
|
||||
|
||||
- qrexec service file + wrapper script artifacts.
|
||||
- dom0 qrexec policy artifacts with sane defaults.
|
||||
- install/uninstall guidance and verification flow for real Qubes deployment.
|
||||
- packaging path (`packaging/qubes/`) and docs wired into README map.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture in Qubes
|
||||
|
||||
### 3.1 Components
|
||||
|
||||
- **Signer qube** (target): runs `nsigner` service entrypoint.
|
||||
- **Caller qube(s)**: apps/tools invoking qrexec service.
|
||||
- **dom0 policy**: controls which caller qubes may invoke signer service.
|
||||
|
||||
### 3.2 Request path
|
||||
|
||||
1. Caller qube invokes qrexec service (e.g. `qubes.NsignerRpc`).
|
||||
2. qrexec starts service command inside signer qube.
|
||||
3. Service command runs `nsigner --listen qrexec`.
|
||||
4. Caller sends framed JSON-RPC request over qrexec stdio channel.
|
||||
5. `nsigner` returns framed JSON-RPC response.
|
||||
|
||||
### 3.3 Trust and identity
|
||||
|
||||
- Source qube identity comes from `QREXEC_REMOTE_DOMAIN`.
|
||||
- `n_signer` maps caller as `qubes:<source-vm>` where available.
|
||||
- qrexec policy in dom0 remains first enforcement boundary.
|
||||
- `n_signer` policy/approval remains second boundary.
|
||||
|
||||
---
|
||||
|
||||
## 4. Required implementation tasks
|
||||
|
||||
## 4.1 Service entrypoint artifacts ✅ Implemented
|
||||
|
||||
Implemented repo artifacts:
|
||||
|
||||
- `packaging/qubes/rpc/qubes.NsignerRpc`
|
||||
- `packaging/qubes/install-service.sh`
|
||||
|
||||
`qubes.NsignerRpc` runs:
|
||||
|
||||
- `exec /usr/local/bin/nsigner --listen qrexec`
|
||||
|
||||
Install inside the signer qube:
|
||||
|
||||
```bash
|
||||
sudo sh packaging/qubes/install-service.sh
|
||||
```
|
||||
|
||||
This installs the qrexec service to `/etc/qubes-rpc/qubes.NsignerRpc` with executable permissions.
|
||||
|
||||
## 4.2 dom0 policy artifacts ✅ Implemented
|
||||
|
||||
Implemented repo artifacts:
|
||||
|
||||
- `packaging/qubes/policy.d/40-nsigner.policy`
|
||||
- `packaging/qubes/install-policy.sh`
|
||||
|
||||
Policy defaults now use explicit `ask` plus deny catch-all:
|
||||
|
||||
- `qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nsigner-vault`
|
||||
- `qubes.NsignerRpc * @anyvm @anyvm deny`
|
||||
|
||||
Install in dom0:
|
||||
|
||||
```bash
|
||||
sudo sh packaging/qubes/install-policy.sh
|
||||
```
|
||||
|
||||
This installs `/etc/qubes/policy.d/40-nsigner.policy` and prints signer-tag guidance.
|
||||
|
||||
## 4.3 Policy model inside n_signer for qubes callers ✅ Implemented
|
||||
|
||||
Current code reads caller as `qubes:<vm>` and qrexec default behavior is hardened.
|
||||
|
||||
In qrexec mode, default prompt behavior is now:
|
||||
|
||||
- `PROMPT_EVERY_REQUEST`
|
||||
|
||||
This replaces the previous permissive `PROMPT_NEVER` temporary setting.
|
||||
|
||||
## 4.4 Client helper examples ✅ Implemented
|
||||
|
||||
Added:
|
||||
|
||||
- `documents/qubes_client_examples.md`
|
||||
|
||||
Includes:
|
||||
|
||||
- shell helper example invoking `qrexec-client-vm` with framed request/response handling
|
||||
- Python helper example implementing frame encode/decode over qrexec stdio channel
|
||||
- reference to `documents/CLIENT_IMPLEMENTATION.md` for full protocol details
|
||||
|
||||
---
|
||||
|
||||
## 5. Operational runbook
|
||||
|
||||
## 5.1 Setup signer qube
|
||||
|
||||
- install `nsigner` binary at `/usr/local/bin/nsigner`
|
||||
- run `sudo sh packaging/qubes/install-service.sh`
|
||||
- verify `/etc/qubes-rpc/qubes.NsignerRpc` exists and is executable
|
||||
|
||||
## 5.2 Setup dom0 policy
|
||||
|
||||
- run `sudo sh packaging/qubes/install-policy.sh`
|
||||
- tag signer qube (example): `qvm-tags nsigner-vault add nsigner-signer`
|
||||
- reload qrexec policy per Qubes procedure/version
|
||||
|
||||
## 5.3 Verification
|
||||
|
||||
- from caller qube, invoke test request (`get_public_key`)
|
||||
- confirm signer qube receives request
|
||||
- confirm activity log displays `qubes:<source-vm>` caller prefix
|
||||
- validate deny behavior from unauthorized qube
|
||||
|
||||
## 5.4 Failure checks
|
||||
|
||||
- malformed frame -> parse error response
|
||||
- missing policy -> deny path
|
||||
- missing `QREXEC_REMOTE_DOMAIN` -> fallback identity path
|
||||
|
||||
---
|
||||
|
||||
## 6. Security requirements
|
||||
|
||||
- Never run signer service in disposable qube if mnemonic persistence is expected.
|
||||
- Prefer dedicated minimal template for signer qube.
|
||||
- Keep qrexec policy narrowly scoped (explicit source + target).
|
||||
- Require user approval for sensitive methods unless explicitly intended otherwise.
|
||||
- Log caller identity and method (without secret payload logging).
|
||||
|
||||
---
|
||||
|
||||
## 7. Documentation tasks
|
||||
|
||||
Update these after packaging lands:
|
||||
|
||||
- `README.md`
|
||||
- add Qubes deployment subsection under transport/usage
|
||||
- add `documents/QUBES_OS.md` and moved `documents/CLIENT_IMPLEMENTATION.md` in document map
|
||||
- `plans/nsigner.md`
|
||||
- mark T1 done with packaging status clearly separated
|
||||
|
||||
---
|
||||
|
||||
## 8. Definition of done (Qubes)
|
||||
|
||||
Qubes integration is considered complete when:
|
||||
|
||||
1. qrexec service artifact exists and is installable.
|
||||
2. dom0 policy artifact exists with secure default pattern.
|
||||
3. End-to-end call from allowed caller qube succeeds.
|
||||
4. Call from unauthorized qube is denied.
|
||||
5. Caller displayed as `qubes:<vm>` in activity.
|
||||
6. README + docs include full setup and troubleshooting.
|
||||
93
documents/qubes_client_examples.md
Normal file
93
documents/qubes_client_examples.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# qubes_client_examples.md
|
||||
|
||||
This document shows minimal caller-qube examples for invoking `nsigner --listen qrexec` through Qubes qrexec.
|
||||
|
||||
For complete protocol details (framing, JSON-RPC, error handling), see `documents/CLIENT_IMPLEMENTATION.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1) Shell example (`qrexec-client-vm` + framed JSON)
|
||||
|
||||
This sends one `get_public_key` request and decodes one framed response.
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TARGET_QUBE="nsigner-vault"
|
||||
SERVICE="qubes.NsignerRpc"
|
||||
REQ='{"id":"1","method":"get_public_key","params":[]}'
|
||||
|
||||
python3 - "$TARGET_QUBE" "$SERVICE" "$REQ" <<'PY'
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
target, service, req_json = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
frame = struct.pack(">I", len(req_json.encode("utf-8"))) + req_json.encode("utf-8")
|
||||
|
||||
p = subprocess.Popen(
|
||||
["qrexec-client-vm", target, service],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
out, _ = p.communicate(frame)
|
||||
if p.returncode != 0:
|
||||
raise SystemExit(f"qrexec-client-vm failed: {p.returncode}")
|
||||
if len(out) < 4:
|
||||
raise SystemExit("short response (missing frame header)")
|
||||
|
||||
n = struct.unpack(">I", out[:4])[0]
|
||||
payload = out[4:4+n]
|
||||
if len(payload) != n:
|
||||
raise SystemExit("short response payload")
|
||||
|
||||
print(json.dumps(json.loads(payload.decode("utf-8")), indent=2))
|
||||
PY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) Python example (explicit frame helpers over qrexec stdio)
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
|
||||
def frame_encode(obj: dict) -> bytes:
|
||||
payload = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||
return struct.pack(">I", len(payload)) + payload
|
||||
|
||||
|
||||
def frame_decode(buf: bytes) -> dict:
|
||||
if len(buf) < 4:
|
||||
raise ValueError("missing frame header")
|
||||
n = struct.unpack(">I", buf[:4])[0]
|
||||
payload = buf[4:4 + n]
|
||||
if len(payload) != n:
|
||||
raise ValueError("short frame payload")
|
||||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
|
||||
def call_nsigner_qrexec(target_qube: str, request: dict) -> dict:
|
||||
proc = subprocess.Popen(
|
||||
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
out, err = proc.communicate(frame_encode(request))
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"qrexec failed ({proc.returncode}): {err.decode('utf-8', 'replace')}")
|
||||
return frame_decode(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
req = {"id": "1", "method": "get_public_key", "params": []}
|
||||
resp = call_nsigner_qrexec("nsigner-vault", req)
|
||||
print(json.dumps(resp, indent=2))
|
||||
```
|
||||
Reference in New Issue
Block a user