Files
n_signer/.roo/n_signer_client.md
T

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