# 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

```bash
nsigner list
```

Expected output format (one per line):

```text
@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

```json
{ "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}]`)
- `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](../README.md) 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

```json
{
  "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)

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

```python
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)

```ts
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:

```json
{ "id": "1", "method": "get_public_key", "params": [] }
```

Response:

```json
{ "id": "1", "result": "<hex_pubkey>" }
```

## 10.2 Happy path: sign event with explicit role

Request:

```json
{
  "id": "2",
  "method": "sign_event",
  "params": ["<event_json>", { "role": "main" }]
}
```

Response:

```json
{ "id": "2", "result": "<signed_event_json>" }
```

## 10.3 Error path: unknown role

Request:

```json
{
  "id": "3",
  "method": "sign_event",
  "params": ["<event_json>", { "role": "does_not_exist" }]
}
```

Response:

```json
{ "id": "3", "error": { "code": 1002, "message": "unknown_role" } }
```

## 10.4 Error path: ambiguous selector

Request:

```json
{
  "id": "4",
  "method": "sign_event",
  "params": [
    "<event_json>",
    { "role": "main", "nostr_index": 0 }
  ]
}
```

Response:

```json
{ "id": "4", "error": { "message": "ambiguous_role_selector" } }
```

## 10.5 Encrypt/decrypt round trip (NIP-44)

Encrypt request:

```json
{
  "id": "5",
  "method": "nip44_encrypt",
  "params": ["<peer_pubkey>", "hello", { "role": "main" }]
}
```

Encrypt response:

```json
{ "id": "5", "result": "<nip44_ciphertext>" }
```

Decrypt request:

```json
{
  "id": "6",
  "method": "nip44_decrypt",
  "params": ["<peer_pubkey>", "<nip44_ciphertext>", { "role": "main" }]
}
```

Decrypt response:

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

```json
{ "id": "1", "result": "<64-char hex pubkey>" }
```

**For secp256k1 with `format: "structured"` option:** new clients can request
the structured format for consistency:

Request:
```json
{
  "id": "1",
  "method": "get_public_key",
  "params": [{ "role": "main", "format": "structured" }]
}
```

Response:
```json
{
  "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:

```json
{
  "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:
```json
{
  "id": "10",
  "method": "sign_data",
  "params": ["68656c6c6f", { "role": "pq_sig" }]
}
```

Response:
```json
{
  "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:
```json
{
  "id": "11",
  "method": "verify_signature",
  "params": ["<msg_hex>", "<sig_hex>", { "role": "ssh_main" }]
}
```

Response:
```json
{ "id": "11", "result": "{\"valid\":true}" }
```

The signature is verified against the role's derived public key.

### 11.6 Example: `ssh_sign` (ed25519)

Request:
```json
{
  "id": "12",
  "method": "ssh_sign",
  "params": ["<session_id_hex>", { "role": "ssh_main" }]
}
```

Response:
```json
{
  "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:
```json
{
  "id": "13",
  "method": "kem_encapsulate",
  "params": ["<peer_pubkey_hex>", { "role": "kem_main" }]
}
```

Response:
```json
{
  "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:
```json
{
  "id": "14",
  "method": "kem_decapsulate",
  "params": ["<ciphertext_hex>", { "role": "kem_main" }]
}
```

Response:
```json
{
  "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`](../examples/pq_sign_example.c) — ML-DSA-65
  `get_public_key` + `sign_data`
- [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) — ML-KEM-768
  `get_public_key` + `kem_encapsulate` + `kem_decapsulate` (verifies shared
  secrets match)
- [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) — ed25519
  `get_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_found` fallback).
- Treat approval as asynchronous human gating even for local calls.
- Track and surface signer name, request id, and method for auditability.