From d7eb6b5ec0860d93dea0bde6f371343acb5536ff Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 1 Aug 2026 21:09:05 -0400 Subject: [PATCH] v0.1.9 - Add AGENT_CLIENT.md reference doc and n_signer_client.roo skill for agent access to n_signer --- .roo/n_signer_client.md | 199 +++++++++++ documents/AGENT_CLIENT.md | 736 ++++++++++++++++++++++++++++++++++++++ src/main.c | 4 +- 3 files changed, 937 insertions(+), 2 deletions(-) create mode 100644 .roo/n_signer_client.md create mode 100644 documents/AGENT_CLIENT.md diff --git a/.roo/n_signer_client.md b/.roo/n_signer_client.md new file mode 100644 index 0000000..eef8e8f --- /dev/null +++ b/.roo/n_signer_client.md @@ -0,0 +1,199 @@ +--- +description: "Call n_signer to sign Nostr events, get public keys, encrypt/decrypt, and perform crypto operations across all transports (qrexec, unix socket, TCP, HTTP, USB/serial)." +--- + +# n_signer Client Skill + +This skill tells an agent how to call [`n_signer`](../README.md) — a hardware/software signing oracle that holds BIP-39 keys in locked memory. The signer may be running on the same machine (Unix socket), in another Qubes qube (qrexec), on a hardware device (USB/serial), or reachable over TCP/HTTP. + +## 1. Transport overview + +| Transport | Scope | Auth required | Best for | +|-----------|-------|---------------|----------| +| **qrexec** | Cross-qube (Qubes OS) | No (identity from `QREXEC_REMOTE_DOMAIN`) | Agents in caller qubes | +| **Unix abstract socket** | Same machine | No (identity from `SO_PEERCRED`) | Local processes | +| **TCP (FIPS mesh)** | Cross-qube or network | Yes (kind-27235 auth envelope) | Remote callers, FIPS networks | +| **HTTP** | Cross-qube or network | Yes (kind-27235 auth envelope) | curl-friendly, REST clients | +| **USB/serial** | Hardware signer (Feather, Teensy, CYD) | No (physical possession) | Embedded/air-gap | +| **Stdio** | One-shot via pipe | No | Scripted one-off calls | + +## 2. Wire protocol (all transports) + +Every request/response uses **length-prefixed framing**: + +``` +[4-byte big-endian payload length][UTF-8 JSON payload] +``` + +The JSON payload is a JSON-RPC 2.0-style object: + +```json +{ "id": "", "method": "", "params": [, , ..., {}] } +``` + +Response (success): +```json +{ "id": "", "result": "" } +``` + +Response (error): +```json +{ "id": "", "error": { "code": , "message": "" } } +``` + +## 3. Transport-specific invocation + +### 3.1 Qubes qrexec (easiest cross-qube) + +```bash +# Pipe framed JSON-RPC through qrexec-client-vm +printf '\x00\x00\x00\x3f'"$(echo '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}')" | qrexec-client-vm qubes.NsignerRpc | tail -c +5 +``` + +Python (stdlib only, zero deps): +```python +import json, struct, subprocess + +def call_nsigner(target_qube, request): + payload = json.dumps(request, separators=(",", ":")).encode() + frame = struct.pack(">I", len(payload)) + payload + proc = subprocess.Popen( + ["qrexec-client-vm", target_qube, "qubes.NsignerRpc"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + out, _ = proc.communicate(frame) + length = struct.unpack(">I", out[:4])[0] + return json.loads(out[4:4+length]) +``` + +See [`client/demo_python.py`](../client/demo_python.py) for a full working demo (get_public_key → sign_event → nip44 → mine_event). + +### 3.2 Local Unix abstract socket (same machine) + +```bash +# Find running signers +nsigner list + +# Connect via socat or the nsigner client subcommand +nsigner client '' --socket-name +``` + +C (using `nostr_core_lib`): +```c +nsigner_transport_t *t = nsigner_transport_open_unix("nsigner", 5000); +nsigner_client_t *c = nsigner_client_new(t); +cJSON *result = NULL; +nsigner_client_call(c, "nostr_get_public_key", params, &result); +``` + +See [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) and [`examples/sign_event_client.c`](../examples/sign_event_client.c). + +### 3.3 TCP (FIPS mesh, cross-qube) + +Requires a **kind-27235 auth envelope** (signed Nostr event proving caller identity). + +Python (with `coincurve`): +```python +import hashlib, json, socket, struct, time +from coincurve import PrivateKey + +# Build auth envelope +sk = PrivateKey(caller_privkey_bytes) +pubkey_x = sk.public_key.format(compressed=False)[1:33].hex() +body_hash = hashlib.sha256(json.dumps(params, separators=(",",":")).encode()).hexdigest() +tags = [["nsigner_rpc","1"],["nsigner_method","get_public_key"],["nsigner_body_hash",body_hash]] +serialized = json.dumps([0, pubkey_x, created_at, 27235, tags, content], separators=(",",":")).encode() +event_id = hashlib.sha256(serialized).hexdigest() +sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00"*32).hex() + +request = {"id":"1","method":"get_public_key","params":params,"auth":{"id":event_id,"pubkey":pubkey_x,"created_at":created_at,"kind":27235,"tags":tags,"content":"py-min","sig":sig}} +``` + +See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js). + +### 3.4 HTTP listener + +```bash +curl -X POST http://:/ \ + -H "Content-Type: application/json" \ + -d '{"id":"1","method":"get_public_key","params":[],"auth":{...}}' +``` + +### 3.5 USB/serial (hardware signers) + +For Feather S3, Teensy 4.1, CYD ESP32, etc. — connect over USB CDC serial with the same framing. + +See [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) and [`examples/feather_sign_event.py`](../examples/feather_sign_event.py). + +## 4. Key verbs + +| Verb | What it does | Params | +|------|-------------|--------| +| `get_public_key` | Get pubkey for algorithm+index | `[{"algorithm":"secp256k1","index":0}]` | +| `nostr_get_public_key` | Get secp256k1 pubkey by nostr_index | `[{"nostr_index":0}]` | +| `nostr_sign_event` | Sign a Nostr event | `["",{"nostr_index":0}]` | +| `sign` | Sign arbitrary bytes (any algorithm) | `["",{"algorithm":"ed25519","index":0}]` | +| `nostr_nip44_encrypt` | NIP-44 encrypt | `["","",{"nostr_index":0}]` | +| `nostr_nip44_decrypt` | NIP-44 decrypt | `["<peer_hex>","<ciphertext>",{"nostr_index":0}]` | +| `nostr_mine_event` | NIP-13 PoW mine + sign | `["<event_json>",{"nostr_index":0,"difficulty":4}]` | +| `encapsulate` | ML-KEM-768 encapsulate | `["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]` | +| `decapsulate` | ML-KEM-768 decapsulate | `["<ciphertext_hex>",{"algorithm":"ml-kem-768","index":0}]` | +| `derive` | HMAC-SHA256(privkey, data) | `["<data>",{"algorithm":"secp256k1","index":0}]` | +| `get_info` | Signer metadata | `[]` | + +Full verb table: [`README.md §4.3`](../README.md#43-verbs) + +## 5. Algorithms + +| Algorithm | Key type | FIPS | Derivation path | +|-----------|----------|------|-----------------| +| `secp256k1` | Signature (Nostr) | — | `m/44'/1237'/<n>'/0/0` | +| `ed25519` | Signature (SSH) | — | `m/44'/102001'/<n>'/0/0'` | +| `x25519` | Key agreement (age) | — | `m/44'/102002'/<n>'/0/0'` | +| `ml-dsa-65` | PQ signature | FIPS 204 | DRBG from seed | +| `slh-dsa-128s` | PQ hash-based sig | FIPS 205 | DRBG from seed | +| `ml-kem-768` | PQ KEM | FIPS 203 | DRBG from seed | +| `otp` | One-time pad | — | USB pad, no derivation | + +## 6. Publishing a Nostr event (end-to-end) + +```python +import json, struct, subprocess, time + +def call_nsigner(target_qube, request): + payload = json.dumps(request, separators=(",", ":")).encode() + frame = struct.pack(">I", len(payload)) + payload + proc = subprocess.Popen( + ["qrexec-client-vm", target_qube, "qubes.NsignerRpc"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + out, _ = proc.communicate(frame) + length = struct.unpack(">I", out[:4])[0] + return json.loads(out[4:4+length]) + +# 1. Get pubkey +pub = call_nsigner("nostr_signer", {"id":"1","method":"get_public_key","params":[{"nostr_index":0}]})["result"] + +# 2. Build and sign event +event = {"kind":1,"content":"Hello from my agent!","created_at":int(time.time()),"tags":[],"pubkey":pub} +result = call_nsigner("nostr_signer", {"id":"2","method":"nostr_sign_event","params":[json.dumps(event),{"nostr_index":0}]}) +signed = json.loads(result["result"]) + +# 3. Broadcast to relay(s) +# signed["id"] and signed["sig"] are now populated +``` + +## 7. Reference files + +| File | What it shows | +|------|---------------| +| [`client/demo_python.py`](../client/demo_python.py) | Full Python demo (qrexec, stdlib only) | +| [`client/demo_javascript.js`](../client/demo_javascript.js) | Full Node.js demo (qrexec) | +| [`client/demo_c99.c`](../client/demo_c99.c) | Full C99 demo (qrexec, nostr_core_lib) | +| [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) | Minimal TCP/FIPS with auth envelope | +| [`examples/get_pubkey_qrexec.c`](../examples/get_pubkey_qrexec.c) | Minimal qrexec in C | +| [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) | Minimal TCP in C with auth envelope | +| [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) | TCP/FIPS in Node.js | +| [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) | qrexec in Node.js | +| [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) | Full wire contract spec | +| [`documents/AGENT_CLIENT.md`](../documents/AGENT_CLIENT.md) | Comprehensive agent reference | diff --git a/documents/AGENT_CLIENT.md b/documents/AGENT_CLIENT.md new file mode 100644 index 0000000..8375233 --- /dev/null +++ b/documents/AGENT_CLIENT.md @@ -0,0 +1,736 @@ +# Agent Client Reference — n_signer + +This document is the complete reference for **agents, AI tools, and automated processes** that need to call [`n_signer`](../README.md) — a signing oracle that holds BIP-39 keys in locked memory and exposes them over multiple transports. + +## Table of Contents + +1. [Architecture overview](#1-architecture-overview) +2. [Wire protocol (all transports)](#2-wire-protocol-all-transports) +3. [Transport: Qubes qrexec (recommended for cross-qube)](#3-transport-qubes-qrexec-recommended-for-cross-qube) +4. [Transport: Local Unix abstract socket](#4-transport-local-unix-abstract-socket) +5. [Transport: TCP (FIPS mesh)](#5-transport-tcp-fips-mesh) +6. [Transport: HTTP](#6-transport-http) +7. [Transport: USB/serial (hardware signers)](#7-transport-usbserial-hardware-signers) +8. [Transport: Stdio (one-shot)](#8-transport-stdio-one-shot) +9. [Auth envelope (kind-27235)](#9-auth-envelope-kind-27235) +10. [Complete verb reference](#10-complete-verb-reference) +11. [Algorithm reference](#11-algorithm-reference) +12. [Error codes](#12-error-codes) +13. [End-to-end: publish a Nostr event](#13-end-to-end-publish-a-nostr-event) +14. [End-to-end: sign arbitrary data](#14-end-to-end-sign-arbitrary-data) +15. [End-to-end: PQ KEM encapsulate/decapsulate](#15-end-to-end-pq-kem-encapsulatedecapsulate) +16. [Discovery: finding running signers](#16-discovery-finding-running-signers) +17. [Prerequisites and setup](#17-prerequisites-and-setup) +18. [Reference files in this repo](#18-reference-files-in-this-repo) + +--- + +## 1. Architecture overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Caller (agent) │ +│ Python / Node / C / Shell / any language │ +│ Builds framed JSON-RPC → sends over transport → reads resp │ +└──────────┬──────────────────────────────────────┬───────────┘ + │ │ + ┌─────┴──────┐ ┌──────────┴──────────┐ + │ qrexec │ │ TCP / HTTP / Unix │ + │ (Qubes) │ │ Socket / USB │ + └─────┬──────┘ └──────────┬──────────┘ + │ │ + ┌─────┴──────────────────────────────────────┴───────────┐ + │ n_signer │ + │ BIP-39 mnemonic in mlock'd RAM │ + │ Derives keys on demand (secp256k1, ed25519, PQ, etc.) │ + │ Enforces policy + approval per caller │ + └─────────────────────────────────────────────────────────┘ +``` + +The signer is a **single foreground process** attached to a terminal. It holds the mnemonic in locked memory only — nothing touches disk. When the process exits, all state is destroyed. + +--- + +## 2. Wire protocol (all transports) + +Every transport uses the same framing and JSON-RPC contract. + +### 2.1 Framing + +``` +[4 bytes: big-endian payload length N][N bytes: UTF-8 JSON payload] +``` + +- Length prefix is `uint32_t` in network byte order. +- Payload is exactly `N` bytes of UTF-8 JSON. +- One request frame → one response frame per connection (except HTTP which uses standard HTTP request/response). + +### 2.2 Request shape + +```json +{ + "id": "<caller-supplied string, echoed in response>", + "method": "<verb name>", + "params": [ <arg0>, <arg1>, ..., { <options> } ] +} +``` + +- `id` — any string. Used to match async responses. Echoed verbatim. +- `method` — one of the verbs in §10. +- `params` — JSON array. Positional args first; last element is conventionally an options object. + +### 2.3 Response shape + +Success: +```json +{ "id": "<string>", "result": "<value>" } +``` + +Error: +```json +{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } } +``` + +`result` is always a JSON string. For structured verbs (like `get_public_key` with algorithm), the string is itself serialized JSON — parse it again. + +### 2.4 Auth envelope (required for TCP/HTTP, optional for qrexec) + +TCP and HTTP listeners require a kind-27235 auth envelope in the `auth` field. See [§9](#9-auth-envelope-kind-27235). + +--- + +## 3. Transport: Qubes qrexec (recommended for cross-qube) + +**Best for**: Agents running in a caller qube, talking to n_signer in a dedicated signer qube. + +**Auth**: Not required — caller identity comes from `QREXEC_REMOTE_DOMAIN` as `qubes:<source-vm>`. + +### 3.1 Shell one-liner + +```bash +# Get public key for nostr_index 0 +printf '\x00\x00\x00\x3f'"$(echo '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}')" | \ + qrexec-client-vm nostr_signer qubes.NsignerRpc | tail -c +5 +``` + +### 3.2 Python (stdlib only) + +```python +import json, struct, subprocess + +def call_nsigner(target_qube, request): + """Send one framed JSON-RPC request via qrexec, return parsed response.""" + payload = json.dumps(request, separators=(",", ":")).encode("utf-8") + frame = struct.pack(">I", len(payload)) + payload + + proc = subprocess.Popen( + ["qrexec-client-vm", target_qube, "qubes.NsignerRpc"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + out, err = proc.communicate(frame) + + if proc.returncode != 0: + raise RuntimeError(f"qrexec failed: {err.decode()}") + + length = struct.unpack(">I", out[:4])[0] + return json.loads(out[4:4+length]) +``` + +Full demo: [`client/demo_python.py`](../client/demo_python.py) + +### 3.3 Node.js + +```javascript +const { spawn } = require("child_process"); + +function callNsigner(targetQube, request) { + return new Promise((resolve, reject) => { + const payload = Buffer.from(JSON.stringify(request), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + const framed = Buffer.concat([header, payload]); + + const proc = spawn("qrexec-client-vm", [targetQube, "qubes.NsignerRpc"], { + stdio: ["pipe", "pipe", "pipe"], + }); + + const chunks = []; + proc.stdout.on("data", (c) => chunks.push(c)); + proc.on("close", (code) => { + if (code !== 0) return reject(new Error(`exit code ${code}`)); + const buf = Buffer.concat(chunks); + const len = buf.readUInt32BE(0); + resolve(JSON.parse(buf.subarray(4, 4 + len).toString())); + }); + proc.stdin.write(framed); + proc.stdin.end(); + }); +} +``` + +Full demo: [`client/demo_javascript.js`](../client/demo_javascript.js) + +### 3.4 C (using nostr_core_lib) + +```c +#include "nostr_signer.h" + +nostr_signer_t *signer = nostr_signer_nsigner_qrexec("nostr_signer", "qubes.NsignerRpc", NULL, 30000); +nostr_signer_nsigner_set_nostr_index(signer, 0); +char pubkey[65]; +nostr_signer_get_public_key(signer, pubkey); +``` + +Full demo: [`client/demo_c99.c`](../client/demo_c99.c) + +### 3.5 Prerequisites + +1. n_signer running in the signer qube: `nsigner --listen unix --socket-name nsigner --bridge-source-trusted` +2. qrexec service installed at `/etc/qubes-rpc/qubes.NsignerRpc`: `exec nsigner bridge --to nsigner` +3. dom0 policy allowing caller qube → signer qube (see [`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy)) + +--- + +## 4. Transport: Local Unix abstract socket + +**Best for**: Agents on the same machine as the signer. + +**Auth**: Not required — identity from `SO_PEERCRED` (kernel-verified UID/PID). + +### 4.1 Discovery + +```bash +nsigner list +# Output: @nsigner, @nsigner_hairy_dog, etc. +``` + +### 4.2 Using the nsigner CLI + +```bash +nsigner client '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}' --socket-name nsigner +``` + +### 4.3 C (using nostr_core_lib) + +```c +#include "nsigner_transport.h" +#include "nsigner_client.h" + +nsigner_transport_t *t = nsigner_transport_open_unix("nsigner", 5000); +nsigner_client_t *c = nsigner_client_new(t); +cJSON *params = cJSON_CreateArray(); +cJSON *result = NULL; +nsigner_client_call(c, "nostr_get_public_key", params, &result); +``` + +See [`examples/get_public_key_client.c`](../examples/get_public_key_client.c). + +### 4.4 Python (raw socket) + +```python +import json, socket, struct + +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.connect("\0nsigner") # \0 prefix for abstract namespace + +payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode() +sock.sendall(struct.pack(">I", len(payload)) + payload) + +hdr = sock.recv(4) +length = struct.unpack(">I", hdr)[0] +body = sock.recv(length) +print(json.loads(body)) +``` + +--- + +## 5. Transport: TCP (FIPS mesh) + +**Best for**: Cross-qube or network callers where qrexec is not available. + +**Auth**: **Required** — kind-27235 auth envelope (see [§9](#9-auth-envelope-kind-27235)). + +### 5.1 Python (with coincurve) + +```python +import hashlib, json, socket, struct, time +from coincurve import PrivateKey + +HOST, PORT = "192.168.1.100", 11111 +CALLER_PRIVKEY = bytes(range(1, 33)) # Replace with your key + +params = [{"nostr_index": 0}] +body_hash = hashlib.sha256(json.dumps(params, separators=(",",":")).encode()).hexdigest() + +sk = PrivateKey(CALLER_PRIVKEY) +pubkey_x = sk.public_key.format(compressed=False)[1:33].hex() +created_at = int(time.time()) + +tags = [["nsigner_rpc","1"],["nsigner_method","get_public_key"],["nsigner_body_hash",body_hash]] +serialized = json.dumps([0, pubkey_x, created_at, 27235, tags, "tcp-agent"], separators=(",",":")).encode() +event_id = hashlib.sha256(serialized).hexdigest() +sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00"*32).hex() + +request = { + "id": "1", "method": "get_public_key", "params": params, + "auth": {"id": event_id, "pubkey": pubkey_x, "created_at": created_at, + "kind": 27235, "tags": tags, "content": "tcp-agent", "sig": sig}, +} + +payload = json.dumps(request, separators=(",",":")).encode() +frame = struct.pack(">I", len(payload)) + payload + +with socket.create_connection((HOST, PORT), timeout=10) as s: + s.sendall(frame) + hdr = s.recv(4) + ln = struct.unpack(">I", hdr)[0] + body = s.recv(ln) + +print(json.loads(body)) +``` + +See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js). + +### 5.2 C (using nostr_core_lib) + +```c +nsigner_transport_t *t = nsigner_transport_open_tcp("192.168.1.100", 11111, 10000); +nsigner_client_t *c = nsigner_client_new(t); +nsigner_client_set_auth(c, caller_privkey, "tcp-agent"); +// ... call as usual +``` + +See [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c). + +--- + +## 6. Transport: HTTP + +**Best for**: curl-friendly callers, REST clients. + +**Auth**: **Required** — kind-27235 auth envelope in the JSON body. + +```bash +curl -X POST http://192.168.1.100:11112/ \ + -H "Content-Type: application/json" \ + -d '{ + "id":"1", + "method":"get_public_key", + "params":[{"nostr_index":0}], + "auth":{...} + }' +``` + +The HTTP listener uses the same framing internally but presents a standard HTTP interface. One request per connection. + +--- + +## 7. Transport: USB/serial (hardware signers) + +**Best for**: Embedded/air-gap signers (Feather S3, Teensy 4.1, CYD ESP32). + +**Auth**: Not required — physical possession is the trust anchor. + +### 7.1 Python (pyserial) + +```python +import json, struct, serial + +ser = serial.Serial("/dev/ttyACM0", 115200, timeout=5) + +payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode() +ser.write(struct.pack(">I", len(payload)) + payload) + +hdr = ser.read(4) +length = struct.unpack(">I", hdr)[0] +body = ser.read(length) +print(json.loads(body)) +``` + +See [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) and [`examples/feather_sign_event.py`](../examples/feather_sign_event.py). + +--- + +## 8. Transport: Stdio (one-shot) + +**Best for**: Scripted one-off calls via pipe. + +The signer can be started with `--listen stdio` to handle exactly one framed request on stdin and write one framed response to stdout. + +```bash +echo -n '<framed request>' | nsigner --listen stdio --allow-all +``` + +Or via the qrexec bridge service (which uses stdio internally to relay to the persistent signer's Unix socket). + +--- + +## 9. Auth envelope (kind-27235) + +TCP and HTTP listeners require a **kind-27235 Nostr event** in the `auth` field of every request. This proves the caller controls a keypair. + +### 9.1 Auth envelope structure + +```json +{ + "auth": { + "id": "<sha256 of serialized event>", + "pubkey": "<caller's secp256k1 x-only pubkey hex>", + "created_at": <unix timestamp>, + "kind": 27235, + "tags": [ + ["nsigner_rpc", "<request id>"], + ["nsigner_method", "<method name>"], + ["nsigner_body_hash", "<sha256 of canonical params JSON>"] + ], + "content": "<arbitrary string>", + "sig": "<schnorr signature hex>" + } +} +``` + +### 9.2 Building the auth envelope (pseudocode) + +``` +1. Compute body_hash = SHA256(JSON.stringify(params, separators=(",",":"))) +2. Build tags: [["nsigner_rpc", id], ["nsigner_method", method], ["nsigner_body_hash", body_hash]] +3. Serialize event for signing: JSON.stringify([0, pubkey, created_at, 27235, tags, content]) +4. Compute event_id = SHA256(serialized) +5. Sign event_id with caller's secp256k1 key (Schnorr/BIP-340) +6. Include full event as the "auth" field in the request +``` + +### 9.3 Validation rules (server-side) + +| Check | Error code | +|-------|-----------| +| Missing `auth` field | 2014 `auth_envelope_required` | +| Malformed auth JSON | 2010 `auth_envelope_malformed` | +| `kind` != 27235 | 2013 `auth_kind_invalid` | +| Signature doesn't verify | 2012 `auth_signature_invalid` | +| `nsigner_rpc` tag != request `id` | 2011 `auth_body_mismatch` | +| `nsigner_method` tag != request `method` | 2011 `auth_body_mismatch` | +| `nsigner_body_hash` != SHA256(params) | 2011 `auth_body_mismatch` | +| Timestamp skew > 300 seconds | 2015 `auth_envelope_mismatch` | +| Replay (same event_id seen before) | 2015 `auth_envelope_mismatch` | + +### 9.4 Python example (full) + +See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) for a complete working example. + +### 9.5 JavaScript example (full) + +See [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) for a complete working example. + +--- + +## 10. Complete verb reference + +### 10.1 Algorithm-based verbs + +These select a key by `algorithm` + `index` (see [§11](#11-algorithm-reference) for derivation paths). + +| Verb | Algorithms | Params | Options | +|------|-----------|--------|---------| +| `get_public_key` | All key-deriving | `[]` | `algorithm`, `index` | +| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `[<message_hex>]` | `algorithm`, `index`, `scheme`* | +| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `[<message_hex>, <sig_hex>]` | `algorithm`, `index`, `scheme`* | +| `encapsulate` | ml-kem-768 | `[<peer_pubkey_hex>]` | `algorithm` | +| `decapsulate` | ml-kem-768 | `[<ciphertext_hex>]` | `algorithm`, `index` | +| `derive_shared_secret` | x25519 | `[<peer_pubkey_hex>]` | `algorithm`, `index` | +| `derive` | secp256k1 | `[<data>]` | `algorithm`, `index` (required) | +| `encrypt` | otp | `[<plaintext_base64>]` | `algorithm`, `encoding` | +| `decrypt` | otp | `[<ciphertext>]` | `algorithm`, `encoding` | + +\* `scheme`: `"schnorr"` (default) or `"ecdsa"` for secp256k1. + +### 10.2 Nostr protocol verbs + +These select a secp256k1 NIP-06 key via `nostr_index` (or `role`/`role_path`). + +| Verb | Params | Options | +|------|--------|---------| +| `nostr_get_public_key` | `[]` | `nostr_index`, `format` | +| `nostr_sign_event` | `[<event_json>]` | `nostr_index` | +| `nostr_mine_event` | `[<event_json>]` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` | +| `nostr_nip04_encrypt` | `[<peer_pubkey_hex>, <plaintext>]` | `nostr_index` | +| `nostr_nip04_decrypt` | `[<peer_pubkey_hex>, <ciphertext>]` | `nostr_index` | +| `nostr_nip44_encrypt` | `[<peer_pubkey_hex>, <plaintext>]` | `nostr_index` | +| `nostr_nip44_decrypt` | `[<peer_pubkey_hex>, <ciphertext>]` | `nostr_index` | + +### 10.3 Metadata + +| Verb | Params | Description | +|------|--------|-------------| +| `get_info` | `[]` | Returns signer metadata (name, version, supported verbs/algorithms). Safe to call before mnemonic is loaded. | + +### 10.4 Example requests + +```json +// Get public key for ML-DSA-65 index 0 +{"id":"1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]} + +// Sign "hello" with ed25519 index 0 +{"id":"2","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]} + +// Sign a Nostr event with nostr_index 0 +{"id":"3","method":"nostr_sign_event","params":["{\"kind\":1,\"content\":\"Hello\",\"tags\":[],\"created_at\":1700000000,\"pubkey\":\"<hex>\"}",{"nostr_index":0}]} + +// NIP-44 encrypt +{"id":"4","method":"nostr_nip44_encrypt","params":["<peer_pubkey_hex>","secret message",{"nostr_index":0}]} + +// ML-KEM-768 encapsulate +{"id":"5","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]} + +// Derive HMAC-SHA256(privkey, data) for opaque identifiers +{"id":"6","method":"derive","params":["<data_hex>",{"algorithm":"secp256k1","index":0}]} +``` + +--- + +## 11. Algorithm reference + +| Algorithm | Key type | FIPS | Derivation path | Pubkey | Privkey | Signature | +|-----------|----------|------|-----------------|--------|---------|-----------| +| `secp256k1` | Signature (Nostr) | — | `m/44'/1237'/<n>'/0/0` | 32 B | 32 B | 64 B | +| `ed25519` | Signature (SSH) | — | `m/44'/102001'/<n>'/0/0'` | 32 B | 32 B | 64 B | +| `x25519` | Key agreement (age) | — | `m/44'/102002'/<n>'/0/0'` | 32 B | 32 B | — | +| `ml-dsa-65` | PQ signature | FIPS 204 | DRBG from seed | 1952 B | 4032 B | 3309 B | +| `slh-dsa-128s` | PQ hash-based sig | FIPS 205 | DRBG from seed | 32 B | 64 B | 7856 B | +| `ml-kem-768` | PQ KEM | FIPS 203 | DRBG from seed | 1184 B | 2400 B | — | +| `otp` | One-time pad | — | USB pad (no derivation) | — | — | — | + +### 11.1 `get_public_key` response format + +Algorithm-based `get_public_key` returns structured JSON: + +```json +{"algorithm":"ml-dsa-65","public_key":"<hex>","key_id":"<first 16 hex chars of pubkey>"} +``` + +Nostr `nostr_get_public_key` returns a plain 64-hex-char string by default, or structured JSON with `{"format":"structured"}`. + +--- + +## 12. Error codes + +| Code | Message | Meaning | +|------|---------|---------| +| -32700 | `parse_error` | Request is not valid JSON | +| -32600 | `invalid_request` | Missing `id`, `method`, or `params` | +| -32601 | `method_not_found` | Unknown verb | +| -32602 | `invalid_params` | Malformed arguments | +| 1001 | `ambiguous_role_selector` | Multiple role selectors given | +| 1002 | `unknown_role` | No role matched selector | +| 1003 | `no_default_role` | No selector and no `main` role | +| 1004 | `purpose_mismatch` | Role purpose not valid for verb | +| 1005 | `curve_mismatch` | Role curve not valid for verb | +| 1006 | `mnemonic_not_loaded` | No mnemonic loaded | +| 1007 | `no_termination_condition` | `nostr_mine_event` without difficulty/timeout | +| 1008 | `mining_failed` | Internal PoW error | +| 1009 | `not_yet_implemented` | Verb+algorithm not yet implemented | +| 1010 | `algorithm_not_supported_for_verb` | Algorithm not valid for verb | +| 2010 | `auth_envelope_malformed` | Auth JSON is malformed | +| 2011 | `auth_body_mismatch` | Auth tags don't match request | +| 2012 | `auth_signature_invalid` | Auth signature doesn't verify | +| 2013 | `auth_kind_invalid` | Auth kind != 27235 | +| 2014 | `auth_envelope_required` | Auth missing (TCP/HTTP) | +| 2015 | `auth_envelope_mismatch` | Timestamp skew or replay | + +--- + +## 13. End-to-end: publish a Nostr event + +This is the most common agent task. Here's the complete flow using qrexec (simplest cross-qube transport): + +```python +#!/usr/bin/env python3 +"""Publish a Nostr event via n_signer over qrexec.""" +import json, struct, subprocess, sys, time + +def call_nsigner(target_qube, request): + payload = json.dumps(request, separators=(",", ":")).encode() + frame = struct.pack(">I", len(payload)) + payload + proc = subprocess.Popen( + ["qrexec-client-vm", target_qube, "qubes.NsignerRpc"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + out, _ = proc.communicate(frame) + length = struct.unpack(">I", out[:4])[0] + return json.loads(out[4:4+length]) + +target = sys.argv[1] if len(sys.argv) > 1 else "nostr_signer" +idx = int(sys.argv[2]) if len(sys.argv) > 2 else 0 + +# Step 1: Get public key +pub = call_nsigner(target, {"id":"1","method":"get_public_key","params":[{"nostr_index":idx}]})["result"] +print(f"Pubkey: {pub}") + +# Step 2: Build and sign event +event = { + "kind": 1, + "content": "Hello from my agent!", + "created_at": int(time.time()), + "tags": [], + "pubkey": pub, +} +result = call_nsigner(target, { + "id": "2", + "method": "nostr_sign_event", + "params": [json.dumps(event, separators=(",",":")), {"nostr_index": idx}], +}) +signed = json.loads(result["result"]) +print(f"Event ID: {signed['id']}") +print(f"Signature: {signed['sig']}") + +# Step 3: Broadcast to relay(s) +# signed is a complete Nostr event with id and sig — send to any relay +``` + +--- + +## 14. End-to-end: sign arbitrary data + +Using algorithm-based verbs (any algorithm, any index): + +```python +# Sign "hello" with ed25519 index 0 +result = call_nsigner(target, { + "id": "1", + "method": "sign", + "params": ["68656c6c6f", {"algorithm": "ed25519", "index": 0}], +}) +signature = result["result"] # hex string + +# Verify +result = call_nsigner(target, { + "id": "2", + "method": "verify", + "params": ["68656c6c6f", signature, {"algorithm": "ed25519", "index": 0}], +}) +print(f"Verified: {result['result']}") # "true" or "false" +``` + +--- + +## 15. End-to-end: PQ KEM encapsulate/decapsulate + +```python +# Get ML-KEM-768 public key for index 0 +pub_result = call_nsigner(target, { + "id": "1", + "method": "get_public_key", + "params": [{"algorithm": "ml-kem-768", "index": 0}], +}) +pub_info = json.loads(pub_result["result"]) +print(f"ML-KEM-768 pubkey: {pub_info['public_key'][:32]}...") + +# Encapsulate (generate a shared secret + ciphertext for that pubkey) +enc_result = call_nsigner(target, { + "id": "2", + "method": "encapsulate", + "params": [pub_info["public_key"], {"algorithm": "ml-kem-768"}], +}) +enc_data = json.loads(enc_result["result"]) +print(f"Ciphertext: {enc_data['ciphertext'][:32]}...") +print(f"Shared secret: {enc_data['shared_secret'][:32]}...") + +# Decapsulate (recover shared secret from ciphertext using private key) +dec_result = call_nsigner(target, { + "id": "3", + "method": "decapsulate", + "params": [enc_data["ciphertext"], {"algorithm": "ml-kem-768", "index": 0}], +}) +dec_data = json.loads(dec_result["result"]) +print(f"Decapsulated secret: {dec_data['shared_secret'][:32]}...") +assert dec_data["shared_secret"] == enc_data["shared_secret"] +print("✓ KEM round-trip verified") +``` + +--- + +## 16. Discovery: finding running signers + +### 16.1 List running signers + +```bash +nsigner list +``` + +Output (one per line): +``` +@nsigner +@nsigner_hairy_dog +@nsigner_brave_canyon +``` + +### 16.2 Check if a specific signer is running + +```bash +nsigner list | grep -q @nsigner && echo "running" || echo "not running" +``` + +### 16.3 Programmatic discovery (Python) + +```python +import subprocess +result = subprocess.run(["nsigner", "list"], capture_output=True, text=True) +signers = [s.strip() for s in result.stdout.split("\n") if s.strip()] +print(f"Found {len(signers)} signer(s): {signers}") +``` + +--- + +## 17. Prerequisites and setup + +### 17.1 On the signer qube + +```bash +# Install nsigner +# Start the persistent signer +nsigner --listen unix --socket-name nsigner --bridge-source-trusted + +# Install qrexec service +sudo cp packaging/qubes/rpc/qubes.NsignerRpc /etc/qubes-rpc/qubes.NsignerRpc +sudo chmod 0755 /etc/qubes-rpc/qubes.NsignerRpc +``` + +### 17.2 In dom0 + +```bash +# Install policy +sudo cp packaging/qubes/policy.d/40-nsigner.policy /etc/qubes/policy.d/40-nsigner.policy + +# Tag the signer qube +qvm-tags nostr_signer add nsigner-signer +``` + +### 17.3 On the caller qube + +No special setup needed — just `qrexec-client-vm` (pre-installed in all Qubes templates). + +--- + +## 18. Reference files in this repo + +| File | What it shows | +|------|---------------| +| [`client/demo_python.py`](../client/demo_python.py) | Full Python demo (qrexec, stdlib only) — get_public_key, sign_event, nip44, mine_event | +| [`client/demo_javascript.js`](../client/demo_javascript.js) | Full Node.js demo (qrexec) — same operations | +| [`client/demo_c99.c`](../client/demo_c99.c) | Full C99 demo (qrexec, nostr_core_lib) — same operations | +| [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) | Minimal TCP/FIPS with auth envelope (Python) | +| [`examples/get_pubkey_qrexec.c`](../examples/get_pubkey_qrexec.c) | Minimal qrexec in C | +| [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) | Minimal TCP in C with auth envelope | +| [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) | TCP/FIPS in Node.js with auth envelope | +| [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) | qrexec in Node.js | +| [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) | Unix socket in C | +| [`examples/sign_event_client.c`](../examples/sign_event_client.c) | Unix socket sign event in C | +| [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) | USB/serial hardware signer (Python) | +| [`examples/feather_sign_event.py`](../examples/feather_sign_event.py) | USB/serial sign event (Python) | +| [`examples/pq_sign_example.c`](../examples/pq_sign_example.c) | ML-DSA-65 sign in C | +| [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) | ML-KEM-768 encaps/decaps in C | +| [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) | ed25519 SSH sign in C | +| [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md) | Full wire contract spec (733 lines) | +| [`README.md`](../README.md) | Full n_signer documentation (741 lines) | +| [`packaging/qubes/setup_signer_qube.sh`](../packaging/qubes/setup_signer_qube.sh) | Automated signer qube setup | +| [`.roo/n_signer_client.md`](../.roo/n_signer_client.md) | Roo skill file (agent-invocable) | diff --git a/src/main.c b/src/main.c index aa7d05e..8c4e88e 100644 --- a/src/main.c +++ b/src/main.c @@ -762,8 +762,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 1 -#define NSIGNER_VERSION_PATCH 8 -#define NSIGNER_VERSION "v0.1.8" +#define NSIGNER_VERSION_PATCH 9 +#define NSIGNER_VERSION "v0.1.9" /* NSIGNER_HEADERLESS_DECLS_END */