Files
n_signer/documents/AGENT_CLIENT.md
T

26 KiB

Agent Client Reference — n_signer

This document is the complete reference for agents, AI tools, and automated processes that need to call n_signer — a signing oracle that holds BIP-39 keys in locked memory and exposes them over multiple transports.

Table of Contents

  1. Architecture overview
  2. Wire protocol (all transports)
  3. Transport: Qubes qrexec (recommended for cross-qube)
  4. Transport: Local Unix abstract socket
  5. Transport: TCP (FIPS mesh)
  6. Transport: HTTP
  7. Transport: USB/serial (hardware signers)
  8. Transport: Stdio (one-shot)
  9. Auth envelope (kind-27235)
  10. Complete verb reference
  11. Algorithm reference
  12. Error codes
  13. End-to-end: publish a Nostr event
  14. End-to-end: sign arbitrary data
  15. End-to-end: PQ KEM encapsulate/decapsulate
  16. Discovery: finding running signers
  17. Prerequisites and setup
  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

{
  "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:

{ "id": "<string>", "result": "<value>" }

Error:

{ "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.


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

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

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

3.3 Node.js

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

3.4 C (using nostr_core_lib)

#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

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)

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

nsigner list
# Output: @nsigner, @nsigner_hairy_dog, etc.

4.2 Using the nsigner CLI

nsigner client '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}' --socket-name nsigner

4.3 C (using nostr_core_lib)

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

4.4 Python (raw socket)

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

5.1 Python (with coincurve)

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 and examples/n_signer_qube_example_fips.js.

5.2 C (using nostr_core_lib)

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.


6. Transport: HTTP

Best for: curl-friendly callers, REST clients.

Auth: Required — kind-27235 auth envelope in the JSON body.

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)

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 and 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.

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

{
  "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 for a complete working example.

9.5 JavaScript example (full)

See 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 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

// 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:

{"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):

#!/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):

# 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

# 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

nsigner list

Output (one per line):

@nsigner
@nsigner_hairy_dog
@nsigner_brave_canyon

16.2 Check if a specific signer is running

nsigner list | grep -q @nsigner && echo "running" || echo "not running"

16.3 Programmatic discovery (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

# 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

# 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 Full Python demo (qrexec, stdlib only) — get_public_key, sign_event, nip44, mine_event
client/demo_javascript.js Full Node.js demo (qrexec) — same operations
client/demo_c99.c Full C99 demo (qrexec, nostr_core_lib) — same operations
examples/get_pubkey_fips.py Minimal TCP/FIPS with auth envelope (Python)
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 with auth envelope
examples/n_signer_qube_example_qrexec.js qrexec in Node.js
examples/get_public_key_client.c Unix socket in C
examples/sign_event_client.c Unix socket sign event in C
examples/feather_get_public_key.py USB/serial hardware signer (Python)
examples/feather_sign_event.py USB/serial sign event (Python)
examples/pq_sign_example.c ML-DSA-65 sign in C
examples/pq_kem_example.c ML-KEM-768 encaps/decaps in C
examples/ssh_sign_example.c ed25519 SSH sign in C
documents/CLIENT_IMPLEMENTATION.md Full wire contract spec (733 lines)
README.md Full n_signer documentation (741 lines)
packaging/qubes/setup_signer_qube.sh Automated signer qube setup
.roo/n_signer_client.md Roo skill file (agent-invocable)