Compare commits

...

3 Commits

5 changed files with 605 additions and 139 deletions

118
README.md
View File

@@ -65,6 +65,14 @@ This reduces deployment variability and shrinks the runtime trust surface.
`n_signer` is intentionally human-attended. It stays attached to a terminal and can require explicit keystroke approval for unknown callers or sensitive actions. Human presence is part of the security model.
### 2.5 Secret memory backing: `mlock` today, `memfd_secret` where supported
Sensitive buffers (mnemonic, master seed, per-role private keys) live in `mlock`'d RAM via [`secure_buf_alloc`](src/secure_mem.c) and are zeroized with `secure_memzero` on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.
`memfd_secret(2)` (Linux `CONFIG_SECRETMEM`) is a stronger backing: pages are invisible to `/proc/pid/mem`, `ptrace`, `kcore`, and hibernation dumps, and are kernel-guaranteed to be wiped on exit. It is the intended tier-1 upgrade for the `secure_buf_alloc` path on hosts that can materialize secretmem pages — bare metal and KVM guests.
It is **not** used yet because Qubes OS VMs are Xen guests: the `memfd_secret` syscall succeeds and returns a valid `/secretmem` fd, but the first page-fault into the mapping raises `SIGBUS` (the Xen hypervisor cannot back the restricted pages). Since Qubes integration is a primary deployment target, the `mlock` path remains correct and universal. A future implementation will probe `memfd_secret` by writing a canary byte into a trial mapping and fall back to `mlock` on `SIGBUS`/`ENOSYS`, enabling the stronger backing automatically on non-Xen hosts.
## 3. How it works
### 3.1 Startup phase (TUI input mode)
@@ -84,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).
6. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
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.
@@ -97,46 +105,42 @@ These modes avoid putting mnemonic material in argv/environment and are designed
### 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
n_signer v0.x | foreground session active
n_signer v0.0.2 > Main Menu
Connections
Server: unix @nsigner (bridge-source-trusted)
Client: nsigner --socket-name nsigner 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>'
Connections:
Server: unix @nsigner_hairy_dog
Client: nsigner --socket-name nsigner_hairy_dog client '<json>'
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
-----
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)
-----------------------
Activity (latest first):
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:46 allow caller=uid:1000 method=sign_event role=ops
15:59:10 deny caller=uid:1001 method=sign_event error=unauthorized
Hotkeys
-------
a toggle auto-approve(prompt) for this session
r refresh
l lock/reunlock session
q quit
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
```
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
When a request needs confirmation, `n_signer` interrupts the status view with a prompt and waits for a local keystroke.
@@ -236,7 +240,7 @@ The `key_id` is a short display identifier (first 16 hex chars of the public key
In addition to the role-based API, n_signer supports an **algorithm-based API** where the caller specifies the algorithm and derivation index directly, without needing to know role names. This is the preferred API for new clients.
### 4c.1 New verbs
### 4c.1 Verbs
| Verb | Description | Algorithm parameter | Key parameter |
|---|---|---|---|
@@ -326,6 +330,8 @@ nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsula
## 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.
```jsonc
@@ -514,7 +520,7 @@ Properties:
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).
- 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.
@@ -670,7 +676,7 @@ Setup scripts and policy are in [`packaging/qubes/`](packaging/qubes/). See also
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):
@@ -756,10 +762,7 @@ Terminal A:
```text
$ nsigner
[unlock] enter mnemonic:
[ok] session unlocked
signer name : hairy dog
socket : @nsigner_hairy_dog
[listen] unix-abstract:@nsigner_hairy_dog
System is ready and waiting for connections on @nsigner_hairy_dog.
[prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n)
```
@@ -795,48 +798,3 @@ Static build:
./build/nsigner_static_x86_64 --version
```
## 11. Implemented adjuncts and future work
### Implemented features
- **HTTP listener mode (`--listen http:HOST:PORT`).** Curl-friendly HTTP POST with JSON body. CORS headers included. See [§7.1](#71-linux-desktop-abstract-namespace-unix-socket) for curl examples.
- **OTP one-time pad encryption (`otp_encrypt` / `otp_decrypt` verbs).** Information-theoretically secure encryption using one-time pads from the [`otp`](../otp) project. Pad material on USB drive, auto-detected at startup. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md).
- **Algorithm-based API (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`).** Caller specifies algorithm + index, no role needed. See [§4c](#4c-algorithm-based-api).
- **MCU / USB signer (Feather ESP32-S3 Reverse TFT).** Working PoC in [`firmware/feather_s3_tft`](firmware/feather_s3_tft). Single TinyUSB composite USB device exposes both CDC-ACM and WebUSB Vendor interfaces. Same dispatcher serves both transports with auth envelope verification, on-device TFT prompts, and physical button approval. See [`firmware/README.md`](firmware/README.md) and [`plans/feather_tinyusb_composite.md`](plans/feather_tinyusb_composite.md).
### Future work (deferred)
These items are designed and worth doing, but are not in the current implementation scope. They are listed here so they are not lost.
- **HTTPS / WSS (TLS) listener modes.** `--listen https:HOST:PORT` and `--listen wss:HOST:PORT` with `--tls-cert` / `--tls-key`. See [`plans/http_wss_listener.md`](plans/http_wss_listener.md).
- **WebSocket listener mode (`--listen ws:HOST:PORT`).** For browser access with JSON text frames. See [`plans/http_wss_listener.md`](plans/http_wss_listener.md).
- **NIP-46 bunker / relay transport.** Tracked in [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md).
- **Browser extension.** Tracked in [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md). NIP-07 surface forwarding to a running `nsigner` instance.
## 12. Document map
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
- [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md): client integration contract and framing behavior
- [`documents/QUBES_OS.md`](documents/QUBES_OS.md): Qubes OS deployment/integration checklist for dedicated signer qubes
- [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md): Tier-1 FIPS deployment runbook using loopback TCP listener
- [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md): design for the qrexec → unix-socket bridge transport (persistent signer, no mnemonic on disk)
- [`packaging/qubes/`](packaging/qubes/): qrexec service script, dom0 policy, and setup scripts for Qubes deployment
- [`examples/n_signer_qube_example_qrexec.js`](examples/n_signer_qube_example_qrexec.js): JavaScript caller example using qrexec (no network, no auth envelope)
- [`examples/n_signer_qube_example_fips.js`](examples/n_signer_qube_example_fips.js): JavaScript caller example using FIPS/TCP with auth envelope
- [`examples/get_pubkey_tcp.c`](examples/get_pubkey_tcp.c): C caller example using TCP transport with auth envelope
- [`plans/nsigner.md`](plans/nsigner.md): implementation plan and sequencing
- [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md): seed phrase domain/use catalog and caveats
- [`plans/post_quantum_crypto.md`](plans/post_quantum_crypto.md): post-quantum and multi-algorithm crypto expansion plan (all 8 phases)
- [`firmware/feather_s3_tft`](firmware/feather_s3_tft): Feather ESP32-S3 Reverse TFT firmware (TinyUSB composite CDC + WebUSB signer PoC)
- [`plans/feather_tinyusb_composite.md`](plans/feather_tinyusb_composite.md): firmware Phase 7b plan and outcome (TinyUSB composite USB transport)
- [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md): browser extension exposing NIP-07 over `nsigner`
- [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md): deferred NIP-46 relay-mode signer transport
- [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md): OTP one-time pad integration plan and design
- [`plans/http_wss_listener.md`](plans/http_wss_listener.md): HTTP/WS/WSS listener modes plan
- [`libotppad/`](libotppad/libotppad.h): shared OTP pad format library (XOR, ASCII armor, binary .otp, Padmé padding, state files, checksum)
- [`src/otp_pad.c`](src/otp_pad.c) / [`src/otp_pad.h`](src/otp_pad.h): OTP pad state management for n_signer
- [`src/http_listener.c`](src/http_listener.c) / [`src/http_listener.h`](src/http_listener.h): minimal HTTP/1.1 parser for HTTP listener mode
- [`tools/make_test_pad.c`](tools/make_test_pad.c): generate a test OTP pad on a USB drive
- [`tools/otp_roundtrip_test.py`](tools/otp_roundtrip_test.py): end-to-end OTP encrypt/decrypt round-trip test
- [`examples/otp_nostr_30078.py`](examples/otp_nostr_30078.py): example — encrypt with OTP, wrap in Nostr kind 30078 event, sign
- [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration

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) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 0
#define NSIGNER_VERSION_PATCH 49
#define NSIGNER_VERSION "v0.0.49"
#define NSIGNER_VERSION_PATCH 52
#define NSIGNER_VERSION "v0.0.52"
/* 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);
}
if (transport_mask & TRANSPORT_TCP) {
connection_info_add("Server: FIPS/TCP [::]:11111");
connection_info_add(" Client: nsigner --listen tcp:[::]:11111 client '<json>'");
printf("System is ready and waiting for connections on tcp:[::]:11111.\n");
const char *tcp_addr = servers[tcp_server_idx].socket_name;
connection_info_add("Server: FIPS/TCP %s", tcp_addr + 4); /* skip "tcp:" */
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) {
connection_info_add("Server: HTTP 127.0.0.1:11111");
connection_info_add(" Client: curl -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' -d '<json>'");
printf("System is ready and waiting for connections on http://127.0.0.1:11111.\n");
const char *http_addr = servers[http_server_idx].socket_name;
connection_info_add("Server: HTTP %s", http_addr + 5); /* skip "http:" */
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) {
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);
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
connection_info_add("Server: %s", listen_target);
connection_info_add(" Client: nsigner --listen %s client '<json>'", listen_target);
printf("System is ready and waiting for connections on %s.\n", listen_target);
const char *actual_addr = server.socket_name;
connection_info_add("Server: %s", actual_addr);
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) {
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>'",
listen_target + 5); /* skip "http:" prefix */
printf("System is ready and waiting for connections on %s.\n", listen_target);
actual_addr + 5); /* skip "http:" prefix */
printf("System is ready and waiting for connections on %s.\n", actual_addr);
} else if (listen_mode == NSIGNER_LISTEN_QREXEC) {
connection_info_add("Server: qrexec (one request per invocation)");
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;
char host[64];
int one = 1;
int bind_attempt;
int bound = 0;
/* HTTP mode uses the same TCP socket setup, just with an "http:" prefix
* instead of "tcp:". Convert to tcp: for parse_tcp_target. */
@@ -1476,54 +1478,95 @@ int server_start(server_ctx_t *ctx) {
return -1;
}
fd = socket(family, SOCK_STREAM, 0);
if (fd < 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"socket(tcp) failed: %s",
strerror(errno));
/* Try the requested port, then increment up to 5 times if it's in use.
* This handles both real conflicts and false positives (e.g. when TCP
* [::]:11111 and HTTP 127.0.0.1:11111 are started together in
* multi-listen mode, the second bind can fail with EADDRINUSE even
* though no other process holds the port). */
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;
}
(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(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);
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;
/* Update ctx->socket_name to reflect the actual bound port, so the
* status display and error messages show the real address. */
{
const char *prefix = (ctx->listen_mode == NSIGNER_LISTEN_HTTP) ? "http:" : "tcp:";
if (family == AF_INET6) {
snprintf(ctx->socket_name, sizeof(ctx->socket_name),
"%s[%s]:%u", prefix, host, (unsigned)port);
} else {
snprintf(ctx->socket_name, sizeof(ctx->socket_name),
"%s%s:%u", prefix, host, (unsigned)port);
}
}