Files
n_signer/documents/CLIENT_IMPLEMENTATION.md
T

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.c and examples/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 stdio and --listen qrexec) for one request/response exchange.
  • TCP framed mode (--listen tcp:IPv4:PORT or --listen tcp:[IPv6]:PORT).

For AF_UNIX:

  • Socket names are exposed in /proc/net/unix with 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:

  1. Explicit target (recommended): pass --socket-name / --name / -n.
  2. Enumerate with nsigner list and select one.
  3. 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 from QREXEC_REMOTE_DOMAIN as qubes:<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 as qubes:<vm-name> only.
  • --listen qrexec --auth optional: if the request includes an auth field, it is verified using the same auth envelope rules as TCP. On success, caller identity is upgraded to qubes:<vm-name>+pubkey:<hex>.
  • --listen qrexec --auth required: every request must carry a valid auth envelope, and caller identity is qubes:<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 auth returns {"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: N bytes UTF-8 JSON text
  • One JSON-RPC object per frame

3.1 Framing pseudocode

Write:

  1. Serialize JSON to bytes
  2. Compute len(payload)
  3. Send uint32_be(length) then payload bytes

Read:

  1. Read exactly 4 bytes
  2. Parse big-endian payload length
  3. Read exactly length bytes
  4. 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_key
  • sign_event
  • nip04_encrypt
  • nip04_decrypt
  • nip44_encrypt
  • nip44_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}])
  • derive — HMAC-SHA256(privkey, data) key-derived MAC (params: [data, {algorithm:"secp256k1", index}]; index required). Returns {algorithm, key_id, digest} where digest is 64 hex chars. Use for deterministic opaque identifiers (e.g. NIP-33 d tags) keyed by the derived private key.
  • get_public_key with algorithm parameter — 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:

  • role — name of a pre-registered role entry
  • role_path — full BIP-44 derivation path

Resolution order:

  1. role
  2. role_path
  3. 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_request
  • method_not_found
  • ambiguous_role_selector
  • unknown_role
  • purpose_mismatch
  • curve_mismatch
  • unauthorized
  • approval_denied
  • internal_error
  • auth_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:

  1. Policy/identity checks (caller and method authorization)
  2. 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_denied as 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 id correlation 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", "role_path": "m/44'/1237'/0'/0/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:


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_found fallback).
  • Treat approval as asynchronous human gating even for local calls.
  • Track and surface signer name, request id, and method for auditability.