v0.1.9 - Add AGENT_CLIENT.md reference doc and n_signer_client.roo skill for agent access to n_signer
This commit is contained in:
@@ -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": "<string>", "method": "<verb>", "params": [<arg0>, <arg1>, ..., {<options>}] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (success):
|
||||||
|
```json
|
||||||
|
{ "id": "<string>", "result": "<value>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (error):
|
||||||
|
```json
|
||||||
|
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 <signer_qube> 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 '<json>' --socket-name <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://<host>:<port>/ \
|
||||||
|
-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 | `["<event_json>",{"nostr_index":0}]` |
|
||||||
|
| `sign` | Sign arbitrary bytes (any algorithm) | `["<hex>",{"algorithm":"ed25519","index":0}]` |
|
||||||
|
| `nostr_nip44_encrypt` | NIP-44 encrypt | `["<peer_hex>","<plaintext>",{"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 |
|
||||||
@@ -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) |
|
||||||
+2
-2
@@ -762,8 +762,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 1
|
#define NSIGNER_VERSION_MINOR 1
|
||||||
#define NSIGNER_VERSION_PATCH 8
|
#define NSIGNER_VERSION_PATCH 9
|
||||||
#define NSIGNER_VERSION "v0.1.8"
|
#define NSIGNER_VERSION "v0.1.9"
|
||||||
|
|
||||||
|
|
||||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|||||||
Reference in New Issue
Block a user