Files
n_signer/documents/AGENT_CLIENT.md
T

737 lines
26 KiB
Markdown

# 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) |