Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c055503d | ||
|
|
a7c6de2dcd |
13
.test_mnemonic_otp_roundtrip.tmp
Normal file
13
.test_mnemonic_otp_roundtrip.tmp
Normal file
@@ -0,0 +1,13 @@
|
||||
alarm impact educate burden vague honey horn buyer sight vocal age render
|
||||
|
||||
index 0
|
||||
|
||||
{
|
||||
"index": 0,
|
||||
"nsec": "nsec1z2lrfamae2dzax7dmnlhv497uuxe4mw0m3w694upx5x54q6dgttqvzrrwl",
|
||||
"npub": "npub1j7d7yf47w8k2kseknqjr3045jvm00u0wnt3433kk6vu67d2zamcs8ynuw4",
|
||||
"npubHex": "979be226be71ecab4336982438beb49336f7f1ee9ae358c6d6d339af3542eef1",
|
||||
"nsecHex": "12be34f77dca9a2e9bcddcff7654bee70d9aedcfdc5da2d781350d4a834d42d6",
|
||||
"fipsIpv6": "fd55:b7c6:536e:26ee:a79:6e25:6f05:a85f",
|
||||
"strDerivationPath": "m/44'/1237'/0'/0/0"
|
||||
}
|
||||
@@ -55,6 +55,7 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev
|
||||
|
||||
# Copy source files
|
||||
COPY src/ /build/src/
|
||||
COPY libotppad/ /build/libotppad/
|
||||
COPY resources/tui_continuous/ /build/resources/tui_continuous/
|
||||
COPY resources/pqclean/ /build/resources/pqclean/
|
||||
|
||||
@@ -76,6 +77,7 @@ RUN ARCH="$(uname -m)"; \
|
||||
-I/build/resources/pqclean/crypto_sign/ml-dsa-65 \
|
||||
-I/build/resources/pqclean/crypto_sign/slh-dsa-128s \
|
||||
-I/build/resources/pqclean/crypto_kem/ml-kem-768 \
|
||||
-I/build/libotppad \
|
||||
/build/src/main.c \
|
||||
/build/src/secure_mem.c \
|
||||
/build/src/mnemonic.c \
|
||||
@@ -92,6 +94,9 @@ RUN ARCH="$(uname -m)"; \
|
||||
/build/src/miner.c \
|
||||
/build/src/pq_crypto.c \
|
||||
/build/src/pq_drbg.c \
|
||||
/build/src/otp_pad.c \
|
||||
/build/src/http_listener.c \
|
||||
/build/libotppad/libotppad.c \
|
||||
/build/resources/pqclean/crypto_sign/ml-dsa-65/sign.c \
|
||||
/build/resources/pqclean/crypto_sign/ml-dsa-65/poly.c \
|
||||
/build/resources/pqclean/crypto_sign/ml-dsa-65/ntt.c \
|
||||
|
||||
5
Makefile
5
Makefile
@@ -1,5 +1,5 @@
|
||||
CC := gcc
|
||||
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous -Iresources/pqclean -Iresources/pqclean/crypto_sign/ml-dsa-65 -Iresources/pqclean/crypto_sign/slh-dsa-128s -Iresources/pqclean/crypto_kem/ml-kem-768 -Iresources/pqclean/common
|
||||
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE -Isrc -Ilibotppad -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous -Iresources/pqclean -Iresources/pqclean/crypto_sign/ml-dsa-65 -Iresources/pqclean/crypto_sign/slh-dsa-128s -Iresources/pqclean/crypto_kem/ml-kem-768 -Iresources/pqclean/common
|
||||
LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
||||
|
||||
SRC_DIR := src
|
||||
@@ -52,6 +52,9 @@ SOURCES := \
|
||||
$(SRC_DIR)/miner.c \
|
||||
$(SRC_DIR)/pq_crypto.c \
|
||||
$(SRC_DIR)/pq_drbg.c \
|
||||
$(SRC_DIR)/otp_pad.c \
|
||||
$(SRC_DIR)/http_listener.c \
|
||||
libotppad/libotppad.c \
|
||||
$(PQCLEAN_SOURCES) \
|
||||
resources/tui_continuous/tui_continuous.c
|
||||
|
||||
|
||||
176
README.md
176
README.md
@@ -20,7 +20,8 @@ This is a **program, not a daemon**:
|
||||
- purpose/curve enforcement
|
||||
- request dispatch
|
||||
- interactive terminal UI
|
||||
- transport adapter(s)
|
||||
- transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP)
|
||||
- OTP one-time pad encryption (optional, with USB pad)
|
||||
|
||||
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
|
||||
|
||||
@@ -74,9 +75,16 @@ When started, `n_signer` immediately enters terminal input mode:
|
||||
- On `E`: prompt for mnemonic with terminal echo disabled, then validate.
|
||||
- On `G`: generate a fresh 12-word BIP-39 mnemonic from `getrandom(2)`, display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
|
||||
2. Build in-memory role/selector state from the mnemonic.
|
||||
3. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
|
||||
4. Initialize transport endpoints and bind the socket.
|
||||
5. Switch to running status display, with the signer name and socket address shown in the banner.
|
||||
3. **Interactive transport selection** (if no `--listen` flag given and stdin is a TTY): choose one or more of:
|
||||
- Local Unix socket
|
||||
- Qubes qrexec bridge
|
||||
- FIPS/TCP listener (framed JSON)
|
||||
- HTTP listener (curl-friendly)
|
||||
4. **Index whitelist** (optional): restrict which `nostr_index` values this session can access.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -93,7 +101,15 @@ After unlock, terminal becomes a live status and control console. Example layout
|
||||
|
||||
```text
|
||||
n_signer v0.x | foreground session active
|
||||
transport: unix-abstract:@nsigner
|
||||
|
||||
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>'
|
||||
OTP pad: 333e9902db839d9d... (offset 288 / 1048576 bytes)
|
||||
|
||||
session: unlocked (RAM-only)
|
||||
|
||||
Roles
|
||||
@@ -277,11 +293,11 @@ Response: `{"id":"3","result":{"algorithm":"ml-dsa-65","public_key":"<hex>","key
|
||||
{"id":"6","method":"derive_shared_secret","params":["<peer_pubkey_hex>",{"algorithm":"x25519","index":0}]}
|
||||
```
|
||||
|
||||
### 4c.4 Old verb aliases
|
||||
### 4c.4 Verb aliases
|
||||
|
||||
Old verb names map to the new algorithm-based verbs when used with the `algorithm` parameter:
|
||||
These verb aliases are also available. When used with the `algorithm` parameter, they map to the algorithm-based verbs:
|
||||
|
||||
| Old verb | New verb | Default algorithm |
|
||||
| Alias | Canonical verb | Default algorithm |
|
||||
|---|---|---|
|
||||
| `sign_data` | `sign` | (from `algorithm` parameter) |
|
||||
| `ssh_sign` | `sign` | `ed25519` |
|
||||
@@ -289,7 +305,7 @@ Old verb names map to the new algorithm-based verbs when used with the `algorith
|
||||
| `kem_encapsulate` | `encapsulate` | `ml-kem-768` |
|
||||
| `kem_decapsulate` | `decapsulate` | `ml-kem-768` |
|
||||
|
||||
Without the `algorithm` parameter, old verbs fall through to the role-based path (backward compatible).
|
||||
Without the `algorithm` parameter, these aliases fall through to the role-based path (backward compatible).
|
||||
|
||||
### 4c.5 Algorithm-based preapprove
|
||||
|
||||
@@ -322,7 +338,7 @@ Request shape is JSON-RPC with NIP-46-style methods and optional trailing select
|
||||
|
||||
Implemented signer verbs in this build:
|
||||
|
||||
**Nostr verbs** (require `purpose="nostr"`, `curve="secp256k1"`):
|
||||
**Nostr verbs** (secp256k1 only, use `role` or `nostr_index` selector):
|
||||
|
||||
- `get_public_key`
|
||||
- `sign_event`
|
||||
@@ -330,27 +346,36 @@ Implemented signer verbs in this build:
|
||||
- `nip44_encrypt` / `nip44_decrypt`
|
||||
- `mine_event` — add NIP-13 proof-of-work and sign (see below)
|
||||
|
||||
**Multi-algorithm verbs**:
|
||||
**Algorithm-based verbs** (use `algorithm` + `index` selector, no role needed):
|
||||
|
||||
| Verb | Required purpose | Required curve | Description |
|
||||
|---|---|---|---|
|
||||
| `get_public_key` | any | any | Returns the role's public key. secp256k1 returns plain hex; other algorithms return structured JSON (see [§4b.4](#4b4-structured-get_public_key-response)). |
|
||||
| `sign_data` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` | Sign arbitrary bytes (not a Nostr event). Returns `{"signature":"<hex>","algorithm":"<alg>"}`. |
|
||||
| `verify_signature` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` | Verify a signature. Returns `{"valid":true/false}`. |
|
||||
| `ssh_sign` | `ssh` | `ed25519` | Sign an SSH authentication challenge in ed25519 format. Returns `{"signature":"<hex>","algorithm":"ed25519"}`. |
|
||||
| `kem_encapsulate` | `pq-kem` | `ml-kem-768` | Encapsulate against a peer's ML-KEM-768 public key. Returns `{"ciphertext":"<hex>","shared_secret":"<hex>","algorithm":"ml-kem-768"}`. |
|
||||
| `kem_decapsulate` | `pq-kem` | `ml-kem-768` | Decapsulate a ciphertext using the role's ML-KEM-768 private key. Returns `{"shared_secret":"<hex>","algorithm":"ml-kem-768"}`. |
|
||||
| Verb | Valid algorithms | Description |
|
||||
|---|---|---|
|
||||
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Sign arbitrary bytes. Returns `{"signature":"<hex>","algorithm":"<alg>","key_id":"<16hex>"}`. |
|
||||
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Verify a signature. Returns `{"valid":true/false}`. |
|
||||
| `encapsulate` | ml-kem-768 | Encapsulate against a peer's ML-KEM-768 public key. Returns `{"ciphertext":"<hex>","shared_secret":"<hex>","algorithm":"ml-kem-768"}`. |
|
||||
| `decapsulate` | ml-kem-768 | Decapsulate a ciphertext using the derived ML-KEM-768 private key. Returns `{"shared_secret":"<hex>","algorithm":"ml-kem-768"}`. |
|
||||
| `derive_shared_secret` | x25519 | ECDH key agreement. Returns `{"shared_secret":"<hex>","algorithm":"x25519"}`. |
|
||||
| `get_public_key` | all algorithms | Returns the public key for the specified algorithm+index. |
|
||||
|
||||
Example `sign_data` request:
|
||||
**OTP verbs** (one-time pad encryption, requires `--otp-pad-dir` + `--otp-pad`):
|
||||
|
||||
| Verb | Description |
|
||||
|---|---|
|
||||
| `otp_encrypt` | Encrypt plaintext (base64) with the bound OTP pad. Returns ASCII-armored or binary ciphertext. |
|
||||
| `otp_decrypt` | Decrypt ciphertext with the bound OTP pad. Returns plaintext (base64). |
|
||||
|
||||
See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md) for the full OTP design.
|
||||
|
||||
Example `sign` request (algorithm-based):
|
||||
|
||||
```jsonc
|
||||
{ "id": "6", "method": "sign_data", "params": ["<message_hex>", { "role": "pq-ml-dsa" }] }
|
||||
{ "id": "6", "method": "sign", "params": ["<message_hex>", { "algorithm": "ed25519", "index": 0 }] }
|
||||
```
|
||||
|
||||
Example `kem_encapsulate` request:
|
||||
Example `otp_encrypt` request:
|
||||
|
||||
```jsonc
|
||||
{ "id": "7", "method": "kem_encapsulate", "params": ["<peer_pubkey_hex>", { "role": "pq-kem-main" }] }
|
||||
{ "id": "7", "method": "otp_encrypt", "params": ["<plaintext_base64>", { "encoding": "ascii" }] }
|
||||
```
|
||||
|
||||
### `mine_event` — NIP-13 Proof-of-Work
|
||||
@@ -409,11 +434,13 @@ Representative error codes:
|
||||
- `approval_denied`
|
||||
- `internal_error`
|
||||
|
||||
## 6. Purpose and curve enforcement
|
||||
## 6. Purpose and curve enforcement (role-based API)
|
||||
|
||||
Selector resolution chooses *which* role. Enforcement decides *whether the requested method is valid* for that role.
|
||||
|
||||
### 6.1 Purpose values
|
||||
**Note:** The algorithm-based API (§4c) bypasses purpose/curve enforcement entirely — the caller specifies the algorithm directly, and the dispatcher derives the key on demand. Purpose values are only used by the role-based API for enforcement.
|
||||
|
||||
### 6.1 Purpose values (role-based API only)
|
||||
|
||||
| Purpose | Description |
|
||||
|---|---|
|
||||
@@ -436,7 +463,7 @@ Selector resolution chooses *which* role. Enforcement decides *whether the reque
|
||||
| `slh-dsa-128s` | SLH-DSA-128s (FIPS 205, hash-based PQ signatures) |
|
||||
| `ml-kem-768` | ML-KEM-768 (FIPS 203, lattice-based PQ KEM) |
|
||||
|
||||
### 6.3 Enforcement matrix
|
||||
### 6.3 Enforcement matrix (role-based API)
|
||||
|
||||
| Verb | Required purpose | Required curve |
|
||||
|---|---|---|
|
||||
@@ -445,18 +472,16 @@ Selector resolution chooses *which* role. Enforcement decides *whether the reque
|
||||
| `nip04_encrypt` / `nip04_decrypt` | `nostr` | `secp256k1` |
|
||||
| `nip44_encrypt` / `nip44_decrypt` | `nostr` | `secp256k1` |
|
||||
| `get_public_key` | any | any (must match role's declared curve) |
|
||||
| `sign_data` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
|
||||
| `sign_data` / `ssh_sign` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
|
||||
| `verify_signature` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
|
||||
| `ssh_sign` | `ssh` | `ed25519` |
|
||||
| `kem_encapsulate` | `pq-kem` | `ml-kem-768` |
|
||||
| `kem_decapsulate` | `pq-kem` | `ml-kem-768` |
|
||||
| `kem_encapsulate` / `kem_decapsulate` | `pq-kem` | `ml-kem-768` |
|
||||
|
||||
Example:
|
||||
The algorithm-based API (§4c) does not use this matrix — the caller specifies the algorithm directly, and enforcement is based on the verb+algorithm combination (see [§4c.6](#4c6-enforcement-matrix)).
|
||||
|
||||
Example (role-based):
|
||||
|
||||
- `sign_event` requires `purpose="nostr"` and `curve="secp256k1"`.
|
||||
- `mine_event` requires `purpose="nostr"` and `curve="secp256k1"` (same as `sign_event`).
|
||||
- If caller selects a Bitcoin-role key for `sign_event`, request fails with `purpose_mismatch`.
|
||||
- If caller selects a `pq-sig`/`ml-dsa-65` key for `sign_event`, request fails with `purpose_mismatch`.
|
||||
|
||||
This prevents cross-protocol misuse inside one mnemonic-rooted signer process. Fail-closed: any unlisted `(verb, purpose, curve)` combination is rejected.
|
||||
|
||||
@@ -487,10 +512,55 @@ Discovery:
|
||||
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
|
||||
- `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
|
||||
- `nsigner --listen qrexec` is the same stdio framing mode, but caller identity can be derived from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
|
||||
- `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables TCP listening for non-AF_UNIX clients (for example `tcp:127.0.0.1:8080`, `tcp:[::]:8080`, or `tcp:[fd00::1234]:8080`).
|
||||
- `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables FIPS/TCP listening for non-AF_UNIX clients (for example `tcp:127.0.0.1:11111`, `tcp:[::]:11111`, or `tcp:[fd00::1234]:11111`). Uses framed JSON protocol (not HTTP).
|
||||
- `nsigner --listen http:HOST:PORT` enables HTTP listening for curl-friendly access (for example `http:127.0.0.1:11111`). Uses standard HTTP POST with JSON body — no custom framing. CORS headers included for browser access. Defaults to localhost; pass `http:0.0.0.0:PORT` to expose externally.
|
||||
- `nsigner bridge --to <socket-name>` is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as the `qubes.NsignerRpc` service entrypoint. See [§8.3](#83-qubes-os-qube) and [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md).
|
||||
- `--bridge-source-trusted` (unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed `{"qrexec_source":"<vm>"}` preamble before the request, and the caller identity is composed as `qubes:<vm>` — matching the native qrexec identity path. This enables a persistent signer (mnemonic in mlock'd RAM) to receive qrexec-routed requests without spawning a fresh process per call.
|
||||
|
||||
#### curl examples (HTTP mode)
|
||||
|
||||
Start the signer in HTTP mode:
|
||||
```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":[{"role":"main"}]}'
|
||||
```
|
||||
|
||||
Sign a Nostr event:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"}]}'
|
||||
```
|
||||
|
||||
OTP encrypt (requires `--otp-pad-dir` and `--otp-pad`):
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"otp_encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"encoding":"ascii"}]}'
|
||||
```
|
||||
|
||||
OTP decrypt:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"otp_decrypt","params":["-----BEGIN OTP MESSAGE-----...","{"encoding":"ascii"}]}'
|
||||
```
|
||||
|
||||
#### Unix socket examples (framed mode)
|
||||
|
||||
```bash
|
||||
# Get public key
|
||||
nsigner --socket-name nsigner client '{"id":"1","method":"get_public_key","params":[{"role":"main"}]}'
|
||||
|
||||
# Sign event
|
||||
nsigner --socket-name nsigner client '{"id":"1","method":"sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"}]}'
|
||||
|
||||
# OTP encrypt
|
||||
nsigner --socket-name nsigner client '{"id":"1","method":"otp_encrypt","params":["SGVsbG8=",{"encoding":"ascii"}]}'
|
||||
```
|
||||
|
||||
### 7.2 ESP32 MCU: TinyUSB composite (CDC + WebUSB)
|
||||
|
||||
On Feather S3 TFT, MCU targets run the same core signer modules behind a TinyUSB composite transport:
|
||||
@@ -528,9 +598,11 @@ MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps t
|
||||
|
||||
Qubes deployment runs `n_signer` in a dedicated signer qube (e.g. `nostr_signer`) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
|
||||
|
||||
Two transport paths are supported:
|
||||
Three transport paths are supported:
|
||||
|
||||
**FIPS/TCP** — the signer listens on `tcp:[::]:8080` and FIPS carries traffic between qubes as an IPv6 mesh substrate. See [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md).
|
||||
**FIPS/TCP** — the signer listens on `tcp:[::]:11111` and FIPS carries traffic between qubes as an IPv6 mesh substrate. Uses framed JSON protocol. See [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md).
|
||||
|
||||
**HTTP** — the signer listens on `http:127.0.0.1:11111` for curl-friendly access within the same qube. Uses standard HTTP POST with JSON body. No auth envelopes required (relies on localhost binding + policy/approval prompts). Add `--listen http:127.0.0.1:11111` or select option 4 in the interactive transport menu.
|
||||
|
||||
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `nsigner bridge` relay (the `qubes.NsignerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>` (from `QREXEC_REMOTE_DOMAIN`), relayed via a trusted preamble. See [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md) for the full design.
|
||||
|
||||
@@ -592,10 +664,22 @@ Generic stdio transport mode (single framed request over stdin/stdout):
|
||||
nsigner --listen stdio
|
||||
```
|
||||
|
||||
TCP transport mode (no TUI; serves requests until terminated):
|
||||
FIPS/TCP transport mode (framed JSON, no TUI; serves requests until terminated):
|
||||
|
||||
```bash
|
||||
nsigner --listen tcp:[::]:8080
|
||||
nsigner --listen tcp:[::]:11111
|
||||
```
|
||||
|
||||
HTTP transport mode (curl-friendly, no TUI; serves requests until terminated):
|
||||
|
||||
```bash
|
||||
nsigner --listen http:127.0.0.1:11111
|
||||
```
|
||||
|
||||
With OTP pad bound (auto-detects pads on USB drives in interactive mode):
|
||||
|
||||
```bash
|
||||
nsigner --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --allow-all
|
||||
```
|
||||
|
||||
Qrexec bridge mode (stateless relay to a persistent signer's unix socket; used as the `qubes.NsignerRpc` service):
|
||||
@@ -687,15 +771,19 @@ Static build:
|
||||
|
||||
## 11. Implemented adjuncts and future work
|
||||
|
||||
### Implemented PoC
|
||||
### 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.
|
||||
|
||||
- **`--listen http:[addr]:port` mode.** Today the TCP listener speaks 4-byte big-endian length-prefixed framed JSON-RPC, which is correct for low-overhead local IPC but is not directly reachable from web browsers (`fetch`, `XMLHttpRequest`, `curl`). A small additional listener that wraps the same dispatcher in minimal HTTP/1.1 (`POST /rpc`, `Content-Type: application/json`, `Content-Length`-framed body, JSON response) would let standard HTTP clients talk to `nsigner` without any custom framing code. The existing auth envelope (`kind:27235`) and JSON-RPC contract are unchanged; only the outer framing differs. CORS allow on the response would let browser extensions and (with TLS) HTTPS pages reach a remote `nsigner` over FIPS or any other carrier. See the discussion in [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md) section 9 ("Next hardening steps").
|
||||
- **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.
|
||||
|
||||
@@ -717,4 +805,12 @@ These items are designed and worth doing, but are not in the current implementat
|
||||
- [`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
|
||||
|
||||
@@ -61,13 +61,13 @@ Operational assumptions:
|
||||
Run `nsigner` in TCP listen mode:
|
||||
|
||||
```bash
|
||||
./build/nsigner --listen tcp:[::]:8080
|
||||
./build/nsigner --listen tcp:[::]:11111
|
||||
```
|
||||
|
||||
Or bind to a specific FIPS ULA address:
|
||||
|
||||
```bash
|
||||
./build/nsigner --listen tcp:[fd00::1234]:8080
|
||||
./build/nsigner --listen tcp:[fd00::1234]:11111
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
|
||||
@@ -8,7 +8,7 @@ import time
|
||||
from coincurve import PrivateKey
|
||||
|
||||
HOST = "npub15uqyclnr3er7r8uhka7f0ae2yt4gkjat8gxdan04q0e6xrnwmtjswcyla3.fips"
|
||||
PORT = 8080
|
||||
PORT = 11111
|
||||
|
||||
# Demo caller key (32 bytes). Replace with your stable caller key in real use.
|
||||
PRIVKEY = bytes(range(1, 33))
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* bech32 npub for each.
|
||||
*
|
||||
* This is a cross-qube test client for Qubes OS: the signer runs in the
|
||||
* nostr_signer qube listening on tcp:[::]:8080, and this client runs in
|
||||
* nostr_signer qube listening on tcp:[::]:11111, and this client runs in
|
||||
* a different qube connecting to the signer's FIPS address.
|
||||
*
|
||||
* Usage:
|
||||
* ./get_pubkey_tcp <host> <port>
|
||||
* ./get_pubkey_tcp npub1xxx...fips 8080
|
||||
* ./get_pubkey_tcp npub1xxx...fips 11111
|
||||
*
|
||||
* If no arguments are given, defaults to localhost:8080.
|
||||
* If no arguments are given, defaults to localhost:11111.
|
||||
*
|
||||
* Output: for each index, prints:
|
||||
* index 0: hex=<64 hex chars> npub=npub1...
|
||||
@@ -139,7 +139,7 @@ cleanup:
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *host = "127.0.0.1";
|
||||
int port = 8080;
|
||||
int port = 11111;
|
||||
char hex0[65], npub0[128];
|
||||
char hex1[65], npub1[128];
|
||||
int failures = 0;
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
* and bech32 npub for each.
|
||||
*
|
||||
* This is a cross-qube test client for Qubes OS: the signer runs in the
|
||||
* nostr_signer qube listening on tcp:[::]:8080, and this client runs in
|
||||
* nostr_signer qube listening on tcp:[::]:11111, and this client runs in
|
||||
* a different qube connecting to the signer's FIPS address.
|
||||
*
|
||||
* Usage:
|
||||
* node n_signer_qube_example.js [host] [port]
|
||||
* node n_signer_qube_example.js fd56:d7c3:f605:719d:15b:18a0:fb06:982f 8080
|
||||
* node n_signer_qube_example.js fd56:d7c3:f605:719d:15b:18a0:fb06:982f 11111
|
||||
*
|
||||
* If no arguments are given, defaults to localhost:8080.
|
||||
* If no arguments are given, defaults to localhost:11111.
|
||||
*
|
||||
* Protocol:
|
||||
* - 4-byte big-endian length prefix + JSON payload (TCP framing)
|
||||
@@ -214,7 +214,7 @@ function hexToNpub(pubkeyHex) {
|
||||
|
||||
async function main() {
|
||||
const host = process.argv[2] || "127.0.0.1";
|
||||
const port = parseInt(process.argv[3] || "8080", 10);
|
||||
const port = parseInt(process.argv[3] || "11111", 10);
|
||||
|
||||
console.log(`Connecting to n_signer at ${host}:${port}`);
|
||||
console.log("Querying get_public_key for nostr_index 0 and 1...\n");
|
||||
|
||||
219
examples/otp_nostr_30078.py
Normal file
219
examples/otp_nostr_30078.py
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
otp_nostr_30078.py — example: encrypt data with OTP, wrap in a Nostr kind 30078
|
||||
event, sign it with n_signer, and print the signed event for publishing.
|
||||
|
||||
Workflow:
|
||||
1. Call n_signer's `otp_encrypt` verb to encrypt plaintext with the bound OTP pad.
|
||||
2. Build a Nostr kind 30078 (replaceable parameterized) event with the ASCII-armored
|
||||
ciphertext as the `content` field.
|
||||
3. Call n_signer's `sign_event` verb to sign the event with the secp256k1 key.
|
||||
4. Print the signed event JSON, ready to publish to Nostr relays.
|
||||
|
||||
This is a demo — it does not actually publish to a relay. To publish, send the
|
||||
signed event to your preferred Nostr relay using a library like nostr-tools,
|
||||
nostril, or nak.
|
||||
|
||||
Usage:
|
||||
python3 examples/otp_nostr_30078.py "Your secret message here"
|
||||
|
||||
Requirements:
|
||||
- n_signer running with --otp-pad-dir / --otp-pad bound, and a secp256k1
|
||||
role (e.g. "main") available for sign_event.
|
||||
- This script connects to n_signer via stdio (one process per request).
|
||||
|
||||
See plans/otp_nostr_integration.md for the full design.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
NSIGNER = "./build/nsigner"
|
||||
PAD_DIR = "/media/user/Music/pads"
|
||||
PAD_SPEC = "333e9902db839d9d"
|
||||
MNEMONIC_FILE = ".test_mnemonic"
|
||||
MNEMONIC_TMP = ".test_mnemonic_otp_30078.tmp"
|
||||
|
||||
|
||||
def send_framed(proc, obj):
|
||||
payload = json.dumps(obj).encode()
|
||||
proc.stdin.write(struct.pack(">I", len(payload)))
|
||||
proc.stdin.write(payload)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def recv_framed(proc):
|
||||
"""Read a framed response, skipping any banner text on stdout."""
|
||||
buf = b""
|
||||
while True:
|
||||
b = proc.stdout.read(1)
|
||||
if not b:
|
||||
return None
|
||||
buf = (buf + b)[-4:]
|
||||
if len(buf) < 4:
|
||||
continue
|
||||
(length,) = struct.unpack(">I", buf)
|
||||
if 1 <= length <= 1024 * 1024:
|
||||
peek = proc.stdout.read(1)
|
||||
if peek == b"{":
|
||||
body = peek + proc.stdout.read(length - 1)
|
||||
return json.loads(body.decode())
|
||||
else:
|
||||
buf = (buf + peek)[-4:]
|
||||
|
||||
|
||||
def run_one_request(req_obj):
|
||||
"""Run nsigner in stdio mode for a single framed request/response."""
|
||||
shell_cmd = (
|
||||
f"exec 3<{MNEMONIC_TMP}; "
|
||||
f"exec {NSIGNER} --listen stdio --mnemonic-fd 3 "
|
||||
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
|
||||
f"--otp-allow-blkback --allow-all"
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c", shell_cmd],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
import time as _time
|
||||
_time.sleep(1.0)
|
||||
if proc.poll() is not None:
|
||||
err = proc.stderr.read().decode()
|
||||
print(f"ERROR: nsigner exited early (code {proc.returncode})")
|
||||
print(f"stderr: {err}")
|
||||
return None
|
||||
send_framed(proc, req_obj)
|
||||
resp = recv_framed(proc)
|
||||
proc.stdin.close()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return resp
|
||||
|
||||
|
||||
def compute_event_id(event):
|
||||
"""Compute the Nostr event ID (SHA-256 of the canonical serialized event)."""
|
||||
# Nostr event serialization: [0, pubkey, created_at, kind, tags, content]
|
||||
serialized = json.dumps([
|
||||
0,
|
||||
event["pubkey"],
|
||||
event["created_at"],
|
||||
event["kind"],
|
||||
event["tags"],
|
||||
event["content"],
|
||||
], separators=(",", ":"), ensure_ascii=False)
|
||||
return hashlib.sha256(serialized.encode()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
plaintext = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "Secret OTP message"
|
||||
print(f"Plaintext: {plaintext}")
|
||||
|
||||
# Prepare the mnemonic temp file.
|
||||
with open(MNEMONIC_FILE) as f:
|
||||
mnemonic = f.read().strip()
|
||||
with open(MNEMONIC_TMP, "w") as f:
|
||||
f.write(mnemonic + "\n")
|
||||
|
||||
try:
|
||||
# Step 1: Get the public key for the "main" role
|
||||
print("\n=== Step 1: get_public_key ===")
|
||||
resp = run_one_request({
|
||||
"id": "1",
|
||||
"method": "get_public_key",
|
||||
"params": [{"role": "main"}],
|
||||
})
|
||||
if resp is None or "result" not in resp:
|
||||
print("ERROR: get_public_key failed")
|
||||
print(f"Response: {resp}")
|
||||
return 1
|
||||
# The result is a plain hex string for secp256k1 backward compat.
|
||||
pubkey_hex = resp["result"].strip('"')
|
||||
print(f"Public key: {pubkey_hex}")
|
||||
|
||||
# Step 2: Encrypt the plaintext with OTP
|
||||
print("\n=== Step 2: otp_encrypt ===")
|
||||
pt_b64 = base64.b64encode(plaintext.encode()).decode()
|
||||
resp = run_one_request({
|
||||
"id": "2",
|
||||
"method": "otp_encrypt",
|
||||
"params": [pt_b64, {"encoding": "ascii"}],
|
||||
})
|
||||
if resp is None or "result" not in resp:
|
||||
print("ERROR: otp_encrypt failed")
|
||||
print(f"Response: {resp}")
|
||||
return 1
|
||||
enc_result = json.loads(resp["result"])
|
||||
ciphertext = enc_result["ciphertext"]
|
||||
pad_chksum = enc_result["pad_chksum"]
|
||||
pad_offset = enc_result["pad_offset_after"]
|
||||
print(f"Pad checksum: {pad_chksum}")
|
||||
print(f"Pad offset after encrypt: {pad_offset}")
|
||||
print(f"Ciphertext (first 60 chars): {ciphertext[:60]}...")
|
||||
|
||||
# Step 3: Build the Nostr kind 30078 event
|
||||
print("\n=== Step 3: Build kind 30078 event ===")
|
||||
# Use a unique d-tag based on the pad checksum and offset.
|
||||
d_tag = f"otp-{pad_chksum[:16]}-{pad_offset}"
|
||||
event = {
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": int(time.time()),
|
||||
"kind": 30078,
|
||||
"tags": [
|
||||
["d", d_tag],
|
||||
["otp-pad", pad_chksum[:16]],
|
||||
["otp-version", "v0.0.2-otp"],
|
||||
["otp-encoding", "ascii"],
|
||||
],
|
||||
"content": ciphertext,
|
||||
}
|
||||
# Compute the event ID.
|
||||
event_id = compute_event_id(event)
|
||||
event["id"] = event_id
|
||||
print(f"Event ID: {event_id}")
|
||||
print(f"d-tag: {d_tag}")
|
||||
|
||||
# Step 4: Sign the event with n_signer
|
||||
print("\n=== Step 4: sign_event ===")
|
||||
# sign_event expects the event JSON as the first param (without id/sig).
|
||||
# The signer computes the id and signature internally.
|
||||
event_for_signing = {
|
||||
"pubkey": event["pubkey"],
|
||||
"created_at": event["created_at"],
|
||||
"kind": event["kind"],
|
||||
"tags": event["tags"],
|
||||
"content": event["content"],
|
||||
}
|
||||
resp = run_one_request({
|
||||
"id": "3",
|
||||
"method": "sign_event",
|
||||
"params": [json.dumps(event_for_signing), {"role": "main"}],
|
||||
})
|
||||
if resp is None or "result" not in resp:
|
||||
print("ERROR: sign_event failed")
|
||||
print(f"Response: {resp}")
|
||||
return 1
|
||||
sig = resp["result"].strip('"')
|
||||
event["sig"] = sig
|
||||
print(f"Signature: {sig[:60]}...")
|
||||
|
||||
# Step 5: Print the signed event
|
||||
print("\n=== Signed Nostr event (ready to publish) ===")
|
||||
print(json.dumps(event, indent=2))
|
||||
print(f"\nTo publish: send this event to a Nostr relay.")
|
||||
print(f"To decrypt: call otp_decrypt with the content field.")
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
os.unlink(MNEMONIC_TMP)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -185,7 +185,7 @@ write_signer_start_script() {
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
LISTEN_TARGET="${NSIGNER_LISTEN_TARGET:-tcp:[::]:8080}"
|
||||
LISTEN_TARGET="${NSIGNER_LISTEN_TARGET:-tcp:[::]:11111}"
|
||||
|
||||
# If first arg is "qrexec", start in unix bridge mode for Qubes qrexec.
|
||||
# Otherwise, pass any extra args through to nsigner (e.g. --allow-all).
|
||||
|
||||
25
libotppad/Makefile
Normal file
25
libotppad/Makefile
Normal file
@@ -0,0 +1,25 @@
|
||||
CC ?= gcc
|
||||
CFLAGS ?= -Wall -Wextra -std=c99 -O2
|
||||
AR ?= ar
|
||||
|
||||
OBJS = libotppad.o
|
||||
LIB = libotppad.a
|
||||
|
||||
all: $(LIB)
|
||||
|
||||
$(LIB): $(OBJS)
|
||||
$(AR) rcs $@ $(OBJS)
|
||||
|
||||
libotppad.o: libotppad.c libotppad.h
|
||||
$(CC) $(CFLAGS) -c libotppad.c -o libotppad.o
|
||||
|
||||
test: test_libotppad
|
||||
./test_libotppad
|
||||
|
||||
test_libotppad: test_libotppad.c $(LIB)
|
||||
$(CC) $(CFLAGS) -I. test_libotppad.c -L. -lotppad -o test_libotppad
|
||||
|
||||
clean:
|
||||
rm -f $(OBJS) $(LIB) test_libotppad
|
||||
|
||||
.PHONY: all test clean
|
||||
398
libotppad/libotppad.c
Normal file
398
libotppad/libotppad.c
Normal file
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
* libotppad.c — implementation of libotppad.h.
|
||||
*
|
||||
* Extracted from the otp project (src/crypto.c, src/padding.c, src/pads.c)
|
||||
* and made self-contained: no main.h, no global state, no UI.
|
||||
*/
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "libotppad.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* XOR transform */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otppad_xor(const unsigned char *data, size_t data_len,
|
||||
const unsigned char *pad_data, unsigned char *result) {
|
||||
if (!data || !pad_data || !result) {
|
||||
return 1;
|
||||
}
|
||||
for (size_t i = 0; i < data_len; i++) {
|
||||
result[i] = data[i] ^ pad_data[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base64 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static const char b64_chars[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
static const int b64_decode_table[256] = {
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63,
|
||||
52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-2,-1,-1,
|
||||
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,
|
||||
15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,
|
||||
-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,
|
||||
41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
|
||||
};
|
||||
|
||||
char *otppad_base64_encode(const unsigned char *input, int length) {
|
||||
if (!input || length < 0) return NULL;
|
||||
int output_length = 4 * ((length + 2) / 3);
|
||||
char *encoded = (char *)malloc((size_t)output_length + 1);
|
||||
if (!encoded) return NULL;
|
||||
|
||||
int i, j;
|
||||
for (i = 0, j = 0; i < length;) {
|
||||
uint32_t octet_a = i < length ? input[i++] : 0;
|
||||
uint32_t octet_b = i < length ? input[i++] : 0;
|
||||
uint32_t octet_c = i < length ? input[i++] : 0;
|
||||
uint32_t triple = (octet_a << 16) + (octet_b << 8) + octet_c;
|
||||
encoded[j++] = b64_chars[(triple >> 18) & 63];
|
||||
encoded[j++] = b64_chars[(triple >> 12) & 63];
|
||||
encoded[j++] = b64_chars[(triple >> 6) & 63];
|
||||
encoded[j++] = b64_chars[triple & 63];
|
||||
}
|
||||
for (int pad = 0; pad < (3 - length % 3) % 3; pad++) {
|
||||
encoded[output_length - 1 - pad] = '=';
|
||||
}
|
||||
encoded[output_length] = '\0';
|
||||
return encoded;
|
||||
}
|
||||
|
||||
unsigned char *otppad_base64_decode(const char *input, int *output_length) {
|
||||
if (!input || !output_length) return NULL;
|
||||
int input_length = (int)strlen(input);
|
||||
if (input_length % 4 != 0) return NULL;
|
||||
|
||||
*output_length = input_length / 4 * 3;
|
||||
if (input[input_length - 1] == '=') (*output_length)--;
|
||||
if (input[input_length - 2] == '=') (*output_length)--;
|
||||
|
||||
unsigned char *decoded = (unsigned char *)malloc((size_t)*output_length);
|
||||
if (!decoded) return NULL;
|
||||
|
||||
int i, j;
|
||||
for (i = 0, j = 0; i < input_length;) {
|
||||
int sa = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
|
||||
int sb = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
|
||||
int sc = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
|
||||
int sd = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
|
||||
if (sa == -1 || sb == -1 || sc == -1 || sd == -1) {
|
||||
free(decoded);
|
||||
return NULL;
|
||||
}
|
||||
uint32_t triple = ((uint32_t)sa << 18) + ((uint32_t)sb << 12) +
|
||||
((uint32_t)sc << 6) + (uint32_t)sd;
|
||||
if (j < *output_length) decoded[j++] = (triple >> 16) & 255;
|
||||
if (j < *output_length) decoded[j++] = (triple >> 8) & 255;
|
||||
if (j < *output_length) decoded[j++] = triple & 255;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Padmé padding */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
size_t otppad_chunk_size(size_t msg_len) {
|
||||
size_t chunk = 256;
|
||||
while (chunk < msg_len + 1) {
|
||||
chunk *= 2;
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
int otppad_pad_apply(unsigned char *buffer, size_t msg_len, size_t chunk_size) {
|
||||
if (!buffer) return 1;
|
||||
if (chunk_size < msg_len + 1) return 2;
|
||||
buffer[msg_len] = 0x80;
|
||||
if (chunk_size > msg_len + 1) {
|
||||
memset(buffer + msg_len + 1, 0x00, chunk_size - msg_len - 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_pad_remove(const unsigned char *buffer, size_t chunk_size,
|
||||
size_t *msg_len) {
|
||||
if (!buffer || !msg_len) return 1;
|
||||
if (chunk_size == 0) return 2;
|
||||
for (int i = (int)chunk_size - 1; i >= 0; i--) {
|
||||
if (buffer[i] == 0x80) {
|
||||
*msg_len = (size_t)i;
|
||||
return 0;
|
||||
} else if (buffer[i] != 0x00) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
|
||||
void otppad_chunk_format(size_t chunk_size, char *buffer, size_t buffer_size) {
|
||||
if (!buffer || buffer_size == 0) return;
|
||||
if (chunk_size < 1024) {
|
||||
snprintf(buffer, buffer_size, "%zu bytes", chunk_size);
|
||||
} else if (chunk_size < 1024 * 1024) {
|
||||
snprintf(buffer, buffer_size, "%.1f KB", chunk_size / 1024.0);
|
||||
} else if (chunk_size < 1024 * 1024 * 1024) {
|
||||
snprintf(buffer, buffer_size, "%.1f MB", chunk_size / (1024.0 * 1024.0));
|
||||
} else {
|
||||
snprintf(buffer, buffer_size, "%.1f GB",
|
||||
chunk_size / (1024.0 * 1024.0 * 1024.0));
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ASCII armored message format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otppad_armor_parse(const char *message, char *chksum, uint64_t *offset,
|
||||
char *base64_data, size_t base64_buf_size) {
|
||||
if (!message || !chksum || !offset || !base64_data || base64_buf_size == 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
size_t msg_len = strlen(message);
|
||||
char *copy = (char *)malloc(msg_len + 1);
|
||||
if (!copy) return 1;
|
||||
strcpy(copy, message);
|
||||
|
||||
char *line = strtok(copy, "\n");
|
||||
int found_begin = 0, in_data = 0, found_chksum = 0, found_offset = 0;
|
||||
chksum[0] = '\0';
|
||||
*offset = 0;
|
||||
base64_data[0] = '\0';
|
||||
|
||||
while (line != NULL) {
|
||||
if (strcmp(line, OTPPAD_ARMOR_BEGIN) == 0) {
|
||||
found_begin = 1;
|
||||
} else if (strcmp(line, OTPPAD_ARMOR_END) == 0) {
|
||||
break;
|
||||
} else if (found_begin) {
|
||||
if (strncmp(line, "Pad-ChkSum: ", 12) == 0) {
|
||||
strncpy(chksum, line + 12, OTPPAD_CHKSUM_HEX_LEN);
|
||||
chksum[OTPPAD_CHKSUM_HEX_LEN] = '\0';
|
||||
found_chksum = 1;
|
||||
} else if (strncmp(line, "Pad-Offset: ", 12) == 0) {
|
||||
*offset = strtoull(line + 12, NULL, 10);
|
||||
found_offset = 1;
|
||||
} else if (strlen(line) == 0) {
|
||||
in_data = 1;
|
||||
} else if (in_data) {
|
||||
strncat(base64_data, line, base64_buf_size - strlen(base64_data) - 1);
|
||||
} else if (strncmp(line, "Version:", 8) != 0 &&
|
||||
strncmp(line, "Pad-", 4) != 0) {
|
||||
strncat(base64_data, line, base64_buf_size - strlen(base64_data) - 1);
|
||||
}
|
||||
}
|
||||
line = strtok(NULL, "\n");
|
||||
}
|
||||
|
||||
free(copy);
|
||||
if (!found_begin || !found_chksum || !found_offset) {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_armor_generate(const char *version, const char *chksum,
|
||||
uint64_t offset,
|
||||
const unsigned char *encrypted_data, size_t data_length,
|
||||
char **ascii_output) {
|
||||
if (!chksum || !encrypted_data || !ascii_output) return 1;
|
||||
|
||||
char *b64 = otppad_base64_encode(encrypted_data, (int)data_length);
|
||||
if (!b64) return 2;
|
||||
|
||||
size_t b64_len = strlen(b64);
|
||||
size_t total = 256 + b64_len + (b64_len / 64) + 64;
|
||||
*ascii_output = (char *)malloc(total);
|
||||
if (!*ascii_output) {
|
||||
free(b64);
|
||||
return 3;
|
||||
}
|
||||
|
||||
char line[256];
|
||||
strcpy(*ascii_output, OTPPAD_ARMOR_BEGIN);
|
||||
strcat(*ascii_output, "\n");
|
||||
|
||||
snprintf(line, sizeof(line), "Version: %s\n", version ? version : "v0");
|
||||
strcat(*ascii_output, line);
|
||||
|
||||
snprintf(line, sizeof(line), "Pad-ChkSum: %s\n", chksum);
|
||||
strcat(*ascii_output, line);
|
||||
|
||||
snprintf(line, sizeof(line), "Pad-Offset: %llu\n",
|
||||
(unsigned long long)offset);
|
||||
strcat(*ascii_output, line);
|
||||
|
||||
strcat(*ascii_output, "\n");
|
||||
|
||||
int b64_len_int = (int)b64_len;
|
||||
for (int i = 0; i < b64_len_int; i += 64) {
|
||||
snprintf(line, sizeof(line), "%.64s\n", b64 + i);
|
||||
strcat(*ascii_output, line);
|
||||
}
|
||||
|
||||
strcat(*ascii_output, OTPPAD_ARMOR_END);
|
||||
strcat(*ascii_output, "\n");
|
||||
|
||||
free(b64);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Binary .otp file format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otppad_bin_header_write(FILE *fp, const otppad_bin_header_t *hdr) {
|
||||
if (!fp || !hdr) return 1;
|
||||
if (fwrite(OTPPAD_MAGIC, 1, OTPPAD_MAGIC_LEN, fp) != OTPPAD_MAGIC_LEN) return 2;
|
||||
if (fwrite(&hdr->version, sizeof(uint16_t), 1, fp) != 1) return 3;
|
||||
if (fwrite(hdr->pad_chksum, 1, OTPPAD_CHKSUM_BIN_LEN, fp) != OTPPAD_CHKSUM_BIN_LEN) return 4;
|
||||
if (fwrite(&hdr->pad_offset, sizeof(uint64_t), 1, fp) != 1) return 5;
|
||||
if (fwrite(&hdr->file_mode, sizeof(uint32_t), 1, fp) != 1) return 6;
|
||||
if (fwrite(&hdr->file_size, sizeof(uint64_t), 1, fp) != 1) return 7;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_bin_header_read(FILE *fp, otppad_bin_header_t *hdr) {
|
||||
if (!fp || !hdr) return 1;
|
||||
memset(hdr, 0, sizeof(*hdr));
|
||||
if (fread(hdr->magic, 1, OTPPAD_MAGIC_LEN, fp) != OTPPAD_MAGIC_LEN) return 2;
|
||||
if (fread(&hdr->version, sizeof(uint16_t), 1, fp) != 1) return 3;
|
||||
if (fread(hdr->pad_chksum, 1, OTPPAD_CHKSUM_BIN_LEN, fp) != OTPPAD_CHKSUM_BIN_LEN) return 4;
|
||||
if (fread(&hdr->pad_offset, sizeof(uint64_t), 1, fp) != 1) return 5;
|
||||
if (fread(&hdr->file_mode, sizeof(uint32_t), 1, fp) != 1) return 6;
|
||||
if (fread(&hdr->file_size, sizeof(uint64_t), 1, fp) != 1) return 7;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_bin_is_magic(const unsigned char *buf, size_t len) {
|
||||
if (!buf || len < OTPPAD_MAGIC_LEN) return 0;
|
||||
return memcmp(buf, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN) == 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-pad .state file */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otppad_state_read(const char *pads_dir, const char *chksum, uint64_t *offset) {
|
||||
if (!pads_dir || !chksum || !offset) return 1;
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 2;
|
||||
|
||||
char line[128];
|
||||
if (!fgets(line, sizeof(line), f)) {
|
||||
fclose(f);
|
||||
return 3;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
if (strncmp(line, "offset=", 7) != 0) {
|
||||
return 4;
|
||||
}
|
||||
*offset = strtoull(line + 7, NULL, 10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_state_write(const char *pads_dir, const char *chksum, uint64_t offset) {
|
||||
if (!pads_dir || !chksum) return 1;
|
||||
char path[1024];
|
||||
char tmp[1100];
|
||||
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
|
||||
snprintf(tmp, sizeof(tmp), "%s/%s.state.tmp.XXXXXX", pads_dir, chksum);
|
||||
|
||||
int tfd = mkstemp(tmp);
|
||||
if (tfd < 0) return 2;
|
||||
FILE *f = fdopen(tfd, "w");
|
||||
if (!f) {
|
||||
close(tfd);
|
||||
unlink(tmp);
|
||||
return 3;
|
||||
}
|
||||
if (fprintf(f, "offset=%llu\n", (unsigned long long)offset) < 0) {
|
||||
fclose(f);
|
||||
unlink(tmp);
|
||||
return 4;
|
||||
}
|
||||
if (fclose(f) != 0) {
|
||||
unlink(tmp);
|
||||
return 5;
|
||||
}
|
||||
if (rename(tmp, path) != 0) {
|
||||
unlink(tmp);
|
||||
return 6;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pad checksum */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otppad_checksum(const char *pad_path, char *checksum_hex) {
|
||||
if (!pad_path || !checksum_hex) return 1;
|
||||
|
||||
FILE *file = fopen(pad_path, "rb");
|
||||
if (!file) return 2;
|
||||
|
||||
unsigned char checksum[OTPPAD_CHKSUM_BIN_LEN];
|
||||
unsigned char buffer[64 * 1024];
|
||||
size_t bytes_read;
|
||||
size_t total_bytes = 0;
|
||||
memset(checksum, 0, OTPPAD_CHKSUM_BIN_LEN);
|
||||
|
||||
while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0) {
|
||||
for (size_t i = 0; i < bytes_read; i++) {
|
||||
size_t pos = total_bytes + i;
|
||||
unsigned char bucket = (unsigned char)(pos % OTPPAD_CHKSUM_BIN_LEN);
|
||||
checksum[bucket] ^= (unsigned char)buffer[i] ^
|
||||
(unsigned char)((pos >> 8) & 0xFF) ^
|
||||
(unsigned char)((pos >> 16) & 0xFF) ^
|
||||
(unsigned char)((pos >> 24) & 0xFF);
|
||||
}
|
||||
total_bytes += bytes_read;
|
||||
}
|
||||
fclose(file);
|
||||
|
||||
file = fopen(pad_path, "rb");
|
||||
if (!file) return 3;
|
||||
unsigned char pad_key[OTPPAD_CHKSUM_BIN_LEN];
|
||||
if (fread(pad_key, 1, OTPPAD_CHKSUM_BIN_LEN, file) != OTPPAD_CHKSUM_BIN_LEN) {
|
||||
fclose(file);
|
||||
return 4;
|
||||
}
|
||||
fclose(file);
|
||||
|
||||
unsigned char enc[OTPPAD_CHKSUM_BIN_LEN];
|
||||
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
|
||||
enc[i] = checksum[i] ^ pad_key[i];
|
||||
}
|
||||
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
|
||||
sprintf(checksum_hex + (i * 2), "%02x", enc[i]);
|
||||
}
|
||||
checksum_hex[OTPPAD_CHKSUM_HEX_LEN] = '\0';
|
||||
return 0;
|
||||
}
|
||||
201
libotppad/libotppad.h
Normal file
201
libotppad/libotppad.h
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* libotppad.h — format-critical helpers for one-time-pad encryption.
|
||||
*
|
||||
* Bit-compatible with the `otp` project (https://git.laantungir.net/laantungir/otp):
|
||||
* - XOR transform
|
||||
* - ASCII armored message format ("-----BEGIN OTP MESSAGE-----")
|
||||
* - Binary .otp file format (magic "OTP\0")
|
||||
* - ISO/IEC 9797-1 Method 2 (Padmé) padding with exponential bucketing
|
||||
* - Per-pad .state file ("offset=<n>\n")
|
||||
* - 256-bit XOR pad checksum (position-dependent, XORed with first 32 pad bytes)
|
||||
*
|
||||
* This library is self-contained: it does not depend on the `otp` project's
|
||||
* main.h, global state, or UI code. Both `otp` and `n_signer` link against it.
|
||||
*
|
||||
* License: same as the otp project.
|
||||
*/
|
||||
#ifndef LIBOTPPAD_H
|
||||
#define LIBOTPPAD_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define OTPPAD_CHKSUM_HEX_LEN 64 /* 32 bytes -> 64 hex chars */
|
||||
#define OTPPAD_CHKSUM_BIN_LEN 32
|
||||
#define OTPPAD_HEADER_RESERVED 32 /* bytes reserved at start of pad */
|
||||
#define OTPPAD_MAGIC "OTP\0" /* 4-byte binary file magic */
|
||||
#define OTPPAD_MAGIC_LEN 4
|
||||
#define OTPPAD_FORMAT_VERSION 1 /* binary .otp format version */
|
||||
|
||||
#define OTPPAD_ARMOR_BEGIN "-----BEGIN OTP MESSAGE-----"
|
||||
#define OTPPAD_ARMOR_END "-----END OTP MESSAGE-----"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* XOR transform */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* XOR `data_len` bytes of `data` with `pad_data` into `result`.
|
||||
* `data`, `pad_data`, `result` must each be at least `data_len` bytes.
|
||||
* `result` may alias `data` or `pad_data`.
|
||||
* Returns 0 on success, non-zero on null pointer.
|
||||
*/
|
||||
int otppad_xor(const unsigned char *data, size_t data_len,
|
||||
const unsigned char *pad_data, unsigned char *result);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base64 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Encode `length` bytes of `input` as a NUL-terminated base64 string.
|
||||
* Caller frees the returned string. Returns NULL on allocation failure. */
|
||||
char *otppad_base64_encode(const unsigned char *input, int length);
|
||||
|
||||
/* Decode NUL-terminated base64 `input` into bytes.
|
||||
* Caller frees the returned buffer. *output_length receives the byte count.
|
||||
* Returns NULL on invalid input or allocation failure. */
|
||||
unsigned char *otppad_base64_decode(const char *input, int *output_length);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Padmé padding (ISO/IEC 9797-1 Method 2) + exponential bucketing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Calculate the bucket size for a message of `msg_len` bytes.
|
||||
* Starts at 256 bytes and doubles until `chunk >= msg_len + 1`. */
|
||||
size_t otppad_chunk_size(size_t msg_len);
|
||||
|
||||
/* Apply Padmé padding to `buffer` (must hold `chunk_size` bytes).
|
||||
* Writes 0x80 at `buffer[msg_len]` then zeroes to `chunk_size`.
|
||||
* Returns 0 on success, non-zero on error. */
|
||||
int otppad_pad_apply(unsigned char *buffer, size_t msg_len, size_t chunk_size);
|
||||
|
||||
/* Remove Padmé padding: scan backwards for 0x80, set *msg_len to its index.
|
||||
* Returns 0 on success, non-zero on invalid padding. */
|
||||
int otppad_pad_remove(const unsigned char *buffer, size_t chunk_size,
|
||||
size_t *msg_len);
|
||||
|
||||
/* Human-readable chunk size string (e.g. "256 bytes", "1.0 KB"). */
|
||||
void otppad_chunk_format(size_t chunk_size, char *buffer, size_t buffer_size);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ASCII armored message format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Parse an ASCII-armored OTP message.
|
||||
*
|
||||
* `chksum` must be at least OTPPAD_CHKSUM_HEX_LEN+1 bytes.
|
||||
* `base64_data` must be at least `base64_buf_size` bytes; the decoded
|
||||
* ciphertext is NOT returned here — only the raw base64 text. Use
|
||||
* otppad_base64_decode() to get the bytes.
|
||||
*
|
||||
* On success returns 0 and sets `chksum`, `*offset`, and `base64_data`.
|
||||
* Returns non-zero on malformed input.
|
||||
*/
|
||||
int otppad_armor_parse(const char *message, char *chksum, uint64_t *offset,
|
||||
char *base64_data, size_t base64_buf_size);
|
||||
|
||||
/*
|
||||
* Build an ASCII-armored OTP message.
|
||||
*
|
||||
* `version` is the version string for the "Version:" header (e.g. "v0.3.53").
|
||||
* `chksum` is the 64-char hex pad checksum.
|
||||
* `offset` is the pad offset where the slice begins.
|
||||
* `encrypted_data` / `data_length` is the ciphertext to base64-encode.
|
||||
*
|
||||
* On success returns 0 and sets `*ascii_output` to a malloc'd NUL-terminated
|
||||
* string. Caller frees `*ascii_output`.
|
||||
*/
|
||||
int otppad_armor_generate(const char *version, const char *chksum,
|
||||
uint64_t offset,
|
||||
const unsigned char *encrypted_data, size_t data_length,
|
||||
char **ascii_output);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Binary .otp file format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Binary .otp header (58 bytes). All fields are little-endian on disk
|
||||
* (written via fwrite of host-endian integers — matches the otp project,
|
||||
* which is x86/ARM little-endian in practice).
|
||||
*
|
||||
* Offset Size Field
|
||||
* 0 4 Magic "OTP\0"
|
||||
* 4 2 Version (uint16, currently 1)
|
||||
* 6 32 Pad checksum (binary, 32 bytes)
|
||||
* 38 8 Pad offset (uint64)
|
||||
* 46 4 Original file mode (uint32)
|
||||
* 50 8 Original file size (uint64, NOT padded size)
|
||||
* 58 var Encrypted (padded) data
|
||||
*/
|
||||
typedef struct {
|
||||
char magic[OTPPAD_MAGIC_LEN];
|
||||
uint16_t version;
|
||||
unsigned char pad_chksum[OTPPAD_CHKSUM_BIN_LEN];
|
||||
uint64_t pad_offset;
|
||||
uint32_t file_mode;
|
||||
uint64_t file_size; /* original (unpadded) size */
|
||||
} otppad_bin_header_t;
|
||||
|
||||
/* Write a binary .otp header to `fp`. Returns 0 on success. */
|
||||
int otppad_bin_header_write(FILE *fp, const otppad_bin_header_t *hdr);
|
||||
|
||||
/* Read a binary .otp header from `fp`. Returns 0 on success, non-zero on
|
||||
* malformed input. Does not validate the magic — use otppad_bin_is_magic()
|
||||
* first if needed. */
|
||||
int otppad_bin_header_read(FILE *fp, otppad_bin_header_t *hdr);
|
||||
|
||||
/* Return 1 if the first 4 bytes of `buf` match the OTP magic. */
|
||||
int otppad_bin_is_magic(const unsigned char *buf, size_t len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-pad .state file */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Read the offset from `<pads_dir>/<chksum>.state`.
|
||||
* Returns 0 on success and sets *offset. Non-zero on error.
|
||||
*/
|
||||
int otppad_state_read(const char *pads_dir, const char *chksum, uint64_t *offset);
|
||||
|
||||
/*
|
||||
* Atomically write the offset to `<pads_dir>/<chksum>.state`.
|
||||
* Writes to a temp file then renames, so a crash cannot corrupt the state.
|
||||
* Returns 0 on success, non-zero on error.
|
||||
*/
|
||||
int otppad_state_write(const char *pads_dir, const char *chksum, uint64_t offset);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pad checksum */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Compute the 256-bit XOR checksum of the pad file at `pad_path`.
|
||||
* `checksum_hex` must be at least OTPPAD_CHKSUM_HEX_LEN+1 bytes.
|
||||
*
|
||||
* Algorithm (matches otp/src/crypto.c:calculate_checksum):
|
||||
* - XOR every byte into one of 32 buckets, selected by (position % 32),
|
||||
* also XORing in bytes (pos>>8),(pos>>16),(pos>>24) of the position.
|
||||
* - XOR the resulting 32-byte checksum with the first 32 bytes of the pad
|
||||
* (the "pad key").
|
||||
* - Hex-encode the 32-byte result.
|
||||
*
|
||||
* Returns 0 on success, non-zero on error.
|
||||
*/
|
||||
int otppad_checksum(const char *pad_path, char *checksum_hex);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* LIBOTPPAD_H */
|
||||
BIN
libotppad/test_libotppad
Executable file
BIN
libotppad/test_libotppad
Executable file
Binary file not shown.
213
libotppad/test_libotppad.c
Normal file
213
libotppad/test_libotppad.c
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* test_libotppad.c — unit tests for libotppad.
|
||||
*
|
||||
* Covers: XOR, base64 round-trip, Padmé padding round-trip, ASCII armor
|
||||
* round-trip, binary header round-trip, .state file round-trip, and pad
|
||||
* checksum against a known pad.
|
||||
*/
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "libotppad.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static int failures = 0;
|
||||
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL: %s (%s:%d)\n", (msg), __FILE__, __LINE__); \
|
||||
failures++; \
|
||||
} else { \
|
||||
printf("ok: %s\n", (msg)); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static void test_xor(void) {
|
||||
unsigned char data[] = {0x01, 0x02, 0x03, 0x04, 0x05};
|
||||
unsigned char pad[] = {0xff, 0xee, 0xdd, 0xcc, 0xbb};
|
||||
unsigned char out[5];
|
||||
CHECK(otppad_xor(data, 5, pad, out) == 0, "xor returns 0");
|
||||
CHECK(out[0] == 0xfe && out[1] == 0xec && out[2] == 0xde &&
|
||||
out[3] == 0xc8 && out[4] == 0xbe, "xor values correct");
|
||||
/* in-place aliasing */
|
||||
CHECK(otppad_xor(data, 5, pad, data) == 0, "xor in-place");
|
||||
CHECK(data[0] == 0xfe, "xor in-place value");
|
||||
}
|
||||
|
||||
static void test_base64(void) {
|
||||
const char *in = "Hello, World!";
|
||||
int len = (int)strlen(in);
|
||||
char *enc = otppad_base64_encode((const unsigned char *)in, len);
|
||||
CHECK(enc != NULL, "base64 encode");
|
||||
int dlen = 0;
|
||||
unsigned char *dec = otppad_base64_decode(enc, &dlen);
|
||||
CHECK(dec != NULL, "base64 decode");
|
||||
CHECK(dlen == len, "base64 length preserved");
|
||||
CHECK(memcmp(dec, in, len) == 0, "base64 round-trip");
|
||||
free(enc);
|
||||
free(dec);
|
||||
|
||||
/* empty input */
|
||||
enc = otppad_base64_encode((const unsigned char *)"", 0);
|
||||
CHECK(enc != NULL && strcmp(enc, "") == 0, "base64 empty");
|
||||
free(enc);
|
||||
}
|
||||
|
||||
static void test_padding(void) {
|
||||
/* 10-byte message -> 256-byte bucket */
|
||||
size_t chunk = otppad_chunk_size(10);
|
||||
CHECK(chunk == 256, "chunk size for 10 bytes is 256");
|
||||
/* 300-byte message -> 512-byte bucket */
|
||||
chunk = otppad_chunk_size(300);
|
||||
CHECK(chunk == 512, "chunk size for 300 bytes is 512");
|
||||
/* 256-byte message -> 512 (needs +1 for 0x80) */
|
||||
chunk = otppad_chunk_size(256);
|
||||
CHECK(chunk == 512, "chunk size for 256 bytes is 512 (needs +1)");
|
||||
|
||||
unsigned char buf[512];
|
||||
const char *msg = "test message";
|
||||
size_t msg_len = strlen(msg);
|
||||
memset(buf, 0xaa, sizeof(buf));
|
||||
memcpy(buf, msg, msg_len);
|
||||
chunk = otppad_chunk_size(msg_len);
|
||||
CHECK(otppad_pad_apply(buf, msg_len, chunk) == 0, "pad apply");
|
||||
CHECK(buf[msg_len] == 0x80, "pad marker at msg_len");
|
||||
size_t recovered;
|
||||
CHECK(otppad_pad_remove(buf, chunk, &recovered) == 0, "pad remove");
|
||||
CHECK(recovered == msg_len, "pad remove recovers length");
|
||||
CHECK(memcmp(buf, msg, msg_len) == 0, "pad round-trip preserves message");
|
||||
}
|
||||
|
||||
static void test_armor(void) {
|
||||
const char *chksum =
|
||||
"333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e";
|
||||
unsigned char ct[] = {0xde, 0xad, 0xbe, 0xef, 0x10, 0x20, 0x30, 0x40};
|
||||
uint64_t offset = 12345;
|
||||
char *armor = NULL;
|
||||
CHECK(otppad_armor_generate("v0.3.53", chksum, offset, ct, sizeof(ct),
|
||||
&armor) == 0,
|
||||
"armor generate");
|
||||
CHECK(armor != NULL && strstr(armor, "BEGIN OTP MESSAGE") != NULL,
|
||||
"armor has begin marker");
|
||||
|
||||
char parsed_chk[OTPPAD_CHKSUM_HEX_LEN + 1];
|
||||
uint64_t parsed_off;
|
||||
char parsed_b64[8192];
|
||||
CHECK(otppad_armor_parse(armor, parsed_chk, &parsed_off, parsed_b64,
|
||||
sizeof(parsed_b64)) == 0,
|
||||
"armor parse");
|
||||
CHECK(strcmp(parsed_chk, chksum) == 0, "armor chksum round-trip");
|
||||
CHECK(parsed_off == offset, "armor offset round-trip");
|
||||
|
||||
int dlen = 0;
|
||||
unsigned char *dec = otppad_base64_decode(parsed_b64, &dlen);
|
||||
CHECK(dec != NULL && dlen == (int)sizeof(ct), "armor b64 length");
|
||||
CHECK(dec && memcmp(dec, ct, sizeof(ct)) == 0, "armor ciphertext round-trip");
|
||||
free(armor);
|
||||
free(dec);
|
||||
}
|
||||
|
||||
static void test_bin_header(void) {
|
||||
otppad_bin_header_t hdr;
|
||||
memset(&hdr, 0, sizeof(hdr));
|
||||
memcpy(hdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN);
|
||||
hdr.version = OTPPAD_FORMAT_VERSION;
|
||||
memset(hdr.pad_chksum, 0xab, OTPPAD_CHKSUM_BIN_LEN);
|
||||
hdr.pad_offset = 999;
|
||||
hdr.file_mode = 0644;
|
||||
hdr.file_size = 4096;
|
||||
|
||||
const char *path = "test_bin_header.tmp";
|
||||
FILE *fp = fopen(path, "wb");
|
||||
CHECK(fp != NULL, "open bin header tmp for write");
|
||||
CHECK(otppad_bin_header_write(fp, &hdr) == 0, "bin header write");
|
||||
fclose(fp);
|
||||
|
||||
fp = fopen(path, "rb");
|
||||
CHECK(fp != NULL, "open bin header tmp for read");
|
||||
otppad_bin_header_t hdr2;
|
||||
CHECK(otppad_bin_header_read(fp, &hdr2) == 0, "bin header read");
|
||||
fclose(fp);
|
||||
unlink(path);
|
||||
|
||||
CHECK(hdr2.version == hdr.version, "bin header version");
|
||||
CHECK(hdr2.pad_offset == hdr.pad_offset, "bin header offset");
|
||||
CHECK(hdr2.file_mode == hdr.file_mode, "bin header file_mode");
|
||||
CHECK(hdr2.file_size == hdr.file_size, "bin header file_size");
|
||||
CHECK(memcmp(hdr2.pad_chksum, hdr.pad_chksum, OTPPAD_CHKSUM_BIN_LEN) == 0,
|
||||
"bin header chksum");
|
||||
CHECK(otppad_bin_is_magic((const unsigned char *)hdr2.magic, 4),
|
||||
"bin is_magic");
|
||||
}
|
||||
|
||||
static void test_state(void) {
|
||||
const char *dir = "test_state_dir.tmp";
|
||||
mkdir(dir, 0755);
|
||||
const char *chksum =
|
||||
"333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e";
|
||||
|
||||
uint64_t off;
|
||||
CHECK(otppad_state_read(dir, chksum, &off) != 0, "state read missing fails");
|
||||
|
||||
CHECK(otppad_state_write(dir, chksum, 42) == 0, "state write");
|
||||
CHECK(otppad_state_read(dir, chksum, &off) == 0, "state read");
|
||||
CHECK(off == 42, "state value");
|
||||
|
||||
CHECK(otppad_state_write(dir, chksum, 1000) == 0, "state overwrite");
|
||||
CHECK(otppad_state_read(dir, chksum, &off) == 0, "state read 2");
|
||||
CHECK(off == 1000, "state value 2");
|
||||
|
||||
/* cleanup */
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/%s.state", dir, chksum);
|
||||
unlink(path);
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
static void test_checksum(void) {
|
||||
/* Build a tiny pad: 64 bytes, first 32 are the "key", rest arbitrary. */
|
||||
const char *dir = "test_chksum_dir.tmp";
|
||||
const char *chksum_expected =
|
||||
"333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e";
|
||||
mkdir(dir, 0755);
|
||||
|
||||
/* Use the real test pad if it exists; otherwise skip this test. */
|
||||
const char *real_pad =
|
||||
"/media/user/Music/pads/333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.pad";
|
||||
FILE *fp = fopen(real_pad, "rb");
|
||||
if (!fp) {
|
||||
printf("ok: checksum test skipped (no real pad at %s)\n", real_pad);
|
||||
rmdir(dir);
|
||||
return;
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
char chk[OTPPAD_CHKSUM_HEX_LEN + 1];
|
||||
CHECK(otppad_checksum(real_pad, chk) == 0, "checksum computes");
|
||||
CHECK(strcmp(chk, chksum_expected) == 0,
|
||||
"checksum matches expected (bit-compatible with otp)");
|
||||
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_xor();
|
||||
test_base64();
|
||||
test_padding();
|
||||
test_armor();
|
||||
test_bin_header();
|
||||
test_state();
|
||||
test_checksum();
|
||||
|
||||
if (failures == 0) {
|
||||
printf("\nALL TESTS PASSED\n");
|
||||
return 0;
|
||||
}
|
||||
printf("\n%d TEST(S) FAILED\n", failures);
|
||||
return 1;
|
||||
}
|
||||
32
packaging/qubes/allow_nostr_dom0.sh
Normal file
32
packaging/qubes/allow_nostr_dom0.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Run in dom0 to allow the "nostr" qube to call n_signer without a Qubes popup.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
POLICY_FILE="/etc/qubes/policy.d/40-nsigner.policy"
|
||||
SIGNER_QUBE="nostr_signer"
|
||||
CALLER_QUBE="nostr"
|
||||
SIGNER_TAG="nsigner-signer"
|
||||
|
||||
echo "Installing n_signer qrexec policy..."
|
||||
sudo mkdir -p /etc/qubes/policy.d
|
||||
sudo tee "$POLICY_FILE" >/dev/null <<EOF
|
||||
# Qubes OS qrexec policy for nsigner
|
||||
qubes.NsignerRpc * ai @tag:${SIGNER_TAG} allow target=${SIGNER_QUBE}
|
||||
qubes.NsignerRpc * ${CALLER_QUBE} @tag:${SIGNER_TAG} allow target=${SIGNER_QUBE}
|
||||
qubes.NsignerRpc * @anyvm @tag:${SIGNER_TAG} ask default_target=${SIGNER_QUBE}
|
||||
qubes.NsignerRpc * @anyvm @anyvm deny
|
||||
EOF
|
||||
sudo chmod 0644 "$POLICY_FILE"
|
||||
|
||||
echo "Tagging ${SIGNER_QUBE} as ${SIGNER_TAG}..."
|
||||
qvm-tags "$SIGNER_QUBE" add "$SIGNER_TAG"
|
||||
|
||||
echo
|
||||
echo "Installed policy:"
|
||||
sudo cat "$POLICY_FILE"
|
||||
echo
|
||||
echo "Tags on ${SIGNER_QUBE}:"
|
||||
qvm-tags "$SIGNER_QUBE" list
|
||||
echo
|
||||
echo "Done. Calls from ${CALLER_QUBE} to ${SIGNER_QUBE} are now allowed without a Qubes popup."
|
||||
274
plans/http_wss_listener.md
Normal file
274
plans/http_wss_listener.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Plan: HTTP and WebSocket (WSS) Listener Modes for n_signer
|
||||
|
||||
## Goal
|
||||
|
||||
Add two new listener modes to n_signer so that standard HTTP clients (curl) and
|
||||
WebSocket clients (browsers, relay-style tools) can send JSON-RPC requests
|
||||
directly, without the custom 4-byte length-prefixed framing.
|
||||
|
||||
## Current state
|
||||
|
||||
n_signer supports these listener modes:
|
||||
- `unix` — abstract namespace Unix socket, 4-byte framed JSON
|
||||
- `stdio` — stdin/stdout, 4-byte framed JSON, one request per invocation
|
||||
- `qrexec` — same as stdio but with `QREXEC_REMOTE_DOMAIN` caller identity
|
||||
- `tcp:HOST:PORT` — TCP, 4-byte framed JSON
|
||||
|
||||
All modes use the same dispatcher (`dispatcher_handle_request`) which takes a
|
||||
JSON string and returns a JSON string. The only difference between modes is the
|
||||
transport framing and caller-identity extraction.
|
||||
|
||||
## Proposed modes
|
||||
|
||||
### HTTP mode: `--listen http:HOST:PORT`
|
||||
|
||||
**Protocol:** HTTP/1.1 POST with JSON body → JSON response.
|
||||
|
||||
```
|
||||
POST / HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
{"id":"1","method":"otp_encrypt","params":["SGVsbG8=",{"encoding":"ascii"}]}
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{"id":"1","result":"{...}"}
|
||||
```
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"get_public_key","params":[{"role":"main"}]}'
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Parse the HTTP request line + headers (minimal parser — just enough for POST)
|
||||
- Read the Content-Length bytes as the JSON-RPC request
|
||||
- Call `dispatcher_handle_request()`
|
||||
- Write the HTTP response with the JSON result
|
||||
|
||||
**Difficulty: Low.** The HTTP parser can be minimal (~100 lines). No need for
|
||||
a full HTTP server — just POST with a JSON body. No chunked encoding, no
|
||||
keep-alive, no static file serving. One request per connection (connection
|
||||
close after response), or simple keep-alive loop.
|
||||
|
||||
**Caller identity:** `tcp:<peer-addr>` (same as TCP mode). No `QREXEC_REMOTE_DOMAIN`.
|
||||
|
||||
**HTTPS variant:** `--listen https:HOST:PORT` wraps the HTTP listener in TLS.
|
||||
Requires `--tls-cert <path>` and `--tls-key <path>` flags. OpenSSL is already
|
||||
linked. The HTTP parser stays the same; TLS is layered underneath (accept
|
||||
connection → TLS handshake → then HTTP). See Phase 3 below.
|
||||
|
||||
### WebSocket mode: `--listen wss:HOST:PORT` (or `ws:HOST:PORT`)
|
||||
|
||||
**Protocol:** WebSocket with JSON text frames.
|
||||
|
||||
```
|
||||
Client → Server: text frame with JSON-RPC request
|
||||
Server → Client: text frame with JSON-RPC response
|
||||
```
|
||||
|
||||
**Browser example:**
|
||||
```javascript
|
||||
const ws = new WebSocket("ws://127.0.0.1:11111");
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({id:"1", method:"get_public_key", params:[{role:"main"}]}));
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
console.log(JSON.parse(e.data));
|
||||
};
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- HTTP upgrade handshake (Sec-WebSocket-Key → Sec-WebSocket-Accept)
|
||||
- WebSocket frame parser (opcode, mask, payload length — 7/16/64 bit)
|
||||
- Text frames (opcode 0x1) carry JSON-RPC requests
|
||||
- Response as text frames (opcode 0x1, unmasked from server)
|
||||
- One JSON-RPC request per text frame; one response per request
|
||||
|
||||
**Difficulty: Medium.** The WebSocket handshake is straightforward (SHA-1 +
|
||||
base64 of the key + magic GUID). The frame parser needs to handle:
|
||||
- 7-bit, 16-bit, and 64-bit payload lengths
|
||||
- Client-to-server masking (XOR with 4-byte mask)
|
||||
- Ping/pong frames (opcode 0x9/0xA)
|
||||
- Close frame (opcode 0x8)
|
||||
- Fragmentation (continuation frames, opcode 0x0) — can defer/skip for v1
|
||||
|
||||
A minimal implementation is ~200-300 lines. No need for a full RFC 6455
|
||||
compliance — just enough for curl, browsers, and common WebSocket clients.
|
||||
|
||||
**WSS (TLS) variant:** `wss:HOST:PORT` wraps the WebSocket in TLS. This
|
||||
requires linking against OpenSSL (already a dependency) and adding TLS
|
||||
handshake + certificate handling. Difficulty: Medium-High due to cert
|
||||
management. For v1, `ws:` (plaintext) is sufficient for localhost/FIPS-mesh
|
||||
use; `wss:` can be added later.
|
||||
|
||||
**Caller identity:** `ws:<peer-addr>` or `wss:<peer-addr>`.
|
||||
|
||||
## Security concerns
|
||||
|
||||
### 1. Network exposure
|
||||
|
||||
**Unix sockets** are only accessible from the same host/qube — no network
|
||||
exposure. **HTTP/WS** listeners bind to a TCP port, which is potentially
|
||||
reachable from other hosts/qubes on the network.
|
||||
|
||||
**Mitigation:**
|
||||
- Default bind address: `127.0.0.1` (localhost only), not `[::]` (all
|
||||
interfaces). The user must explicitly pass `--listen http:0.0.0.0:11111` to
|
||||
expose it.
|
||||
- The existing policy/approval system is the real gate — network access alone
|
||||
doesn't grant signing. Unknown callers get `PROMPT_EVERY_REQUEST` by default.
|
||||
|
||||
### 2. No authentication in HTTP/WS mode
|
||||
|
||||
The current TCP mode has no authentication either — it relies on the
|
||||
policy/approval prompt. HTTP/WS would be the same. Anyone who can reach the
|
||||
port can send requests, but they still need approval for sensitive operations.
|
||||
|
||||
**Mitigation:**
|
||||
- The `--auth required` mode (auth envelopes) could be extended to HTTP/WS.
|
||||
The client would include a signed auth envelope in an HTTP header
|
||||
(e.g. `X-Nsigner-Auth: <envelope>`) or as a WebSocket subprotocol header.
|
||||
- For v1, rely on localhost binding + policy/approval prompts, same as TCP.
|
||||
|
||||
### 3. CORS for browser access
|
||||
|
||||
If the WebSocket listener is accessed from a browser running on a different
|
||||
origin, CORS headers are needed for the HTTP upgrade handshake.
|
||||
|
||||
**Mitigation:**
|
||||
- Add `Access-Control-Allow-Origin: *` to the WebSocket upgrade response
|
||||
(the policy/approval system is the real security boundary, not CORS).
|
||||
- Or restrict to same-origin only (no CORS header → browser blocks
|
||||
cross-origin).
|
||||
|
||||
### 4. Request size limits — MUST be raised for OTP blob encryption
|
||||
|
||||
The current `SERVER_MAX_MSG_SIZE` is 65536 bytes (64KB). This is too small
|
||||
for OTP encryption of images or blobs:
|
||||
|
||||
- A 48KB image → base64 in JSON param = ~64KB → hits the limit
|
||||
- A 1MB image → base64 = ~1.33MB → way over
|
||||
- OTP Padmé padding rounds up to the next power of 2, and the response
|
||||
contains the base64-encoded ciphertext, which is even larger
|
||||
|
||||
**This is a pre-existing limitation that affects ALL transport modes**, not
|
||||
just HTTP/WS. It should be fixed before or alongside the HTTP/WS work.
|
||||
|
||||
**Proposed change:** raise `SERVER_MAX_MSG_SIZE` to 16MB (16777216). This
|
||||
accommodates:
|
||||
- Up to ~12MB plaintext (base64 = ~16MB in the JSON param)
|
||||
- The OTP response (base64-encoded padded ciphertext) for the same
|
||||
- The `stdin_buf[SERVER_MAX_MSG_SIZE + 1]` stack buffer in `client_main`
|
||||
should be changed to a heap allocation to avoid a 16MB stack frame
|
||||
|
||||
The `transport_recv_framed` function already takes a max-size parameter, so
|
||||
the change is: update the constant, change the stack buffer to malloc, and
|
||||
verify the dispatcher doesn't have other hardcoded size assumptions.
|
||||
|
||||
**For HTTP/WS:** enforce the same `SERVER_MAX_MSG_SIZE` limit via
|
||||
`Content-Length` checking in the HTTP parser.
|
||||
|
||||
### 5. TLS for WSS
|
||||
|
||||
For `wss:` mode, TLS is required. This means:
|
||||
- Certificate management (self-signed for local/FIPS mesh, or CA-signed for
|
||||
public-facing).
|
||||
- The signer would need a `--tls-cert` and `--tls-key` flag.
|
||||
- Pinning: clients should verify the cert fingerprint, not just trust the CA.
|
||||
|
||||
**For v1:** skip WSS/TLS. Provide `ws:` (plaintext) only, suitable for
|
||||
localhost and FIPS mesh (which has its own network-level isolation). Add
|
||||
`wss:` later when there's a real public-facing use case.
|
||||
|
||||
### 6. Connection flooding
|
||||
|
||||
HTTP/WS listeners are susceptible to connection flooding (many open
|
||||
connections, slowloris-style attacks). The current single-threaded poll loop
|
||||
handles one connection at a time, which naturally limits flood impact but
|
||||
also limits throughput.
|
||||
|
||||
**Mitigation:**
|
||||
- Connection timeout (close idle connections after N seconds).
|
||||
- Max connections limit.
|
||||
- For v1, the single-threaded model is fine — it's a signer, not a web server.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Phase 0: Raise SERVER_MAX_MSG_SIZE (prerequisite)
|
||||
|
||||
- [ ] Change `SERVER_MAX_MSG_SIZE` from 65536 to 16777216 (16MB) in all
|
||||
`.c` files where it's defined (the headerless-decls pattern means it's
|
||||
redefined in every file).
|
||||
- [ ] Change the `stdin_buf[SERVER_MAX_MSG_SIZE + 1]` stack buffer in
|
||||
`client_main` to a heap allocation (malloc/free) to avoid a 16MB stack
|
||||
frame.
|
||||
- [ ] Verify `transport_recv_framed` handles the larger size correctly (it
|
||||
already takes max-size as a parameter).
|
||||
- [ ] Test with a large OTP encrypt/decrypt round-trip (e.g. 1MB plaintext).
|
||||
|
||||
### Phase 1: HTTP listener mode
|
||||
|
||||
- [ ] Add `NSIGNER_LISTEN_HTTP` to the listen mode enum.
|
||||
- [ ] Parse `--listen http:HOST:PORT` in the CLI flag parser.
|
||||
- [ ] Implement a minimal HTTP/1.1 parser in `src/http_listener.c`:
|
||||
- Read request line (method, path, HTTP version)
|
||||
- Read headers (only need Content-Length)
|
||||
- Read body (Content-Length bytes)
|
||||
- Reject non-POST methods with 405
|
||||
- Reject oversized bodies with 413
|
||||
- [ ] Call `dispatcher_handle_request()` with the body, write JSON response
|
||||
with `200 OK` + `Content-Type: application/json`.
|
||||
- [ ] Caller identity: `http:<peer-ip>:<peer-port>`.
|
||||
- [ ] Default bind: `127.0.0.1` (not `[::]`).
|
||||
- [ ] Add to interactive transport menu as option 4.
|
||||
- [ ] Test with curl.
|
||||
|
||||
### Phase 2: WebSocket listener mode
|
||||
|
||||
- [ ] Add `NSIGNER_LISTEN_WS` to the listen mode enum.
|
||||
- [ ] Parse `--listen ws:HOST:PORT` in the CLI flag parser.
|
||||
- [ ] Implement WebSocket handshake + frame parser in `src/ws_listener.c`:
|
||||
- HTTP upgrade handshake (Sec-WebSocket-Key → Accept)
|
||||
- Frame parser (opcode, mask, payload length)
|
||||
- Text frames → JSON-RPC request → dispatcher → text frame response
|
||||
- Ping/pong handling
|
||||
- Close frame handling
|
||||
- [ ] Caller identity: `ws:<peer-ip>:<peer-port>`.
|
||||
- [ ] Default bind: `127.0.0.1`.
|
||||
- [ ] Add to interactive transport menu as option 5.
|
||||
- [ ] Test with a browser or `websocat`.
|
||||
|
||||
### Phase 3: TLS mode (HTTPS + WSS)
|
||||
|
||||
- [ ] Add `--tls-cert <path>` and `--tls-key <path>` CLI flags.
|
||||
- [ ] Add `--listen https:HOST:PORT` — HTTP listener wrapped in TLS.
|
||||
- [ ] Add `--listen wss:HOST:PORT` — WebSocket listener wrapped in TLS.
|
||||
- [ ] TLS handshake via OpenSSL (already a dependency):
|
||||
- Load cert/key from files
|
||||
- `SSL_CTX_new(TLS_server_method())`
|
||||
- `SSL_CTX_use_certificate_file()` / `SSL_CTX_use_PrivateKey_file()`
|
||||
- Per-connection: `SSL_new()` → `SSL_set_fd()` → `SSL_accept()`
|
||||
- Replace read/write calls with `SSL_read()` / `SSL_write()`
|
||||
- [ ] Self-signed cert generation helper (or document `openssl req` command).
|
||||
- [ ] Certificate pinning guidance for clients (verify fingerprint, not just CA).
|
||||
- [ ] curl usage: `curl --cacert <cert.pem>` or `curl -k` (insecure, for testing).
|
||||
- [ ] Not strictly needed for localhost/FIPS mesh use, but good for defense in depth.
|
||||
|
||||
## Difficulty assessment
|
||||
|
||||
| Mode | Difficulty | New code | Dependencies | Risk |
|
||||
|---|---|---|---|---|
|
||||
| HTTP | Low | ~150 lines | None (minimal parser) | Low — simple POST/JSON |
|
||||
| WS | Medium | ~300 lines | None (minimal frame parser) | Medium — frame parsing edge cases |
|
||||
| HTTPS | Medium | ~150 + ~80 TLS | OpenSSL (already linked) | Medium — cert management |
|
||||
| WSS | Medium-High | ~300 + ~80 TLS | OpenSSL (already linked) | Higher — cert + WS edge cases |
|
||||
|
||||
## Recommendation
|
||||
|
||||
Start with HTTP mode (Phase 1) — it's the simplest and immediately enables
|
||||
curl access. Add WebSocket (Phase 2) if browser access is needed. Defer WSS
|
||||
(Phase 3) until there's a real public-facing use case.
|
||||
@@ -44,7 +44,7 @@ Each selected transport gets configured with sensible defaults:
|
||||
|---|---|---|
|
||||
| 1. Unix socket | `--listen unix` | socket name `nsigner` (or random if collision) |
|
||||
| 2. Qrexec bridge | `--listen unix --bridge-source-trusted` | socket name `nsigner`, accepts qrexec preamble |
|
||||
| 3. TCP | `--listen tcp:[::]:8080` | bind all interfaces, port 8080 |
|
||||
| 3. TCP | `--listen tcp:[::]:11111` | bind all interfaces, port 11111 |
|
||||
| 4. Qrexec one-shot | `--listen qrexec` | one request via stdin, then exit |
|
||||
|
||||
**Choices 1 and 2 can coexist** — they're both unix listeners, just with different identity handling. Choice 2 implies choice 1's socket. If both are selected, the bridge-source-trusted flag is set on the single unix listener (it handles both local and bridge connections).
|
||||
@@ -66,7 +66,7 @@ Currently `main()` creates one `server_ctx_t` and polls `server.listen_fd` + `ST
|
||||
```text
|
||||
Connections
|
||||
listen: unix @nsigner (bridge-source-trusted)
|
||||
listen: tcp [::]:8080
|
||||
listen: tcp [::]:11111
|
||||
client: nsigner --socket-name nsigner client '<json>'
|
||||
```
|
||||
|
||||
@@ -116,7 +116,7 @@ After confirmation, the selected listeners are started and the status display re
|
||||
### 3.3 Transport setup from menu selection
|
||||
- [ ] Unix: `server_init` + `server_start` with `NSIGNER_LISTEN_UNIX`, socket name `nsigner`.
|
||||
- [ ] Qrexec bridge: same as unix + `server_set_bridge_source_trusted(&server, 1)`.
|
||||
- [ ] TCP: `server_init` + `server_start` with `NSIGNER_LISTEN_TCP`, target `tcp:[::]:8080`.
|
||||
- [ ] TCP: `server_init` + `server_start` with `NSIGNER_LISTEN_TCP`, target `tcp:[::]:11111`.
|
||||
- [ ] Qrexec one-shot: existing `NSIGNER_LISTEN_QREXEC` path (stdin, one request, exit).
|
||||
|
||||
### 3.4 TUI status display
|
||||
@@ -126,7 +126,7 @@ After confirmation, the selected listeners are started and the status display re
|
||||
### 3.5 Testing
|
||||
- [ ] Interactive: start nsigner with no `--listen`, select unix+TCP, verify both listeners work.
|
||||
- [ ] Interactive: select qrexec bridge, verify bridge connections get `qubes:<vm>` identity.
|
||||
- [ ] CLI: `--listen tcp:[::]:8080` still works (skips menu).
|
||||
- [ ] CLI: `--listen tcp:[::]:11111` still works (skips menu).
|
||||
- [ ] CLI: `--listen unix --bridge-source-trusted` still works (skips menu).
|
||||
- [ ] Non-TTY: `--mnemonic-stdin` without `--listen` defaults to unix (no menu).
|
||||
|
||||
@@ -138,6 +138,6 @@ After confirmation, the selected listeners are started and the status display re
|
||||
|
||||
2. **Should there be a hotkey to add/remove transports at runtime?** E.g. press `t` in the TUI to bring up the transport menu again. This is a nice-to-have but adds complexity. **Decision: defer — the menu is startup-only for now.**
|
||||
|
||||
3. **TCP port selection in the menu?** The menu could ask for a port number if TCP is selected. **Decision: default to 8080, let the user override with `--listen tcp:...` if they need a different port. Keep the menu simple.**
|
||||
3. **TCP port selection in the menu?** The menu could ask for a port number if TCP is selected. **Decision: default to 11111, let the user override with `--listen tcp:...` if they need a different port. Keep the menu simple.**
|
||||
|
||||
4. **Socket name selection?** Same — default to `nsigner`, override with `--socket-name` if needed. **Decision: default, no prompt.**
|
||||
|
||||
340
plans/otp_nostr_integration.md
Normal file
340
plans/otp_nostr_integration.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Plan: OTP-encrypted Nostr events via n_signer
|
||||
|
||||
## Goal
|
||||
|
||||
Store encrypted blobs on Nostr (kind `30078` replaceable parameterized events) that are
|
||||
**information-theoretically secure** — unbreakable by any computer, quantum or classical,
|
||||
forever — because they are encrypted with a one-time pad (OTP) sourced from the
|
||||
[`otp`](../otp) project.
|
||||
|
||||
A client program sends plaintext (or a ciphertext) to `n_signer` over its existing
|
||||
JSON-RPC transport. `n_signer` performs the OTP XOR against pad material it reads from a
|
||||
USB drive, advances the per-pad offset, and returns the ciphertext (or plaintext). The
|
||||
caller then wraps the result in a Nostr `30078` event and signs/publishes it via the
|
||||
existing `sign_event` verb.
|
||||
|
||||
Both output encodings are supported, matching the standalone `otp` tool:
|
||||
|
||||
- **ASCII armored** (`-----BEGIN OTP MESSAGE-----` + base64): text-safe, for embedding
|
||||
directly in Nostr event `content` (kind `30078`).
|
||||
- **Binary** (`.otp` structured header + raw encrypted bytes): for uploading to Blossom
|
||||
servers as a blob and referencing from a Nostr event by SHA-256 hash. The caller
|
||||
receives the binary blob base64-encoded in the JSON-RPC response and decodes it
|
||||
before uploading.
|
||||
|
||||
For testing, pad material can be generated from local entropy (`/dev/urandom` or
|
||||
keyboard entropy) using the `otp` tool. The eventual production target is a USB drive
|
||||
holding the pad, accessed by `n_signer` at runtime. A future microcontroller hardware
|
||||
signer that carries the pad onboard is explicitly out of scope for this plan and is
|
||||
tracked separately.
|
||||
|
||||
## Design summary
|
||||
|
||||
- `n_signer` gains two new verbs: `otp_encrypt` and `otp_decrypt`.
|
||||
- Pad material lives on a USB drive (file path supplied at startup). `n_signer` reads
|
||||
only the slice it needs, XORs in `mlock`'d RAM, and writes the new offset back to the
|
||||
pad's `.state` file on the USB drive.
|
||||
- The pad is **not** loaded whole into RAM; it is seeked-and-read per request. This
|
||||
preserves the spirit of the zero-filesystem-footprint model (no pad material is ever
|
||||
copied onto the host disk; the only on-disk artifact is the offset counter on the USB
|
||||
drive itself, which is required for multi-device coordination).
|
||||
- The encrypted payload is returned in either ASCII-armored or binary `.otp` format,
|
||||
selected per request via an `encoding` option. ASCII armor is base64-safe for JSON
|
||||
and Nostr event `content`; binary `.otp` is for Blossom blob uploads.
|
||||
- The caller is responsible for building and publishing the `30078` event; `n_signer`
|
||||
only does the OTP transform and (separately) signs the event when asked.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client[Client program] -->|otp_encrypt JSON-RPC| NS[n_signer]
|
||||
NS -->|seek + read slice| USB[USB pad file .pad]
|
||||
NS -->|read/advance offset| State[USB .state file]
|
||||
NS -->|XOR in mlock RAM| CT[Ciphertext blob]
|
||||
NS -->|return ascii-armored| Client
|
||||
Client -->|wrap in 30078 event| Event[Nostr event JSON]
|
||||
Client -->|sign_event| NS
|
||||
NS -->|schnorr sig| Client
|
||||
Client -->|publish| Relay[Nostr relay]
|
||||
```
|
||||
|
||||
## Architecture decisions
|
||||
|
||||
### 1. New verbs, not a new transport
|
||||
|
||||
OTP operations are just new verbs on the existing dispatcher
|
||||
([`src/dispatcher.c`](src/dispatcher.c:1)). They use the same framed JSON-RPC,
|
||||
policy, approval, and enforcement machinery as `sign_event` / `nip44_encrypt`. No new
|
||||
transport is needed.
|
||||
|
||||
### 2. Pad storage on USB, not in mnemonic RAM
|
||||
|
||||
The OTP pad is far too large to live in `mlock`'d RAM (gigabytes) and is not
|
||||
mnemonic-derived. It lives on a USB drive mounted at a path `n_signer` is told at
|
||||
startup via a new `--otp-pad-dir <path>` flag. `n_signer` opens the pad file
|
||||
read-only, seeks to the current offset, reads exactly `chunk_size` bytes (after
|
||||
Padmé padding), XORs against the (padded) plaintext in a small `mlock`'d scratch
|
||||
buffer, and writes the advanced offset back to `<chksum>.state` on the USB drive.
|
||||
|
||||
This is a deliberate, narrow exception to the "zero filesystem footprint" rule: the
|
||||
only filesystem artifact `n_signer` touches is the offset counter on the USB drive
|
||||
itself, which is mandatory for pad-reuse avoidance across devices. No pad bytes and
|
||||
no plaintext ever touch the host disk.
|
||||
|
||||
### 2a. Qubes OS USB access strategy
|
||||
|
||||
On Qubes, the signer qube must have sole access to the pad-bearing USB drive. The
|
||||
chosen strategy is **PCI USB controller passthrough** (Option A): an entire USB
|
||||
controller is assigned to the signer qube via `qvm-pci attach`, so dom0, `sys-usb`,
|
||||
and every other qube are blind to the pad device. The drive appears as a normal
|
||||
`/dev/sd*` inside the signer qube.
|
||||
|
||||
Fallback if no spare controller is available: **`qvm-block attach` from `sys-usb`**
|
||||
(Option B), accepting that `sys-usb` briefly enumerates the device and block I/O
|
||||
transits dom0's blkback (a traffic-analysis concern, not a plaintext-leak concern
|
||||
since pad bytes stay encrypted-on-disk).
|
||||
|
||||
Code-level guard (Option D): `n_signer` refuses to open `--otp-pad-dir` unless the
|
||||
underlying device is a directly-owned PCI device (`/dev/sd*` from a passthrough
|
||||
controller) when running under Qubes; blkback devices (`/dev/xvdi`) are rejected
|
||||
unless `--otp-allow-blkback` is explicitly passed. This makes "sole access" a
|
||||
code-level invariant, not just an operator convention.
|
||||
|
||||
Offset writes use atomic write-temp-then-rename so a crash mid-write cannot corrupt
|
||||
the `.state` file. The offset is advanced **only after** the XOR succeeds and the
|
||||
ciphertext is handed back to the caller.
|
||||
|
||||
### 3. Reuse the `otp` project's file formats and padding
|
||||
|
||||
- Pad file format: `<chksum>.pad` raw random bytes, with a 32-byte header reserved
|
||||
(matches [`../otp/src/pads.c`](../otp/src/pads.c:1) `offset=32` initial reservation).
|
||||
- State file format: `<chksum>.state` containing `offset=<n>\n` (matches
|
||||
[`../otp/src/pads.c:284`](../otp/src/pads.c:284) `read_state_offset`).
|
||||
- ASCII armor format: `-----BEGIN OTP MESSAGE-----` with `Pad-ChkSum` and
|
||||
`Pad-Offset` headers (matches [`../otp/src/crypto.c`](../otp/src/crypto.c:1)
|
||||
`parse_ascii_message` / `generate_ascii_armor`).
|
||||
- Padding: exponential bucketing + ISO/IEC 9797-1 Method 2 (Padmé) from
|
||||
[`../otp/src/padding.c`](../otp/src/padding.c:1).
|
||||
|
||||
This means pads generated by the standalone `otp` CLI are bit-compatible with pads
|
||||
consumed by `n_signer`, and ciphertexts produced by either tool are interchangeable.
|
||||
|
||||
### 4. Code sharing strategy
|
||||
|
||||
Rather than vendoring a copy of the `otp` source into `n_signer`, extract the
|
||||
format-critical functions into a small shared static library `libotppad` that both
|
||||
projects link against. Candidates to extract:
|
||||
|
||||
- `universal_xor_operation` ([`../otp/src/crypto.c:35`](../otp/src/crypto.c:35))
|
||||
- `parse_ascii_message` / `generate_ascii_armor`
|
||||
- `calculate_chunk_size` / `apply_padme_padding` / `remove_padme_padding`
|
||||
([`../otp/src/padding.c`](../otp/src/padding.c:1))
|
||||
- `read_state_offset` / `write_state_offset`
|
||||
([`../otp/src/pads.c:284`](../otp/src/pads.c:284))
|
||||
- `calculate_checksum` (pad identification)
|
||||
|
||||
`n_signer` then only needs to implement: USB pad directory config, the two new
|
||||
dispatcher verbs, the seek-read-XOR-write-offset loop, and approval/policy wiring.
|
||||
|
||||
### 5. Nostr event shape (kind 30078)
|
||||
|
||||
The caller builds the event; `n_signer` does not. Two payload patterns:
|
||||
|
||||
**A. ASCII armor in event content** (text-safe, self-contained):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"content": "-----BEGIN OTP MESSAGE-----\nVersion: v0.3.53\nPad-ChkSum: <64hex>\nPad-Offset: <n>\n\n<base64>\n-----END OTP MESSAGE-----",
|
||||
"tags": [
|
||||
["d", "<caller-chosen-d-tag>"],
|
||||
["otp-pad", "<16-char chksum prefix>"],
|
||||
["otp-version", "v0.3.53"],
|
||||
["otp-encoding", "ascii"]
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**B. Binary `.otp` uploaded to Blossom, referenced by hash** (for binaries/large blobs):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "<caller-chosen-d-tag>"],
|
||||
["otp-pad", "<16-char chksum prefix>"],
|
||||
["otp-version", "v0.3.53"],
|
||||
["otp-encoding", "binary"],
|
||||
["blob", "<sha256-hex>", "<mimetype>", "<size-bytes>"],
|
||||
["url", "<blossom-url>"]
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
In both cases the `Pad-Offset` (in the ASCII armor header, or in the binary `.otp`
|
||||
file header) is what a decrypting device uses to seek into its copy of the same pad.
|
||||
The `otp-pad` tag lets a reader find the right pad without parsing the payload first.
|
||||
The `otp-encoding` tag tells the reader whether to look in `content` or follow the
|
||||
`blob`/`url` tags to Blossom.
|
||||
|
||||
### 6. Multi-device offset coordination (deferred)
|
||||
|
||||
Out of scope for v1. The `.state` file on the device's USB drive is the local source
|
||||
of truth for how far that device has consumed the pad, and the `Pad-Offset` header
|
||||
in each ciphertext's ASCII armor / binary header records which slice was used. That
|
||||
is sufficient for single-device operation.
|
||||
|
||||
If multi-device pad sharing is added later (two devices holding copies of the same
|
||||
`.pad`), a dedicated signed coordination event would be needed so devices never
|
||||
reuse a slice. That event does not have to be kind `30078` and is not designed here.
|
||||
Note: kind `30078` is a Nostr application-data convention, not part of the OTP spec —
|
||||
the `otp` project has no Nostr code today.
|
||||
|
||||
## Phased implementation
|
||||
|
||||
### Phase 0 — Test pad on the USB drive ✅ Done
|
||||
|
||||
- [x] Created `pads/` directory on the mounted USB drive.
|
||||
- [x] Generated a 1 MB test pad from `/dev/urandom` with a 32-byte reserved header,
|
||||
using [`tools/make_test_pad.c`](tools/make_test_pad.c:1).
|
||||
- [x] Computed the pad's 256-bit XOR checksum (matching the `otp` project's
|
||||
[`../otp/src/crypto.c:242`](../otp/src/crypto.c:242) `calculate_checksum`
|
||||
algorithm) and named the pad file by that checksum.
|
||||
- [x] Wrote the initial `.state` file (`offset=32\n`).
|
||||
- [x] Verified the checksum matches the filename via an independent re-computation.
|
||||
|
||||
**Actual test pad on this qube:**
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| USB drive | SanDisk 3.2 Gen1, 466 GB, `/dev/sda1` |
|
||||
| Mount point | `/media/user/Music` (FAT32, label "Music") |
|
||||
| Pad directory | `/media/user/Music/pads` |
|
||||
| Pad file | `333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.pad` |
|
||||
| State file | `333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.state` |
|
||||
| Size | 1,048,576 bytes (1 MB) |
|
||||
| Chksum prefix | `333e9902db839d9d` |
|
||||
| Initial offset | 32 (header reserved) |
|
||||
|
||||
Note: the drive is currently mounted at `/media/user/Music` (its FAT32 label is
|
||||
"Music"), not at `/media/user/USBDISK`. For `n_signer` testing, pass
|
||||
`--otp-pad-dir /media/user/Music/pads`. This pad is for local-entropy testing only;
|
||||
production pads will be generated with the `otp` CLI (keyboard/TRNG entropy) or a
|
||||
future hardware signer.
|
||||
|
||||
### Phase 1 — Shared `libotppad` extraction
|
||||
|
||||
- [ ] Create `libotppad/` directory with the format-critical functions extracted from
|
||||
[`../otp/src/crypto.c`](../otp/src/crypto.c:1), [`../otp/src/padding.c`](../otp/src/padding.c:1),
|
||||
and [`../otp/src/pads.c`](../otp/src/pads.c:1).
|
||||
- [ ] Add a `libotppad.h` public header declaring: `universal_xor_operation`,
|
||||
`parse_ascii_message`, `generate_ascii_armor`, `calculate_chunk_size`,
|
||||
`apply_padme_padding`, `remove_padme_padding`, `read_state_offset`,
|
||||
`write_state_offset`, `calculate_checksum`.
|
||||
- [ ] Refactor the `otp` project to link against `libotppad` instead of its own copies.
|
||||
- [ ] Add unit tests for `libotppad` (round-trip encrypt/decrypt, padding edge cases,
|
||||
state file read/write).
|
||||
|
||||
### Phase 2 — `n_signer` USB pad directory support
|
||||
|
||||
- [ ] Add `--otp-pad-dir <path>` CLI flag to [`src/main.c`](src/main.c:1); store the
|
||||
path in a new `otp_pad_state_t` alongside the existing mnemonic/role state.
|
||||
- [ ] Add a `--otp-pad <chksum-or-prefix>` flag (or interactive selector) to pick
|
||||
**the single pad** that is active for this session. One pad per session — the
|
||||
pad is bound at startup and cannot be switched without restarting `n_signer`.
|
||||
- [ ] Implement `otp_pad_open(chksum)` / `otp_pad_read_slice(offset, len)` /
|
||||
`otp_pad_advance_offset(delta)` helpers in a new `src/otp_pad.c`.
|
||||
- [ ] Validate the pad's checksum matches the requested chksum before first use.
|
||||
- [ ] Refuse to operate if the pad directory is on the same filesystem as `/` (require
|
||||
it to be a removable mount — best-effort check via `statvfs`).
|
||||
|
||||
### Phase 3 — `otp_encrypt` / `otp_decrypt` verbs
|
||||
|
||||
- [ ] Add `VERB_OTP_ENCRYPT "otp_encrypt"` and `VERB_OTP_DECRYPT "otp_decrypt"` to
|
||||
[`src/dispatcher.c`](src/dispatcher.c:1).
|
||||
- [ ] Request shapes (plaintext is base64 in the JSON param for both text and
|
||||
binary; the pad is the one bound at startup, so no `pad` field is needed):
|
||||
```json
|
||||
{ "id": "1", "method": "otp_encrypt",
|
||||
"params": [ "<plaintext-base64>", { "encoding": "ascii|binary" } ] }
|
||||
```
|
||||
```json
|
||||
{ "id": "2", "method": "otp_decrypt",
|
||||
"params": [ "<ciphertext-ascii-armor-or-base64-otp-blob>", { "encoding": "ascii|binary" } ] }
|
||||
```
|
||||
- [ ] `otp_encrypt` flow: padmé-pad plaintext → seek to offset → read slice → XOR in
|
||||
`mlock`'d scratch → advance offset → return ciphertext in requested encoding
|
||||
(`ascii` → ASCII-armored string, `binary` → base64-encoded `.otp` blob in the
|
||||
JSON result).
|
||||
- [ ] `otp_decrypt` flow: accept either ASCII armor or base64-encoded binary `.otp`
|
||||
blob → parse header → seek to `Pad-Offset` → read slice → XOR in `mlock`'d
|
||||
scratch → strip padding → return plaintext (in requested encoding).
|
||||
- [ ] Add an `encoding` option to both verbs: `"encoding": "ascii"` (default) or
|
||||
`"encoding": "binary"`. For `otp_encrypt`, controls output format. For
|
||||
`otp_decrypt`, tells the signer what format the input is in (auto-detection by
|
||||
magic bytes `OTP\0` is a fallback).
|
||||
- [ ] Wire both verbs into the policy/enforcement table
|
||||
([`src/policy.c`](src/policy.c:1), [`src/enforcement.c`](src/enforcement.c:1))
|
||||
with **per-session grant** approval: the first `otp_encrypt` / `otp_decrypt`
|
||||
call for the session prompts the user; once granted, subsequent calls on the
|
||||
same pad in the same session do not re-prompt. This matches the `[a] always
|
||||
allow this session` hotkey behavior already in [`README.md`](README.md:1).
|
||||
- [ ] Add approval-prompt display fields: pad chksum prefix, offset before/after,
|
||||
plaintext length bucket.
|
||||
|
||||
### Phase 4 — Local-entropy test path
|
||||
|
||||
- [ ] Document the test workflow: generate a small pad with the `otp` CLI
|
||||
(`./otp generate 1MB`) into a directory, point `n_signer` at it with
|
||||
`--otp-pad-dir`, run `otp_encrypt` round-trips from a client.
|
||||
- [ ] Add an integration test in [`tests/test_integration.c`](tests/test_integration.c:1)
|
||||
that: starts `n_signer` with a temp pad dir, calls `otp_encrypt` then
|
||||
`otp_decrypt`, asserts round-trip equality, and asserts the offset advanced by
|
||||
the padded chunk size.
|
||||
- [ ] Add a test that confirms a ciphertext produced by `n_signer` can be decrypted by
|
||||
the standalone `otp` CLI (cross-compatibility).
|
||||
|
||||
### Phase 5 — Nostr 30078 client example
|
||||
|
||||
- [ ] Add `examples/otp_nostr_30078.c` showing: call `otp_encrypt` → build a kind 30078
|
||||
event with the ASCII armor as `content` → call `sign_event` → print the signed
|
||||
event for publishing.
|
||||
- [ ] Add a matching `examples/otp_nostr_30078_decrypt.c` showing: fetch a 30078 event
|
||||
→ call `otp_decrypt` with its `content` → print recovered plaintext.
|
||||
- [ ] Document the workflow in [`documents/`](documents/) and link from
|
||||
[`README.md`](README.md:1).
|
||||
|
||||
### Phase 6 (deferred) — USB-bound pad hardening
|
||||
|
||||
- [ ] Detect removable-mount requirement more strictly (udev properties).
|
||||
- [ ] Optional: read-only mount enforcement, pad integrity re-check on each request.
|
||||
- [ ] Optional: per-pad `mlock`'d offset cache so a crash mid-request does not corrupt
|
||||
the `.state` file (write-offset-after-success-only is already the plan).
|
||||
|
||||
### Phase 7 (explicitly deferred) — Microcontroller hardware signer with onboard pad
|
||||
|
||||
Tracked separately. When it lands, the `--otp-pad-dir` path is replaced by a transport
|
||||
call (serial/WebUSB) to a pad-serving firmware, and the verbs stay identical.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Plaintext encoding in `otp_encrypt` params.** Accept base64 in the JSON param
|
||||
for both text and binary plaintext; the signer decodes it. Output encoding is the
|
||||
`ascii` vs `binary` option described above.
|
||||
2. **One pad per session.** The pad is bound at `n_signer` startup via
|
||||
`--otp-pad-dir` + `--otp-pad` and cannot be switched without restarting the
|
||||
signer. Simpler and safer for v1.
|
||||
3. **No pad-heartbeat event in v1.** The `.state` file on the USB drive is the local
|
||||
source of truth; the `Pad-Offset` header in each ciphertext records the slice
|
||||
used. Multi-device pad sharing and any coordination event are deferred. (Kind
|
||||
`30078` is a Nostr application-data convention, not part of the OTP spec.)
|
||||
4. **Per-session grant approval.** The first `otp_encrypt` / `otp_decrypt` call in a
|
||||
session prompts the user; once granted, subsequent calls on the same pad do not
|
||||
re-prompt. Matches the existing `[a] always allow this session` behavior.
|
||||
5. **Qubes USB strategy.** PCI USB controller passthrough (Option A) is the target;
|
||||
`qvm-block` from `sys-usb` (Option B) is the fallback. For development/testing,
|
||||
the USB drive is already accessible in this qube at `/media/user/Music` (see
|
||||
Phase 0). Code-level guard (Option D) rejects blkback devices unless
|
||||
`--otp-allow-blkback` is passed.
|
||||
263
plans/ssh_agent_proxy.md
Normal file
263
plans/ssh_agent_proxy.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Plan: SSH Agent Proxy Bridge for n_signer
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Allow untrusted Qubes qubes to SSH into remote servers using an ed25519 private key held safely in n_signer, without the private key ever leaving the signer qube.
|
||||
|
||||
The untrusted qube runs a small proxy program that implements the OpenSSH `ssh-agent` protocol. OpenSSH talks to the proxy as if it were a normal `ssh-agent`. The proxy forwards signing requests to n_signer via qrexec. The private key never touches the untrusted qube's memory.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
SSH[ssh client in untrusted qube] -->|SSH_AUTH_SOCK| PROXY[nsigner-ssh-agent proxy]
|
||||
PROXY -->|qrexec qubes.NsignerRpc| SIGNER[n_signer in nostr_signer qube]
|
||||
SIGNER -->|derives ed25519 key from mnemonic| KEY[ed25519 private key in mlock'd RAM]
|
||||
SSH -->|SSH protocol| SERVER[remote SSH server]
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. User in untrusted qube runs `ssh user@server`
|
||||
2. OpenSSH connects to the proxy via `SSH_AUTH_SOCK`
|
||||
3. OpenSSH sends `SSH_AGENTC_REQUEST_IDENTITIES` — proxy fetches the ed25519 public key from n_signer and returns it
|
||||
4. OpenSSH sends the public key to the remote server as part of `SSH_MSG_USERAUTH_REQUEST`
|
||||
5. The server sends back a challenge (the data to sign)
|
||||
6. OpenSSH sends `SSH_AGENTC_SIGN_REQUEST` with the challenge data to the proxy
|
||||
7. The proxy forwards the data to n_signer via qrexec: `{"method":"sign","params":["<hex>","algorithm":"ed25519","index":0]}`
|
||||
8. n_signer signs the data with the ed25519 private key and returns the signature
|
||||
9. The proxy returns the signature to OpenSSH
|
||||
10. OpenSSH sends the signed authentication response to the server
|
||||
|
||||
**Key security property:** The private key exists only in n_signer's mlock'd memory in the `nostr_signer` qube. The untrusted qube never sees it. The proxy only ever handles public keys and signing requests/responses.
|
||||
|
||||
## 3. The ssh-agent protocol
|
||||
|
||||
OpenSSH's agent protocol is defined in `draft-miller-ssh-agent` (PROTOCOL.agent in the OpenSSH source). It uses a Unix socket with a simple message framing:
|
||||
|
||||
**Message format:**
|
||||
```
|
||||
uint32 message_length
|
||||
byte message_type
|
||||
byte[] message_data
|
||||
```
|
||||
|
||||
**Messages the proxy must handle:**
|
||||
|
||||
### SSH_AGENTC_REQUEST_IDENTITIES (11)
|
||||
Request the list of keys the agent holds.
|
||||
|
||||
**Response: SSH_AGENT_IDENTITIES_ANSWER (12)**
|
||||
```
|
||||
uint32 num_keys
|
||||
string key_blob_1 (public key in SSH wire format)
|
||||
string key_comment_1 (human-readable comment)
|
||||
string key_blob_2
|
||||
string key_comment_2
|
||||
...
|
||||
```
|
||||
|
||||
The proxy returns one key: the ed25519 public key from n_signer, formatted as an SSH ed25519 public key blob.
|
||||
|
||||
**SSH ed25519 public key blob format:**
|
||||
```
|
||||
string "ssh-ed25519"
|
||||
string <32-byte public key>
|
||||
```
|
||||
|
||||
### SSH_AGENTC_SIGN_REQUEST (13)
|
||||
```
|
||||
string key_blob (the public key to sign with)
|
||||
string data (the data to sign)
|
||||
uint32 flags (SSH_AGENT_SIGN_FLAG_* — currently 0 or SSH_AGENT_FLAG_RSA_SHA2_256/512 for RSA only)
|
||||
```
|
||||
|
||||
**Response: SSH_AGENT_SIGN_RESPONSE (14)**
|
||||
```
|
||||
string signature_blob
|
||||
```
|
||||
|
||||
**SSH ed25519 signature blob format:**
|
||||
```
|
||||
string "ssh-ed25519"
|
||||
string <64-byte signature>
|
||||
```
|
||||
|
||||
### Other messages
|
||||
- `SSH_AGENTC_REMOVE_ALL_IDENTITIES` (11) — return `SSH_AGENT_SUCCESS`
|
||||
- `SSH_AGENTC_REMOVE_IDENTITY` (18) — return `SSH_AGENT_SUCCESS`
|
||||
- `SSH_AGENTC_LOCK` / `SSH_AGENTC_UNLOCK` (22/23) — return `SSH_AGENT_SUCCESS`
|
||||
- `SSH_AGENTC_ADD_IDENTITY` (17) — return `SSH_AGENT_FAILURE` (we don't allow adding keys)
|
||||
- Any unknown message — return `SSH_AGENT_FAILURE` (5)
|
||||
|
||||
## 4. Implementation
|
||||
|
||||
### 4.1 New program: `nsigner-ssh-agent`
|
||||
|
||||
A small C program (~400-500 lines) that:
|
||||
|
||||
1. Creates a Unix socket at a configurable path (default: `$XDG_RUNTIME_DIR/nsigner-ssh-agent.sock`)
|
||||
2. Listens for connections from OpenSSH
|
||||
3. On `SSH_AGENTC_REQUEST_IDENTITIES`:
|
||||
- Calls n_signer via qrexec to get the ed25519 public key: `{"method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}`
|
||||
- Parses the structured response to extract the 32-byte public key
|
||||
- Formats it as an SSH ed25519 key blob
|
||||
- Returns `SSH_AGENT_IDENTITIES_ANSWER` with one key
|
||||
4. On `SSH_AGENTC_SIGN_REQUEST`:
|
||||
- Extracts the data to sign from the request
|
||||
- Calls n_signer via qrexec: `{"method":"sign","params":["<data_hex>",{"algorithm":"ed25519","index":0}]}`
|
||||
- Parses the response to extract the 64-byte signature
|
||||
- Formats it as an SSH ed25519 signature blob
|
||||
- Returns `SSH_AGENT_SIGN_RESPONSE`
|
||||
5. On other messages: returns appropriate responses (see §3)
|
||||
|
||||
**Key caching:** The proxy caches the public key after the first `REQUEST_IDENTITIES` call (it doesn't change during a session). The private key is never cached — each sign request goes to n_signer.
|
||||
|
||||
**Multiple keys:** The proxy can support multiple ed25519 keys by using different `index` values. Configuration via command-line args or environment variables:
|
||||
```bash
|
||||
nsigner-ssh-agent --index 0 --index 1 --index 2
|
||||
```
|
||||
Each index produces a different ed25519 key, all derived from the same mnemonic.
|
||||
|
||||
### 4.2 qrexec communication
|
||||
|
||||
The proxy communicates with n_signer via `qrexec-client-vm nostr_signer qubes.NsignerRpc`, using the same 4-byte big-endian length framing as the existing qrexec examples.
|
||||
|
||||
Each qrexec call is a one-shot: spawn `qrexec-client-vm`, send one framed request, receive one framed response, exit. This matches the existing qrexec protocol.
|
||||
|
||||
### 4.3 Configuration
|
||||
|
||||
**In the untrusted qube:**
|
||||
```bash
|
||||
# Start the proxy in the background
|
||||
nsigner-ssh-agent --socket $XDG_RUNTIME_DIR/nsigner-ssh-agent.sock &
|
||||
export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/nsigner-ssh-agent.sock
|
||||
export SSH_AUTH_SIGNER_QUBE=nostr_signer
|
||||
|
||||
# Now use ssh normally
|
||||
ssh user@server
|
||||
```
|
||||
|
||||
**In the nostr_signer qube:**
|
||||
- n_signer must be running with the mnemonic loaded
|
||||
- The `qubes.NsignerRpc` service must be installed
|
||||
- The qrexec policy must allow the untrusted qube to call `qubes.NsignerRpc`
|
||||
|
||||
**Qubes policy (`packaging/qubes/policy.d/40-nsigner.policy`):**
|
||||
```
|
||||
nsigner.NsignerRpc +untrusted-qube nostr_signer allow
|
||||
```
|
||||
|
||||
Or with the deny-by-default model from n_signer's policy:
|
||||
```
|
||||
nsigner.NsignerRpc +untrusted-qube nostr_signer ask
|
||||
```
|
||||
The `ask` policy shows a Qubes dom0 prompt each time the untrusted qube tries to sign, giving the user a chance to approve/deny.
|
||||
|
||||
### 4.4 n_signer policy
|
||||
|
||||
Use n_signer's `--preapprove` to pre-approve the untrusted qube for ed25519 signing:
|
||||
```bash
|
||||
nsigner --preapprove caller=qubes:untrusted-qube,algorithm=ed25519,index=0,verb=sign,get_public_key
|
||||
```
|
||||
|
||||
Or use the deny-by-default prompt model — each sign request shows a prompt in the signer qube's TUI:
|
||||
```
|
||||
Caller: qubes:untrusted-qube
|
||||
Action: sign with ed25519
|
||||
Key: index 0 (key_id: 1fd9c73a93189484)
|
||||
[a] approve [d] deny
|
||||
```
|
||||
|
||||
### 4.5 File structure
|
||||
|
||||
```
|
||||
src/nsigner_ssh_agent.c — the proxy program
|
||||
packaging/qubes/
|
||||
install-ssh-agent.sh — install the proxy in an AppVM
|
||||
ssh-agent.desktop — autostart the proxy on qube boot
|
||||
```
|
||||
|
||||
### 4.6 Build
|
||||
|
||||
The proxy is a separate binary, not part of the main nsigner binary. It links against:
|
||||
- cJSON (for JSON-RPC parsing)
|
||||
- libnostr_core (for the qrexec transport framing)
|
||||
|
||||
Makefile target:
|
||||
```makefile
|
||||
$(BUILD_DIR)/nsigner-ssh-agent: src/nsigner_ssh_agent.c
|
||||
$(CC) $(CFLAGS) src/nsigner_ssh_agent.c -o $(BUILD_DIR)/nsigner-ssh-agent $(LDFLAGS)
|
||||
```
|
||||
|
||||
## 5. Security considerations
|
||||
|
||||
### 5.1 The untrusted qube sees the public key
|
||||
|
||||
The ed25519 public key is sent to the untrusted qube. This is fine — public keys are not secret. The untrusted qube could share the public key with anyone, but that doesn't compromise the private key.
|
||||
|
||||
### 5.2 The untrusted qube controls what is signed
|
||||
|
||||
The untrusted qube constructs the SSH authentication message and sends it to the proxy for signing. The proxy forwards it to n_signer, which signs it without inspecting the content.
|
||||
|
||||
**Risk:** A compromised untrusted qube could ask n_signer to sign arbitrary data, not just SSH authentication messages. The ed25519 signature could be used for purposes other than SSH.
|
||||
|
||||
**Mitigation:** This is the same trust model as any ssh-agent. A compromised process with access to `SSH_AUTH_SOCK` can sign arbitrary data. The n_signer policy model (deny-by-default with per-request approval) provides an additional layer: the user can see each sign request at the signer's TUI and approve/deny it.
|
||||
|
||||
### 5.3 The proxy holds no secrets
|
||||
|
||||
The proxy process in the untrusted qube holds no private key material. It only has:
|
||||
- The Unix socket path
|
||||
- The qrexec target qube name
|
||||
- The cached public key (non-secret)
|
||||
- The ed25519 index to use
|
||||
|
||||
If the proxy process is compromised, the attacker gains the ability to forward sign requests to n_signer — but n_signer's policy still gates each request.
|
||||
|
||||
### 5.4 Qubes qrexec authentication
|
||||
|
||||
The qrexec framework authenticates the source qube. n_signer sees the caller as `qubes:<source-vm>`. This means:
|
||||
- The signer knows which qube is requesting the signature
|
||||
- The Qubes dom0 policy controls which qubes can call the service
|
||||
- The n_signer policy can approve specific qubes for specific algorithms/verbs
|
||||
|
||||
### 5.5 No private key on disk
|
||||
|
||||
The ed25519 private key is never written to disk. It's derived from the mnemonic in n_signer's mlock'd RAM on each startup. The proxy doesn't have the mnemonic. The untrusted qube doesn't have the mnemonic. Only the `nostr_signer` qube has the mnemonic (entered at startup, never persisted).
|
||||
|
||||
## 6. What is explicitly out of scope
|
||||
|
||||
1. **RSA key support** — the proxy only supports ed25519. RSA SSH keys require different signing (PKCS#1 v1.5 or PSS) and different key derivation. ed25519 is the modern default.
|
||||
2. **SSH certificate support** — OpenSSH certificates (ssh-ed25519-cert-v01@openssh.com) are not supported. These require a CA key to sign the certificate, which is a separate workflow.
|
||||
3. **Agent forwarding** — `ssh -A` (forwarding the agent to a remote host) is not supported. The proxy only accepts local Unix socket connections.
|
||||
4. **Multiple signer qubes** — the proxy talks to one n_signer instance. Multiple signers would require multiple proxy instances.
|
||||
5. **Hybrid PQ SSH keys** — OpenSSH doesn't support PQ signing keys yet. When it does, the proxy can be extended to use ML-DSA-65 via n_signer's `sign` verb with `algorithm=ml-dsa-65`.
|
||||
|
||||
## 7. Implementation phases
|
||||
|
||||
### Phase 1: Core proxy
|
||||
- Implement `nsigner-ssh-agent.c` with ssh-agent protocol handling
|
||||
- Implement qrexec communication with n_signer
|
||||
- Support `SSH_AGENTC_REQUEST_IDENTITIES` and `SSH_AGENTC_SIGN_REQUEST`
|
||||
- Test with a real SSH connection
|
||||
|
||||
### Phase 2: Qubes integration
|
||||
- Install script for AppVMs
|
||||
- Autostart on qube boot
|
||||
- Qubes policy documentation
|
||||
- n_signer `--preapprove` documentation
|
||||
|
||||
### Phase 3: Hardening
|
||||
- Connection rate limiting (prevent sign-request flooding)
|
||||
- Optional: inspect the sign request to verify it looks like an SSH auth message
|
||||
- Optional: support multiple ed25519 indices (multiple SSH identities)
|
||||
- Optional: support `SSH_AGENTC_REQUEST_EXTENSION` for OpenSSH extensions
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
1. **Should the proxy inspect the sign request data?** The proxy could check that the data being signed looks like an SSH authentication message (starts with the session ID, has the right message type). This would prevent the untrusted qube from using the signer for non-SSH signing. However, it's fragile (SSH protocol may change) and doesn't match how normal ssh-agents work. Default: no inspection, rely on n_signer's policy.
|
||||
|
||||
2. **Should the proxy support `ssh-add -l`?** `ssh-add -l` lists the keys the agent holds, which maps to `SSH_AGENTC_REQUEST_IDENTITIES`. Yes, this is already supported by the protocol handling.
|
||||
|
||||
3. **Should the proxy cache the public key across connections?** Yes — the public key doesn't change during a session. Cache it after the first `REQUEST_IDENTITIES` call. Clear the cache on `SIGHUP` or when the qrexec call fails.
|
||||
|
||||
4. **Should we support `sk-ssh-ed25519@openssh.com` (security key) key type?** This is the YubiKey key type. It adds a "flags" byte to the signature (e.g., "require user presence"). Not needed for n_signer, but could be useful for compatibility with systems that expect security key signatures. Default: no, use standard `ssh-ed25519`.
|
||||
181
src/dispatcher.c
181
src/dispatcher.c
@@ -3,6 +3,8 @@
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include <cJSON.h>
|
||||
#include "otp_pad.h"
|
||||
#include "libotppad.h"
|
||||
|
||||
/* from secure_mem.h */
|
||||
|
||||
@@ -232,6 +234,8 @@ const char *selector_strerror(int err);
|
||||
#define VERB_SSH_SIGN "ssh_sign"
|
||||
#define VERB_KEM_ENCAPS "kem_encapsulate"
|
||||
#define VERB_KEM_DECAPS "kem_decapsulate"
|
||||
#define VERB_OTP_ENCRYPT "otp_encrypt"
|
||||
#define VERB_OTP_DECRYPT "otp_decrypt"
|
||||
|
||||
/*
|
||||
* Check whether `verb` is allowed to execute against `role`.
|
||||
@@ -652,7 +656,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
@@ -1388,6 +1392,168 @@ static char *handle_algorithm_verb(dispatcher_ctx_t *ctx, const char *id_str,
|
||||
return make_error_response(id_str, -32601, "method_not_found");
|
||||
}
|
||||
|
||||
/* ---- OTP verb handler ----
|
||||
*
|
||||
* Handles otp_encrypt and otp_decrypt. These verbs operate on the bound OTP
|
||||
* pad (one pad per session) and do not use the role table or mnemonic.
|
||||
*
|
||||
* Request shapes (see plans/otp_nostr_integration.md):
|
||||
* otp_encrypt: params = [ "<plaintext-base64>", { "encoding": "ascii|binary" } ]
|
||||
* otp_decrypt: params = [ "<ciphertext>", { "encoding": "ascii|binary" } ]
|
||||
*
|
||||
* The ciphertext for otp_decrypt is either an ASCII-armored string (encoding
|
||||
* "ascii") or a base64-encoded binary .otp blob (encoding "binary"). If
|
||||
* encoding is omitted, auto-detection by magic bytes is used.
|
||||
*
|
||||
* Results are returned as a JSON object string:
|
||||
* otp_encrypt: {"ciphertext":"<ascii-armor-or-base64-blob>","encoding":"...",
|
||||
* "pad_chksum":"<64hex>","pad_offset_before":N,"pad_offset_after":M}
|
||||
* otp_decrypt: {"plaintext":"<base64>","encoding":"..."}
|
||||
*/
|
||||
static char *handle_otp_verb(const char *id_str, const char *method,
|
||||
cJSON *params_item, cJSON *options_item) {
|
||||
const char *encoding = NULL;
|
||||
cJSON *payload_item = cJSON_GetArrayItem(params_item, 0);
|
||||
|
||||
if (!cJSON_IsString(payload_item) || payload_item->valuestring == NULL) {
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
|
||||
/* Parse encoding option. */
|
||||
if (cJSON_IsObject(options_item)) {
|
||||
cJSON *enc = cJSON_GetObjectItemCaseSensitive(options_item, "encoding");
|
||||
if (cJSON_IsString(enc) && enc->valuestring != NULL) {
|
||||
encoding = enc->valuestring;
|
||||
}
|
||||
}
|
||||
|
||||
if (!otp_pad_is_bound()) {
|
||||
return make_error_response(id_str, -32601, "otp_pad_not_bound");
|
||||
}
|
||||
|
||||
if (strcmp(method, VERB_OTP_ENCRYPT) == 0) {
|
||||
/* Decode base64 plaintext. */
|
||||
int pt_len = 0;
|
||||
unsigned char *pt = otppad_base64_decode(payload_item->valuestring, &pt_len);
|
||||
if (pt == NULL) {
|
||||
return make_error_response(id_str, -32602, "invalid_plaintext_base64");
|
||||
}
|
||||
|
||||
uint64_t off_before = otp_pad_current_offset();
|
||||
char *out_payload = NULL;
|
||||
size_t out_len = 0;
|
||||
uint64_t off_after = 0;
|
||||
int rc = otp_pad_encrypt(pt, (size_t)pt_len, encoding,
|
||||
&out_payload, &out_len, &off_after);
|
||||
secure_memzero(pt, (size_t)pt_len);
|
||||
free(pt);
|
||||
if (rc != 0) {
|
||||
return make_error_response(id_str, -32602, "otp_encrypt_failed");
|
||||
}
|
||||
|
||||
/* Build result object. */
|
||||
cJSON *result_obj = cJSON_CreateObject();
|
||||
if (result_obj == NULL) {
|
||||
secure_memzero(out_payload, out_len);
|
||||
free(out_payload);
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
const char *enc_used = (encoding && strcmp(encoding, "binary") == 0)
|
||||
? "binary" : "ascii";
|
||||
|
||||
/* For binary encoding, out_payload is raw bytes (may contain NULs),
|
||||
* so base64-encode it for the JSON string. For ASCII, it's already a
|
||||
* NUL-terminated string. */
|
||||
char *ciphertext_json = NULL;
|
||||
if (strcmp(enc_used, "binary") == 0) {
|
||||
ciphertext_json = otppad_base64_encode((const unsigned char *)out_payload,
|
||||
(int)out_len);
|
||||
secure_memzero(out_payload, out_len);
|
||||
free(out_payload);
|
||||
if (ciphertext_json == NULL) {
|
||||
cJSON_Delete(result_obj);
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
} else {
|
||||
ciphertext_json = out_payload; /* NUL-terminated, take ownership */
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(result_obj, "ciphertext", ciphertext_json);
|
||||
cJSON_AddStringToObject(result_obj, "encoding", enc_used);
|
||||
cJSON_AddStringToObject(result_obj, "pad_chksum", otp_pad_chksum());
|
||||
cJSON_AddNumberToObject(result_obj, "pad_offset_before", (double)off_before);
|
||||
cJSON_AddNumberToObject(result_obj, "pad_offset_after", (double)off_after);
|
||||
|
||||
char *out = cJSON_PrintUnformatted(result_obj);
|
||||
cJSON_Delete(result_obj);
|
||||
if (strcmp(enc_used, "binary") == 0) {
|
||||
/* ciphertext_json was base64-encoded by us. */
|
||||
free(ciphertext_json);
|
||||
} else {
|
||||
secure_memzero(ciphertext_json, out_len);
|
||||
free(ciphertext_json);
|
||||
}
|
||||
if (out == NULL) {
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
return make_success_response(id_str, out);
|
||||
} else if (strcmp(method, VERB_OTP_DECRYPT) == 0) {
|
||||
unsigned char *pt = NULL;
|
||||
size_t pt_len = 0;
|
||||
int rc;
|
||||
|
||||
/* For binary encoding, the input is a base64-encoded .otp blob;
|
||||
* decode it to raw bytes first. For ASCII, the input is the
|
||||
* NUL-terminated armor string. */
|
||||
if (encoding && strcmp(encoding, "binary") == 0) {
|
||||
int bin_len = 0;
|
||||
unsigned char *bin = otppad_base64_decode(payload_item->valuestring,
|
||||
&bin_len);
|
||||
if (bin == NULL) {
|
||||
return make_error_response(id_str, -32602,
|
||||
"invalid_ciphertext_base64");
|
||||
}
|
||||
rc = otp_pad_decrypt((const char *)bin, (size_t)bin_len, encoding,
|
||||
&pt, &pt_len);
|
||||
secure_memzero(bin, (size_t)bin_len);
|
||||
free(bin);
|
||||
} else {
|
||||
size_t input_len = strlen(payload_item->valuestring);
|
||||
rc = otp_pad_decrypt(payload_item->valuestring, input_len, encoding,
|
||||
&pt, &pt_len);
|
||||
}
|
||||
if (rc != 0) {
|
||||
return make_error_response(id_str, -32602, "otp_decrypt_failed");
|
||||
}
|
||||
|
||||
/* Base64-encode the plaintext for the JSON result. */
|
||||
char *pt_b64 = otppad_base64_encode(pt, (int)pt_len);
|
||||
secure_memzero(pt, pt_len);
|
||||
free(pt);
|
||||
if (pt_b64 == NULL) {
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
|
||||
cJSON *result_obj = cJSON_CreateObject();
|
||||
if (result_obj == NULL) {
|
||||
free(pt_b64);
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
cJSON_AddStringToObject(result_obj, "plaintext", pt_b64);
|
||||
cJSON_AddStringToObject(result_obj, "pad_chksum", otp_pad_chksum());
|
||||
free(pt_b64);
|
||||
|
||||
char *out = cJSON_PrintUnformatted(result_obj);
|
||||
cJSON_Delete(result_obj);
|
||||
if (out == NULL) {
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
return make_success_response(id_str, out);
|
||||
}
|
||||
|
||||
return make_error_response(id_str, -32601, "method_not_found");
|
||||
}
|
||||
|
||||
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request) {
|
||||
cJSON *root = NULL;
|
||||
cJSON *method_item;
|
||||
@@ -1496,6 +1662,19 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- OTP verb routing ----
|
||||
*
|
||||
* otp_encrypt and otp_decrypt do not use the role table or the mnemonic;
|
||||
* they operate on the bound OTP pad (one pad per session, bound at
|
||||
* startup via --otp-pad-dir / --otp-pad). Route them before role
|
||||
* resolution. */
|
||||
if (strcmp(method, VERB_OTP_ENCRYPT) == 0 ||
|
||||
strcmp(method, VERB_OTP_DECRYPT) == 0) {
|
||||
char *otp_response = handle_otp_verb(id_str, method, params_item, options_item);
|
||||
cJSON_Delete(root);
|
||||
return otp_response;
|
||||
}
|
||||
|
||||
if (cJSON_IsObject(options_item)) {
|
||||
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role");
|
||||
if (tmp != NULL) {
|
||||
|
||||
@@ -637,7 +637,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
220
src/http_listener.c
Normal file
220
src/http_listener.c
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* http_listener.c — minimal HTTP/1.1 parser for n_signer's HTTP listener mode.
|
||||
*
|
||||
* Only supports POST with a JSON body. No chunked encoding, no keep-alive,
|
||||
* no static file serving. One request per connection (connection close after
|
||||
* response).
|
||||
*
|
||||
* This is intentionally minimal — n_signer is a signer, not a web server.
|
||||
*/
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Read a line from fd into buf (up to buf_size-1 chars, NUL-terminated).
|
||||
* Returns line length (excluding NUL) on success, -1 on error/EOF.
|
||||
* The line includes the trailing \r\n if present. */
|
||||
static int read_line_fd(int fd, char *buf, size_t buf_size) {
|
||||
size_t pos = 0;
|
||||
while (pos < buf_size - 1) {
|
||||
char c;
|
||||
ssize_t n = read(fd, &c, 1);
|
||||
if (n <= 0) {
|
||||
if (pos == 0) return -1;
|
||||
break;
|
||||
}
|
||||
buf[pos++] = c;
|
||||
if (c == '\n') break;
|
||||
}
|
||||
buf[pos] = '\0';
|
||||
return (int)pos;
|
||||
}
|
||||
|
||||
/* Read exactly n bytes from fd into buf. Returns 0 on success, -1 on error. */
|
||||
static int read_n_bytes(int fd, char *buf, size_t n) {
|
||||
size_t got = 0;
|
||||
while (got < n) {
|
||||
ssize_t r = read(fd, buf + got, n - got);
|
||||
if (r <= 0) return -1;
|
||||
got += (size_t)r;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Write all bytes to fd. Returns 0 on success, -1 on error. */
|
||||
static int write_all(int fd, const char *buf, size_t len) {
|
||||
size_t put = 0;
|
||||
while (put < len) {
|
||||
ssize_t w = write(fd, buf + put, len - put);
|
||||
if (w <= 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return -1;
|
||||
}
|
||||
put += (size_t)w;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Read an HTTP POST request from fd and return the JSON body.
|
||||
*
|
||||
* On success returns 0 and sets *out_body to a malloc'd NUL-terminated
|
||||
* string (caller frees). Returns -1 on error.
|
||||
*/
|
||||
int http_recv_request(int fd, char **out_body, size_t max_body_size) {
|
||||
char line[2048];
|
||||
long content_length = -1;
|
||||
int is_post = 0;
|
||||
|
||||
if (!out_body) return -1;
|
||||
*out_body = NULL;
|
||||
|
||||
/* Read request line: "METHOD PATH HTTP/1.1\r\n" */
|
||||
if (read_line_fd(fd, line, sizeof(line)) < 0) {
|
||||
return -1;
|
||||
}
|
||||
/* Parse method. */
|
||||
if (strncmp(line, "POST", 4) == 0) {
|
||||
is_post = 1;
|
||||
} else if (strncmp(line, "OPTIONS", 7) == 0) {
|
||||
/* CORS preflight — read remaining headers, then return a special
|
||||
* marker so the caller can send CORS headers. */
|
||||
while (read_line_fd(fd, line, sizeof(line)) > 0) {
|
||||
if (strcmp(line, "\r\n") == 0 || strcmp(line, "\n") == 0) break;
|
||||
}
|
||||
*out_body = strdup(""); /* empty body signals OPTIONS */
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!is_post) {
|
||||
/* Not POST — read remaining headers so we can send a 405. */
|
||||
while (read_line_fd(fd, line, sizeof(line)) > 0) {
|
||||
if (strcmp(line, "\r\n") == 0 || strcmp(line, "\n") == 0) break;
|
||||
}
|
||||
return -2; /* method not allowed */
|
||||
}
|
||||
|
||||
/* Read headers until empty line. */
|
||||
while (read_line_fd(fd, line, sizeof(line)) > 0) {
|
||||
/* Strip trailing \r\n. */
|
||||
size_t len = strlen(line);
|
||||
while (len > 0 && (line[len-1] == '\r' || line[len-1] == '\n')) {
|
||||
line[--len] = '\0';
|
||||
}
|
||||
if (len == 0) break; /* end of headers */
|
||||
|
||||
/* Parse Content-Length (case-insensitive). */
|
||||
if (strncasecmp(line, "Content-Length:", 15) == 0) {
|
||||
const char *p = line + 15;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
content_length = atol(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (content_length < 0) {
|
||||
return -3; /* missing Content-Length */
|
||||
}
|
||||
if ((size_t)content_length > max_body_size) {
|
||||
/* Drain the body so the connection isn't left half-open. */
|
||||
char tmp[4096];
|
||||
long remaining = content_length;
|
||||
while (remaining > 0) {
|
||||
size_t to_read = (size_t)remaining;
|
||||
if (to_read > sizeof(tmp)) to_read = sizeof(tmp);
|
||||
ssize_t r = read(fd, tmp, to_read);
|
||||
if (r <= 0) break;
|
||||
remaining -= r;
|
||||
}
|
||||
return -4; /* body too large */
|
||||
}
|
||||
|
||||
/* Read the body. */
|
||||
char *body = (char *)malloc((size_t)content_length + 1);
|
||||
if (!body) return -1;
|
||||
if (read_n_bytes(fd, body, (size_t)content_length) != 0) {
|
||||
free(body);
|
||||
return -1;
|
||||
}
|
||||
body[content_length] = '\0';
|
||||
*out_body = body;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Send an HTTP response with a JSON body.
|
||||
*/
|
||||
int http_send_response(int fd, const char *json_body) {
|
||||
if (!json_body) json_body = "";
|
||||
size_t body_len = strlen(json_body);
|
||||
|
||||
char header[512];
|
||||
int hlen = snprintf(header, sizeof(header),
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: %zu\r\n"
|
||||
"Access-Control-Allow-Origin: *\r\n"
|
||||
"Access-Control-Allow-Methods: POST, OPTIONS\r\n"
|
||||
"Access-Control-Allow-Headers: Content-Type\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n",
|
||||
body_len);
|
||||
|
||||
if (write_all(fd, header, (size_t)hlen) != 0) return -1;
|
||||
if (write_all(fd, json_body, body_len) != 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Send an HTTP error response.
|
||||
*/
|
||||
int http_send_error(int fd, int status, const char *message) {
|
||||
if (!message) message = "error";
|
||||
char body[512];
|
||||
int blen = snprintf(body, sizeof(body),
|
||||
"{\"error\":{\"code\":%d,\"message\":\"%s\"}}", status, message);
|
||||
|
||||
char header[512];
|
||||
int hlen = snprintf(header, sizeof(header),
|
||||
"HTTP/1.1 %d %s\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: %d\r\n"
|
||||
"Access-Control-Allow-Origin: *\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n",
|
||||
status,
|
||||
(status == 405) ? "Method Not Allowed" :
|
||||
(status == 413) ? "Payload Too Large" :
|
||||
(status == 400) ? "Bad Request" : "Internal Server Error",
|
||||
blen);
|
||||
|
||||
if (write_all(fd, header, (size_t)hlen) != 0) return -1;
|
||||
if (write_all(fd, body, (size_t)blen) != 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Send a CORS preflight response (for OPTIONS requests).
|
||||
*/
|
||||
int http_send_cors_preflight(int fd) {
|
||||
const char *resp =
|
||||
"HTTP/1.1 204 No Content\r\n"
|
||||
"Access-Control-Allow-Origin: *\r\n"
|
||||
"Access-Control-Allow-Methods: POST, OPTIONS\r\n"
|
||||
"Access-Control-Allow-Headers: Content-Type\r\n"
|
||||
"Access-Control-Max-Age: 86400\r\n"
|
||||
"Content-Length: 0\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n";
|
||||
return write_all(fd, resp, strlen(resp));
|
||||
}
|
||||
40
src/http_listener.h
Normal file
40
src/http_listener.h
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* http_listener.h — minimal HTTP/1.1 parser for n_signer's HTTP listener mode.
|
||||
*/
|
||||
#ifndef NSIGNER_HTTP_LISTENER_H
|
||||
#define NSIGNER_HTTP_LISTENER_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Read an HTTP POST request from fd and return the JSON body.
|
||||
* On success returns 0 and sets *out_body to a malloc'd NUL-terminated
|
||||
* string (caller frees). Returns -1 on read error, -2 on method not allowed,
|
||||
* -3 on missing Content-Length, -4 on body too large.
|
||||
*/
|
||||
int http_recv_request(int fd, char **out_body, size_t max_body_size);
|
||||
|
||||
/*
|
||||
* Send an HTTP 200 response with a JSON body.
|
||||
*/
|
||||
int http_send_response(int fd, const char *json_body);
|
||||
|
||||
/*
|
||||
* Send an HTTP error response.
|
||||
*/
|
||||
int http_send_error(int fd, int status, const char *message);
|
||||
|
||||
/*
|
||||
* Send a CORS preflight response (for OPTIONS requests).
|
||||
*/
|
||||
int http_send_cors_preflight(int fd);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* NSIGNER_HTTP_LISTENER_H */
|
||||
@@ -612,7 +612,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
571
src/main.c
571
src/main.c
@@ -5,6 +5,7 @@
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include <cJSON.h>
|
||||
#include "otp_pad.h"
|
||||
|
||||
/* from secure_mem.h */
|
||||
|
||||
@@ -644,12 +645,13 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
#define NSIGNER_LISTEN_UNIX 0
|
||||
#define NSIGNER_LISTEN_STDIO 1
|
||||
#define NSIGNER_LISTEN_QREXEC 2
|
||||
#define NSIGNER_LISTEN_TCP 3
|
||||
#define NSIGNER_LISTEN_HTTP 4
|
||||
|
||||
#define NSIGNER_AUTH_OFF 0
|
||||
#define NSIGNER_AUTH_OPTIONAL 1
|
||||
@@ -757,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 46
|
||||
#define NSIGNER_VERSION "v0.0.46"
|
||||
#define NSIGNER_VERSION_PATCH 48
|
||||
#define NSIGNER_VERSION "v0.0.48"
|
||||
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
@@ -772,6 +774,7 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size);
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
@@ -783,6 +786,7 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size);
|
||||
#include <limits.h>
|
||||
#include <strings.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/un.h>
|
||||
#include <termios.h>
|
||||
@@ -819,10 +823,10 @@ static char g_connection_info[CONNECTION_INFO_CAP][192];
|
||||
static int g_connection_info_count = 0;
|
||||
|
||||
static const TuiMenuItem g_main_menu_items[] = {
|
||||
{"^_q^:/x quit", 'q'},
|
||||
{"^_l^: lock/reunlock", 'l'},
|
||||
{"^_r^: refresh", 'r'},
|
||||
{"^_A^: toggle auto-approve", 'a'}
|
||||
{"^_a^: toggle auto-approve", 'a'},
|
||||
{"^_q^:/x quit", 'q'}
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
@@ -1054,7 +1058,7 @@ static void print_usage(const char *program_name) {
|
||||
tui_print(" %s --version", program_name);
|
||||
tui_print("");
|
||||
tui_print("Options:");
|
||||
tui_print(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT");
|
||||
tui_print(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT|http:HOST:PORT");
|
||||
tui_print(" --preapprove, -p SPEC");
|
||||
tui_print(" Pre-approve a caller for a role (repeatable)");
|
||||
tui_print(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>");
|
||||
@@ -1065,6 +1069,9 @@ static void print_usage(const char *program_name) {
|
||||
tui_print(" --bridge-source-trusted Accept qrexec_source preamble on unix connections (bridge mode)");
|
||||
tui_print(" --allow-index SPEC Restrict which nostr_index values this session can access");
|
||||
tui_print(" SPEC: 'all' (default), '1,3,4', '0-3', or '0-3,7,9'");
|
||||
tui_print(" --otp-pad-dir DIR Bind an OTP pad directory at startup (one pad per session)");
|
||||
tui_print(" --otp-pad SPEC Pad chksum (64 hex) or unique prefix; required with --otp-pad-dir");
|
||||
tui_print(" --otp-allow-blkback Allow pads on qvm-block (blkback) devices; NOT for production pads");
|
||||
}
|
||||
|
||||
static int extract_nsigner_socket_from_proc_line(const char *line,
|
||||
@@ -1179,7 +1186,7 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
|
||||
int fd;
|
||||
char *request = NULL;
|
||||
char *response = NULL;
|
||||
char stdin_buf[SERVER_MAX_MSG_SIZE + 1];
|
||||
char *stdin_buf = NULL;
|
||||
|
||||
if (argc < 1) {
|
||||
fprintf(stderr, "Usage: nsigner [--socket-name|--name|-n <name>] client '<json-rpc-request>' | -\n");
|
||||
@@ -1187,8 +1194,16 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
|
||||
}
|
||||
|
||||
if (strcmp(argv[0], "-") == 0) {
|
||||
if (read_line_stdin(stdin_buf, sizeof(stdin_buf)) != 0) {
|
||||
/* Heap-allocate the stdin buffer (SERVER_MAX_MSG_SIZE is 16MB — too
|
||||
* large for the stack). */
|
||||
stdin_buf = (char *)malloc(SERVER_MAX_MSG_SIZE + 1);
|
||||
if (!stdin_buf) {
|
||||
fprintf(stderr, "Failed to allocate stdin buffer\n");
|
||||
return 1;
|
||||
}
|
||||
if (read_line_stdin(stdin_buf, SERVER_MAX_MSG_SIZE + 1) != 0) {
|
||||
fprintf(stderr, "Failed to read request from stdin\n");
|
||||
free(stdin_buf);
|
||||
return 1;
|
||||
}
|
||||
request = stdin_buf;
|
||||
@@ -1212,12 +1227,14 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
|
||||
if (transport_send_framed(fd, request) != 0) {
|
||||
perror("send");
|
||||
close(fd);
|
||||
free(stdin_buf);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (transport_recv_framed(fd, &response, SERVER_MAX_MSG_SIZE) != 0) {
|
||||
perror("recv");
|
||||
close(fd);
|
||||
free(stdin_buf);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1225,6 +1242,7 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
|
||||
|
||||
free(response);
|
||||
close(fd);
|
||||
free(stdin_buf);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1437,7 +1455,7 @@ static void render_status(const role_table_t *role_table,
|
||||
TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Main Menu" };
|
||||
TuiMenu menu = { g_main_menu_items, (int)(sizeof(g_main_menu_items) / sizeof(g_main_menu_items[0])) };
|
||||
TuiSize size = tui_terminal_size();
|
||||
int left_col = tui_menu_left_col(&frame, size.width);
|
||||
const int left_col = 0;
|
||||
char status_buf[256];
|
||||
|
||||
tui_clear_continuous(size.height);
|
||||
@@ -1453,19 +1471,6 @@ static void render_status(const role_table_t *role_table,
|
||||
}
|
||||
|
||||
tui_print("");
|
||||
tui_render_menu(&menu, left_col);
|
||||
tui_print("");
|
||||
|
||||
(void)snprintf(status_buf,
|
||||
sizeof(status_buf),
|
||||
"session=%s (%d words) signer=%s derived=%d auto-approve=%s",
|
||||
mnemonic_is_loaded(mnemonic) ? "unlocked" : "locked",
|
||||
(mnemonic != NULL) ? mnemonic->word_count : 0,
|
||||
(socket_name != NULL) ? socket_name : "(none)",
|
||||
derived_count,
|
||||
g_auto_approve ? "ON" : "OFF");
|
||||
tui_print("%s", status_buf);
|
||||
|
||||
tui_print("^*Roles^:");
|
||||
if (role_table == NULL || role_table->count == 0) {
|
||||
tui_print("(none)");
|
||||
@@ -1502,6 +1507,19 @@ static void render_status(const role_table_t *role_table,
|
||||
}
|
||||
}
|
||||
|
||||
tui_print("");
|
||||
(void)snprintf(status_buf,
|
||||
sizeof(status_buf),
|
||||
"session=%s (%d words) signer=%s derived=%d auto-approve=%s",
|
||||
mnemonic_is_loaded(mnemonic) ? "unlocked" : "locked",
|
||||
(mnemonic != NULL) ? mnemonic->word_count : 0,
|
||||
(socket_name != NULL) ? socket_name : "(none)",
|
||||
derived_count,
|
||||
g_auto_approve ? "ON" : "OFF");
|
||||
tui_print("%s", status_buf);
|
||||
tui_print("");
|
||||
tui_render_menu(&menu, left_col);
|
||||
|
||||
tui_anchor_prompt(0, left_col);
|
||||
fflush(stdout);
|
||||
}
|
||||
@@ -1848,7 +1866,7 @@ static void apply_test_overrides(policy_table_t *policy) {
|
||||
const char *p = hotkeys;
|
||||
while (*p != '\0') {
|
||||
char ch = *p;
|
||||
if (ch == 'A') {
|
||||
if (ch == 'a') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
}
|
||||
@@ -1861,7 +1879,8 @@ static void apply_test_overrides(policy_table_t *policy) {
|
||||
#define TRANSPORT_UNIX 0x01
|
||||
#define TRANSPORT_QREXEC_BRIDGE 0x02
|
||||
#define TRANSPORT_TCP 0x04
|
||||
#define TRANSPORT_QREXEC_ONESHOT 0x08
|
||||
#define TRANSPORT_HTTP 0x08
|
||||
#define TRANSPORT_QREXEC_ONESHOT 0x10
|
||||
|
||||
/*
|
||||
* Interactive transport selection menu.
|
||||
@@ -1879,8 +1898,10 @@ static int prompt_transport_selection(void) {
|
||||
(selected & TRANSPORT_UNIX) ? "x" : " ");
|
||||
printf(" [%s] 2. Qubes qrexec bridge (other qubes via qrexec, no network)\n",
|
||||
(selected & TRANSPORT_QREXEC_BRIDGE) ? "x" : " ");
|
||||
printf(" [%s] 3. TCP listener (FIPS mesh or local network)\n",
|
||||
printf(" [%s] 3. FIPS/TCP listener (framed JSON, FIPS mesh or local network)\n",
|
||||
(selected & TRANSPORT_TCP) ? "x" : " ");
|
||||
printf(" [%s] 4. HTTP listener (curl-friendly, localhost by default)\n",
|
||||
(selected & TRANSPORT_HTTP) ? "x" : " ");
|
||||
printf("\n [a] select all Enter = confirm\n");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
@@ -1908,7 +1929,7 @@ static int prompt_transport_selection(void) {
|
||||
}
|
||||
|
||||
if (input[0] == 'a' || input[0] == 'A') {
|
||||
selected = TRANSPORT_UNIX | TRANSPORT_QREXEC_BRIDGE | TRANSPORT_TCP;
|
||||
selected = TRANSPORT_UNIX | TRANSPORT_QREXEC_BRIDGE | TRANSPORT_TCP | TRANSPORT_HTTP;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1918,8 +1939,10 @@ static int prompt_transport_selection(void) {
|
||||
selected ^= TRANSPORT_QREXEC_BRIDGE;
|
||||
} else if (input[0] == '3') {
|
||||
selected ^= TRANSPORT_TCP;
|
||||
} else if (input[0] == '4') {
|
||||
selected ^= TRANSPORT_HTTP;
|
||||
} else {
|
||||
printf("Invalid input. Type 1-3, 'a', or Enter to confirm.\n");
|
||||
printf("Invalid input. Type 1-4, 'a', or Enter to confirm.\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1979,6 +2002,321 @@ static char *prompt_index_whitelist(void) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Scan for OTP pads on mounted USB drives.
|
||||
*
|
||||
* Looks at /proc/mounts for mounts whose source device is /dev/sd* (USB mass
|
||||
* storage), then checks each mount point for a "pads/" subdirectory containing
|
||||
* .pad files. Also checks the mount point root itself for .pad files.
|
||||
*
|
||||
* Fills `out_dirs` (array of malloc'd pad-directory paths) and `out_pads`
|
||||
* (array of malloc'd pad chksum strings) up to max_entries. Returns the count
|
||||
* found, or 0 if none.
|
||||
*/
|
||||
#define OTP_SCAN_MAX 16
|
||||
static int scan_usb_pads(char *out_dirs[][OTP_SCAN_MAX],
|
||||
char *out_pads[][OTP_SCAN_MAX]) {
|
||||
FILE *mtab = fopen("/proc/mounts", "r");
|
||||
if (!mtab) return 0;
|
||||
|
||||
char line[1024];
|
||||
int count = 0;
|
||||
|
||||
while (fgets(line, sizeof(line), mtab) && count < OTP_SCAN_MAX) {
|
||||
char source[512], mount[512], fstype[64];
|
||||
if (sscanf(line, "%511s %511s %63s", source, mount, fstype) < 3) continue;
|
||||
/* Only look at /dev/sd* devices (USB mass storage). */
|
||||
if (strncmp(source, "/dev/sd", 7) != 0) continue;
|
||||
|
||||
/* Check <mount>/pads/ for .pad files, and also the mount root. */
|
||||
const char *subdirs[] = {"pads", "" /* root */, NULL};
|
||||
for (int s = 0; subdirs[s] != NULL && count < OTP_SCAN_MAX; s++) {
|
||||
char dir[600];
|
||||
if (subdirs[s][0] == '\0') {
|
||||
snprintf(dir, sizeof(dir), "%s", mount);
|
||||
} else {
|
||||
snprintf(dir, sizeof(dir), "%s/%s", mount, subdirs[s]);
|
||||
}
|
||||
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) continue;
|
||||
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d)) != NULL && count < OTP_SCAN_MAX) {
|
||||
size_t nlen = strlen(e->d_name);
|
||||
if (nlen < 5 || strcmp(e->d_name + nlen - 4, ".pad") != 0) continue;
|
||||
/* Found a .pad file. The chksum is the filename without .pad. */
|
||||
char chksum[128];
|
||||
size_t clen = nlen - 4;
|
||||
if (clen >= sizeof(chksum)) continue;
|
||||
memcpy(chksum, e->d_name, clen);
|
||||
chksum[clen] = '\0';
|
||||
|
||||
(*out_dirs)[count] = strdup(dir);
|
||||
(*out_pads)[count] = strdup(chksum);
|
||||
count++;
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
}
|
||||
fclose(mtab);
|
||||
return count;
|
||||
}
|
||||
|
||||
/*
|
||||
* Interactive OTP pad selection prompt.
|
||||
* Shown after index whitelist when no --otp-pad-dir flag was given and
|
||||
* stdin is a TTY. Automatically scans attached USB drives for pads and
|
||||
* offers them. Falls back to manual directory entry if no pads are found.
|
||||
*
|
||||
* On success with a pad selected, returns 0 and fills:
|
||||
* *out_pad_dir — malloc'd pad directory path (caller frees)
|
||||
* *out_pad_spec — malloc'd pad chksum/prefix (caller frees)
|
||||
* *out_allow_blkback — 1 if the user acknowledged blkback, 0 otherwise
|
||||
* Returns 1 if the user declined (no pad), -1 on read error.
|
||||
*/
|
||||
static int prompt_otp_pad(char **out_pad_dir, char **out_pad_spec,
|
||||
int *out_allow_blkback) {
|
||||
char input[512];
|
||||
|
||||
*out_pad_dir = NULL;
|
||||
*out_pad_spec = NULL;
|
||||
*out_allow_blkback = 0;
|
||||
|
||||
/* Auto-scan USB drives for pads. */
|
||||
char *found_dirs[OTP_SCAN_MAX];
|
||||
char *found_pads[OTP_SCAN_MAX];
|
||||
int found_count = scan_usb_pads(&found_dirs, &found_pads);
|
||||
|
||||
if (found_count > 0) {
|
||||
/* Offer the discovered pads. */
|
||||
for (;;) {
|
||||
tui_render_content_screen(NULL, "OTP pad — pads found on USB drives");
|
||||
printf("Found %d pad(s) on attached USB drive(s):\n\n", found_count);
|
||||
for (int i = 0; i < found_count; i++) {
|
||||
printf(" %d. %s (in %s)\n", i + 1, found_pads[i], found_dirs[i]);
|
||||
}
|
||||
printf("\n Type a number to select, 'm' for manual entry, 'n' to skip\n");
|
||||
printf(" Enter = 1 (first pad)\n");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
|
||||
if (read_line_stdin(input, sizeof(input)) != 0) {
|
||||
for (int i = 0; i < found_count; i++) {
|
||||
free(found_dirs[i]); free(found_pads[i]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
size_t len = strlen(input);
|
||||
while (len > 0 && (input[len-1] == '\n' || input[len-1] == '\r' ||
|
||||
input[len-1] == ' ' || input[len-1] == '\t')) {
|
||||
input[--len] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
if (input[0] == '\0') {
|
||||
/* Default: first pad. */
|
||||
*out_pad_dir = strdup(found_dirs[0]);
|
||||
*out_pad_spec = strdup(found_pads[0]);
|
||||
break;
|
||||
}
|
||||
if (input[0] == 'n' || input[0] == 'N') {
|
||||
for (int i = 0; i < found_count; i++) {
|
||||
free(found_dirs[i]); free(found_pads[i]);
|
||||
}
|
||||
return 1; /* declined */
|
||||
}
|
||||
if (input[0] == 'm' || input[0] == 'M') {
|
||||
/* Fall through to manual entry. */
|
||||
break;
|
||||
}
|
||||
/* Try to parse a number. */
|
||||
int choice = atoi(input);
|
||||
if (choice >= 1 && choice <= found_count) {
|
||||
*out_pad_dir = strdup(found_dirs[choice - 1]);
|
||||
*out_pad_spec = strdup(found_pads[choice - 1]);
|
||||
break;
|
||||
}
|
||||
printf("Invalid selection. Type 1-%d, 'm', or 'n'.\n", found_count);
|
||||
}
|
||||
|
||||
/* If the user selected a pad (not manual), we're done. */
|
||||
if (*out_pad_dir != NULL) {
|
||||
for (int i = 0; i < found_count; i++) {
|
||||
free(found_dirs[i]); free(found_pads[i]);
|
||||
}
|
||||
/* USB drives (/dev/sd*) are not blkback, so no warning needed. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Manual entry requested — free the scan results and fall through. */
|
||||
for (int i = 0; i < found_count; i++) {
|
||||
free(found_dirs[i]); free(found_pads[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* No pads found on USB, or user chose manual entry. */
|
||||
for (;;) {
|
||||
tui_render_content_screen(NULL, "OTP pad — bind a one-time pad?");
|
||||
if (found_count > 0) {
|
||||
printf("Manual pad directory entry:\n\n");
|
||||
} else {
|
||||
printf("No pads found on attached USB drives.\n");
|
||||
printf("Do you want to manually specify a pad directory? (y/n)\n\n");
|
||||
printf(" Enter = no\n");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
|
||||
if (read_line_stdin(input, sizeof(input)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
size_t len = strlen(input);
|
||||
while (len > 0 && (input[len-1] == '\n' || input[len-1] == '\r' ||
|
||||
input[len-1] == ' ' || input[len-1] == '\t')) {
|
||||
input[--len] = '\0';
|
||||
}
|
||||
}
|
||||
if (input[0] == '\0' || input[0] == 'n' || input[0] == 'N') {
|
||||
return 1; /* declined */
|
||||
}
|
||||
if (input[0] != 'y' && input[0] != 'Y') {
|
||||
printf("Please type 'y' or 'n'.\n");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt for pad directory. */
|
||||
tui_render_content_screen(NULL, "OTP pad — pad directory");
|
||||
printf("Enter the directory containing the .pad and .state files:\n\n");
|
||||
printf(" Examples:\n");
|
||||
printf(" /media/user/Music/pads\n");
|
||||
printf(" /mnt/usb/pads\n");
|
||||
printf("\n (This is typically a directory on a USB drive.)\n");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
|
||||
if (read_line_stdin(input, sizeof(input)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
size_t len = strlen(input);
|
||||
while (len > 0 && (input[len-1] == '\n' || input[len-1] == '\r' ||
|
||||
input[len-1] == ' ' || input[len-1] == '\t')) {
|
||||
input[--len] = '\0';
|
||||
}
|
||||
}
|
||||
if (input[0] == '\0') {
|
||||
printf("No directory entered. Skipping OTP pad.\n");
|
||||
return 1;
|
||||
}
|
||||
struct stat st;
|
||||
if (stat(input, &st) != 0 || !S_ISDIR(st.st_mode)) {
|
||||
printf("Directory '%s' not found or not a directory. Try again.\n", input);
|
||||
continue;
|
||||
}
|
||||
*out_pad_dir = strdup(input);
|
||||
break;
|
||||
}
|
||||
|
||||
/* Prompt for pad checksum or prefix. */
|
||||
for (;;) {
|
||||
tui_render_content_screen(NULL, "OTP pad — pad checksum or prefix");
|
||||
printf("Enter the pad checksum (64 hex chars) or a unique prefix:\n\n");
|
||||
printf(" The pad file is named <chksum>.pad in the directory.\n");
|
||||
printf(" You can enter the first few characters if unique.\n");
|
||||
printf(" (Press Enter to list available pads in the directory.)\n");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
|
||||
if (read_line_stdin(input, sizeof(input)) != 0) {
|
||||
free(*out_pad_dir);
|
||||
*out_pad_dir = NULL;
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
size_t len = strlen(input);
|
||||
while (len > 0 && (input[len-1] == '\n' || input[len-1] == '\r' ||
|
||||
input[len-1] == ' ' || input[len-1] == '\t')) {
|
||||
input[--len] = '\0';
|
||||
}
|
||||
}
|
||||
if (input[0] == '\0') {
|
||||
/* List available pads. */
|
||||
DIR *d = opendir(*out_pad_dir);
|
||||
if (!d) {
|
||||
printf("Cannot open directory '%s'. Try again.\n", *out_pad_dir);
|
||||
continue;
|
||||
}
|
||||
printf("\nAvailable pads in %s:\n", *out_pad_dir);
|
||||
struct dirent *e;
|
||||
int count = 0;
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
size_t nlen = strlen(e->d_name);
|
||||
if (nlen > 4 && strcmp(e->d_name + nlen - 4, ".pad") == 0) {
|
||||
printf(" %.*s\n", (int)(nlen - 4), e->d_name);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
if (count == 0) {
|
||||
printf(" (no .pad files found)\n");
|
||||
}
|
||||
printf("\n");
|
||||
continue;
|
||||
}
|
||||
*out_pad_spec = strdup(input);
|
||||
break;
|
||||
}
|
||||
|
||||
/* Check if the pad directory is on a blkback device and warn. */
|
||||
{
|
||||
const char *dir = *out_pad_dir;
|
||||
FILE *mtab = fopen("/proc/mounts", "r");
|
||||
int is_blkback = 0;
|
||||
if (mtab) {
|
||||
char line[1024];
|
||||
size_t best_len = 0;
|
||||
while (fgets(line, sizeof(line), mtab)) {
|
||||
char source[512], mount[512], fstype[64];
|
||||
if (sscanf(line, "%511s %511s %63s", source, mount, fstype) < 3) continue;
|
||||
size_t mlen = strlen(mount);
|
||||
if (strncmp(dir, mount, mlen) == 0 &&
|
||||
(dir[mlen] == '/' || dir[mlen] == '\0') &&
|
||||
mlen > best_len) {
|
||||
best_len = mlen;
|
||||
is_blkback = (strncmp(source, "/dev/xvd", 8) == 0);
|
||||
}
|
||||
}
|
||||
fclose(mtab);
|
||||
}
|
||||
if (is_blkback) {
|
||||
tui_render_content_screen(NULL, "OTP pad — blkback warning");
|
||||
printf("WARNING: %s appears to be on a blkback device (qvm-block).\n", dir);
|
||||
printf("For pad secrecy, PCI USB controller passthrough is recommended.\n");
|
||||
printf("Continue anyway? (y/n)\n");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
if (read_line_stdin(input, sizeof(input)) != 0) {
|
||||
free(*out_pad_dir); free(*out_pad_spec);
|
||||
*out_pad_dir = NULL; *out_pad_spec = NULL;
|
||||
return -1;
|
||||
}
|
||||
if (input[0] == 'y' || input[0] == 'Y') {
|
||||
*out_allow_blkback = 1;
|
||||
} else {
|
||||
printf("OTP pad binding cancelled.\n");
|
||||
free(*out_pad_dir); free(*out_pad_spec);
|
||||
*out_pad_dir = NULL; *out_pad_spec = NULL;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
mnemonic_state_t mnemonic;
|
||||
role_table_t role_table;
|
||||
@@ -2005,6 +2343,9 @@ int main(int argc, char *argv[]) {
|
||||
int bridge_source_trusted = 0;
|
||||
const char *allow_index_spec = NULL;
|
||||
mnemonic_source_t mnemonic_source = { MNEMONIC_SOURCE_TUI, -1 };
|
||||
const char *otp_pad_dir = NULL;
|
||||
const char *otp_pad_spec = NULL;
|
||||
int otp_allow_blkback = 0;
|
||||
|
||||
while (argi < argc) {
|
||||
if (strcmp(argv[argi], "--socket-name") == 0 ||
|
||||
@@ -2034,8 +2375,11 @@ int main(int argc, char *argv[]) {
|
||||
} else if (strncmp(argv[argi + 1], "tcp:", 4) == 0) {
|
||||
listen_mode = NSIGNER_LISTEN_TCP;
|
||||
listen_target = argv[argi + 1];
|
||||
} else if (strncmp(argv[argi + 1], "http:", 5) == 0) {
|
||||
listen_mode = NSIGNER_LISTEN_HTTP;
|
||||
listen_target = argv[argi + 1];
|
||||
} else {
|
||||
fprintf(stderr, "Invalid --listen mode: %s (expected unix|stdio|qrexec|tcp:HOST:PORT)\n", argv[argi + 1]);
|
||||
fprintf(stderr, "Invalid --listen mode: %s (expected unix|stdio|qrexec|tcp:HOST:PORT|http:HOST:PORT)\n", argv[argi + 1]);
|
||||
return 1;
|
||||
}
|
||||
argi += 2;
|
||||
@@ -2123,6 +2467,29 @@ int main(int argc, char *argv[]) {
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--otp-pad-dir") == 0) {
|
||||
if (argi + 1 >= argc) {
|
||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||
return 1;
|
||||
}
|
||||
otp_pad_dir = argv[argi + 1];
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--otp-pad") == 0) {
|
||||
if (argi + 1 >= argc) {
|
||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||
return 1;
|
||||
}
|
||||
otp_pad_spec = argv[argi + 1];
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--otp-allow-blkback") == 0) {
|
||||
otp_allow_blkback = 1;
|
||||
argi += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2263,6 +2630,22 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
dispatcher_init(&dispatcher, &role_table, &mnemonic, &key_store, &alg_key_cache);
|
||||
|
||||
/* Bind OTP pad if requested. One pad per session. */
|
||||
if (otp_pad_dir != NULL) {
|
||||
if (otp_pad_spec == NULL) {
|
||||
fprintf(stderr, "nsigner: --otp-pad-dir requires --otp-pad <chksum-or-prefix>\n");
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
nostr_cleanup(); mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
if (otp_pad_bind(otp_pad_dir, otp_pad_spec, otp_allow_blkback) != 0) {
|
||||
fprintf(stderr, "nsigner: failed to bind OTP pad\n");
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
nostr_cleanup(); mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Interactive transport selection: if no --listen flag was given and
|
||||
* stdin is a TTY, show the multi-select transport menu. This lets the
|
||||
@@ -2271,7 +2654,7 @@ int main(int argc, char *argv[]) {
|
||||
*/
|
||||
int transport_mask = 0;
|
||||
int multi_listen = 0; /* set when multiple persistent listeners are active */
|
||||
server_ctx_t servers[2]; /* max: unix + tcp */
|
||||
server_ctx_t servers[3]; /* max: unix + tcp + http */
|
||||
int server_count = 0;
|
||||
int unix_server_idx = -1;
|
||||
int tcp_server_idx = -1;
|
||||
@@ -2295,6 +2678,27 @@ int main(int argc, char *argv[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/* OTP pad prompt (only if --otp-pad-dir wasn't given on CLI) */
|
||||
if (otp_pad_dir == NULL) {
|
||||
char *prompted_dir = NULL;
|
||||
char *prompted_spec = NULL;
|
||||
int prompted_blkback = 0;
|
||||
int otp_rc = prompt_otp_pad(&prompted_dir, &prompted_spec,
|
||||
&prompted_blkback);
|
||||
if (otp_rc == 0) {
|
||||
otp_pad_dir = prompted_dir;
|
||||
otp_pad_spec = prompted_spec;
|
||||
otp_allow_blkback = prompted_blkback;
|
||||
} else if (otp_rc < 0) {
|
||||
fprintf(stderr, "Error reading OTP pad selection.\n");
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
nostr_cleanup();
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
/* otp_rc == 1 means user declined — leave otp_pad_dir NULL */
|
||||
}
|
||||
|
||||
/* Persistent listeners — configure from menu selection */
|
||||
multi_listen = 1;
|
||||
|
||||
@@ -2329,7 +2733,8 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
||||
listen_target = socket_name;
|
||||
} else if (listen_mode == NSIGNER_LISTEN_TCP && socket_name_explicit) {
|
||||
} else if ((listen_mode == NSIGNER_LISTEN_TCP ||
|
||||
listen_mode == NSIGNER_LISTEN_HTTP) && socket_name_explicit) {
|
||||
fprintf(stderr, "--socket-name is only valid with unix listen mode\n");
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
nostr_cleanup();
|
||||
@@ -2349,6 +2754,7 @@ int main(int argc, char *argv[]) {
|
||||
if (listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
auth_mode = NSIGNER_AUTH_REQUIRED;
|
||||
}
|
||||
/* HTTP mode: no auth envelopes by default (rely on localhost + policy). */
|
||||
|
||||
server_init(&server,
|
||||
listen_target,
|
||||
@@ -2372,12 +2778,13 @@ int main(int argc, char *argv[]) {
|
||||
}
|
||||
}
|
||||
if (server_start(&server) != 0) {
|
||||
if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
||||
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
|
||||
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
fprintf(stderr, "Failed to start server on %s: %s\n", listen_target, server_last_error(&server));
|
||||
} else {
|
||||
fprintf(stderr, "Failed to start server (%s): %s\n",
|
||||
if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
||||
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
|
||||
} else if (listen_mode == NSIGNER_LISTEN_TCP ||
|
||||
listen_mode == NSIGNER_LISTEN_HTTP) {
|
||||
fprintf(stderr, "Failed to start server on %s: %s\n", listen_target, server_last_error(&server));
|
||||
} else {
|
||||
fprintf(stderr, "Failed to start server (%s): %s\n",
|
||||
(listen_mode == NSIGNER_LISTEN_QREXEC) ? "qrexec" : "stdio",
|
||||
server_last_error(&server));
|
||||
}
|
||||
@@ -2393,7 +2800,7 @@ int main(int argc, char *argv[]) {
|
||||
* already running. Both share the same dispatcher and policy.
|
||||
*/
|
||||
if (multi_listen && (transport_mask & TRANSPORT_TCP)) {
|
||||
const char *tcp_target = "tcp:[::]:8080";
|
||||
const char *tcp_target = "tcp:[::]:11111";
|
||||
tcp_server_idx = server_count;
|
||||
server_init(&servers[tcp_server_idx],
|
||||
tcp_target,
|
||||
@@ -2407,7 +2814,7 @@ int main(int argc, char *argv[]) {
|
||||
server_set_index_whitelist(&servers[tcp_server_idx], allow_index_spec);
|
||||
}
|
||||
if (server_start(&servers[tcp_server_idx]) != 0) {
|
||||
fprintf(stderr, "Failed to start TCP server on %s: %s\n",
|
||||
fprintf(stderr, "Failed to start FIPS/TCP server on %s: %s\n",
|
||||
tcp_target, server_last_error(&servers[tcp_server_idx]));
|
||||
server_stop(&server);
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
@@ -2418,6 +2825,36 @@ int main(int argc, char *argv[]) {
|
||||
server_count++;
|
||||
}
|
||||
|
||||
/* HTTP listener (multi-listener mode) */
|
||||
int http_server_idx = -1;
|
||||
if (multi_listen && (transport_mask & TRANSPORT_HTTP)) {
|
||||
const char *http_target = "http:127.0.0.1:11111";
|
||||
http_server_idx = server_count;
|
||||
server_init(&servers[http_server_idx],
|
||||
http_target,
|
||||
0,
|
||||
NSIGNER_LISTEN_HTTP,
|
||||
NSIGNER_AUTH_OFF,
|
||||
auth_skew_seconds,
|
||||
&dispatcher,
|
||||
&policy);
|
||||
if (allow_index_spec != NULL) {
|
||||
server_set_index_whitelist(&servers[http_server_idx], allow_index_spec);
|
||||
}
|
||||
if (server_start(&servers[http_server_idx]) != 0) {
|
||||
fprintf(stderr, "Failed to start HTTP server on %s: %s\n",
|
||||
http_target, server_last_error(&servers[http_server_idx]));
|
||||
/* Stop already-started servers */
|
||||
for (int si = 0; si < server_count; si++) server_stop(&servers[si]);
|
||||
server_stop(&server);
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
nostr_cleanup();
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
server_count++;
|
||||
}
|
||||
|
||||
/* Copy the primary (unix) server into the array for unified polling */
|
||||
if (multi_listen) {
|
||||
unix_server_idx = server_count;
|
||||
@@ -2449,43 +2886,60 @@ int main(int argc, char *argv[]) {
|
||||
if (multi_listen) {
|
||||
/* Multi-listener mode: show all active transports */
|
||||
if (transport_mask & (TRANSPORT_UNIX | TRANSPORT_QREXEC_BRIDGE)) {
|
||||
if (bridge_source_trusted) {
|
||||
connection_info_add("listen: unix @%s (bridge-source-trusted)", socket_name);
|
||||
} else {
|
||||
connection_info_add("listen: unix @%s", socket_name);
|
||||
}
|
||||
connection_info_add("client: nsigner --socket-name %s client '<json>'", socket_name);
|
||||
connection_info_add("Server: unix @%s%s", socket_name,
|
||||
bridge_source_trusted ? " (bridge-source-trusted)" : "");
|
||||
connection_info_add(" Client: nsigner --socket-name %s client '<json>'", socket_name);
|
||||
if (transport_mask & TRANSPORT_QREXEC_BRIDGE) {
|
||||
connection_info_add("qrexec service: %s", NSIGNER_QREXEC_SERVICE_NAME);
|
||||
connection_info_add("qrexec caller: qrexec-client-vm <target_qube> %s", NSIGNER_QREXEC_SERVICE_NAME);
|
||||
connection_info_add(" Qrexec: qrexec-client-vm <target_qube> %s",
|
||||
NSIGNER_QREXEC_SERVICE_NAME);
|
||||
printf("qrexec service: %s\n", NSIGNER_QREXEC_SERVICE_NAME);
|
||||
}
|
||||
printf("System is ready and waiting for connections on @%s.\n", socket_name);
|
||||
}
|
||||
if (transport_mask & TRANSPORT_TCP) {
|
||||
connection_info_add("listen: tcp [::]:8080");
|
||||
printf("System is ready and waiting for connections on tcp:[::]:8080.\n");
|
||||
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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
} else if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
||||
connection_info_add("listen: unix @%s", socket_name);
|
||||
connection_info_add("client: nsigner --socket-name %s client '<json>'", socket_name);
|
||||
connection_info_add("Server: unix @%s", socket_name);
|
||||
connection_info_add(" Client: nsigner --socket-name %s client '<json>'", socket_name);
|
||||
if (bridge_source_trusted) {
|
||||
connection_info_add("qrexec service: %s", NSIGNER_QREXEC_SERVICE_NAME);
|
||||
connection_info_add("qrexec caller: qrexec-client-vm <target_qube> %s", NSIGNER_QREXEC_SERVICE_NAME);
|
||||
connection_info_add(" Qrexec: qrexec-client-vm <target_qube> %s",
|
||||
NSIGNER_QREXEC_SERVICE_NAME);
|
||||
printf("qrexec service: %s\n", NSIGNER_QREXEC_SERVICE_NAME);
|
||||
}
|
||||
printf("System is ready and waiting for connections on @%s.\n", socket_name);
|
||||
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
connection_info_add("listen: %s", listen_target);
|
||||
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);
|
||||
} else if (listen_mode == NSIGNER_LISTEN_HTTP) {
|
||||
connection_info_add("Server: %s", listen_target);
|
||||
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);
|
||||
} else if (listen_mode == NSIGNER_LISTEN_QREXEC) {
|
||||
connection_info_add("listen: qrexec");
|
||||
connection_info_add("Server: qrexec (one request per invocation)");
|
||||
printf("System is ready and waiting for a qrexec request.\n");
|
||||
} else {
|
||||
connection_info_add("listen: stdio");
|
||||
connection_info_add("Server: stdio (one request per invocation)");
|
||||
printf("System is ready and waiting for a stdio request.\n");
|
||||
}
|
||||
|
||||
/* Show OTP pad status if bound. */
|
||||
if (otp_pad_is_bound()) {
|
||||
connection_info_add("OTP pad: %s (offset %llu / %llu bytes)",
|
||||
otp_pad_chksum(),
|
||||
(unsigned long long)otp_pad_current_offset(),
|
||||
(unsigned long long)otp_pad_size());
|
||||
}
|
||||
|
||||
if (have_fips_identity) {
|
||||
connection_info_add("fips ipv6: %s", fips_ipv6);
|
||||
connection_info_add("fips npub: %s", fips_npub);
|
||||
@@ -2510,7 +2964,8 @@ int main(int argc, char *argv[]) {
|
||||
return (hrc < 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
if (listen_mode == NSIGNER_LISTEN_TCP ||
|
||||
listen_mode == NSIGNER_LISTEN_HTTP) {
|
||||
pfds[0].fd = server.listen_fd;
|
||||
pfds[0].events = POLLIN;
|
||||
|
||||
@@ -2532,6 +2987,7 @@ int main(int argc, char *argv[]) {
|
||||
server_stop(&server);
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
nostr_cleanup();
|
||||
otp_pad_unbind();
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 0;
|
||||
}
|
||||
@@ -2612,7 +3068,7 @@ int main(int argc, char *argv[]) {
|
||||
g_running = 0;
|
||||
} else if (lower == 'r') {
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
} else if (ch == 'A') {
|
||||
} else if (ch == 'a') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
@@ -2657,6 +3113,7 @@ int main(int argc, char *argv[]) {
|
||||
}
|
||||
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
|
||||
nostr_cleanup();
|
||||
otp_pad_unbind();
|
||||
mnemonic_unload(&mnemonic);
|
||||
printf("Shutdown. All secrets wiped.\n");
|
||||
return 0;
|
||||
|
||||
@@ -631,7 +631,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
585
src/otp_pad.c
Normal file
585
src/otp_pad.c
Normal file
@@ -0,0 +1,585 @@
|
||||
/*
|
||||
* otp_pad.c — OTP pad state for n_signer.
|
||||
*
|
||||
* One pad per session (by design — see plans/otp_nostr_integration.md).
|
||||
* The pad is bound at startup via otp_pad_bind() and accessed by the
|
||||
* otp_encrypt / otp_decrypt dispatcher verbs via otp_pad_get_state().
|
||||
*
|
||||
* The pad file is opened read-only and kept open for the lifetime of the
|
||||
* process. The per-pad .state file (offset counter) is read and written
|
||||
* via libotppad. Offset writes are atomic (temp + rename) inside libotppad.
|
||||
*
|
||||
* Pad bytes are never loaded whole into RAM. Each request seeks to the
|
||||
* current offset and reads exactly the slice it needs into a small
|
||||
* mlock'd scratch buffer.
|
||||
*/
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#ifndef _GNU_SOURCE
|
||||
#define _GNU_SOURCE
|
||||
#endif
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/mman.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "libotppad.h"
|
||||
#include "otp_pad.h"
|
||||
|
||||
/* from secure_mem.h (headerless decls pattern) */
|
||||
extern void secure_memzero(void *ptr, size_t len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* OTP pad state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define OTP_PAD_DIR_MAX 512
|
||||
#define OTP_PAD_CHKSUM_MAX 128
|
||||
#define OTP_PAD_PATH_MAX (OTP_PAD_DIR_MAX + OTP_PAD_CHKSUM_MAX + 16)
|
||||
#define OTP_SCRATCH_MAX (4 * 1024 * 1024) /* 4 MB max chunk */
|
||||
|
||||
typedef struct {
|
||||
int bound; /* 1 if a pad is bound */
|
||||
char pads_dir[OTP_PAD_DIR_MAX]; /* directory holding .pad/.state */
|
||||
char chksum[OTP_PAD_CHKSUM_MAX]; /* 64-hex-char pad checksum */
|
||||
char pad_path[OTP_PAD_PATH_MAX]; /* full path to .pad file */
|
||||
FILE *pad_fp; /* read-only FILE* on .pad */
|
||||
uint64_t pad_size; /* total pad file size in bytes */
|
||||
int allow_blkback; /* 1 if --otp-allow-blkback passed */
|
||||
/* mlock'd scratch buffer for XOR */
|
||||
void *scratch_data;
|
||||
size_t scratch_size;
|
||||
int scratch_locked;
|
||||
} otp_pad_state_t;
|
||||
|
||||
static otp_pad_state_t g_otp_pad = {0};
|
||||
|
||||
otp_pad_state_t *otp_pad_get_state(void) {
|
||||
return &g_otp_pad;
|
||||
}
|
||||
|
||||
int otp_pad_is_bound(void) {
|
||||
return g_otp_pad.bound;
|
||||
}
|
||||
|
||||
const char *otp_pad_chksum(void) {
|
||||
return g_otp_pad.bound ? g_otp_pad.chksum : NULL;
|
||||
}
|
||||
|
||||
const char *otp_pad_dir(void) {
|
||||
return g_otp_pad.bound ? g_otp_pad.pads_dir : NULL;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Removable-mount check (Qubes guard) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Return 1 if `path` lives on a blkback device (e.g. /dev/xvdi via qvm-block),
|
||||
* 0 if it's a directly-owned device (e.g. /dev/sda via PCI passthrough),
|
||||
* -1 on error. Best-effort: compares the fs source device name. */
|
||||
static int is_blkback_mount(const char *path) {
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) return -1;
|
||||
|
||||
/* Read the mount source for the filesystem containing `path`. */
|
||||
FILE *mtab = fopen("/proc/mounts", "r");
|
||||
if (!mtab) return -1;
|
||||
|
||||
char line[1024];
|
||||
int found = 0;
|
||||
int is_blkback = 0;
|
||||
size_t best_len = 0;
|
||||
|
||||
while (fgets(line, sizeof(line), mtab)) {
|
||||
char source[512], mount[512], fstype[64];
|
||||
if (sscanf(line, "%511s %511s %63s", source, mount, fstype) < 3) continue;
|
||||
size_t mlen = strlen(mount);
|
||||
/* Pick the longest matching mount prefix. */
|
||||
if (strncmp(path, mount, mlen) == 0 &&
|
||||
(path[mlen] == '/' || path[mlen] == '\0') &&
|
||||
mlen > best_len) {
|
||||
best_len = mlen;
|
||||
found = 1;
|
||||
/* blkback devices show up as /dev/xvd* in the guest. */
|
||||
is_blkback = (strncmp(source, "/dev/xvd", 8) == 0);
|
||||
}
|
||||
}
|
||||
fclose(mtab);
|
||||
if (!found) return -1;
|
||||
return is_blkback;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Find a pad by chksum prefix in pads_dir */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Resolve a chksum-or-prefix to a full 64-char chksum by scanning pads_dir.
|
||||
* Returns 0 on success and fills `out_chksum` (must be >= OTP_PAD_CHKSUM_MAX).
|
||||
* Returns -1 if not found, -2 if ambiguous (multiple matches). */
|
||||
static int resolve_pad_chksum(const char *pads_dir, const char *prefix,
|
||||
char *out_chksum) {
|
||||
DIR *d = opendir(pads_dir);
|
||||
if (!d) return -1;
|
||||
|
||||
struct dirent *e;
|
||||
int matches = 0;
|
||||
char found[OTPPAD_CHKSUM_HEX_LEN + 1] = {0};
|
||||
size_t plen = strlen(prefix);
|
||||
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
size_t nlen = strlen(e->d_name);
|
||||
if (nlen < 5 || strcmp(e->d_name + nlen - 4, ".pad") != 0) continue;
|
||||
size_t base_len = nlen - 4; /* without ".pad" */
|
||||
if (base_len != OTPPAD_CHKSUM_HEX_LEN) continue;
|
||||
if (plen == 0 || strncmp(e->d_name, prefix, plen) == 0) {
|
||||
memcpy(found, e->d_name, base_len);
|
||||
found[base_len] = '\0';
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
|
||||
if (matches == 0) return -1;
|
||||
if (matches > 1) return -2;
|
||||
strncpy(out_chksum, found, OTPPAD_CHKSUM_HEX_LEN);
|
||||
out_chksum[OTPPAD_CHKSUM_HEX_LEN] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Bind / unbind */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Bind a pad at startup. Returns 0 on success, non-zero on error.
|
||||
* `pads_dir` is the directory containing <chksum>.pad and <chksum>.state.
|
||||
* `pad_spec` is a full 64-char chksum or a unique prefix.
|
||||
* `allow_blkback` non-zero skips the blkback guard (for qvm-block testing). */
|
||||
int otp_pad_bind(const char *pads_dir, const char *pad_spec, int allow_blkback) {
|
||||
if (!pads_dir || !pad_spec) return 1;
|
||||
if (g_otp_pad.bound) return 2; /* already bound */
|
||||
|
||||
/* Removable-mount guard. */
|
||||
int blkback = is_blkback_mount(pads_dir);
|
||||
if (blkback < 0) {
|
||||
/* Could not determine — warn but continue (best-effort). */
|
||||
fprintf(stderr, "otp_pad: warning: could not determine mount type for %s\n",
|
||||
pads_dir);
|
||||
} else if (blkback && !allow_blkback) {
|
||||
fprintf(stderr, "otp_pad: %s is on a blkback device (qvm-block).\n",
|
||||
pads_dir);
|
||||
fprintf(stderr, " Refusing to bind for pad secrecy. Use PCI USB "
|
||||
"controller passthrough, or pass --otp-allow-blkback "
|
||||
"to override (not recommended for production pads).\n");
|
||||
return 3;
|
||||
}
|
||||
|
||||
/* Resolve the pad chksum. */
|
||||
char chksum[OTPPAD_CHKSUM_HEX_LEN + 1];
|
||||
if (strlen(pad_spec) == OTPPAD_CHKSUM_HEX_LEN) {
|
||||
strncpy(chksum, pad_spec, OTPPAD_CHKSUM_HEX_LEN);
|
||||
chksum[OTPPAD_CHKSUM_HEX_LEN] = '\0';
|
||||
/* Verify the file exists. */
|
||||
char path[OTP_PAD_PATH_MAX];
|
||||
snprintf(path, sizeof(path), "%s/%s.pad", pads_dir, chksum);
|
||||
if (access(path, R_OK) != 0) {
|
||||
fprintf(stderr, "otp_pad: pad file not found: %s\n", path);
|
||||
return 4;
|
||||
}
|
||||
} else {
|
||||
int r = resolve_pad_chksum(pads_dir, pad_spec, chksum);
|
||||
if (r == -1) {
|
||||
fprintf(stderr, "otp_pad: no pad matching prefix '%s' in %s\n",
|
||||
pad_spec, pads_dir);
|
||||
return 5;
|
||||
} else if (r == -2) {
|
||||
fprintf(stderr, "otp_pad: ambiguous pad prefix '%s' (multiple matches)\n",
|
||||
pad_spec);
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Build the pad path and open it read-only. */
|
||||
char pad_path[OTP_PAD_PATH_MAX];
|
||||
snprintf(pad_path, sizeof(pad_path), "%s/%s.pad", pads_dir, chksum);
|
||||
|
||||
FILE *fp = fopen(pad_path, "rb");
|
||||
if (!fp) {
|
||||
fprintf(stderr, "otp_pad: cannot open %s: %s\n", pad_path, strerror(errno));
|
||||
return 7;
|
||||
}
|
||||
|
||||
/* Determine pad size. */
|
||||
struct stat st;
|
||||
if (fstat(fileno(fp), &st) != 0) {
|
||||
fprintf(stderr, "otp_pad: fstat failed: %s\n", strerror(errno));
|
||||
fclose(fp);
|
||||
return 8;
|
||||
}
|
||||
if (st.st_size < (off_t)OTPPAD_HEADER_RESERVED) {
|
||||
fprintf(stderr, "otp_pad: pad too small (%lld bytes, need >= %d)\n",
|
||||
(long long)st.st_size, OTPPAD_HEADER_RESERVED);
|
||||
fclose(fp);
|
||||
return 9;
|
||||
}
|
||||
|
||||
/* Verify the pad checksum matches the filename. */
|
||||
char computed[OTPPAD_CHKSUM_HEX_LEN + 1];
|
||||
if (otppad_checksum(pad_path, computed) != 0) {
|
||||
fprintf(stderr, "otp_pad: checksum computation failed\n");
|
||||
fclose(fp);
|
||||
return 10;
|
||||
}
|
||||
if (strcmp(computed, chksum) != 0) {
|
||||
fprintf(stderr, "otp_pad: checksum mismatch (file says %s, computed %s)\n",
|
||||
chksum, computed);
|
||||
fclose(fp);
|
||||
return 11;
|
||||
}
|
||||
|
||||
/* Read the current offset. */
|
||||
uint64_t offset;
|
||||
if (otppad_state_read(pads_dir, chksum, &offset) != 0) {
|
||||
/* No state file — initialize at the reserved header. */
|
||||
offset = OTPPAD_HEADER_RESERVED;
|
||||
if (otppad_state_write(pads_dir, chksum, offset) != 0) {
|
||||
fprintf(stderr, "otp_pad: cannot write initial state file\n");
|
||||
fclose(fp);
|
||||
return 12;
|
||||
}
|
||||
}
|
||||
if (offset < OTPPAD_HEADER_RESERVED) {
|
||||
fprintf(stderr, "otp_pad: offset %llu < reserved header %d\n",
|
||||
(unsigned long long)offset, OTPPAD_HEADER_RESERVED);
|
||||
fclose(fp);
|
||||
return 13;
|
||||
}
|
||||
if (offset > (uint64_t)st.st_size) {
|
||||
fprintf(stderr, "otp_pad: offset %llu past end of pad (%lld)\n",
|
||||
(unsigned long long)offset, (long long)st.st_size);
|
||||
fclose(fp);
|
||||
return 14;
|
||||
}
|
||||
|
||||
/* Commit. */
|
||||
strncpy(g_otp_pad.pads_dir, pads_dir, OTP_PAD_DIR_MAX - 1);
|
||||
strncpy(g_otp_pad.chksum, chksum, OTP_PAD_CHKSUM_MAX - 1);
|
||||
strncpy(g_otp_pad.pad_path, pad_path, OTP_PAD_PATH_MAX - 1);
|
||||
g_otp_pad.pad_fp = fp;
|
||||
g_otp_pad.pad_size = (uint64_t)st.st_size;
|
||||
g_otp_pad.allow_blkback = allow_blkback;
|
||||
g_otp_pad.bound = 1;
|
||||
|
||||
fprintf(stderr, "otp_pad: bound pad %s (%llu bytes, offset=%llu)\n",
|
||||
chksum, (unsigned long long)st.st_size,
|
||||
(unsigned long long)offset);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void otp_pad_unbind(void) {
|
||||
if (g_otp_pad.pad_fp) {
|
||||
fclose(g_otp_pad.pad_fp);
|
||||
g_otp_pad.pad_fp = NULL;
|
||||
}
|
||||
if (g_otp_pad.scratch_data) {
|
||||
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
if (g_otp_pad.scratch_locked) {
|
||||
munlock(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
}
|
||||
free(g_otp_pad.scratch_data);
|
||||
g_otp_pad.scratch_data = NULL;
|
||||
g_otp_pad.scratch_size = 0;
|
||||
g_otp_pad.scratch_locked = 0;
|
||||
}
|
||||
secure_memzero(&g_otp_pad, sizeof(g_otp_pad));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Core encrypt/decrypt transform */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Ensure the scratch buffer is at least `size` bytes, mlock'd. */
|
||||
static int ensure_scratch(size_t size) {
|
||||
if (size > OTP_SCRATCH_MAX) return -1;
|
||||
if (g_otp_pad.scratch_size >= size && g_otp_pad.scratch_data) return 0;
|
||||
/* Grow. */
|
||||
if (g_otp_pad.scratch_data) {
|
||||
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
if (g_otp_pad.scratch_locked) {
|
||||
munlock(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
}
|
||||
free(g_otp_pad.scratch_data);
|
||||
g_otp_pad.scratch_data = NULL;
|
||||
g_otp_pad.scratch_size = 0;
|
||||
g_otp_pad.scratch_locked = 0;
|
||||
}
|
||||
g_otp_pad.scratch_data = malloc(size);
|
||||
if (!g_otp_pad.scratch_data) return -2;
|
||||
g_otp_pad.scratch_size = size;
|
||||
if (mlock(g_otp_pad.scratch_data, size) == 0) {
|
||||
g_otp_pad.scratch_locked = 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Read `len` bytes from the pad at `offset` into `out` (must be mlock'd by
|
||||
* caller). Returns 0 on success, non-zero on error. */
|
||||
static int read_pad_slice(uint64_t offset, size_t len, unsigned char *out) {
|
||||
if (!g_otp_pad.pad_fp) return -1;
|
||||
if (fseek(g_otp_pad.pad_fp, (long)offset, SEEK_SET) != 0) return -2;
|
||||
size_t got = fread(out, 1, len, g_otp_pad.pad_fp);
|
||||
if (got != len) return -3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public encrypt/decrypt entrypoints (called by dispatcher verbs) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* OTPPAD_ENCRYPT_OK etc. are returned via *out_result. */
|
||||
|
||||
/* Encrypt: takes plaintext bytes, returns malloc'd ASCII armor or binary blob.
|
||||
*
|
||||
* `encoding` is "ascii" or "binary".
|
||||
* On success returns 0 and sets *out_payload (malloc'd, caller frees),
|
||||
* *out_payload_len, and *out_new_offset.
|
||||
*/
|
||||
int otp_pad_encrypt(const unsigned char *plaintext, size_t pt_len,
|
||||
const char *encoding,
|
||||
char **out_payload, size_t *out_payload_len,
|
||||
uint64_t *out_new_offset) {
|
||||
if (!g_otp_pad.bound) return 1; /* not bound */
|
||||
if (!plaintext || !out_payload || !out_payload_len || !out_new_offset) return 2;
|
||||
*out_payload = NULL;
|
||||
*out_payload_len = 0;
|
||||
|
||||
/* Pad the plaintext. */
|
||||
size_t chunk = otppad_chunk_size(pt_len);
|
||||
if (ensure_scratch(chunk) != 0) return 3;
|
||||
unsigned char *buf = (unsigned char *)g_otp_pad.scratch_data;
|
||||
memcpy(buf, plaintext, pt_len);
|
||||
if (otppad_pad_apply(buf, pt_len, chunk) != 0) return 4;
|
||||
|
||||
/* Read current offset. */
|
||||
uint64_t offset;
|
||||
if (otppad_state_read(g_otp_pad.pads_dir, g_otp_pad.chksum, &offset) != 0) {
|
||||
return 5;
|
||||
}
|
||||
if (offset + chunk > g_otp_pad.pad_size) {
|
||||
return 6; /* pad exhausted */
|
||||
}
|
||||
|
||||
/* Read the pad slice into a second scratch buffer. */
|
||||
/* Reuse the same scratch: read pad into second half, XOR in place.
|
||||
* For simplicity, allocate a separate pad-slice buffer. */
|
||||
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
|
||||
if (!pad_slice) return 7;
|
||||
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
|
||||
free(pad_slice);
|
||||
return 8;
|
||||
}
|
||||
|
||||
/* XOR. */
|
||||
for (size_t i = 0; i < chunk; i++) {
|
||||
buf[i] ^= pad_slice[i];
|
||||
}
|
||||
secure_memzero(pad_slice, chunk);
|
||||
free(pad_slice);
|
||||
|
||||
/* Advance offset atomically. */
|
||||
uint64_t new_offset = offset + chunk;
|
||||
if (otppad_state_write(g_otp_pad.pads_dir, g_otp_pad.chksum, new_offset) != 0) {
|
||||
return 9;
|
||||
}
|
||||
|
||||
/* Encode output. */
|
||||
if (encoding && strcmp(encoding, "binary") == 0) {
|
||||
/* Binary .otp: header + encrypted (padded) data. */
|
||||
otppad_bin_header_t hdr;
|
||||
memset(&hdr, 0, sizeof(hdr));
|
||||
memcpy(hdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN);
|
||||
hdr.version = OTPPAD_FORMAT_VERSION;
|
||||
/* pad_chksum is binary 32 bytes — convert hex to bytes. */
|
||||
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
|
||||
unsigned int byte;
|
||||
sscanf(g_otp_pad.chksum + i * 2, "%02x", &byte);
|
||||
hdr.pad_chksum[i] = (unsigned char)byte;
|
||||
}
|
||||
hdr.pad_offset = offset;
|
||||
hdr.file_mode = 0644;
|
||||
hdr.file_size = pt_len; /* original (unpadded) size */
|
||||
|
||||
/* Build the blob in memory (avoid fmemopen — it can misbehave
|
||||
* with NUL bytes on some platforms). */
|
||||
size_t blob_size = 58 + chunk;
|
||||
unsigned char *blob = (unsigned char *)malloc(blob_size);
|
||||
if (!blob) return 10;
|
||||
unsigned char *p = blob;
|
||||
memcpy(p, OTPPAD_MAGIC, 4); p += 4;
|
||||
memcpy(p, &hdr.version, 2); p += 2;
|
||||
memcpy(p, hdr.pad_chksum, OTPPAD_CHKSUM_BIN_LEN); p += OTPPAD_CHKSUM_BIN_LEN;
|
||||
memcpy(p, &hdr.pad_offset, 8); p += 8;
|
||||
memcpy(p, &hdr.file_mode, 4); p += 4;
|
||||
memcpy(p, &hdr.file_size, 8); p += 8;
|
||||
/* p is now at byte 58. Copy the encrypted (padded) data. */
|
||||
memcpy(p, buf, chunk);
|
||||
*out_payload = (char *)blob;
|
||||
*out_payload_len = blob_size;
|
||||
} else {
|
||||
/* ASCII armor. */
|
||||
char *armor = NULL;
|
||||
if (otppad_armor_generate(NSIGNER_OTP_VERSION, g_otp_pad.chksum, offset,
|
||||
buf, chunk, &armor) != 0) {
|
||||
return 14;
|
||||
}
|
||||
*out_payload = armor;
|
||||
*out_payload_len = strlen(armor);
|
||||
}
|
||||
|
||||
*out_new_offset = new_offset;
|
||||
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Decrypt: takes ASCII armor or binary blob, returns malloc'd plaintext.
|
||||
*
|
||||
* `encoding` is "ascii" or "binary" (auto-detected if NULL).
|
||||
* On success returns 0 and sets *out_plaintext (malloc'd, caller frees),
|
||||
* *out_pt_len.
|
||||
*/
|
||||
int otp_pad_decrypt(const char *input, size_t input_len,
|
||||
const char *encoding,
|
||||
unsigned char **out_plaintext, size_t *out_pt_len) {
|
||||
if (!g_otp_pad.bound) return 1;
|
||||
if (!input || !out_plaintext || !out_pt_len) return 2;
|
||||
*out_plaintext = NULL;
|
||||
*out_pt_len = 0;
|
||||
|
||||
uint64_t offset;
|
||||
size_t chunk;
|
||||
unsigned char *ciphertext = NULL;
|
||||
size_t ct_len = 0;
|
||||
|
||||
int is_binary;
|
||||
if (encoding && strcmp(encoding, "binary") == 0) {
|
||||
is_binary = 1;
|
||||
} else if (encoding && strcmp(encoding, "ascii") == 0) {
|
||||
is_binary = 0;
|
||||
} else {
|
||||
/* Auto-detect by magic bytes. */
|
||||
is_binary = (input_len >= 4 && memcmp(input, OTPPAD_MAGIC, 4) == 0);
|
||||
}
|
||||
|
||||
if (!is_binary) {
|
||||
/* ASCII armor. */
|
||||
char chksum[OTPPAD_CHKSUM_HEX_LEN + 1];
|
||||
char b64[65536];
|
||||
if (otppad_armor_parse(input, chksum, &offset, b64, sizeof(b64)) != 0) {
|
||||
return 3;
|
||||
}
|
||||
if (strcmp(chksum, g_otp_pad.chksum) != 0) {
|
||||
return 4; /* pad mismatch */
|
||||
}
|
||||
int dlen = 0;
|
||||
ciphertext = otppad_base64_decode(b64, &dlen);
|
||||
if (!ciphertext) return 5;
|
||||
ct_len = (size_t)dlen;
|
||||
chunk = ct_len;
|
||||
} else {
|
||||
/* Binary .otp blob — parse directly from the buffer (avoid fmemopen
|
||||
* which can misbehave with NUL bytes on some platforms). */
|
||||
if (input_len < 58) return 6;
|
||||
const unsigned char *p = (const unsigned char *)input;
|
||||
otppad_bin_header_t hdr;
|
||||
memset(&hdr, 0, sizeof(hdr));
|
||||
memcpy(hdr.magic, p, 4); p += 4;
|
||||
memcpy(&hdr.version, p, 2); p += 2;
|
||||
memcpy(hdr.pad_chksum, p, OTPPAD_CHKSUM_BIN_LEN); p += OTPPAD_CHKSUM_BIN_LEN;
|
||||
memcpy(&hdr.pad_offset, p, 8); p += 8;
|
||||
memcpy(&hdr.file_mode, p, 4); p += 4;
|
||||
memcpy(&hdr.file_size, p, 8); p += 8;
|
||||
/* p is now at byte 58. */
|
||||
if (memcmp(hdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN) != 0) {
|
||||
return 8;
|
||||
}
|
||||
/* Convert binary chksum to hex for comparison. */
|
||||
char chksum_hex[OTPPAD_CHKSUM_HEX_LEN + 1];
|
||||
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
|
||||
sprintf(chksum_hex + i * 2, "%02x", hdr.pad_chksum[i]);
|
||||
}
|
||||
chksum_hex[OTPPAD_CHKSUM_HEX_LEN] = '\0';
|
||||
if (strcmp(chksum_hex, g_otp_pad.chksum) != 0) {
|
||||
return 9;
|
||||
}
|
||||
offset = hdr.pad_offset;
|
||||
/* Remaining bytes after the 58-byte header are the ciphertext. */
|
||||
ct_len = input_len - 58;
|
||||
chunk = ct_len;
|
||||
ciphertext = (unsigned char *)malloc(ct_len);
|
||||
if (!ciphertext) return 10;
|
||||
memcpy(ciphertext, p, ct_len);
|
||||
}
|
||||
|
||||
if (ensure_scratch(chunk) != 0) {
|
||||
free(ciphertext); return 12;
|
||||
}
|
||||
unsigned char *buf = (unsigned char *)g_otp_pad.scratch_data;
|
||||
|
||||
/* Read pad slice. */
|
||||
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
|
||||
if (!pad_slice) { free(ciphertext); return 13; }
|
||||
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
|
||||
free(pad_slice); free(ciphertext); return 14;
|
||||
}
|
||||
|
||||
/* XOR (in-place into scratch). */
|
||||
for (size_t i = 0; i < chunk; i++) {
|
||||
buf[i] = ciphertext[i] ^ pad_slice[i];
|
||||
}
|
||||
secure_memzero(pad_slice, chunk);
|
||||
free(pad_slice);
|
||||
secure_memzero(ciphertext, ct_len);
|
||||
free(ciphertext);
|
||||
|
||||
/* Strip padding. */
|
||||
size_t pt_len;
|
||||
if (otppad_pad_remove(buf, chunk, &pt_len) != 0) {
|
||||
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
return 15;
|
||||
}
|
||||
|
||||
/* Return plaintext. */
|
||||
unsigned char *pt = (unsigned char *)malloc(pt_len ? pt_len : 1);
|
||||
if (!pt) {
|
||||
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
return 16;
|
||||
}
|
||||
memcpy(pt, buf, pt_len);
|
||||
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
|
||||
*out_plaintext = pt;
|
||||
*out_pt_len = pt_len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers for the dispatcher */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
uint64_t otp_pad_current_offset(void) {
|
||||
if (!g_otp_pad.bound) return 0;
|
||||
uint64_t off;
|
||||
if (otppad_state_read(g_otp_pad.pads_dir, g_otp_pad.chksum, &off) != 0) {
|
||||
return 0;
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
uint64_t otp_pad_size(void) {
|
||||
return g_otp_pad.bound ? g_otp_pad.pad_size : 0;
|
||||
}
|
||||
76
src/otp_pad.h
Normal file
76
src/otp_pad.h
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* otp_pad.h — OTP pad state for n_signer (one pad per session).
|
||||
*
|
||||
* Bound at startup via otp_pad_bind(); accessed by the otp_encrypt /
|
||||
* otp_decrypt dispatcher verbs. See plans/otp_nostr_integration.md.
|
||||
*/
|
||||
#ifndef NSIGNER_OTP_PAD_H
|
||||
#define NSIGNER_OTP_PAD_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Version string stamped into ASCII-armored output. */
|
||||
#define NSIGNER_OTP_VERSION "v0.0.2-otp"
|
||||
|
||||
/* Returns 1 if a pad has been bound at startup, 0 otherwise. */
|
||||
int otp_pad_is_bound(void);
|
||||
|
||||
/* Return the bound pad's 64-char hex checksum, or NULL if unbound. */
|
||||
const char *otp_pad_chksum(void);
|
||||
|
||||
/* Return the bound pad's directory, or NULL if unbound. */
|
||||
const char *otp_pad_dir(void);
|
||||
|
||||
/* Current offset (bytes consumed) of the bound pad, or 0 if unbound. */
|
||||
uint64_t otp_pad_current_offset(void);
|
||||
|
||||
/* Total size in bytes of the bound pad, or 0 if unbound. */
|
||||
uint64_t otp_pad_size(void);
|
||||
|
||||
/*
|
||||
* Bind a pad at startup. Returns 0 on success, non-zero on error.
|
||||
* `pads_dir` is the directory containing <chksum>.pad and <chksum>.state.
|
||||
* `pad_spec` is a full 64-char chksum or a unique prefix.
|
||||
* `allow_blkback` non-zero skips the blkback guard (for qvm-block testing).
|
||||
*/
|
||||
int otp_pad_bind(const char *pads_dir, const char *pad_spec, int allow_blkback);
|
||||
|
||||
/* Unbind and zeroize all pad state. Idempotent. */
|
||||
void otp_pad_unbind(void);
|
||||
|
||||
/*
|
||||
* Encrypt plaintext bytes with the bound pad.
|
||||
*
|
||||
* `encoding` is "ascii" (default) or "binary".
|
||||
* On success returns 0 and sets:
|
||||
* *out_payload — malloc'd, caller frees (NUL-terminated for ascii)
|
||||
* *out_payload_len — length of payload
|
||||
* *out_new_offset — pad offset after this encryption
|
||||
*/
|
||||
int otp_pad_encrypt(const unsigned char *plaintext, size_t pt_len,
|
||||
const char *encoding,
|
||||
char **out_payload, size_t *out_payload_len,
|
||||
uint64_t *out_new_offset);
|
||||
|
||||
/*
|
||||
* Decrypt a ciphertext (ASCII armor or binary .otp blob) with the bound pad.
|
||||
*
|
||||
* `encoding` is "ascii", "binary", or NULL (auto-detect by magic bytes).
|
||||
* On success returns 0 and sets:
|
||||
* *out_plaintext — malloc'd, caller frees
|
||||
* *out_pt_len — length of plaintext
|
||||
*/
|
||||
int otp_pad_decrypt(const char *input, size_t input_len,
|
||||
const char *encoding,
|
||||
unsigned char **out_plaintext, size_t *out_pt_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* NSIGNER_OTP_PAD_H */
|
||||
@@ -631,7 +631,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
@@ -671,7 +671,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
@@ -634,7 +634,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
@@ -633,7 +633,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
@@ -631,7 +631,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
73
src/server.c
73
src/server.c
@@ -5,6 +5,7 @@
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include <cJSON.h>
|
||||
#include "http_listener.h"
|
||||
|
||||
/* from secure_mem.h */
|
||||
|
||||
@@ -644,12 +645,13 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
#define NSIGNER_LISTEN_UNIX 0
|
||||
#define NSIGNER_LISTEN_STDIO 1
|
||||
#define NSIGNER_LISTEN_QREXEC 2
|
||||
#define NSIGNER_LISTEN_TCP 3
|
||||
#define NSIGNER_LISTEN_HTTP 4
|
||||
|
||||
#define NSIGNER_AUTH_OFF 0
|
||||
#define NSIGNER_AUTH_OPTIONAL 1
|
||||
@@ -1449,13 +1451,23 @@ int server_start(server_ctx_t *ctx) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_TCP ||
|
||||
ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
|
||||
int family;
|
||||
uint16_t port;
|
||||
char host[64];
|
||||
int one = 1;
|
||||
|
||||
int prc = parse_tcp_target(ctx->socket_name, &family, host, sizeof(host), &port);
|
||||
/* HTTP mode uses the same TCP socket setup, just with an "http:" prefix
|
||||
* instead of "tcp:". Convert to tcp: for parse_tcp_target. */
|
||||
char tcp_target[128];
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
|
||||
snprintf(tcp_target, sizeof(tcp_target), "tcp:%s", ctx->socket_name + 5);
|
||||
} else {
|
||||
snprintf(tcp_target, sizeof(tcp_target), "%s", ctx->socket_name);
|
||||
}
|
||||
|
||||
int prc = parse_tcp_target(tcp_target, &family, host, sizeof(host), &port);
|
||||
if (prc != 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
@@ -1775,6 +1787,14 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
/* Set client fd to blocking mode (the listen socket is non-blocking,
|
||||
* and accept() may inherit that flag on some platforms). */
|
||||
{
|
||||
int cflags = fcntl(client_fd, F_GETFL, 0);
|
||||
if (cflags >= 0) {
|
||||
(void)fcntl(client_fd, F_SETFL, cflags & ~O_NONBLOCK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (server_get_caller(client_fd, &caller) != 0) {
|
||||
@@ -1784,6 +1804,12 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* HTTP mode: override caller kind so auth envelopes are not required
|
||||
* (HTTP relies on localhost binding + policy/approval prompts). */
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
|
||||
caller.kind = NSIGNER_LISTEN_HTTP;
|
||||
}
|
||||
|
||||
/*
|
||||
* Bridge-source-trusted path: when the unix listener is marked as a
|
||||
* trusted bridge socket (started with --bridge-source-trusted), each
|
||||
@@ -1839,7 +1865,30 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
cJSON_Delete(proot);
|
||||
}
|
||||
|
||||
if (transport_recv_framed(client_fd, &request, SERVER_MAX_MSG_SIZE) != 0) {
|
||||
int is_http = (ctx->listen_mode == NSIGNER_LISTEN_HTTP);
|
||||
|
||||
if (is_http) {
|
||||
/* HTTP mode: parse the HTTP request to get the JSON body. */
|
||||
int http_rc = http_recv_request(client_fd, &request, SERVER_MAX_MSG_SIZE);
|
||||
if (http_rc == 0 && request != NULL && request[0] == '\0') {
|
||||
/* OPTIONS preflight — send CORS headers. */
|
||||
(void)http_send_cors_preflight(client_fd);
|
||||
free(request);
|
||||
close(client_fd);
|
||||
return 1;
|
||||
}
|
||||
if (http_rc != 0 || request == NULL) {
|
||||
if (http_rc == -2) {
|
||||
(void)http_send_error(client_fd, 405, "method_not_allowed");
|
||||
} else if (http_rc == -4) {
|
||||
(void)http_send_error(client_fd, 413, "payload_too_large");
|
||||
} else {
|
||||
(void)http_send_error(client_fd, 400, "bad_request");
|
||||
}
|
||||
if (client_fd != STDIN_FILENO) close(client_fd);
|
||||
return 1;
|
||||
}
|
||||
} else if (transport_recv_framed(client_fd, &request, SERVER_MAX_MSG_SIZE) != 0) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32700,\"message\":\"parse_error\"}}");
|
||||
if (response != NULL) {
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
@@ -1899,7 +1948,11 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
response = strdup(errbuf);
|
||||
}
|
||||
if (response != NULL) {
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
if (is_http) {
|
||||
(void)http_send_response(client_fd, response);
|
||||
} else {
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
}
|
||||
free(response);
|
||||
}
|
||||
free(request);
|
||||
@@ -2092,7 +2145,11 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
}
|
||||
|
||||
if (response != NULL) {
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
if (is_http) {
|
||||
(void)http_send_response(client_fd, response);
|
||||
} else {
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
}
|
||||
}
|
||||
|
||||
if (pchk == POLICY_ALLOW && policy_src == POLICY_SOURCE_PREAPPROVE) {
|
||||
@@ -2135,7 +2192,9 @@ void server_stop(server_ctx_t *ctx) {
|
||||
}
|
||||
|
||||
if (ctx->listen_fd >= 0) {
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_UNIX || ctx->listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_UNIX ||
|
||||
ctx->listen_mode == NSIGNER_LISTEN_TCP ||
|
||||
ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
|
||||
close(ctx->listen_fd);
|
||||
}
|
||||
ctx->listen_fd = -1;
|
||||
|
||||
@@ -633,7 +633,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
|
||||
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
#define SERVER_MAX_MSG_SIZE 16777216
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
|
||||
BIN
tools/__pycache__/otp_roundtrip_test.cpython-313.pyc
Normal file
BIN
tools/__pycache__/otp_roundtrip_test.cpython-313.pyc
Normal file
Binary file not shown.
141
tools/http_test.py
Normal file
141
tools/http_test.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
http_test.py — test the HTTP listener mode with curl-like requests.
|
||||
|
||||
Starts nsigner in HTTP mode, sends requests via urllib, and verifies
|
||||
the responses.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
NSIGNER = "./build/nsigner"
|
||||
PAD_DIR = "/media/user/Music/pads"
|
||||
PAD_SPEC = "333e9902db839d9d"
|
||||
MNEMONIC_FILE = ".test_mnemonic"
|
||||
MNEMONIC_TMP = ".test_mnemonic_http.tmp"
|
||||
PORT = 11111
|
||||
|
||||
|
||||
def main():
|
||||
# Reset pad offset
|
||||
state_path = f"{PAD_DIR}/333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.state"
|
||||
with open(state_path, "w") as f:
|
||||
f.write("offset=32\n")
|
||||
|
||||
# Prepare mnemonic temp file
|
||||
with open(MNEMONIC_FILE) as f:
|
||||
mnemonic = f.read().strip()
|
||||
with open(MNEMONIC_TMP, "w") as f:
|
||||
f.write(mnemonic + "\n")
|
||||
|
||||
# Start nsigner in HTTP mode
|
||||
shell_cmd = (
|
||||
f"exec 3<{MNEMONIC_TMP} 2>/dev/null; "
|
||||
f"exec {NSIGNER} --listen http:127.0.0.1:{PORT} --mnemonic-fd 3 "
|
||||
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
|
||||
f"--otp-allow-blkback --allow-all"
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c", shell_cmd],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(2.0) # let the signer start
|
||||
|
||||
if proc.poll() is not None:
|
||||
err = proc.stderr.read().decode()
|
||||
print(f"ERROR: nsigner exited early (code {proc.returncode})")
|
||||
print(f"stderr: {err}")
|
||||
try:
|
||||
os.unlink(MNEMONIC_TMP)
|
||||
except OSError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
try:
|
||||
url = f"http://127.0.0.1:{PORT}/"
|
||||
|
||||
# Test 1: get_public_key
|
||||
print("=== Test 1: get_public_key via HTTP ===")
|
||||
req_data = json.dumps({
|
||||
"id": "1",
|
||||
"method": "get_public_key",
|
||||
"params": [{"role": "main"}],
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=req_data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
print(f"Response: {json.dumps(result)[:120]}...")
|
||||
if "result" not in result:
|
||||
print("ERROR: no result in get_public_key response")
|
||||
return 1
|
||||
pubkey = result["result"].strip('"')
|
||||
print(f"Public key: {pubkey}")
|
||||
|
||||
# Test 2: otp_encrypt
|
||||
print("\n=== Test 2: otp_encrypt via HTTP ===")
|
||||
import base64
|
||||
pt_b64 = base64.b64encode(b"Hello, OTP via HTTP!").decode()
|
||||
req_data = json.dumps({
|
||||
"id": "2",
|
||||
"method": "otp_encrypt",
|
||||
"params": [pt_b64, {"encoding": "ascii"}],
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=req_data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
print(f"Response: {json.dumps(result)[:200]}...")
|
||||
if "result" not in result:
|
||||
print("ERROR: no result in otp_encrypt response")
|
||||
return 1
|
||||
enc_result = json.loads(result["result"])
|
||||
ciphertext = enc_result["ciphertext"]
|
||||
print(f"Pad offset: {enc_result['pad_offset_before']} -> {enc_result['pad_offset_after']}")
|
||||
print(f"Ciphertext (first 60 chars): {ciphertext[:60]}...")
|
||||
|
||||
# Test 3: otp_decrypt
|
||||
print("\n=== Test 3: otp_decrypt via HTTP ===")
|
||||
req_data = json.dumps({
|
||||
"id": "3",
|
||||
"method": "otp_decrypt",
|
||||
"params": [ciphertext, {"encoding": "ascii"}],
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=req_data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
print(f"Response: {json.dumps(result)[:200]}...")
|
||||
if "result" not in result:
|
||||
print("ERROR: no result in otp_decrypt response")
|
||||
return 1
|
||||
dec_result = json.loads(result["result"])
|
||||
recovered = base64.b64decode(dec_result["plaintext"]).decode()
|
||||
print(f"Recovered plaintext: {recovered}")
|
||||
|
||||
if recovered == "Hello, OTP via HTTP!":
|
||||
print("\n=== HTTP ROUND-TRIP SUCCESS ===")
|
||||
return 0
|
||||
else:
|
||||
print("\n=== HTTP ROUND-TRIP FAILED ===")
|
||||
print(f"Expected: Hello, OTP via HTTP!")
|
||||
print(f"Got: {recovered}")
|
||||
return 1
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
try:
|
||||
os.unlink(MNEMONIC_TMP)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
tools/make_test_pad
Executable file
BIN
tools/make_test_pad
Executable file
Binary file not shown.
228
tools/make_test_pad.c
Normal file
228
tools/make_test_pad.c
Normal file
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* make_test_pad.c — generate a small test OTP pad on a target directory.
|
||||
*
|
||||
* Bit-compatible with the `otp` project's pad format:
|
||||
* - <chksum>.pad : raw random bytes, first 32 bytes are the "reserved header"
|
||||
* (also used as the key to encrypt the checksum).
|
||||
* - <chksum>.state : text file "offset=32\n" (32-byte header reserved).
|
||||
* - chksum is the 64-hex-char XOR checksum as computed by
|
||||
* otp/src/crypto.c:calculate_checksum (position-dependent XOR folded into
|
||||
* 32 buckets, then XORed with the first 32 pad bytes).
|
||||
*
|
||||
* Usage:
|
||||
* make_test_pad <pad_dir> <size_bytes>
|
||||
*
|
||||
* Example:
|
||||
* make_test_pad /media/user/USBDISK/pads 1048576
|
||||
*
|
||||
* Entropy source: /dev/urandom (local-entropy test path only — NOT for production
|
||||
* pads; production pads should use the `otp` CLI with keyboard/TRNG entropy or a
|
||||
* future hardware signer).
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
|
||||
#define CHKSUM_HEX_LEN 64
|
||||
#define CHKSUM_BIN_LEN 32
|
||||
#define HEADER_RESERVED 32
|
||||
#define BUF_SIZE (64 * 1024)
|
||||
|
||||
static int compute_checksum(const char *pad_path, char *checksum_hex) {
|
||||
FILE *file = fopen(pad_path, "rb");
|
||||
if (!file) {
|
||||
fprintf(stderr, "compute_checksum: cannot open %s: %s\n",
|
||||
pad_path, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
|
||||
unsigned char checksum[CHKSUM_BIN_LEN];
|
||||
unsigned char buffer[BUF_SIZE];
|
||||
size_t bytes_read;
|
||||
size_t total_bytes = 0;
|
||||
|
||||
memset(checksum, 0, CHKSUM_BIN_LEN);
|
||||
|
||||
while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0) {
|
||||
for (size_t i = 0; i < bytes_read; i++) {
|
||||
size_t pos = total_bytes + i;
|
||||
unsigned char bucket = (unsigned char)(pos % CHKSUM_BIN_LEN);
|
||||
checksum[bucket] ^= buffer[i] ^
|
||||
(unsigned char)((pos >> 8) & 0xFF) ^
|
||||
(unsigned char)((pos >> 16) & 0xFF) ^
|
||||
(unsigned char)((pos >> 24) & 0xFF);
|
||||
}
|
||||
total_bytes += bytes_read;
|
||||
}
|
||||
fclose(file);
|
||||
|
||||
/* XOR the checksum with the first 32 bytes of the pad (the "pad key"). */
|
||||
file = fopen(pad_path, "rb");
|
||||
if (!file) {
|
||||
fprintf(stderr, "compute_checksum: cannot reopen %s: %s\n",
|
||||
pad_path, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
unsigned char pad_key[CHKSUM_BIN_LEN];
|
||||
if (fread(pad_key, 1, CHKSUM_BIN_LEN, file) != CHKSUM_BIN_LEN) {
|
||||
fprintf(stderr, "compute_checksum: pad too small for header key\n");
|
||||
fclose(file);
|
||||
return 1;
|
||||
}
|
||||
fclose(file);
|
||||
|
||||
unsigned char encrypted_checksum[CHKSUM_BIN_LEN];
|
||||
for (int i = 0; i < CHKSUM_BIN_LEN; i++) {
|
||||
encrypted_checksum[i] = checksum[i] ^ pad_key[i];
|
||||
}
|
||||
|
||||
for (int i = 0; i < CHKSUM_BIN_LEN; i++) {
|
||||
sprintf(checksum_hex + (i * 2), "%02x", encrypted_checksum[i]);
|
||||
}
|
||||
checksum_hex[CHKSUM_HEX_LEN] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int write_random(const char *path, size_t size) {
|
||||
int urand = open("/dev/urandom", O_RDONLY);
|
||||
if (urand < 0) {
|
||||
fprintf(stderr, "cannot open /dev/urandom: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int out = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (out < 0) {
|
||||
fprintf(stderr, "cannot create %s: %s\n", path, strerror(errno));
|
||||
close(urand);
|
||||
return 1;
|
||||
}
|
||||
|
||||
unsigned char buffer[BUF_SIZE];
|
||||
size_t written = 0;
|
||||
while (written < size) {
|
||||
size_t chunk = size - written;
|
||||
if (chunk > sizeof(buffer)) chunk = sizeof(buffer);
|
||||
|
||||
ssize_t got = 0;
|
||||
while ((size_t)got < chunk) {
|
||||
ssize_t r = read(urand, buffer + got, chunk - (size_t)got);
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
fprintf(stderr, "read urandom failed: %s\n", strerror(errno));
|
||||
close(out);
|
||||
close(urand);
|
||||
return 1;
|
||||
}
|
||||
if (r == 0) {
|
||||
fprintf(stderr, "urandom EOF (unexpected)\n");
|
||||
close(out);
|
||||
close(urand);
|
||||
return 1;
|
||||
}
|
||||
got += r;
|
||||
}
|
||||
|
||||
ssize_t put = 0;
|
||||
while (put < got) {
|
||||
ssize_t w = write(out, buffer + put, (size_t)got - (size_t)put);
|
||||
if (w < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
fprintf(stderr, "write %s failed: %s\n", path, strerror(errno));
|
||||
close(out);
|
||||
close(urand);
|
||||
return 1;
|
||||
}
|
||||
put += w;
|
||||
}
|
||||
written += (size_t)got;
|
||||
}
|
||||
|
||||
close(out);
|
||||
close(urand);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
if (argc != 3) {
|
||||
fprintf(stderr, "Usage: %s <pad_dir> <size_bytes>\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *pad_dir = argv[1];
|
||||
size_t size = (size_t)strtoull(argv[2], NULL, 10);
|
||||
if (size < 64) {
|
||||
fprintf(stderr, "size must be at least 64 bytes\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Ensure pad_dir exists. */
|
||||
struct stat st;
|
||||
if (stat(pad_dir, &st) != 0) {
|
||||
if (mkdir(pad_dir, 0755) != 0) {
|
||||
fprintf(stderr, "cannot create %s: %s\n", pad_dir, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
} else if (!S_ISDIR(st.st_mode)) {
|
||||
fprintf(stderr, "%s exists but is not a directory\n", pad_dir);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Write the pad to a temporary name first, then rename by checksum. */
|
||||
char tmp_path[1024];
|
||||
snprintf(tmp_path, sizeof(tmp_path), "%s/.tmp_pad_XXXXXX", pad_dir);
|
||||
/* mkstemp would be cleaner, but we want a stable name for the rename. */
|
||||
int tfd = mkstemp(tmp_path);
|
||||
if (tfd < 0) {
|
||||
fprintf(stderr, "mkstemp failed: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
close(tfd);
|
||||
|
||||
if (write_random(tmp_path, size) != 0) {
|
||||
unlink(tmp_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
char chksum[CHKSUM_HEX_LEN + 1];
|
||||
if (compute_checksum(tmp_path, chksum) != 0) {
|
||||
unlink(tmp_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
char final_path[1024];
|
||||
char state_path[1024];
|
||||
snprintf(final_path, sizeof(final_path), "%s/%s.pad", pad_dir, chksum);
|
||||
snprintf(state_path, sizeof(state_path), "%s/%s.state", pad_dir, chksum);
|
||||
|
||||
if (rename(tmp_path, final_path) != 0) {
|
||||
fprintf(stderr, "rename %s -> %s failed: %s\n",
|
||||
tmp_path, final_path, strerror(errno));
|
||||
unlink(tmp_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Write initial state file: offset=32 (header reserved). */
|
||||
FILE *state = fopen(state_path, "w");
|
||||
if (!state) {
|
||||
fprintf(stderr, "cannot create %s: %s\n", state_path, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
fprintf(state, "offset=%d\n", HEADER_RESERVED);
|
||||
fclose(state);
|
||||
|
||||
printf("Created test pad:\n");
|
||||
printf(" pad: %s\n", final_path);
|
||||
printf(" state: %s\n", state_path);
|
||||
printf(" size: %zu bytes\n", size);
|
||||
printf(" chksum: %s\n", chksum);
|
||||
printf(" chksum prefix (16 chars): %.16s\n", chksum);
|
||||
return 0;
|
||||
}
|
||||
213
tools/otp_roundtrip_test.py
Normal file
213
tools/otp_roundtrip_test.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
otp_roundtrip_test.py — end-to-end test of otp_encrypt / otp_decrypt verbs.
|
||||
|
||||
Sends framed JSON-RPC requests to nsigner --listen stdio and checks the
|
||||
round-trip: plaintext -> otp_encrypt -> otp_decrypt -> recovered plaintext.
|
||||
|
||||
Framing: 4-byte big-endian length prefix + JSON payload.
|
||||
|
||||
Usage: python3 tools/otp_roundtrip_test.py
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
NSIGNER = "./build/nsigner"
|
||||
PAD_DIR = "/media/user/Music/pads"
|
||||
PAD_SPEC = "333e9902db839d9d"
|
||||
MNEMONIC_FILE = ".test_mnemonic"
|
||||
|
||||
|
||||
def send_framed(proc, obj):
|
||||
payload = json.dumps(obj).encode()
|
||||
proc.stdin.write(struct.pack(">I", len(payload)))
|
||||
proc.stdin.write(payload)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def recv_framed(proc):
|
||||
"""Read a framed response, skipping any banner text the signer writes
|
||||
to stdout before the first frame. The banner is line-based ASCII; a
|
||||
valid frame starts with a 4-byte big-endian length followed by '{'."""
|
||||
# Read 4 bytes at a time, sliding window, until we find a frame header.
|
||||
buf = b""
|
||||
while True:
|
||||
b = proc.stdout.read(1)
|
||||
if not b:
|
||||
return None
|
||||
buf = (buf + b)[-4:]
|
||||
if len(buf) < 4:
|
||||
continue
|
||||
(length,) = struct.unpack(">I", buf)
|
||||
# Sanity: frame length should be reasonable (1..1MB) and the next
|
||||
# byte after the header should be '{' (start of JSON).
|
||||
if 1 <= length <= 1024 * 1024:
|
||||
# Peek: read one more byte to check for '{'.
|
||||
peek = proc.stdout.read(1)
|
||||
if peek == b'{':
|
||||
body = peek + proc.stdout.read(length - 1)
|
||||
return json.loads(body.decode())
|
||||
else:
|
||||
# Not a frame; prepend peek to the stream by including it
|
||||
# in the sliding window.
|
||||
buf = (buf + peek)[-4:]
|
||||
# Otherwise keep scanning.
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(NSIGNER):
|
||||
print(f"ERROR: {NSIGNER} not found. Run 'make dev' first.")
|
||||
return 1
|
||||
|
||||
with open(MNEMONIC_FILE) as f:
|
||||
mnemonic = f.read().strip()
|
||||
|
||||
plaintext = b"Hello, OTP world!"
|
||||
pt_b64 = base64.b64encode(plaintext).decode()
|
||||
print(f"Plaintext: {plaintext.decode()}")
|
||||
print(f"Plaintext base64: {pt_b64}")
|
||||
|
||||
# Write the mnemonic to a fixed temp file and pass it as fd 3 to nsigner
|
||||
# via a bash wrapper (so stdin stays free for framed requests).
|
||||
mnem_path = ".test_mnemonic_otp_roundtrip.tmp"
|
||||
with open(mnem_path, "w") as f:
|
||||
f.write(mnemonic + "\n")
|
||||
|
||||
def run_one_request(req_obj):
|
||||
"""Run nsigner in stdio mode for a single framed request/response.
|
||||
Returns the parsed JSON response or None."""
|
||||
shell_cmd = (
|
||||
f"exec 3<{mnem_path}; "
|
||||
f"exec {NSIGNER} --listen stdio --mnemonic-fd 3 "
|
||||
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
|
||||
f"--otp-allow-blkback --allow-all"
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c", shell_cmd],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
)
|
||||
time.sleep(1.5) # let the signer bind the pad and be ready
|
||||
if proc.poll() is not None:
|
||||
err = proc.stderr.read().decode()
|
||||
print(f"ERROR: nsigner exited early (code {proc.returncode})")
|
||||
print(f"stderr: {err}")
|
||||
return None
|
||||
send_framed(proc, req_obj)
|
||||
resp = recv_framed(proc)
|
||||
proc.stdin.close()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return resp
|
||||
|
||||
# --- otp_encrypt ---
|
||||
print("\n=== Sending otp_encrypt ===")
|
||||
resp = run_one_request({
|
||||
"id": "1",
|
||||
"method": "otp_encrypt",
|
||||
"params": [pt_b64, {"encoding": "ascii"}],
|
||||
})
|
||||
print(f"Encrypt response: {json.dumps(resp)}")
|
||||
|
||||
if resp is None or "result" not in resp:
|
||||
print("ERROR: no result in encrypt response")
|
||||
try:
|
||||
os.unlink(mnem_path)
|
||||
except OSError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
result_obj = json.loads(resp["result"])
|
||||
ciphertext = result_obj["ciphertext"]
|
||||
off_before = result_obj["pad_offset_before"]
|
||||
off_after = result_obj["pad_offset_after"]
|
||||
print(f"Pad offset: {off_before} -> {off_after} "
|
||||
f"(consumed {off_after - off_before} bytes)")
|
||||
print(f"Ciphertext (first 80 chars): {ciphertext[:80]}...")
|
||||
|
||||
# --- otp_decrypt (separate invocation; offset persists in .state) ---
|
||||
print("\n=== Sending otp_decrypt ===")
|
||||
resp2 = run_one_request({
|
||||
"id": "2",
|
||||
"method": "otp_decrypt",
|
||||
"params": [ciphertext, {"encoding": "ascii"}],
|
||||
})
|
||||
print(f"Decrypt response: {json.dumps(resp2)}")
|
||||
|
||||
if resp2 is None or "result" not in resp2:
|
||||
print("ERROR: no result in decrypt response")
|
||||
return 1
|
||||
|
||||
result2 = json.loads(resp2["result"])
|
||||
recovered_b64 = result2["plaintext"]
|
||||
recovered = base64.b64decode(recovered_b64)
|
||||
print(f"\nRecovered plaintext: {recovered.decode()}")
|
||||
|
||||
if recovered == plaintext:
|
||||
print("\n=== ASCII ROUND-TRIP SUCCESS ===")
|
||||
else:
|
||||
print("\n=== ASCII ROUND-TRIP FAILED ===")
|
||||
print(f"Expected: {plaintext.decode()}")
|
||||
print(f"Got: {recovered.decode()}")
|
||||
return 1
|
||||
|
||||
# --- Binary encoding round-trip ---
|
||||
# Reset the pad offset for a clean binary test.
|
||||
state_path = (f"{PAD_DIR}/{result_obj['pad_chksum']}.state")
|
||||
with open(state_path, "w") as sf:
|
||||
sf.write("offset=32\n")
|
||||
|
||||
print("\n=== Sending otp_encrypt (binary) ===")
|
||||
resp3 = run_one_request({
|
||||
"id": "3",
|
||||
"method": "otp_encrypt",
|
||||
"params": [pt_b64, {"encoding": "binary"}],
|
||||
})
|
||||
print(f"Binary encrypt response: {json.dumps(resp3)}")
|
||||
if resp3 is None or "result" not in resp3:
|
||||
print("ERROR: no result in binary encrypt response")
|
||||
return 1
|
||||
result3 = json.loads(resp3["result"])
|
||||
bin_b64 = result3["ciphertext"]
|
||||
# The binary ciphertext is base64-encoded in the JSON result.
|
||||
bin_blob = base64.b64decode(bin_b64)
|
||||
print(f"Binary blob size: {len(bin_blob)} bytes "
|
||||
f"(header 58 + padded data {len(bin_blob) - 58})")
|
||||
if not bin_blob[:4] == b"OTP\0":
|
||||
print("ERROR: binary blob missing OTP magic")
|
||||
return 1
|
||||
|
||||
print("\n=== Sending otp_decrypt (binary) ===")
|
||||
resp4 = run_one_request({
|
||||
"id": "4",
|
||||
"method": "otp_decrypt",
|
||||
"params": [bin_b64, {"encoding": "binary"}],
|
||||
})
|
||||
print(f"Binary decrypt response: {json.dumps(resp4)}")
|
||||
if resp4 is None or "result" not in resp4:
|
||||
print("ERROR: no result in binary decrypt response")
|
||||
return 1
|
||||
result4 = json.loads(resp4["result"])
|
||||
recovered2 = base64.b64decode(result4["plaintext"])
|
||||
print(f"\nRecovered plaintext (binary path): {recovered2.decode()}")
|
||||
|
||||
if recovered2 == plaintext:
|
||||
print("\n=== BINARY ROUND-TRIP SUCCESS ===")
|
||||
return 0
|
||||
else:
|
||||
print("\n=== BINARY ROUND-TRIP FAILED ===")
|
||||
print(f"Expected: {plaintext.decode()}")
|
||||
print(f"Got: {recovered2.decode()}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
94
tools/otp_roundtrip_test.sh
Normal file
94
tools/otp_roundtrip_test.sh
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# otp_roundtrip_test.sh — end-to-end test of otp_encrypt / otp_decrypt verbs.
|
||||
#
|
||||
# Sends framed JSON-RPC requests to nsigner --listen stdio and checks the
|
||||
# round-trip: plaintext -> otp_encrypt -> otp_decrypt -> recovered plaintext.
|
||||
#
|
||||
# Usage: ./tools/otp_roundtrip_test.sh
|
||||
set -euo pipefail
|
||||
|
||||
NSIGNER="./build/nsigner"
|
||||
PAD_DIR="/media/user/Music/pads"
|
||||
PAD_SPEC="333e9902db839d9d"
|
||||
MNEMONIC_FILE=".test_mnemonic"
|
||||
|
||||
if [ ! -x "$NSIGNER" ]; then
|
||||
echo "ERROR: $NSIGNER not found. Run 'make dev' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Helper: send a framed JSON-RPC request to nsigner stdio and print the response.
|
||||
# Framing: 4-byte big-endian length prefix + JSON payload.
|
||||
send_request() {
|
||||
local json="$1"
|
||||
local len=${#json}
|
||||
# 4-byte big-endian length
|
||||
printf '\\x%02x\\x%02x\\x%02x\\x%02x' \
|
||||
$(( (len >> 24) & 0xff )) $(( (len >> 16) & 0xff )) \
|
||||
$(( (len >> 8) & 0xff )) $(( len & 0xff ))
|
||||
printf '%s' "$json"
|
||||
}
|
||||
|
||||
# Build the plaintext base64. "Hello, OTP world!" -> base64
|
||||
PLAINTEXT_B64=$(printf 'Hello, OTP world!' | base64)
|
||||
echo "Plaintext base64: $PLAINTEXT_B64"
|
||||
|
||||
# Build the encrypt request
|
||||
ENCRYPT_REQ=$(cat <<EOF
|
||||
{"id":"1","method":"otp_encrypt","params":["$PLAINTEXT_B64",{"encoding":"ascii"}]}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "=== Sending otp_encrypt ==="
|
||||
RESPONSE=$( (printf '\\x00\\x00\\x00\\x%02x' ${#ENCRYPT_REQ}; printf '%s' "$ENCRYPT_REQ") | \
|
||||
(exec 3<"$MNEMONIC_FILE"; "$NSIGNER" --listen stdio --mnemonic-fd 3 \
|
||||
--otp-pad-dir "$PAD_DIR" --otp-pad "$PAD_SPEC" --otp-allow-blkback --allow-all) 2>/dev/null \
|
||||
| tail -1 )
|
||||
|
||||
echo "Encrypt response: $RESPONSE"
|
||||
|
||||
# Extract the ciphertext field (rough parse — look for "ciphertext":"...")
|
||||
CIPHERTEXT=$(echo "$RESPONSE" | sed -n 's/.*"ciphertext":"\([^"]*\)".*/\1/p')
|
||||
if [ -z "$CIPHERTEXT" ]; then
|
||||
echo "ERROR: no ciphertext in response"
|
||||
exit 1
|
||||
fi
|
||||
echo "Ciphertext (first 80 chars): ${CIPHERTEXT:0:80}..."
|
||||
|
||||
# Build the decrypt request — the ciphertext is the ASCII armor (with newlines
|
||||
# escaped as \n in JSON). We need to properly JSON-escape it.
|
||||
# Use python to do the JSON encoding safely.
|
||||
DECRYPT_REQ=$(python3 -c "
|
||||
import json, sys
|
||||
ct = sys.argv[1]
|
||||
print(json.dumps({'id':'2','method':'otp_decrypt','params':[ct,{'encoding':'ascii'}]}))
|
||||
" "$CIPHERTEXT")
|
||||
|
||||
echo "=== Sending otp_decrypt ==="
|
||||
RESPONSE2=$( (printf '\\x00\\x00\\x00\\x%02x' ${#DECRYPT_REQ}; printf '%s' "$DECRYPT_REQ") | \
|
||||
(exec 3<"$MNEMONIC_FILE"; "$NSIGNER" --listen stdio --mnemonic-fd 3 \
|
||||
--otp-pad-dir "$PAD_DIR" --otp-pad "$PAD_SPEC" --otp-allow-blkback --allow-all) 2>/dev/null \
|
||||
| tail -1 )
|
||||
|
||||
echo "Decrypt response: $RESPONSE2"
|
||||
|
||||
# Extract and decode the recovered plaintext
|
||||
RECOVERED_B64=$(echo "$RESPONSE2" | sed -n 's/.*"plaintext":"\([^"]*\)".*/\1/p')
|
||||
if [ -z "$RECOVERED_B64" ]; then
|
||||
echo "ERROR: no plaintext in decrypt response"
|
||||
exit 1
|
||||
fi
|
||||
RECOVERED=$(echo "$RECOVERED_B64" | base64 -d 2>/dev/null)
|
||||
echo "Recovered plaintext: $RECOVERED"
|
||||
|
||||
if [ "$RECOVERED" = "Hello, OTP world!" ]; then
|
||||
echo ""
|
||||
echo "=== ROUND-TRIP SUCCESS ==="
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "=== ROUND-TRIP FAILED ==="
|
||||
echo "Expected: Hello, OTP world!"
|
||||
echo "Got: $RECOVERED"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user