v0.0.52 - Added api.md with unified verb scheme, fixed TUI status display in README, added TCP/HTTP port auto-increment on EADDRINUSE (up to 5 tries)

This commit is contained in:
Laan Tungir
2026-07-20 09:07:21 -04:00
parent db274ce487
commit 0355744103
5 changed files with 596 additions and 93 deletions

View File

@@ -92,7 +92,7 @@ When started, `n_signer` immediately enters terminal input mode:
5. **OTP pad selection** (optional): auto-scans attached USB drives for OTP pads and offers to bind one. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md). 5. **OTP pad selection** (optional): auto-scans attached USB drives for OTP pads and offers to bind one. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md).
6. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override). 6. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
7. Initialize transport endpoints and bind the socket. 7. Initialize transport endpoints and bind the socket.
8. Switch to running status display, with the signer name, socket address, and OTP pad status shown in the banner. 8. Switch to running status display, with the signer name and socket address shown in the Connections section and status line (see [§3.2](#32-running-phase-status-display--signer)).
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash. No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
@@ -105,46 +105,42 @@ These modes avoid putting mnemonic material in argv/environment and are designed
### 3.2 Running phase (status display + signer) ### 3.2 Running phase (status display + signer)
After unlock, terminal becomes a live status and control console. Example layout: After unlock, the terminal becomes a live status and control console rendered by [`render_status()`](src/main.c). The top frame shows the program name and version; below it are the Connections, Roles, and Activity sections, followed by a single status line. Example layout (single Unix listener, one role derived):
```text ```text
n_signer v0.x | foreground session active n_signer v0.0.2 > Main Menu
Connections Connections:
Server: unix @nsigner (bridge-source-trusted) Server: unix @nsigner_hairy_dog
Client: nsigner --socket-name nsigner client '<json>' Client: nsigner --socket-name nsigner_hairy_dog client '<json>'
Qrexec: qrexec-client-vm <target_qube> qubes.NsignerRpc
Server: HTTP 127.0.0.1:11111
Client: curl -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' -d '<json>'
OTP pad: 333e9902db839d9d... (offset 288 / 1048576 bytes) OTP pad: 333e9902db839d9d... (offset 288 / 1048576 bytes)
session: unlocked (RAM-only) Roles:
Role Purpose Curve Selector
main nostr secp256k1 role:main
ops nostr secp256k1 nostr_index:7
backup bitcoin secp256k1 role_path:m/84'/0'/0'/0/5
Roles Activity (latest first):
-----
main purpose=nostr curve=secp256k1 selector=role:main
ops purpose=nostr curve=secp256k1 selector=nostr_index:7
backup purpose=bitcoin curve=secp256k1 selector=role_path:m/84'/0'/0'/0/5
Pending approvals
-----------------
(none)
Activity (latest first)
-----------------------
16:03:11 allow caller=uid:1000 method=get_public_key role=main 16:03:11 allow caller=uid:1000 method=get_public_key role=main
16:02:44 prompt caller=uid:1000 method=sign_event role=ops 16:02:44 prompt caller=uid:1000 method=sign_event role=ops
16:02:46 allow caller=uid:1000 method=sign_event role=ops 16:02:46 allow caller=uid:1000 method=sign_event role=ops
15:59:10 deny caller=uid:1001 method=sign_event error=unauthorized 15:59:10 deny caller=uid:1001 method=sign_event error=unauthorized
Hotkeys session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
-------
a toggle auto-approve(prompt) for this session
r refresh
l lock/reunlock session
q quit
``` ```
The signer's name (`nsigner_hairy_dog` in this example) appears in two places: the **Connections** section as the abstract socket address (`Server: unix @nsigner_hairy_dog`), and the **status line** at the bottom (`signer=nsigner_hairy_dog`). See [§7.1](#71-linux-desktop-abstract-namespace-unix-socket) for how the name is generated.
When multiple transports are active (interactive transport selection), the Connections section lists each one with its client command. HTTP and FIPS/TCP entries show `curl` / `nsigner --listen ... client` examples respectively.
Hotkeys (active while the status display is shown):
- `a` — toggle auto-approve (prompt) for this session
- `r` — refresh the display
- `l` — lock / re-unlock the session
- `q` — quit
### 3.3 Approval prompts ### 3.3 Approval prompts
When a request needs confirmation, `n_signer` interrupts the status view with a prompt and waits for a local keystroke. When a request needs confirmation, `n_signer` interrupts the status view with a prompt and waits for a local keystroke.
@@ -334,6 +330,8 @@ nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsula
## 5. Wire contract (JSON-RPC) ## 5. Wire contract (JSON-RPC)
> **The full, authoritative API reference now lives in [`api.md`](api.md).** The summary below is kept for orientation; for request/response shapes, error codes, and worked examples, consult [`api.md`](api.md).
Request shape is JSON-RPC with NIP-46-style methods and optional trailing selector options. Request shape is JSON-RPC with NIP-46-style methods and optional trailing selector options.
```jsonc ```jsonc
@@ -522,7 +520,7 @@ Properties:
Naming rules: Naming rules:
- Default: random pick at startup, displayed in the TUI banner. - Default: random pick at startup, displayed in the Connections section and status line of the running display.
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name (useful for scripts and tests). - Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name (useful for scripts and tests).
- Collision: if the chosen random name is already bound by another process, `nsigner` retries with a fresh pair up to 8 times before erroring out with a hint to use an explicit name override. - Collision: if the chosen random name is already bound by another process, `nsigner` retries with a fresh pair up to 8 times before erroring out with a hint to use an explicit name override.
@@ -678,7 +676,7 @@ Setup scripts and policy are in [`packaging/qubes/`](packaging/qubes/). See also
nsigner nsigner
``` ```
Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the TUI banner shows the randomly assigned signer name and its abstract socket address. Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the running status display shows the randomly assigned signer name and its abstract socket address in the Connections section and the bottom status line (see [§3.2](#32-running-phase-status-display--signer)).
To force a specific socket name (e.g. for scripted clients): To force a specific socket name (e.g. for scripted clients):
@@ -764,10 +762,7 @@ Terminal A:
```text ```text
$ nsigner $ nsigner
[unlock] enter mnemonic: [unlock] enter mnemonic:
[ok] session unlocked System is ready and waiting for connections on @nsigner_hairy_dog.
signer name : hairy dog
socket : @nsigner_hairy_dog
[listen] unix-abstract:@nsigner_hairy_dog
[prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n) [prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n)
``` ```

382
api.md Normal file
View File

@@ -0,0 +1,382 @@
# n_signer API
`n_signer` exposes a JSON-RPC 2.0-style request/response protocol. Every request is a single JSON object; every response is a single JSON object. This document is the complete, authoritative description of the API.
For the security model, transports, and operational behavior, see [`README.md`](README.md). For the migration plan from the legacy verb names, see [`plans/legacy_verb_aliases.md`](plans/legacy_verb_aliases.md).
---
## 1. API format
### 1.1 Request
```json
{ "id": "<string>", "method": "<verb>", "params": [ <arg0>, <arg1>, ..., { <options> } ] }
```
- `id` — caller-supplied string echoed verbatim in the response. Used to match requests to responses.
- `method` — the verb name (see [§2](#2-verbs)).
- `params` — a JSON array. Positional arguments come first; the **last array element** is conventionally an options object. The options object is optional for most verbs.
### 1.2 Response (success)
```json
{ "id": "<string>", "result": <value> }
```
`result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it.
### 1.3 Response (error)
```json
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
```
### 1.4 Error codes
| Code | Message | Meaning |
|-------|-------------------------------|--------------------------------------------------------------------|
| -32700| `parse_error` | Request was not valid JSON. |
| -32600| `invalid_request` | Missing `id`, `method`, or `params`, or `params` is not an array. |
| -32601| `method_not_found` | Unknown verb, or verb not valid for the selected algorithm. |
| -32602| `invalid_params` | Malformed arguments (bad hex, wrong length, missing field, etc.). |
| 1001 | `ambiguous_role_selector` | More than one role selector was supplied. |
| 1002 | `unknown_role` | No role matched the selector. |
| 1003 | `no_default_role` | No selector given and no `main` role exists. |
| 1004 | `purpose_mismatch` | Role's purpose is not valid for this verb. |
| 1005 | `curve_mismatch` | Role's curve is not valid for this verb. |
| 1006 | `mnemonic_not_loaded` | No mnemonic is loaded in the signer. |
| 1007 | `no_termination_condition` | `nostr_mine_event` called without `difficulty` or `timeout_sec`. |
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
---
## 2. Verbs
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§3](#3-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `nostr_index` and implement Nostr-protocol-specific serialization on top of the raw crypto.
| Verb | Algorithms | Positional params | Options |
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
| `get_public_key` | all key-deriving algorithms | — | `algorithm`, `index` |
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>` | `algorithm`, `index`, `scheme`* |
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>`, `<signature_hex>` | `algorithm`, `index`, `scheme`* |
| `encapsulate` | ml-kem-768 | `<peer_pubkey_hex>` | `algorithm` |
| `decapsulate` | ml-kem-768 | `<ciphertext_hex>` | `algorithm`, `index` |
| `derive_shared_secret` | x25519 | `<peer_pubkey_hex>` | `algorithm`, `index` |
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `nostr_index`, `format` |
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index` |
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` |
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
\* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
### 2.1 Enforcement matrix
| Verb | Valid algorithms |
|----------------------------|-----------------------------------------------|
| `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
| `encapsulate` / `decapsulate` | ml-kem-768 |
| `derive_shared_secret` | x25519 |
| `encrypt` / `decrypt` | otp |
| `get_public_key` | all key-deriving algorithms |
| `nostr_*` | secp256k1 (Nostr protocol) |
Any unlisted `(verb, algorithm)` pair is rejected with `algorithm_not_supported_for_verb` (1010).
---
## 3. Algorithms
All keys derive deterministically from the loaded BIP-39 mnemonic. The caller selects an algorithm by name and a derivation `index` (an integer `<n>` substituted into the algorithm's derivation path). OTP is the exception — it does not derive a key, it consumes a bound one-time pad (see [§3.3](#33-otp)).
### 3.1 Algorithm table
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|-----------------|-----------------|---------------|---------------------------------------|-------------------------------|
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0/0'` → DRBG | 4032 / 1952 |
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
### 3.2 Key derivation
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces PQClean's `randombytes()` callback during keygen. Same mnemonic, same index, same key pair every time.
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
### 3.3 OTP
The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response.
---
## 4. Examples
### 4.1 `get_public_key`
```json
{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }
```
Response:
```json
{ "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" }
```
`key_id` is the first 16 hex characters of the public key — a short display identifier.
### 4.2 `sign`
```json
{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }
```
Response:
```json
{ "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" }
```
The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`:
```json
{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }
```
### 4.3 `verify`
```json
{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }
```
Response:
```json
{ "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" }
```
The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library.
### 4.4 `encapsulate` (ML-KEM-768)
```json
{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }
```
Response:
```json
{ "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
```
`peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`.
### 4.5 `decapsulate` (ML-KEM-768)
```json
{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }
```
Response:
```json
{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
```
### 4.6 `derive_shared_secret` (X25519)
```json
{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }
```
Response:
```json
{ "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" }
```
`peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
### 4.7 `encrypt` / `decrypt` (OTP)
```json
{ "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] }
{ "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] }
```
`encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used.
`encrypt` response:
```json
{
"id": "8",
"result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}"
}
```
`decrypt` response:
```json
{ "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" }
```
If no pad is bound at startup, the error is `-32601` `otp_pad_not_bound`.
### 4.8 `nostr_get_public_key`
```json
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "nostr_index": 0 } ] }
```
Response (default): a plain 64-hex-char secp256k1 public key string.
Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
### 4.9 `nostr_sign_event`
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
```json
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "nostr_index": 0 } ] }
```
`<event_json>` is the unsigned event object:
```json
{ "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" }
```
Response: the signed event JSON string, with `id` and `sig` populated.
### 4.10 `nostr_mine_event`
Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive.
```json
{
"id": "12",
"method": "nostr_mine_event",
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 } ]
}
```
| Option | Required | Default | Meaning |
|---------------|-------------------------------|---------|---------------------------------------------------------------|
| `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. |
| `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. |
| `threads` | no | 1 | Mining threads (clamped to 1..32). |
At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned.
Response:
```json
{
"id": "12",
"result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}"
}
```
Errors:
- `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given.
- `1008` `mining_failed` — internal mining error.
### 4.11 `nostr_nip04_encrypt` / `nostr_nip04_decrypt`
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
```json
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
```
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
### 4.12 `nostr_nip44_encrypt` / `nostr_nip44_decrypt`
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
```json
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
```
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
---
## 5. Transports
The API is transport-independent. The same JSON request works over every transport; only the framing differs.
| Transport | `--listen` flag | Framing | Caller identity |
|-----------|--------------------------------|------------------------------------------|----------------------------|
| Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED``uid:<n>` |
| stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid |
| qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` |
| FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) |
| HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) |
### 5.1 HTTP examples
Start the signer:
```bash
nsigner --listen http:127.0.0.1:11111 --allow-all
```
Get a public key:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
```
Sign a Nostr event:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
```
OTP encrypt:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}'
```
### 5.2 Unix socket examples (framed mode)
```bash
# get_public_key
nsigner --socket-name nsigner client \
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
# Sign a Nostr event
nsigner --socket-name nsigner client \
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
# ed25519 sign
nsigner --socket-name nsigner client \
'{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}'
```
---
## 6. Pre-approval
Pre-approval entries skip the interactive prompt for matching requests. They are configured at startup with `--preapprove`.
### 6.1 Algorithm-based
```bash
nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign,verify
nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsulate
```
### 6.2 Nostr
```bash
nsigner --preapprove caller=uid:1000,nostr_index=0,verb=nostr_sign_event,nostr_get_public_key
```
A `*` wildcard matches any caller, role, or verb. Index ranges use `min-max` syntax. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).

View File

@@ -0,0 +1,78 @@
# Plan: Legacy Verb Aliases (Migration Path)
## Context
`n_signer` is being moved to a clean, unified API (see [`api.md`](../api.md)). The current implementation in [`src/dispatcher.c`](../src/dispatcher.c) still uses the legacy unprefixed verb names. This document captures the old → new mapping so the implementation can be updated in one pass. Since this is a new project, there are no external clients to migrate — the legacy names are an internal cleanup item, not a long-term compatibility surface.
## Goal
Rename the verbs in [`src/dispatcher.c`](../src/dispatcher.c) to match [`api.md`](../api.md), remove the legacy aliases, and delete the alias-routing logic. No backward-compatibility shim is needed.
## Old → New Verb Mapping
### Nostr protocol verbs (add `nostr_` prefix)
| Legacy verb | New verb |
|---------------------|---------------------------|
| `sign_event` | `nostr_sign_event` |
| `mine_event` | `nostr_mine_event` |
| `nip04_encrypt` | `nostr_nip04_encrypt` |
| `nip04_decrypt` | `nostr_nip04_decrypt` |
| `nip44_encrypt` | `nostr_nip44_encrypt` |
| `nip44_decrypt` | `nostr_nip44_decrypt` |
The role-based `get_public_key` (with `nostr_index`/`role`/`role_path` selector) becomes `nostr_get_public_key`. The algorithm-based `get_public_key` (with `algorithm` option) stays `get_public_key`.
### Algorithm-based verb aliases (collapse into canonical names)
| Legacy verb | Canonical verb | Default algorithm (when `algorithm` omitted) |
|---------------------|--------------------|----------------------------------------------|
| `sign_data` | `sign` | (from role) |
| `ssh_sign` | `sign` | `ed25519` |
| `verify_signature` | `verify` | (from role) |
| `kem_encapsulate` | `encapsulate` | `ml-kem-768` |
| `kem_decapsulate` | `decapsulate` | `ml-kem-768` |
After the rename, callers must always supply `algorithm` explicitly — the "default algorithm" fallbacks are removed. This makes the algorithm-based verbs uniform: every call specifies its algorithm.
### OTP verb aliases (collapse into `encrypt`/`decrypt`)
| Legacy verb | Canonical verb | Required option |
|-----------------|----------------|--------------------------|
| `otp_encrypt` | `encrypt` | `{"algorithm":"otp"}` |
| `otp_decrypt` | `decrypt` | `{"algorithm":"otp"}` |
The general `encrypt`/`decrypt` with `curve:"otp"` routing is replaced by `algorithm:"otp"`.
## Implementation Steps
1. **[`src/dispatcher.c`](../src/dispatcher.c)** — rename the `VERB_*` string constants:
- `VERB_SIGN_EVENT``"nostr_sign_event"`
- `VERB_MINE_EVENT``"nostr_mine_event"`
- `VERB_NIP04_ENCRYPT``"nostr_nip04_encrypt"`
- `VERB_NIP04_DECRYPT``"nostr_nip04_decrypt"`
- `VERB_NIP44_ENCRYPT``"nostr_nip44_encrypt"`
- `VERB_NIP44_DECRYPT``"nostr_nip44_decrypt"`
- Add `VERB_NOSTR_GET_PUBLIC_KEY` = `"nostr_get_public_key"`; split the current `get_public_key` handler into two branches based on whether `algorithm` is present (algorithm-based) or `nostr_index`/`role`/`role_path` is present (Nostr).
2. **Remove alias routing** — delete [`is_algorithm_verb()`](../src/dispatcher.c:829), [`canonical_alg_verb()`](../src/dispatcher.c:846), and the alias-fallthrough logic in [`dispatcher_handle_request()`](../src/dispatcher.c:1559). The verbs `sign_data`, `ssh_sign`, `verify_signature`, `kem_encapsulate`, `kem_decapsulate`, `otp_encrypt`, `otp_decrypt` are no longer recognized.
3. **Remove `curve:"otp"` routing** — the `encrypt`/`decrypt` handler no longer special-cases `curve:"otp"`; OTP is selected via `algorithm:"otp"` like every other algorithm.
4. **Update tests** — [`tests/test_dispatcher.c`](../tests/test_dispatcher.c), [`tests/test_integration.c`](../tests/test_integration.c), [`tests/test_algorithm_api.c`](../tests/test_algorithm_api.c), and any other test that uses the legacy verb names. Rename all call sites to the new verbs and add `algorithm` explicitly where it was previously defaulted.
5. **Update examples and clients**:
- [`client/demo_c99.c`](../client/demo_c99.c)
- [`client/demo_javascript.js`](../client/demo_javascript.js)
- [`client/demo_python.py`](../client/demo_python.py)
- [`examples/`](../examples/) — all example files using legacy verb names
- [`README.md`](../README.md) — the §5 summary and any inline examples
6. **Update policy/preapprove** — [`src/policy.c`](../src/policy.c) and the `--preapprove` CLI parsing in [`src/main.c`](../src/main.c) should accept the new verb names. The role-based preapprove examples in [`api.md`](../api.md) §6 already use the `nostr_` prefix.
## Verification
- `make dev && ./build/nsigner --version` builds clean.
- `make test` — all tests pass with the new verb names.
- Manual smoke test over HTTP: each verb in [`api.md`](../api.md) §3 responds as documented.
- `grep -rn "sign_event\|mine_event\|nip04_\|nip44_\|sign_data\|ssh_sign\|verify_signature\|kem_encapsulate\|kem_decapsulate\|otp_encrypt\|otp_decrypt" src/ tests/ client/ examples/` returns no matches (all legacy names gone).

View File

@@ -759,8 +759,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */ /* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 0 #define NSIGNER_VERSION_MINOR 0
#define NSIGNER_VERSION_PATCH 51 #define NSIGNER_VERSION_PATCH 52
#define NSIGNER_VERSION "v0.0.51" #define NSIGNER_VERSION "v0.0.52"
/* NSIGNER_HEADERLESS_DECLS_END */ /* NSIGNER_HEADERLESS_DECLS_END */
@@ -2897,14 +2897,17 @@ int main(int argc, char *argv[]) {
printf("System is ready and waiting for connections on @%s.\n", socket_name); printf("System is ready and waiting for connections on @%s.\n", socket_name);
} }
if (transport_mask & TRANSPORT_TCP) { if (transport_mask & TRANSPORT_TCP) {
connection_info_add("Server: FIPS/TCP [::]:11111"); const char *tcp_addr = servers[tcp_server_idx].socket_name;
connection_info_add(" Client: nsigner --listen tcp:[::]:11111 client '<json>'"); connection_info_add("Server: FIPS/TCP %s", tcp_addr + 4); /* skip "tcp:" */
printf("System is ready and waiting for connections on tcp:[::]:11111.\n"); connection_info_add(" Client: nsigner --listen %s client '<json>'", tcp_addr);
printf("System is ready and waiting for connections on %s.\n", tcp_addr);
} }
if (transport_mask & TRANSPORT_HTTP) { if (transport_mask & TRANSPORT_HTTP) {
connection_info_add("Server: HTTP 127.0.0.1:11111"); const char *http_addr = servers[http_server_idx].socket_name;
connection_info_add(" Client: curl -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' -d '<json>'"); connection_info_add("Server: HTTP %s", http_addr + 5); /* skip "http:" */
printf("System is ready and waiting for connections on http://127.0.0.1:11111.\n"); connection_info_add(" Client: curl -X POST http://%s/ -H 'Content-Type: application/json' -d '<json>'",
http_addr + 5);
printf("System is ready and waiting for connections on http://%s.\n", http_addr + 5);
} }
} else if (listen_mode == NSIGNER_LISTEN_UNIX) { } else if (listen_mode == NSIGNER_LISTEN_UNIX) {
connection_info_add("Server: unix @%s", socket_name); connection_info_add("Server: unix @%s", socket_name);
@@ -2916,14 +2919,16 @@ int main(int argc, char *argv[]) {
} }
printf("System is ready and waiting for connections on @%s.\n", socket_name); printf("System is ready and waiting for connections on @%s.\n", socket_name);
} else if (listen_mode == NSIGNER_LISTEN_TCP) { } else if (listen_mode == NSIGNER_LISTEN_TCP) {
connection_info_add("Server: %s", listen_target); const char *actual_addr = server.socket_name;
connection_info_add(" Client: nsigner --listen %s client '<json>'", listen_target); connection_info_add("Server: %s", actual_addr);
printf("System is ready and waiting for connections on %s.\n", listen_target); connection_info_add(" Client: nsigner --listen %s client '<json>'", actual_addr);
printf("System is ready and waiting for connections on %s.\n", actual_addr);
} else if (listen_mode == NSIGNER_LISTEN_HTTP) { } else if (listen_mode == NSIGNER_LISTEN_HTTP) {
connection_info_add("Server: %s", listen_target); const char *actual_addr = server.socket_name;
connection_info_add("Server: %s", actual_addr);
connection_info_add(" Client: curl -X POST http://%s/ -H 'Content-Type: application/json' -d '<json>'", connection_info_add(" Client: curl -X POST http://%s/ -H 'Content-Type: application/json' -d '<json>'",
listen_target + 5); /* skip "http:" prefix */ actual_addr + 5); /* skip "http:" prefix */
printf("System is ready and waiting for connections on %s.\n", listen_target); printf("System is ready and waiting for connections on %s.\n", actual_addr);
} else if (listen_mode == NSIGNER_LISTEN_QREXEC) { } else if (listen_mode == NSIGNER_LISTEN_QREXEC) {
connection_info_add("Server: qrexec (one request per invocation)"); connection_info_add("Server: qrexec (one request per invocation)");
printf("System is ready and waiting for a qrexec request.\n"); printf("System is ready and waiting for a qrexec request.\n");

View File

@@ -1457,6 +1457,8 @@ int server_start(server_ctx_t *ctx) {
uint16_t port; uint16_t port;
char host[64]; char host[64];
int one = 1; int one = 1;
int bind_attempt;
int bound = 0;
/* HTTP mode uses the same TCP socket setup, just with an "http:" prefix /* HTTP mode uses the same TCP socket setup, just with an "http:" prefix
* instead of "tcp:". Convert to tcp: for parse_tcp_target. */ * instead of "tcp:". Convert to tcp: for parse_tcp_target. */
@@ -1476,54 +1478,95 @@ int server_start(server_ctx_t *ctx) {
return -1; return -1;
} }
fd = socket(family, SOCK_STREAM, 0); /* Try the requested port, then increment up to 5 times if it's in use.
if (fd < 0) { * This handles both real conflicts and false positives (e.g. when TCP
(void)snprintf(ctx->last_error, * [::]:11111 and HTTP 127.0.0.1:11111 are started together in
sizeof(ctx->last_error), * multi-listen mode, the second bind can fail with EADDRINUSE even
"socket(tcp) failed: %s", * though no other process holds the port). */
strerror(errno)); for (bind_attempt = 0; bind_attempt < 5 && !bound; ++bind_attempt) {
uint16_t try_port = (uint16_t)(port + bind_attempt);
if (try_port < port) {
/* port wrapped past 65535 — stop retrying */
break;
}
fd = socket(family, SOCK_STREAM, 0);
if (fd < 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"socket(tcp) failed: %s",
strerror(errno));
return -1;
}
(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (family == AF_INET) {
struct sockaddr_in addr4;
memset(&addr4, 0, sizeof(addr4));
addr4.sin_family = AF_INET;
addr4.sin_port = htons(try_port);
if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr4, sizeof(addr4)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
if (errno == EADDRINUSE) {
continue; /* try next port */
}
return -1;
}
} else {
struct sockaddr_in6 addr6;
memset(&addr6, 0, sizeof(addr6));
addr6.sin6_family = AF_INET6;
addr6.sin6_port = htons(try_port);
if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET6) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr6, sizeof(addr6)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
if (errno == EADDRINUSE) {
continue; /* try next port */
}
return -1;
}
}
bound = 1;
port = try_port;
}
if (!bound) {
/* All 5 attempts failed with EADDRINUSE. ctx->last_error already
* holds the most recent bind failure message. */
return -1; return -1;
} }
(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); /* Update ctx->socket_name to reflect the actual bound port, so the
* status display and error messages show the real address. */
if (family == AF_INET) { {
struct sockaddr_in addr4; const char *prefix = (ctx->listen_mode == NSIGNER_LISTEN_HTTP) ? "http:" : "tcp:";
memset(&addr4, 0, sizeof(addr4)); if (family == AF_INET6) {
addr4.sin_family = AF_INET; snprintf(ctx->socket_name, sizeof(ctx->socket_name),
addr4.sin_port = htons(port); "%s[%s]:%u", prefix, host, (unsigned)port);
if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) { } else {
close(fd); snprintf(ctx->socket_name, sizeof(ctx->socket_name),
server_set_error(ctx, "inet_pton(AF_INET) failed for listen target"); "%s%s:%u", prefix, host, (unsigned)port);
return -1;
}
if (bind(fd, (struct sockaddr *)&addr4, sizeof(addr4)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
return -1;
}
} else {
struct sockaddr_in6 addr6;
memset(&addr6, 0, sizeof(addr6));
addr6.sin6_family = AF_INET6;
addr6.sin6_port = htons(port);
if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET6) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr6, sizeof(addr6)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
return -1;
} }
} }