8.5 KiB
description
| 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 — 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:
{ "id": "<string>", "method": "<verb>", "params": [<arg0>, <arg1>, ..., {<options>}] }
Response (success):
{ "id": "<string>", "result": "<value>" }
Response (error):
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
3. Transport-specific invocation
3.1 Qubes qrexec (easiest cross-qube)
# 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):
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 for a full working demo (get_public_key → sign_event → nip44 → mine_event).
3.2 Local Unix abstract socket (same machine)
# Find running signers
nsigner list
# Connect via socat or the nsigner client subcommand
nsigner client '<json>' --socket-name <name>
C (using nostr_core_lib):
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 and 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):
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 and examples/n_signer_qube_example_fips.js.
3.4 HTTP listener
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 and 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
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)
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 |
Full Python demo (qrexec, stdlib only) |
client/demo_javascript.js |
Full Node.js demo (qrexec) |
client/demo_c99.c |
Full C99 demo (qrexec, nostr_core_lib) |
examples/get_pubkey_fips.py |
Minimal TCP/FIPS with auth envelope |
examples/get_pubkey_qrexec.c |
Minimal qrexec in C |
examples/get_pubkey_tcp.c |
Minimal TCP in C with auth envelope |
examples/n_signer_qube_example_fips.js |
TCP/FIPS in Node.js |
examples/n_signer_qube_example_qrexec.js |
qrexec in Node.js |
documents/CLIENT_IMPLEMENTATION.md |
Full wire contract spec |
documents/AGENT_CLIENT.md |
Comprehensive agent reference |