458 lines
10 KiB
Markdown
458 lines
10 KiB
Markdown
# 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.
|
|
|
|
---
|
|
|
|
## 2. Discovery and socket targeting
|
|
|
|
`nsigner` currently supports two transport families:
|
|
|
|
- Linux AF_UNIX **abstract namespace** sockets.
|
|
- Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange.
|
|
|
|
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).
|
|
|
|
---
|
|
|
|
## 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.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`
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## 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. 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. |