Files
n_signer/documents/CLIENT_IMPLEMENTATION.md

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}])
  • deriveHMAC-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
  • nostr_index
  • role_path

Resolution order:

  1. role
  2. nostr_index
  3. role_path
  4. 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", "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:


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.