20 KiB
CLIENT_IMPLEMENTATION.md
1. Purpose
This document is the client-integration spec for nsigner.
It is written for agent/tool authors implementing robust request flows against the local signer process.
Reference implementation in this repository:
- Reusable C client library:
client/nsigner_client.h+client/nsigner_client.c - Minimal usage examples:
examples/get_public_key_client.candexamples/sign_event_client.c - Auth envelope builder used by the client:
src/auth_envelope.h(auth_envelope_build_for_request)
2. Discovery and socket targeting
nsigner currently supports three transport families:
- Linux AF_UNIX abstract namespace sockets.
- Stdio framed mode (
--listen stdioand--listen qrexec) for one request/response exchange. - TCP framed mode (
--listen tcp:IPv4:PORTor--listen tcp:[IPv6]:PORT).
For AF_UNIX:
- Socket names are exposed in
/proc/net/unixwith a leading@. - Typical runtime names:
@nsigner_hairy_dog,@nsigner_brave_canyon. - Clients pass the socket name without
@to CLI flags (example:nsigner_hairy_dog).
2.1 Discovery rules
Use one of these patterns:
- Explicit target (recommended): pass
--socket-name/--name/-n. - Enumerate with
nsigner listand select one. - Auto-discovery only when exactly one signer is running.
2.2 Enumerating running signers
nsigner list
Expected output format (one per line):
@nsigner
@nsigner_hairy_dog
@nsigner_brave_canyon
Clients should accept both @nsigner and @nsigner_* names.
2.3 Stdio / qrexec mode
In server mode:
nsigner --listen stdio: reads exactly one framed request from stdin and writes one framed response to stdout.nsigner --listen qrexec: same behavior, but caller identity may be tagged fromQREXEC_REMOTE_DOMAINasqubes:<vm-name>.
This mode is server-side only in the current CLI (the client subcommand still targets AF_UNIX).
2.4 qrexec authentication posture (--auth)
qrexec now supports configurable auth-envelope handling:
--listen qrexec --auth off(default): legacy behavior. Requests are authorized asqubes:<vm-name>only.--listen qrexec --auth optional: if the request includes anauthfield, it is verified using the same auth envelope rules as TCP. On success, caller identity is upgraded toqubes:<vm-name>+pubkey:<hex>.--listen qrexec --auth required: every request must carry a validauthenvelope, and caller identity isqubes:<vm-name>+pubkey:<hex>.
When --auth optional is used, a malformed or invalid auth object is rejected with auth-layer errors (2010..2017) rather than silently falling back to qubes:<vm-name>.
2.5 TCP mode authentication (required)
For TCP transport, requests MUST include an auth object containing a signed Nostr-style event envelope.
- Missing
authreturns{"error":{"code":2014,"message":"auth_envelope_required"}}. - Signature verification, method/id/body binding, timestamp skew checks, and replay checks are enforced before policy lookup.
- On success, caller identity is normalized to
pubkey:<hex>for policy checks.
3. Transport framing
Signer requests/responses use a length-prefixed frame format over the socket:
- Prefix: 4-byte unsigned big-endian length
N - Payload:
Nbytes UTF-8 JSON text - One JSON-RPC object per frame
3.1 Framing pseudocode
Write:
- Serialize JSON to bytes
- Compute
len(payload) - Send
uint32_be(length)then payload bytes
Read:
- Read exactly 4 bytes
- Parse big-endian payload length
- Read exactly
lengthbytes - Parse JSON
Do not assume line-delimited JSON.
4. JSON-RPC contract
4.1 Request shape
{ "id": "1", "method": "get_public_key", "params": [] }
Methods are NIP-46 style verbs.
4.2 Implemented methods
get_public_keysign_eventnip04_encryptnip04_decryptnip44_encryptnip44_decrypt
4.2b Algorithm-based verbs (new)
In addition to the role-based verbs above, the signer supports algorithm-based verbs where the caller specifies algorithm and index directly:
sign— sign arbitrary bytes (params:[message_hex, {algorithm, index, scheme?}])verify— verify a signature (params:[message_hex, signature_hex, {algorithm, index, scheme?}])encapsulate— KEM encapsulation (params:[peer_pubkey_hex, {algorithm}])decapsulate— KEM decapsulation (params:[ciphertext_hex, {algorithm, index}])derive_shared_secret— ECDH key agreement (params:[peer_pubkey_hex, {algorithm, index}])get_public_keywithalgorithmparameter — returns structured JSON
Algorithm names: secp256k1, ed25519, ml-dsa-65, slh-dsa-128s, x25519, ml-kem-768
For secp256k1 sign/verify, the optional scheme parameter selects "schnorr" (default, BIP-340) or "ecdsa".
Old verb aliases (sign_data, ssh_sign, verify_signature, kem_encapsulate, kem_decapsulate) map to the new verbs when used with the algorithm parameter. Without algorithm, they fall through to the role-based path.
See README.md §4c for full details.
4.3 Selector options
The last param may include selector options:
rolenostr_indexrole_path
Resolution order:
rolenostr_indexrole_path- default role
main
Conflicting selector fields must be rejected as ambiguous_role_selector.
4.4 Selector example
{
"id": "2",
"method": "sign_event",
"params": [
"<event_json>",
{ "role": "main" }
]
}
5. Error handling contract
Representative error names clients must handle:
invalid_requestmethod_not_foundambiguous_role_selectorunknown_rolepurpose_mismatchcurve_mismatchunauthorizedapproval_deniedinternal_errorauth_envelope_malformed(2010)auth_body_mismatch(2011)auth_signature_invalid(2012)auth_kind_invalid(2013)auth_envelope_required(2014)auth_envelope_mismatch(2015)auth_envelope_stale(2016)auth_replay_detected(2017)
5.1 Recovery guidance
invalid_request: client bug or malformed payload; fix request and retry.method_not_found: version mismatch; feature-detect and downgrade behavior.ambiguous_role_selector: send exactly one selector strategy.unknown_role: selector did not resolve; verify role inventory/config.purpose_mismatch/curve_mismatch: selected key is incompatible with method; pick compatible selector.unauthorized: caller identity disallowed by policy; do not blind-retry.approval_denied: user rejected prompt; treat as final unless user initiates retry.internal_error: bounded retry with backoff; surface diagnostics.auth_envelope_*/auth_*(2010-2017): fix request signing/auth envelope generation; do not blind-retry unchanged payloads.
6. Approval semantics
Approval has two layers:
- Policy/identity checks (caller and method authorization)
- User prompt (interactive allow/deny)
Important behavior:
- Passing identity checks does not bypass prompts.
- Non-interactive/no-TTY contexts can resolve to deny by policy/test configuration.
- Clients must treat
approval_deniedas a normal, expected outcome.
6.1 UX recommendation
If a signing call is denied, return control to the user and let them explicitly retry.
7. Multi-instance, concurrency, and timeouts
7.1 Multi-instance safety
- Multiple signers can coexist using different socket names.
- Always pin requests to a selected signer name once chosen.
- Avoid “discover per request” after initial bind in long-running clients.
7.2 Connection strategy
- Keep one connection per in-flight request path, or serialize requests if your client runtime is simple.
- Validate response
idcorrelation before completing promise/future.
7.3 Timeout guidance
Use separate timeouts:
- connect timeout (short)
- write timeout (short)
- read timeout (longer, because human approval may be required)
For prompt-requiring methods, use a read timeout that accounts for user interaction.
8. Security expectations for clients
- Treat socket access as sensitive local capability.
- Do not log plaintext secrets, private keys, or full decrypted payloads.
- Redact request params for encrypt/decrypt methods in normal logs.
- Validate method-level expectations before sending (selector + purpose compatibility).
- Use least-privilege execution context for any process that can reach the signer socket.
9. Reference code snippets
9.1 C (frame write/read skeleton)
#include <arpa/inet.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
static int write_all(int fd, const void *buf, size_t len) {
const unsigned char *p = (const unsigned char *)buf;
while (len > 0) {
ssize_t n = write(fd, p, len);
if (n <= 0) return -1;
p += (size_t)n;
len -= (size_t)n;
}
return 0;
}
static int read_all(int fd, void *buf, size_t len) {
unsigned char *p = (unsigned char *)buf;
while (len > 0) {
ssize_t n = read(fd, p, len);
if (n <= 0) return -1;
p += (size_t)n;
len -= (size_t)n;
}
return 0;
}
int send_json_frame(int fd, const char *json) {
uint32_t n = (uint32_t)strlen(json);
uint32_t be = htonl(n);
if (write_all(fd, &be, 4) != 0) return -1;
if (write_all(fd, json, n) != 0) return -1;
return 0;
}
9.2 Python (Unix abstract socket + frame)
import json
import socket
import struct
def send_rpc(socket_name: str, obj: dict) -> dict:
payload = json.dumps(obj, separators=(",", ":")).encode("utf-8")
frame = struct.pack(">I", len(payload)) + payload
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.connect("\0" + socket_name) # abstract namespace
s.sendall(frame)
hdr = recv_exact(s, 4)
ln = struct.unpack(">I", hdr)[0]
body = recv_exact(s, ln)
return json.loads(body.decode("utf-8"))
finally:
s.close()
def recv_exact(s: socket.socket, n: int) -> bytes:
out = bytearray()
while len(out) < n:
chunk = s.recv(n - len(out))
if not chunk:
raise ConnectionError("unexpected EOF")
out.extend(chunk)
return bytes(out)
9.3 TypeScript (Node.js net + frame)
import net from "node:net";
export async function sendRpc(socketName: string, request: unknown): Promise<any> {
const payload = Buffer.from(JSON.stringify(request), "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(payload.length, 0);
return await new Promise((resolve, reject) => {
const socket = net.createConnection({ path: `\u0000${socketName}` });
let chunks: Buffer[] = [];
let needed = 4;
let mode: "header" | "body" = "header";
socket.on("connect", () => {
socket.write(Buffer.concat([header, payload]));
});
socket.on("data", (data) => {
chunks.push(data);
let buf = Buffer.concat(chunks);
while (buf.length >= needed) {
const part = buf.subarray(0, needed);
buf = buf.subarray(needed);
if (mode === "header") {
needed = part.readUInt32BE(0);
mode = "body";
} else {
socket.end();
resolve(JSON.parse(part.toString("utf8")));
return;
}
}
chunks = [buf];
});
socket.on("error", reject);
socket.on("end", () => {
// no-op; resolution occurs when full body is parsed
});
});
}
10. End-to-end transcripts
10.1 Happy path: get public key
Request:
{ "id": "1", "method": "get_public_key", "params": [] }
Response:
{ "id": "1", "result": "<hex_pubkey>" }
10.2 Happy path: sign event with explicit role
Request:
{
"id": "2",
"method": "sign_event",
"params": ["<event_json>", { "role": "main" }]
}
Response:
{ "id": "2", "result": "<signed_event_json>" }
10.3 Error path: unknown role
Request:
{
"id": "3",
"method": "sign_event",
"params": ["<event_json>", { "role": "does_not_exist" }]
}
Response:
{ "id": "3", "error": { "code": 1002, "message": "unknown_role" } }
10.4 Error path: ambiguous selector
Request:
{
"id": "4",
"method": "sign_event",
"params": [
"<event_json>",
{ "role": "main", "nostr_index": 0 }
]
}
Response:
{ "id": "4", "error": { "message": "ambiguous_role_selector" } }
10.5 Encrypt/decrypt round trip (NIP-44)
Encrypt request:
{
"id": "5",
"method": "nip44_encrypt",
"params": ["<peer_pubkey>", "hello", { "role": "main" }]
}
Encrypt response:
{ "id": "5", "result": "<nip44_ciphertext>" }
Decrypt request:
{
"id": "6",
"method": "nip44_decrypt",
"params": ["<peer_pubkey>", "<nip44_ciphertext>", { "role": "main" }]
}
Decrypt response:
{ "id": "6", "result": "hello" }
11. Post-Quantum and Multi-Algorithm Support
n_signer supports six cryptographic algorithms, all derived deterministically from the same BIP-39 mnemonic via distinct derivation paths:
| Algorithm | Purpose | Curve string | Purpose string | Derivation path |
|---|---|---|---|---|
secp256k1 |
Nostr (sign_event, NIP-04/44) | secp256k1 |
nostr |
m/44'/1237'/<n>'/0/0 (NIP-06) |
ed25519 |
SSH signing, general signatures | ed25519 |
ssh |
m/44'/102001'/<n>'/0'/0' (SLIP-0010) |
x25519 |
Key agreement (age, ECDH) | x25519 |
age |
m/44'/102002'/<n>'/0'/0' (SLIP-0010) |
ml-dsa-65 |
Post-quantum signatures (FIPS 204) | ml-dsa-65 |
pq-sig |
m/44'/102003'/<n>'/0'/0' → seed → PQClean keygen |
slh-dsa-128s |
Post-quantum hash-based signatures (FIPS 205) | slh-dsa-128s |
pq-sig |
m/44'/102004'/<n>'/0'/0' → seed → PQClean keygen |
ml-kem-768 |
Post-quantum key encapsulation (FIPS 203) | ml-kem-768 |
pq-kem |
m/44'/102005'/<n>'/0'/0' → seed → PQClean keygen |
The 102XXX coin types are unregistered in SLIP-44 and reserved by n_signer
for PQ/SSH/age algorithm families. All non-secp256k1 paths use SLIP-0010
all-hardened derivation.
11.1 Algorithm key sizes
| Algorithm | Pub key | Priv key | Signature | Ciphertext | Shared secret |
|---|---|---|---|---|---|
| secp256k1 | 32 bytes | 32 bytes | 64 bytes | — | — |
| ed25519 | 32 bytes | 32 bytes | 64 bytes | — | — |
| x25519 | 32 bytes | 32 bytes | — | — | 32 bytes |
| ML-DSA-65 | 1952 bytes | 4032 bytes | 3309 bytes | — | — |
| SLH-DSA-128s | 32 bytes | 64 bytes | 7856 bytes | — | — |
| ML-KEM-768 | 1184 bytes | 2400 bytes | — | 1088 bytes | 32 bytes |
PQ public keys and signatures are much larger than classical ones. Clients must allocate buffers accordingly (ML-DSA-65 pubkey hex = 3904 chars; SLH-DSA-128s signature hex = 15712 chars; ML-KEM-768 pubkey hex = 2368 chars).
11.2 New verbs
| Verb | Purpose | Allowed (purpose, curve) | Description |
|---|---|---|---|
sign_data |
pq-sig, ssh | (pq-sig, ml-dsa-65), (pq-sig, slh-dsa-128s), (ssh, ed25519) | Sign arbitrary bytes (not a Nostr event) |
verify_signature |
pq-sig, ssh | same as sign_data |
Verify a signature against the role's public key |
ssh_sign |
ssh | (ssh, ed25519) | Sign an SSH authentication challenge (ed25519) |
kem_encapsulate |
pq-kem | (pq-kem, ml-kem-768) | Encapsulate: generate ciphertext + shared secret from a peer's ML-KEM public key |
kem_decapsulate |
pq-kem | (pq-kem, ml-kem-768) | Decapsulate: recover shared secret from ciphertext using the role's ML-KEM private key |
The existing Nostr verbs (sign_event, nip44_*, nip04_*, mine_event)
remain restricted to purpose=nostr + curve=secp256k1.
11.3 Structured get_public_key response format
get_public_key is a universal verb — it works for all six algorithms.
For secp256k1 (backward compatibility): the result is a plain hex string (the existing format). Existing Nostr clients are unaffected.
{ "id": "1", "result": "<64-char hex pubkey>" }
For secp256k1 with format: "structured" option: new clients can request
the structured format for consistency:
Request:
{
"id": "1",
"method": "get_public_key",
"params": [{ "role": "main", "format": "structured" }]
}
Response:
{
"id": "1",
"result": "{\"algorithm\":\"secp256k1\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}"
}
For all other algorithms (ed25519, x25519, ML-DSA-65, SLH-DSA-128s, ML-KEM-768): the result is always a structured JSON object serialized as a string:
{
"id": "1",
"result": {
"algorithm": "ml-dsa-65",
"public_key": "<hex-encoded public key>",
"key_id": "<first 16 hex chars of public key>"
}
}
The key_id is the first 16 hex characters of the public key — a short
display identifier similar to an SSH key fingerprint. The result field is a
JSON string (the object serialized), so clients must parse it twice: once for
the JSON-RPC envelope, once for the result object.
11.4 Example: sign_data (ML-DSA-65)
Request:
{
"id": "10",
"method": "sign_data",
"params": ["68656c6c6f", { "role": "pq_sig" }]
}
Response:
{
"id": "10",
"result": "{\"signature\":\"<hex>\",\"algorithm\":\"ml-dsa-65\"}"
}
The first param is the message bytes as hex. The signature is hex-encoded (3309 bytes = 6618 hex chars for ML-DSA-65).
11.5 Example: verify_signature (ed25519)
Request:
{
"id": "11",
"method": "verify_signature",
"params": ["<msg_hex>", "<sig_hex>", { "role": "ssh_main" }]
}
Response:
{ "id": "11", "result": "{\"valid\":true}" }
The signature is verified against the role's derived public key.
11.6 Example: ssh_sign (ed25519)
Request:
{
"id": "12",
"method": "ssh_sign",
"params": ["<session_id_hex>", { "role": "ssh_main" }]
}
Response:
{
"id": "12",
"result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\"}"
}
The first param is the SSH session ID (or challenge) as hex. The signature is a raw ed25519 signature (64 bytes = 128 hex chars).
11.7 Example: kem_encapsulate (ML-KEM-768)
Request:
{
"id": "13",
"method": "kem_encapsulate",
"params": ["<peer_pubkey_hex>", { "role": "kem_main" }]
}
Response:
{
"id": "13",
"result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}"
}
The first param is the peer's ML-KEM-768 public key as hex (1184 bytes = 2368 hex chars). The response contains the ciphertext (1088 bytes = 2176 hex chars) and the shared secret (32 bytes = 64 hex chars). The encapsulating party keeps the shared secret; the ciphertext is sent to the decapsulating party.
11.8 Example: kem_decapsulate (ML-KEM-768)
Request:
{
"id": "14",
"method": "kem_decapsulate",
"params": ["<ciphertext_hex>", { "role": "kem_main" }]
}
Response:
{
"id": "14",
"result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}"
}
The first param is the ciphertext from kem_encapsulate (1088 bytes = 2176
hex chars). The decapsulated shared secret will match the encapsulating
party's shared secret.
11.9 Example clients
See the examples/ directory for working C clients demonstrating the new
verbs:
examples/pq_sign_example.c— ML-DSA-65get_public_key+sign_dataexamples/pq_kem_example.c— ML-KEM-768get_public_key+kem_encapsulate+kem_decapsulate(verifies shared secrets match)examples/ssh_sign_example.c— ed25519get_public_key+ssh_sign
12. Compatibility notes
- If you are writing an autonomous agent client, pin to explicit socket name and explicit role selector.
- Keep method support feature-detected (
method_not_foundfallback). - Treat approval as asynchronous human gating even for local calls.
- Track and surface signer name, request id, and method for auditability.