Compare commits

...
2 Commits
8 changed files with 1044 additions and 563 deletions
+199
View File
@@ -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 |
+736
View File
@@ -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) |
-527
View File
@@ -1,527 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer Feather WebUSB Demo</title>
<style>
:root {
--bg: #0b0f14;
--panel: #121821;
--panel-2: #182231;
--text: #e6edf3;
--muted: #9fb0c3;
--accent: #58a6ff;
--good: #3fb950;
--bad: #f85149;
--border: #263448;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.35;
}
.wrap {
max-width: 980px;
margin: 20px auto;
padding: 0 14px 24px;
}
h1 { margin: 0 0 8px; font-size: 1.45rem; }
p.note { margin: 0 0 14px; color: var(--muted); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-top: 12px;
}
.card {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
}
.card h2 {
font-size: 1rem;
margin: 0 0 10px;
}
label {
font-size: 0.86rem;
color: var(--muted);
display: block;
margin: 6px 0 4px;
}
input, textarea, button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
}
input, textarea {
width: 100%;
background: #0c131d;
color: var(--text);
padding: 8px 10px;
}
textarea { min-height: 84px; resize: vertical; }
input[type="number"] { max-width: 130px; }
button {
background: #1f6feb;
color: white;
padding: 8px 12px;
cursor: pointer;
border: 0;
}
button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.secondary { background: #334155; }
.status {
padding: 6px 10px;
border-radius: 999px;
background: #2a3648;
color: var(--muted);
font-size: 0.85rem;
border: 1px solid var(--border);
}
.status.ok { color: var(--good); border-color: #2f5a3a; }
.status.err { color: var(--bad); border-color: #6a3131; }
pre {
margin: 8px 0 0;
background: #0a1018;
border: 1px solid #1b2636;
color: #d7e2ee;
padding: 10px;
border-radius: 8px;
overflow: auto;
max-height: 220px;
font-size: 12px;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
</style>
</head>
<body>
<div class="wrap">
<h1>n_signer Feather WebUSB Demo</h1>
<p class="note">Connect to the board, choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.</p>
<div class="card">
<div class="row">
<button id="connectBtn">Connect WebUSB</button>
<span id="connStatus" class="status">Disconnected</span>
</div>
<pre id="log" class="mono"></pre>
</div>
<div class="grid">
<section class="card">
<h2>Public Key</h2>
<label for="keyIndex">Key index (nostr_index)</label>
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
<div class="row" style="margin-top:10px">
<button id="pubkeyBtn" disabled>Get Public Key</button>
</div>
<pre id="pubkeyOut" class="mono"></pre>
</section>
<section class="card">
<h2>Sign Kind 1 Event</h2>
<label for="kind1Content">Content</label>
<textarea id="kind1Content">hello from feather webusb demo</textarea>
<label for="kind1Tags">Tags JSON (array)</label>
<input id="kind1Tags" value="[]" />
<div class="row" style="margin-top:10px">
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
</div>
<pre id="signOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Encrypt</h2>
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
<label for="nip04Msg">Plaintext</label>
<textarea id="nip04Msg">hello via nip04</textarea>
<div class="row" style="margin-top:10px">
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
</div>
<pre id="nip04Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Decrypt</h2>
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip04Cipher">Ciphertext</label>
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
<div class="row" style="margin-top:10px">
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
</div>
<pre id="nip04DecOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Encrypt</h2>
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
<label for="nip44Msg">Plaintext</label>
<textarea id="nip44Msg">hello via nip44</textarea>
<div class="row" style="margin-top:10px">
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
</div>
<pre id="nip44Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Decrypt</h2>
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip44Cipher">Ciphertext</label>
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
<div class="row" style="margin-top:10px">
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
</div>
<pre id="nip44DecOut" class="mono"></pre>
</section>
</div>
</div>
<script type="module">
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
const logEl = document.getElementById("log");
const connStatusEl = document.getElementById("connStatus");
const connectBtn = document.getElementById("connectBtn");
const pubkeyBtn = document.getElementById("pubkeyBtn");
const signKind1Btn = document.getElementById("signKind1Btn");
const nip04EncBtn = document.getElementById("nip04EncBtn");
const nip04DecBtn = document.getElementById("nip04DecBtn");
const nip44EncBtn = document.getElementById("nip44EncBtn");
const nip44DecBtn = document.getElementById("nip44DecBtn");
const keyIndexEl = document.getElementById("keyIndex");
const pubkeyOutEl = document.getElementById("pubkeyOut");
const kind1ContentEl = document.getElementById("kind1Content");
const kind1TagsEl = document.getElementById("kind1Tags");
const signOutEl = document.getElementById("signOut");
const nip04PeerEl = document.getElementById("nip04Peer");
const nip04MsgEl = document.getElementById("nip04Msg");
const nip04OutEl = document.getElementById("nip04Out");
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
const nip04CipherEl = document.getElementById("nip04Cipher");
const nip04DecOutEl = document.getElementById("nip04DecOut");
const nip44PeerEl = document.getElementById("nip44Peer");
const nip44MsgEl = document.getElementById("nip44Msg");
const nip44OutEl = document.getElementById("nip44Out");
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
const nip44CipherEl = document.getElementById("nip44Cipher");
const nip44DecOutEl = document.getElementById("nip44DecOut");
let dev = null;
let iface = null;
let ownPubkey = "";
const EP_OUT = 1;
const EP_IN = 1;
function log(...args) {
logEl.textContent += args.join(" ") + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, mode = "") {
connStatusEl.textContent = text;
connStatusEl.className = `status ${mode}`.trim();
}
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
function utf8(s) {
return new TextEncoder().encode(s);
}
function be32(n) {
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
}
async function sha256Hex(dataBytes) {
const h = await crypto.subtle.digest("SHA-256", dataBytes);
return hex(new Uint8Array(h));
}
function getIndexOptions() {
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
return { nostr_index: index };
}
function pretty(value) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
async function buildAuth(method, params) {
// Demo caller key only (not secret in browser demo).
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params);
const bodyHash = await sha256Hex(utf8(paramsJson));
const tags = [
["nsigner_rpc", "1"],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "webusb-demo";
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await sha256Hex(utf8(ser));
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
return {
id,
pubkey: callerPubX,
created_at: createdAt,
kind: 27235,
tags,
content,
sig: sigHex,
};
}
async function sendRpc(reqObj) {
const body = utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(be32(body.length), 0);
frame.set(body, 4);
await dev.transferOut(EP_OUT, frame);
const deadline = Date.now() + 10000;
let ring = new Uint8Array(0);
while (Date.now() < deadline) {
const r = await dev.transferIn(EP_IN, 512);
if (!r.data || r.data.byteLength === 0) continue;
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
const next = new Uint8Array(ring.length + chunk.length);
next.set(ring, 0);
next.set(chunk, ring.length);
ring = next;
while (ring.length >= 4) {
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
if (n <= 0 || n > 1_000_000) {
ring = ring.slice(1);
continue;
}
if (ring.length < 4 + n) break;
const payload = ring.slice(4, 4 + n);
ring = ring.slice(4 + n);
const txt = new TextDecoder().decode(payload);
return JSON.parse(txt);
}
}
throw new Error("Timed out waiting for framed response");
}
async function rpcCall(method, params, id = "web-1") {
const auth = await buildAuth(method, params);
const req = { jsonrpc: "2.0", id, method, params, auth };
log("→", method, JSON.stringify(params));
const resp = await sendRpc(req);
log("←", method, JSON.stringify(resp));
return resp;
}
function requirePeerHex(peer) {
const v = String(peer || "").trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(v)) {
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
}
return v;
}
function requireStringResult(resp, what) {
if (resp && typeof resp.result === "string") {
return resp.result;
}
throw new Error(`${what} failed: ${pretty(resp)}`);
}
async function fetchOwnPubkeyAndFillPeers() {
const params = [getIndexOptions()];
const resp = await rpcCall("get_public_key", params, "web-own-pubkey");
if (resp && typeof resp.result === "string") {
ownPubkey = resp.result.trim().toLowerCase();
pubkeyOutEl.textContent = pretty(resp);
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
nip04PeerEl.value = ownPubkey;
nip04DecPeerEl.value = ownPubkey;
nip44PeerEl.value = ownPubkey;
nip44DecPeerEl.value = ownPubkey;
}
}
}
async function connect() {
dev = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303a }] });
await dev.open();
if (dev.configuration === null) {
await dev.selectConfiguration(1);
}
const intf = dev.configuration.interfaces.find(i =>
i.alternates.some(a => a.interfaceClass === 0xff)
);
if (!intf) throw new Error("No vendor WebUSB interface found");
iface = intf.interfaceNumber;
await dev.claimInterface(iface);
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
await dev.selectAlternateInterface(iface, alt.alternateSetting);
await dev.controlTransferOut({
requestType: "class",
recipient: "interface",
request: 0x22,
value: 1,
index: iface,
});
pubkeyBtn.disabled = false;
signKind1Btn.disabled = false;
nip04EncBtn.disabled = false;
nip04DecBtn.disabled = false;
nip44EncBtn.disabled = false;
nip44DecBtn.disabled = false;
setStatus(`Connected (iface ${iface})`, "ok");
log("Connected. Interface", String(iface));
try {
await fetchOwnPubkeyAndFillPeers();
log("Default peer pubkeys set to selected signer pubkey");
} catch (e) {
log("Auto pubkey fetch failed:", String(e));
}
}
connectBtn.addEventListener("click", async () => {
try {
await connect();
} catch (e) {
setStatus("Connect failed", "err");
log("Connect failed:", String(e));
}
});
pubkeyBtn.addEventListener("click", async () => {
try {
await fetchOwnPubkeyAndFillPeers();
} catch (e) {
pubkeyOutEl.textContent = String(e);
}
});
signKind1Btn.addEventListener("click", async () => {
try {
let tags = [];
try {
tags = JSON.parse(kind1TagsEl.value || "[]");
if (!Array.isArray(tags)) throw new Error("tags must be an array");
} catch (e) {
throw new Error(`Invalid tags JSON: ${String(e)}`);
}
const unsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: String(kind1ContentEl.value || ""),
};
const params = [unsignedEvent, getIndexOptions()];
const resp = await rpcCall("nostr_sign_event", params, "web-sign-kind1");
signOutEl.textContent = pretty(resp?.result ?? resp);
} catch (e) {
signOutEl.textContent = String(e);
}
});
nip04EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04PeerEl.value);
const msg = String(nip04MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nostr_nip04_encrypt", params, "web-nip04-enc");
nip04OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip04DecPeerEl.value = peer;
nip04CipherEl.value = resp.result;
}
} catch (e) {
nip04OutEl.textContent = String(e);
}
});
nip04DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04DecPeerEl.value);
const ciphertext = String(nip04CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nostr_nip04_decrypt", params, "web-nip04-dec");
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
} catch (e) {
nip04DecOutEl.textContent = String(e);
}
});
nip44EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44PeerEl.value);
const msg = String(nip44MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nostr_nip44_encrypt", params, "web-nip44-enc");
nip44OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip44DecPeerEl.value = peer;
nip44CipherEl.value = resp.result;
}
} catch (e) {
nip44OutEl.textContent = String(e);
}
});
nip44DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44DecPeerEl.value);
const ciphertext = String(nip44CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nostr_nip44_decrypt", params, "web-nip44-dec");
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>
+5 -5
View File
@@ -46,10 +46,10 @@ CDC path:
./.venv/bin/python ./examples/feather_sign_event.py /dev/ttyACM0
```
WebUSB path:
WebUSB / Web Serial path (unified):
- Open [`examples/feather_webusb_demo.html`](../examples/feather_webusb_demo.html)
- Connect in Chrome/Edge
- Open [`usb-test.html`](../usb-test.html) in Chrome/Edge
- Connect via Web Serial (works for Teensy 4.1 USB CDC, CYD CH340, Feather, etc.)
- Run `get_public_key`
- Confirm pubkey matches CDC result
@@ -58,9 +58,9 @@ WebUSB path:
The CYD has no native USB; its CH340 bridge exposes a serial port. The browser
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
page covering every algorithm and verb lives at
[`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html):
[`usb-test.html`](../usb-test.html):
- Open [`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html) in Chrome/Edge
- Open [`usb-test.html`](../usb-test.html) in Chrome/Edge
- Click **Connect Web Serial**, select the CH340 port (`1a86:7523`)
- Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
`encapsulate`/`decapsulate`, `derive_shared_secret`, `derive`,
+2 -2
View File
@@ -95,9 +95,9 @@ hardware note below.
The CYD has no native USB; the CH340 bridge exposes a serial port. The browser
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
page covering every algorithm and verb lives at
[`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html):
[`usb-test.html`](../../usb-test.html):
1. Open [`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html) in Chrome/Edge.
1. Open [`usb-test.html`](../../usb-test.html) in Chrome/Edge.
2. Click **Connect Web Serial**, select the CH340 port (`1a86:7523`).
3. On the CYD touchscreen, enter or generate a mnemonic to reach the "ready" state.
4. Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
+12 -2
View File
@@ -20,7 +20,7 @@
// each boot. Mirrors CYD_DEBUG_AUTO_GENERATE in
// firmware/cyd_esp32_2432s028/main/main.c. Set to 0 for the normal interactive
// boot flow (show the startup menu, wait for the user to tap Generate/Enter).
#define DEBUG_AUTO_GENERATE 1
#define DEBUG_AUTO_GENERATE 0
#include <lvgl.h>
#include <ST7796_t3.h>
@@ -507,7 +507,17 @@ void setup() {
} else {
Serial.print("Found ");
Serial.print(n_pads);
Serial.println(" pad(s), showing selection screen...");
Serial.println(" pad(s):");
for (int i = 0; i < n_pads; i++) {
Serial.print(" [");
Serial.print(i);
Serial.print("] ");
Serial.print(pad_chksums[i]);
Serial.print(" (");
Serial.print((unsigned long)pad_sizes[i]);
Serial.println(" bytes)");
}
Serial.println("Showing selection screen...");
const char *chksum_ptrs[4];
for (int i = 0; i < n_pads; i++) chksum_ptrs[i] = pad_chksums[i];
char selected[65];
+2 -2
View File
@@ -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 7
#define NSIGNER_VERSION "v0.1.7"
#define NSIGNER_VERSION_PATCH 9
#define NSIGNER_VERSION "v0.1.9"
/* NSIGNER_HEADERLESS_DECLS_END */
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer CYD Web Serial Demo</title>
<title>n_signer USB Test</title>
<script>
/* §12 Dark mode: set class synchronously before paint. */
(function () {
@@ -342,7 +342,7 @@
<div class="divHeaderButtons">
<button id="btnHamburger" title="Menu" aria-label="Open menu"></button>
</div>
<div class="divHeaderText">n_signer CYD Web Serial</div>
<div class="divHeaderText">n_signer USB Test</div>
<div class="divHeaderButtons"></div>
</div>
@@ -350,7 +350,7 @@
<!-- Connection card (full width) -->
<section class="divPostItem full">
<h2>Connection</h2>
<p class="note">Connect to the CYD (CH340 serial) via Web Serial, then exercise every algorithm and verb in the n_signer API. Chrome/Edge/Brave/Opera only.</p>
<p class="note">Connect to the signer (Teensy 4.1 USB CDC, CYD CH340 serial, etc.) via Web Serial, then exercise every algorithm and verb in the n_signer API. Chrome/Edge/Brave/Opera only.</p>
<div class="row">
<button id="connectBtn" class="btn">Connect Web Serial</button>
<button id="disconnectBtn" class="btn secondary" disabled>Disconnect</button>
@@ -464,7 +464,7 @@
<section class="divPostItem section">
<h2>nostr_sign_event</h2>
<label for="nseContent">content</label>
<textarea id="nseContent" class="inpStyle">hello from cyd webserial demo</textarea>
<textarea id="nseContent" class="inpStyle">hello from usb test</textarea>
<label for="nseIdx">nostr_index</label>
<input id="nseIdx" type="number" value="0" min="0" class="inpStyle" />
<div class="row">
@@ -478,7 +478,7 @@
<h2>nostr_mine_event</h2>
<p class="warn">Slow on ESP32 — uses single-threaded PoW. Keep difficulty low.</p>
<label for="nmeContent">content</label>
<textarea id="nmeContent" class="inpStyle">mined by cyd</textarea>
<textarea id="nmeContent" class="inpStyle">mined by usb test</textarea>
<label for="nmeIdx">nostr_index</label>
<input id="nmeIdx" type="number" value="0" min="0" class="inpStyle" />
<label for="nmeDiff">difficulty (leading zero bits)</label>
@@ -530,9 +530,9 @@
<!-- OTP encrypt / decrypt -->
<section class="divPostItem section">
<h2>encrypt / decrypt (otp)</h2>
<p class="note">OTP pad is derived from the mnemonic on the CYD. Offset advances monotonically.</p>
<label for="otpPlain">plaintext (base64)</label>
<textarea id="otpPlain" class="inpStyle">SGVsbG8sIE9UUCB3b3JsZCE=</textarea>
<p class="note">OTP pad is bound from the SD card (Teensy 4.1) or derived from the mnemonic (CYD). Offset advances monotonically.</p>
<label for="otpPlain">plaintext</label>
<textarea id="otpPlain" class="inpStyle">Secret message.</textarea>
<label for="otpCipher">ciphertext (for decrypt, base64)</label>
<textarea id="otpCipher" class="inpStyle" placeholder="filled by encrypt"></textarea>
<label for="otpEnc">encoding</label>
@@ -555,17 +555,17 @@
<div id="divSideNav">
<button id="btnCloseSideNav" title="Close menu" aria-label="Close menu">×</button>
<div id="divSideNavBody">
<h3>n_signer CYD Web Serial Demo</h3>
<p>A browser control panel for the CYD (ESP32-2432S028) signer firmware. Speaks the n_signer JSON-RPC protocol over Web Serial at 115200 baud.</p>
<h3>n_signer USB Test</h3>
<p>A browser control panel for any n_signer hardware signer (Teensy 4.1, CYD ESP32-2432S028, etc.). Speaks the n_signer JSON-RPC protocol over Web Serial at 115200 baud.</p>
<h3>Getting started</h3>
<p>1. Plug the CYD into a USB port (CH340 enumerates as /dev/ttyUSB*).</p>
<p>2. Click <code>Connect Web Serial</code> and pick the CYD port.</p>
<p>1. Plug the signer into a USB port (Teensy 4.1 enumerates as /dev/ttyACM*, CYD CH340 enumerates as /dev/ttyUSB*).</p>
<p>2. Click <code>Connect Web Serial</code> and pick the signer's port.</p>
<p>3. <code>get_info</code> fires automatically and reports firmware version + supported verbs.</p>
<p>4. Enter a mnemonic on the CYD touchscreen to enable the signing verbs.</p>
<p>4. Enter a mnemonic on the signer's touchscreen to enable the signing verbs.</p>
<h3>Auth envelope</h3>
<p>Every request is signed with a demo secp256k1 key (priv = 0x0102…2020). The CYD verifies the kind-27235 auth event and replays-protects by event id.</p>
<p>Every request is signed with a demo secp256k1 key (priv = 0x0102…2020). The signer verifies the kind-27235 auth event and replays-protects by event id.</p>
<h3>Transports</h3>
<p>Frames are 4-byte big-endian length + UTF-8 JSON payload. Same wire format as the host Unix-socket and TCP transports.</p>
@@ -632,7 +632,7 @@
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "cyd-webserial-demo";
const content = "usb-test";
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await sha256Hex(utf8(ser));
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
@@ -695,7 +695,7 @@
setTimeout(() => {
if (pendingResolve === resolve) {
pendingResolve = null;
reject(new Error("timeout (30s) — check the CYD screen for an approval prompt"));
reject(new Error("timeout (30s) — check the signer screen for an approval prompt"));
}
}, 65000);
});
@@ -747,7 +747,7 @@
rxBuffer = new Uint8Array(0);
readLoop();
setStatus("Connected", "ok");
log("Connected to CYD via Web Serial @ 115200 baud");
log("Connected to signer via Web Serial @ 115200 baud");
document.querySelectorAll("button[id$='Btn']").forEach(b => {
if (b !== connectBtn && b !== disconnectBtn) b.disabled = false;
});
@@ -860,10 +860,21 @@
callVerb("nostr_mine_event", [event, { nostr_index: idx, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
});
const nip04Enc = () => {
const nip04Enc = async () => {
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value, idx = Number($("nip04Idx").value || 0);
if (!peer) { $("nip04Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("nostr_nip04_encrypt", [peer, msg, { nostr_index: idx }], $("nip04Out"));
const params = [peer, msg, { nostr_index: idx }];
$("nip04Out").textContent = "→ nostr_nip04_encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("nostr_nip04_encrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "nostr_nip04_encrypt", params, auth });
$("nip04Out").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
$("nip04Cipher").value = resp.result;
}
} catch (e) {
$("nip04Out").textContent += "\n✗ " + e.message;
}
};
const nip04Dec = () => {
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0);
@@ -873,10 +884,21 @@
$("nip04EncBtn").addEventListener("click", nip04Enc);
$("nip04DecBtn").addEventListener("click", nip04Dec);
const nip44Enc = () => {
const nip44Enc = async () => {
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value, idx = Number($("nip44Idx").value || 0);
if (!peer) { $("nip44Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("nostr_nip44_encrypt", [peer, msg, { nostr_index: idx }], $("nip44Out"));
const params = [peer, msg, { nostr_index: idx }];
$("nip44Out").textContent = "→ nostr_nip44_encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("nostr_nip44_encrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "nostr_nip44_encrypt", params, auth });
$("nip44Out").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
$("nip44Cipher").value = resp.result;
}
} catch (e) {
$("nip44Out").textContent += "\n✗ " + e.message;
}
};
const nip44Dec = () => {
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0);
@@ -886,14 +908,55 @@
$("nip44EncBtn").addEventListener("click", nip44Enc);
$("nip44DecBtn").addEventListener("click", nip44Dec);
$("otpEncBtn").addEventListener("click", () => {
/* OTP encrypt expects base64 plaintext; decrypt returns base64 plaintext.
The UI lets the user type plain text, so we encode/decode transparently. */
function utf8ToB64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function b64ToUtf8(b64) {
try { return decodeURIComponent(escape(atob(b64))); }
catch (e) { return atob(b64); }
}
$("otpEncBtn").addEventListener("click", async () => {
const pt = $("otpPlain").value, enc = $("otpEnc").value;
callVerb("encrypt", [pt, { algorithm: "otp", encoding: enc }], $("otpOut"));
const ptB64 = utf8ToB64(pt);
const params = [ptB64, { algorithm: "otp", encoding: enc }];
$("otpOut").textContent = "→ encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("encrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "encrypt", params, auth });
$("otpOut").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
/* OTP result is a JSON string: {"ciphertext": ...} */
let r = resp.result;
if (typeof r === "string") { try { r = JSON.parse(r); } catch (_) { /* leave as string */ } }
const ct = (r && r.ciphertext) ? r.ciphertext : (typeof resp.result === "string" ? resp.result : null);
if (ct) { $("otpCipher").value = ct; }
}
} catch (e) {
$("otpOut").textContent += "\n✗ " + e.message;
}
});
$("otpDecBtn").addEventListener("click", () => {
$("otpDecBtn").addEventListener("click", async () => {
const ct = $("otpCipher").value, enc = $("otpEnc").value;
if (!ct) { $("otpOut").textContent = "✗ paste ciphertext first (from encrypt)"; return; }
callVerb("decrypt", [ct, { algorithm: "otp", encoding: enc }], $("otpOut"));
const params = [ct, { algorithm: "otp", encoding: enc }];
$("otpOut").textContent = "→ decrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("decrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "decrypt", params, auth });
$("otpOut").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
let r = resp.result;
if (typeof r === "string") { try { r = JSON.parse(r); } catch (_) { /* leave as string */ } }
const ptB64 = (r && r.plaintext) ? r.plaintext : (typeof resp.result === "string" ? resp.result : null);
if (ptB64) {
$("otpOut").textContent += "\nplaintext: " + b64ToUtf8(ptB64);
}
}
} catch (e) {
$("otpOut").textContent += "\n✗ " + e.message;
}
});
if (!("serial" in navigator)) {