v0.0.48 - Added OTP one-time pad encryption (otp_encrypt/otp_decrypt verbs), HTTP listener mode (--listen http:HOST:PORT), interactive OTP pad auto-scan on USB drives, raised SERVER_MAX_MSG_SIZE to 16MB, updated README with curl examples and current API documentation
This commit is contained in:
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.
|
||||
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 {
|
||||
|
||||
525
src/main.c
525
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 47
|
||||
#define NSIGNER_VERSION "v0.0.47"
|
||||
#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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -2374,7 +2780,8 @@ 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) {
|
||||
} 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",
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
69
src/server.c
69
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) {
|
||||
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,8 +2145,12 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
}
|
||||
|
||||
if (response != NULL) {
|
||||
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) {
|
||||
source_label = "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