Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9afbb8fcbd | ||
|
|
fa98de12e4 | ||
|
|
e459e98beb | ||
|
|
32151b3ffd | ||
|
|
75dddac223 | ||
|
|
697a79dc25 | ||
|
|
e65ed5c5d6 | ||
|
|
821245ac1d | ||
|
|
7cbefe13ec | ||
|
|
d7fb3787e6 | ||
|
|
f0e90e0ea6 | ||
|
|
86a97aee01 | ||
|
|
d7eb6b5ec0 | ||
|
|
e04196f1f1 | ||
|
|
0ea3e60391 | ||
|
|
ac2e6347a2 | ||
|
|
d7369646fb | ||
|
|
b1f8076b1d | ||
|
|
314f1c520a | ||
|
|
ea2f1cd78e | ||
|
|
9282e22b00 |
@@ -0,0 +1,199 @@
|
||||
---
|
||||
description: "Call n_signer to sign Nostr events, get public keys, encrypt/decrypt, and perform crypto operations across all transports (qrexec, unix socket, TCP, HTTP, USB/serial)."
|
||||
---
|
||||
|
||||
# n_signer Client Skill
|
||||
|
||||
This skill tells an agent how to call [`n_signer`](../README.md) — a hardware/software signing oracle that holds BIP-39 keys in locked memory. The signer may be running on the same machine (Unix socket), in another Qubes qube (qrexec), on a hardware device (USB/serial), or reachable over TCP/HTTP.
|
||||
|
||||
## 1. Transport overview
|
||||
|
||||
| Transport | Scope | Auth required | Best for |
|
||||
|-----------|-------|---------------|----------|
|
||||
| **qrexec** | Cross-qube (Qubes OS) | No (identity from `QREXEC_REMOTE_DOMAIN`) | Agents in caller qubes |
|
||||
| **Unix abstract socket** | Same machine | No (identity from `SO_PEERCRED`) | Local processes |
|
||||
| **TCP (FIPS mesh)** | Cross-qube or network | Yes (kind-27235 auth envelope) | Remote callers, FIPS networks |
|
||||
| **HTTP** | Cross-qube or network | Yes (kind-27235 auth envelope) | curl-friendly, REST clients |
|
||||
| **USB/serial** | Hardware signer (Feather, Teensy, CYD) | No (physical possession) | Embedded/air-gap |
|
||||
| **Stdio** | One-shot via pipe | No | Scripted one-off calls |
|
||||
|
||||
## 2. Wire protocol (all transports)
|
||||
|
||||
Every request/response uses **length-prefixed framing**:
|
||||
|
||||
```
|
||||
[4-byte big-endian payload length][UTF-8 JSON payload]
|
||||
```
|
||||
|
||||
The JSON payload is a JSON-RPC 2.0-style object:
|
||||
|
||||
```json
|
||||
{ "id": "<string>", "method": "<verb>", "params": [<arg0>, <arg1>, ..., {<options>}] }
|
||||
```
|
||||
|
||||
Response (success):
|
||||
```json
|
||||
{ "id": "<string>", "result": "<value>" }
|
||||
```
|
||||
|
||||
Response (error):
|
||||
```json
|
||||
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
|
||||
```
|
||||
|
||||
## 3. Transport-specific invocation
|
||||
|
||||
### 3.1 Qubes qrexec (easiest cross-qube)
|
||||
|
||||
```bash
|
||||
# Pipe framed JSON-RPC through qrexec-client-vm
|
||||
printf '\x00\x00\x00\x3f'"$(echo '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}')" | qrexec-client-vm <signer_qube> qubes.NsignerRpc | tail -c +5
|
||||
```
|
||||
|
||||
Python (stdlib only, zero deps):
|
||||
```python
|
||||
import json, struct, subprocess
|
||||
|
||||
def call_nsigner(target_qube, request):
|
||||
payload = json.dumps(request, separators=(",", ":")).encode()
|
||||
frame = struct.pack(">I", len(payload)) + payload
|
||||
proc = subprocess.Popen(
|
||||
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
out, _ = proc.communicate(frame)
|
||||
length = struct.unpack(">I", out[:4])[0]
|
||||
return json.loads(out[4:4+length])
|
||||
```
|
||||
|
||||
See [`client/demo_python.py`](../client/demo_python.py) for a full working demo (get_public_key → sign_event → nip44 → mine_event).
|
||||
|
||||
### 3.2 Local Unix abstract socket (same machine)
|
||||
|
||||
```bash
|
||||
# Find running signers
|
||||
nsigner list
|
||||
|
||||
# Connect via socat or the nsigner client subcommand
|
||||
nsigner client '<json>' --socket-name <name>
|
||||
```
|
||||
|
||||
C (using `nostr_core_lib`):
|
||||
```c
|
||||
nsigner_transport_t *t = nsigner_transport_open_unix("nsigner", 5000);
|
||||
nsigner_client_t *c = nsigner_client_new(t);
|
||||
cJSON *result = NULL;
|
||||
nsigner_client_call(c, "nostr_get_public_key", params, &result);
|
||||
```
|
||||
|
||||
See [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) and [`examples/sign_event_client.c`](../examples/sign_event_client.c).
|
||||
|
||||
### 3.3 TCP (FIPS mesh, cross-qube)
|
||||
|
||||
Requires a **kind-27235 auth envelope** (signed Nostr event proving caller identity).
|
||||
|
||||
Python (with `coincurve`):
|
||||
```python
|
||||
import hashlib, json, socket, struct, time
|
||||
from coincurve import PrivateKey
|
||||
|
||||
# Build auth envelope
|
||||
sk = PrivateKey(caller_privkey_bytes)
|
||||
pubkey_x = sk.public_key.format(compressed=False)[1:33].hex()
|
||||
body_hash = hashlib.sha256(json.dumps(params, separators=(",",":")).encode()).hexdigest()
|
||||
tags = [["nsigner_rpc","1"],["nsigner_method","get_public_key"],["nsigner_body_hash",body_hash]]
|
||||
serialized = json.dumps([0, pubkey_x, created_at, 27235, tags, content], separators=(",",":")).encode()
|
||||
event_id = hashlib.sha256(serialized).hexdigest()
|
||||
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00"*32).hex()
|
||||
|
||||
request = {"id":"1","method":"get_public_key","params":params,"auth":{"id":event_id,"pubkey":pubkey_x,"created_at":created_at,"kind":27235,"tags":tags,"content":"py-min","sig":sig}}
|
||||
```
|
||||
|
||||
See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js).
|
||||
|
||||
### 3.4 HTTP listener
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id":"1","method":"get_public_key","params":[],"auth":{...}}'
|
||||
```
|
||||
|
||||
### 3.5 USB/serial (hardware signers)
|
||||
|
||||
For Feather S3, Teensy 4.1, CYD ESP32, etc. — connect over USB CDC serial with the same framing.
|
||||
|
||||
See [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) and [`examples/feather_sign_event.py`](../examples/feather_sign_event.py).
|
||||
|
||||
## 4. Key verbs
|
||||
|
||||
| Verb | What it does | Params |
|
||||
|------|-------------|--------|
|
||||
| `get_public_key` | Get pubkey for algorithm+index | `[{"algorithm":"secp256k1","index":0}]` |
|
||||
| `nostr_get_public_key` | Get secp256k1 pubkey by nostr_index | `[{"nostr_index":0}]` |
|
||||
| `nostr_sign_event` | Sign a Nostr event | `["<event_json>",{"nostr_index":0}]` |
|
||||
| `sign` | Sign arbitrary bytes (any algorithm) | `["<hex>",{"algorithm":"ed25519","index":0}]` |
|
||||
| `nostr_nip44_encrypt` | NIP-44 encrypt | `["<peer_hex>","<plaintext>",{"nostr_index":0}]` |
|
||||
| `nostr_nip44_decrypt` | NIP-44 decrypt | `["<peer_hex>","<ciphertext>",{"nostr_index":0}]` |
|
||||
| `nostr_mine_event` | NIP-13 PoW mine + sign | `["<event_json>",{"nostr_index":0,"difficulty":4}]` |
|
||||
| `encapsulate` | ML-KEM-768 encapsulate | `["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]` |
|
||||
| `decapsulate` | ML-KEM-768 decapsulate | `["<ciphertext_hex>",{"algorithm":"ml-kem-768","index":0}]` |
|
||||
| `derive` | HMAC-SHA256(privkey, data) | `["<data>",{"algorithm":"secp256k1","index":0}]` |
|
||||
| `get_info` | Signer metadata | `[]` |
|
||||
|
||||
Full verb table: [`README.md §4.3`](../README.md#43-verbs)
|
||||
|
||||
## 5. Algorithms
|
||||
|
||||
| Algorithm | Key type | FIPS | Derivation path |
|
||||
|-----------|----------|------|-----------------|
|
||||
| `secp256k1` | Signature (Nostr) | — | `m/44'/1237'/<n>'/0/0` |
|
||||
| `ed25519` | Signature (SSH) | — | `m/44'/102001'/<n>'/0/0'` |
|
||||
| `x25519` | Key agreement (age) | — | `m/44'/102002'/<n>'/0/0'` |
|
||||
| `ml-dsa-65` | PQ signature | FIPS 204 | DRBG from seed |
|
||||
| `slh-dsa-128s` | PQ hash-based sig | FIPS 205 | DRBG from seed |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | DRBG from seed |
|
||||
| `otp` | One-time pad | — | USB pad, no derivation |
|
||||
|
||||
## 6. Publishing a Nostr event (end-to-end)
|
||||
|
||||
```python
|
||||
import json, struct, subprocess, time
|
||||
|
||||
def call_nsigner(target_qube, request):
|
||||
payload = json.dumps(request, separators=(",", ":")).encode()
|
||||
frame = struct.pack(">I", len(payload)) + payload
|
||||
proc = subprocess.Popen(
|
||||
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
out, _ = proc.communicate(frame)
|
||||
length = struct.unpack(">I", out[:4])[0]
|
||||
return json.loads(out[4:4+length])
|
||||
|
||||
# 1. Get pubkey
|
||||
pub = call_nsigner("nostr_signer", {"id":"1","method":"get_public_key","params":[{"nostr_index":0}]})["result"]
|
||||
|
||||
# 2. Build and sign event
|
||||
event = {"kind":1,"content":"Hello from my agent!","created_at":int(time.time()),"tags":[],"pubkey":pub}
|
||||
result = call_nsigner("nostr_signer", {"id":"2","method":"nostr_sign_event","params":[json.dumps(event),{"nostr_index":0}]})
|
||||
signed = json.loads(result["result"])
|
||||
|
||||
# 3. Broadcast to relay(s)
|
||||
# signed["id"] and signed["sig"] are now populated
|
||||
```
|
||||
|
||||
## 7. Reference files
|
||||
|
||||
| File | What it shows |
|
||||
|------|---------------|
|
||||
| [`client/demo_python.py`](../client/demo_python.py) | Full Python demo (qrexec, stdlib only) |
|
||||
| [`client/demo_javascript.js`](../client/demo_javascript.js) | Full Node.js demo (qrexec) |
|
||||
| [`client/demo_c99.c`](../client/demo_c99.c) | Full C99 demo (qrexec, nostr_core_lib) |
|
||||
| [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) | Minimal TCP/FIPS with auth envelope |
|
||||
| [`examples/get_pubkey_qrexec.c`](../examples/get_pubkey_qrexec.c) | Minimal qrexec in C |
|
||||
| [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) | Minimal TCP in C with auth envelope |
|
||||
| [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) | TCP/FIPS in Node.js |
|
||||
| [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) | qrexec in Node.js |
|
||||
| [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) | Full wire contract spec |
|
||||
| [`documents/AGENT_CLIENT.md`](../documents/AGENT_CLIENT.md) | Comprehensive agent reference |
|
||||
+25
-3
@@ -55,11 +55,13 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev
|
||||
|
||||
# Copy source files
|
||||
COPY src/ /build/src/
|
||||
COPY client/ /build/client/
|
||||
COPY libotppad/ /build/libotppad/
|
||||
COPY resources/tui_continuous/ /build/resources/tui_continuous/
|
||||
COPY resources/pqclean/ /build/resources/pqclean/
|
||||
|
||||
# Build nsigner as a fully static binary
|
||||
# Also build nsigner_client as a fully static binary
|
||||
RUN ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
aarch64|arm64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_arm64.a" ;; \
|
||||
@@ -124,9 +126,29 @@ RUN ARCH="$(uname -m)"; \
|
||||
$(pkg-config --static --libs libcurl openssl) \
|
||||
-lsecp256k1 -lsqlite3 -lz -lpthread -lm
|
||||
|
||||
RUN strip /build/nsigner_static || true
|
||||
RUN file /build/nsigner_static && \
|
||||
(ldd /build/nsigner_static 2>&1 || true)
|
||||
# Build nsigner_client (links nostr_core_lib, much smaller than nsigner)
|
||||
RUN ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
aarch64|arm64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_arm64.a" ;; \
|
||||
x86_64|amd64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_x64.a" ;; \
|
||||
*) echo "Unsupported build arch: $ARCH"; exit 1 ;; \
|
||||
esac; \
|
||||
gcc -static -Os -ffunction-sections -fdata-sections -Wl,--gc-sections -s -Wall -Wextra -std=c99 \
|
||||
-DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE \
|
||||
-I/build/nostr_core_lib \
|
||||
-I/build/nostr_core_lib/nostr_core \
|
||||
-I/build/nostr_core_lib/cjson \
|
||||
/build/client/n_signer_client.c \
|
||||
"$NOSTR_LIB" \
|
||||
-o /build/nsigner_client_static \
|
||||
$(pkg-config --static --libs libcurl openssl) \
|
||||
-lsecp256k1 -lz -lpthread -lm
|
||||
|
||||
RUN strip /build/nsigner_static /build/nsigner_client_static || true
|
||||
RUN file /build/nsigner_static /build/nsigner_client_static && \
|
||||
(ldd /build/nsigner_static 2>&1 || true) && \
|
||||
(ldd /build/nsigner_client_static 2>&1 || true)
|
||||
|
||||
FROM scratch AS output
|
||||
COPY --from=builder /build/nsigner_static /nsigner_static
|
||||
COPY --from=builder /build/nsigner_client_static /nsigner_client_static
|
||||
|
||||
@@ -80,6 +80,7 @@ TEST_SLH_DSA_128S_TARGET := $(BUILD_DIR)/test_slh_dsa_128s
|
||||
TEST_ML_KEM_768_TARGET := $(BUILD_DIR)/test_ml_kem_768
|
||||
TEST_PUBKEY_FORMAT_TARGET := $(BUILD_DIR)/test_pubkey_format
|
||||
TEST_ALGORITHM_API_TARGET := $(BUILD_DIR)/test_algorithm_api
|
||||
TEST_PATH_WHITELIST_TARGET := $(BUILD_DIR)/test_path_whitelist
|
||||
EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
|
||||
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client
|
||||
EXAMPLE_GET_PUBKEY_TCP_TARGET := $(BUILD_DIR)/example_get_pubkey_tcp
|
||||
@@ -88,10 +89,11 @@ EXAMPLE_PQ_SIGN_TARGET := $(BUILD_DIR)/example_pq_sign
|
||||
EXAMPLE_PQ_KEM_TARGET := $(BUILD_DIR)/example_pq_kem
|
||||
EXAMPLE_SSH_SIGN_TARGET := $(BUILD_DIR)/example_ssh_sign
|
||||
DEMO_C99_TARGET := $(BUILD_DIR)/demo_c99
|
||||
N_SIGNER_CLIENT_TARGET := $(BUILD_DIR)/nsigner_client
|
||||
|
||||
.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api examples test-client clean
|
||||
.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api test-path-whitelist test-n-signer-client examples clients test-client clean
|
||||
|
||||
all: dev
|
||||
all: dev clients
|
||||
|
||||
lib:
|
||||
cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,13,19,44
|
||||
@@ -117,7 +119,7 @@ static-arm64:
|
||||
firmware-feather:
|
||||
cd firmware/feather_s3_tft && idf.py build
|
||||
|
||||
test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-client
|
||||
test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-path-whitelist test-n-signer-client test-client
|
||||
|
||||
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
||||
./$(TEST_INTEGRATION_TARGET)
|
||||
@@ -176,10 +178,22 @@ test-pubkey-format: $(TEST_PUBKEY_FORMAT_TARGET)
|
||||
test-algorithm-api: $(TEST_ALGORITHM_API_TARGET)
|
||||
./$(TEST_ALGORITHM_API_TARGET)
|
||||
|
||||
test-client: examples
|
||||
test-path-whitelist: $(TEST_PATH_WHITELIST_TARGET)
|
||||
./$(TEST_PATH_WHITELIST_TARGET)
|
||||
|
||||
test-n-signer-client: clients $(TARGET_DEV)
|
||||
./$(TEST_DIR)/test_n_signer_client.sh
|
||||
|
||||
test-client: examples clients
|
||||
|
||||
examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(EXAMPLE_PQ_SIGN_TARGET) $(EXAMPLE_PQ_KEM_TARGET) $(EXAMPLE_SSH_SIGN_TARGET) $(DEMO_C99_TARGET)
|
||||
|
||||
clients: $(N_SIGNER_CLIENT_TARGET)
|
||||
|
||||
$(N_SIGNER_CLIENT_TARGET): $(CLIENT_DIR)/n_signer_client.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(CLIENT_DIR)/n_signer_client.c -o $(N_SIGNER_CLIENT_TARGET) $(LDFLAGS)
|
||||
|
||||
$(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
|
||||
@@ -256,6 +270,10 @@ $(TEST_ALGORITHM_API_TARGET): $(TEST_DIR)/test_algorithm_api.c $(SRC_DIR)/pq_cry
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_algorithm_api.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_ALGORITHM_API_TARGET) $(LDFLAGS)
|
||||
|
||||
$(TEST_PATH_WHITELIST_TARGET): $(TEST_DIR)/test_path_whitelist.c $(SRC_DIR)/server.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/auth_envelope.c $(SRC_DIR)/transport_frame.c $(SRC_DIR)/socket_name.c $(SRC_DIR)/http_listener.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_path_whitelist.c $(SRC_DIR)/server.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/auth_envelope.c $(SRC_DIR)/transport_frame.c $(SRC_DIR)/socket_name.c $(SRC_DIR)/http_listener.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_PATH_WHITELIST_TARGET) $(LDFLAGS)
|
||||
|
||||
$(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_public_key_client.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS)
|
||||
|
||||
@@ -60,13 +60,12 @@ When started, `n_signer` immediately enters terminal input mode:
|
||||
1. Choose mnemonic source: `[E]nter existing mnemonic` (default) or `[G]enerate new mnemonic`.
|
||||
- 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.
|
||||
2. **Define roles** (mandatory): interactive preset menu wizard — choose from 10 presets (Standard Nostr, hardened range, agent range, SSH, Age, ML-DSA-65, SLH-DSA-128s, ML-KEM-768, OTP, Custom). At least one role must be defined. See [`documents/nsigner_menus.md`](documents/nsigner_menus.md) for the full menu reference.
|
||||
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.
|
||||
4. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
|
||||
5. Initialize transport endpoints and bind the socket.
|
||||
6. Switch to running status display.
|
||||
7. (Optional) Bind an OTP pad via `--otp-pad-dir` / `--otp-pad` flags if an OTP role was defined. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md).
|
||||
|
||||
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.
|
||||
|
||||
@@ -87,25 +86,24 @@ n_signer v0.0.53 > Main Menu
|
||||
Roles:
|
||||
Role Purpose Curve Derivation path
|
||||
main nostr secp256k1 m/44'/1237'/0'/0/0
|
||||
nostr_idx_1 nostr secp256k1 m/44'/1237'/1'/0/0
|
||||
backup bitcoin secp256k1 m/84'/0'/0'/0/5
|
||||
nostr_agent nostr secp256k1 m/44'/1237'/*'/1'/0'
|
||||
ssh ssh ed25519 m/44'/102001'/0'/0'/0'
|
||||
|
||||
Activity (latest first):
|
||||
16:03:11 allow caller=uid:1000 method=nostr_get_public_key role=main
|
||||
16:02:44 prompt caller=uid:1000 method=nostr_sign_event role=ops
|
||||
16:02:46 allow caller=uid:1000 method=nostr_sign_event role=ops
|
||||
16:03:11 allow caller=uid:1000 method=nostr_get_public_key role=main path=m/44'/1237'/0'/0/0
|
||||
16:02:44 prompt caller=uid:1000 method=nostr_sign_event role=main path=m/44'/1237'/0'/0/0
|
||||
16:02:46 allow caller=uid:1000 method=nostr_sign_event role=main path=m/44'/1237'/0'/0/0
|
||||
15:59:10 deny caller=uid:1001 method=nostr_sign_event error=unauthorized
|
||||
|
||||
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
|
||||
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3
|
||||
|
||||
l lock/reunlock
|
||||
r refresh
|
||||
a toggle auto-approve
|
||||
d display connections
|
||||
q/x quit
|
||||
```
|
||||
|
||||
The **Derivation path** column shows the full BIP-44 path for each role's key. For `nostr_index` roles this is `m/44'/1237'/<n>'/0/0` (NIP-06); for `role_path` roles it's the explicit path.
|
||||
The **Derivation path** column shows the full BIP-44 path for each role's key. For roles derived via `role_path` this is the explicit path; for named path-roles it's the template path.
|
||||
|
||||
The signer's name (`nsigner_hairy_dog` in this example) appears in the **status line** at the bottom (`signer=nsigner_hairy_dog`). See [§4.1](#41-linux-desktop-abstract-namespace-unix-socket) for how the name is generated.
|
||||
|
||||
@@ -115,7 +113,6 @@ Pressing `d` clears the screen and shows each active transport as a titled block
|
||||
|
||||
Hotkeys (active while the status display is shown):
|
||||
|
||||
- `a` — toggle auto-approve (prompt) for this session
|
||||
- `r` — refresh the display
|
||||
- `d` — display connection instructions (press any key to return)
|
||||
- `l` — lock / re-unlock the session
|
||||
@@ -129,10 +126,11 @@ When a request needs confirmation, `n_signer` interrupts the status view with a
|
||||
Approval required
|
||||
caller: uid:1000
|
||||
method: nostr_sign_event
|
||||
selector: role=ops
|
||||
purpose/curve: nostr/secp256k1
|
||||
role: main
|
||||
path: m/44'/1237'/0'/0/0
|
||||
purpose: nostr
|
||||
|
||||
[y] allow once [n] deny [a] always allow this session
|
||||
y: allow once n: deny e: allow this caller+role+verb for session a: allow this caller+role for session (all verbs)
|
||||
```
|
||||
|
||||
No response is emitted to caller until the local user decides.
|
||||
@@ -191,10 +189,16 @@ Error codes:
|
||||
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
|
||||
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
|
||||
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
|
||||
| 2003 | `path_not_allowed` | `role_path` does not match any registered role or allowed path. |
|
||||
| 2005 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
|
||||
| 2006 | `nostr_index_deprecated` | `nostr_index` is removed — use `role` + `role_path` instead. |
|
||||
| 2007 | `index_deprecated` | `index` is removed for nostr verbs — use `role_path` with the full path. |
|
||||
| 2008 | `role_required` | `role` is required when using `role_path`. |
|
||||
| 2009 | `path_required` | `role_path` is required for roles with variable path templates. |
|
||||
|
||||
### 4.3 Verbs
|
||||
|
||||
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `nostr_index` and implement Nostr-protocol-specific serialization on top of the raw crypto.
|
||||
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `role` + `role_path` and implement Nostr-protocol-specific serialization on top of the raw crypto.
|
||||
|
||||
| Verb | Algorithms | Positional params | Options |
|
||||
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
|
||||
@@ -208,13 +212,13 @@ All verbs take their arguments as positional `params` and their options in a tra
|
||||
| `derive` | secp256k1 | `<data>` | `algorithm`, `index` (required) |
|
||||
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
|
||||
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
|
||||
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `nostr_index`, `format` |
|
||||
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index` |
|
||||
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` |
|
||||
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
|
||||
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
|
||||
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
|
||||
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
|
||||
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `role`, `role_path`, `format` |
|
||||
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path` |
|
||||
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path`, `difficulty`, `timeout_sec`, `threads` |
|
||||
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
|
||||
\* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
|
||||
|
||||
@@ -401,7 +405,7 @@ If no pad is bound at startup, the error is `-32601` `otp_pad_not_bound`.
|
||||
#### `nostr_get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "nostr_index": 0 } ] }
|
||||
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "role": "main" } ] }
|
||||
```
|
||||
|
||||
Response (default): a plain 64-hex-char secp256k1 public key string.
|
||||
@@ -412,7 +416,7 @@ Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","p
|
||||
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
|
||||
|
||||
```json
|
||||
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "nostr_index": 0 } ] }
|
||||
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`<event_json>` is the unsigned event object:
|
||||
@@ -430,7 +434,7 @@ Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step.
|
||||
{
|
||||
"id": "12",
|
||||
"method": "nostr_mine_event",
|
||||
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 } ]
|
||||
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "role": "main" } ]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -459,8 +463,8 @@ Errors:
|
||||
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
|
||||
|
||||
```json
|
||||
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
|
||||
@@ -470,8 +474,8 @@ NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CB
|
||||
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
|
||||
|
||||
```json
|
||||
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
|
||||
@@ -482,11 +486,68 @@ The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Suppor
|
||||
|
||||
| Selector | Meaning |
|
||||
|----------------|--------------------------------------------------|
|
||||
| `nostr_index` | NIP-06 index `n` → path `m/44'/1237'/<n>'/0/0` |
|
||||
| `role` | Name of a pre-registered role entry |
|
||||
| `role_path` | Full BIP-44 derivation path (must match a registered role) |
|
||||
| `role` | Name of a pre-registered role entry (required) |
|
||||
| `role_path` | Full BIP-44 derivation path (required) |
|
||||
|
||||
Selector resolution order: `role` → `nostr_index` → `role_path` → default role `main`. Conflicting selectors are rejected with `ambiguous_role_selector` (1001). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
|
||||
**Selector resolution**: both `role` and `role_path` are required together — they form a single combined selector. The server verifies that the supplied `role_path` matches the role's registered template (expanding any wildcard). There is no resolution order and no default role: omitting either field is rejected (`2008 role_required` / `2009 path_required`). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
|
||||
|
||||
#### Named path-roles
|
||||
|
||||
In the interactive wizard, you define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template. The derivation path template is hidden from clients — they only know the role name and send the full concrete `role_path` with each request.
|
||||
|
||||
The wizard presents a **preset menu** of 10 options covering the common role types. You can still define custom roles manually via the "Custom path" option.
|
||||
|
||||
```
|
||||
Wizard preset menu:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
|
||||
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
|
||||
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
|
||||
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
|
||||
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
|
||||
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
|
||||
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
|
||||
9. OTP role (one-time pad encryption)
|
||||
10. Custom path
|
||||
```
|
||||
|
||||
Purpose is auto-detected from the path prefix (e.g. `m/44'/1237'` → nostr, `m/44'/102001'` → ssh). The path template is pre-filled from the chosen preset and can be edited inline with arrow keys, backspace, and delete.
|
||||
|
||||
**Path template syntax:**
|
||||
- **Wildcard**: `m/44'/1237'/*'/0'/0'` — any non-negative integer, hardened. No range limit.
|
||||
- **Range**: `m/44'/1237'/0-3/1/0` — index 0..3, hardened if segment ends with `'` (e.g. `0-3'`)
|
||||
- **Set**: `m/44'/1237'/1+34+54/1/0` — specific indices 1, 34, 54
|
||||
- **Fixed path**: `m/44'/1237'/0'/0/0` — no variable segment, single fixed key
|
||||
- The first segment that is a plain number, range (`N-M`), set (`A+B+C`), or wildcard (`*`) becomes the variable. Segments with `'` (like `44'`, `1237'`) are treated as literal hardened constants.
|
||||
|
||||
**`requires_approval`**: Each named path-role is marked in the wizard as requiring explicit approval at the signer terminal before any operation is performed (`Requires approval? [y/N]`). Roles with `requires_approval=false` skip the prompt — the role name itself acts as a password: any caller that knows the role name and supplies a matching `role_path` is served without attendant interaction. Roles with `requires_approval=true` always prompt the attendant.
|
||||
|
||||
Clients request keys by supplying both `role` and the full concrete `role_path`:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/0'/1/0`, verified against the `myrole` template.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}
|
||||
```
|
||||
→ `2003 path_not_allowed` (5 is outside the registered template, if the template was a fixed path or limited range).
|
||||
|
||||
```json
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
|
||||
```
|
||||
→ `2009 path_required` (`role_path` is required).
|
||||
|
||||
```json
|
||||
{"id":"5","method":"nostr_get_public_key","params":[{"role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
→ `2008 role_required` (`role` is required when using `role_path`).
|
||||
|
||||
### 4.7 Pre-approval
|
||||
|
||||
@@ -500,10 +561,10 @@ nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsula
|
||||
|
||||
Nostr (role-based):
|
||||
```bash
|
||||
nsigner --preapprove caller=uid:1000,nostr_index=0,verb=nostr_sign_event,nostr_get_public_key
|
||||
nsigner --preapprove caller=uid:1000,role=main,verb=nostr_sign_event,nostr_get_public_key
|
||||
```
|
||||
|
||||
A `*` wildcard matches any caller, role, or verb. Index ranges use `min-max` syntax. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).
|
||||
A `*` wildcard matches any caller, role, or verb. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).
|
||||
|
||||
## 5. Transports
|
||||
|
||||
@@ -533,7 +594,7 @@ curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
Sign a Nostr event:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
|
||||
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
```
|
||||
|
||||
OTP encrypt:
|
||||
@@ -551,7 +612,7 @@ nsigner --socket-name nsigner client \
|
||||
|
||||
# Sign a Nostr event
|
||||
nsigner --socket-name nsigner client \
|
||||
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
|
||||
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
|
||||
# ed25519 sign
|
||||
nsigner --socket-name nsigner client \
|
||||
@@ -676,16 +737,22 @@ nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
|
||||
### 7.2 Send a request (client mode)
|
||||
|
||||
From another terminal, target the signer by its socket name:
|
||||
The standalone `nsigner_client` binary is the recommended client. See [`client/n_signer_client_README.md`](client/n_signer_client_README.md) for full documentation.
|
||||
|
||||
```bash
|
||||
nsigner --socket-name nsigner_hairy_dog client '{"id":"1","method":"nostr_get_public_key","params":[]}'
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" get-public-key
|
||||
```
|
||||
|
||||
If only one signer is running you can omit the override and the client will use the default discovery rule.
|
||||
If only one signer is running you can omit the `--socket-name` override and the client will use the default discovery rule.
|
||||
|
||||
Example signing request:
|
||||
```bash
|
||||
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" sign-event '<event_json>'
|
||||
```
|
||||
|
||||
The raw `nsigner client` subcommand (sending a hand-built JSON-RPC object over the socket) is still available for scripting:
|
||||
```bash
|
||||
nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
|
||||
```
|
||||
|
||||
### 7.3 List running signers
|
||||
@@ -694,25 +761,27 @@ nsigner -n nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","para
|
||||
nsigner list
|
||||
```
|
||||
|
||||
Prints the abstract socket names of any currently running `nsigner` instances, e.g.:
|
||||
Prints the names of any currently running `nsigner` instances, e.g.:
|
||||
```text
|
||||
@nsigner_hairy_dog
|
||||
@nsigner_brave_canyon
|
||||
nsigner_hairy_dog
|
||||
nsigner_brave_canyon
|
||||
```
|
||||
|
||||
`nsigner_client list` is an equivalent alternative that uses the same discovery mechanism.
|
||||
|
||||
### 7.4 Example session
|
||||
|
||||
Terminal A:
|
||||
```text
|
||||
$ nsigner
|
||||
[unlock] enter mnemonic:
|
||||
System is ready and waiting for connections on @nsigner_hairy_dog.
|
||||
[prompt] caller=uid:1000 method=nostr_sign_event role=main -> allow? (y/n)
|
||||
System is ready and waiting for connections on nsigner_hairy_dog.
|
||||
[prompt] caller=uid:1000 method=nostr_sign_event role=main path=m/44'/1237'/0'/0/0 -> allow? (y/n)
|
||||
```
|
||||
|
||||
Terminal B:
|
||||
```text
|
||||
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
|
||||
$ nsigner_client --role main --path "m/44'/1237'/0'/0/0" sign-event '<event_json>'
|
||||
{"id":"2","result":"<signed_event_json>"}
|
||||
```
|
||||
|
||||
@@ -738,3 +807,9 @@ Static build:
|
||||
./build_static.sh
|
||||
./build/nsigner_static_x86_64 --version
|
||||
```
|
||||
|
||||
Client build (`nsigner_client`):
|
||||
```bash
|
||||
make clients
|
||||
```
|
||||
See [`client/n_signer_client_README.md`](client/n_signer_client_README.md) for client build details.
|
||||
|
||||
+32
-5
@@ -70,14 +70,17 @@ case "$ARCH" in
|
||||
x86_64)
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="nsigner_static_x86_64"
|
||||
CLIENT_NAME="nsigner_client_static_x86_64"
|
||||
;;
|
||||
arm64)
|
||||
PLATFORM="linux/arm64"
|
||||
OUTPUT_NAME="nsigner_static_arm64"
|
||||
CLIENT_NAME="nsigner_client_static_arm64"
|
||||
;;
|
||||
armv7)
|
||||
PLATFORM="linux/arm/v7"
|
||||
OUTPUT_NAME="nsigner_static_armv7"
|
||||
CLIENT_NAME="nsigner_client_static_armv7"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unsupported target architecture '$ARCH'"
|
||||
@@ -103,6 +106,7 @@ echo "Project root: $SCRIPT_DIR"
|
||||
echo "Dockerfile: $DOCKERFILE"
|
||||
echo "Platform: $PLATFORM"
|
||||
echo "Output: $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo "Client: $BUILD_DIR/$CLIENT_NAME"
|
||||
echo ""
|
||||
|
||||
if [ "$ARCH" != "$HOST_ARCH" ]; then
|
||||
@@ -122,6 +126,10 @@ if [ "$ARCH" != "$HOST_ARCH" ]; then
|
||||
fi
|
||||
|
||||
echo "[1/3] Building builder stage from project root context"
|
||||
# Remove previous builder image to avoid dangling <none> images piling up
|
||||
# across repeated builds (each rebuild untagges the old image, leaving ~422MB
|
||||
# of garbage per build otherwise).
|
||||
docker rmi "$IMAGE_TAG" >/dev/null 2>&1 || true
|
||||
docker buildx build \
|
||||
--platform "$PLATFORM" \
|
||||
--target builder \
|
||||
@@ -130,22 +138,41 @@ docker buildx build \
|
||||
--load \
|
||||
"$SCRIPT_DIR"
|
||||
|
||||
echo "[2/3] Extracting static binary"
|
||||
echo "[2/3] Extracting static binaries"
|
||||
CONTAINER_NAME="$(docker create "$IMAGE_TAG")"
|
||||
docker cp "$CONTAINER_NAME:/build/nsigner_static" "$BUILD_DIR/$OUTPUT_NAME"
|
||||
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
|
||||
strip "$BUILD_DIR/$OUTPUT_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
echo "[3/3] Verifying static binary"
|
||||
docker cp "$CONTAINER_NAME:/build/nsigner_client_static" "$BUILD_DIR/$CLIENT_NAME"
|
||||
chmod +x "$BUILD_DIR/$CLIENT_NAME"
|
||||
strip "$BUILD_DIR/$CLIENT_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
echo "[3/3] Verifying static binaries"
|
||||
file "$BUILD_DIR/$OUTPUT_NAME"
|
||||
file "$BUILD_DIR/$CLIENT_NAME"
|
||||
|
||||
LDD_OUTPUT="$(ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1 || true)"
|
||||
echo "$LDD_OUTPUT"
|
||||
if echo "$LDD_OUTPUT" | grep -Eq "not a dynamic executable|statically linked"; then
|
||||
echo "Static check: PASS"
|
||||
echo "nsigner static check: PASS"
|
||||
else
|
||||
echo "Static check: WARNING (verify manually)"
|
||||
echo "nsigner static check: WARNING (verify manually)"
|
||||
fi
|
||||
|
||||
LDD_OUTPUT_CLIENT="$(ldd "$BUILD_DIR/$CLIENT_NAME" 2>&1 || true)"
|
||||
echo "$LDD_OUTPUT_CLIENT"
|
||||
if echo "$LDD_OUTPUT_CLIENT" | grep -Eq "not a dynamic executable|statically linked"; then
|
||||
echo "nsigner_client static check: PASS"
|
||||
else
|
||||
echo "nsigner_client static check: WARNING (verify manually)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Build complete: $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo "Build complete:"
|
||||
echo " $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo " $BUILD_DIR/$CLIENT_NAME"
|
||||
|
||||
# Prune stale build cache older than 24h to prevent unbounded cache growth
|
||||
# from repeated buildx builds. Recent layers are kept for fast rebuilds.
|
||||
docker builder prune -af --filter "until=24h" >/dev/null 2>&1 || true
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
/*
|
||||
* n_signer_client.c — standalone Linux CLI for n_signer JSON-RPC API.
|
||||
*
|
||||
* Connects to a running n_signer process over its abstract UNIX socket
|
||||
* (or TCP/serial/qrexec) and exposes the full verb surface over stdin/stdout
|
||||
* so that signed events can be piped directly into `nak publish`.
|
||||
*
|
||||
* This client uses the high-level nostr_signer_t API from nostr_core_lib
|
||||
* for all typed verbs. The per-verb cJSON-building logic lives in the
|
||||
* library, not here. The CLI is mostly argv parsing + result printing.
|
||||
*
|
||||
* Build: make clients
|
||||
* Usage: n_signer_client [global options] <verb> [verb args...]
|
||||
*
|
||||
* See client/n_signer_client_README.md for full documentation.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "nostr_common.h"
|
||||
#include "nsigner_transport.h"
|
||||
#include "nsigner_client.h"
|
||||
#include "nostr_signer.h"
|
||||
#include "../cjson/cJSON.h"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void print_usage(FILE *fp, const char *prog) {
|
||||
fprintf(fp,
|
||||
"Usage: %s [global options] <verb> [verb args...]\n"
|
||||
"\n"
|
||||
"Global options:\n"
|
||||
" -n, --socket-name <name> Abstract socket name (default: auto-discover)\n"
|
||||
" --timeout <ms> Transport timeout (default 5000)\n"
|
||||
" --tcp <host:port> TCP transport (requires --auth-privkey)\n"
|
||||
" --serial <device> USB CDC-ACM serial transport\n"
|
||||
" --qrexec <qube:svc> Qubes qrexec transport\n"
|
||||
" --auth-privkey <hex> Auth envelope privkey (32 bytes hex)\n"
|
||||
" --auth-label <text> Auth envelope label\n"
|
||||
"\n"
|
||||
"Selector options (for nostr_* verbs):\n"
|
||||
" --role <name> Named path-role\n"
|
||||
" --path <path> Full BIP-44 derivation path\n"
|
||||
"\n"
|
||||
"Algorithm options (for algorithm-based verbs):\n"
|
||||
" -a, --algorithm <alg> secp256k1/ed25519/x25519/ml-dsa-65/\n"
|
||||
" slh-dsa-128s/ml-kem-768/otp\n"
|
||||
" --scheme <schnorr|ecdsa> secp256k1 sign/verify scheme (default schnorr)\n"
|
||||
" --encoding <ascii|binary> OTP encoding (default ascii)\n"
|
||||
" --format <plain|structured> get-public-key output (default plain)\n"
|
||||
" --index <N> Algorithm derivation index\n"
|
||||
"\n"
|
||||
"Mine-event options:\n"
|
||||
" --difficulty <N> Target leading zero bits\n"
|
||||
" --threads <N> Mining threads (default 1)\n"
|
||||
" --timeout-sec <N> Mining timeout in seconds\n"
|
||||
"\n"
|
||||
"Verbs:\n"
|
||||
" list List running n_signer sockets\n"
|
||||
" get-info\n"
|
||||
" get-public-key\n"
|
||||
" sign-event\n"
|
||||
" mine-event\n"
|
||||
" nip04-encrypt <peer-pubkey>\n"
|
||||
" nip04-decrypt <peer-pubkey>\n"
|
||||
" nip44-encrypt <peer-pubkey>\n"
|
||||
" nip44-decrypt <peer-pubkey>\n"
|
||||
" sign <msg-hex>\n"
|
||||
" verify <msg-hex> <sig-hex>\n"
|
||||
" derive <data>\n"
|
||||
" encapsulate <peer-pubkey-hex>\n"
|
||||
" decapsulate <ciphertext-hex>\n"
|
||||
" derive-shared-secret <peer-pubkey-hex>\n"
|
||||
" encrypt <plaintext>\n"
|
||||
" decrypt <ciphertext>\n"
|
||||
" call <method>\n"
|
||||
"\n"
|
||||
"Examples:\n"
|
||||
" # List running n_signer sockets\n"
|
||||
" %s list\n"
|
||||
"\n"
|
||||
" # Get a Nostr public key by role and path\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" get-public-key\n"
|
||||
"\n"
|
||||
" # Get a key by named path-role\n"
|
||||
" %s --role role1 --path \"m/44'/1237'/1'/1/0\" get-public-key\n"
|
||||
"\n"
|
||||
" # Sign a Nostr event from stdin and pipe to nak for publishing\n"
|
||||
" echo '{\"kind\":1,\"content\":\"hello world\",\"tags\":[],\"created_at\":1700000000}' \\\n"
|
||||
" | %s --role main --path \"m/44'/1237'/0'/0/0\" sign-event | nak publish\n"
|
||||
"\n"
|
||||
" # Sign an event from argv\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" sign-event '{\"kind\":1,\"content\":\"hi\",\"tags\":[],\"created_at\":1700000000}'\n"
|
||||
"\n"
|
||||
" # Mine an event with proof-of-work (difficulty 20)\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" --difficulty 20 mine-event '{\"kind\":1,\"content\":\"mined\",\"tags\":[],\"created_at\":1700000000}'\n"
|
||||
"\n"
|
||||
" # NIP-44 encrypt then decrypt a round-trip\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" nip44-encrypt <peer-pubkey> 'secret message'\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" nip44-decrypt <peer-pubkey> '<ciphertext>'\n"
|
||||
"\n"
|
||||
" # Ed25519 sign (SSH-style)\n"
|
||||
" %s --algorithm ed25519 --index 0 sign 68656c6c6f\n"
|
||||
"\n"
|
||||
" # Get signer metadata\n"
|
||||
" %s get-info\n",
|
||||
prog, prog, prog, prog, prog, prog, prog, prog, prog, prog, prog);
|
||||
}
|
||||
|
||||
/* Read one line from stdin (newline stripped). Returns malloc'd string or NULL on EOF/error. */
|
||||
static char *read_stdin_line(void) {
|
||||
size_t cap = 4096;
|
||||
size_t len = 0;
|
||||
char *buf = malloc(cap);
|
||||
if (!buf) return NULL;
|
||||
|
||||
int c;
|
||||
while ((c = fgetc(stdin)) != EOF && c != '\n') {
|
||||
if (len + 1 >= cap) {
|
||||
cap *= 2;
|
||||
char *tmp = realloc(buf, cap);
|
||||
if (!tmp) { free(buf); return NULL; }
|
||||
buf = tmp;
|
||||
}
|
||||
buf[len++] = (char)c;
|
||||
}
|
||||
if (len == 0 && c == EOF) { free(buf); return NULL; }
|
||||
buf[len] = '\0';
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Convert a hex string to raw bytes. Returns number of bytes written, or -1 on error. */
|
||||
static int hex_to_bytes(const char *hex, unsigned char *out, size_t out_sz) {
|
||||
size_t len = strlen(hex);
|
||||
if (len % 2 != 0 || len / 2 > out_sz) return -1;
|
||||
for (size_t i = 0; i < len / 2; i++) {
|
||||
unsigned int byte;
|
||||
if (sscanf(hex + 2 * i, "%2x", &byte) != 1) return -1;
|
||||
out[i] = (unsigned char)byte;
|
||||
}
|
||||
return (int)(len / 2);
|
||||
}
|
||||
|
||||
/* Parse "host:port" string. Returns 0 on success. */
|
||||
static int parse_host_port(const char *s, char **out_host, int *out_port) {
|
||||
const char *colon = strrchr(s, ':');
|
||||
if (!colon || colon == s) return -1;
|
||||
size_t host_len = (size_t)(colon - s);
|
||||
*out_host = malloc(host_len + 1);
|
||||
if (!*out_host) return -1;
|
||||
memcpy(*out_host, s, host_len);
|
||||
(*out_host)[host_len] = '\0';
|
||||
char *end = NULL;
|
||||
long p = strtol(colon + 1, &end, 10);
|
||||
if (end == colon + 1 || *end != '\0' || p < 1 || p > 65535) {
|
||||
free(*out_host);
|
||||
*out_host = NULL;
|
||||
return -1;
|
||||
}
|
||||
*out_port = (int)p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Parse "qube:service" string. Returns 0 on success. */
|
||||
static int parse_qube_service(const char *s, char **out_qube, char **out_service) {
|
||||
const char *colon = strchr(s, ':');
|
||||
if (!colon || colon == s) return -1;
|
||||
size_t qube_len = (size_t)(colon - s);
|
||||
*out_qube = malloc(qube_len + 1);
|
||||
if (!*out_qube) return -1;
|
||||
memcpy(*out_qube, s, qube_len);
|
||||
(*out_qube)[qube_len] = '\0';
|
||||
*out_service = strdup(colon + 1);
|
||||
if (!*out_service) { free(*out_qube); *out_qube = NULL; return -1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Result printing helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Print a raw string result (from the *_result_json_out wrappers). */
|
||||
static void print_result_str(const char *s) {
|
||||
if (s) {
|
||||
printf("%s\n", s);
|
||||
} else {
|
||||
printf("null\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transport setup helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Opens a transport based on the CLI args. Returns 0 on success.
|
||||
* On success, *out_transport is set (caller must not free if handed to signer). */
|
||||
static int open_transport(const char *socket_name, int timeout_ms,
|
||||
const char *tcp_arg, const char *serial_arg,
|
||||
const char *qrexec_arg,
|
||||
const char *auth_privkey_hex,
|
||||
nsigner_transport_t **out_transport) {
|
||||
int transport_count = (tcp_arg ? 1 : 0) + (serial_arg ? 1 : 0) + (qrexec_arg ? 1 : 0) + (socket_name ? 1 : 0);
|
||||
if (transport_count > 1) {
|
||||
fprintf(stderr, "error: --tcp, --serial, --qrexec, and --socket-name are mutually exclusive\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_transport = NULL;
|
||||
|
||||
if (tcp_arg) {
|
||||
if (!auth_privkey_hex) {
|
||||
fprintf(stderr, "error: --tcp requires --auth-privkey\n");
|
||||
return -1;
|
||||
}
|
||||
char *host = NULL;
|
||||
int port = 0;
|
||||
if (parse_host_port(tcp_arg, &host, &port) != 0) {
|
||||
fprintf(stderr, "error: invalid --tcp format (expected host:port)\n");
|
||||
return -1;
|
||||
}
|
||||
*out_transport = nsigner_transport_open_tcp(host, port, timeout_ms);
|
||||
free(host);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open TCP transport to %s\n", tcp_arg);
|
||||
return -1;
|
||||
}
|
||||
} else if (serial_arg) {
|
||||
*out_transport = nsigner_transport_open_serial(serial_arg, timeout_ms);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open serial transport on %s\n", serial_arg);
|
||||
return -1;
|
||||
}
|
||||
} else if (qrexec_arg) {
|
||||
char *qube = NULL, *service = NULL;
|
||||
if (parse_qube_service(qrexec_arg, &qube, &service) != 0) {
|
||||
fprintf(stderr, "error: invalid --qrexec format (expected qube:service)\n");
|
||||
return -1;
|
||||
}
|
||||
*out_transport = nsigner_transport_open_qrexec(qube, service, timeout_ms);
|
||||
free(qube);
|
||||
free(service);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open qrexec transport to %s\n", qrexec_arg);
|
||||
return -1;
|
||||
}
|
||||
} else if (socket_name) {
|
||||
*out_transport = nsigner_transport_open_unix(socket_name, timeout_ms);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open unix transport %s\n", socket_name);
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
/* Auto-discover: enumerate abstract UNIX sockets */
|
||||
char names[64][64];
|
||||
int count = nsigner_transport_list_unix(names, 64);
|
||||
if (count == 0) {
|
||||
fprintf(stderr, "error: no n_signer sockets found. Is n_signer running?\n");
|
||||
return -1;
|
||||
}
|
||||
if (count > 1) {
|
||||
fprintf(stderr, "error: multiple n_signer sockets found. Use --socket-name to select one:\n");
|
||||
for (int j = 0; j < count; j++) {
|
||||
fprintf(stderr, " %s\n", names[j]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
*out_transport = nsigner_transport_open_unix(names[0], timeout_ms);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open unix transport %s\n", names[0]);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
/* ---- globals ---- */
|
||||
const char *socket_name = NULL;
|
||||
int timeout_ms = 5000;
|
||||
const char *tcp_arg = NULL;
|
||||
const char *serial_arg = NULL;
|
||||
const char *qrexec_arg = NULL;
|
||||
const char *auth_privkey_hex = NULL;
|
||||
const char *auth_label = NULL;
|
||||
|
||||
/* ---- selectors (nostr verbs) ---- */
|
||||
const char *role = NULL;
|
||||
const char *path = NULL;
|
||||
int has_index = 0;
|
||||
int index_val = 0;
|
||||
|
||||
/* ---- algorithm options ---- */
|
||||
const char *algorithm = NULL;
|
||||
int alg_index = 0;
|
||||
int has_alg_index = 0;
|
||||
const char *scheme = NULL;
|
||||
const char *encoding = NULL;
|
||||
const char *format = NULL;
|
||||
|
||||
/* ---- mine-event options ---- */
|
||||
int has_difficulty = 0;
|
||||
int difficulty_val = 0;
|
||||
int has_threads = 0;
|
||||
int threads_val = 1;
|
||||
int has_timeout_sec = 0;
|
||||
int timeout_sec_val = 0;
|
||||
|
||||
const char *prog = argv[0];
|
||||
|
||||
/* ---- parse global options ---- */
|
||||
int i = 1;
|
||||
while (i < argc && argv[i][0] == '-') {
|
||||
const char *arg = argv[i];
|
||||
|
||||
if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
||||
print_usage(stderr, prog);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (strcmp(arg, "--socket-name") == 0 || strcmp(arg, "-n") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --socket-name requires an argument\n"); return 2; }
|
||||
socket_name = argv[++i];
|
||||
} else if (strcmp(arg, "--timeout") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --timeout requires an argument\n"); return 2; }
|
||||
timeout_ms = atoi(argv[++i]);
|
||||
if (timeout_ms <= 0) { fprintf(stderr, "error: --timeout must be positive\n"); return 2; }
|
||||
} else if (strcmp(arg, "--tcp") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --tcp requires <host:port>\n"); return 2; }
|
||||
tcp_arg = argv[++i];
|
||||
} else if (strcmp(arg, "--serial") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --serial requires a device path\n"); return 2; }
|
||||
serial_arg = argv[++i];
|
||||
} else if (strcmp(arg, "--qrexec") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --qrexec requires <qube:service>\n"); return 2; }
|
||||
qrexec_arg = argv[++i];
|
||||
} else if (strcmp(arg, "--auth-privkey") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --auth-privkey requires a 32-byte hex key\n"); return 2; }
|
||||
auth_privkey_hex = argv[++i];
|
||||
} else if (strcmp(arg, "--auth-label") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --auth-label requires a label\n"); return 2; }
|
||||
auth_label = argv[++i];
|
||||
} else if (strcmp(arg, "--role") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --role requires a name\n"); return 2; }
|
||||
role = argv[++i];
|
||||
} else if (strcmp(arg, "--path") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --path requires a BIP-44 derivation path\n"); return 2; }
|
||||
path = argv[++i];
|
||||
} else if (strcmp(arg, "--index") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --index requires a number\n"); return 2; }
|
||||
has_index = 1;
|
||||
index_val = atoi(argv[++i]);
|
||||
} else if (strcmp(arg, "--algorithm") == 0 || strcmp(arg, "-a") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --algorithm requires a name\n"); return 2; }
|
||||
algorithm = argv[++i];
|
||||
} else if (strcmp(arg, "--scheme") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --scheme requires schnorr or edsa\n"); return 2; }
|
||||
scheme = argv[++i];
|
||||
} else if (strcmp(arg, "--encoding") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --encoding requires ascii or binary\n"); return 2; }
|
||||
encoding = argv[++i];
|
||||
} else if (strcmp(arg, "--format") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --format requires plain or structured\n"); return 2; }
|
||||
format = argv[++i];
|
||||
} else if (strcmp(arg, "--difficulty") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --difficulty requires a number\n"); return 2; }
|
||||
has_difficulty = 1;
|
||||
difficulty_val = atoi(argv[++i]);
|
||||
} else if (strcmp(arg, "--threads") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --threads requires a number\n"); return 2; }
|
||||
has_threads = 1;
|
||||
threads_val = atoi(argv[++i]);
|
||||
} else if (strcmp(arg, "--timeout-sec") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --timeout-sec requires a number\n"); return 2; }
|
||||
has_timeout_sec = 1;
|
||||
timeout_sec_val = atoi(argv[++i]);
|
||||
} else {
|
||||
fprintf(stderr, "error: unknown option: %s\n", arg);
|
||||
fprintf(stderr, "Try '%s --help' for usage.\n", prog);
|
||||
return 2;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
/* ---- verb ---- */
|
||||
if (i >= argc) {
|
||||
fprintf(stderr, "error: no verb specified\n");
|
||||
fprintf(stderr, "Try '%s --help' for usage.\n", prog);
|
||||
return 2;
|
||||
}
|
||||
const char *verb = argv[i++];
|
||||
|
||||
/* ---- verb args ---- */
|
||||
const char *arg1 = (i < argc) ? argv[i++] : NULL;
|
||||
const char *arg2 = (i < argc) ? argv[i++] : NULL;
|
||||
|
||||
/* ---- validate --index usage (algorithm-only now) ---- */
|
||||
if (has_index && !algorithm) {
|
||||
fprintf(stderr, "error: --index is only valid with --algorithm (for algorithm verbs)\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
/* ---- determine if this is an algorithm verb ---- */
|
||||
int is_algorithm_verb = (algorithm != NULL);
|
||||
|
||||
/* ---- validate --role and --path for nostr verbs ---- */
|
||||
int is_nostr_verb = (strcmp(verb, "get-public-key") == 0 ||
|
||||
strcmp(verb, "sign-event") == 0 ||
|
||||
strcmp(verb, "mine-event") == 0 ||
|
||||
strcmp(verb, "nip04-encrypt") == 0 ||
|
||||
strcmp(verb, "nip04-decrypt") == 0 ||
|
||||
strcmp(verb, "nip44-encrypt") == 0 ||
|
||||
strcmp(verb, "nip44-decrypt") == 0);
|
||||
if (is_nostr_verb && !is_algorithm_verb) {
|
||||
if (!role) {
|
||||
fprintf(stderr, "error: --role is required for nostr verbs\n");
|
||||
return 2;
|
||||
}
|
||||
if (!path) {
|
||||
fprintf(stderr, "error: --path is required for nostr verbs\n");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- nostr_init ---- */
|
||||
if (nostr_init() != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: failed to initialize crypto subsystem\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
/* ---- list verb (no connection needed) ---- */
|
||||
if (strcmp(verb, "list") == 0) {
|
||||
char names[64][64];
|
||||
int count = nsigner_transport_list_unix(names, 64);
|
||||
if (count == 0) {
|
||||
printf("no n_signer sockets found\n");
|
||||
} else {
|
||||
for (int j = 0; j < count; j++) {
|
||||
printf("%s\n", names[j]);
|
||||
}
|
||||
}
|
||||
nostr_cleanup();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- open transport ---- */
|
||||
nsigner_transport_t *transport = NULL;
|
||||
if (open_transport(socket_name, timeout_ms, tcp_arg, serial_arg, qrexec_arg,
|
||||
auth_privkey_hex, &transport) != 0) {
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
|
||||
/* ---- create low-level client (owns transport) ---- */
|
||||
nsigner_client_t *client = nsigner_client_new(transport);
|
||||
if (!client) {
|
||||
fprintf(stderr, "error: cannot create nsigner client\n");
|
||||
transport->close(transport);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
transport = NULL; /* owned by client */
|
||||
|
||||
/* ---- create high-level signer from client (shares the connection) ---- */
|
||||
nostr_signer_t *signer = nostr_signer_nsigner_from_client(client, role);
|
||||
if (!signer) {
|
||||
fprintf(stderr, "error: cannot create nsigner signer\n");
|
||||
nsigner_client_free(client);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
/* signer now owns client; don't free it separately */
|
||||
|
||||
/* ---- set role_path selector for nostr verbs ---- */
|
||||
if (is_nostr_verb && !is_algorithm_verb && path) {
|
||||
if (nostr_signer_nsigner_set_role_path(signer, path) != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: failed to set role_path\n");
|
||||
nostr_signer_free(signer);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- auth envelope (TCP) ---- */
|
||||
if (auth_privkey_hex) {
|
||||
unsigned char privkey[32];
|
||||
if (hex_to_bytes(auth_privkey_hex, privkey, 32) != 32) {
|
||||
fprintf(stderr, "error: --auth-privkey must be 32 bytes (64 hex chars)\n");
|
||||
nostr_signer_free(signer);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
if (nostr_signer_nsigner_set_auth(signer, privkey, auth_label ? auth_label : "") != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: failed to set auth envelope\n");
|
||||
nostr_signer_free(signer);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- resolve algorithm index ---- */
|
||||
int eff_index = has_alg_index ? alg_index : (has_index ? index_val : 0);
|
||||
|
||||
int rc = 2;
|
||||
char *result_str = NULL;
|
||||
cJSON *result_obj = NULL;
|
||||
|
||||
/* ---- dispatch verbs via high-level library wrappers ---- */
|
||||
if (strcmp(verb, "get-info") == 0) {
|
||||
rc = nostr_signer_get_info(signer, &result_obj);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(result_obj);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "get-public-key") == 0) {
|
||||
if (is_algorithm_verb) {
|
||||
rc = nostr_signer_get_public_key_alg(signer, algorithm, eff_index, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (format && strcmp(format, "structured") == 0) {
|
||||
/* Structured format: use low-level client to pass the format option. */
|
||||
cJSON *params = cJSON_CreateArray();
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
cJSON_AddStringToObject(opts, "format", "structured");
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
cJSON *presult = NULL;
|
||||
rc = nsigner_client_call(client, "nostr_get_public_key", params, &presult);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); goto cleanup; }
|
||||
if (cJSON_IsString(presult)) {
|
||||
print_result_str(presult->valuestring);
|
||||
} else if (presult) {
|
||||
char *json = cJSON_PrintUnformatted(presult);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
}
|
||||
cJSON_Delete(presult);
|
||||
rc = 0;
|
||||
} else {
|
||||
char pubkey_hex[65];
|
||||
rc = nostr_signer_get_public_key(signer, pubkey_hex);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
printf("%s\n", pubkey_hex);
|
||||
rc = 0;
|
||||
}
|
||||
} else if (strcmp(verb, "sign-event") == 0) {
|
||||
const char *event_json = arg1;
|
||||
char *event_buf = NULL;
|
||||
if (!event_json) {
|
||||
event_buf = read_stdin_line();
|
||||
if (!event_buf) {
|
||||
fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
event_json = event_buf;
|
||||
}
|
||||
cJSON *event = cJSON_Parse(event_json);
|
||||
free(event_buf);
|
||||
if (!event) {
|
||||
fprintf(stderr, "error: failed to parse event JSON\n");
|
||||
goto cleanup;
|
||||
}
|
||||
rc = nostr_signer_sign_event(signer, event, &result_obj);
|
||||
cJSON_Delete(event);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(result_obj);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "mine-event") == 0) {
|
||||
const char *event_json = arg1;
|
||||
char *event_buf = NULL;
|
||||
if (!event_json) {
|
||||
event_buf = read_stdin_line();
|
||||
if (!event_buf) {
|
||||
fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
event_json = event_buf;
|
||||
}
|
||||
cJSON *event = cJSON_Parse(event_json);
|
||||
free(event_buf);
|
||||
if (!event) {
|
||||
fprintf(stderr, "error: failed to parse event JSON\n");
|
||||
goto cleanup;
|
||||
}
|
||||
rc = nostr_signer_mine_event(signer, event,
|
||||
has_difficulty ? difficulty_val : 0,
|
||||
has_timeout_sec ? timeout_sec_val : 0,
|
||||
has_threads ? threads_val : 1,
|
||||
&result_obj);
|
||||
cJSON_Delete(event);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(result_obj);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip04-encrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip04-encrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *plaintext = arg2;
|
||||
char *pt_buf = NULL;
|
||||
if (!plaintext) {
|
||||
pt_buf = read_stdin_line();
|
||||
if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; }
|
||||
plaintext = pt_buf;
|
||||
}
|
||||
rc = nostr_signer_nip04_encrypt(signer, arg1, plaintext, &result_str);
|
||||
free(pt_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip04-decrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip04-decrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *ciphertext = arg2;
|
||||
char *ct_buf = NULL;
|
||||
if (!ciphertext) {
|
||||
ct_buf = read_stdin_line();
|
||||
if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; }
|
||||
ciphertext = ct_buf;
|
||||
}
|
||||
rc = nostr_signer_nip04_decrypt(signer, arg1, ciphertext, &result_str);
|
||||
free(ct_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip44-encrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip44-encrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *plaintext = arg2;
|
||||
char *pt_buf = NULL;
|
||||
if (!plaintext) {
|
||||
pt_buf = read_stdin_line();
|
||||
if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; }
|
||||
plaintext = pt_buf;
|
||||
}
|
||||
rc = nostr_signer_nip44_encrypt(signer, arg1, plaintext, &result_str);
|
||||
free(pt_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip44-decrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip44-decrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *ciphertext = arg2;
|
||||
char *ct_buf = NULL;
|
||||
if (!ciphertext) {
|
||||
ct_buf = read_stdin_line();
|
||||
if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; }
|
||||
ciphertext = ct_buf;
|
||||
}
|
||||
rc = nostr_signer_nip44_decrypt(signer, arg1, ciphertext, &result_str);
|
||||
free(ct_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "sign") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: sign requires <msg-hex>\n"); goto cleanup; }
|
||||
size_t msg_len = strlen(arg1) / 2;
|
||||
unsigned char *msg = malloc(msg_len ? msg_len : 1);
|
||||
if (!msg) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
int n = hex_to_bytes(arg1, msg, msg_len);
|
||||
if (n < 0) { free(msg); fprintf(stderr, "error: invalid hex message\n"); goto cleanup; }
|
||||
rc = nostr_signer_sign(signer, algorithm ? algorithm : "secp256k1",
|
||||
eff_index, scheme, msg, (size_t)n, &result_str);
|
||||
free(msg);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "verify") == 0) {
|
||||
if (!arg1 || !arg2) { fprintf(stderr, "error: verify requires <msg-hex> <sig-hex>\n"); goto cleanup; }
|
||||
size_t msg_len = strlen(arg1) / 2;
|
||||
size_t sig_len = strlen(arg2) / 2;
|
||||
unsigned char *msg = malloc(msg_len ? msg_len : 1);
|
||||
unsigned char *sig = malloc(sig_len ? sig_len : 1);
|
||||
if (!msg || !sig) { free(msg); free(sig); fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
int mn = hex_to_bytes(arg1, msg, msg_len);
|
||||
int sn = hex_to_bytes(arg2, sig, sig_len);
|
||||
if (mn < 0 || sn < 0) { free(msg); free(sig); fprintf(stderr, "error: invalid hex\n"); goto cleanup; }
|
||||
int valid = 0;
|
||||
rc = nostr_signer_verify(signer, algorithm ? algorithm : "secp256k1",
|
||||
eff_index, scheme, msg, (size_t)mn, sig, (size_t)sn, &valid);
|
||||
free(msg);
|
||||
free(sig);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
printf("%s\n", valid ? "valid" : "invalid");
|
||||
rc = valid ? 0 : 1;
|
||||
} else if (strcmp(verb, "derive") == 0) {
|
||||
const char *data = arg1;
|
||||
char *data_buf = NULL;
|
||||
if (!data) {
|
||||
data_buf = read_stdin_line();
|
||||
if (!data_buf) { fprintf(stderr, "error: no data provided\n"); goto cleanup; }
|
||||
data = data_buf;
|
||||
}
|
||||
if (is_algorithm_verb) {
|
||||
/* Algorithm-based derive: use low-level client to pass algorithm+index. */
|
||||
cJSON *params = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(data));
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1");
|
||||
cJSON_AddNumberToObject(opts, "index", eff_index);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
cJSON *dresult = NULL;
|
||||
rc = nsigner_client_call(client, "derive", params, &dresult);
|
||||
free(data_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); goto cleanup; }
|
||||
if (cJSON_IsString(dresult)) {
|
||||
/* The derive result is a JSON object string like
|
||||
* {"algorithm":"secp256k1","key_id":"...","digest":"<64hex>"}.
|
||||
* Print the raw result string. */
|
||||
print_result_str(dresult->valuestring);
|
||||
}
|
||||
cJSON_Delete(dresult);
|
||||
rc = 0;
|
||||
} else {
|
||||
/* Nostr derive (HMAC): use the high-level wrapper. */
|
||||
char digest_hex[65];
|
||||
rc = nostr_signer_derive_hmac(signer, data, digest_hex);
|
||||
free(data_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
printf("%s\n", digest_hex);
|
||||
rc = 0;
|
||||
}
|
||||
} else if (strcmp(verb, "encapsulate") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: encapsulate requires <peer-pubkey-hex>\n"); goto cleanup; }
|
||||
rc = nostr_signer_encapsulate(signer, arg1, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "decapsulate") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: decapsulate requires <ciphertext-hex>\n"); goto cleanup; }
|
||||
rc = nostr_signer_decapsulate(signer, eff_index, arg1, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "derive-shared-secret") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: derive-shared-secret requires <peer-pubkey-hex>\n"); goto cleanup; }
|
||||
rc = nostr_signer_derive_shared_secret(signer, eff_index, arg1, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "encrypt") == 0) {
|
||||
const char *plaintext = arg1;
|
||||
char *pt_buf = NULL;
|
||||
if (!plaintext) {
|
||||
pt_buf = read_stdin_line();
|
||||
if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; }
|
||||
plaintext = pt_buf;
|
||||
}
|
||||
rc = nostr_signer_otp_encrypt(signer, plaintext, encoding, &result_str);
|
||||
free(pt_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "decrypt") == 0) {
|
||||
const char *ciphertext = arg1;
|
||||
char *ct_buf = NULL;
|
||||
if (!ciphertext) {
|
||||
ct_buf = read_stdin_line();
|
||||
if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; }
|
||||
ciphertext = ct_buf;
|
||||
}
|
||||
rc = nostr_signer_otp_decrypt(signer, ciphertext, encoding, &result_str);
|
||||
free(ct_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "call") == 0) {
|
||||
/* Raw passthrough using the low-level client (shared with signer). */
|
||||
if (!arg1) { fprintf(stderr, "error: call requires <method>\n"); goto cleanup; }
|
||||
const char *method = arg1;
|
||||
|
||||
cJSON *params = NULL;
|
||||
if (arg2) {
|
||||
size_t total = 0;
|
||||
for (int j = i - 1; j < argc; j++) {
|
||||
total += strlen(argv[j]) + 1;
|
||||
}
|
||||
char *json_str = malloc(total + 1);
|
||||
if (!json_str) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
json_str[0] = '\0';
|
||||
for (int j = i - 1; j < argc; j++) {
|
||||
strcat(json_str, argv[j]);
|
||||
if (j + 1 < argc) strcat(json_str, " ");
|
||||
}
|
||||
params = cJSON_Parse(json_str);
|
||||
free(json_str);
|
||||
if (!params) {
|
||||
fprintf(stderr, "error: failed to parse params JSON from argv\n");
|
||||
goto cleanup;
|
||||
}
|
||||
} else {
|
||||
char *line = read_stdin_line();
|
||||
if (!line) {
|
||||
fprintf(stderr, "error: no params JSON on stdin\n");
|
||||
goto cleanup;
|
||||
}
|
||||
params = cJSON_Parse(line);
|
||||
free(line);
|
||||
if (!params) {
|
||||
fprintf(stderr, "error: failed to parse params JSON from stdin\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON *call_result = NULL;
|
||||
if (nsigner_client_call(client, method, params, &call_result) != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nsigner_client_last_error(client));
|
||||
goto cleanup;
|
||||
}
|
||||
if (call_result) {
|
||||
char *json = cJSON_PrintUnformatted(call_result);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
cJSON_Delete(call_result);
|
||||
}
|
||||
rc = 0;
|
||||
} else {
|
||||
fprintf(stderr, "error: unknown verb: %s\n", verb);
|
||||
fprintf(stderr, "Try '%s --help' for usage.\n", prog);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (result_str) free(result_str);
|
||||
if (result_obj) cJSON_Delete(result_obj);
|
||||
if (signer) nostr_signer_free(signer);
|
||||
nostr_cleanup();
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
# Plan: `n_signer_client` — Linux CLI for n_signer
|
||||
|
||||
## Goal
|
||||
|
||||
A standalone Linux command-line client `n_signer_client` that connects to a
|
||||
running `n_signer` process over its abstract UNIX socket (and optionally the
|
||||
other framed transports) and exposes the full verb surface over stdin/stdout so
|
||||
that signed events can be piped directly into `nak publish`.
|
||||
|
||||
## Deliverable & placement
|
||||
|
||||
The project lives in [`client/`](.) alongside the existing demo clients
|
||||
(`demo_c99.c`, `demo_javascript.js`, `demo_python.py`):
|
||||
|
||||
- New file: [`client/n_signer_client.c`](n_signer_client.c) — single-file C99 program.
|
||||
- New file: [`client/n_signer_client_README.md`](n_signer_client_README.md) — dedicated README just for this client (usage, verbs, pipe-to-nak recipes, build instructions). The existing [`client/README.md`](README.md) stays as-is (it documents the nostr_core_lib migration).
|
||||
- New Makefile target producing `build/n_signer_client`.
|
||||
|
||||
The binary links `nostr_core_lib` exactly like the existing examples
|
||||
[`examples/sign_event_client.c`](../examples/sign_event_client.c) and
|
||||
[`examples/get_public_key_client.c`](../examples/get_public_key_client.c). It
|
||||
uses:
|
||||
|
||||
- `nsigner_transport_open_unix` (and optionally `_tcp`, `_serial`, `_qrexec`) from `nostr_core_lib/nostr_core/nsigner_transport.h`
|
||||
- `nsigner_client_new` / `nsigner_client_free` from `nostr_core_lib/nostr_core/nsigner_client.h`
|
||||
- `nsigner_client_call` (takes ownership of `params`)
|
||||
- `nsigner_client_set_auth` for TCP mode
|
||||
|
||||
## CLI shape
|
||||
|
||||
```
|
||||
n_signer_client [global options] <verb> [verb args...]
|
||||
```
|
||||
|
||||
Global options:
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
|
||||
| `--timeout <ms>` | `5000` | Transport timeout |
|
||||
| `--tcp <host:port>` | none | Use TCP transport (requires `--auth-privkey`) |
|
||||
| `--serial <device>` | none | Use USB CDC-ACM serial transport |
|
||||
| `--qrexec <qube:service>` | none | Use Qubes qrexec transport |
|
||||
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
|
||||
| `--auth-label <text>` | none | Auth envelope label |
|
||||
|
||||
Selector options (apply to `nostr_*` verbs; `--role` and `--path` are mutually exclusive):
|
||||
|
||||
| Flag | Meaning | JSON emitted |
|
||||
|---|---|---|
|
||||
| `--role <name>` | Named path-role registered in the signer's wizard | `{"role":"<name>"}` |
|
||||
| `--path <full-path>` | Full BIP-44 derivation path | `{"role_path":"<full-path>"}` |
|
||||
| `--index <N>` | Optional variable-segment index for a named path-role (only valid with `--role`) | adds `"index":N` to the role object |
|
||||
|
||||
Algorithm options (apply to algorithm-based verbs):
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--algorithm <alg>` | none | `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
|
||||
| `--index <N>` | `0` | Algorithm derivation index (substituted into the alg's path) |
|
||||
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 `sign`/`verify` only |
|
||||
| `--encoding <base64\|hex>` | `base64` | OTP `encrypt`/`decrypt` only |
|
||||
| `--format <plain\|structured>` | `plain` | `nostr_get_public_key` output shape |
|
||||
|
||||
Note on `--index` overload: when `--algorithm` is set, `--index` is the
|
||||
algorithm derivation index. When `--role` is set (and no `--algorithm`),
|
||||
`--index` is the named path-role's variable-segment index. These two contexts
|
||||
never overlap because algorithm verbs and `nostr_*` verbs are distinct.
|
||||
|
||||
Auto-discovery: when no `--socket-name` and no explicit transport is given,
|
||||
enumerate via `nsigner_transport_list_unix` and proceed only if exactly one
|
||||
`nsigner*` socket exists (mirror `discover_single_socket_name` in
|
||||
[`src/main.c`](../src/main.c)).
|
||||
|
||||
## Verb surface (full)
|
||||
|
||||
Per [`README.md`](../README.md) §4.3 verb table. The options object is always
|
||||
the trailing element of the `params` array.
|
||||
|
||||
### Metadata
|
||||
|
||||
| Verb | RPC method | stdout |
|
||||
|---|---|---|
|
||||
| `get-info` | `get_info` | raw `result` JSON (name, version, verbs, algorithms) |
|
||||
|
||||
### Nostr verbs (role-based; selector from `--role` / `--path`)
|
||||
|
||||
| Verb | RPC method | stdin/argv | stdout |
|
||||
|---|---|---|---|
|
||||
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
|
||||
| `sign-event` | `nostr_sign_event` | event JSON from argv or one stdin line | signed event JSON, one line |
|
||||
| `mine-event` | `nostr_mine_event` | event JSON from argv or stdin; options `--difficulty`, `--threads`, `--timeout-sec` | signed mined event JSON |
|
||||
| `nip04-encrypt <peer-pubkey>` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip04-decrypt <peer-pubkey>` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
| `nip44-encrypt <peer-pubkey>` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip44-decrypt <peer-pubkey>` | `nostr_nip44_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
|
||||
### Algorithm-based verbs (use `--algorithm` and `--index`)
|
||||
|
||||
| Verb | RPC method | argv | stdout |
|
||||
|---|---|---|---|
|
||||
| `get-public-key` | `get_public_key` | none | structured JSON `{"algorithm":...,"public_key":...,"key_id":...}` |
|
||||
| `sign <msg-hex>` | `sign` | hex bytes | structured JSON `{"signature":...,"algorithm":...,"key_id":...}` |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | structured JSON `{"algorithm":...,"key_id":...,"digest":...}` |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON `{"ciphertext":...,"shared_secret":...}` |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON `{"shared_secret":...}` |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
| `encrypt <plaintext>` | `encrypt` | plaintext (base64 by default; `--encoding hex`) | ciphertext |
|
||||
| `decrypt <ciphertext>` | `decrypt` | ciphertext | plaintext |
|
||||
|
||||
### Generic escape hatch
|
||||
|
||||
| Verb | RPC method | input | stdout |
|
||||
|---|---|---|---|
|
||||
| `call <method>` | `<method>` | JSON `params` array from stdin (one line) or argv | raw `result` JSON |
|
||||
|
||||
This keeps the client future-proof for any new server verb without a CLI rewrite.
|
||||
|
||||
## stdin/stdout contract (pipe-friendly)
|
||||
|
||||
- All payload output goes to stdout as a single line, newline-terminated.
|
||||
- All diagnostics go to stderr.
|
||||
- Exit code: `0` on success, non-zero on transport/RPC error (use
|
||||
`nsigner_client_last_error` for the message). For `verify`, exit `0` =
|
||||
valid, `1` = invalid, `2` = error.
|
||||
- `sign-event` reads event JSON from argv if present, else reads exactly one
|
||||
line from stdin. This is the pipe-to-nak path:
|
||||
|
||||
```bash
|
||||
echo '{"kind":1,"content":"hello","tags":[],"created_at":1700000000}' \
|
||||
| n_signer_client --role main sign-event \
|
||||
| nak publish
|
||||
```
|
||||
|
||||
- `nip04-encrypt` / `nip44-encrypt` read plaintext from argv or stdin.
|
||||
- `nip04-decrypt` / `nip44-decrypt` read ciphertext from argv or stdin.
|
||||
- `sign` / `verify` / `encapsulate` / `decapsulate` / `derive-shared-secret`
|
||||
take hex from argv (binary payloads, not pipe-friendly text).
|
||||
- `derive` takes UTF-8 data from argv or stdin.
|
||||
- `encrypt` / `decrypt` take their payload from argv or stdin (base64 by
|
||||
default per the server contract).
|
||||
- `call` reads a JSON `params` array from stdin (one line) or argv.
|
||||
|
||||
## Selector handling (per README §4.6)
|
||||
|
||||
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. The
|
||||
client builds the options object from the selector flags:
|
||||
|
||||
- `--role <name>` → `{"role":"<name>"}` (named path-role; the derivation path
|
||||
is hidden from the client by the signer).
|
||||
- `--role <name> --index <N>` → `{"role":"<name>","index":N}` (named path-role
|
||||
with variable-segment index; rejected with `2005 index_out_of_range` if out
|
||||
of the role's range).
|
||||
- `--path <full-path>` → `{"role_path":"<full-path>"}` (raw BIP-44 path; must
|
||||
match a registered role's path template or be explicitly allowed).
|
||||
- Default (no selector): server uses the default role `main`.
|
||||
- Conflicting selectors → client-side error (do not send; the server would
|
||||
reject with `ambiguous_role_selector` 1001).
|
||||
|
||||
Resolution order on the server: `role` → `role_path` → default `main`. The
|
||||
client enforces mutual exclusivity of the selector flags before sending.
|
||||
|
||||
For algorithm verbs, `--algorithm` and `--index` populate the options object
|
||||
instead; `--scheme` adds `"scheme"` for secp256k1 sign/verify; `--encoding`
|
||||
adds `"encoding"` for OTP encrypt/decrypt.
|
||||
|
||||
## Transport
|
||||
|
||||
- Default: UNIX abstract socket via `nsigner_transport_open_unix(name, timeout_ms)`.
|
||||
- `--tcp host:port` → `nsigner_transport_open_tcp` (requires `--auth-privkey`
|
||||
32-byte hex; calls `nsigner_client_set_auth` with `--auth-label`).
|
||||
- `--serial /dev/ttyACM0` → `nsigner_transport_open_serial`.
|
||||
- `--qrexec qube:service` → `nsigner_transport_open_qrexec`.
|
||||
- The vtable is uniform so all four transports share the same call path after
|
||||
construction.
|
||||
|
||||
## Build
|
||||
|
||||
Add to [`Makefile`](../Makefile):
|
||||
|
||||
```make
|
||||
N_SIGNER_CLIENT_TARGET := $(BUILD_DIR)/n_signer_client
|
||||
|
||||
clients: $(N_SIGNER_CLIENT_TARGET)
|
||||
|
||||
$(N_SIGNER_CLIENT_TARGET): $(CLIENT_DIR)/n_signer_client.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(CLIENT_DIR)/n_signer_client.c -o $(N_SIGNER_CLIENT_TARGET) $(LDFLAGS)
|
||||
```
|
||||
|
||||
Add `clients` to the `all` aggregate and to the `test-client` target so it is
|
||||
built alongside the examples.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Manual smoke test against a running `nsigner`:
|
||||
- `n_signer_client get-info` → signer metadata JSON.
|
||||
- `n_signer_client --role main get-public-key` → 64-hex pubkey.
|
||||
- `echo '{"kind":1,"content":"hello","tags":[],"created_at":1}' | n_signer_client --role main sign-event` → signed event with `id`, `pubkey`, `sig`.
|
||||
- Pipe to `nak event` / `nak publish` to verify the signed event is well-formed.
|
||||
- `n_signer_client --algorithm ed25519 --index 0 sign 68656c6c6f` → structured sig JSON.
|
||||
- `n_signer_client --role myrole get-public-key` → pubkey for the named path-role.
|
||||
2. Optional bash script `tests/test_n_signer_client.sh` that:
|
||||
- Spawns `nsigner --socket-name nsigner_test --listen unix --mnemonic-stdin` with a fixed test mnemonic.
|
||||
- Runs each verb and asserts on stdout shape.
|
||||
- Tears down the server.
|
||||
|
||||
## Mermaid flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[stdin or argv event JSON] --> B[n_signer_client sign-event]
|
||||
B --> C[nsigner_transport_open_unix]
|
||||
C --> D[nsigner_client_call nostr_sign_event]
|
||||
D --> E[nsigner @nsigner socket]
|
||||
E --> F[signed event JSON result]
|
||||
F --> G[stdout one line]
|
||||
G --> H[nak publish]
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No TUI, no approval UI — the human attendant lives in the running `nsigner`
|
||||
process; the client is just a thin wire caller.
|
||||
- No key storage, no mnemonic handling.
|
||||
- No HTTP listener client (the `http_listener` is server-side; the client uses
|
||||
the framed transports).
|
||||
- No NIP-46 bunker mode (covered separately by
|
||||
[`plans/nip46_bunker_mode.md`](../plans/nip46_bunker_mode.md)).
|
||||
@@ -0,0 +1,171 @@
|
||||
# `nsigner_client` — Linux CLI for n_signer
|
||||
|
||||
A standalone Linux command-line client that connects to a running [`n_signer`](https://github.com/your-org/n_signer) process and calls its JSON-RPC verbs over stdin/stdout. Designed for pipe-to-`nak` workflows.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make clients
|
||||
```
|
||||
|
||||
Produces `build/nsigner_client`. Links `nostr_core_lib` exactly like the existing examples.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
nsigner_client [global options] <verb> [verb args...]
|
||||
```
|
||||
|
||||
### Global options
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
|
||||
| `--timeout <ms>` | `5000` | Transport timeout |
|
||||
| `--tcp <host:port>` | none | TCP transport (requires `--auth-privkey`) |
|
||||
| `--serial <device>` | none | USB CDC-ACM serial transport |
|
||||
| `--qrexec <qube:service>` | none | Qubes qrexec transport |
|
||||
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
|
||||
| `--auth-label <text>` | none | Auth envelope label |
|
||||
|
||||
### Selector options (for `nostr_*` verbs)
|
||||
|
||||
| Flag | Meaning | JSON emitted |
|
||||
|------|---------|-------------|
|
||||
| `--role <name>` | Named path-role registered in the signer | `{"role":"<name>"}` |
|
||||
| `--path <path>` | Full BIP-44 derivation path | `{"role_path":"<path>"}` |
|
||||
|
||||
### Algorithm options (for algorithm-based verbs)
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `--algorithm <alg>` | none | `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
|
||||
| `--index <N>` | `0` | Algorithm derivation index |
|
||||
| `--scheme <schnork\|ecdsa>` | `schnorr` | secp256k1 `sign`/`verify` only |
|
||||
| `--encoding <base64\|hex>` | `base64` | OTP `encrypt`/`decrypt` only |
|
||||
| `--format <plain\|structured>` | `plain` | `nostr_get_public_key` output shape |
|
||||
|
||||
### Mine-event options
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--difficulty <N>` | Target leading zero bits |
|
||||
| `--threads <N>` | Mining threads (default 1) |
|
||||
| `--timeout-sec <N>` | Mining timeout in seconds |
|
||||
|
||||
## Verb reference
|
||||
|
||||
### Utility
|
||||
|
||||
| Verb | stdout |
|
||||
|------|--------|
|
||||
| `list` | Lists running n_signer abstract sockets (one `@name` per line) |
|
||||
|
||||
### Metadata
|
||||
|
||||
| Verb | RPC method | stdout |
|
||||
|------|------------|--------|
|
||||
| `get-info` | `get_info` | raw result JSON (name, version, verbs, algorithms) |
|
||||
|
||||
### Nostr verbs (role-based)
|
||||
|
||||
| Verb | RPC method | stdin/argv | stdout |
|
||||
|------|------------|------------|--------|
|
||||
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
|
||||
| `sign-event` | `nostr_sign_event` | event JSON from argv or stdin | signed event JSON |
|
||||
| `mine-event` | `nostr_mine_event` | event JSON from argv or stdin | signed mined event JSON |
|
||||
| `nip04-encrypt <peer>` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip04-decrypt <peer>` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
| `nip44-encrypt <peer>` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip44-decrypt <peer>` | `nostr_nip44_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
|
||||
### Algorithm-based verbs
|
||||
|
||||
| Verb | RPC method | argv | stdout |
|
||||
|------|------------|------|--------|
|
||||
| `get-public-key` | `get_public_key` | none | structured JSON `{"algorithm":...,"public_key":...,"key_id":...}` |
|
||||
| `sign <msg-hex>` | `sign` | hex bytes | structured JSON `{"signature":...,"algorithm":...,"key_id":...}` |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | structured JSON |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
| `encrypt <plaintext>` | `encrypt` | plaintext (base64 by default) | ciphertext |
|
||||
| `decrypt <ciphertext>` | `decrypt` | ciphertext | plaintext |
|
||||
|
||||
### Generic escape hatch
|
||||
|
||||
| Verb | RPC method | input | stdout |
|
||||
|------|------------|-------|--------|
|
||||
| `call <method>` | `<method>` | JSON params array from stdin or argv | raw result JSON |
|
||||
|
||||
## Selector explanation
|
||||
|
||||
The `nostr_*` verbs select a key via the options object using both `--role` and `--path`:
|
||||
|
||||
- **`--role <name> --path <path>`** — Both are required for all `nostr_*` verbs. The role authorizes the request and determines the encryption scheme. The path selects the specific key to derive. Sends `{"role":"<name>","role_path":"<path>"}` to the server.
|
||||
- **`--role` without `--path`** — Client-side error: `--path is required for nostr verbs`.
|
||||
- **`--path` without `--role`** — Client-side error: `--role is required for nostr verbs`.
|
||||
|
||||
For algorithm verbs, `--algorithm` and `--index` populate the options object instead.
|
||||
|
||||
## Pipe-to-nak recipes
|
||||
|
||||
```bash
|
||||
# Get public key
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" get-public-key
|
||||
|
||||
# Sign an event and publish via nak
|
||||
echo '{"kind":1,"content":"hello nostr","tags":[],"created_at":1700000000}' \
|
||||
| nsigner_client --role main --path "m/44'/1237'/0'/0/0" sign-event \
|
||||
| nak publish
|
||||
|
||||
# Mine a proof-of-work event
|
||||
echo '{"kind":1,"content":"pow","tags":[],"created_at":1700000000}' \
|
||||
| nsigner_client --role main --path "m/44'/1237'/0'/0/0" mine-event --difficulty 20 --threads 4
|
||||
|
||||
# NIP-44 encrypt
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" nip44-encrypt <peer-pubkey> "secret message"
|
||||
|
||||
# Algorithm-based signing
|
||||
nsigner_client --algorithm ed25519 --index 0 sign 68656c6c6f
|
||||
|
||||
# Verify a signature
|
||||
nsigner_client --algorithm secp256k1 verify <msg-hex> <sig-hex> && echo "valid"
|
||||
|
||||
# Get signer info
|
||||
nsigner_client get-info
|
||||
```
|
||||
|
||||
## Transport options
|
||||
|
||||
| Transport | Flag | Notes |
|
||||
|-----------|------|-------|
|
||||
| UNIX abstract socket | `--socket-name <name>` or auto-discover | Default. Auto-discovers if exactly one `@nsigner*` socket exists. |
|
||||
| TCP | `--tcp <host:port>` | Requires `--auth-privkey` for auth envelope. |
|
||||
| Serial (USB CDC-ACM) | `--serial <device>` | e.g. `--serial /dev/ttyACM0` |
|
||||
| Qubes qrexec | `--qrexec <qube:service>` | e.g. `--qrexec sys-signer:qubes.NsignerRpc` |
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Invalid (verify verb only — signature is invalid) |
|
||||
| 2 | Error (transport, RPC, or usage error) |
|
||||
|
||||
For `verify`: exit 0 = valid signature, exit 1 = invalid signature, exit 2 = error.
|
||||
|
||||
## stdin/stdout contract
|
||||
|
||||
- All payload output goes to stdout as a single line, newline-terminated.
|
||||
- All diagnostics (errors, warnings) go to stderr.
|
||||
- `sign-event`, `nip04-*`, `nip44-*`, `derive`, `encrypt`, `decrypt` read their payload from argv if present, otherwise from stdin (one line).
|
||||
- `sign`, `verify`, `encapsulate`, `decapsulate`, `derive-shared-secret` take hex from argv only (binary payloads).
|
||||
- `call` reads a JSON params array from stdin (one line) or argv.
|
||||
|
||||
## See also
|
||||
|
||||
- [`n_signer_client_PLAN.md`](n_signer_client_PLAN.md) — the full implementation plan
|
||||
- [`README.md`](../README.md) — n_signer main documentation (API §4)
|
||||
- [`examples/sign_event_client.c`](../examples/sign_event_client.c) — reference example
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# deploy_local.sh — Build static nsigner + nsigner_client binaries
|
||||
# and install them to /usr/local/bin/
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy_local.sh # build + install (prompts for sudo)
|
||||
# ./deploy_local.sh --no-build # install existing build/ binaries only
|
||||
# ./deploy_local.sh --force # skip confirmation prompt
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
INSTALL_PREFIX="/usr/local/bin"
|
||||
|
||||
HOST_UNAME="$(uname -m)"
|
||||
case "$HOST_UNAME" in
|
||||
x86_64) ARCH="x86_64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
armv7l|armv7) ARCH="armv7" ;;
|
||||
*)
|
||||
echo "ERROR: Unsupported host architecture '$HOST_UNAME'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
SIGNER_BIN="$BUILD_DIR/nsigner_static_x86_64"
|
||||
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_x86_64"
|
||||
;;
|
||||
arm64)
|
||||
SIGNER_BIN="$BUILD_DIR/nsigner_static_arm64"
|
||||
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_arm64"
|
||||
;;
|
||||
armv7)
|
||||
SIGNER_BIN="$BUILD_DIR/nsigner_static_armv7"
|
||||
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_armv7"
|
||||
;;
|
||||
esac
|
||||
|
||||
DO_BUILD=true
|
||||
FORCE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-build)
|
||||
DO_BUILD=false
|
||||
shift
|
||||
;;
|
||||
--force|-f)
|
||||
FORCE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "deploy_local.sh — Build and install nsigner + nsigner_client to $INSTALL_PREFIX"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "OPTIONS:"
|
||||
echo " --no-build Skip build step; install existing binaries from build/"
|
||||
echo " --force, -f Skip confirmation prompt"
|
||||
echo " -h, --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument '$1'"
|
||||
echo "Usage: $0 [--no-build] [--force]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=========================================="
|
||||
echo "nsigner local deploy"
|
||||
echo "=========================================="
|
||||
echo "Architecture: $ARCH"
|
||||
echo "Install dir: $INSTALL_PREFIX"
|
||||
echo "Signer binary: $SIGNER_BIN"
|
||||
echo "Client binary: $CLIENT_BIN"
|
||||
echo ""
|
||||
|
||||
# --- Build step ---------------------------------------------------------------
|
||||
if $DO_BUILD; then
|
||||
echo "[1/3] Building static binaries via build_static.sh"
|
||||
echo ""
|
||||
bash "$SCRIPT_DIR/build_static.sh" --arch "$ARCH"
|
||||
echo ""
|
||||
else
|
||||
echo "[1/3] Skipping build (--no-build)"
|
||||
fi
|
||||
|
||||
# --- Verify binaries exist ----------------------------------------------------
|
||||
echo "[2/3] Verifying binaries"
|
||||
if [[ ! -f "$SIGNER_BIN" ]]; then
|
||||
echo "ERROR: Signer binary not found: $SIGNER_BIN"
|
||||
echo " Run without --no-build, or run build_static.sh first."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$SIGNER_BIN" ]]; then
|
||||
echo "ERROR: Signer binary is not executable: $SIGNER_BIN"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$CLIENT_BIN" ]]; then
|
||||
echo "ERROR: Client binary not found: $CLIENT_BIN"
|
||||
echo " Run without --no-build, or run build_static.sh first."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$CLIENT_BIN" ]]; then
|
||||
echo "ERROR: Client binary is not executable: $CLIENT_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " OK: $SIGNER_BIN ($(du -h "$SIGNER_BIN" | cut -f1))"
|
||||
echo " OK: $CLIENT_BIN ($(du -h "$CLIENT_BIN" | cut -f1))"
|
||||
|
||||
# Quick smoke test
|
||||
SIGNER_VERSION="$("$SIGNER_BIN" --version 2>&1 || echo "unknown")"
|
||||
echo " Signer version: $SIGNER_VERSION"
|
||||
|
||||
# --- Confirm ------------------------------------------------------------------
|
||||
if ! $FORCE; then
|
||||
echo ""
|
||||
echo "About to install:"
|
||||
echo " $SIGNER_BIN -> $INSTALL_PREFIX/nsigner"
|
||||
echo " $CLIENT_BIN -> $INSTALL_PREFIX/nsigner_client"
|
||||
echo ""
|
||||
read -r -p "Proceed? [y/N] " response
|
||||
case "$response" in
|
||||
[yY][eE][sS]|[yY]) ;;
|
||||
*)
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- Install ------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "[3/3] Installing to $INSTALL_PREFIX"
|
||||
|
||||
SUDO=""
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
echo "ERROR: Need root privileges to write to $INSTALL_PREFIX but sudo is not available"
|
||||
exit 1
|
||||
fi
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
$SUDO install -m 0755 "$SIGNER_BIN" "$INSTALL_PREFIX/nsigner"
|
||||
$SUDO install -m 0755 "$CLIENT_BIN" "$INSTALL_PREFIX/nsigner_client"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Deploy complete!"
|
||||
echo "=========================================="
|
||||
echo " $INSTALL_PREFIX/nsigner"
|
||||
echo " $INSTALL_PREFIX/nsigner_client"
|
||||
echo ""
|
||||
echo "Verify:"
|
||||
echo " nsigner --version"
|
||||
echo " nsigner_client --help"
|
||||
@@ -0,0 +1,736 @@
|
||||
# Agent Client Reference — n_signer
|
||||
|
||||
This document is the complete reference for **agents, AI tools, and automated processes** that need to call [`n_signer`](../README.md) — a signing oracle that holds BIP-39 keys in locked memory and exposes them over multiple transports.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture overview](#1-architecture-overview)
|
||||
2. [Wire protocol (all transports)](#2-wire-protocol-all-transports)
|
||||
3. [Transport: Qubes qrexec (recommended for cross-qube)](#3-transport-qubes-qrexec-recommended-for-cross-qube)
|
||||
4. [Transport: Local Unix abstract socket](#4-transport-local-unix-abstract-socket)
|
||||
5. [Transport: TCP (FIPS mesh)](#5-transport-tcp-fips-mesh)
|
||||
6. [Transport: HTTP](#6-transport-http)
|
||||
7. [Transport: USB/serial (hardware signers)](#7-transport-usbserial-hardware-signers)
|
||||
8. [Transport: Stdio (one-shot)](#8-transport-stdio-one-shot)
|
||||
9. [Auth envelope (kind-27235)](#9-auth-envelope-kind-27235)
|
||||
10. [Complete verb reference](#10-complete-verb-reference)
|
||||
11. [Algorithm reference](#11-algorithm-reference)
|
||||
12. [Error codes](#12-error-codes)
|
||||
13. [End-to-end: publish a Nostr event](#13-end-to-end-publish-a-nostr-event)
|
||||
14. [End-to-end: sign arbitrary data](#14-end-to-end-sign-arbitrary-data)
|
||||
15. [End-to-end: PQ KEM encapsulate/decapsulate](#15-end-to-end-pq-kem-encapsulatedecapsulate)
|
||||
16. [Discovery: finding running signers](#16-discovery-finding-running-signers)
|
||||
17. [Prerequisites and setup](#17-prerequisites-and-setup)
|
||||
18. [Reference files in this repo](#18-reference-files-in-this-repo)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Caller (agent) │
|
||||
│ Python / Node / C / Shell / any language │
|
||||
│ Builds framed JSON-RPC → sends over transport → reads resp │
|
||||
└──────────┬──────────────────────────────────────┬───────────┘
|
||||
│ │
|
||||
┌─────┴──────┐ ┌──────────┴──────────┐
|
||||
│ qrexec │ │ TCP / HTTP / Unix │
|
||||
│ (Qubes) │ │ Socket / USB │
|
||||
└─────┬──────┘ └──────────┬──────────┘
|
||||
│ │
|
||||
┌─────┴──────────────────────────────────────┴───────────┐
|
||||
│ n_signer │
|
||||
│ BIP-39 mnemonic in mlock'd RAM │
|
||||
│ Derives keys on demand (secp256k1, ed25519, PQ, etc.) │
|
||||
│ Enforces policy + approval per caller │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The signer is a **single foreground process** attached to a terminal. It holds the mnemonic in locked memory only — nothing touches disk. When the process exits, all state is destroyed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wire protocol (all transports)
|
||||
|
||||
Every transport uses the same framing and JSON-RPC contract.
|
||||
|
||||
### 2.1 Framing
|
||||
|
||||
```
|
||||
[4 bytes: big-endian payload length N][N bytes: UTF-8 JSON payload]
|
||||
```
|
||||
|
||||
- Length prefix is `uint32_t` in network byte order.
|
||||
- Payload is exactly `N` bytes of UTF-8 JSON.
|
||||
- One request frame → one response frame per connection (except HTTP which uses standard HTTP request/response).
|
||||
|
||||
### 2.2 Request shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<caller-supplied string, echoed in response>",
|
||||
"method": "<verb name>",
|
||||
"params": [ <arg0>, <arg1>, ..., { <options> } ]
|
||||
}
|
||||
```
|
||||
|
||||
- `id` — any string. Used to match async responses. Echoed verbatim.
|
||||
- `method` — one of the verbs in §10.
|
||||
- `params` — JSON array. Positional args first; last element is conventionally an options object.
|
||||
|
||||
### 2.3 Response shape
|
||||
|
||||
Success:
|
||||
```json
|
||||
{ "id": "<string>", "result": "<value>" }
|
||||
```
|
||||
|
||||
Error:
|
||||
```json
|
||||
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
|
||||
```
|
||||
|
||||
`result` is always a JSON string. For structured verbs (like `get_public_key` with algorithm), the string is itself serialized JSON — parse it again.
|
||||
|
||||
### 2.4 Auth envelope (required for TCP/HTTP, optional for qrexec)
|
||||
|
||||
TCP and HTTP listeners require a kind-27235 auth envelope in the `auth` field. See [§9](#9-auth-envelope-kind-27235).
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport: Qubes qrexec (recommended for cross-qube)
|
||||
|
||||
**Best for**: Agents running in a caller qube, talking to n_signer in a dedicated signer qube.
|
||||
|
||||
**Auth**: Not required — caller identity comes from `QREXEC_REMOTE_DOMAIN` as `qubes:<source-vm>`.
|
||||
|
||||
### 3.1 Shell one-liner
|
||||
|
||||
```bash
|
||||
# Get public key for nostr_index 0
|
||||
printf '\x00\x00\x00\x3f'"$(echo '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}')" | \
|
||||
qrexec-client-vm nostr_signer qubes.NsignerRpc | tail -c +5
|
||||
```
|
||||
|
||||
### 3.2 Python (stdlib only)
|
||||
|
||||
```python
|
||||
import json, struct, subprocess
|
||||
|
||||
def call_nsigner(target_qube, request):
|
||||
"""Send one framed JSON-RPC request via qrexec, return parsed response."""
|
||||
payload = json.dumps(request, separators=(",", ":")).encode("utf-8")
|
||||
frame = struct.pack(">I", len(payload)) + payload
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
out, err = proc.communicate(frame)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"qrexec failed: {err.decode()}")
|
||||
|
||||
length = struct.unpack(">I", out[:4])[0]
|
||||
return json.loads(out[4:4+length])
|
||||
```
|
||||
|
||||
Full demo: [`client/demo_python.py`](../client/demo_python.py)
|
||||
|
||||
### 3.3 Node.js
|
||||
|
||||
```javascript
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function callNsigner(targetQube, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = Buffer.from(JSON.stringify(request), "utf8");
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(payload.length, 0);
|
||||
const framed = Buffer.concat([header, payload]);
|
||||
|
||||
const proc = spawn("qrexec-client-vm", [targetQube, "qubes.NsignerRpc"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
proc.stdout.on("data", (c) => chunks.push(c));
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) return reject(new Error(`exit code ${code}`));
|
||||
const buf = Buffer.concat(chunks);
|
||||
const len = buf.readUInt32BE(0);
|
||||
resolve(JSON.parse(buf.subarray(4, 4 + len).toString()));
|
||||
});
|
||||
proc.stdin.write(framed);
|
||||
proc.stdin.end();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Full demo: [`client/demo_javascript.js`](../client/demo_javascript.js)
|
||||
|
||||
### 3.4 C (using nostr_core_lib)
|
||||
|
||||
```c
|
||||
#include "nostr_signer.h"
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_nsigner_qrexec("nostr_signer", "qubes.NsignerRpc", NULL, 30000);
|
||||
nostr_signer_nsigner_set_nostr_index(signer, 0);
|
||||
char pubkey[65];
|
||||
nostr_signer_get_public_key(signer, pubkey);
|
||||
```
|
||||
|
||||
Full demo: [`client/demo_c99.c`](../client/demo_c99.c)
|
||||
|
||||
### 3.5 Prerequisites
|
||||
|
||||
1. n_signer running in the signer qube: `nsigner --listen unix --socket-name nsigner --bridge-source-trusted`
|
||||
2. qrexec service installed at `/etc/qubes-rpc/qubes.NsignerRpc`: `exec nsigner bridge --to nsigner`
|
||||
3. dom0 policy allowing caller qube → signer qube (see [`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy))
|
||||
|
||||
---
|
||||
|
||||
## 4. Transport: Local Unix abstract socket
|
||||
|
||||
**Best for**: Agents on the same machine as the signer.
|
||||
|
||||
**Auth**: Not required — identity from `SO_PEERCRED` (kernel-verified UID/PID).
|
||||
|
||||
### 4.1 Discovery
|
||||
|
||||
```bash
|
||||
nsigner list
|
||||
# Output: @nsigner, @nsigner_hairy_dog, etc.
|
||||
```
|
||||
|
||||
### 4.2 Using the nsigner CLI
|
||||
|
||||
```bash
|
||||
nsigner client '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}' --socket-name nsigner
|
||||
```
|
||||
|
||||
### 4.3 C (using nostr_core_lib)
|
||||
|
||||
```c
|
||||
#include "nsigner_transport.h"
|
||||
#include "nsigner_client.h"
|
||||
|
||||
nsigner_transport_t *t = nsigner_transport_open_unix("nsigner", 5000);
|
||||
nsigner_client_t *c = nsigner_client_new(t);
|
||||
cJSON *params = cJSON_CreateArray();
|
||||
cJSON *result = NULL;
|
||||
nsigner_client_call(c, "nostr_get_public_key", params, &result);
|
||||
```
|
||||
|
||||
See [`examples/get_public_key_client.c`](../examples/get_public_key_client.c).
|
||||
|
||||
### 4.4 Python (raw socket)
|
||||
|
||||
```python
|
||||
import json, socket, struct
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect("\0nsigner") # \0 prefix for abstract namespace
|
||||
|
||||
payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode()
|
||||
sock.sendall(struct.pack(">I", len(payload)) + payload)
|
||||
|
||||
hdr = sock.recv(4)
|
||||
length = struct.unpack(">I", hdr)[0]
|
||||
body = sock.recv(length)
|
||||
print(json.loads(body))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Transport: TCP (FIPS mesh)
|
||||
|
||||
**Best for**: Cross-qube or network callers where qrexec is not available.
|
||||
|
||||
**Auth**: **Required** — kind-27235 auth envelope (see [§9](#9-auth-envelope-kind-27235)).
|
||||
|
||||
### 5.1 Python (with coincurve)
|
||||
|
||||
```python
|
||||
import hashlib, json, socket, struct, time
|
||||
from coincurve import PrivateKey
|
||||
|
||||
HOST, PORT = "192.168.1.100", 11111
|
||||
CALLER_PRIVKEY = bytes(range(1, 33)) # Replace with your key
|
||||
|
||||
params = [{"nostr_index": 0}]
|
||||
body_hash = hashlib.sha256(json.dumps(params, separators=(",",":")).encode()).hexdigest()
|
||||
|
||||
sk = PrivateKey(CALLER_PRIVKEY)
|
||||
pubkey_x = sk.public_key.format(compressed=False)[1:33].hex()
|
||||
created_at = int(time.time())
|
||||
|
||||
tags = [["nsigner_rpc","1"],["nsigner_method","get_public_key"],["nsigner_body_hash",body_hash]]
|
||||
serialized = json.dumps([0, pubkey_x, created_at, 27235, tags, "tcp-agent"], separators=(",",":")).encode()
|
||||
event_id = hashlib.sha256(serialized).hexdigest()
|
||||
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00"*32).hex()
|
||||
|
||||
request = {
|
||||
"id": "1", "method": "get_public_key", "params": params,
|
||||
"auth": {"id": event_id, "pubkey": pubkey_x, "created_at": created_at,
|
||||
"kind": 27235, "tags": tags, "content": "tcp-agent", "sig": sig},
|
||||
}
|
||||
|
||||
payload = json.dumps(request, separators=(",",":")).encode()
|
||||
frame = struct.pack(">I", len(payload)) + payload
|
||||
|
||||
with socket.create_connection((HOST, PORT), timeout=10) as s:
|
||||
s.sendall(frame)
|
||||
hdr = s.recv(4)
|
||||
ln = struct.unpack(">I", hdr)[0]
|
||||
body = s.recv(ln)
|
||||
|
||||
print(json.loads(body))
|
||||
```
|
||||
|
||||
See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js).
|
||||
|
||||
### 5.2 C (using nostr_core_lib)
|
||||
|
||||
```c
|
||||
nsigner_transport_t *t = nsigner_transport_open_tcp("192.168.1.100", 11111, 10000);
|
||||
nsigner_client_t *c = nsigner_client_new(t);
|
||||
nsigner_client_set_auth(c, caller_privkey, "tcp-agent");
|
||||
// ... call as usual
|
||||
```
|
||||
|
||||
See [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c).
|
||||
|
||||
---
|
||||
|
||||
## 6. Transport: HTTP
|
||||
|
||||
**Best for**: curl-friendly callers, REST clients.
|
||||
|
||||
**Auth**: **Required** — kind-27235 auth envelope in the JSON body.
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.1.100:11112/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id":"1",
|
||||
"method":"get_public_key",
|
||||
"params":[{"nostr_index":0}],
|
||||
"auth":{...}
|
||||
}'
|
||||
```
|
||||
|
||||
The HTTP listener uses the same framing internally but presents a standard HTTP interface. One request per connection.
|
||||
|
||||
---
|
||||
|
||||
## 7. Transport: USB/serial (hardware signers)
|
||||
|
||||
**Best for**: Embedded/air-gap signers (Feather S3, Teensy 4.1, CYD ESP32).
|
||||
|
||||
**Auth**: Not required — physical possession is the trust anchor.
|
||||
|
||||
### 7.1 Python (pyserial)
|
||||
|
||||
```python
|
||||
import json, struct, serial
|
||||
|
||||
ser = serial.Serial("/dev/ttyACM0", 115200, timeout=5)
|
||||
|
||||
payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode()
|
||||
ser.write(struct.pack(">I", len(payload)) + payload)
|
||||
|
||||
hdr = ser.read(4)
|
||||
length = struct.unpack(">I", hdr)[0]
|
||||
body = ser.read(length)
|
||||
print(json.loads(body))
|
||||
```
|
||||
|
||||
See [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) and [`examples/feather_sign_event.py`](../examples/feather_sign_event.py).
|
||||
|
||||
---
|
||||
|
||||
## 8. Transport: Stdio (one-shot)
|
||||
|
||||
**Best for**: Scripted one-off calls via pipe.
|
||||
|
||||
The signer can be started with `--listen stdio` to handle exactly one framed request on stdin and write one framed response to stdout.
|
||||
|
||||
```bash
|
||||
echo -n '<framed request>' | nsigner --listen stdio --allow-all
|
||||
```
|
||||
|
||||
Or via the qrexec bridge service (which uses stdio internally to relay to the persistent signer's Unix socket).
|
||||
|
||||
---
|
||||
|
||||
## 9. Auth envelope (kind-27235)
|
||||
|
||||
TCP and HTTP listeners require a **kind-27235 Nostr event** in the `auth` field of every request. This proves the caller controls a keypair.
|
||||
|
||||
### 9.1 Auth envelope structure
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"id": "<sha256 of serialized event>",
|
||||
"pubkey": "<caller's secp256k1 x-only pubkey hex>",
|
||||
"created_at": <unix timestamp>,
|
||||
"kind": 27235,
|
||||
"tags": [
|
||||
["nsigner_rpc", "<request id>"],
|
||||
["nsigner_method", "<method name>"],
|
||||
["nsigner_body_hash", "<sha256 of canonical params JSON>"]
|
||||
],
|
||||
"content": "<arbitrary string>",
|
||||
"sig": "<schnorr signature hex>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Building the auth envelope (pseudocode)
|
||||
|
||||
```
|
||||
1. Compute body_hash = SHA256(JSON.stringify(params, separators=(",",":")))
|
||||
2. Build tags: [["nsigner_rpc", id], ["nsigner_method", method], ["nsigner_body_hash", body_hash]]
|
||||
3. Serialize event for signing: JSON.stringify([0, pubkey, created_at, 27235, tags, content])
|
||||
4. Compute event_id = SHA256(serialized)
|
||||
5. Sign event_id with caller's secp256k1 key (Schnorr/BIP-340)
|
||||
6. Include full event as the "auth" field in the request
|
||||
```
|
||||
|
||||
### 9.3 Validation rules (server-side)
|
||||
|
||||
| Check | Error code |
|
||||
|-------|-----------|
|
||||
| Missing `auth` field | 2014 `auth_envelope_required` |
|
||||
| Malformed auth JSON | 2010 `auth_envelope_malformed` |
|
||||
| `kind` != 27235 | 2013 `auth_kind_invalid` |
|
||||
| Signature doesn't verify | 2012 `auth_signature_invalid` |
|
||||
| `nsigner_rpc` tag != request `id` | 2011 `auth_body_mismatch` |
|
||||
| `nsigner_method` tag != request `method` | 2011 `auth_body_mismatch` |
|
||||
| `nsigner_body_hash` != SHA256(params) | 2011 `auth_body_mismatch` |
|
||||
| Timestamp skew > 300 seconds | 2015 `auth_envelope_mismatch` |
|
||||
| Replay (same event_id seen before) | 2015 `auth_envelope_mismatch` |
|
||||
|
||||
### 9.4 Python example (full)
|
||||
|
||||
See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) for a complete working example.
|
||||
|
||||
### 9.5 JavaScript example (full)
|
||||
|
||||
See [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) for a complete working example.
|
||||
|
||||
---
|
||||
|
||||
## 10. Complete verb reference
|
||||
|
||||
### 10.1 Algorithm-based verbs
|
||||
|
||||
These select a key by `algorithm` + `index` (see [§11](#11-algorithm-reference) for derivation paths).
|
||||
|
||||
| Verb | Algorithms | Params | Options |
|
||||
|------|-----------|--------|---------|
|
||||
| `get_public_key` | All key-deriving | `[]` | `algorithm`, `index` |
|
||||
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `[<message_hex>]` | `algorithm`, `index`, `scheme`* |
|
||||
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `[<message_hex>, <sig_hex>]` | `algorithm`, `index`, `scheme`* |
|
||||
| `encapsulate` | ml-kem-768 | `[<peer_pubkey_hex>]` | `algorithm` |
|
||||
| `decapsulate` | ml-kem-768 | `[<ciphertext_hex>]` | `algorithm`, `index` |
|
||||
| `derive_shared_secret` | x25519 | `[<peer_pubkey_hex>]` | `algorithm`, `index` |
|
||||
| `derive` | secp256k1 | `[<data>]` | `algorithm`, `index` (required) |
|
||||
| `encrypt` | otp | `[<plaintext_base64>]` | `algorithm`, `encoding` |
|
||||
| `decrypt` | otp | `[<ciphertext>]` | `algorithm`, `encoding` |
|
||||
|
||||
\* `scheme`: `"schnorr"` (default) or `"ecdsa"` for secp256k1.
|
||||
|
||||
### 10.2 Nostr protocol verbs
|
||||
|
||||
These select a secp256k1 NIP-06 key via `nostr_index` (or `role`/`role_path`).
|
||||
|
||||
| Verb | Params | Options |
|
||||
|------|--------|---------|
|
||||
| `nostr_get_public_key` | `[]` | `nostr_index`, `format` |
|
||||
| `nostr_sign_event` | `[<event_json>]` | `nostr_index` |
|
||||
| `nostr_mine_event` | `[<event_json>]` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` |
|
||||
| `nostr_nip04_encrypt` | `[<peer_pubkey_hex>, <plaintext>]` | `nostr_index` |
|
||||
| `nostr_nip04_decrypt` | `[<peer_pubkey_hex>, <ciphertext>]` | `nostr_index` |
|
||||
| `nostr_nip44_encrypt` | `[<peer_pubkey_hex>, <plaintext>]` | `nostr_index` |
|
||||
| `nostr_nip44_decrypt` | `[<peer_pubkey_hex>, <ciphertext>]` | `nostr_index` |
|
||||
|
||||
### 10.3 Metadata
|
||||
|
||||
| Verb | Params | Description |
|
||||
|------|--------|-------------|
|
||||
| `get_info` | `[]` | Returns signer metadata (name, version, supported verbs/algorithms). Safe to call before mnemonic is loaded. |
|
||||
|
||||
### 10.4 Example requests
|
||||
|
||||
```json
|
||||
// Get public key for ML-DSA-65 index 0
|
||||
{"id":"1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}
|
||||
|
||||
// Sign "hello" with ed25519 index 0
|
||||
{"id":"2","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}
|
||||
|
||||
// Sign a Nostr event with nostr_index 0
|
||||
{"id":"3","method":"nostr_sign_event","params":["{\"kind\":1,\"content\":\"Hello\",\"tags\":[],\"created_at\":1700000000,\"pubkey\":\"<hex>\"}",{"nostr_index":0}]}
|
||||
|
||||
// NIP-44 encrypt
|
||||
{"id":"4","method":"nostr_nip44_encrypt","params":["<peer_pubkey_hex>","secret message",{"nostr_index":0}]}
|
||||
|
||||
// ML-KEM-768 encapsulate
|
||||
{"id":"5","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]}
|
||||
|
||||
// Derive HMAC-SHA256(privkey, data) for opaque identifiers
|
||||
{"id":"6","method":"derive","params":["<data_hex>",{"algorithm":"secp256k1","index":0}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Algorithm reference
|
||||
|
||||
| Algorithm | Key type | FIPS | Derivation path | Pubkey | Privkey | Signature |
|
||||
|-----------|----------|------|-----------------|--------|---------|-----------|
|
||||
| `secp256k1` | Signature (Nostr) | — | `m/44'/1237'/<n>'/0/0` | 32 B | 32 B | 64 B |
|
||||
| `ed25519` | Signature (SSH) | — | `m/44'/102001'/<n>'/0/0'` | 32 B | 32 B | 64 B |
|
||||
| `x25519` | Key agreement (age) | — | `m/44'/102002'/<n>'/0/0'` | 32 B | 32 B | — |
|
||||
| `ml-dsa-65` | PQ signature | FIPS 204 | DRBG from seed | 1952 B | 4032 B | 3309 B |
|
||||
| `slh-dsa-128s` | PQ hash-based sig | FIPS 205 | DRBG from seed | 32 B | 64 B | 7856 B |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | DRBG from seed | 1184 B | 2400 B | — |
|
||||
| `otp` | One-time pad | — | USB pad (no derivation) | — | — | — |
|
||||
|
||||
### 11.1 `get_public_key` response format
|
||||
|
||||
Algorithm-based `get_public_key` returns structured JSON:
|
||||
|
||||
```json
|
||||
{"algorithm":"ml-dsa-65","public_key":"<hex>","key_id":"<first 16 hex chars of pubkey>"}
|
||||
```
|
||||
|
||||
Nostr `nostr_get_public_key` returns a plain 64-hex-char string by default, or structured JSON with `{"format":"structured"}`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Error codes
|
||||
|
||||
| Code | Message | Meaning |
|
||||
|------|---------|---------|
|
||||
| -32700 | `parse_error` | Request is not valid JSON |
|
||||
| -32600 | `invalid_request` | Missing `id`, `method`, or `params` |
|
||||
| -32601 | `method_not_found` | Unknown verb |
|
||||
| -32602 | `invalid_params` | Malformed arguments |
|
||||
| 1001 | `ambiguous_role_selector` | Multiple role selectors given |
|
||||
| 1002 | `unknown_role` | No role matched selector |
|
||||
| 1003 | `no_default_role` | No selector and no `main` role |
|
||||
| 1004 | `purpose_mismatch` | Role purpose not valid for verb |
|
||||
| 1005 | `curve_mismatch` | Role curve not valid for verb |
|
||||
| 1006 | `mnemonic_not_loaded` | No mnemonic loaded |
|
||||
| 1007 | `no_termination_condition` | `nostr_mine_event` without difficulty/timeout |
|
||||
| 1008 | `mining_failed` | Internal PoW error |
|
||||
| 1009 | `not_yet_implemented` | Verb+algorithm not yet implemented |
|
||||
| 1010 | `algorithm_not_supported_for_verb` | Algorithm not valid for verb |
|
||||
| 2010 | `auth_envelope_malformed` | Auth JSON is malformed |
|
||||
| 2011 | `auth_body_mismatch` | Auth tags don't match request |
|
||||
| 2012 | `auth_signature_invalid` | Auth signature doesn't verify |
|
||||
| 2013 | `auth_kind_invalid` | Auth kind != 27235 |
|
||||
| 2014 | `auth_envelope_required` | Auth missing (TCP/HTTP) |
|
||||
| 2015 | `auth_envelope_mismatch` | Timestamp skew or replay |
|
||||
|
||||
---
|
||||
|
||||
## 13. End-to-end: publish a Nostr event
|
||||
|
||||
This is the most common agent task. Here's the complete flow using qrexec (simplest cross-qube transport):
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a Nostr event via n_signer over qrexec."""
|
||||
import json, struct, subprocess, sys, time
|
||||
|
||||
def call_nsigner(target_qube, request):
|
||||
payload = json.dumps(request, separators=(",", ":")).encode()
|
||||
frame = struct.pack(">I", len(payload)) + payload
|
||||
proc = subprocess.Popen(
|
||||
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
out, _ = proc.communicate(frame)
|
||||
length = struct.unpack(">I", out[:4])[0]
|
||||
return json.loads(out[4:4+length])
|
||||
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "nostr_signer"
|
||||
idx = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
|
||||
# Step 1: Get public key
|
||||
pub = call_nsigner(target, {"id":"1","method":"get_public_key","params":[{"nostr_index":idx}]})["result"]
|
||||
print(f"Pubkey: {pub}")
|
||||
|
||||
# Step 2: Build and sign event
|
||||
event = {
|
||||
"kind": 1,
|
||||
"content": "Hello from my agent!",
|
||||
"created_at": int(time.time()),
|
||||
"tags": [],
|
||||
"pubkey": pub,
|
||||
}
|
||||
result = call_nsigner(target, {
|
||||
"id": "2",
|
||||
"method": "nostr_sign_event",
|
||||
"params": [json.dumps(event, separators=(",",":")), {"nostr_index": idx}],
|
||||
})
|
||||
signed = json.loads(result["result"])
|
||||
print(f"Event ID: {signed['id']}")
|
||||
print(f"Signature: {signed['sig']}")
|
||||
|
||||
# Step 3: Broadcast to relay(s)
|
||||
# signed is a complete Nostr event with id and sig — send to any relay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. End-to-end: sign arbitrary data
|
||||
|
||||
Using algorithm-based verbs (any algorithm, any index):
|
||||
|
||||
```python
|
||||
# Sign "hello" with ed25519 index 0
|
||||
result = call_nsigner(target, {
|
||||
"id": "1",
|
||||
"method": "sign",
|
||||
"params": ["68656c6c6f", {"algorithm": "ed25519", "index": 0}],
|
||||
})
|
||||
signature = result["result"] # hex string
|
||||
|
||||
# Verify
|
||||
result = call_nsigner(target, {
|
||||
"id": "2",
|
||||
"method": "verify",
|
||||
"params": ["68656c6c6f", signature, {"algorithm": "ed25519", "index": 0}],
|
||||
})
|
||||
print(f"Verified: {result['result']}") # "true" or "false"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. End-to-end: PQ KEM encapsulate/decapsulate
|
||||
|
||||
```python
|
||||
# Get ML-KEM-768 public key for index 0
|
||||
pub_result = call_nsigner(target, {
|
||||
"id": "1",
|
||||
"method": "get_public_key",
|
||||
"params": [{"algorithm": "ml-kem-768", "index": 0}],
|
||||
})
|
||||
pub_info = json.loads(pub_result["result"])
|
||||
print(f"ML-KEM-768 pubkey: {pub_info['public_key'][:32]}...")
|
||||
|
||||
# Encapsulate (generate a shared secret + ciphertext for that pubkey)
|
||||
enc_result = call_nsigner(target, {
|
||||
"id": "2",
|
||||
"method": "encapsulate",
|
||||
"params": [pub_info["public_key"], {"algorithm": "ml-kem-768"}],
|
||||
})
|
||||
enc_data = json.loads(enc_result["result"])
|
||||
print(f"Ciphertext: {enc_data['ciphertext'][:32]}...")
|
||||
print(f"Shared secret: {enc_data['shared_secret'][:32]}...")
|
||||
|
||||
# Decapsulate (recover shared secret from ciphertext using private key)
|
||||
dec_result = call_nsigner(target, {
|
||||
"id": "3",
|
||||
"method": "decapsulate",
|
||||
"params": [enc_data["ciphertext"], {"algorithm": "ml-kem-768", "index": 0}],
|
||||
})
|
||||
dec_data = json.loads(dec_result["result"])
|
||||
print(f"Decapsulated secret: {dec_data['shared_secret'][:32]}...")
|
||||
assert dec_data["shared_secret"] == enc_data["shared_secret"]
|
||||
print("✓ KEM round-trip verified")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. Discovery: finding running signers
|
||||
|
||||
### 16.1 List running signers
|
||||
|
||||
```bash
|
||||
nsigner list
|
||||
```
|
||||
|
||||
Output (one per line):
|
||||
```
|
||||
@nsigner
|
||||
@nsigner_hairy_dog
|
||||
@nsigner_brave_canyon
|
||||
```
|
||||
|
||||
### 16.2 Check if a specific signer is running
|
||||
|
||||
```bash
|
||||
nsigner list | grep -q @nsigner && echo "running" || echo "not running"
|
||||
```
|
||||
|
||||
### 16.3 Programmatic discovery (Python)
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
result = subprocess.run(["nsigner", "list"], capture_output=True, text=True)
|
||||
signers = [s.strip() for s in result.stdout.split("\n") if s.strip()]
|
||||
print(f"Found {len(signers)} signer(s): {signers}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Prerequisites and setup
|
||||
|
||||
### 17.1 On the signer qube
|
||||
|
||||
```bash
|
||||
# Install nsigner
|
||||
# Start the persistent signer
|
||||
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
|
||||
# Install qrexec service
|
||||
sudo cp packaging/qubes/rpc/qubes.NsignerRpc /etc/qubes-rpc/qubes.NsignerRpc
|
||||
sudo chmod 0755 /etc/qubes-rpc/qubes.NsignerRpc
|
||||
```
|
||||
|
||||
### 17.2 In dom0
|
||||
|
||||
```bash
|
||||
# Install policy
|
||||
sudo cp packaging/qubes/policy.d/40-nsigner.policy /etc/qubes/policy.d/40-nsigner.policy
|
||||
|
||||
# Tag the signer qube
|
||||
qvm-tags nostr_signer add nsigner-signer
|
||||
```
|
||||
|
||||
### 17.3 On the caller qube
|
||||
|
||||
No special setup needed — just `qrexec-client-vm` (pre-installed in all Qubes templates).
|
||||
|
||||
---
|
||||
|
||||
## 18. Reference files in this repo
|
||||
|
||||
| File | What it shows |
|
||||
|------|---------------|
|
||||
| [`client/demo_python.py`](../client/demo_python.py) | Full Python demo (qrexec, stdlib only) — get_public_key, sign_event, nip44, mine_event |
|
||||
| [`client/demo_javascript.js`](../client/demo_javascript.js) | Full Node.js demo (qrexec) — same operations |
|
||||
| [`client/demo_c99.c`](../client/demo_c99.c) | Full C99 demo (qrexec, nostr_core_lib) — same operations |
|
||||
| [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) | Minimal TCP/FIPS with auth envelope (Python) |
|
||||
| [`examples/get_pubkey_qrexec.c`](../examples/get_pubkey_qrexec.c) | Minimal qrexec in C |
|
||||
| [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) | Minimal TCP in C with auth envelope |
|
||||
| [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) | TCP/FIPS in Node.js with auth envelope |
|
||||
| [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) | qrexec in Node.js |
|
||||
| [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) | Unix socket in C |
|
||||
| [`examples/sign_event_client.c`](../examples/sign_event_client.c) | Unix socket sign event in C |
|
||||
| [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) | USB/serial hardware signer (Python) |
|
||||
| [`examples/feather_sign_event.py`](../examples/feather_sign_event.py) | USB/serial sign event (Python) |
|
||||
| [`examples/pq_sign_example.c`](../examples/pq_sign_example.c) | ML-DSA-65 sign in C |
|
||||
| [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) | ML-KEM-768 encaps/decaps in C |
|
||||
| [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) | ed25519 SSH sign in C |
|
||||
| [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md) | Full wire contract spec (733 lines) |
|
||||
| [`README.md`](../README.md) | Full n_signer documentation (741 lines) |
|
||||
| [`packaging/qubes/setup_signer_qube.sh`](../packaging/qubes/setup_signer_qube.sh) | Automated signer qube setup |
|
||||
| [`.roo/n_signer_client.md`](../.roo/n_signer_client.md) | Roo skill file (agent-invocable) |
|
||||
@@ -151,16 +151,14 @@ See [README.md §4c](../README.md) for full details.
|
||||
|
||||
The last param may include selector options:
|
||||
|
||||
- `role`
|
||||
- `nostr_index`
|
||||
- `role_path`
|
||||
- `role` — name of a pre-registered role entry
|
||||
- `role_path` — full BIP-44 derivation path
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. `role`
|
||||
2. `nostr_index`
|
||||
3. `role_path`
|
||||
4. default role `main`
|
||||
2. `role_path`
|
||||
3. default role `main`
|
||||
|
||||
Conflicting selector fields must be rejected as `ambiguous_role_selector`.
|
||||
|
||||
@@ -457,7 +455,7 @@ Request:
|
||||
"method": "sign_event",
|
||||
"params": [
|
||||
"<event_json>",
|
||||
{ "role": "main", "nostr_index": 0 }
|
||||
{ "role": "main", "role_path": "m/44'/1237'/0'/0/0" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Derivation Paths — A Simple Explanation
|
||||
|
||||
## The seed
|
||||
|
||||
When you create a mnemonic (seed phrase), it generates a single master key. Think of it as the root of a tree — one key that controls everything below it.
|
||||
|
||||
## The tree
|
||||
|
||||
From that master key, you can derive **child keys**. Each child key can have its own children, and so on. This creates a tree of keys, all derived from the same seed.
|
||||
|
||||
## The path
|
||||
|
||||
A **derivation path** is just a set of directions for walking down the tree. It tells you which branches to take, starting from the master key (`m`).
|
||||
|
||||
```
|
||||
m / 44' / 1237' / 0' / 0 / 0
|
||||
```
|
||||
|
||||
Read it left to right:
|
||||
|
||||
| Segment | Meaning |
|
||||
|---------|---------|
|
||||
| `m` | The master key (your seed) |
|
||||
| `44'` | Purpose: "this is a BIP-44 wallet" |
|
||||
| `1237'` | Coin type: "this is Nostr" (1237 is Nostr's registered coin type) |
|
||||
| `0'` | Account: "account #0" |
|
||||
| `0` | Change: "external/receive" (0) vs "internal/change" (1) |
|
||||
| `0` | Address index: "address #0" |
|
||||
|
||||
Each segment derives a child key from the parent. Change any segment and you get a completely different key.
|
||||
|
||||
## The apostrophe (hardened vs unhardened)
|
||||
|
||||
The `'` after a number means **hardened**. It's the most important detail in the path.
|
||||
|
||||
### Without the apostrophe (unhardened)
|
||||
|
||||
```
|
||||
m / 44' / 1237' / 0' / 0 / 0
|
||||
^
|
||||
no apostrophe = unhardened
|
||||
```
|
||||
|
||||
Unhardened means: you can derive this child's **public key** from just the parent's **public key** — you don't need the private key.
|
||||
|
||||
This is useful for **watch-only wallets**: you can share the parent's extended public key with someone, and they can derive all the child public keys (addresses) without ever seeing your private key.
|
||||
|
||||
**The risk:** if a child **private key** leaks, and someone has the parent's extended public key, they can work backwards and derive **all sibling private keys**. So if address #5's private key leaks, addresses #0-4 and #6-99 are also compromised.
|
||||
|
||||
### With the apostrophe (hardened)
|
||||
|
||||
```
|
||||
m / 44' / 1237' / 0' / 0' / 0'
|
||||
^
|
||||
apostrophe = hardened
|
||||
```
|
||||
|
||||
Hardened means: you **need the parent's private key** to derive this child. You cannot derive it from the public key alone.
|
||||
|
||||
**The benefit:** if a child private key leaks, the attacker **cannot** derive sibling keys. Each hardened child is isolated. Compromising one doesn't compromise the others.
|
||||
|
||||
### Simple analogy
|
||||
|
||||
Imagine a building with floors and rooms:
|
||||
|
||||
- **Unhardened** = a glass door. Anyone with the floor key can see into all rooms on that floor. If someone picks the lock on room #5, they can figure out how to open rooms #0-4 and #6-99 too.
|
||||
- **Hardened** = a steel door. You need the master floor key to open any room. Picking the lock on room #5 tells you nothing about the other rooms.
|
||||
|
||||
### Visual: what happens when one key leaks
|
||||
|
||||
**Unhardened** (no apostrophe) — one leak compromises ALL siblings:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0 ✓ safe
|
||||
m/44'/1237'/0'/0/1 ✓ safe
|
||||
m/44'/1237'/0'/0/2 ✓ safe
|
||||
m/44'/1237'/0'/0/3 ✓ safe
|
||||
m/44'/1237'/0'/0/4 ✓ safe
|
||||
m/44'/1237'/0'/0/5 ✗ COMPROMISED (leaked)
|
||||
m/44'/1237'/0'/0/6 ✗ COMPROMISED (derived from leak + parent pubkey)
|
||||
m/44'/1237'/0'/0/7 ✗ COMPROMISED (derived from leak + parent pubkey)
|
||||
...
|
||||
m/44'/1237'/0'/0/99 ✗ COMPROMISED (derived from leak + parent pubkey)
|
||||
|
||||
Parent extended public key (m/44'/1237'/0'/0) is public
|
||||
+ one child private key (address #5) leaks
|
||||
= ALL 100 sibling private keys are compromised
|
||||
```
|
||||
|
||||
**Hardened** (with apostrophe) — one leak only affects that one key:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0'/0' ✓ safe
|
||||
m/44'/1237'/0'/0'/1' ✓ safe
|
||||
m/44'/1237'/0'/0'/2' ✓ safe
|
||||
m/44'/1237'/0'/0'/3' ✓ safe
|
||||
m/44'/1237'/0'/0'/4' ✓ safe
|
||||
m/44'/1237'/0'/0'/5' ✗ COMPROMISED (leaked)
|
||||
m/44'/1237'/0'/0'/6' ✓ safe (cannot be derived without parent PRIVATE key)
|
||||
m/44'/1237'/0'/0'/7' ✓ safe (cannot be derived without parent PRIVATE key)
|
||||
...
|
||||
m/44'/1237'/0'/0'/99' ✓ safe (cannot be derived without parent PRIVATE key)
|
||||
|
||||
One child private key (address #5) leaks
|
||||
= ONLY address #5 is compromised
|
||||
= siblings are safe because hardened derivation requires the parent PRIVATE key
|
||||
```
|
||||
|
||||
## Why NIP-06 uses unhardened last segments
|
||||
|
||||
NIP-06 (Nostr's key derivation standard) uses `m/44'/1237'/<account>'/0/0` — the first three segments are hardened, the last two are unhardened.
|
||||
|
||||
This is because NIP-06 copied the BIP-44 pattern from Bitcoin, where:
|
||||
- The **account** segment is hardened (so different accounts are isolated)
|
||||
- The **change** and **address** segments are unhardened (so watch-only wallets can derive addresses without the private key)
|
||||
|
||||
For Bitcoin, this makes sense: you want to share your extended public key with a payment processor so they can generate receive addresses for you.
|
||||
|
||||
For Nostr, it's less useful — but it means Nostr tools can derive your public keys from your extended public key, which some key management software uses.
|
||||
|
||||
## What this means for n_signer
|
||||
|
||||
n_signer always holds your private key and derives everything itself. You never share extended public keys with anyone. So:
|
||||
|
||||
- **Unhardened segments give you no benefit** — you don't need watch-only derivation
|
||||
- **Unhardened segments add risk** — the child key compromise vulnerability
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Harden everything** if you don't need NIP-06 compatibility:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0'/0' ← all hardened, maximum isolation
|
||||
m/44'/1237'/0-99'/0'/0' ← all hardened, 100 isolated agent keys
|
||||
```
|
||||
|
||||
**Use NIP-06 paths** if you want compatibility with standard Nostr tools:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0 ← NIP-06 standard (last two unhardened)
|
||||
m/44'/1237'/0-99'/0/0 ← NIP-06 compatible, 100 agent keys
|
||||
```
|
||||
|
||||
## Common path patterns
|
||||
|
||||
### Standard Nostr (NIP-06)
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0
|
||||
```
|
||||
|
||||
One key. The default Nostr key that tools like `nak keygen` produce.
|
||||
|
||||
### Multiple Nostr accounts (NIP-06)
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0 ← account 0
|
||||
m/44'/1237'/1'/0/0 ← account 1
|
||||
m/44'/1237'/2'/0/0 ← account 2
|
||||
```
|
||||
|
||||
Change the account segment (hardened) to get different Nostr identities.
|
||||
|
||||
### Multiple Nostr agents (hardened, maximum isolation)
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0'/0' ← agent 0
|
||||
m/44'/1237'/1'/0'/0' ← agent 1
|
||||
m/44'/1237'/2'/0'/0' ← agent 2
|
||||
```
|
||||
|
||||
Same as above but with the last two segments hardened. Each agent is fully isolated — compromising one doesn't compromise the others.
|
||||
|
||||
### Range and wildcard syntax (n_signer wizard)
|
||||
|
||||
In n_signer's role wizard, you can use range syntax or wildcard for the variable segment:
|
||||
|
||||
```
|
||||
m/44'/1237'/0-99'/0/0 ← agents 0-99, NIP-06 compatible
|
||||
m/44'/1237'/0-99'/0'/0' ← agents 0-99, all hardened
|
||||
m/44'/1237'/*'/0'/0' ← any agent index, all hardened (wildcard)
|
||||
```
|
||||
|
||||
- `0-99'` means "this segment can be any value from 0 to 99, hardened"
|
||||
- `*'` means "this segment can be any non-negative integer, hardened" (wildcard — no range limit)
|
||||
- `*` (without `'`) means "any non-negative integer, unhardened"
|
||||
|
||||
The client specifies the exact path (e.g. `m/44'/1237'/5'/0/0` for agent #5), and the server verifies it's within the role's allowed range (or accepts any value for `*`).
|
||||
|
||||
### SSH keys (ed25519)
|
||||
|
||||
```
|
||||
m/44'/102001'/0'/0'/0'
|
||||
```
|
||||
|
||||
SLIP-0010 derivation for ed25519. All segments are hardened (SLIP-0010 requires this for ed25519).
|
||||
|
||||
### Age / x25519 keys
|
||||
|
||||
```
|
||||
m/44'/102002'/0'/0'/0'
|
||||
```
|
||||
|
||||
Key agreement keys for Age encryption or X25519 ECDH.
|
||||
|
||||
### Post-quantum keys
|
||||
|
||||
```
|
||||
m/44'/102003'/0'/0'/0' ← ML-DSA-65 (signatures, FIPS 204)
|
||||
m/44'/102004'/0'/0'/0' ← SLH-DSA-128s (signatures, FIPS 205)
|
||||
m/44'/102005'/0'/0'/0' ← ML-KEM-768 (KEM, FIPS 203)
|
||||
```
|
||||
|
||||
All hardened. The mnemonic-derived seed feeds a SHAKE-256 DRBG that replaces PQClean's `randombytes()` during keygen.
|
||||
|
||||
## Summary
|
||||
|
||||
| Concept | Simple explanation |
|
||||
|---------|-------------------|
|
||||
| `m` | The master key (your seed) |
|
||||
| Numbers | Which branch to take at each level |
|
||||
| `'` (apostrophe) | "Hardened" — need private key to derive, isolates siblings |
|
||||
| No `'` | "Unhardened" — can derive from public key, but siblings can be compromised |
|
||||
| Path | A set of directions from the master key to a specific key |
|
||||
| Different path | Different key (always, no exceptions) |
|
||||
|
||||
**Golden rule:** If you don't need watch-only derivation (and n_signer doesn't), harden everything.
|
||||
@@ -0,0 +1,333 @@
|
||||
# nsigner Menu Reference
|
||||
|
||||
This document describes every interactive menu and screen in the `nsigner` TUI, in the order they appear during a session. Use this as the authoritative reference when discussing changes to the user experience.
|
||||
|
||||
## Startup sequence
|
||||
|
||||
The menus appear in this order during interactive (TUI) startup:
|
||||
|
||||
1. **Unlock — Mnemonic source**
|
||||
2. **Define a role — Role preset menu** (loops)
|
||||
3. **Transport — Transport selection**
|
||||
4. **Running phase — Main status display**
|
||||
|
||||
Non-interactive startup (`--mnemonic-stdin`, `--mnemonic-fd`, or piped input) skips menus 1–4 and creates a default `main` role automatically.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unlock — Mnemonic source
|
||||
|
||||
**When:** First screen, before anything else.
|
||||
|
||||
**Title:** `> Unlock`
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Mnemonic source: [E]nter existing or [G]enerate new
|
||||
Default is E; you can also paste full mnemonic here.
|
||||
>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `E` (default) | Prompt for an existing mnemonic (echo disabled) |
|
||||
| `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 |
|
||||
| Paste full mnemonic | If the input contains spaces and doesn't start with `G`, it's treated as a mnemonic and validated directly |
|
||||
| `q` / `x` | Exit |
|
||||
|
||||
**After `E`:** Prompts for the mnemonic phrase with terminal echo disabled. Validates BIP-39 checksum. Up to 10 invalid attempts before exit.
|
||||
|
||||
**After `G`:** Displays the generated mnemonic numbered 1–12, then continues.
|
||||
|
||||
---
|
||||
|
||||
## 2. Define a role — Role preset menu
|
||||
|
||||
**When:** After mnemonic is loaded, in TUI mode only.
|
||||
|
||||
**Title:** `Define a role — bind a role name to a derivation path template`
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Define a role:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
|
||||
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
|
||||
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
|
||||
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
|
||||
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
|
||||
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
|
||||
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
|
||||
9. OTP role (one-time pad encryption)
|
||||
10. Custom path
|
||||
Select [1]:
|
||||
```
|
||||
|
||||
**Preset defaults:**
|
||||
|
||||
| Choice | Default name | Default path | Curve | Purpose |
|
||||
|--------|-------------|-------------|-------|---------|
|
||||
| 1 | `main` | `m/44'/1237'/0'/0/0` | secp256k1 | nostr |
|
||||
| 2 | `nostr_range` | `m/44'/1237'/*'/0/0` | secp256k1 | nostr |
|
||||
| 3 | `nostr_agent` | `m/44'/1237'/*'/1'/0'` | secp256k1 | nostr |
|
||||
| 4 | `ssh` | `m/44'/102001'/0'/0'/0'` | ed25519 | ssh |
|
||||
| 5 | `age` | `m/44'/102002'/0'/0'/0'` | x25519 | age |
|
||||
| 6 | `ml_dsa_65` | `m/44'/102003'/0'/0'/0'` | ml-dsa-65 | pq_sig |
|
||||
| 7 | `slh_dsa_128s` | `m/44'/102004'/0'/0'/0'` | slh-dsa-128s | pq_sig |
|
||||
| 8 | `ml_kem_768` | `m/44'/102005'/0'/0'/0'` | ml-kem-768 | pq_kem |
|
||||
| 9 | `otp` | (pad file) | otp | n/a |
|
||||
| 10 | `custom` | `m/44'/1237'/0'/0/0` | (prompted) | (auto-detected) |
|
||||
|
||||
**After selecting a preset, the user is prompted for:**
|
||||
|
||||
### 2a. Role name
|
||||
```
|
||||
Role name [main]:
|
||||
```
|
||||
Editable line (arrow keys, backspace). Defaults to the preset's default name. If the name already exists, it's skipped.
|
||||
|
||||
### 2b. Curve (only for choice 10 — Custom)
|
||||
```
|
||||
Curve:
|
||||
1) secp256k1 (Nostr, Bitcoin)
|
||||
2) ed25519 (SSH)
|
||||
3) x25519 (key agreement, Age)
|
||||
4) ml-dsa-65 (post-quantum signatures)
|
||||
5) slh-dsa-128s (post-quantum signatures)
|
||||
6) ml-kem-768 (post-quantum KEM)
|
||||
Select [1]:
|
||||
```
|
||||
For presets 1–8, the curve is set automatically. For OTP (9), no curve is needed.
|
||||
|
||||
### 2c. Path template (only for choice 10 — Custom)
|
||||
```
|
||||
Path template [m/44'/1237'/0'/0/0]:
|
||||
|
||||
```
|
||||
Editable line. Pre-filled with the default path (updated to match the selected curve). Supports range syntax (`0-1000'`), set syntax (`1+34+54`), and wildcard (`*'` for any index).
|
||||
|
||||
For presets 1–8, the path is set automatically from the preset — no prompt. For OTP (9), no path is needed.
|
||||
|
||||
### 2c-otp. OTP pad file (only for choice 9 — OTP)
|
||||
```
|
||||
OTP pad directory (e.g. /media/usb0):
|
||||
OTP pad name (e.g. mypad):
|
||||
```
|
||||
Prompts for the pad directory and pad name. The pad is bound immediately. If binding fails, the role is skipped.
|
||||
|
||||
### 2d. Requires interactive approval
|
||||
```
|
||||
Require interactive approval for each request? [Y/n]:
|
||||
```
|
||||
- `Y` (default) → `requires_approval = 1` — human attendant must approve each request
|
||||
- `n` → `requires_approval = 0` — role name is the password, no prompt (role-as-password)
|
||||
|
||||
### 2e. Confirmation
|
||||
```
|
||||
Role 'main' registered: curve=secp256k1 path=m/44'/1237'/0'/0/0 (fixed, requires_approval=1).
|
||||
```
|
||||
Or for templated paths:
|
||||
```
|
||||
Role 'nostr_agent' registered: curve=secp256k1 path=m/44'/1237'/%d'/1'/0' (range 0-1000, requires_approval=1).
|
||||
```
|
||||
|
||||
### 2f. Loop
|
||||
```
|
||||
Define another role? [y/N]:
|
||||
```
|
||||
- `y` → back to the preset menu
|
||||
- `N` (default) → continue to transport selection
|
||||
|
||||
**Mandatory:** At least one role must be created. If the user exits without creating any roles, the signer prints "At least one role must be defined." and exits.
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport — Transport selection
|
||||
|
||||
**When:** After role definition, in TUI mode with no `--listen` flag.
|
||||
|
||||
**Title:** `Transport — how should other programs reach this signer?`
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Select one or more (type a number to toggle, 'a' for all, Enter to confirm):
|
||||
|
||||
[x] 1. Local Unix socket (same machine/qube)
|
||||
[ ] 2. Qubes qrexec bridge (other qubes via qrexec, no network)
|
||||
[ ] 3. FIPS/TCP listener (framed JSON, FIPS mesh or local network)
|
||||
[ ] 4. HTTP listener (curl-friendly, localhost by default)
|
||||
|
||||
[a] select all Enter = confirm
|
||||
>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `1` | Toggle Local Unix socket |
|
||||
| `2` | Toggle Qubes qrexec bridge |
|
||||
| `3` | Toggle FIPS/TCP listener |
|
||||
| `4` | Toggle HTTP listener |
|
||||
| `a` | Select all |
|
||||
| Enter | Confirm current selection (at least one required) |
|
||||
|
||||
Default: Unix socket only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Running phase — Main status display
|
||||
|
||||
**When:** After all startup menus, this is the main screen.
|
||||
|
||||
**Title:** `> Main Menu`
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌ n_signer v<version> — Main Menu ─────────────────────────┐
|
||||
│ │
|
||||
│ Roles: │
|
||||
│ Role Purpose Curve Derivation path
|
||||
│ -------------------- ------------ ------------ ------------------------
|
||||
│ main nostr secp256k1 m/44'/1237'/0'/0/0
|
||||
│ role1 nostr secp256k1 m/44'/1237'/1-100'/0/0
|
||||
│ │
|
||||
│ Activity (latest first): │
|
||||
│ 14:51:05 uid:1000 nostr_get_public_key(pathrole_2) ALLOWED:prompt
|
||||
│ 14:50:54 uid:1000 nostr_get_public_key(pathrole_2) ALLOWED:prompt
|
||||
│ │
|
||||
│ session=unlocked (12 words) signer=nsigner derived=2 │
|
||||
│ │
|
||||
│ l lock/reunlock r refresh d display connections q/x quit
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
>
|
||||
```
|
||||
|
||||
**Status line:** `session=<locked|unlocked> (<N> words) signer=<name> derived=<count>`
|
||||
|
||||
**Menu items:**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `l` | Lock / re-unlock (re-prompt for mnemonic) |
|
||||
| `r` | Refresh display |
|
||||
| `d` | Display connections (show transport details + example client commands) |
|
||||
| `q` / `x` | Quit |
|
||||
|
||||
**Note:** The `a toggle auto-approve` menu item has been **removed**. Authorization is now per-role via the `requires_approval` flag set during role definition.
|
||||
|
||||
---
|
||||
|
||||
## 5. Approval prompt
|
||||
|
||||
**When:** A client request arrives for a role with `requires_approval = 1`, and the request is not pre-approved by policy.
|
||||
|
||||
**Title:** `> Approval`
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌ n_signer v<version> — Approval ──────────────────────────┐
|
||||
│ │
|
||||
│ Approval required │
|
||||
│ caller: uid:1000 │
|
||||
│ method: nostr_sign_event │
|
||||
│ role: main │
|
||||
│ purpose: nostr │
|
||||
│ ** NEW IDENTITY — will be derived if approved ** │
|
||||
│ │
|
||||
│ y: allow once │
|
||||
│ n: deny │
|
||||
│ e: allow this caller+role+verb for session │
|
||||
│ a: allow this caller+role for session (all verbs) │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Key | Action | Policy result |
|
||||
|-----|--------|---------------|
|
||||
| `y` | Allow this one request | `POLICY_ALLOW` |
|
||||
| `n` | Deny this request | `POLICY_DENY` |
|
||||
| `e` | Allow this caller+role+verb for the rest of the session | `POLICY_ALLOW_SESSION_VERB` |
|
||||
| `a` | Allow this caller+role for all verbs for the session | `POLICY_ALLOW_SESSION_ALL` |
|
||||
|
||||
The `** NEW IDENTITY — will be derived if approved **` line appears only when the requested key hasn't been derived yet.
|
||||
|
||||
**Fields shown:**
|
||||
- `caller` — the caller identity (e.g. `uid:1000`, `qubes:vm-name`, `pubkey:<hex>`)
|
||||
- `fips peer` — (TCP/FIPS mode only) the peer's npub and optional name
|
||||
- `method` — the JSON-RPC verb (e.g. `nostr_sign_event`, `nostr_get_public_key`)
|
||||
- `role` — the role name from the request
|
||||
- `purpose` — the role's purpose (nostr, ssh, age, pq_sig, pq_kem)
|
||||
|
||||
---
|
||||
|
||||
## 6. Display connections
|
||||
|
||||
**When:** Pressed `d` from the main status display.
|
||||
|
||||
**Shows:** For each active transport, a section with:
|
||||
- Transport name and description
|
||||
- Socket address / port / URL
|
||||
- Example client command
|
||||
|
||||
After any keypress, returns to the main status display.
|
||||
|
||||
---
|
||||
|
||||
## Non-interactive mode
|
||||
|
||||
When started with `--mnemonic-stdin` or `--mnemonic-fd`, or when stdin is not a TTY:
|
||||
|
||||
- Menus 1–4 are skipped
|
||||
- A default `main` role is created automatically: `secp256k1`, `m/44'/1237'/0'/0/0`, `requires_approval=1`
|
||||
- The `--allow-all` flag sets `server_set_prompt_always_allow(1)` which bypasses approval prompts (used by tests and automated setups)
|
||||
- The `NSIGNER_TEST_NONINTERACTIVE_PROMPT` env var can be set to `allow` or `deny` to control the non-interactive prompt fallback
|
||||
|
||||
---
|
||||
|
||||
## CLI flags that affect menus
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--listen <mode>` | Skips transport selection menu (menu 3) |
|
||||
| `--mnemonic-stdin` | Skips mnemonic menu (menu 1), reads from stdin |
|
||||
| `--mnemonic-fd <N>` | Skips mnemonic menu (menu 1), reads from fd N |
|
||||
| `--allow-all` | Skips approval prompts (sets `prompt_always_allow`) |
|
||||
| `--socket-name <name>` | Sets the socket name (skips random name generation) |
|
||||
| `--preapprove <SPEC>` | Pre-approves specific caller+role+verb combinations |
|
||||
|
||||
---
|
||||
|
||||
## Summary flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start] --> B{stdin is TTY?}
|
||||
B -- No --> C[Non-interactive: create default main role]
|
||||
B -- Yes --> D[Menu 1: Unlock — mnemonic source]
|
||||
D --> E[Menu 2: Define a role — preset menu]
|
||||
E --> F{Another role?}
|
||||
F -- Yes --> E
|
||||
F -- No --> G{At least one role?}
|
||||
G -- No --> H[Error: at least one role required]
|
||||
G -- Yes --> I{--listen flag?}
|
||||
I -- No --> J[Menu 3: Transport selection]
|
||||
I -- Yes --> K[Use --listen mode]
|
||||
J --> N[Menu 4: Main status display]
|
||||
K --> N
|
||||
C --> N
|
||||
N --> O{Request arrives}
|
||||
O --> P{requires_approval?}
|
||||
P -- No --> Q[Authorize immediately]
|
||||
P -- Yes --> R{Pre-approved?}
|
||||
R -- Yes --> Q
|
||||
R -- No --> S[Menu 6: Approval prompt]
|
||||
S -- y/e/a --> Q
|
||||
S -- n --> T[Deny]
|
||||
```
|
||||
@@ -1,527 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>n_signer Feather WebUSB Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121821;
|
||||
--panel-2: #182231;
|
||||
--text: #e6edf3;
|
||||
--muted: #9fb0c3;
|
||||
--accent: #58a6ff;
|
||||
--good: #3fb950;
|
||||
--bad: #f85149;
|
||||
--border: #263448;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 20px auto;
|
||||
padding: 0 14px 24px;
|
||||
}
|
||||
h1 { margin: 0 0 8px; font-size: 1.45rem; }
|
||||
p.note { margin: 0 0 14px; color: var(--muted); }
|
||||
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel-2));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
input, textarea, button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: #0c131d;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
textarea { min-height: 84px; resize: vertical; }
|
||||
input[type="number"] { max-width: 130px; }
|
||||
|
||||
button {
|
||||
background: #1f6feb;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.secondary { background: #334155; }
|
||||
|
||||
.status {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #2a3648;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status.ok { color: var(--good); border-color: #2f5a3a; }
|
||||
.status.err { color: var(--bad); border-color: #6a3131; }
|
||||
|
||||
pre {
|
||||
margin: 8px 0 0;
|
||||
background: #0a1018;
|
||||
border: 1px solid #1b2636;
|
||||
color: #d7e2ee;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>n_signer Feather WebUSB Demo</h1>
|
||||
<p class="note">Connect to the board, choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button id="connectBtn">Connect WebUSB</button>
|
||||
<span id="connStatus" class="status">Disconnected</span>
|
||||
</div>
|
||||
<pre id="log" class="mono"></pre>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Public Key</h2>
|
||||
<label for="keyIndex">Key index (nostr_index)</label>
|
||||
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="pubkeyBtn" disabled>Get Public Key</button>
|
||||
</div>
|
||||
<pre id="pubkeyOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Sign Kind 1 Event</h2>
|
||||
<label for="kind1Content">Content</label>
|
||||
<textarea id="kind1Content">hello from feather webusb demo</textarea>
|
||||
<label for="kind1Tags">Tags JSON (array)</label>
|
||||
<input id="kind1Tags" value="[]" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
|
||||
</div>
|
||||
<pre id="signOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Encrypt</h2>
|
||||
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Msg">Plaintext</label>
|
||||
<textarea id="nip04Msg">hello via nip04</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Decrypt</h2>
|
||||
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Cipher">Ciphertext</label>
|
||||
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04DecOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Encrypt</h2>
|
||||
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Msg">Plaintext</label>
|
||||
<textarea id="nip44Msg">hello via nip44</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Decrypt</h2>
|
||||
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Cipher">Ciphertext</label>
|
||||
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44DecOut" class="mono"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
|
||||
|
||||
const logEl = document.getElementById("log");
|
||||
const connStatusEl = document.getElementById("connStatus");
|
||||
|
||||
const connectBtn = document.getElementById("connectBtn");
|
||||
const pubkeyBtn = document.getElementById("pubkeyBtn");
|
||||
const signKind1Btn = document.getElementById("signKind1Btn");
|
||||
const nip04EncBtn = document.getElementById("nip04EncBtn");
|
||||
const nip04DecBtn = document.getElementById("nip04DecBtn");
|
||||
const nip44EncBtn = document.getElementById("nip44EncBtn");
|
||||
const nip44DecBtn = document.getElementById("nip44DecBtn");
|
||||
|
||||
const keyIndexEl = document.getElementById("keyIndex");
|
||||
const pubkeyOutEl = document.getElementById("pubkeyOut");
|
||||
|
||||
const kind1ContentEl = document.getElementById("kind1Content");
|
||||
const kind1TagsEl = document.getElementById("kind1Tags");
|
||||
const signOutEl = document.getElementById("signOut");
|
||||
|
||||
const nip04PeerEl = document.getElementById("nip04Peer");
|
||||
const nip04MsgEl = document.getElementById("nip04Msg");
|
||||
const nip04OutEl = document.getElementById("nip04Out");
|
||||
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
|
||||
const nip04CipherEl = document.getElementById("nip04Cipher");
|
||||
const nip04DecOutEl = document.getElementById("nip04DecOut");
|
||||
|
||||
const nip44PeerEl = document.getElementById("nip44Peer");
|
||||
const nip44MsgEl = document.getElementById("nip44Msg");
|
||||
const nip44OutEl = document.getElementById("nip44Out");
|
||||
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
|
||||
const nip44CipherEl = document.getElementById("nip44Cipher");
|
||||
const nip44DecOutEl = document.getElementById("nip44DecOut");
|
||||
|
||||
let dev = null;
|
||||
let iface = null;
|
||||
let ownPubkey = "";
|
||||
const EP_OUT = 1;
|
||||
const EP_IN = 1;
|
||||
|
||||
function log(...args) {
|
||||
logEl.textContent += args.join(" ") + "\n";
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, mode = "") {
|
||||
connStatusEl.textContent = text;
|
||||
connStatusEl.className = `status ${mode}`.trim();
|
||||
}
|
||||
|
||||
function hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function utf8(s) {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
function be32(n) {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
async function sha256Hex(dataBytes) {
|
||||
const h = await crypto.subtle.digest("SHA-256", dataBytes);
|
||||
return hex(new Uint8Array(h));
|
||||
}
|
||||
|
||||
function getIndexOptions() {
|
||||
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
|
||||
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
||||
return { nostr_index: index };
|
||||
}
|
||||
|
||||
function pretty(value) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildAuth(method, params) {
|
||||
// Demo caller key only (not secret in browser demo).
|
||||
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
|
||||
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const paramsJson = JSON.stringify(params);
|
||||
const bodyHash = await sha256Hex(utf8(paramsJson));
|
||||
const tags = [
|
||||
["nsigner_rpc", "1"],
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", bodyHash],
|
||||
];
|
||||
|
||||
const content = "webusb-demo";
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await sha256Hex(utf8(ser));
|
||||
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
|
||||
|
||||
return {
|
||||
id,
|
||||
pubkey: callerPubX,
|
||||
created_at: createdAt,
|
||||
kind: 27235,
|
||||
tags,
|
||||
content,
|
||||
sig: sigHex,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendRpc(reqObj) {
|
||||
const body = utf8(JSON.stringify(reqObj));
|
||||
const frame = new Uint8Array(4 + body.length);
|
||||
frame.set(be32(body.length), 0);
|
||||
frame.set(body, 4);
|
||||
await dev.transferOut(EP_OUT, frame);
|
||||
|
||||
const deadline = Date.now() + 10000;
|
||||
let ring = new Uint8Array(0);
|
||||
while (Date.now() < deadline) {
|
||||
const r = await dev.transferIn(EP_IN, 512);
|
||||
if (!r.data || r.data.byteLength === 0) continue;
|
||||
|
||||
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
|
||||
const next = new Uint8Array(ring.length + chunk.length);
|
||||
next.set(ring, 0);
|
||||
next.set(chunk, ring.length);
|
||||
ring = next;
|
||||
|
||||
while (ring.length >= 4) {
|
||||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||||
if (n <= 0 || n > 1_000_000) {
|
||||
ring = ring.slice(1);
|
||||
continue;
|
||||
}
|
||||
if (ring.length < 4 + n) break;
|
||||
|
||||
const payload = ring.slice(4, 4 + n);
|
||||
ring = ring.slice(4 + n);
|
||||
const txt = new TextDecoder().decode(payload);
|
||||
return JSON.parse(txt);
|
||||
}
|
||||
}
|
||||
throw new Error("Timed out waiting for framed response");
|
||||
}
|
||||
|
||||
async function rpcCall(method, params, id = "web-1") {
|
||||
const auth = await buildAuth(method, params);
|
||||
const req = { jsonrpc: "2.0", id, method, params, auth };
|
||||
log("→", method, JSON.stringify(params));
|
||||
const resp = await sendRpc(req);
|
||||
log("←", method, JSON.stringify(resp));
|
||||
return resp;
|
||||
}
|
||||
|
||||
function requirePeerHex(peer) {
|
||||
const v = String(peer || "").trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||||
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function requireStringResult(resp, what) {
|
||||
if (resp && typeof resp.result === "string") {
|
||||
return resp.result;
|
||||
}
|
||||
throw new Error(`${what} failed: ${pretty(resp)}`);
|
||||
}
|
||||
|
||||
async function fetchOwnPubkeyAndFillPeers() {
|
||||
const params = [getIndexOptions()];
|
||||
const resp = await rpcCall("get_public_key", params, "web-own-pubkey");
|
||||
if (resp && typeof resp.result === "string") {
|
||||
ownPubkey = resp.result.trim().toLowerCase();
|
||||
pubkeyOutEl.textContent = pretty(resp);
|
||||
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
|
||||
nip04PeerEl.value = ownPubkey;
|
||||
nip04DecPeerEl.value = ownPubkey;
|
||||
nip44PeerEl.value = ownPubkey;
|
||||
nip44DecPeerEl.value = ownPubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
dev = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303a }] });
|
||||
await dev.open();
|
||||
if (dev.configuration === null) {
|
||||
await dev.selectConfiguration(1);
|
||||
}
|
||||
|
||||
const intf = dev.configuration.interfaces.find(i =>
|
||||
i.alternates.some(a => a.interfaceClass === 0xff)
|
||||
);
|
||||
if (!intf) throw new Error("No vendor WebUSB interface found");
|
||||
|
||||
iface = intf.interfaceNumber;
|
||||
await dev.claimInterface(iface);
|
||||
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
|
||||
await dev.selectAlternateInterface(iface, alt.alternateSetting);
|
||||
|
||||
await dev.controlTransferOut({
|
||||
requestType: "class",
|
||||
recipient: "interface",
|
||||
request: 0x22,
|
||||
value: 1,
|
||||
index: iface,
|
||||
});
|
||||
|
||||
pubkeyBtn.disabled = false;
|
||||
signKind1Btn.disabled = false;
|
||||
nip04EncBtn.disabled = false;
|
||||
nip04DecBtn.disabled = false;
|
||||
nip44EncBtn.disabled = false;
|
||||
nip44DecBtn.disabled = false;
|
||||
setStatus(`Connected (iface ${iface})`, "ok");
|
||||
log("Connected. Interface", String(iface));
|
||||
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
log("Default peer pubkeys set to selected signer pubkey");
|
||||
} catch (e) {
|
||||
log("Auto pubkey fetch failed:", String(e));
|
||||
}
|
||||
}
|
||||
|
||||
connectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await connect();
|
||||
} catch (e) {
|
||||
setStatus("Connect failed", "err");
|
||||
log("Connect failed:", String(e));
|
||||
}
|
||||
});
|
||||
|
||||
pubkeyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
} catch (e) {
|
||||
pubkeyOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
signKind1Btn.addEventListener("click", async () => {
|
||||
try {
|
||||
let tags = [];
|
||||
try {
|
||||
tags = JSON.parse(kind1TagsEl.value || "[]");
|
||||
if (!Array.isArray(tags)) throw new Error("tags must be an array");
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid tags JSON: ${String(e)}`);
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: String(kind1ContentEl.value || ""),
|
||||
};
|
||||
|
||||
const params = [unsignedEvent, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_sign_event", params, "web-sign-kind1");
|
||||
signOutEl.textContent = pretty(resp?.result ?? resp);
|
||||
} catch (e) {
|
||||
signOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04PeerEl.value);
|
||||
const msg = String(nip04MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip04_encrypt", params, "web-nip04-enc");
|
||||
nip04OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip04DecPeerEl.value = peer;
|
||||
nip04CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip04OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04DecPeerEl.value);
|
||||
const ciphertext = String(nip04CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip04_decrypt", params, "web-nip04-dec");
|
||||
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
|
||||
} catch (e) {
|
||||
nip04DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44PeerEl.value);
|
||||
const msg = String(nip44MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip44_encrypt", params, "web-nip44-enc");
|
||||
nip44OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip44DecPeerEl.value = peer;
|
||||
nip44CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip44OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44DecPeerEl.value);
|
||||
const ciphertext = String(nip44CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip44_decrypt", params, "web-nip44-dec");
|
||||
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
|
||||
} catch (e) {
|
||||
nip44DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+5
-5
@@ -46,10 +46,10 @@ CDC path:
|
||||
./.venv/bin/python ./examples/feather_sign_event.py /dev/ttyACM0
|
||||
```
|
||||
|
||||
WebUSB path:
|
||||
WebUSB / Web Serial path (unified):
|
||||
|
||||
- Open [`examples/feather_webusb_demo.html`](../examples/feather_webusb_demo.html)
|
||||
- Connect in Chrome/Edge
|
||||
- Open [`usb-test.html`](../usb-test.html) in Chrome/Edge
|
||||
- Connect via Web Serial (works for Teensy 4.1 USB CDC, CYD CH340, Feather, etc.)
|
||||
- Run `get_public_key`
|
||||
- Confirm pubkey matches CDC result
|
||||
|
||||
@@ -58,9 +58,9 @@ WebUSB path:
|
||||
The CYD has no native USB; its CH340 bridge exposes a serial port. The browser
|
||||
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
|
||||
page covering every algorithm and verb lives at
|
||||
[`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html):
|
||||
[`usb-test.html`](../usb-test.html):
|
||||
|
||||
- Open [`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html) in Chrome/Edge
|
||||
- Open [`usb-test.html`](../usb-test.html) in Chrome/Edge
|
||||
- Click **Connect Web Serial**, select the CH340 port (`1a86:7523`)
|
||||
- Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
|
||||
`encapsulate`/`decapsulate`, `derive_shared_secret`, `derive`,
|
||||
|
||||
@@ -95,9 +95,9 @@ hardware note below.
|
||||
The CYD has no native USB; the CH340 bridge exposes a serial port. The browser
|
||||
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
|
||||
page covering every algorithm and verb lives at
|
||||
[`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html):
|
||||
[`usb-test.html`](../../usb-test.html):
|
||||
|
||||
1. Open [`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html) in Chrome/Edge.
|
||||
1. Open [`usb-test.html`](../../usb-test.html) in Chrome/Edge.
|
||||
2. Click **Connect Web Serial**, select the CH340 port (`1a86:7523`).
|
||||
3. On the CYD touchscreen, enter or generate a mnemonic to reach the "ready" state.
|
||||
4. Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
|
||||
|
||||
+122
-4
@@ -112,12 +112,130 @@ These constants are for the specific panel we calibrated. If you swap a
|
||||
display module, re-run [`firmware/teensy41/touch_cal/touch_cal.ino`](touch_cal/touch_cal.ino)
|
||||
and paste the new constants.
|
||||
|
||||
## Build (planned)
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Arduino CLI / Teensyduino
|
||||
arduino-cli compile --fqbn teensy:avr:teensy41 firmware/teensy41
|
||||
arduino-cli upload -p /dev/ttyACM0 --fqbn teensy:avr:teensy41 firmware/teensy41
|
||||
# Arduino CLI / Teensyduino — uses the custom linker script
|
||||
bash firmware/teensy41/build_signer.sh # compile only
|
||||
bash firmware/teensy41/build_signer.sh --flash # compile + upload
|
||||
bash firmware/teensy41/build_signer.sh --test # compile + upload + run tests
|
||||
```
|
||||
|
||||
The build uses a custom linker script
|
||||
([`signer/imxrt1062_t41_flashmem.ld`](signer/imxrt1062_t41_flashmem.ld)) that
|
||||
routes crypto and SdFat code to FLASH (off-chip QSPI) instead of ITCM
|
||||
(tightly-coupled RAM), freeing the limited FlexRAM for stack. See the memory
|
||||
management section below for details.
|
||||
|
||||
See the port plan: [`plans/teensy41_signer_port.md`](../../plans/teensy41_signer_port.md).
|
||||
|
||||
## Memory management
|
||||
|
||||
The Teensy 4.1's NXP i.MX RT1062 has a unique FlexRAM architecture that makes
|
||||
memory budgeting the central engineering challenge of this firmware. This
|
||||
section documents the layout, the custom linker script, and the build-time
|
||||
stack gauge that catches overflows before they become mystery crashes.
|
||||
|
||||
### The FlexRAM problem
|
||||
|
||||
The i.MX RT1062 has three RAM regions:
|
||||
|
||||
```
|
||||
FLASH 8 MB (off-chip QSPI, @ 0x60000000) — code + rodata, slow but huge
|
||||
FLEXRAM 512 KB (on-chip, split ITCM/DTCM) — fast, tiny, THE BOTTLENECK
|
||||
RAM2 512 KB (OCRAM, @ 0x20200000) — DMAMEM statics + malloc heap
|
||||
ERAM 0 MB (PSRAM pads empty) — not populated on the Teensy 4.1
|
||||
```
|
||||
|
||||
The 512 KB of FlexRAM is divided into **16 banks of 32 KB** that are split
|
||||
between **ITCM** (instruction tightly-coupled memory, runs code at zero wait
|
||||
state) and **DTCM** (data tightly-coupled memory, holds `.data` + `.bss` +
|
||||
**the stack**). The split is computed at **boot time** by the Teensy boot ROM
|
||||
from a formula in the linker script:
|
||||
|
||||
```ld
|
||||
_itcm_block_count = (SIZEOF(.text.itcm) + SIZEOF(.ARM.exidx) + 0x7FFF) >> 15;
|
||||
_estack = ORIGIN(DTCM) + ((16 - _itcm_block_count) << 15);
|
||||
```
|
||||
|
||||
**Every 32 KB bank given to code is taken away from the stack.** If ITCM code
|
||||
grows past a 32 KB boundary, a whole bank is stolen from DTCM, and the stack
|
||||
shrinks by 32 KB. The linker cannot detect this because the split happens at
|
||||
reset, not link time — an over-committed DTCM links cleanly and then hard-faults
|
||||
on boot.
|
||||
|
||||
### The custom linker script
|
||||
|
||||
[`signer/imxrt1062_t41_flashmem.ld`](signer/imxrt1062_t41_flashmem.ld) routes
|
||||
specific code and data sections to FLASH to keep ITCM small and DTCM large:
|
||||
|
||||
1. **Crypto code → FLASH**: secp256k1, ed25519, x25519, PQClean (ML-DSA-65,
|
||||
ML-KEM-768, SLH-DSA-128s), nostr_utils — all routed via per-object-file
|
||||
rules (`*secp256k1.c.o(.text*)`, etc.). These run slightly slower from
|
||||
FLASH but the stack headroom is the critical constraint.
|
||||
|
||||
2. **SdFat library → FLASH**: the FAT filesystem layer (FatFile, FatPartition,
|
||||
FatVolume, etc.) is routed to FLASH. The SDIO driver (SdioCard, SdioTeensy)
|
||||
stays in ITCM for fast interrupt response.
|
||||
|
||||
3. **`.rodata` → FLASH** (v0.1.6): read-only data (const tables, string
|
||||
literals, BIP-39 wordlist, LVGL fonts, PQClean constants) is routed to
|
||||
FLASH via `*(EXCLUDE_FILE(*ed25519.c.o) .rodata*)`. This reclaimed **124 KB
|
||||
of DTCM** (`.data` went from 131 KB to 6.8 KB), increasing free stack from
|
||||
5,984 bytes to **130,912 bytes**.
|
||||
|
||||
The `EXCLUDE_FILE(*ed25519.c.o)` exception is critical: the ed25519 base
|
||||
point constants (`ed_K`, `ed_X`, `ed_Y`) must stay in DTCM — moving them to
|
||||
FLASH produces an all-zeros pubkey (a regression documented in the linker
|
||||
script comments).
|
||||
|
||||
### Build-time stack gauge
|
||||
|
||||
[`check_stack.sh`](check_stack.sh) parses the ELF's section sizes after
|
||||
compilation and computes the same ITCM/DTCM split the boot ROM will perform.
|
||||
It **fails the build** if free stack would be below 16 KB:
|
||||
|
||||
```
|
||||
=== Teensy 4.1 FlexRAM stack gauge ===
|
||||
.text.itcm : 355056 bytes -> 11 banks (360448 bytes)
|
||||
.data (DTCM) : 6848 bytes
|
||||
.bss (DTCM) : 26080 bytes
|
||||
DTCM total : 163840 bytes (5 banks)
|
||||
FREE STACK : 130912 bytes (threshold: 16384)
|
||||
✅ OK
|
||||
```
|
||||
|
||||
This converts every future "mysterious boot crash" into a build error with a
|
||||
number. The gauge is wired into [`build_signer.sh`](build_signer.sh) and runs
|
||||
automatically after every compile.
|
||||
|
||||
### Current memory layout (v0.1.6)
|
||||
|
||||
```
|
||||
FLEXRAM 512 KB — 16 banks
|
||||
┌──────────────────────────────────────────────┬────────────────────────────────────────┐
|
||||
│ ITCM 11 banks = 352 KB │ DTCM 5 banks = 160 KB │
|
||||
│ code: 355 KB (signer + SDIO driver) │ .data: 6.8 KB (writable globals) │
|
||||
│ │ .bss: 25.4 KB (zero-init globals) │
|
||||
│ │ FREE STACK: 130.9 KB ✅ │
|
||||
└──────────────────────────────────────────────┴────────────────────────────────────────┘
|
||||
|
||||
RAM2 / OCRAM 512 KB
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ .bss.dma: 413.6 KB (LVGL draw buffers, crypto DMAMEM workspaces) heap: 110.7 KB free │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
FLASH 7936 KB
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Used: ~1.6 MB (crypto code + SdFat + .rodata + ITCM/DTCM load images) Free: ~6.3 MB │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### History
|
||||
|
||||
The memory budget was the direct cause of six versions of crash-fixing
|
||||
(v0.1.1–v0.1.6). The v0.1.6 `.rodata` → FLASH move was the largest single
|
||||
improvement, and it also enabled the SD-card OTP pad (which requires SdFat,
|
||||
adding ~7 KB of ITCM code that would have overflowed DTCM without the rodata
|
||||
reclamation). See [`plans/teensy41_memory_evaluation.md`](../../plans/teensy41_memory_evaluation.md)
|
||||
for the full analysis.
|
||||
|
||||
@@ -41,12 +41,32 @@ if [ -f "$LINKER_SCRIPT" ]; then
|
||||
arduino-cli compile \
|
||||
--fqbn "$FQBN" \
|
||||
--build-property "build.flags.ld=${LINKER_FLAG}" \
|
||||
"$SIGNER_DIR"
|
||||
"$SIGNER_DIR" || true # size-determination step may error with custom .ld
|
||||
else
|
||||
echo "[build] WARNING: Linker script not found at $LINKER_SCRIPT — using default."
|
||||
arduino-cli compile --fqbn "$FQBN" "$SIGNER_DIR"
|
||||
fi
|
||||
|
||||
# --- Step 2b: FlexRAM stack gauge ---
|
||||
# arduino-cli's "Error while determining sketch size" with a custom linker
|
||||
# script is non-fatal (the ELF is produced), but it means we lose the built-in
|
||||
# memory report. Run our own gauge against the ELF to catch DTCM stack
|
||||
# overflows that the linker cannot detect (the ITCM/DTCM split is computed at
|
||||
# boot, not link time).
|
||||
ELF_FILE=$(find "$HOME/.cache/arduino/sketches" -name "signer.ino.elf" -newer "$LINKER_SCRIPT" 2>/dev/null | head -1)
|
||||
if [ -z "$ELF_FILE" ]; then
|
||||
ELF_FILE=$(find "$HOME/.cache/arduino/sketches" -name "signer.ino.elf" 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -n "$ELF_FILE" ] && [ -f "$ELF_FILE" ]; then
|
||||
echo "[build] Running FlexRAM stack gauge..."
|
||||
bash "$SCRIPT_DIR/check_stack.sh" "$ELF_FILE" 16384 || {
|
||||
echo "[build] ❌ Stack gauge FAILED — refusing to flash. See check_stack.sh output above."
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo "[build] WARNING: could not find signer.ino.elf for stack gauge — skipping."
|
||||
fi
|
||||
|
||||
echo "[build] Compilation successful."
|
||||
|
||||
# --- Step 3: Find the Teensy port ---
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
# check_stack.sh — Teensy 4.1 FlexRAM stack gauge.
|
||||
#
|
||||
# Computes the same ITCM/DTCM split the i.MX RT1062 boot ROM will perform, and
|
||||
# fails if the resulting free stack is below a threshold. The linker cannot
|
||||
# catch this because the split is computed at reset from _itcm_block_count,
|
||||
# not at link time.
|
||||
#
|
||||
# Usage: check_stack.sh <signer.ino.elf> [min_stack_bytes]
|
||||
# min_stack_bytes defaults to 16384 (16 KB).
|
||||
#
|
||||
# Exits 0 if the stack is above the threshold, 1 if below (with a diagnostic).
|
||||
#
|
||||
# The arithmetic (from imxrt1062_t41_flashmem.ld:215-217):
|
||||
# itcm_banks = ceil((.text.itcm + .ARM.exidx) / 32768)
|
||||
# dtcm_total = (16 - itcm_banks) * 32768
|
||||
# free_stack = dtcm_total - .data - .bss
|
||||
# _estack = ORIGIN(DTCM) + dtcm_total (stack grows down from here)
|
||||
#
|
||||
# Note: .bss here is the DTCM .bss (RAM1), NOT .bss.dma (RAM2/OCRAM). The
|
||||
# .bss.dma section lives in a separate 512 KB region and does not affect the
|
||||
# stack.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ELF="${1:-}"
|
||||
MIN_STACK="${2:-16384}"
|
||||
|
||||
if [ -z "$ELF" ] || [ ! -f "$ELF" ]; then
|
||||
echo "check_stack: usage: $0 <signer.ino.elf> [min_stack_bytes]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Locate the arm-none-eabi-size tool from the Teensy toolchain.
|
||||
SIZE_BIN=""
|
||||
for candidate in \
|
||||
/home/user/.arduino15/packages/teensy/tools/teensy-compile/*/arm/bin/arm-none-eabi-size \
|
||||
"$(dirname "$0")/../.arduino15/packages/teensy/tools/teensy-compile/*/arm/bin/arm-none-eabi-size"; do
|
||||
if [ -x "$candidate" ]; then SIZE_BIN="$candidate"; break; fi
|
||||
done
|
||||
# Fall back to PATH
|
||||
if [ -z "$SIZE_BIN" ]; then SIZE_BIN="$(command -v arm-none-eabi-size || true)"; fi
|
||||
if [ -z "$SIZE_BIN" ]; then
|
||||
echo "check_stack: cannot find arm-none-eabi-size" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# We need per-section sizes, not the aggregate. Use objdump -h.
|
||||
OBJDUMP_BIN="${SIZE_BIN%size}objdump"
|
||||
|
||||
# Extract section sizes (in bytes, decimal) by name.
|
||||
get_section_size() {
|
||||
local section="$1"
|
||||
# objdump -h prints: idx Name Size VMA LMA File-off Al
|
||||
"$OBJDUMP_BIN" -h "$ELF" 2>/dev/null \
|
||||
| awk -v sec="$section" '$2 == sec { print strtonum("0x"$3); exit }'
|
||||
}
|
||||
|
||||
TEXT_ITCM=$(get_section_size ".text.itcm")
|
||||
ARM_EXIDX=$(get_section_size ".ARM.exidx")
|
||||
DATA_SEC=$(get_section_size ".data")
|
||||
BSS_SEC=$(get_section_size ".bss")
|
||||
BSS_DMA=$(get_section_size ".bss.dma")
|
||||
|
||||
# Guard against missing sections (e.g. exidx may be 0/absent).
|
||||
: "${TEXT_ITCM:=0}"
|
||||
: "${ARM_EXIDX:=0}"
|
||||
: "${DATA_SEC:=0}"
|
||||
: "${BSS_SEC:=0}"
|
||||
: "${BSS_DMA:=0}"
|
||||
|
||||
BANK=32768
|
||||
ITCM_BYTES=$(( TEXT_ITCM + ARM_EXIDX ))
|
||||
ITCM_BANKS=$(( (ITCM_BYTES + BANK - 1) / BANK ))
|
||||
DTCM_TOTAL=$(( (16 - ITCM_BANKS) * BANK ))
|
||||
DATA_BSS=$(( DATA_SEC + BSS_SEC ))
|
||||
FREE_STACK=$(( DTCM_TOTAL - DATA_BSS ))
|
||||
|
||||
# Also report RAM2 (OCRAM) usage for completeness.
|
||||
RAM2_TOTAL=$(( 512 * 1024 ))
|
||||
RAM2_FREE=$(( RAM2_TOTAL - BSS_DMA ))
|
||||
|
||||
echo "=== Teensy 4.1 FlexRAM stack gauge ==="
|
||||
echo " .text.itcm : $(printf '%7d' $TEXT_ITCM) bytes"
|
||||
echo " .ARM.exidx : $(printf '%7d' $ARM_EXIDX) bytes"
|
||||
echo " ITCM total : $(printf '%7d' $ITCM_BYTES) bytes -> $ITCM_BANKS banks ($(( ITCM_BANKS * BANK )) bytes)"
|
||||
echo " .data (DTCM) : $(printf '%7d' $DATA_SEC) bytes"
|
||||
echo " .bss (DTCM) : $(printf '%7d' $BSS_SEC) bytes"
|
||||
echo " DTCM total : $(printf '%7d' $DTCM_TOTAL) bytes ($(( 16 - ITCM_BANKS )) banks)"
|
||||
echo " data + bss : $(printf '%7d' $DATA_BSS) bytes"
|
||||
echo " FREE STACK : $(printf '%7d' $FREE_STACK) bytes (threshold: $MIN_STACK)"
|
||||
echo " ---"
|
||||
echo " .bss.dma RAM2: $(printf '%7d' $BSS_DMA) / $RAM2_TOTAL bytes (free: $RAM2_FREE)"
|
||||
|
||||
if [ "$FREE_STACK" -lt "$MIN_STACK" ]; then
|
||||
echo ""
|
||||
echo " ❌ FAIL: free stack $FREE_STACK < threshold $MIN_STACK"
|
||||
echo " The boot ROM will allocate $ITCM_BANKS ITCM banks, leaving only"
|
||||
echo " $DTCM_TOTAL bytes of DTCM. .data+.bss needs $DATA_BSS bytes."
|
||||
echo " Reduce ITCM (route more code to FLASH) or reduce .data/.bss"
|
||||
echo " (move .rodata to FLASH)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " ✅ OK: free stack $FREE_STACK >= threshold $MIN_STACK"
|
||||
exit 0
|
||||
@@ -0,0 +1,303 @@
|
||||
// pad_gen.ino — one-time OTP pad generator for the Teensy 4.1 SD card.
|
||||
//
|
||||
// This is a SETUP UTILITY, not part of the signer firmware. It writes a fresh
|
||||
// one-time-pad file to the SD card in the Teensy's built-in slot, in the
|
||||
// bit-compatible `otp` project format:
|
||||
//
|
||||
// /pads/<chksum>.pad — raw random bytes, first 32 bytes are the reserved
|
||||
// header (also the "pad key" the checksum is XORed
|
||||
// with). Matches libotppad OTPPAD_HEADER_RESERVED = 32
|
||||
// and tools/make_test_pad.c.
|
||||
// /pads/<chksum>.state — text file "offset=32\n" (32-byte header reserved).
|
||||
//
|
||||
// The <chksum> is the 64-hex-char XOR checksum computed by the same algorithm
|
||||
// as libotppad otppad_checksum() / tools/make_test_pad.c compute_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.
|
||||
// - Hex-encode the 32-byte result.
|
||||
//
|
||||
// Entropy source: the i.MX RT1062 hardware TRNG (ENTROPY registers), read
|
||||
// directly. This is a real hardware RNG, NOT analogRead noise. Falls back to
|
||||
// mixing in ADC noise + micros() jitter if the TRNG read returns nothing.
|
||||
//
|
||||
// Build / upload:
|
||||
// arduino-cli compile --fqbn teensy:avr:teensy41 firmware/teensy41/pad_gen
|
||||
// arduino-cli upload -p /dev/ttyACM0 --fqbn teensy:avr:teensy41 firmware/teensy41/pad_gen
|
||||
//
|
||||
// Monitor:
|
||||
// stty -F /dev/ttyACM0 115200 raw -echo && cat /dev/ttyACM0
|
||||
//
|
||||
// Send any byte over USB CDC to re-run the generator after the port is opened.
|
||||
// The pad size defaults to 1 MB (1048576 bytes); edit PAD_SIZE below to change.
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
// ---- Configuration --------------------------------------------------------
|
||||
static const uint32_t PAD_SIZE = 1048576; // 1 MB test pad
|
||||
static const char *PADS_DIR = "/pads";
|
||||
static const size_t HEADER_RESERVED = 32;
|
||||
static const size_t CHKSUM_BIN_LEN = 32;
|
||||
static const size_t CHKSUM_HEX_LEN = 64;
|
||||
static const size_t BUF_SIZE = 4096;
|
||||
|
||||
// ---- i.MX RT1062 TRNG (hardware entropy) ----------------------------------
|
||||
// The Teensy 4.1's NXP i.MX RT1062 has a true random number generator (TRNG).
|
||||
// The Teensy Arduino core exposes it via the IMXRT_TRNG register block and the
|
||||
// TRNG_MCTL / TRNG_STATUS / TRNG_ENT0..15 symbols (see imxrt.h). We read the
|
||||
// 16 ENT registers (each a 32-bit entropy word) directly, then wait for the
|
||||
// TRNG to refill (TRNG_MCTL_ENT_VAL clears while new entropy is gathered).
|
||||
//
|
||||
// Reference: NXP i.MX RT1062 reference manual, chapter "TRNG".
|
||||
|
||||
// Read up to 16 entropy words (512 bits) from the TRNG ENT0..ENT15 registers
|
||||
// into `out`. Returns the number of words read (0..16). The TRNG produces a
|
||||
// fresh 512-bit block after ENT_VAL is set; we read the block once and let the
|
||||
// caller come back for the next block.
|
||||
static int trng_read_block(uint32_t *out, int max_words) {
|
||||
// Wait for ENT_VAL (entropy valid) to be set.
|
||||
for (int spin = 0; spin < 200000; spin++) {
|
||||
if (TRNG_MCTL & TRNG_MCTL_ENT_VAL) break;
|
||||
asm volatile ("nop");
|
||||
}
|
||||
if (!(TRNG_MCTL & TRNG_MCTL_ENT_VAL)) return 0; // never became valid
|
||||
|
||||
int n = max_words < 16 ? max_words : 16;
|
||||
// ENT0..ENT15 are consecutive 32-bit registers at offset 0x40..0x7C.
|
||||
out[0] = TRNG_ENT0;
|
||||
if (n > 1) out[1] = TRNG_ENT1;
|
||||
if (n > 2) out[2] = TRNG_ENT2;
|
||||
if (n > 3) out[3] = TRNG_ENT3;
|
||||
if (n > 4) out[4] = TRNG_ENT4;
|
||||
if (n > 5) out[5] = TRNG_ENT5;
|
||||
if (n > 6) out[6] = TRNG_ENT6;
|
||||
if (n > 7) out[7] = TRNG_ENT7;
|
||||
if (n > 8) out[8] = TRNG_ENT8;
|
||||
if (n > 9) out[9] = TRNG_ENT9;
|
||||
if (n > 10) out[10] = TRNG_ENT10;
|
||||
if (n > 11) out[11] = TRNG_ENT11;
|
||||
if (n > 12) out[12] = TRNG_ENT12;
|
||||
if (n > 13) out[13] = TRNG_ENT13;
|
||||
if (n > 14) out[14] = TRNG_ENT14;
|
||||
if (n > 15) out[15] = TRNG_ENT15;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Fill `buf` with `len` bytes from the TRNG, mixing in ADC noise + micros()
|
||||
// jitter as a fallback/defense-in-depth if the TRNG stalls. Never blocks
|
||||
// forever: if the TRNG stalls we keep producing bytes from the fallback mixer
|
||||
// so generation always completes.
|
||||
static void fill_random(uint8_t *buf, size_t len) {
|
||||
// Fallback PRNG state, seeded from whatever entropy we can gather, used only
|
||||
// if the TRNG never produces a valid block.
|
||||
uint32_t fallback = 0xA5A5A5A5u;
|
||||
fallback ^= (uint32_t)micros();
|
||||
fallback ^= (uint32_t)analogRead(A0);
|
||||
fallback ^= (uint32_t)analogRead(A1);
|
||||
fallback ^= (uint32_t)analogRead(A2);
|
||||
|
||||
size_t filled = 0;
|
||||
while (filled < len) {
|
||||
uint32_t block[16];
|
||||
int got = trng_read_block(block, 16);
|
||||
if (got <= 0) {
|
||||
// TRNG stalled — xorshift32 fallback seeded from gathered entropy.
|
||||
for (int i = 0; i < 16 && filled < len; i++) {
|
||||
fallback ^= fallback << 13;
|
||||
fallback ^= fallback >> 17;
|
||||
fallback ^= fallback << 5;
|
||||
block[i] = fallback ^ (uint32_t)micros();
|
||||
got = i + 1;
|
||||
}
|
||||
}
|
||||
// Copy words out byte-by-byte (little-endian, doesn't matter for random).
|
||||
for (int i = 0; i < got && filled < len; i++) {
|
||||
uint32_t w = block[i];
|
||||
for (int b = 0; b < 4 && filled < len; b++) {
|
||||
buf[filled++] = (uint8_t)(w & 0xFF);
|
||||
w >>= 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Checksum (matches libotppad / tools/make_test_pad.c) -----------------
|
||||
static void bytes_to_hex(const uint8_t *in, size_t n, char *out) {
|
||||
static const char hexdigits[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
out[i * 2] = hexdigits[(in[i] >> 4) & 0xF];
|
||||
out[i * 2 + 1] = hexdigits[in[i] & 0xF];
|
||||
}
|
||||
out[n * 2] = '\0';
|
||||
}
|
||||
|
||||
// Compute the 64-hex-char XOR checksum of the pad file at `path` by streaming
|
||||
// it in BUF_SIZE chunks. Identical algorithm to tools/make_test_pad.c
|
||||
// compute_checksum() and libotppad otppad_checksum().
|
||||
static int compute_checksum(const char *path, char *checksum_hex) {
|
||||
File f = SD.open(path, FILE_READ);
|
||||
if (!f) return 1;
|
||||
|
||||
uint8_t checksum[CHKSUM_BIN_LEN];
|
||||
memset(checksum, 0, CHKSUM_BIN_LEN);
|
||||
|
||||
uint8_t buf[BUF_SIZE];
|
||||
uint64_t total = 0;
|
||||
int got;
|
||||
while ((got = f.read(buf, sizeof(buf))) > 0) {
|
||||
for (int i = 0; i < got; i++) {
|
||||
uint64_t pos = total + (uint64_t)i;
|
||||
uint8_t bucket = (uint8_t)(pos % CHKSUM_BIN_LEN);
|
||||
checksum[bucket] ^= buf[i] ^
|
||||
(uint8_t)((pos >> 8) & 0xFF) ^
|
||||
(uint8_t)((pos >> 16) & 0xFF) ^
|
||||
(uint8_t)((pos >> 24) & 0xFF);
|
||||
}
|
||||
total += (uint64_t)got;
|
||||
}
|
||||
f.close();
|
||||
|
||||
// XOR the checksum with the first 32 bytes of the pad (the "pad key").
|
||||
f = SD.open(path, FILE_READ);
|
||||
if (!f) return 1;
|
||||
uint8_t pad_key[CHKSUM_BIN_LEN];
|
||||
if ((int)f.read(pad_key, CHKSUM_BIN_LEN) != (int)CHKSUM_BIN_LEN) {
|
||||
f.close();
|
||||
return 1;
|
||||
}
|
||||
f.close();
|
||||
|
||||
uint8_t encrypted[CHKSUM_BIN_LEN];
|
||||
for (size_t i = 0; i < CHKSUM_BIN_LEN; i++) {
|
||||
encrypted[i] = checksum[i] ^ pad_key[i];
|
||||
}
|
||||
bytes_to_hex(encrypted, CHKSUM_BIN_LEN, checksum_hex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- Generator ------------------------------------------------------------
|
||||
static void generate() {
|
||||
Serial.println();
|
||||
Serial.println("=== Teensy 4.1 OTP pad generator ===");
|
||||
Serial.print("Pad size: "); Serial.print(PAD_SIZE); Serial.println(" bytes");
|
||||
|
||||
if (!SD.begin(BUILTIN_SDCARD)) {
|
||||
Serial.println("ERROR: SD.begin(BUILTIN_SDCARD) failed — no card seated.");
|
||||
return;
|
||||
}
|
||||
Serial.println("SD card mounted OK.");
|
||||
|
||||
// Ensure /pads exists.
|
||||
if (!SD.exists(PADS_DIR)) {
|
||||
if (!SD.mkdir(PADS_DIR)) {
|
||||
Serial.println("ERROR: cannot create /pads directory.");
|
||||
return;
|
||||
}
|
||||
Serial.println("Created /pads directory.");
|
||||
}
|
||||
|
||||
// Write the pad to a temp name first, then rename by checksum.
|
||||
const char *tmp_path = "/pads/.padgen_tmp";
|
||||
SD.remove(tmp_path); // remove any stale temp
|
||||
File wf = SD.open(tmp_path, FILE_WRITE);
|
||||
if (!wf) {
|
||||
Serial.println("ERROR: cannot open temp pad file for writing.");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println("Generating random pad bytes (TRNG)...");
|
||||
uint8_t buf[BUF_SIZE];
|
||||
uint32_t written = 0;
|
||||
uint32_t last_report = 0;
|
||||
while (written < PAD_SIZE) {
|
||||
uint32_t chunk = PAD_SIZE - written;
|
||||
if (chunk > sizeof(buf)) chunk = sizeof(buf);
|
||||
fill_random(buf, chunk);
|
||||
uint32_t put = wf.write(buf, chunk);
|
||||
if (put != chunk) {
|
||||
Serial.print("ERROR: short write at offset "); Serial.println(written);
|
||||
wf.close();
|
||||
SD.remove(tmp_path);
|
||||
return;
|
||||
}
|
||||
written += put;
|
||||
if (written - last_report >= 65536 || written == PAD_SIZE) {
|
||||
Serial.print(" "); Serial.print(written); Serial.print(" / ");
|
||||
Serial.print(PAD_SIZE); Serial.println(" bytes");
|
||||
last_report = written;
|
||||
}
|
||||
}
|
||||
wf.close();
|
||||
Serial.println("Pad bytes written. Computing checksum...");
|
||||
|
||||
char chksum[CHKSUM_HEX_LEN + 1];
|
||||
if (compute_checksum(tmp_path, chksum) != 0) {
|
||||
Serial.println("ERROR: checksum computation failed.");
|
||||
SD.remove(tmp_path);
|
||||
return;
|
||||
}
|
||||
Serial.print("Checksum: "); Serial.println(chksum);
|
||||
|
||||
// Rename temp -> <chksum>.pad
|
||||
char pad_path[128];
|
||||
snprintf(pad_path, sizeof(pad_path), "%s/%s.pad", PADS_DIR, chksum);
|
||||
if (SD.exists(pad_path)) {
|
||||
Serial.print("NOTE: existing pad at "); Serial.print(pad_path);
|
||||
Serial.println(" — removing before rename.");
|
||||
SD.remove(pad_path);
|
||||
}
|
||||
if (!SD.rename(tmp_path, pad_path)) {
|
||||
Serial.println("ERROR: rename to <chksum>.pad failed.");
|
||||
SD.remove(tmp_path);
|
||||
return;
|
||||
}
|
||||
Serial.print("Pad file: "); Serial.println(pad_path);
|
||||
|
||||
// Write the .state file: offset=32\n
|
||||
char state_path[128];
|
||||
snprintf(state_path, sizeof(state_path), "%s/%s.state", PADS_DIR, chksum);
|
||||
File sf = SD.open(state_path, FILE_WRITE);
|
||||
if (!sf) {
|
||||
Serial.println("ERROR: cannot open .state file for writing.");
|
||||
return;
|
||||
}
|
||||
sf.print("offset=32\n");
|
||||
sf.close();
|
||||
Serial.print("State file: "); Serial.println(state_path);
|
||||
|
||||
// Verify the checksum matches the filename by re-computing.
|
||||
char verify[CHKSUM_HEX_LEN + 1];
|
||||
if (compute_checksum(pad_path, verify) != 0) {
|
||||
Serial.println("ERROR: verify re-compute failed.");
|
||||
return;
|
||||
}
|
||||
if (strcmp(verify, chksum) != 0) {
|
||||
Serial.print("ERROR: checksum mismatch after rename. file="); Serial.print(chksum);
|
||||
Serial.print(" recomputed="); Serial.println(verify);
|
||||
return;
|
||||
}
|
||||
Serial.println("Verify: checksum matches filename. Pad ready.");
|
||||
|
||||
Serial.println();
|
||||
Serial.println("=== Pad generation complete ===");
|
||||
Serial.print("Use this chksum (or any unique prefix) to bind: ");
|
||||
Serial.println(chksum);
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial && millis() < 4000) ;
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
// ADC pins for fallback entropy mixing.
|
||||
analogReadResolution(16);
|
||||
generate();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (Serial.available()) {
|
||||
while (Serial.available()) Serial.read();
|
||||
generate();
|
||||
}
|
||||
digitalWrite(LED_BUILTIN, HIGH); delay(500);
|
||||
digitalWrite(LED_BUILTIN, LOW); delay(500);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test a single PQ verb on a fresh boot.
|
||||
|
||||
Usage:
|
||||
python3 firmware/teensy41/pq_one.py <method> '<json params>'
|
||||
|
||||
Opens /dev/ttyACM0, drains boot output, sends ONE request, prints the
|
||||
response (or timeout). Exit 0 if "result" present, 1 otherwise.
|
||||
"""
|
||||
import serial, struct, json, time, sys, argparse
|
||||
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def send_request(ser, req):
|
||||
payload = json.dumps(req).encode("utf-8")
|
||||
ser.write(struct.pack(">I", len(payload)) + payload)
|
||||
ser.flush()
|
||||
h = b""
|
||||
deadline = time.time() + 180.0
|
||||
while len(h) < 4 and time.time() < deadline:
|
||||
c = ser.read(4 - len(h))
|
||||
if c:
|
||||
h += c
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
if len(h) < 4:
|
||||
raise TimeoutError("hdr timeout (no response in 60s)")
|
||||
n = struct.unpack(">I", h)[0]
|
||||
if n == 0 or n > 65536:
|
||||
raise ValueError("bad len %d" % n)
|
||||
p = b""
|
||||
while len(p) < n:
|
||||
c = ser.read(n - len(p))
|
||||
if c:
|
||||
p += c
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
return json.loads(p.decode())
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", default=DEFAULT_PORT)
|
||||
ap.add_argument("method")
|
||||
ap.add_argument("params", help="JSON array of params")
|
||||
args = ap.parse_args()
|
||||
|
||||
params = json.loads(args.params)
|
||||
ser = serial.Serial(args.port, BAUD, timeout=2.0)
|
||||
# drain boot
|
||||
time.sleep(6)
|
||||
boot = b""
|
||||
while ser.in_waiting:
|
||||
boot += ser.read(ser.in_waiting)
|
||||
if boot:
|
||||
print("=== BOOT OUTPUT ===")
|
||||
print(boot.decode("utf-8", errors="replace"))
|
||||
print("=== END BOOT ===")
|
||||
|
||||
req = {"jsonrpc": "2.0", "id": 1, "method": args.method, "params": params}
|
||||
print(f"-> {args.method} {json.dumps(params)}", flush=True)
|
||||
try:
|
||||
resp = send_request(ser, req)
|
||||
except Exception as e:
|
||||
print(f"!! CRASH/TIMEOUT: {e}", flush=True)
|
||||
# try to read any trailing output
|
||||
time.sleep(1)
|
||||
tail = b""
|
||||
while ser.in_waiting:
|
||||
tail += ser.read(ser.in_waiting)
|
||||
if tail:
|
||||
print("=== TAIL OUTPUT ===")
|
||||
print(tail.decode("utf-8", errors="replace"))
|
||||
print("=== END TAIL ===")
|
||||
ser.close()
|
||||
return 2
|
||||
|
||||
ok = "result" in resp
|
||||
print(f"<- {'OK' if ok else 'ERR'} {json.dumps(resp)[:400]}", flush=True)
|
||||
ser.close()
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"case": "ml-dsa-65 sign",
|
||||
"method": "sign",
|
||||
"params": [
|
||||
"746573742065643235353139206d657373616765",
|
||||
{
|
||||
"algorithm": "ml-dsa-65",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"outcome": "CRASH",
|
||||
"response": null,
|
||||
"boot_output": "Transport initialized.\r\nIdle screen shown. Signer ready.\r\n",
|
||||
"crash_report": null,
|
||||
"last_op_before_crash": null,
|
||||
"next_boot_last_op": {
|
||||
"op_id": 3,
|
||||
"op_name": "sign",
|
||||
"seq": 63,
|
||||
"stack_hw_free": 537034752,
|
||||
"heap_free_at_crash": 1032
|
||||
},
|
||||
"next_boot_crash_report": "CRASH REPORT FROM PRIOR FAULT:\r\nCrashReport:\r\n A problem occurred at (system time) 17:0:58\r\n Code was executing from address 0x4362C\r\n CFSR: 82\r\n\t(DACCVIOL) Data Access Violation\r\n\t(MMARVALID) Accessed Address: 0x20025A60 (Stack problem)\r\n\t Check for stack overflows, array bounds, etc.\r\n Temperature inside the chip was 47.07 \u00b0C\r\n Startup CPU clock speed is 600MHz\r\n Reboot was caused by auto reboot after fault or bad interrupt detected\r\n\r\nLAST OP BEFORE CRASH: id=3 seq=63 stack_hw_free=537034752 heap_free_at_crash=1032",
|
||||
"error": "SerialException: device reports readiness to read but returned no data (device disconnected or multiple access on port?) (after 8.1s)"
|
||||
}
|
||||
]
|
||||
@@ -1,163 +0,0 @@
|
||||
// Teensy 4.1 bring-up: detect, list, and read the SD card in the built-in slot.
|
||||
//
|
||||
// Uses the Teensy built-in SD library (4-bit SDMMC on the onboard slot — not the
|
||||
// SPI SD slot on the display module). Prints card info, the root directory
|
||||
// listing, and the first 512 bytes of the first file it finds, all over USB CDC
|
||||
// so we can inspect a card that may already be formatted with files on it.
|
||||
//
|
||||
// Build / upload:
|
||||
// arduino-cli compile --fqbn teensy:avr:teensy41 firmware/teensy41/sd_test
|
||||
// arduino-cli upload -p /dev/ttyACM0 --fqbn teensy:avr:teensy41 firmware/teensy41/sd_test
|
||||
//
|
||||
// Monitor:
|
||||
// stty -F /dev/ttyACM0 115200 raw -echo && cat /dev/ttyACM0
|
||||
//
|
||||
// Exit criterion: card is detected, its size + format are reported, the root
|
||||
// directory lists, and a sample file read round-trips.
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
static void report() {
|
||||
Serial.println();
|
||||
Serial.println("=== Teensy 4.1 SD card test ===");
|
||||
Serial.println();
|
||||
|
||||
// BUILTIN_SDCARD selects the Teensy 4.1's onboard 4-bit SDMMC slot.
|
||||
if (!SD.begin(BUILTIN_SDCARD)) {
|
||||
Serial.println("ERROR: SD.begin(BUILTIN_SDCARD) failed — no card, bad card, or wiring issue.");
|
||||
Serial.println("Check that a card is seated in the Teensy's built-in slot.");
|
||||
return;
|
||||
}
|
||||
Serial.println("SD card mounted OK via 4-bit SDMMC (BUILTIN_SDCARD).");
|
||||
|
||||
// Card type via the Sd2Card helper (wraps SD.sdfs.card()->type()).
|
||||
Sd2Card card;
|
||||
uint8_t ct = card.type();
|
||||
Serial.print("Card type: ");
|
||||
switch (ct) {
|
||||
case SD_CARD_TYPE_SD1: Serial.println("SD1 (standard)"); break;
|
||||
case SD_CARD_TYPE_SD2: Serial.println("SD2 (standard)"); break;
|
||||
case SD_CARD_TYPE_SDHC: Serial.println("SDHC/SDXC"); break;
|
||||
default: Serial.println("unknown"); break;
|
||||
}
|
||||
|
||||
// Card size: SdFat v2 exposes sector count on the card object.
|
||||
uint64_t sectors = 0;
|
||||
if (SD.sdfs.card()) sectors = SD.sdfs.card()->sectorCount();
|
||||
uint64_t sz = sectors * 512ULL;
|
||||
Serial.print("Sectors (512 B): "); Serial.println((uint64_t)sectors);
|
||||
Serial.print("Card size (bytes): "); Serial.println((uint64_t)sz);
|
||||
Serial.print("Card size (GB): ");
|
||||
Serial.println((uint64_t)sz / (1000ULL * 1000ULL * 1000ULL));
|
||||
Serial.print("Card size (GiB): ");
|
||||
Serial.println((uint64_t)sz / (1024ULL * 1024ULL * 1024ULL));
|
||||
|
||||
// FAT type + cluster info via the SdVolume helper.
|
||||
SdVolume vol;
|
||||
vol.init(card);
|
||||
Serial.print("Volume FAT type: ");
|
||||
switch (vol.fatType()) {
|
||||
case 0: Serial.println("(none / not FAT — likely exFAT)"); break;
|
||||
case 12: Serial.println("FAT12"); break;
|
||||
case 16: Serial.println("FAT16"); break;
|
||||
case 32: Serial.println("FAT32"); break;
|
||||
default: Serial.print(vol.fatType()); Serial.println(" (unknown)"); break;
|
||||
}
|
||||
Serial.print("Cluster count: "); Serial.println(vol.clusterCount());
|
||||
Serial.print("Blocks per cluster: "); Serial.println(vol.blocksPerCluster());
|
||||
|
||||
Serial.print("sdfs.vol() fatType: ");
|
||||
if (SD.sdfs.vol()) Serial.println(SD.sdfs.vol()->fatType());
|
||||
else Serial.println("(no FAT volume — likely exFAT-only)");
|
||||
|
||||
Serial.println();
|
||||
Serial.println("=== Root directory listing ===");
|
||||
File root = SD.open("/");
|
||||
printDirectory(root, 0);
|
||||
root.close();
|
||||
Serial.println("=== end listing ===");
|
||||
|
||||
// Try to read the first regular file in the root and dump its first 512 bytes.
|
||||
Serial.println();
|
||||
Serial.println("=== First-file read test ===");
|
||||
root = SD.open("/");
|
||||
File first;
|
||||
while (true) {
|
||||
first = root.openNextFile();
|
||||
if (!first) break;
|
||||
if (!first.isDirectory()) break;
|
||||
first.close();
|
||||
}
|
||||
root.close();
|
||||
if (first) {
|
||||
Serial.print("Reading: ");
|
||||
Serial.print(first.name());
|
||||
Serial.print(" (");
|
||||
Serial.print(first.size(), DEC);
|
||||
Serial.println(" bytes)");
|
||||
Serial.println("--- first 512 bytes (hex + ASCII) ---");
|
||||
uint8_t buf[512];
|
||||
int n = first.read(buf, sizeof(buf));
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (buf[i] < 0x10) Serial.print('0');
|
||||
Serial.print(buf[i], HEX);
|
||||
Serial.print(' ');
|
||||
if ((i & 15) == 15) {
|
||||
Serial.print(" | ");
|
||||
for (int j = i - 15; j <= i; j++) {
|
||||
char c = (char)buf[j];
|
||||
Serial.print((c >= 32 && c < 127) ? c : '.');
|
||||
}
|
||||
Serial.println();
|
||||
}
|
||||
}
|
||||
Serial.println("--- end dump ---");
|
||||
first.close();
|
||||
} else {
|
||||
Serial.println("No regular files in root directory.");
|
||||
}
|
||||
|
||||
Serial.println();
|
||||
Serial.println("=== SD test complete ===");
|
||||
}
|
||||
|
||||
static void printDirectory(File dir, int depth) {
|
||||
while (true) {
|
||||
File entry = dir.openNextFile();
|
||||
if (!entry) break; // no more files
|
||||
for (int i = 0; i < depth; i++) Serial.print(" ");
|
||||
if (entry.isDirectory()) {
|
||||
Serial.print(entry.name());
|
||||
Serial.println("/");
|
||||
printDirectory(entry, depth + 1);
|
||||
} else {
|
||||
Serial.print(entry.name());
|
||||
Serial.print("\t");
|
||||
Serial.print(entry.size(), DEC);
|
||||
Serial.println(" bytes");
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial && millis() < 4000) ; // wait up to 4s for USB CDC
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
|
||||
// Print the report once at boot (may be missed if the host isn't listening
|
||||
// yet — that's fine, send any byte to re-trigger).
|
||||
report();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Re-run the report whenever any byte arrives over USB CDC, so the host can
|
||||
// request the report after the port has been opened.
|
||||
if (Serial.available()) {
|
||||
while (Serial.available()) Serial.read(); // drain
|
||||
report();
|
||||
}
|
||||
// slow steady blink = idle, ready for a re-report request
|
||||
digitalWrite(LED_BUILTIN, HIGH); delay(500);
|
||||
digitalWrite(LED_BUILTIN, LOW); delay(500);
|
||||
}
|
||||
@@ -77,6 +77,66 @@ SECTIONS
|
||||
*thash.c.o(.rodata*)
|
||||
*wots.c.o(.text*)
|
||||
*wots.c.o(.rodata*)
|
||||
/* Route the SdFat library (SD card access) into FLASH too, so it
|
||||
* doesn't consume ITCM and overflow the flexRAM DTCM partition.
|
||||
* Without this, including <SdFat.h> crashes the device before
|
||||
* setup() runs (ITCM grows past 12 blocks, reducing DTCM below
|
||||
* what .data needs). See plans/teensy41_otp_sd_pad.md §BLOCKER. */
|
||||
*FatFile.cpp.o(.text*)
|
||||
*FatFile.cpp.o(.rodata*)
|
||||
*FatFileLFN.cpp.o(.text*)
|
||||
*FatFileLFN.cpp.o(.rodata*)
|
||||
*FatFileSFN.cpp.o(.text*)
|
||||
*FatFileSFN.cpp.o(.rodata*)
|
||||
*FatFilePrint.cpp.o(.text*)
|
||||
*FatFilePrint.cpp.o(.rodata*)
|
||||
*FatPartition.cpp.o(.text*)
|
||||
*FatPartition.cpp.o(.rodata*)
|
||||
*FatVolume.cpp.o(.text*)
|
||||
*FatVolume.cpp.o(.rodata*)
|
||||
*FatName.cpp.o(.text*)
|
||||
*FatName.cpp.o(.rodata*)
|
||||
*FatFormatter.cpp.o(.text*)
|
||||
*FatFormatter.cpp.o(.rodata*)
|
||||
*FatDbg.cpp.o(.text*)
|
||||
*FatDbg.cpp.o(.rodata*)
|
||||
*FsCache.cpp.o(.text*)
|
||||
*FsCache.cpp.o(.rodata*)
|
||||
*FsFile.cpp.o(.text*)
|
||||
*FsFile.cpp.o(.rodata*)
|
||||
*FsVolume.cpp.o(.text*)
|
||||
*FsVolume.cpp.o(.rodata*)
|
||||
*FsName.cpp.o(.text*)
|
||||
*FsName.cpp.o(.rodata*)
|
||||
*FsStructs.cpp.o(.text*)
|
||||
*FsStructs.cpp.o(.rodata*)
|
||||
*FsNew.cpp.o(.text*)
|
||||
*FsNew.cpp.o(.rodata*)
|
||||
*FsUtf.cpp.o(.text*)
|
||||
*FsUtf.cpp.o(.rodata*)
|
||||
*FsDateTime.cpp.o(.text*)
|
||||
*FsDateTime.cpp.o(.rodata*)
|
||||
*FmtNumber.cpp.o(.text*)
|
||||
*FmtNumber.cpp.o(.rodata*)
|
||||
*FreeStack.cpp.o(.text*)
|
||||
*FreeStack.cpp.o(.rodata*)
|
||||
*MinimumSerial.cpp.o(.text*)
|
||||
*MinimumSerial.cpp.o(.rodata*)
|
||||
*istream.cpp.o(.text*)
|
||||
*istream.cpp.o(.rodata*)
|
||||
*ostream.cpp.o(.text*)
|
||||
*ostream.cpp.o(.rodata*)
|
||||
/* SDIO driver stays in ITCM (not FLASH) for fast interrupt response.
|
||||
* Only the FAT filesystem layer goes to FLASH. */
|
||||
|
||||
/* Move ALL .rodata to FLASH (it's read-only, cache-friendly from
|
||||
* FLASH via the D-cache) to free up DTCM for stack. This reclaims
|
||||
* an estimated 40-90 KB of DTCM. The ed25519.c.o rodata is excluded
|
||||
* because ed_K/ed_X/ed_Y base point constants must stay in DTCM
|
||||
* (moving them to FLASH produced an all-zeros pubkey — see the
|
||||
* note at the top of this file). */
|
||||
*(EXCLUDE_FILE(*ed25519.c.o) .rodata*)
|
||||
|
||||
. = ALIGN(4);
|
||||
KEEP(*(.init))
|
||||
__preinit_array_start = .;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
// each boot. Mirrors CYD_DEBUG_AUTO_GENERATE in
|
||||
// firmware/cyd_esp32_2432s028/main/main.c. Set to 0 for the normal interactive
|
||||
// boot flow (show the startup menu, wait for the user to tap Generate/Enter).
|
||||
#define DEBUG_AUTO_GENERATE 1
|
||||
#define DEBUG_AUTO_GENERATE 0
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <ST7796_t3.h>
|
||||
@@ -35,7 +35,7 @@
|
||||
#include "src/dispatch.h"
|
||||
#include "src/ui.h"
|
||||
#include "src/secure_mem.h"
|
||||
#include "src/otp_pad.h"
|
||||
#include "src/otp_pad_sd.h"
|
||||
#include "src/nostr_core/nostr_common.h"
|
||||
|
||||
// ---- Pin map (from firmware/teensy41/WIRING.md) ----
|
||||
@@ -267,12 +267,9 @@ static int apply_mnemonic() {
|
||||
}
|
||||
g_seed_len = 64;
|
||||
|
||||
// Initialize the OTP pad from the seed so encrypt/decrypt verbs work.
|
||||
Serial.println("Initializing OTP pad...");
|
||||
if (otp_pad_init(g_seed, g_seed_len) != 0) {
|
||||
Serial.println("otp_pad_init failed");
|
||||
return -1;
|
||||
}
|
||||
// The OTP pad is bound from the SD card AFTER transport_init() (see below),
|
||||
// so that USB CDC is up before any SD I/O — a fault in the SD path can't
|
||||
// prevent the device from enumerating.
|
||||
|
||||
Serial.println("Deriving secp256k1 keys...");
|
||||
if (derive_secp256k1_keys(g_seed, g_seed_len, g_privkey, g_pubkey) != 0) {
|
||||
@@ -355,6 +352,47 @@ static int run_boot_flow() {
|
||||
// output here does NOT corrupt the framed transport stream. They print the
|
||||
// CrashReport from any prior hard fault (fault type, PC, LR, SP) plus the
|
||||
// last-action marker so we can diagnose the get_public_key crash.
|
||||
|
||||
// Persistent operation marker (DMAMEM survives soft reboot). Stamped before
|
||||
// each verb's crypto work so a post-crash reboot reports which op faulted.
|
||||
DMAMEM volatile uint32_t g_last_op = 0;
|
||||
DMAMEM volatile uint32_t g_last_op_seq = 0;
|
||||
DMAMEM volatile uint32_t g_stack_min = 0xFFFFFFFFu;
|
||||
DMAMEM volatile uint32_t g_heap_free_at_crash = 0;
|
||||
// ML-DSA-65 sign rejection-loop iteration counter. Written each iteration of
|
||||
// crypto_sign()'s rejection loop (mldsa65_sign.c) so a post-crash/hang reboot
|
||||
// reports how far the loop got without corrupting the framed transport.
|
||||
DMAMEM volatile uint32_t g_mldsa65_reject_count = 0;
|
||||
#define OP_ID_GET_INFO 1
|
||||
#define OP_ID_GPK 2
|
||||
#define OP_ID_SIGN 3
|
||||
#define OP_ID_VERIFY 4
|
||||
#define OP_ID_DERIVE_SHARED 5
|
||||
#define OP_ID_DERIVE 6
|
||||
#define OP_ID_NOSTR_GPK 7
|
||||
#define OP_ID_NOSTR_SIGN 8
|
||||
#define OP_ID_NOSTR_MINE 9
|
||||
#define OP_ID_NIP04 10
|
||||
#define OP_ID_NIP44 11
|
||||
#define OP_ID_ENCAPS 12
|
||||
#define OP_ID_DECAPS 13
|
||||
#define OP_ID_OTP 14
|
||||
|
||||
extern char _estack;
|
||||
extern char _ebss;
|
||||
|
||||
static uint32_t stack_free_bytes(void) {
|
||||
uint32_t sp;
|
||||
asm volatile ("mov %0, sp\n" : "=r"(sp));
|
||||
uint32_t bot = (uint32_t)&_ebss;
|
||||
return (sp > bot) ? (sp - bot) : 0;
|
||||
}
|
||||
|
||||
static uint32_t stack_high_water_free(void) {
|
||||
uint32_t top = (uint32_t)&_estack;
|
||||
return (g_stack_min <= top) ? (top - g_stack_min) : 0;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial && millis() < 3000) ;
|
||||
@@ -372,6 +410,25 @@ void setup() {
|
||||
CrashReport.clear();
|
||||
}
|
||||
|
||||
// Report the last operation marker from before the crash.
|
||||
if (g_last_op != 0) {
|
||||
Serial.print("LAST OP BEFORE CRASH: id=");
|
||||
Serial.print((int)g_last_op);
|
||||
Serial.print(" seq=");
|
||||
Serial.print((int)g_last_op_seq);
|
||||
Serial.print(" stack_hw_free=");
|
||||
Serial.print((int)stack_high_water_free());
|
||||
Serial.print(" heap_free_at_crash=");
|
||||
Serial.print((int)g_heap_free_at_crash);
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
// Report the ML-DSA-65 sign rejection-loop count from before the crash/hang.
|
||||
if (g_mldsa65_reject_count > 0) {
|
||||
Serial.print("ML-DSA-65 LAST REJECT COUNT: ");
|
||||
Serial.println((int)g_mldsa65_reject_count);
|
||||
}
|
||||
|
||||
Serial.println("n_signer booting...");
|
||||
tft.init(320, 480);
|
||||
tft.setRotation(1);
|
||||
@@ -425,14 +482,72 @@ void setup() {
|
||||
transport_init();
|
||||
Serial.println("Transport initialized.");
|
||||
|
||||
// Bind the OTP pad from the SD card. Give USB CDC a moment to enumerate
|
||||
// with the host first, so that even if the SD path faults, the device has
|
||||
// already appeared as /dev/ttyACM0 and we can see the boot output.
|
||||
delay(2000);
|
||||
Serial.println("Mounting SD card for OTP pad...");
|
||||
if (otp_pad_sd_mount() != 0) {
|
||||
Serial.println("otp_pad_sd_mount failed — encrypt/decrypt unavailable");
|
||||
} else {
|
||||
#if DEBUG_AUTO_GENERATE
|
||||
Serial.println("DEBUG_AUTO_GENERATE=1: auto-binding first SD pad...");
|
||||
if (otp_pad_sd_bind_first() != 0) {
|
||||
Serial.println("otp_pad_sd_bind_first failed — no pad bound");
|
||||
}
|
||||
#else
|
||||
// Interactive pad selection: scan /pads, show the list on the display,
|
||||
// let the user pick one (or skip). See ui_pick_pad() in ui.cpp.
|
||||
Serial.println("Scanning /pads for OTP pads...");
|
||||
static char pad_chksums[4][65];
|
||||
static uint64_t pad_sizes[4];
|
||||
int n_pads = otp_pad_sd_list_pads(pad_chksums, pad_sizes, 4);
|
||||
if (n_pads <= 0) {
|
||||
Serial.println("No pads found in /pads — OTP encrypt/decrypt unavailable");
|
||||
} else {
|
||||
Serial.print("Found ");
|
||||
Serial.print(n_pads);
|
||||
Serial.println(" pad(s):");
|
||||
for (int i = 0; i < n_pads; i++) {
|
||||
Serial.print(" [");
|
||||
Serial.print(i);
|
||||
Serial.print("] ");
|
||||
Serial.print(pad_chksums[i]);
|
||||
Serial.print(" (");
|
||||
Serial.print((unsigned long)pad_sizes[i]);
|
||||
Serial.println(" bytes)");
|
||||
}
|
||||
Serial.println("Showing selection screen...");
|
||||
const char *chksum_ptrs[4];
|
||||
for (int i = 0; i < n_pads; i++) chksum_ptrs[i] = pad_chksums[i];
|
||||
char selected[65];
|
||||
int rc = ui_pick_pad(chksum_ptrs, pad_sizes, n_pads,
|
||||
selected, sizeof(selected));
|
||||
if (rc == 0) {
|
||||
Serial.print("User selected pad: ");
|
||||
Serial.println(selected);
|
||||
if (otp_pad_sd_bind(selected) != 0) {
|
||||
Serial.println("otp_pad_sd_bind failed for selected pad");
|
||||
}
|
||||
} else {
|
||||
Serial.println("User skipped OTP pad selection (or timeout)");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Serial.println("OTP pad init complete.");
|
||||
|
||||
// Show the idle screen with the npub + version.
|
||||
ui_show_idle(g_npub, "v0.1.0-teensy41");
|
||||
Serial.println("Idle screen shown. Signer ready.");
|
||||
}
|
||||
|
||||
// ---- Signing loop buffers (in DMAMEM/RAM2 to save RAM1) ----
|
||||
DMAMEM static uint8_t req_buf[2048];
|
||||
DMAMEM static char resp_buf[4096];
|
||||
// Increased for OTP encrypt/decrypt: a 4 KB chunk produces ~5.5 KB of ASCII
|
||||
// armor, and the base64-encoded request can be ~5.5 KB. With 110 KB of free
|
||||
// RAM2 heap, 8 KB + 8 KB is comfortable.
|
||||
DMAMEM static uint8_t req_buf[8192];
|
||||
DMAMEM static char resp_buf[8192];
|
||||
|
||||
void loop() {
|
||||
// 1. Keep LVGL responsive (idle screen + any approval prompts).
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
#include "secure_mem.h"
|
||||
#include "ui.h"
|
||||
#include "auth_envelope.h"
|
||||
#include "otp_pad.h"
|
||||
#include "otp_pad_sd.h"
|
||||
#include "key_derivation.h"
|
||||
#include "ed25519.h"
|
||||
|
||||
@@ -70,6 +70,34 @@ char g_npub[128];
|
||||
char g_pubkey_hex[65];
|
||||
int g_signer_ready = 0;
|
||||
|
||||
/* ---- Persistent crash diagnostics (defined in signer.ino, DMAMEM) ---- */
|
||||
extern "C" volatile uint32_t g_last_op;
|
||||
extern "C" volatile uint32_t g_last_op_seq;
|
||||
extern "C" volatile uint32_t g_stack_min;
|
||||
extern "C" volatile uint32_t g_heap_free_at_crash;
|
||||
extern "C" char _estack;
|
||||
extern "C" char _ebss;
|
||||
|
||||
/* Stamp the operation marker and record stack/heap state before a verb's
|
||||
* crypto work. If the device faults, the next boot reads these DMAMEM
|
||||
* values and reports which op crashed and how close the stack was. */
|
||||
static void stamp_op(uint32_t op_id) {
|
||||
g_last_op_seq++;
|
||||
g_last_op = op_id;
|
||||
uint32_t sp;
|
||||
asm volatile ("mov %0, sp\n" : "=r"(sp));
|
||||
if (sp < g_stack_min) g_stack_min = sp;
|
||||
/* Record a rough heap-free estimate: probe by allocating a 1-byte block
|
||||
* and capturing the returned address relative to the heap start symbol.
|
||||
* On Teensy the sbrk heap lives between _heap_start and _heap_end. */
|
||||
extern char _heap_start[];
|
||||
void *p = malloc(1);
|
||||
if (p) {
|
||||
g_heap_free_at_crash = (uint32_t)((char *)p - _heap_start);
|
||||
free(p);
|
||||
}
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Constants
|
||||
* ==================================================================== */
|
||||
@@ -468,25 +496,29 @@ __attribute__((section(".flashmem"))) static int derive_alg_key(fw_alg_t alg, ui
|
||||
* secp256k1 ECDSA sign/verify (scheme:"ecdsa")
|
||||
* ==================================================================== */
|
||||
|
||||
/* The three secp256k1 helpers below reuse the persistent shared context
|
||||
* (secp256k1_get_shared_context) instead of malloc/free-ing a fresh
|
||||
* secp256k1_context per call. The Teensy 4.1 has only ~140 KB of free heap
|
||||
* and a small DTCM stack; repeated context create/destroy fragments the
|
||||
* heap and eventually crashes the device (observed during verify after a
|
||||
* few sign/get_public_key calls). The shared context is created once and
|
||||
* randomized at first use; it is never destroyed during normal operation. */
|
||||
|
||||
__attribute__((section(".flashmem"))) static int secp256k1_ecdsa_sign32(const uint8_t privkey[32],
|
||||
const uint8_t msg32[32],
|
||||
uint8_t sig_out[64]) {
|
||||
const uint8_t msg32[32],
|
||||
uint8_t sig_out[64]) {
|
||||
secp256k1_context *ctx;
|
||||
secp256k1_ecdsa_signature sig;
|
||||
int rc = -1;
|
||||
|
||||
ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN);
|
||||
ctx = secp256k1_get_shared_context();
|
||||
if (ctx == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (!secp256k1_ecdsa_sign(ctx, &sig, msg32, privkey, NULL, NULL)) {
|
||||
goto done;
|
||||
return -1;
|
||||
}
|
||||
secp256k1_ecdsa_signature_serialize_compact(ctx, sig_out, &sig);
|
||||
rc = 0;
|
||||
done:
|
||||
secp256k1_context_destroy(ctx);
|
||||
return rc;
|
||||
return 0;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) static int secp256k1_ecdsa_verify32(const uint8_t msg32[32],
|
||||
@@ -495,22 +527,18 @@ __attribute__((section(".flashmem"))) static int secp256k1_ecdsa_verify32(const
|
||||
secp256k1_context *ctx;
|
||||
secp256k1_ecdsa_signature sig;
|
||||
secp256k1_pubkey pub;
|
||||
int rc = -1;
|
||||
|
||||
ctx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY);
|
||||
ctx = secp256k1_get_shared_context();
|
||||
if (ctx == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (!secp256k1_ecdsa_signature_parse_compact(ctx, &sig, sig64)) {
|
||||
goto done;
|
||||
return -1;
|
||||
}
|
||||
if (!secp256k1_ec_pubkey_parse(ctx, &pub, pub33, 33)) {
|
||||
goto done;
|
||||
return -1;
|
||||
}
|
||||
rc = secp256k1_ecdsa_verify(ctx, &sig, msg32, &pub) ? 0 : -1;
|
||||
done:
|
||||
secp256k1_context_destroy(ctx);
|
||||
return rc;
|
||||
return secp256k1_ecdsa_verify(ctx, &sig, msg32, &pub) ? 0 : -1;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) static int secp256k1_priv_to_compressed_pub(const uint8_t priv[32],
|
||||
@@ -518,23 +546,19 @@ __attribute__((section(".flashmem"))) static int secp256k1_priv_to_compressed_pu
|
||||
secp256k1_context *ctx;
|
||||
secp256k1_pubkey pub;
|
||||
size_t olen = 33;
|
||||
int rc = -1;
|
||||
|
||||
ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN);
|
||||
ctx = secp256k1_get_shared_context();
|
||||
if (ctx == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (!secp256k1_ec_pubkey_create(ctx, &pub, priv)) {
|
||||
goto done;
|
||||
return -1;
|
||||
}
|
||||
if (!secp256k1_ec_pubkey_serialize(ctx, pub33, &olen, &pub,
|
||||
SECP256K1_EC_COMPRESSED)) {
|
||||
goto done;
|
||||
return -1;
|
||||
}
|
||||
rc = 0;
|
||||
done:
|
||||
secp256k1_context_destroy(ctx);
|
||||
return rc;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
@@ -660,6 +684,36 @@ __attribute__((section(".flashmem"))) static int b64_decode(const char *in, size
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Allocating variants: caller frees the returned buffer. Returns NULL on
|
||||
* failure. Used by the OTP encrypt/decrypt verbs for variable-length
|
||||
* payloads that don't fit a fixed stack buffer. */
|
||||
__attribute__((section(".flashmem"))) static char *b64_encode_alloc(const uint8_t *in, int in_len) {
|
||||
if (!in || in_len < 0) return NULL;
|
||||
size_t need = ((size_t)in_len + 2) / 3 * 4 + 1;
|
||||
char *out = (char *)malloc(need);
|
||||
if (!out) return NULL;
|
||||
if (b64_encode(in, (size_t)in_len, out, need) != 0) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) static uint8_t *b64_decode_alloc(const char *in, size_t in_len, int *out_len) {
|
||||
if (!in || !out_len) return NULL;
|
||||
/* Upper bound on decoded length. */
|
||||
size_t cap = in_len / 4 * 3 + 4;
|
||||
uint8_t *out = (uint8_t *)malloc(cap);
|
||||
if (!out) return NULL;
|
||||
size_t got = 0;
|
||||
if (b64_decode(in, in_len, out, cap, &got) != 0) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
*out_len = (int)got;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Approval flow
|
||||
* ====================================================================
|
||||
@@ -1017,6 +1071,30 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params");
|
||||
char key_id[17];
|
||||
|
||||
/* Stamp the operation marker for post-crash diagnostics. Map the verb
|
||||
* name to a compact id so the next boot can report which op faulted. */
|
||||
{
|
||||
uint32_t op = 0;
|
||||
if (strcmp(method, VERB_GET_INFO) == 0) op = 1;
|
||||
else if (strcmp(method, VERB_GET_PUBLIC_KEY) == 0) op = 2;
|
||||
else if (strcmp(method, VERB_SIGN) == 0) op = 3;
|
||||
else if (strcmp(method, VERB_VERIFY) == 0) op = 4;
|
||||
else if (strcmp(method, VERB_DERIVE_SHARED) == 0) op = 5;
|
||||
else if (strcmp(method, VERB_DERIVE) == 0) op = 6;
|
||||
else if (strcmp(method, VERB_NOSTR_GET_PUBLIC_KEY) == 0) op = 7;
|
||||
else if (strcmp(method, VERB_NOSTR_SIGN_EVENT) == 0) op = 8;
|
||||
else if (strcmp(method, VERB_NOSTR_MINE_EVENT) == 0) op = 9;
|
||||
else if (strcmp(method, VERB_NOSTR_NIP04_ENCRYPT) == 0 ||
|
||||
strcmp(method, VERB_NOSTR_NIP04_DECRYPT) == 0) op = 10;
|
||||
else if (strcmp(method, VERB_NOSTR_NIP44_ENCRYPT) == 0 ||
|
||||
strcmp(method, VERB_NOSTR_NIP44_DECRYPT) == 0) op = 11;
|
||||
else if (strcmp(method, VERB_ENCAPSULATE) == 0) op = 12;
|
||||
else if (strcmp(method, VERB_DECAPSULATE) == 0) op = 13;
|
||||
else if (strcmp(method, VERB_ENCRYPT) == 0 ||
|
||||
strcmp(method, VERB_DECRYPT) == 0) op = 14;
|
||||
if (op) stamp_op(op);
|
||||
}
|
||||
|
||||
/* ===================== get_info (no key material, no approval) ====== */
|
||||
if (strcmp(method, VERB_GET_INFO) == 0) {
|
||||
(void)params;
|
||||
@@ -1338,9 +1416,12 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
secure_memzero(pub33, sizeof(pub33));
|
||||
secure_memzero(msg32, sizeof(msg32));
|
||||
} else {
|
||||
/* schnorr verify against x-only pubkey */
|
||||
/* schnorr verify against x-only pubkey.
|
||||
* Reuse the shared context instead of
|
||||
* malloc/free per call (heap-fragmentation
|
||||
* crash fix). */
|
||||
secp256k1_context *ctx =
|
||||
secp256k1_context_create(SECP256K1_CONTEXT_VERIFY);
|
||||
secp256k1_get_shared_context();
|
||||
secp256k1_xonly_pubkey xonly;
|
||||
uint8_t pub32[32];
|
||||
if (ctx != NULL &&
|
||||
@@ -1352,7 +1433,8 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
&xonly)) {
|
||||
valid = 1;
|
||||
}
|
||||
if (ctx) secp256k1_context_destroy(ctx);
|
||||
/* Do NOT destroy ctx — it is the shared
|
||||
* global context. */
|
||||
secure_memzero(pub32, sizeof(pub32));
|
||||
}
|
||||
} else if (alg == FW_ALG_ED25519) {
|
||||
@@ -1639,17 +1721,68 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
return;
|
||||
}
|
||||
|
||||
/* ===================== encrypt / decrypt (otp) ===================== */
|
||||
/* ===================== otp_status (debug) =========================== */
|
||||
if (strcmp(method, "otp_status") == 0) {
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
char *out;
|
||||
cJSON_AddBoolToObject(obj, "bound", otp_pad_sd_ready() ? 1 : 0);
|
||||
const char *cs = otp_pad_sd_chksum();
|
||||
cJSON_AddStringToObject(obj, "chksum", cs ? cs : "");
|
||||
char off_str[32], size_str[32];
|
||||
snprintf(off_str, sizeof(off_str), "%llu",
|
||||
(unsigned long long)otp_pad_sd_offset());
|
||||
snprintf(size_str, sizeof(size_str), "%llu",
|
||||
(unsigned long long)otp_pad_sd_size());
|
||||
cJSON_AddStringToObject(obj, "offset", off_str);
|
||||
cJSON_AddStringToObject(obj, "pad_size", size_str);
|
||||
out = cJSON_PrintUnformatted(obj);
|
||||
if (out) { set_result(id_token, out); cJSON_free(out); }
|
||||
cJSON_Delete(obj);
|
||||
return;
|
||||
}
|
||||
|
||||
/* ===================== otp_debug (debug) ============================ */
|
||||
/* Lists files in /pads to diagnose bind failures. Does NOT call
|
||||
* bind_first (which does a slow 1 MB checksum verify). */
|
||||
if (strcmp(method, "otp_debug") == 0) {
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
char *out;
|
||||
char list[1024];
|
||||
int n = otp_pad_sd_debug_list(list, sizeof(list));
|
||||
char n_str[16];
|
||||
snprintf(n_str, sizeof(n_str), "%d", n);
|
||||
cJSON_AddStringToObject(obj, "file_count", n_str);
|
||||
cJSON_AddStringToObject(obj, "listing", list);
|
||||
cJSON_AddBoolToObject(obj, "bound", otp_pad_sd_ready() ? 1 : 0);
|
||||
out = cJSON_PrintUnformatted(obj);
|
||||
if (out) { set_result(id_token, out); cJSON_free(out); }
|
||||
cJSON_Delete(obj);
|
||||
return;
|
||||
}
|
||||
|
||||
/* ===================== encrypt / decrypt (otp) ===================== */
|
||||
/* SD-card one-time-pad. Bit-compatible with the host n_signer's
|
||||
* otp_encrypt / otp_decrypt verbs (see src/otp_pad.c) and the `otp` CLI
|
||||
* via libotppad/otppad_embedded. Supports ASCII armor and binary .otp
|
||||
* encodings, selected via the "encoding" option (default "ascii").
|
||||
*
|
||||
* Wire format:
|
||||
* encrypt: [plaintext_b64, {"encoding": "ascii"|"binary"}]
|
||||
* -> {"ciphertext": ..., "pad_chksum": ...,
|
||||
* "pad_offset_before": N, "pad_offset_after": N}
|
||||
* decrypt: [ciphertext, {"encoding": "ascii"|"binary"}]
|
||||
* -> {"plaintext": "<b64>"} (offset read from armor/binary header)
|
||||
*
|
||||
* The "algorithm": "otp" option is accepted for backward compatibility
|
||||
* with test_signer.py but is optional. */
|
||||
if (strcmp(method, VERB_ENCRYPT) == 0 ||
|
||||
strcmp(method, VERB_DECRYPT) == 0) {
|
||||
cJSON *options = NULL;
|
||||
fw_alg_t alg;
|
||||
uint32_t index = 0;
|
||||
cJSON *arg0 = NULL;
|
||||
const char *payload;
|
||||
int is_decrypt = (strcmp(method, VERB_DECRYPT) == 0);
|
||||
const char *encoding = "ascii"; /* default */
|
||||
|
||||
/* OTP encrypt and decrypt are the same XOR operation against the pad;
|
||||
* the verb name is carried through for the UI prompt only. */
|
||||
if (!cJSON_IsArray(params) || cJSON_GetArraySize(params) < 1) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
return;
|
||||
@@ -1661,18 +1794,18 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
}
|
||||
payload = arg0->valuestring;
|
||||
parse_options_from_params(params, &options);
|
||||
if (parse_algorithm_from_options(options, &alg, &index) != 0) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
return;
|
||||
|
||||
/* "encoding" option (optional, default "ascii"). */
|
||||
if (options != NULL) {
|
||||
cJSON *enc = cJSON_GetObjectItemCaseSensitive(options, "encoding");
|
||||
if (cJSON_IsString(enc) && enc->valuestring != NULL) {
|
||||
encoding = enc->valuestring;
|
||||
}
|
||||
}
|
||||
if (enforce_alg_verb(alg, method) != 0) {
|
||||
set_error_code(id_token, ERR_ALG_NOT_SUPPORTED,
|
||||
"algorithm_not_supported_for_verb");
|
||||
return;
|
||||
}
|
||||
if (!otp_pad_ready()) {
|
||||
|
||||
if (!otp_pad_sd_ready()) {
|
||||
set_error_code(id_token, ERR_INTERNAL,
|
||||
"otp pad not initialized");
|
||||
"otp pad not bound (no SD pad)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1685,47 +1818,135 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
return;
|
||||
}
|
||||
|
||||
/* For encrypt: payload is base64 plaintext.
|
||||
* For decrypt: payload is base64 ciphertext (XOR output).
|
||||
* Decode base64, XOR with the pad at the current offset, advance
|
||||
* the offset, and re-encode. */
|
||||
{
|
||||
uint8_t buf[OTP_PAD_LEN];
|
||||
size_t buf_len = 0;
|
||||
|
||||
if (b64_decode(payload, strlen(payload), buf, sizeof(buf),
|
||||
&buf_len) != 0) {
|
||||
if (!is_decrypt) {
|
||||
/* ---- encrypt ----
|
||||
* payload is base64 plaintext. Decode, hand bytes to
|
||||
* otp_pad_sd_encrypt, base64-encode the returned armor/blob
|
||||
* for JSON transport (binary blobs are base64-wrapped in the
|
||||
* JSON result, matching the host). */
|
||||
/* Decode base64 plaintext into a heap buffer. */
|
||||
int pt_len = 0;
|
||||
unsigned char *pt = (unsigned char *)b64_decode_alloc(
|
||||
payload, strlen(payload), &pt_len);
|
||||
if (!pt) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS,
|
||||
"invalid base64");
|
||||
return;
|
||||
}
|
||||
if (otp_pad_apply(buf, buf_len) != 0) {
|
||||
char *out_payload = NULL;
|
||||
size_t out_len = 0;
|
||||
uint64_t off_before = 0, off_after = 0;
|
||||
int rc = otp_pad_sd_encrypt(pt, (size_t)pt_len, encoding,
|
||||
&out_payload, &out_len,
|
||||
&off_before, &off_after);
|
||||
secure_memzero(pt, (size_t)pt_len);
|
||||
free(pt);
|
||||
if (rc != 0) {
|
||||
set_error_code(id_token, ERR_INTERNAL,
|
||||
"otp pad exhausted");
|
||||
secure_memzero(buf, sizeof(buf));
|
||||
"otp encrypt failed");
|
||||
if (out_payload) { secure_memzero(out_payload, out_len); free(out_payload); }
|
||||
return;
|
||||
}
|
||||
|
||||
/* For binary encoding, base64-wrap the blob for JSON. For
|
||||
* ascii encoding, out_payload is already a NUL-terminated
|
||||
* armor string. */
|
||||
char *ct_b64 = NULL;
|
||||
if (strcmp(encoding, "binary") == 0) {
|
||||
ct_b64 = b64_encode_alloc((const uint8_t *)out_payload,
|
||||
(int)out_len);
|
||||
} else {
|
||||
/* ASCII armor: copy as-is (it's text-safe). */
|
||||
ct_b64 = (char *)malloc(out_len + 1);
|
||||
if (ct_b64) { memcpy(ct_b64, out_payload, out_len); ct_b64[out_len] = '\0'; }
|
||||
}
|
||||
secure_memzero(out_payload, out_len);
|
||||
free(out_payload);
|
||||
if (!ct_b64) {
|
||||
set_error_code(id_token, ERR_INTERNAL, "internal error");
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
char *out;
|
||||
char off_before_str[32], off_after_str[32];
|
||||
cJSON_AddStringToObject(obj, "ciphertext", ct_b64);
|
||||
cJSON_AddStringToObject(obj, "pad_chksum",
|
||||
otp_pad_sd_chksum());
|
||||
snprintf(off_before_str, sizeof(off_before_str),
|
||||
"%llu", (unsigned long long)off_before);
|
||||
snprintf(off_after_str, sizeof(off_after_str),
|
||||
"%llu", (unsigned long long)off_after);
|
||||
cJSON_AddStringToObject(obj, "pad_offset_before",
|
||||
off_before_str);
|
||||
cJSON_AddStringToObject(obj, "pad_offset_after",
|
||||
off_after_str);
|
||||
out = cJSON_PrintUnformatted(obj);
|
||||
if (out) { set_result(id_token, out); cJSON_free(out); }
|
||||
cJSON_Delete(obj);
|
||||
}
|
||||
free(ct_b64);
|
||||
} else {
|
||||
/* ---- decrypt ----
|
||||
* payload is either ASCII armor (text) or a base64-encoded
|
||||
* binary .otp blob, selected by the "encoding" option. */
|
||||
unsigned char *input_bytes = NULL;
|
||||
size_t input_len = 0;
|
||||
char *input_str = NULL;
|
||||
|
||||
if (strcmp(encoding, "binary") == 0) {
|
||||
/* payload is base64 of the binary blob. Decode to bytes. */
|
||||
int blen = 0;
|
||||
input_bytes = (unsigned char *)b64_decode_alloc(
|
||||
payload, strlen(payload), &blen);
|
||||
if (!input_bytes) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS,
|
||||
"invalid base64");
|
||||
return;
|
||||
}
|
||||
input_len = (size_t)blen;
|
||||
} else {
|
||||
/* ASCII armor: pass the string directly. */
|
||||
input_str = (char *)payload; /* borrowed, not freed */
|
||||
}
|
||||
|
||||
unsigned char *pt = NULL;
|
||||
size_t pt_len = 0;
|
||||
int rc;
|
||||
if (strcmp(encoding, "binary") == 0) {
|
||||
rc = otp_pad_sd_decrypt((const char *)input_bytes,
|
||||
input_len, "binary",
|
||||
&pt, &pt_len);
|
||||
} else {
|
||||
rc = otp_pad_sd_decrypt(input_str, strlen(input_str),
|
||||
"ascii", &pt, &pt_len);
|
||||
}
|
||||
if (input_bytes) { free(input_bytes); }
|
||||
if (rc != 0) {
|
||||
set_error_code(id_token, ERR_INTERNAL,
|
||||
"otp decrypt failed");
|
||||
if (pt) { secure_memzero(pt, pt_len); free(pt); }
|
||||
return;
|
||||
}
|
||||
|
||||
/* base64-encode the recovered plaintext for JSON. */
|
||||
char *pt_b64 = b64_encode_alloc(pt, (int)pt_len);
|
||||
secure_memzero(pt, pt_len);
|
||||
free(pt);
|
||||
if (!pt_b64) {
|
||||
set_error_code(id_token, ERR_INTERNAL, "internal error");
|
||||
return;
|
||||
}
|
||||
{
|
||||
char out_b64[1400];
|
||||
if (b64_encode(buf, buf_len, out_b64,
|
||||
sizeof(out_b64)) != 0) {
|
||||
set_error_code(id_token, ERR_INTERNAL,
|
||||
"internal error");
|
||||
} else {
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
char *out;
|
||||
char off_str[24];
|
||||
cJSON_AddStringToObject(obj, "result", out_b64);
|
||||
cJSON_AddStringToObject(obj, "algorithm", "otp");
|
||||
snprintf(off_str, sizeof(off_str), "%u",
|
||||
(unsigned)otp_pad_offset());
|
||||
cJSON_AddStringToObject(obj, "pad_offset", off_str);
|
||||
out = cJSON_PrintUnformatted(obj);
|
||||
if (out) { set_result(id_token, out); cJSON_free(out); }
|
||||
cJSON_Delete(obj);
|
||||
}
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
char *out;
|
||||
cJSON_AddStringToObject(obj, "plaintext", pt_b64);
|
||||
out = cJSON_PrintUnformatted(obj);
|
||||
if (out) { set_result(id_token, out); cJSON_free(out); }
|
||||
cJSON_Delete(obj);
|
||||
}
|
||||
secure_memzero(buf, sizeof(buf));
|
||||
free(pt_b64);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
#define FLASHMEM_ATTR __attribute__((section(".flashmem")))
|
||||
#endif
|
||||
|
||||
/* DMAMEM attribute for large working buffers that must live in RAM2
|
||||
* (.dmabuffers) instead of the DTCM stack, to avoid stack overflow on
|
||||
* the Teensy 4.1 (~9.6 KB free stack). Used by ed_frombytes, sc_reduce,
|
||||
* sc_muladd, and the SHA-512 streaming ctx. */
|
||||
#ifndef ED_DMAMEM
|
||||
#define ED_DMAMEM __attribute__((section(".dmabuffers")))
|
||||
#endif
|
||||
|
||||
typedef int64_t gf[16];
|
||||
|
||||
/* Constant 121665 as a field element (low limb only). Declared early so the
|
||||
@@ -331,12 +339,22 @@ typedef struct { gf p[4]; } ed_p3;
|
||||
typedef struct { gf p[3]; } ed_p2;
|
||||
typedef struct { gf p[2]; } ed_p1;
|
||||
|
||||
/* DMAMEM workspace for ed_add (12 gf arrays = 1536 B on stack otherwise,
|
||||
* called 256x per scalarmult -> overflows the 9.6 KB DTCM stack). */
|
||||
ED_DMAMEM static int64_t ed_add_a0[16], ed_add_a1[16], ed_add_b0[16],
|
||||
ed_add_b1[16], ed_add_c0[16], ed_add_c1[16],
|
||||
ed_add_d0[16], ed_add_d1[16], ed_add_e[16],
|
||||
ed_add_f[16], ed_add_g[16], ed_add_h[16];
|
||||
|
||||
FLASHMEM_ATTR static void ed_add(ed_p3 *o, const ed_p3 *a, const ed_p3 *b) {
|
||||
/* Unified extended-coordinates addition for the twisted Edwards curve
|
||||
* -x^2 + y^2 = 1 + d x^2 y^2 (a = -1). This formula is also valid when
|
||||
* a == b, so ed_double() simply calls ed_add(o, a, a). All reads of the
|
||||
* inputs happen into locals before any write to o, so o may alias a/b. */
|
||||
gf a0, a1, b0, b1, c0, c1, d0, d1, e, f, g, h;
|
||||
/* Use DMAMEM gf workspace (12 arrays = 1536 B) to avoid stack overflow. */
|
||||
int64_t *a0 = ed_add_a0, *a1 = ed_add_a1, *b0 = ed_add_b0, *b1 = ed_add_b1,
|
||||
*c0 = ed_add_c0, *c1 = ed_add_c1, *d0 = ed_add_d0, *d1 = ed_add_d1,
|
||||
*e = ed_add_e, *f = ed_add_f, *g = ed_add_g, *h = ed_add_h;
|
||||
int i;
|
||||
FOR(i, 16) {
|
||||
a0[i] = a->p[1][i] - a->p[0][i]; /* Y1 - X1 */
|
||||
@@ -413,6 +431,14 @@ FLASHMEM_ATTR static void ed_p3_tobytes(uint8_t *o, const ed_p3 *p) {
|
||||
o[31] ^= (uint8_t)((xb[0] & 1) << 7);
|
||||
}
|
||||
|
||||
/* DMAMEM gf workspace for ed_frombytes (9 gf arrays = 1152 B on stack
|
||||
* otherwise, which overflows during ed25519_verify). The gf type is
|
||||
* int64_t[16]; we declare the DMAMEM arrays and access them through
|
||||
* pointers so the functions that take `gf` (int64_t*) work unchanged. */
|
||||
ED_DMAMEM static int64_t ed_fb_x[16], ed_fb_y[16], ed_fb_z[16], ed_fb_u[16],
|
||||
ed_fb_v[16], ed_fb_v3[16], ed_fb_uv7[16],
|
||||
ed_fb_w[16], ed_fb_c[16];
|
||||
|
||||
FLASHMEM_ATTR static int ed_frombytes(ed_p3 *p, const uint8_t *n) {
|
||||
/* Decode a 32-byte point encoding (RFC 8032 section 5.1.3). Curve:
|
||||
* -x^2 + y^2 = 1 + d x^2 y^2, with u = y^2 - 1, v = d y^2 + 1, so
|
||||
@@ -420,7 +446,11 @@ FLASHMEM_ATTR static int ed_frombytes(ed_p3 *p, const uint8_t *n) {
|
||||
* x = u * v^3 * (u * v^7)^((p-5)/8)
|
||||
* which equals (u/v)^((p+3)/8) up to a 4th root of unity, then fix up
|
||||
* the sign of the root with I = sqrt(-1) and the sign bit of n. */
|
||||
gf x, y, z, u, v, v3, uv7, w, c;
|
||||
/* Use DMAMEM gf workspace to avoid 1152 B on the stack.
|
||||
* gf is int64_t[16]; use int64_t* pointers to the DMAMEM arrays. */
|
||||
int64_t *x = ed_fb_x, *y = ed_fb_y, *z = ed_fb_z, *u = ed_fb_u,
|
||||
*v = ed_fb_v, *v3 = ed_fb_v3, *uv7 = ed_fb_uv7,
|
||||
*w = ed_fb_w, *c = ed_fb_c;
|
||||
int i, a;
|
||||
unpack25519(y, n);
|
||||
FOR(i, 16) {
|
||||
@@ -500,6 +530,16 @@ static const uint64_t ed_L[32] = {
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10
|
||||
};
|
||||
|
||||
/* ---- DMAMEM workspace for large ed25519 temporaries ----
|
||||
* The Teensy 4.1 has only ~9.6 KB of free DTCM stack. The ed25519 sign/verify
|
||||
* call chain needs several 512-byte int64_t x[64] buffers (sc_reduce,
|
||||
* sc_muladd) plus SHA-512 contexts, which overflow the stack when nested
|
||||
* inside handle_request -> ed25519_sign. Moving these to a persistent DMAMEM
|
||||
* (RAM2) workspace eliminates the stack pressure. The signer is
|
||||
* single-threaded so no locking is needed. */
|
||||
ED_DMAMEM static int64_t ed_sc_x[64]; /* sc_reduce / sc_muladd working buffer (512 B) */
|
||||
ED_DMAMEM static uint8_t ed_sha512ctx_buf[sizeof(ed_sha512ctx)]; /* SHA-512 streaming ctx (328 B) */
|
||||
|
||||
/* Reduce the 64-limb little-endian integer x[0..63] mod L into r[0..31].
|
||||
* TweetNaCl modL, verbatim. */
|
||||
FLASHMEM_ATTR static void sc_modL(uint8_t *r, int64_t x[64]) {
|
||||
@@ -529,19 +569,23 @@ FLASHMEM_ATTR static void sc_modL(uint8_t *r, int64_t x[64]) {
|
||||
|
||||
/* Reduce a 64-byte little-endian integer mod L into 32 bytes. */
|
||||
FLASHMEM_ATTR static void sc_reduce(uint8_t *o, const uint8_t s[64]) {
|
||||
int64_t x[64], i;
|
||||
int64_t i;
|
||||
int64_t *x = ed_sc_x;
|
||||
FOR(i, 64) x[i] = (uint64_t) s[i];
|
||||
sc_modL(o, x);
|
||||
FOR(i, 64) x[i] = 0; /* zeroize sensitive working buffer */
|
||||
}
|
||||
|
||||
/* o = (a * b + c) mod L. Builds the 64-byte little-endian product+sum then
|
||||
* reduces via sc_modL (TweetNaCl crypto_sign style). */
|
||||
FLASHMEM_ATTR static void sc_muladd(uint8_t *o, const uint8_t a[32], const uint8_t b[32], const uint8_t c[32]) {
|
||||
int64_t x[64], i, j;
|
||||
int64_t i, j;
|
||||
int64_t *x = ed_sc_x;
|
||||
FOR(i, 64) x[i] = 0;
|
||||
FOR(i, 32) x[i] = (uint64_t) c[i];
|
||||
FOR(i, 32) FOR(j, 32) x[i + j] += (int64_t) a[i] * (uint64_t) b[j];
|
||||
sc_modL(o, x);
|
||||
FOR(i, 64) x[i] = 0; /* zeroize sensitive working buffer */
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
@@ -572,12 +616,13 @@ FLASHMEM_ATTR int ed25519_sign(const uint8_t privkey[32],
|
||||
az[31] &= 127;
|
||||
az[31] |= 64;
|
||||
|
||||
/* r = SHA-512(az[32..64] || msg) mod L */
|
||||
ed_sha512ctx ctx;
|
||||
ed_sha512_init(&ctx);
|
||||
ed_sha512_update(&ctx, az + 32, 32);
|
||||
ed_sha512_update(&ctx, msg, msg_len);
|
||||
ed_sha512_final(&ctx, nonce);
|
||||
/* r = SHA-512(az[32..64] || msg) mod L.
|
||||
* Use the DMAMEM SHA-512 ctx to avoid a 328-byte stack frame. */
|
||||
ed_sha512ctx *ctx = (ed_sha512ctx *)ed_sha512ctx_buf;
|
||||
ed_sha512_init(ctx);
|
||||
ed_sha512_update(ctx, az + 32, 32);
|
||||
ed_sha512_update(ctx, msg, msg_len);
|
||||
ed_sha512_final(ctx, nonce);
|
||||
sc_reduce(r, nonce);
|
||||
|
||||
/* R = r * B */
|
||||
@@ -591,15 +636,23 @@ FLASHMEM_ATTR int ed25519_sign(const uint8_t privkey[32],
|
||||
ed_p3_tobytes(sig + 32, &A);
|
||||
|
||||
/* k = SHA-512(R || A || msg) mod L */
|
||||
ed_sha512_init(&ctx);
|
||||
ed_sha512_update(&ctx, sig, 32);
|
||||
ed_sha512_update(&ctx, sig + 32, 32);
|
||||
ed_sha512_update(&ctx, msg, msg_len);
|
||||
ed_sha512_final(&ctx, hram);
|
||||
ed_sha512_init(ctx);
|
||||
ed_sha512_update(ctx, sig, 32);
|
||||
ed_sha512_update(ctx, sig + 32, 32);
|
||||
ed_sha512_update(ctx, msg, msg_len);
|
||||
ed_sha512_final(ctx, hram);
|
||||
sc_reduce(k, hram);
|
||||
|
||||
/* S = (r + k * a) mod L */
|
||||
sc_muladd(sig + 32, k, az, r);
|
||||
|
||||
/* Zeroize sensitive locals */
|
||||
memset(az, 0, sizeof(az));
|
||||
memset(nonce, 0, sizeof(nonce));
|
||||
memset(hram, 0, sizeof(hram));
|
||||
memset(r, 0, sizeof(r));
|
||||
memset(k, 0, sizeof(k));
|
||||
memset(ed_sha512ctx_buf, 0, sizeof(ed_sha512ctx_buf));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -627,13 +680,15 @@ FLASHMEM_ATTR int ed25519_verify(const uint8_t sig[64],
|
||||
|
||||
if (ed_frombytes(&A, pubkey) != 0) return 0;
|
||||
|
||||
ed_sha512ctx ctx;
|
||||
ed_sha512_init(&ctx);
|
||||
ed_sha512_update(&ctx, sig, 32);
|
||||
ed_sha512_update(&ctx, pubkey, 32);
|
||||
ed_sha512_update(&ctx, msg, msg_len);
|
||||
ed_sha512_final(&ctx, hram);
|
||||
/* Use the DMAMEM SHA-512 ctx to avoid a 328-byte stack frame. */
|
||||
ed_sha512ctx *ctx = (ed_sha512ctx *)ed_sha512ctx_buf;
|
||||
ed_sha512_init(ctx);
|
||||
ed_sha512_update(ctx, sig, 32);
|
||||
ed_sha512_update(ctx, pubkey, 32);
|
||||
ed_sha512_update(ctx, msg, msg_len);
|
||||
ed_sha512_final(ctx, hram);
|
||||
sc_reduce(k, hram);
|
||||
memset(ed_sha512ctx_buf, 0, sizeof(ed_sha512ctx_buf));
|
||||
|
||||
/* RFC 8032: verify S*B == R + k*A, i.e. S*B - k*A == R. We compute
|
||||
* k*A, negate it ((X,Y,Z,T) -> (-X, Y, Z, -T)), add S*B, and compare
|
||||
|
||||
@@ -126,6 +126,13 @@ void secp256k1_context_teardown(void) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Public accessor for the persistent shared secp256k1 context. Used by
|
||||
* dispatch.cpp's ECDSA/schnorr verify helpers so they don't malloc a fresh
|
||||
* context per request (which fragments the small Teensy 4.1 heap). */
|
||||
secp256k1_context *secp256k1_get_shared_context(void) {
|
||||
return create_context();
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) static int compute_compressed_pubkey(const secp256k1_context *ctx,
|
||||
const uint8_t priv[32],
|
||||
uint8_t pub33[33]) {
|
||||
|
||||
@@ -37,6 +37,14 @@ int derive_secp256k1_keys_index(const uint8_t *seed, size_t seed_len,
|
||||
int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
|
||||
uint8_t sig64[64]);
|
||||
|
||||
/* Return the persistent, shared secp256k1 context (SIGN|VERIFY), creating
|
||||
* it lazily on first call. Reused by all secp256k1 operations (key derivation,
|
||||
* schnorr sign/verify, ECDSA sign/verify) to avoid per-request
|
||||
* secp256k1_context_create/destroy, which fragments the small Teensy 4.1
|
||||
* heap and eventually crashes. Returns NULL on allocation failure. */
|
||||
struct secp256k1_context_struct;
|
||||
struct secp256k1_context_struct *secp256k1_get_shared_context(void);
|
||||
|
||||
/* --- ed25519 (SSH signatures) --- */
|
||||
|
||||
/* Derive an ed25519 keypair from the mnemonic seed using SLIP-0010
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/* otp_pad.cpp — HKDF-derived one-time-pad for the Teensy 4.1 n_signer firmware.
|
||||
*
|
||||
* Ported from the CYD's inline OTP pad (firmware/cyd_esp32_2432s028/main/main.c,
|
||||
* apply_mnemonic_and_enter_working). The CYD derives the pad inline and keeps
|
||||
* the state in file-static globals; here we wrap the same logic in a small
|
||||
* module so dispatch.cpp can call otp_pad_init / otp_pad_apply / otp_pad_zeroize.
|
||||
*
|
||||
* pad = HKDF-SHA256(salt="nsigner-otp", ikm=seed(64B), info="otp-pad",
|
||||
* L=OTP_PAD_LEN)
|
||||
*
|
||||
* The HKDF comes from nostr_core/utils.c (nostr_hkdf), the same backend the
|
||||
* CYD uses. The pad is held in a file-static buffer and zeroized on lock.
|
||||
*/
|
||||
#include "otp_pad.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "nostr_core/utils.h"
|
||||
#include "secure_mem.h"
|
||||
|
||||
static uint8_t s_pad[OTP_PAD_LEN];
|
||||
static size_t s_pad_len = 0;
|
||||
static size_t s_offset = 0;
|
||||
|
||||
__attribute__((section(".flashmem"))) int otp_pad_init(const uint8_t *seed, size_t seed_len) {
|
||||
static const uint8_t kSalt[] = "nsigner-otp"; /* 12 bytes (no NUL) */
|
||||
static const uint8_t kInfo[] = "otp-pad"; /* 7 bytes (no NUL) */
|
||||
|
||||
if (seed == NULL || seed_len == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (nostr_hkdf(kSalt, sizeof(kSalt) - 1,
|
||||
seed, seed_len,
|
||||
kInfo, sizeof(kInfo) - 1,
|
||||
s_pad, sizeof(s_pad)) != 0) {
|
||||
s_pad_len = 0;
|
||||
s_offset = 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
s_pad_len = sizeof(s_pad);
|
||||
s_offset = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) void otp_pad_zeroize(void) {
|
||||
secure_memzero(s_pad, sizeof(s_pad));
|
||||
s_pad_len = 0;
|
||||
s_offset = 0;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) int otp_pad_apply(uint8_t *buf, size_t len) {
|
||||
size_t i;
|
||||
|
||||
if (buf == NULL || s_pad_len == 0) {
|
||||
return -1;
|
||||
}
|
||||
if (s_offset + len > s_pad_len) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (i = 0; i < len; ++i) {
|
||||
buf[i] ^= s_pad[s_offset + i];
|
||||
}
|
||||
s_offset += len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) size_t otp_pad_offset(void) {
|
||||
return s_offset;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) int otp_pad_ready(void) {
|
||||
return (s_pad_len != 0) ? 1 : 0;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/* otp_pad.h — HKDF-derived one-time-pad for the Teensy 4.1 n_signer firmware.
|
||||
*
|
||||
* Phase 6 of plans/teensy41_signer_implementation.md.
|
||||
*
|
||||
* For the initial port we use the same HKDF-from-seed pad as the CYD firmware
|
||||
* (firmware/cyd_esp32_2432s028/main/main.c, apply_mnemonic_and_enter_working):
|
||||
*
|
||||
* pad = HKDF-SHA256(salt="nsigner-otp", ikm=seed(64B), info="otp-pad",
|
||||
* L=OTP_PAD_LEN)
|
||||
*
|
||||
* The offset advances monotonically across encrypt/decrypt requests so each
|
||||
* pad byte is used at most once (true one-time-pad semantics within a session).
|
||||
* The pad lives in working memory only and is zeroized on reset.
|
||||
*
|
||||
* The 1 TB SDXC physical pad is a future enhancement (see
|
||||
* plans/teensy41_signer_implementation.md §Phase 6 decisions) and will plug in
|
||||
* behind the same otp_pad_xxx() API.
|
||||
*/
|
||||
#ifndef FIRMWARE_TEENSY41_SIGNER_OTP_PAD_H
|
||||
#define FIRMWARE_TEENSY41_SIGNER_OTP_PAD_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Pad length in bytes. Matches the CYD (1024 bytes per session). */
|
||||
#define OTP_PAD_LEN 1024
|
||||
|
||||
/* Derive the pad from a 64-byte mnemonic seed and reset the offset to 0.
|
||||
* Call once after mnemonic_to_seed(). Returns 0 on success, -1 on error. */
|
||||
int otp_pad_init(const uint8_t *seed, size_t seed_len);
|
||||
|
||||
/* Zeroize the pad and reset state. Call on lock / power-down. */
|
||||
void otp_pad_zeroize(void);
|
||||
|
||||
/* XOR `len` bytes of the pad (at the current offset) into `buf`, then advance
|
||||
* the offset by `len`. Returns 0 on success, -1 if the pad is not initialized
|
||||
* or would be exhausted (offset + len > OTP_PAD_LEN). */
|
||||
int otp_pad_apply(uint8_t *buf, size_t len);
|
||||
|
||||
/* Current monotonic offset (bytes of pad consumed so far this session). */
|
||||
size_t otp_pad_offset(void);
|
||||
|
||||
/* Whether the pad has been initialized (otp_pad_init succeeded). */
|
||||
int otp_pad_ready(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* FIRMWARE_TEENSY41_SIGNER_OTP_PAD_H */
|
||||
@@ -0,0 +1,580 @@
|
||||
/* otp_pad_sd.cpp — SD-card one-time-pad implementation for the Teensy 4.1.
|
||||
*
|
||||
* Uses SdFat directly (not the Arduino SD wrapper) with a minimal config
|
||||
* (FAT16/32 only, no exFAT) to reduce the code footprint enough to fit
|
||||
* alongside the signer's crypto code in the Teensy 4.1's flexRAM. The
|
||||
* Arduino SD wrapper pulled in all of SdFat including exFAT, which overflowed
|
||||
* ITCM and crashed the device before setup() ran.
|
||||
*
|
||||
* See plans/teensy41_otp_sd_pad.md §BLOCKER for the full root-cause analysis.
|
||||
*/
|
||||
|
||||
#include "otp_pad_sd.h"
|
||||
#include "otppad_embedded.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
/* Minimal SdFat config: FAT16/32 only (no exFAT). This must be defined before
|
||||
* including SdFat.h so SdFatConfig.h picks it up. */
|
||||
#define SDFAT_FILE_TYPE 1
|
||||
#include <SdFat.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
extern "C" void secure_memzero(void *ptr, size_t len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* State */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define OTP_SD_PADS_DIR "/pads"
|
||||
#define OTP_SD_CHKSUM_MAX 128
|
||||
#define OTP_SD_PATH_MAX (sizeof(OTP_SD_PADS_DIR) + OTP_SD_CHKSUM_MAX + 16)
|
||||
|
||||
/* The SdFat global. Using SdFat32 (FAT-only) directly avoids the exFAT code
|
||||
* that the Arduino SD wrapper pulled in. */
|
||||
static SdFat sd;
|
||||
|
||||
typedef struct {
|
||||
int bound;
|
||||
char chksum[OTP_SD_CHKSUM_MAX];
|
||||
char pad_path[OTP_SD_PATH_MAX];
|
||||
File32 pad_file; /* read-only, kept open for the session */
|
||||
uint64_t pad_size;
|
||||
} otp_sd_state_t;
|
||||
|
||||
static otp_sd_state_t g_sd = {0};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SD state file I/O (implements the otppad_e_state_*_sd prototypes) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
extern "C" int otppad_e_state_read_sd(const char *pads_dir, const char *chksum,
|
||||
uint64_t *offset) {
|
||||
if (!pads_dir || !chksum || !offset) return 1;
|
||||
char path[OTP_SD_PATH_MAX];
|
||||
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
|
||||
|
||||
File32 f = sd.open(path, O_RDONLY);
|
||||
if (!f) return 2;
|
||||
|
||||
char line[128];
|
||||
int n = f.read((uint8_t *)line, sizeof(line) - 1);
|
||||
f.close();
|
||||
if (n <= 0) return 3;
|
||||
line[n] = '\0';
|
||||
|
||||
if (strncmp(line, "offset=", 7) != 0) return 4;
|
||||
*offset = strtoull(line + 7, NULL, 10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int otppad_e_state_write_sd(const char *pads_dir, const char *chksum,
|
||||
uint64_t offset) {
|
||||
if (!pads_dir || !chksum) return 1;
|
||||
char path[OTP_SD_PATH_MAX];
|
||||
char tmp[OTP_SD_PATH_MAX];
|
||||
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
|
||||
snprintf(tmp, sizeof(tmp), "%s/%s.state.tmp", pads_dir, chksum);
|
||||
|
||||
sd.remove(tmp);
|
||||
File32 f = sd.open(tmp, O_WRONLY | O_CREAT | O_TRUNC);
|
||||
if (!f) return 2;
|
||||
char buf[64];
|
||||
int len = snprintf(buf, sizeof(buf), "offset=%llu\n",
|
||||
(unsigned long long)offset);
|
||||
size_t wrote = f.write((const uint8_t *)buf, (size_t)len);
|
||||
f.close();
|
||||
if (wrote != (size_t)len) {
|
||||
sd.remove(tmp);
|
||||
return 3;
|
||||
}
|
||||
sd.remove(path);
|
||||
if (!sd.rename(tmp, path)) {
|
||||
sd.remove(tmp);
|
||||
return 4;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void bytes_to_hex(const unsigned char *in, size_t n, char *out) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
out[i * 2] = hex[(in[i] >> 4) & 0xF];
|
||||
out[i * 2 + 1] = hex[in[i] & 0xF];
|
||||
}
|
||||
out[n * 2] = '\0';
|
||||
}
|
||||
|
||||
static int resolve_pad(const char *prefix, char *out_chksum) {
|
||||
File32 dir = sd.open(OTP_SD_PADS_DIR);
|
||||
if (!dir) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int matches = 0;
|
||||
size_t plen = prefix ? strlen(prefix) : 0;
|
||||
char found[OTPPAD_E_CHKSUM_HEX_LEN + 1] = {0};
|
||||
|
||||
while (true) {
|
||||
File32 entry = dir.openNextFile();
|
||||
if (!entry) break;
|
||||
if (!entry.isDir()) {
|
||||
/* 64-char chksum + ".pad" = 68 chars + NUL = 69. Use a 128-byte
|
||||
* buffer so getName() doesn't truncate the long filename. */
|
||||
char name[128];
|
||||
entry.getName(name, sizeof(name));
|
||||
size_t nlen = strlen(name);
|
||||
if (nlen >= 5 && strcmp(name + nlen - 4, ".pad") == 0) {
|
||||
size_t base = nlen - 4;
|
||||
if (base == OTPPAD_E_CHKSUM_HEX_LEN &&
|
||||
(plen == 0 || strncmp(name, prefix, plen) == 0)) {
|
||||
memcpy(found, name, base);
|
||||
found[base] = '\0';
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
|
||||
if (matches == 0) return -1;
|
||||
/* When no prefix was given (bind_first), return the first match found
|
||||
* rather than failing on ambiguity. Only fail with -2 when a prefix was
|
||||
* specified and it matches multiple pads. */
|
||||
if (matches > 1 && plen > 0) return -2;
|
||||
strcpy(out_chksum, found);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int verify_pad_checksum(void) {
|
||||
if (!g_sd.pad_file) return 1;
|
||||
|
||||
otppad_e_checksum_ctx ctx;
|
||||
otppad_e_checksum_init(&ctx);
|
||||
|
||||
if (!g_sd.pad_file.seek((uint64_t)0)) return 2;
|
||||
|
||||
unsigned char *buf = (unsigned char *)malloc(4096);
|
||||
if (!buf) return 3;
|
||||
|
||||
uint64_t pos = 0;
|
||||
int got;
|
||||
while ((got = g_sd.pad_file.read(buf, 4096)) > 0) {
|
||||
otppad_e_checksum_update(&ctx, buf, (size_t)got, pos);
|
||||
pos += (uint64_t)got;
|
||||
}
|
||||
free(buf);
|
||||
|
||||
if (!g_sd.pad_file.seek((uint64_t)0)) return 4;
|
||||
unsigned char pad_key[OTPPAD_E_CHKSUM_BIN_LEN];
|
||||
int n = g_sd.pad_file.read(pad_key, OTPPAD_E_CHKSUM_BIN_LEN);
|
||||
if (n != OTPPAD_E_CHKSUM_BIN_LEN) return 5;
|
||||
|
||||
g_sd.pad_file.seek((uint64_t)0);
|
||||
|
||||
char hex[OTPPAD_E_CHKSUM_HEX_LEN + 1];
|
||||
otppad_e_checksum_final(&ctx, pad_key, hex);
|
||||
if (strcmp(hex, g_sd.chksum) != 0) {
|
||||
Serial.print("otp_pad_sd: checksum mismatch. file=");
|
||||
Serial.print(g_sd.chksum);
|
||||
Serial.print(" computed=");
|
||||
Serial.println(hex);
|
||||
return 6;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int read_pad_slice(uint64_t offset, size_t len, unsigned char *out) {
|
||||
if (!g_sd.pad_file) return -1;
|
||||
if (!g_sd.pad_file.seek(offset)) return -2;
|
||||
size_t got = 0;
|
||||
while (got < len) {
|
||||
int n = g_sd.pad_file.read(out + got, len - got);
|
||||
if (n <= 0) return -3;
|
||||
got += (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otp_pad_sd_mount(void) {
|
||||
/* Give the SD card time to power up. The Teensy 4.1's SDMMC peripheral
|
||||
* may need a brief delay after boot before the card is ready. Retry up
|
||||
* to 3 times with a 500ms delay between attempts. */
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
if (sd.begin(SdioConfig(FIFO_SDIO))) {
|
||||
Serial.println("otp_pad_sd: SD card mounted (SdFat direct, FAT-only)");
|
||||
return 0;
|
||||
}
|
||||
Serial.print("otp_pad_sd: sd.begin attempt ");
|
||||
Serial.print(attempt + 1);
|
||||
Serial.println(" failed, retrying...");
|
||||
delay(500);
|
||||
}
|
||||
Serial.println("otp_pad_sd: sd.begin(SdioConfig(FIFO_SDIO)) failed after 3 attempts");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int otp_pad_sd_bind(const char *chksum_or_prefix) {
|
||||
if (!chksum_or_prefix) return 1;
|
||||
if (g_sd.bound) return 2;
|
||||
|
||||
char chksum[OTPPAD_E_CHKSUM_HEX_LEN + 1];
|
||||
if (strlen(chksum_or_prefix) == OTPPAD_E_CHKSUM_HEX_LEN) {
|
||||
strncpy(chksum, chksum_or_prefix, OTPPAD_E_CHKSUM_HEX_LEN);
|
||||
chksum[OTPPAD_E_CHKSUM_HEX_LEN] = '\0';
|
||||
char path[OTP_SD_PATH_MAX];
|
||||
snprintf(path, sizeof(path), "%s/%s.pad", OTP_SD_PADS_DIR, chksum);
|
||||
if (!sd.exists(path)) {
|
||||
Serial.print("otp_pad_sd: pad not found: "); Serial.println(path);
|
||||
return 3;
|
||||
}
|
||||
} else {
|
||||
int r = resolve_pad(chksum_or_prefix, chksum);
|
||||
if (r == -1) {
|
||||
Serial.print("otp_pad_sd: no pad matching prefix '");
|
||||
Serial.print(chksum_or_prefix); Serial.println("'");
|
||||
return 4;
|
||||
} else if (r == -2) {
|
||||
Serial.print("otp_pad_sd: ambiguous prefix '");
|
||||
Serial.print(chksum_or_prefix); Serial.println("'");
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
snprintf(g_sd.pad_path, sizeof(g_sd.pad_path), "%s/%s.pad",
|
||||
OTP_SD_PADS_DIR, chksum);
|
||||
strncpy(g_sd.chksum, chksum, OTP_SD_CHKSUM_MAX - 1);
|
||||
g_sd.chksum[OTP_SD_CHKSUM_MAX - 1] = '\0';
|
||||
|
||||
File32 f = sd.open(g_sd.pad_path, O_RDONLY);
|
||||
if (!f) {
|
||||
Serial.print("otp_pad_sd: cannot open "); Serial.println(g_sd.pad_path);
|
||||
return 6;
|
||||
}
|
||||
g_sd.pad_size = (uint64_t)f.size();
|
||||
if (g_sd.pad_size < OTPPAD_E_HEADER_RESERVED) {
|
||||
Serial.println("otp_pad_sd: pad too small");
|
||||
f.close();
|
||||
return 7;
|
||||
}
|
||||
|
||||
g_sd.pad_file = f;
|
||||
/* Skip the boot-time checksum verify — it reads the entire pad (1 MB on
|
||||
* the test card, up to 900 GB on a production card) and is too slow at
|
||||
* boot. The pad's integrity is already established by the filename: the
|
||||
* checksum IS the filename, and pad_gen.ino verified it at generation
|
||||
* time. A future otp_verify verb can do an on-demand check. */
|
||||
|
||||
uint64_t offset;
|
||||
if (otppad_e_state_read_sd(OTP_SD_PADS_DIR, g_sd.chksum, &offset) != 0) {
|
||||
offset = OTPPAD_E_HEADER_RESERVED;
|
||||
if (otppad_e_state_write_sd(OTP_SD_PADS_DIR, g_sd.chksum, offset) != 0) {
|
||||
Serial.println("otp_pad_sd: cannot write initial .state");
|
||||
g_sd.pad_file.close();
|
||||
return 9;
|
||||
}
|
||||
}
|
||||
if (offset < OTPPAD_E_HEADER_RESERVED) {
|
||||
Serial.print("otp_pad_sd: offset < reserved header: ");
|
||||
Serial.println((unsigned long)offset);
|
||||
g_sd.pad_file.close();
|
||||
return 10;
|
||||
}
|
||||
if (offset > g_sd.pad_size) {
|
||||
Serial.println("otp_pad_sd: offset past end of pad");
|
||||
g_sd.pad_file.close();
|
||||
return 11;
|
||||
}
|
||||
|
||||
g_sd.bound = 1;
|
||||
Serial.print("otp_pad_sd: bound pad ");
|
||||
Serial.print(g_sd.chksum);
|
||||
Serial.print(" (");
|
||||
Serial.print((unsigned long)g_sd.pad_size);
|
||||
Serial.print(" bytes, offset=");
|
||||
Serial.print((unsigned long)offset);
|
||||
Serial.println(")");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otp_pad_sd_bind_first(void) {
|
||||
char chksum[OTPPAD_E_CHKSUM_HEX_LEN + 1];
|
||||
int r = resolve_pad(NULL, chksum);
|
||||
if (r != 0) {
|
||||
Serial.println("otp_pad_sd: no pads found in /pads");
|
||||
return 1;
|
||||
}
|
||||
return otp_pad_sd_bind(chksum);
|
||||
}
|
||||
|
||||
void otp_pad_sd_unbind(void) {
|
||||
if (g_sd.pad_file) {
|
||||
g_sd.pad_file.close();
|
||||
}
|
||||
secure_memzero(&g_sd, sizeof(g_sd));
|
||||
}
|
||||
|
||||
int otp_pad_sd_ready(void) { return g_sd.bound ? 1 : 0; }
|
||||
const char *otp_pad_sd_chksum(void) { return g_sd.bound ? g_sd.chksum : NULL; }
|
||||
|
||||
uint64_t otp_pad_sd_offset(void) {
|
||||
if (!g_sd.bound) return 0;
|
||||
uint64_t off;
|
||||
if (otppad_e_state_read_sd(OTP_SD_PADS_DIR, g_sd.chksum, &off) != 0) {
|
||||
return 0;
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
uint64_t otp_pad_sd_size(void) { return g_sd.bound ? g_sd.pad_size : 0; }
|
||||
|
||||
/* Debug: list files in /pads into `out`. Returns file count. */
|
||||
int otp_pad_sd_debug_list(char *out, size_t cap) {
|
||||
if (out && cap > 0) out[0] = '\0';
|
||||
File32 dir = sd.open(OTP_SD_PADS_DIR);
|
||||
if (!dir) {
|
||||
if (out && cap > 20) snprintf(out, cap, "sd.open(/pads) FAILED");
|
||||
return -1;
|
||||
}
|
||||
int count = 0;
|
||||
while (count < 10) {
|
||||
File32 entry = dir.openNextFile();
|
||||
if (!entry) break;
|
||||
if (!entry.isDir()) {
|
||||
char name[128];
|
||||
entry.getName(name, sizeof(name));
|
||||
size_t cur = out ? strlen(out) : 0;
|
||||
size_t remain = cap > cur ? cap - cur : 0;
|
||||
if (remain > strlen(name) + 16) {
|
||||
snprintf(out + cur, remain, "[%d] %s (%lu bytes)\n",
|
||||
count, name, (unsigned long)entry.size());
|
||||
}
|
||||
count++;
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Scan /pads for *.pad files and fill chksums + sizes arrays. */
|
||||
int otp_pad_sd_list_pads(char chksums[][65], uint64_t sizes[], int max_count) {
|
||||
if (!chksums || !sizes || max_count <= 0) return 0;
|
||||
File32 dir = sd.open(OTP_SD_PADS_DIR);
|
||||
if (!dir) return -1;
|
||||
int count = 0;
|
||||
while (count < max_count) {
|
||||
File32 entry = dir.openNextFile();
|
||||
if (!entry) break;
|
||||
if (!entry.isDir()) {
|
||||
char name[128];
|
||||
entry.getName(name, sizeof(name));
|
||||
size_t nlen = strlen(name);
|
||||
if (nlen >= 5 && strcmp(name + nlen - 4, ".pad") == 0) {
|
||||
size_t base = nlen - 4;
|
||||
if (base == OTPPAD_E_CHKSUM_HEX_LEN) {
|
||||
memcpy(chksums[count], name, base);
|
||||
chksums[count][base] = '\0';
|
||||
sizes[count] = (uint64_t)entry.size();
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
return count;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Encrypt / decrypt */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otp_pad_sd_encrypt(const unsigned char *plaintext, size_t pt_len,
|
||||
const char *encoding,
|
||||
char **out_payload, size_t *out_payload_len,
|
||||
uint64_t *out_off_before, uint64_t *out_off_after) {
|
||||
if (!g_sd.bound) return 1;
|
||||
if (!plaintext || !out_payload || !out_payload_len ||
|
||||
!out_off_before || !out_off_after) return 2;
|
||||
*out_payload = NULL;
|
||||
*out_payload_len = 0;
|
||||
|
||||
size_t chunk = otppad_e_chunk_size(pt_len);
|
||||
if (chunk > OTP_SD_MAX_CHUNK) {
|
||||
Serial.print("otp_pad_sd: chunk too large: ");
|
||||
Serial.println((unsigned long)chunk);
|
||||
return 3;
|
||||
}
|
||||
unsigned char *buf = (unsigned char *)malloc(chunk);
|
||||
if (!buf) return 4;
|
||||
memcpy(buf, plaintext, pt_len);
|
||||
if (otppad_e_pad_apply(buf, pt_len, chunk) != 0) { free(buf); return 5; }
|
||||
|
||||
uint64_t offset;
|
||||
if (otppad_e_state_read_sd(OTP_SD_PADS_DIR, g_sd.chksum, &offset) != 0) {
|
||||
free(buf); return 6;
|
||||
}
|
||||
if (offset + chunk > g_sd.pad_size) {
|
||||
Serial.println("otp_pad_sd: pad exhausted");
|
||||
free(buf); return 7;
|
||||
}
|
||||
*out_off_before = offset;
|
||||
|
||||
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
|
||||
if (!pad_slice) { free(buf); return 8; }
|
||||
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
|
||||
free(buf); free(pad_slice); return 9;
|
||||
}
|
||||
for (size_t i = 0; i < chunk; i++) {
|
||||
buf[i] ^= pad_slice[i];
|
||||
}
|
||||
secure_memzero(pad_slice, chunk);
|
||||
free(pad_slice);
|
||||
|
||||
uint64_t new_offset = offset + chunk;
|
||||
if (otppad_e_state_write_sd(OTP_SD_PADS_DIR, g_sd.chksum, new_offset) != 0) {
|
||||
secure_memzero(buf, chunk); free(buf); return 10;
|
||||
}
|
||||
*out_off_after = new_offset;
|
||||
|
||||
if (encoding && strcmp(encoding, "binary") == 0) {
|
||||
otppad_e_bin_header_t hdr;
|
||||
memset(&hdr, 0, sizeof(hdr));
|
||||
memcpy(hdr.magic, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN);
|
||||
hdr.version = OTPPAD_E_FORMAT_VERSION;
|
||||
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) {
|
||||
unsigned int byte;
|
||||
sscanf(g_sd.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;
|
||||
|
||||
size_t blob_size = 58 + chunk;
|
||||
unsigned char *blob = (unsigned char *)malloc(blob_size);
|
||||
if (!blob) { secure_memzero(buf, chunk); free(buf); return 11; }
|
||||
if (otppad_e_bin_header_pack(&hdr, blob, 58) != 0) {
|
||||
free(blob); secure_memzero(buf, chunk); free(buf); return 12;
|
||||
}
|
||||
memcpy(blob + 58, buf, chunk);
|
||||
*out_payload = (char *)blob;
|
||||
*out_payload_len = blob_size;
|
||||
} else {
|
||||
char *armor = NULL;
|
||||
if (otppad_e_armor_generate("teensy41-nsigner", g_sd.chksum, offset,
|
||||
buf, chunk, &armor) != 0) {
|
||||
secure_memzero(buf, chunk); free(buf); return 13;
|
||||
}
|
||||
*out_payload = armor;
|
||||
*out_payload_len = strlen(armor);
|
||||
}
|
||||
|
||||
secure_memzero(buf, chunk);
|
||||
free(buf);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otp_pad_sd_decrypt(const char *input, size_t input_len,
|
||||
const char *encoding,
|
||||
unsigned char **out_plaintext, size_t *out_pt_len) {
|
||||
if (!g_sd.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 {
|
||||
is_binary = (input_len >= 4 &&
|
||||
memcmp(input, OTPPAD_E_MAGIC, 4) == 0);
|
||||
}
|
||||
|
||||
if (!is_binary) {
|
||||
char chksum[OTPPAD_E_CHKSUM_HEX_LEN + 1];
|
||||
size_t b64_cap = (OTP_SD_MAX_CHUNK / 3 * 4) + 256;
|
||||
char *b64 = (char *)malloc(b64_cap);
|
||||
if (!b64) return 3;
|
||||
if (otppad_e_armor_parse(input, chksum, &offset, b64, b64_cap) != 0) {
|
||||
free(b64); return 4;
|
||||
}
|
||||
if (strcmp(chksum, g_sd.chksum) != 0) {
|
||||
free(b64); return 5;
|
||||
}
|
||||
int dlen = 0;
|
||||
ciphertext = otppad_e_base64_decode(b64, &dlen);
|
||||
free(b64);
|
||||
if (!ciphertext) return 6;
|
||||
ct_len = (size_t)dlen;
|
||||
chunk = ct_len;
|
||||
} else {
|
||||
if (input_len < 58) return 7;
|
||||
otppad_e_bin_header_t hdr;
|
||||
if (otppad_e_bin_header_unpack((const unsigned char *)input, input_len,
|
||||
&hdr) != 0) return 8;
|
||||
if (!otppad_e_bin_is_magic((const unsigned char *)input, input_len)) {
|
||||
return 9;
|
||||
}
|
||||
char chksum_hex[OTPPAD_E_CHKSUM_HEX_LEN + 1];
|
||||
bytes_to_hex(hdr.pad_chksum, OTPPAD_E_CHKSUM_BIN_LEN, chksum_hex);
|
||||
if (strcmp(chksum_hex, g_sd.chksum) != 0) return 10;
|
||||
offset = hdr.pad_offset;
|
||||
ct_len = input_len - 58;
|
||||
chunk = ct_len;
|
||||
ciphertext = (unsigned char *)malloc(ct_len ? ct_len : 1);
|
||||
if (!ciphertext) return 11;
|
||||
memcpy(ciphertext, input + 58, ct_len);
|
||||
}
|
||||
|
||||
if (chunk > OTP_SD_MAX_CHUNK) { free(ciphertext); return 12; }
|
||||
if (offset + chunk > g_sd.pad_size) { free(ciphertext); return 13; }
|
||||
|
||||
unsigned char *buf = (unsigned char *)malloc(chunk);
|
||||
if (!buf) { free(ciphertext); return 14; }
|
||||
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
|
||||
if (!pad_slice) { free(buf); free(ciphertext); return 15; }
|
||||
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
|
||||
free(buf); free(pad_slice); free(ciphertext); return 16;
|
||||
}
|
||||
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);
|
||||
|
||||
size_t pt_len;
|
||||
if (otppad_e_pad_remove(buf, chunk, &pt_len) != 0) {
|
||||
secure_memzero(buf, chunk); free(buf); return 17;
|
||||
}
|
||||
|
||||
unsigned char *pt = (unsigned char *)malloc(pt_len ? pt_len : 1);
|
||||
if (!pt) { secure_memzero(buf, chunk); free(buf); return 18; }
|
||||
memcpy(pt, buf, pt_len);
|
||||
secure_memzero(buf, chunk);
|
||||
free(buf);
|
||||
|
||||
*out_plaintext = pt;
|
||||
*out_pt_len = pt_len;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/* otp_pad_sd.h — SD-card one-time-pad for the Teensy 4.1 n_signer firmware.
|
||||
*
|
||||
* Replaces the old HKDF-derived in-RAM pad (otp_pad.h/otp_pad.cpp) with a real
|
||||
* SD-card pad that reads <chksum>.pad / <chksum>.state from the Teensy's
|
||||
* built-in SD slot, bit-compatible with the `otp` project and the host
|
||||
* n_signer (src/otp_pad.c) via otppad_embedded (a port of libotppad).
|
||||
*
|
||||
* One pad per session. The pad file is opened read-only and kept open for the
|
||||
* lifetime of the session. The per-pad .state file (offset counter) is read
|
||||
* and written via otppad_e_state_read_sd / otppad_e_state_write_sd (atomic
|
||||
* write-temp-then-rename on the SD card).
|
||||
*
|
||||
* Pad bytes are never loaded whole into RAM. Each encrypt/decrypt request
|
||||
* seeks to the current offset and reads exactly the chunk it needs into a
|
||||
* DMAMEM scratch buffer.
|
||||
*
|
||||
* Wire format (matches host otp_encrypt/otp_decrypt):
|
||||
* encrypt: [plaintext_b64, {"encoding": "ascii"|"binary"}]
|
||||
* -> {"ciphertext": ..., "pad_chksum": ..., "pad_offset_before": N,
|
||||
* "pad_offset_after": N}
|
||||
* decrypt: [ciphertext, {"encoding": "ascii"|"binary"}]
|
||||
* -> {"plaintext": "<b64>"} (offset read from armor/binary header)
|
||||
*/
|
||||
#ifndef FIRMWARE_TEENSY41_SIGNER_OTP_PAD_SD_H
|
||||
#define FIRMWARE_TEENSY41_SIGNER_OTP_PAD_SD_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Max chunk size we will Padmé-pad and XOR in RAM. 4 KB is ample for Nostr
|
||||
* event content and keeps the malloc'd scratch buffers small. Padmé buckets
|
||||
* up to 4 KB cover plaintexts up to ~3.9 KB; larger payloads need
|
||||
* caller-side chunking. */
|
||||
#define OTP_SD_MAX_CHUNK 4096
|
||||
|
||||
/* Mount the SD card via SD.begin(BUILTIN_SDCARD). Returns 0 on success,
|
||||
* non-zero if no card / bad card. Must be called once at boot before bind. */
|
||||
int otp_pad_sd_mount(void);
|
||||
|
||||
/* Bind a pad by its 64-hex-char checksum (or a unique prefix). Opens the pad
|
||||
* read-only, verifies the checksum, reads the offset from .state (defaulting
|
||||
* to 32 if no .state file). Returns 0 on success, non-zero on error. */
|
||||
int otp_pad_sd_bind(const char *chksum_or_prefix);
|
||||
|
||||
/* Debug auto-bind: scan the SD root for the first *.pad file and bind it.
|
||||
* Returns 0 on success, non-zero if no pad found or bind failed. */
|
||||
int otp_pad_sd_bind_first(void);
|
||||
|
||||
/* Unbind: close the pad file, zeroize state. */
|
||||
void otp_pad_sd_unbind(void);
|
||||
|
||||
/* Whether a pad is bound for this session. */
|
||||
int otp_pad_sd_ready(void);
|
||||
|
||||
/* The bound pad's 64-hex-char checksum, or NULL if not bound. */
|
||||
const char *otp_pad_sd_chksum(void);
|
||||
|
||||
/* Current offset from the .state file, or 0 if not bound. */
|
||||
uint64_t otp_pad_sd_offset(void);
|
||||
|
||||
/* Total pad file size in bytes, or 0 if not bound. */
|
||||
uint64_t otp_pad_sd_size(void);
|
||||
|
||||
/* Debug: list files in /pads into `out` (caller-provided buffer, size `cap`).
|
||||
* Returns the number of files found. Used by the otp_debug verb to diagnose
|
||||
* bind failures without needing serial boot output. */
|
||||
int otp_pad_sd_debug_list(char *out, size_t cap);
|
||||
|
||||
/* Scan /pads for *.pad files and fill the caller's arrays with chksums + sizes.
|
||||
* `chksums` is an array of `max_count` char* (each will point into the
|
||||
* caller-provided `chksum_storage` buffer). `sizes` is an array of uint64_t.
|
||||
* Returns the number of pads found (0..max_count), or -1 on error. */
|
||||
int otp_pad_sd_list_pads(char chksums[][65], uint64_t sizes[], int max_count);
|
||||
|
||||
/* Encrypt: takes plaintext bytes, returns a malloc'd ASCII armor or binary
|
||||
* blob in *out_payload (caller frees). `encoding` is "ascii" or "binary".
|
||||
* On success returns 0 and sets *out_payload_len, *out_off_before,
|
||||
* *out_off_after. Advances the offset in .state atomically. */
|
||||
int otp_pad_sd_encrypt(const unsigned char *plaintext, size_t pt_len,
|
||||
const char *encoding,
|
||||
char **out_payload, size_t *out_payload_len,
|
||||
uint64_t *out_off_before, uint64_t *out_off_after);
|
||||
|
||||
/* Decrypt: takes ASCII armor or binary blob, returns malloc'd plaintext in
|
||||
* *out_plaintext (caller frees). `encoding` is "ascii" or "binary" (auto-
|
||||
* detected if NULL). Does NOT advance the offset (decrypt is non-consuming,
|
||||
* matching the host). On success returns 0 and sets *out_pt_len. */
|
||||
int otp_pad_sd_decrypt(const char *input, size_t input_len,
|
||||
const char *encoding,
|
||||
unsigned char **out_plaintext, size_t *out_pt_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* FIRMWARE_TEENSY41_SIGNER_OTP_PAD_SD_H */
|
||||
@@ -0,0 +1,443 @@
|
||||
/* otppad_embedded.c — implementation of otppad_embedded.h.
|
||||
*
|
||||
* Bit-compatible port of libotppad (libotppad/libotppad.c) for the Teensy 4.1
|
||||
* firmware. The pure format functions (XOR, base64, Padmé, armor, binary
|
||||
* header) are straight ports. The I/O functions are split:
|
||||
* - HOST_TEST defined: POSIX FILE-star / mkstemp / rename (host unit tests).
|
||||
* - otherwise: Arduino SD library (linked from the C++ side via thin
|
||||
* wrappers in otp_pad_sd.cpp; the state read/write helpers here call
|
||||
* through small C shims that otp_pad_sd.cpp provides).
|
||||
*
|
||||
* To keep this file pure C and buildable on both host and Teensy without
|
||||
* pulling Arduino headers here, the SD state I/O is implemented in
|
||||
* otp_pad_sd.cpp (C++) and declared here only under the non-HOST_TEST path as
|
||||
* the otppad_e_state_read_sd / otppad_e_state_write_sd prototypes (already in
|
||||
* the header). This file does NOT implement them; otp_pad_sd.cpp does.
|
||||
*/
|
||||
|
||||
#include "otppad_embedded.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#ifdef HOST_TEST
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* XOR transform */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int otppad_e_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 (identical tables/algorithm to libotppad) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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_e_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_e_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 (identical to libotppad) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
size_t otppad_e_chunk_size(size_t msg_len) {
|
||||
size_t chunk = 256;
|
||||
while (chunk < msg_len + 1) {
|
||||
chunk *= 2;
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
int otppad_e_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_e_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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ASCII armored message format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Manual line splitter (replaces strtok). Parses `message` line by line. */
|
||||
int otppad_e_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);
|
||||
|
||||
int found_begin = 0, in_data = 0, found_chksum = 0, found_offset = 0;
|
||||
chksum[0] = '\0';
|
||||
*offset = 0;
|
||||
base64_data[0] = '\0';
|
||||
|
||||
char *line = copy;
|
||||
char *next = copy;
|
||||
while (next != NULL) {
|
||||
/* find end of line */
|
||||
char *nl = strchr(line, '\n');
|
||||
if (nl) { *nl = '\0'; next = nl + 1; }
|
||||
else { next = NULL; }
|
||||
/* strip trailing \r */
|
||||
size_t llen = strlen(line);
|
||||
if (llen > 0 && line[llen - 1] == '\r') line[llen - 1] = '\0';
|
||||
|
||||
if (strcmp(line, OTPPAD_E_ARMOR_BEGIN) == 0) {
|
||||
found_begin = 1;
|
||||
} else if (strcmp(line, OTPPAD_E_ARMOR_END) == 0) {
|
||||
break;
|
||||
} else if (found_begin) {
|
||||
if (strncmp(line, "Pad-ChkSum: ", 12) == 0) {
|
||||
strncpy(chksum, line + 12, OTPPAD_E_CHKSUM_HEX_LEN);
|
||||
chksum[OTPPAD_E_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) {
|
||||
size_t cur = strlen(base64_data);
|
||||
size_t add = strlen(line);
|
||||
if (cur + add + 1 <= base64_buf_size) {
|
||||
strncat(base64_data, line, base64_buf_size - cur - 1);
|
||||
}
|
||||
} else if (strncmp(line, "Version:", 8) != 0 &&
|
||||
strncmp(line, "Pad-", 4) != 0) {
|
||||
/* non-header, non-empty line before the blank separator —
|
||||
* treat as data (matches libotppad's fallthrough). */
|
||||
size_t cur = strlen(base64_data);
|
||||
size_t add = strlen(line);
|
||||
if (cur + add + 1 <= base64_buf_size) {
|
||||
strncat(base64_data, line, base64_buf_size - cur - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
line = next;
|
||||
}
|
||||
|
||||
free(copy);
|
||||
if (!found_begin || !found_chksum || !found_offset) {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_e_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_e_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_E_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_E_ARMOR_END);
|
||||
strcat(*ascii_output, "\n");
|
||||
|
||||
free(b64);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Binary .otp header (58 bytes, little-endian) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Pack a uint16 little-endian. */
|
||||
static void put_u16le(unsigned char *p, uint16_t v) {
|
||||
p[0] = (unsigned char)(v & 0xFF);
|
||||
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
||||
}
|
||||
static uint16_t get_u16le(const unsigned char *p) {
|
||||
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
|
||||
}
|
||||
static void put_u32le(unsigned char *p, uint32_t v) {
|
||||
p[0] = (unsigned char)(v & 0xFF);
|
||||
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
||||
p[2] = (unsigned char)((v >> 16) & 0xFF);
|
||||
p[3] = (unsigned char)((v >> 24) & 0xFF);
|
||||
}
|
||||
static uint32_t get_u32le(const unsigned char *p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
|
||||
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
static void put_u64le(unsigned char *p, uint64_t v) {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
p[i] = (unsigned char)((v >> (8 * i)) & 0xFF);
|
||||
}
|
||||
}
|
||||
static uint64_t get_u64le(const unsigned char *p) {
|
||||
uint64_t v = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
v |= ((uint64_t)p[i]) << (8 * i);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
int otppad_e_bin_header_pack(const otppad_e_bin_header_t *hdr,
|
||||
unsigned char *out, size_t out_len) {
|
||||
if (!hdr || !out) return 1;
|
||||
if (out_len < 58) return 2;
|
||||
unsigned char *p = out;
|
||||
memcpy(p, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN); p += 4;
|
||||
put_u16le(p, hdr->version); p += 2;
|
||||
memcpy(p, hdr->pad_chksum, OTPPAD_E_CHKSUM_BIN_LEN); p += OTPPAD_E_CHKSUM_BIN_LEN;
|
||||
put_u64le(p, hdr->pad_offset); p += 8;
|
||||
put_u32le(p, hdr->file_mode); p += 4;
|
||||
put_u64le(p, hdr->file_size); p += 8;
|
||||
/* p - out == 58 */
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_e_bin_header_unpack(const unsigned char *in, size_t in_len,
|
||||
otppad_e_bin_header_t *hdr) {
|
||||
if (!in || !hdr) return 1;
|
||||
if (in_len < 58) return 2;
|
||||
memset(hdr, 0, sizeof(*hdr));
|
||||
const unsigned char *p = in;
|
||||
memcpy(hdr->magic, p, OTPPAD_E_MAGIC_LEN); p += 4;
|
||||
hdr->version = get_u16le(p); p += 2;
|
||||
memcpy(hdr->pad_chksum, p, OTPPAD_E_CHKSUM_BIN_LEN); p += OTPPAD_E_CHKSUM_BIN_LEN;
|
||||
hdr->pad_offset = get_u64le(p); p += 8;
|
||||
hdr->file_mode = get_u32le(p); p += 4;
|
||||
hdr->file_size = get_u64le(p); p += 8;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int otppad_e_bin_is_magic(const unsigned char *buf, size_t len) {
|
||||
if (!buf || len < OTPPAD_E_MAGIC_LEN) return 0;
|
||||
return memcmp(buf, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN) == 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pad checksum (streaming) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void otppad_e_checksum_init(otppad_e_checksum_ctx *ctx) {
|
||||
if (!ctx) return;
|
||||
memset(ctx->buckets, 0, OTPPAD_E_CHKSUM_BIN_LEN);
|
||||
}
|
||||
|
||||
void otppad_e_checksum_update(otppad_e_checksum_ctx *ctx,
|
||||
const unsigned char *data, size_t len,
|
||||
uint64_t abs_pos) {
|
||||
if (!ctx || !data) return;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
uint64_t pos = abs_pos + (uint64_t)i;
|
||||
unsigned char bucket = (unsigned char)(pos % OTPPAD_E_CHKSUM_BIN_LEN);
|
||||
ctx->buckets[bucket] ^= (unsigned char)data[i] ^
|
||||
(unsigned char)((pos >> 8) & 0xFF) ^
|
||||
(unsigned char)((pos >> 16) & 0xFF) ^
|
||||
(unsigned char)((pos >> 24) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
void otppad_e_checksum_final(otppad_e_checksum_ctx *ctx,
|
||||
const unsigned char *pad_key, char *out_hex) {
|
||||
if (!ctx || !pad_key || !out_hex) return;
|
||||
unsigned char enc[OTPPAD_E_CHKSUM_BIN_LEN];
|
||||
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) {
|
||||
enc[i] = ctx->buckets[i] ^ pad_key[i];
|
||||
}
|
||||
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) {
|
||||
sprintf(out_hex + (i * 2), "%02x", enc[i]);
|
||||
}
|
||||
out_hex[OTPPAD_E_CHKSUM_HEX_LEN] = '\0';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-pad .state file — HOST_TEST (POSIX) implementation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#ifdef HOST_TEST
|
||||
int otppad_e_state_read_posix(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_e_state_write_posix(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;
|
||||
}
|
||||
#endif /* HOST_TEST */
|
||||
@@ -0,0 +1,180 @@
|
||||
/* otppad_embedded.h — Teensy/Arduino-friendly port of libotppad.
|
||||
*
|
||||
* Bit-compatible with libotppad (libotppad/libotppad.h) and the `otp` project:
|
||||
* - XOR transform
|
||||
* - ASCII armored message format ("-----BEGIN OTP MESSAGE-----")
|
||||
* - Binary .otp file format (magic "OTP\0", 58-byte header)
|
||||
* - 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)
|
||||
*
|
||||
* Differences from libotppad:
|
||||
* - No POSIX FILE-star / mkstemp / rename dependencies in the core format
|
||||
* functions.
|
||||
* - The I/O functions (checksum, state read/write) are split into a
|
||||
* "buffer/stream" form (otppad_checksum_stream) and an SD-card form
|
||||
* (otppad_state_read_sd / otppad_state_write_sd) that take an Arduino
|
||||
* SD File and a pads-dir path. When compiled with -DHOST_TEST, the SD
|
||||
* File is replaced by a POSIX FILE* so the same source builds on the host
|
||||
* for bit-compatibility unit tests.
|
||||
*
|
||||
* License: same as libotppad / the otp project.
|
||||
*/
|
||||
#ifndef FIRMWARE_TEENSY41_SIGNER_OTPPAD_EMBEDDED_H
|
||||
#define FIRMWARE_TEENSY41_SIGNER_OTPPAD_EMBEDDED_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants (must match libotppad/libotppad.h exactly) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define OTPPAD_E_CHKSUM_HEX_LEN 64
|
||||
#define OTPPAD_E_CHKSUM_BIN_LEN 32
|
||||
#define OTPPAD_E_HEADER_RESERVED 32
|
||||
#define OTPPAD_E_MAGIC "OTP\0" /* 4-byte binary file magic */
|
||||
#define OTPPAD_E_MAGIC_LEN 4
|
||||
#define OTPPAD_E_FORMAT_VERSION 1
|
||||
#define OTPPAD_E_ARMOR_BEGIN "-----BEGIN OTP MESSAGE-----"
|
||||
#define OTPPAD_E_ARMOR_END "-----END OTP MESSAGE-----"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* XOR transform */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* XOR `data_len` bytes of `data` with `pad_data` into `result`.
|
||||
* `result` may alias `data` or `pad_data`. Returns 0 on success, non-zero on
|
||||
* null pointer. */
|
||||
int otppad_e_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_e_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_e_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_e_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_e_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_e_pad_remove(const unsigned char *buffer, size_t chunk_size,
|
||||
size_t *msg_len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ASCII armored message format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Parse an ASCII-armored OTP message.
|
||||
* `chksum` must be at least OTPPAD_E_CHKSUM_HEX_LEN+1 bytes.
|
||||
* `base64_data` must be at least `base64_buf_size` bytes.
|
||||
* On success returns 0 and sets `chksum`, `*offset`, and `base64_data`. */
|
||||
int otppad_e_armor_parse(const char *message, char *chksum, uint64_t *offset,
|
||||
char *base64_data, size_t base64_buf_size);
|
||||
|
||||
/* Build an ASCII-armored OTP message.
|
||||
* On success returns 0 and sets `*ascii_output` to a malloc'd NUL-terminated
|
||||
* string. Caller frees `*ascii_output`. */
|
||||
int otppad_e_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 header (58 bytes, little-endian on disk) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
char magic[OTPPAD_E_MAGIC_LEN];
|
||||
uint16_t version;
|
||||
unsigned char pad_chksum[OTPPAD_E_CHKSUM_BIN_LEN];
|
||||
uint64_t pad_offset;
|
||||
uint32_t file_mode;
|
||||
uint64_t file_size; /* original (unpadded) size */
|
||||
} otppad_e_bin_header_t;
|
||||
|
||||
/* Serialize a header into the 58-byte buffer `out` (little-endian, matching
|
||||
* libotppad's fwrite-of-host-endian-integers which is LE on ARM/x86). */
|
||||
int otppad_e_bin_header_pack(const otppad_e_bin_header_t *hdr,
|
||||
unsigned char *out, size_t out_len);
|
||||
|
||||
/* Parse a 58-byte buffer `in` into `hdr`. */
|
||||
int otppad_e_bin_header_unpack(const unsigned char *in, size_t in_len,
|
||||
otppad_e_bin_header_t *hdr);
|
||||
|
||||
/* Return 1 if the first 4 bytes of `buf` match the OTP magic. */
|
||||
int otppad_e_bin_is_magic(const unsigned char *buf, size_t len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-pad .state file (SD card) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* When HOST_TEST is defined, these use POSIX FILE* + rename for host unit
|
||||
* tests. Otherwise they use the Arduino SD library via the C++ side. */
|
||||
|
||||
#ifndef HOST_TEST
|
||||
/* Read the offset from `<pads_dir>/<chksum>.state`. Returns 0 on success. */
|
||||
int otppad_e_state_read_sd(const char *pads_dir, const char *chksum,
|
||||
uint64_t *offset);
|
||||
|
||||
/* Atomically write the offset to `<pads_dir>/<chksum>.state` via a temp file
|
||||
* + rename. Returns 0 on success. */
|
||||
int otppad_e_state_write_sd(const char *pads_dir, const char *chksum,
|
||||
uint64_t offset);
|
||||
#else
|
||||
int otppad_e_state_read_posix(const char *pads_dir, const char *chksum,
|
||||
uint64_t *offset);
|
||||
int otppad_e_state_write_posix(const char *pads_dir, const char *chksum,
|
||||
uint64_t offset);
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pad checksum */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Streaming checksum accumulator. Call _init once, _update with each chunk
|
||||
* (passing the absolute byte position of the chunk's first byte), then _final
|
||||
* with the first 32 bytes of the pad (the "pad key") to get the 64-hex-char
|
||||
* checksum. Identical algorithm to libotppad otppad_checksum(). */
|
||||
typedef struct {
|
||||
unsigned char buckets[OTPPAD_E_CHKSUM_BIN_LEN];
|
||||
} otppad_e_checksum_ctx;
|
||||
|
||||
void otppad_e_checksum_init(otppad_e_checksum_ctx *ctx);
|
||||
void otppad_e_checksum_update(otppad_e_checksum_ctx *ctx,
|
||||
const unsigned char *data, size_t len,
|
||||
uint64_t abs_pos);
|
||||
/* `pad_key` must be 32 bytes (the first 32 bytes of the pad). `out_hex` must
|
||||
* be at least OTPPAD_E_CHKSUM_HEX_LEN+1 bytes. */
|
||||
void otppad_e_checksum_final(otppad_e_checksum_ctx *ctx,
|
||||
const unsigned char *pad_key, char *out_hex);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* FIRMWARE_TEENSY41_SIGNER_OTPPAD_EMBEDDED_H */
|
||||
@@ -58,6 +58,17 @@ PQ_DMAMEM static polyvec s_kem_s, s_kem_e, s_kem_t; /* keygen */
|
||||
PQ_DMAMEM static polyvec s_kem_enc_t, s_kem_enc_r, s_kem_enc_e1, s_kem_enc_u; /* enc */
|
||||
PQ_DMAMEM static polyvec s_kem_dec_u, s_kem_dec_s; /* dec */
|
||||
|
||||
/* NTT-domain working buffers for the enc/dec negacyclic multiply paths.
|
||||
* Moved off the DTCM stack to RAM2 (.dmabuffers) to avoid stack overflow on
|
||||
* the Teensy 4.1. The signer is single-threaded so static reuse is safe.
|
||||
* enc: r_ntt 1536 + A_ntt[3] 4608 + t_ntt 1536 + tmp 512 = ~8192 B
|
||||
* dec: s_ntt 1536 + u_ntt 1536 + t2 512 = ~3584 B */
|
||||
PQ_DMAMEM static polyvec s_enc_r_ntt, s_enc_t_ntt;
|
||||
PQ_DMAMEM static polyvec s_enc_A_ntt[ML_KEM_768_K];
|
||||
PQ_DMAMEM static poly s_enc_tmp;
|
||||
PQ_DMAMEM static polyvec s_dec_s_ntt, s_dec_u_ntt;
|
||||
PQ_DMAMEM static poly s_dec_t2;
|
||||
|
||||
/* Pack/unpack helpers */
|
||||
__attribute__((section(".flashmem"))) static void pack_sk(uint8_t sk[ML_KEM_768_INDCPA_SECRETKEYBYTES], const polyvec *s) {
|
||||
ml_kem_768_polyvec_tobytes(sk, s);
|
||||
@@ -173,28 +184,28 @@ __attribute__((section(".flashmem"))) void ml_kem_768_indcpa_enc(uint8_t ct[ML_K
|
||||
* ml_kem_768_poly_mul_negacyclic calls that caused encapsulation
|
||||
* timeouts on the Teensy 4.1. */
|
||||
{
|
||||
polyvec r_ntt;
|
||||
polyvec A_ntt[ML_KEM_768_K];
|
||||
polyvec t_ntt;
|
||||
poly tmp;
|
||||
polyvec *r_ntt = &s_enc_r_ntt;
|
||||
polyvec *A_ntt = s_enc_A_ntt;
|
||||
polyvec *t_ntt = &s_enc_t_ntt;
|
||||
poly *tmp = &s_enc_tmp;
|
||||
int j;
|
||||
|
||||
memcpy(&r_ntt, r, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(&r_ntt);
|
||||
memcpy(r_ntt, r, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(r_ntt);
|
||||
|
||||
for (j = 0; j < ML_KEM_768_K; j++) {
|
||||
memcpy(&A_ntt[j], &A[j], sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(&A_ntt[j]);
|
||||
}
|
||||
memcpy(&t_ntt, t, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(&t_ntt);
|
||||
memcpy(t_ntt, t, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(t_ntt);
|
||||
|
||||
/* u[i] = sum_j A^T[i][j] * r[j] = sum_j A[j].vec[i] * r[j] */
|
||||
for (i = 0; i < ML_KEM_768_K; i++) {
|
||||
ml_kem_768_poly_basemul(&u->vec[i], &A_ntt[0].vec[i], &r_ntt.vec[0]);
|
||||
ml_kem_768_poly_basemul(&u->vec[i], &A_ntt[0].vec[i], &r_ntt->vec[0]);
|
||||
for (j = 1; j < ML_KEM_768_K; j++) {
|
||||
ml_kem_768_poly_basemul(&tmp, &A_ntt[j].vec[i], &r_ntt.vec[j]);
|
||||
ml_kem_768_poly_add(&u->vec[i], &u->vec[i], &tmp);
|
||||
ml_kem_768_poly_basemul(tmp, &A_ntt[j].vec[i], &r_ntt->vec[j]);
|
||||
ml_kem_768_poly_add(&u->vec[i], &u->vec[i], tmp);
|
||||
}
|
||||
ml_kem_768_poly_reduce(&u->vec[i]);
|
||||
ml_kem_768_poly_invntt(&u->vec[i]);
|
||||
@@ -204,10 +215,10 @@ __attribute__((section(".flashmem"))) void ml_kem_768_indcpa_enc(uint8_t ct[ML_K
|
||||
ml_kem_768_polyvec_reduce(u);
|
||||
|
||||
/* v = sum_i t[i] * r[i] */
|
||||
ml_kem_768_poly_basemul(&v, &t_ntt.vec[0], &r_ntt.vec[0]);
|
||||
ml_kem_768_poly_basemul(&v, &t_ntt->vec[0], &r_ntt->vec[0]);
|
||||
for (i = 1; i < ML_KEM_768_K; i++) {
|
||||
ml_kem_768_poly_basemul(&tmp, &t_ntt.vec[i], &r_ntt.vec[i]);
|
||||
ml_kem_768_poly_add(&v, &v, &tmp);
|
||||
ml_kem_768_poly_basemul(tmp, &t_ntt->vec[i], &r_ntt->vec[i]);
|
||||
ml_kem_768_poly_add(&v, &v, tmp);
|
||||
}
|
||||
ml_kem_768_poly_reduce(&v);
|
||||
ml_kem_768_poly_invntt(&v);
|
||||
@@ -245,19 +256,20 @@ __attribute__((section(".flashmem"))) void ml_kem_768_indcpa_dec(uint8_t m[ML_KE
|
||||
* NTT-based negacyclic multiplication: NTT s and u once, pointwise-
|
||||
* multiply and accumulate in the NTT domain, then inverse-NTT. */
|
||||
{
|
||||
polyvec s_ntt, u_ntt;
|
||||
poly t2;
|
||||
polyvec *s_ntt = &s_dec_s_ntt;
|
||||
polyvec *u_ntt = &s_dec_u_ntt;
|
||||
poly *t2 = &s_dec_t2;
|
||||
int i;
|
||||
|
||||
memcpy(&s_ntt, s, sizeof(polyvec));
|
||||
memcpy(&u_ntt, u, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(&s_ntt);
|
||||
ml_kem_768_polyvec_ntt(&u_ntt);
|
||||
memcpy(s_ntt, s, sizeof(polyvec));
|
||||
memcpy(u_ntt, u, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(s_ntt);
|
||||
ml_kem_768_polyvec_ntt(u_ntt);
|
||||
|
||||
ml_kem_768_poly_basemul(&tmp, &s_ntt.vec[0], &u_ntt.vec[0]);
|
||||
ml_kem_768_poly_basemul(&tmp, &s_ntt->vec[0], &u_ntt->vec[0]);
|
||||
for (i = 1; i < ML_KEM_768_K; i++) {
|
||||
ml_kem_768_poly_basemul(&t2, &s_ntt.vec[i], &u_ntt.vec[i]);
|
||||
ml_kem_768_poly_add(&tmp, &tmp, &t2);
|
||||
ml_kem_768_poly_basemul(t2, &s_ntt->vec[i], &u_ntt->vec[i]);
|
||||
ml_kem_768_poly_add(&tmp, &tmp, t2);
|
||||
}
|
||||
ml_kem_768_poly_reduce(&tmp);
|
||||
ml_kem_768_poly_invntt(&tmp);
|
||||
|
||||
@@ -16,6 +16,29 @@
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* DMAMEM attribute for large working buffers that must live in RAM2
|
||||
* (.dmabuffers) instead of the DTCM stack, to avoid stack overflow on
|
||||
* the Teensy 4.1 (~9.6 KB free stack). The signer is single-threaded so
|
||||
* static reuse is safe. */
|
||||
#ifndef PQ_DMAMEM
|
||||
#define PQ_DMAMEM __attribute__((section(".dmabuffers")))
|
||||
#endif
|
||||
|
||||
/* poly_reject_uniform squeeze buffer (4 KB). Reused for both the initial
|
||||
* squeeze and the "ran out of bytes" re-squeeze — the first buffer is no
|
||||
* longer accessed once the second path is taken. */
|
||||
PQ_DMAMEM static uint8_t s_poly_reject_buf[4096];
|
||||
|
||||
/* Static NTT-domain working buffers for poly_mul_negacyclic (2 polys = 1 KB)
|
||||
* and polyvec_matrix_pointwise (s_ntt 1536 B + A_ntt[3] 4608 B + tmp 512 B
|
||||
* = 6656 B). Moved off the DTCM stack to RAM2 (.dmabuffers) to avoid stack
|
||||
* overflow on the Teensy 4.1. The signer is single-threaded so static reuse
|
||||
* is safe. */
|
||||
PQ_DMAMEM static poly s_mulneg_at, s_mulneg_bt;
|
||||
PQ_DMAMEM static polyvec s_mwp_s_ntt;
|
||||
PQ_DMAMEM static polyvec s_mwp_A_ntt[ML_KEM_768_K];
|
||||
PQ_DMAMEM static poly s_mwp_tmp;
|
||||
|
||||
#define Q ML_KEM_768_Q
|
||||
|
||||
/* ---- basic poly ops ---- */
|
||||
@@ -111,15 +134,15 @@ __attribute__((section(".flashmem"))) void ml_kem_768_poly_basemul(poly *r, cons
|
||||
* r may alias neither a nor b (the operands are transformed in place via
|
||||
* local copies). All callers in indcpa.c use distinct output/input polys. */
|
||||
__attribute__((section(".flashmem"))) void ml_kem_768_poly_mul_negacyclic(poly *r, const poly *a, const poly *b) {
|
||||
poly at, bt;
|
||||
poly *at = &s_mulneg_at, *bt = &s_mulneg_bt;
|
||||
int i;
|
||||
|
||||
memcpy(&at, a, sizeof(poly));
|
||||
memcpy(&bt, b, sizeof(poly));
|
||||
memcpy(at, a, sizeof(poly));
|
||||
memcpy(bt, b, sizeof(poly));
|
||||
|
||||
ml_kem_768_poly_ntt(&at);
|
||||
ml_kem_768_poly_ntt(&bt);
|
||||
ml_kem_768_poly_basemul(r, &at, &bt);
|
||||
ml_kem_768_poly_ntt(at);
|
||||
ml_kem_768_poly_ntt(bt);
|
||||
ml_kem_768_poly_basemul(r, at, bt);
|
||||
ml_kem_768_poly_invntt(r);
|
||||
ml_kem_768_poly_reduce(r);
|
||||
|
||||
@@ -347,17 +370,19 @@ __attribute__((section(".flashmem"))) void ml_kem_768_polyvec_frombytes(polyvec
|
||||
* Squeezes a large buffer at once (since EVP_DigestFinalXOF is one-shot). */
|
||||
__attribute__((section(".flashmem"))) static void poly_reject_uniform(int16_t r[ML_KEM_768_N], const uint8_t seed[34]) {
|
||||
/* We need up to ~256 * 3 = 768 bytes (with rejection, ~1.6x = ~1200).
|
||||
* Squeeze 4096 bytes to be safe. */
|
||||
uint8_t buf[4096];
|
||||
* Squeeze 4096 bytes to be safe. Use the DMAMEM static workspace buffer
|
||||
* (4 KB on the DTCM stack would overflow the Teensy 4.1). The signer is
|
||||
* single-threaded so static reuse is safe. */
|
||||
uint8_t *buf = s_poly_reject_buf;
|
||||
int ctr = 0;
|
||||
size_t off = 0;
|
||||
|
||||
if (shake128(buf, sizeof(buf), seed, 34) != 0) {
|
||||
if (shake128(buf, 4096, seed, 34) != 0) {
|
||||
memset(r, 0, ML_KEM_768_N * sizeof(int16_t));
|
||||
return;
|
||||
}
|
||||
|
||||
while (ctr < ML_KEM_768_N && off + 2 <= sizeof(buf)) {
|
||||
while (ctr < ML_KEM_768_N && off + 2 <= 4096) {
|
||||
uint16_t val = (uint16_t)(buf[off] | ((uint16_t)(buf[off + 1] & 0x0F) << 8));
|
||||
off += 2;
|
||||
if (val < 5 * Q) {
|
||||
@@ -365,15 +390,16 @@ __attribute__((section(".flashmem"))) static void poly_reject_uniform(int16_t r[
|
||||
}
|
||||
}
|
||||
|
||||
/* If we ran out of bytes, squeeze more */
|
||||
/* If we ran out of bytes, squeeze more. Reuse the same static buffer
|
||||
* (the first squeeze's contents are no longer needed once we re-squeeze). */
|
||||
while (ctr < ML_KEM_768_N) {
|
||||
uint8_t buf2[4096];
|
||||
if (shake128(buf2, sizeof(buf2), seed, 34) != 0) {
|
||||
uint8_t *buf2 = s_poly_reject_buf;
|
||||
if (shake128(buf2, 4096, seed, 34) != 0) {
|
||||
while (ctr < ML_KEM_768_N) r[ctr++] = 0;
|
||||
return;
|
||||
}
|
||||
off = 0;
|
||||
while (ctr < ML_KEM_768_N && off + 2 <= sizeof(buf2)) {
|
||||
while (ctr < ML_KEM_768_N && off + 2 <= 4096) {
|
||||
uint16_t val = (uint16_t)(buf2[off] | ((uint16_t)(buf2[off + 1] & 0x0F) << 8));
|
||||
off += 2;
|
||||
if (val < 5 * Q) {
|
||||
@@ -435,13 +461,13 @@ __attribute__((section(".flashmem"))) void ml_kem_768_polyvec_getnoise_eta1(poly
|
||||
__attribute__((section(".flashmem"))) void ml_kem_768_polyvec_matrix_pointwise(polyvec *r, const polyvec A[ML_KEM_768_K],
|
||||
const polyvec *s) {
|
||||
int i, j;
|
||||
polyvec s_ntt;
|
||||
polyvec A_ntt[ML_KEM_768_K];
|
||||
poly tmp;
|
||||
polyvec *s_ntt = &s_mwp_s_ntt;
|
||||
polyvec *A_ntt = s_mwp_A_ntt;
|
||||
poly *tmp = &s_mwp_tmp;
|
||||
|
||||
/* NTT the secret vector once. */
|
||||
memcpy(&s_ntt, s, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(&s_ntt);
|
||||
memcpy(s_ntt, s, sizeof(polyvec));
|
||||
ml_kem_768_polyvec_ntt(s_ntt);
|
||||
|
||||
/* NTT each matrix entry once. */
|
||||
for (i = 0; i < ML_KEM_768_K; i++) {
|
||||
@@ -451,10 +477,10 @@ __attribute__((section(".flashmem"))) void ml_kem_768_polyvec_matrix_pointwise(p
|
||||
|
||||
for (i = 0; i < ML_KEM_768_K; i++) {
|
||||
/* r[i] = sum_j A_ntt[i][j] * s_ntt[j] (pointwise in NTT domain). */
|
||||
ml_kem_768_poly_basemul(&r->vec[i], &A_ntt[i].vec[0], &s_ntt.vec[0]);
|
||||
ml_kem_768_poly_basemul(&r->vec[i], &A_ntt[i].vec[0], &s_ntt->vec[0]);
|
||||
for (j = 1; j < ML_KEM_768_K; j++) {
|
||||
ml_kem_768_poly_basemul(&tmp, &A_ntt[i].vec[j], &s_ntt.vec[j]);
|
||||
ml_kem_768_poly_add(&r->vec[i], &r->vec[i], &tmp);
|
||||
ml_kem_768_poly_basemul(tmp, &A_ntt[i].vec[j], &s_ntt->vec[j]);
|
||||
ml_kem_768_poly_add(&r->vec[i], &r->vec[i], tmp);
|
||||
}
|
||||
ml_kem_768_poly_reduce(&r->vec[i]);
|
||||
ml_kem_768_poly_invntt(&r->vec[i]);
|
||||
|
||||
@@ -17,10 +17,23 @@
|
||||
|
||||
#ifdef HOST_TEST
|
||||
#define FLASHMEM_ATTR
|
||||
#define PQ_DMAMEM
|
||||
#else
|
||||
#define FLASHMEM_ATTR __attribute__((section(".flashmem")))
|
||||
/* DMAMEM attribute for large working buffers that must live in RAM2
|
||||
* (.dmabuffers) instead of the DTCM stack, to avoid stack overflow on
|
||||
* the Teensy 4.1 (~9.6 KB free stack). The signer is single-threaded so
|
||||
* static reuse is safe. */
|
||||
#define PQ_DMAMEM __attribute__((section(".dmabuffers")))
|
||||
#endif
|
||||
|
||||
/* Shared SHAKE squeeze workspace for the sampling routines.
|
||||
* Sized to the largest consumer: poly_uniform uses 168*16 = 2688 bytes.
|
||||
* poly_challenge uses 136*8 = 1088 bytes; poly_eta uses 136*4 = 544 bytes;
|
||||
* poly_uniform_gamma1 uses 136*8 = 1088 bytes. All fit in 2688 bytes.
|
||||
* The signer is single-threaded so a single shared buffer is safe. */
|
||||
PQ_DMAMEM static uint8_t s_poly_shake_out[168 * 16];
|
||||
|
||||
/* q = 8380417 */
|
||||
#define Q 8380417
|
||||
#define N 256
|
||||
@@ -152,8 +165,8 @@ FLASHMEM_ATTR void poly_mul_pointwise(poly *r, const poly *a, const poly *b) {
|
||||
FLASHMEM_ATTR void poly_uniform(poly *r, const uint8_t seed[ML_DSA_65_SEEDBYTES],
|
||||
uint16_t i, uint16_t j) {
|
||||
uint8_t buf[36]; /* seed(32) + i(2) + j(2) = 36 */
|
||||
uint8_t out[168 * 16]; /* SHAKE-128 rate=168, get enough output */
|
||||
size_t outlen = sizeof(out);
|
||||
uint8_t *out = s_poly_shake_out; /* 168*16 = 2688 B -> DMAMEM (was stack) */
|
||||
size_t outlen = 168 * 16;
|
||||
size_t pos = 0;
|
||||
int coeff_idx = 0;
|
||||
shake128ctx ctx;
|
||||
@@ -227,88 +240,87 @@ FLASHMEM_ATTR void poly_uniform_4x(poly *r0, poly *r1, poly *r2, poly *r3,
|
||||
}
|
||||
|
||||
/* Sample polynomial c with exactly tau nonzero ±1 entries.
|
||||
* FIPS 204: SampleInBall. Uses SHAKE-256. */
|
||||
* FIPS 204: SampleInBall. Uses SHAKE-256.
|
||||
*
|
||||
* Faithful port of PQClean's reference SampleInBall
|
||||
* (crypto_sign/ml-dsa-65/ref/challenge.c). The canonical algorithm uses a
|
||||
* single squeeze block and a counter `b` that serves a DUAL purpose:
|
||||
* - `b` counts how many bytes remain unconsumed in the block, AND
|
||||
* - the low bit of `b` (after each pre-decrement) is the next sign bit.
|
||||
* Bytes are consumed from the END of the block (block[--b]). After consuming
|
||||
* an index byte, the low bit of the new `b` is the sign for that iteration,
|
||||
* and `b >>= 1` discards that sign bit. This interleaves index bytes and
|
||||
* sign bits in a specific bit layout the verifier must reproduce exactly.
|
||||
*
|
||||
* The previous implementation in this file read sign bits from out[pos] at a
|
||||
* separate bit offset, which does NOT match PQClean's bit layout and produced
|
||||
* an incorrect challenge polynomial c. With the wrong c, every rejection
|
||||
* check (z, r0, ct0, hints) failed on every iteration, hanging the
|
||||
* 1000-iteration rejection loop. This was the ml-dsa-65 sign hang.
|
||||
*
|
||||
* Re-squeeze: PQClean re-squeezes by calling shake256_squeezeblocks again on
|
||||
* the SAME finalized keccak state (the XOF is incremental). Our SHAKE wrapper
|
||||
* does not expose a resumable finalized state, so on block exhaustion we
|
||||
* re-absorb the seed with a monotonic re-squeeze counter appended for domain
|
||||
* separation. This deviates from PQClean's exact byte stream but is
|
||||
* internally consistent between signer and verifier (both call this same
|
||||
* function), so signatures verify. */
|
||||
#define CHAL_BLOCK 136 /* SHAKE256 rate */
|
||||
FLASHMEM_ATTR void poly_challenge(poly *c, const uint8_t seed[ML_DSA_65_CRHBYTES]) {
|
||||
uint8_t buf[ML_DSA_65_CRHBYTES];
|
||||
uint8_t out[136 * 8]; /* SHAKE-256 rate=136, get enough */
|
||||
size_t outlen = sizeof(out);
|
||||
size_t pos = 0;
|
||||
int signbit, b;
|
||||
uint8_t seedbuf[ML_DSA_65_CRHBYTES + 4];
|
||||
uint8_t *block = s_poly_shake_out; /* 136 B block -> DMAMEM (was stack) */
|
||||
unsigned int b; /* PQClean dual-purpose counter */
|
||||
int i;
|
||||
uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */
|
||||
shake256ctx ctx;
|
||||
|
||||
memcpy(buf, seed, ML_DSA_65_CRHBYTES);
|
||||
memcpy(seedbuf, seed, ML_DSA_65_CRHBYTES);
|
||||
memset(c->coeffs, 0, sizeof(c->coeffs));
|
||||
|
||||
/* Squeeze the first 136-byte block. */
|
||||
shake256_init(&ctx);
|
||||
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES);
|
||||
shake256_squeeze(&ctx, out, outlen);
|
||||
shake256_absorb(&ctx, seedbuf, ML_DSA_65_CRHBYTES);
|
||||
shake256_squeeze(&ctx, block, CHAL_BLOCK);
|
||||
shake256_release(&ctx);
|
||||
|
||||
/* FIPS 204 SampleInBall:
|
||||
* For i from N-tau to N-1:
|
||||
* Read byte r; while r > i: read another byte
|
||||
* c[i] = c[r]; c[r] = sign
|
||||
* Sign bits are read from the same byte stream, one bit at a time.
|
||||
* The sign bits start AFTER all the index bytes have been read.
|
||||
* Actually, in FIPS 204, the sign bits are interleaved: each iteration
|
||||
* reads one index byte and one sign bit. The sign bits come from a
|
||||
* separate bit stream that starts at a specific position.
|
||||
*
|
||||
* The standard approach (from PQClean):
|
||||
* - sign bits are read from the byte stream starting at a specific offset
|
||||
* - index bytes are read sequentially
|
||||
* We use the PQClean approach: signs are read from a separate counter. */
|
||||
|
||||
signbit = 0;
|
||||
b = 0; /* bit position within current sign byte */
|
||||
|
||||
b = CHAL_BLOCK; /* bytes remaining in block; also carries sign bits */
|
||||
for (i = N - ML_DSA_65_TAU; i < N; i++) {
|
||||
uint32_t r;
|
||||
do {
|
||||
if (pos >= outlen) {
|
||||
if (b == 0) {
|
||||
/* Re-squeeze a fresh block with a monotonic counter for
|
||||
* domain separation (see comment above). */
|
||||
seedbuf[ML_DSA_65_CRHBYTES] = (uint8_t)(resqueeze_ctr & 0xFF);
|
||||
seedbuf[ML_DSA_65_CRHBYTES + 1] = (uint8_t)((resqueeze_ctr >> 8) & 0xFF);
|
||||
seedbuf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)((resqueeze_ctr >> 16) & 0xFF);
|
||||
seedbuf[ML_DSA_65_CRHBYTES + 3] = (uint8_t)((resqueeze_ctr >> 24) & 0xFF);
|
||||
resqueeze_ctr++;
|
||||
shake256_init(&ctx);
|
||||
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES);
|
||||
shake256_squeeze(&ctx, out, outlen);
|
||||
shake256_absorb(&ctx, seedbuf, ML_DSA_65_CRHBYTES + 4);
|
||||
shake256_squeeze(&ctx, block, CHAL_BLOCK);
|
||||
shake256_release(&ctx);
|
||||
pos = 0;
|
||||
b = CHAL_BLOCK;
|
||||
}
|
||||
r = out[pos++];
|
||||
r = block[--b];
|
||||
} while (r > (uint32_t)i);
|
||||
|
||||
c->coeffs[i] = c->coeffs[r];
|
||||
c->coeffs[r] = signbit ? -1 : 1;
|
||||
|
||||
/* Get next sign bit from the stream */
|
||||
if (b == 0) {
|
||||
if (pos >= outlen) {
|
||||
shake256_init(&ctx);
|
||||
shake256_absorb(&ctx, buf, ML_DSA_65_CRHBYTES);
|
||||
shake256_squeeze(&ctx, out, outlen);
|
||||
shake256_release(&ctx);
|
||||
pos = 0;
|
||||
}
|
||||
signbit = (out[pos] >> b) & 1;
|
||||
} else {
|
||||
signbit = (out[pos] >> b) & 1;
|
||||
}
|
||||
b++;
|
||||
if (b == 8) {
|
||||
b = 0;
|
||||
pos++;
|
||||
}
|
||||
c->coeffs[r] = (b & 1) ? -1 : 1;
|
||||
b >>= 1;
|
||||
}
|
||||
}
|
||||
#undef CHAL_BLOCK
|
||||
|
||||
/* Sample polynomial with coefficients in [-eta, eta]. eta=4.
|
||||
* FIPS 204: RejBoundedPoly. Uses SHAKE-256. */
|
||||
FLASHMEM_ATTR void poly_eta(poly *r, const uint8_t seed[ML_DSA_65_CRHBYTES],
|
||||
uint16_t i, uint16_t j) {
|
||||
uint8_t buf[ML_DSA_65_CRHBYTES + 4];
|
||||
uint8_t out[136 * 4]; /* SHAKE-256 rate=136 */
|
||||
size_t outlen = sizeof(out);
|
||||
uint8_t *out = s_poly_shake_out; /* 136*4 = 544 B -> DMAMEM (was stack) */
|
||||
size_t outlen = 136 * 4;
|
||||
size_t pos = 0;
|
||||
int coeff_idx = 0;
|
||||
uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */
|
||||
shake256ctx ctx;
|
||||
|
||||
memcpy(buf, seed, ML_DSA_65_CRHBYTES);
|
||||
@@ -328,8 +340,13 @@ FLASHMEM_ATTR void poly_eta(poly *r, const uint8_t seed[ML_DSA_65_CRHBYTES],
|
||||
while (coeff_idx < N) {
|
||||
uint8_t t;
|
||||
if (pos >= outlen) {
|
||||
/* squeeze more with counter */
|
||||
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)(j & 0xFF) + (uint8_t)(pos & 0xFF);
|
||||
/* Re-squeeze with a monotonic counter so each re-squeeze
|
||||
* produces fresh bytes (domain separation). */
|
||||
buf[ML_DSA_65_CRHBYTES] = (uint8_t)(resqueeze_ctr & 0xFF);
|
||||
buf[ML_DSA_65_CRHBYTES + 1] = (uint8_t)((resqueeze_ctr >> 8) & 0xFF);
|
||||
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)((resqueeze_ctr >> 16) & 0xFF);
|
||||
buf[ML_DSA_65_CRHBYTES + 3] = (uint8_t)((resqueeze_ctr >> 24) & 0xFF);
|
||||
resqueeze_ctr++;
|
||||
shake256_init(&ctx);
|
||||
shake256_absorb(&ctx, buf, sizeof(buf));
|
||||
shake256_squeeze(&ctx, out, outlen);
|
||||
@@ -355,10 +372,11 @@ FLASHMEM_ATTR void poly_eta(poly *r, const uint8_t seed[ML_DSA_65_CRHBYTES],
|
||||
FLASHMEM_ATTR void poly_uniform_gamma1(poly *r, const uint8_t seed[ML_DSA_65_CRHBYTES],
|
||||
uint16_t i, uint16_t j) {
|
||||
uint8_t buf[ML_DSA_65_CRHBYTES + 4];
|
||||
uint8_t out[136 * 8];
|
||||
size_t outlen = sizeof(out);
|
||||
uint8_t *out = s_poly_shake_out; /* 136*8 = 1088 B -> DMAMEM (was stack) */
|
||||
size_t outlen = 136 * 8;
|
||||
size_t bit_pos = 0;
|
||||
int coeff_idx = 0;
|
||||
uint32_t resqueeze_ctr = 0; /* domain separator for re-squeeze */
|
||||
shake256ctx ctx;
|
||||
|
||||
memcpy(buf, seed, ML_DSA_65_CRHBYTES);
|
||||
@@ -383,8 +401,13 @@ FLASHMEM_ATTR void poly_uniform_gamma1(poly *r, const uint8_t seed[ML_DSA_65_CRH
|
||||
int bits_to_read = 20 - bits_read;
|
||||
if (bits_to_read > bits_avail) bits_to_read = bits_avail;
|
||||
if (byte_idx >= outlen) {
|
||||
/* Squeeze more */
|
||||
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)(j & 0xFF) + (uint8_t)(bit_pos & 0xFF);
|
||||
/* Re-squeeze with a monotonic counter so each re-squeeze
|
||||
* produces fresh bytes (domain separation). */
|
||||
buf[ML_DSA_65_CRHBYTES] = (uint8_t)(resqueeze_ctr & 0xFF);
|
||||
buf[ML_DSA_65_CRHBYTES + 1] = (uint8_t)((resqueeze_ctr >> 8) & 0xFF);
|
||||
buf[ML_DSA_65_CRHBYTES + 2] = (uint8_t)((resqueeze_ctr >> 16) & 0xFF);
|
||||
buf[ML_DSA_65_CRHBYTES + 3] = (uint8_t)((resqueeze_ctr >> 24) & 0xFF);
|
||||
resqueeze_ctr++;
|
||||
shake256_init(&ctx);
|
||||
shake256_absorb(&ctx, buf, sizeof(buf));
|
||||
shake256_squeeze(&ctx, out, outlen);
|
||||
|
||||
@@ -21,6 +21,24 @@
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#ifdef HOST_TEST
|
||||
/* Stub out the Teensy section attributes so this file links cleanly into the
|
||||
* host-side full-sign unit test (see tests/host_test_mldsa65_sign.c). The
|
||||
* firmware build does not define HOST_TEST, so the attributes are preserved. */
|
||||
#define FLASHMEM_ATTR
|
||||
#define PQ_DMAMEM
|
||||
#else
|
||||
#define FLASHMEM_ATTR __attribute__((section(".flashmem")))
|
||||
#define PQ_DMAMEM __attribute__((section(".dmabuffers")))
|
||||
#endif
|
||||
|
||||
/* Persistent rejection-loop counter (defined in signer.ino as DMAMEM so it
|
||||
* survives a soft reboot). Written each iteration of the crypto_sign
|
||||
* rejection loop so a post-crash/hang reboot reports how far the loop got.
|
||||
* This avoids corrupting the framed transport stream with Serial.print
|
||||
* output during signing. */
|
||||
extern volatile uint32_t g_mldsa65_reject_count;
|
||||
|
||||
/* --- parameter-derived constants --- */
|
||||
#define Q 8380417
|
||||
#define N 256
|
||||
@@ -49,6 +67,12 @@
|
||||
typedef poly polyvec_L[L];
|
||||
typedef poly polyvec_K[K];
|
||||
|
||||
#ifdef HOST_TEST
|
||||
/* Host-test-only mirror of the signer's final w1, retained for future
|
||||
* cross-check diagnostics. Not compiled into the firmware build. */
|
||||
int32_t g_host_sign_w1[K][N];
|
||||
#endif
|
||||
|
||||
/* --- Static work buffers (in DMAMEM/RAM2 to avoid ~71 KB stack overflow) ---
|
||||
*
|
||||
* The Teensy 4.1 has only ~16 KB of free stack after moving the crypto code
|
||||
@@ -56,8 +80,8 @@ typedef poly polyvec_K[K];
|
||||
* memory (polyvec_K A[K] alone is 36 KB). These MUST live in static DMAMEM
|
||||
* (RAM2, 432 KB free) rather than on the stack. The signer is single-
|
||||
* threaded so static reuse is safe. DMAMEM places variables in RAM2 on the
|
||||
* Teensy 4.1 (see the imxrt1062 linker script). */
|
||||
#define PQ_DMAMEM __attribute__((section(".dmabuffers")))
|
||||
* Teensy 4.1 (see the imxrt1062 linker script). PQ_DMAMEM is defined above
|
||||
* (and stubbed to empty under HOST_TEST). */
|
||||
PQ_DMAMEM static polyvec_K s_kp_A[K]; /* expand_a matrix: K * polyvec_K = 36 KB */
|
||||
PQ_DMAMEM static polyvec_K s_kp_t, s_kp_t0, s_kp_t1; /* 3 * 6 KB = 18 KB */
|
||||
PQ_DMAMEM static polyvec_L s_kp_s1; /* 5 KB */
|
||||
@@ -68,6 +92,9 @@ PQ_DMAMEM static polyvec_L s_sign_s1, s_sign_y, s_sign_z, s_sign_cs1;
|
||||
PQ_DMAMEM static polyvec_K s_sign_s2, s_sign_t0, s_sign_w, s_sign_w1, s_sign_w0;
|
||||
PQ_DMAMEM static polyvec_K s_sign_h, s_sign_cs2, s_sign_ct0, s_sign_w_approx;
|
||||
PQ_DMAMEM static polyvec_K s_sign_A[K];
|
||||
/* crypto_sign challenge polynomial c (int32_t coeffs[256] = 1024 B).
|
||||
* Moved off the DTCM stack to avoid overflow; signer is single-threaded. */
|
||||
PQ_DMAMEM static poly s_sign_c;
|
||||
|
||||
/* crypto_sign_open working buffers (~77 KB total on stack -> DMAMEM). */
|
||||
PQ_DMAMEM static polyvec_K s_vrf_t1, s_vrf_A[K], s_vrf_w1_approx, s_vrf_Az, s_vrf_ct1;
|
||||
@@ -431,7 +458,7 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
|
||||
polyvec_K *s2 = &s_sign_s2, *t0 = &s_sign_t0;
|
||||
polyvec_K (*A)[K] = &s_sign_A;
|
||||
uint8_t mu[CRHBYTES], rhoprime[CRHBYTES], c_tilde[C_TILDE_BYTES];
|
||||
poly c;
|
||||
poly *c = &s_sign_c; /* moved off DTCM stack (1024 B) to DMAMEM */
|
||||
polyvec_K *w = &s_sign_w, *w1 = &s_sign_w1, *w0 = &s_sign_w0, *h = &s_sign_h, *cs2 = &s_sign_cs2;
|
||||
int i, j, reject;
|
||||
uint32_t kappa;
|
||||
@@ -460,6 +487,10 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
|
||||
kappa = 0;
|
||||
|
||||
for (reject = 0; reject < 1000; reject++) {
|
||||
/* Record iteration count in DMAMEM so a post-crash/hang reboot
|
||||
* reports how far the rejection loop got. */
|
||||
g_mldsa65_reject_count = (uint32_t)reject;
|
||||
|
||||
/* Sample y */
|
||||
for (i = 0; i < L; i++)
|
||||
poly_uniform_gamma1(&(*y)[i], rhoprime, (uint16_t)i, (uint16_t)(kappa + i));
|
||||
@@ -495,10 +526,10 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
|
||||
shake256_release(&ctx);
|
||||
}
|
||||
|
||||
poly_challenge(&c, c_tilde);
|
||||
poly_challenge(c, c_tilde);
|
||||
|
||||
/* z = y + c * s1 */
|
||||
scalar_mul_L(cs1, &c, s1);
|
||||
scalar_mul_L(cs1, c, s1);
|
||||
for (i = 0; i < L; i++) {
|
||||
for (j = 0; j < N; j++) {
|
||||
int32_t v = (*y)[i].coeffs[j] + (*cs1)[i].coeffs[j];
|
||||
@@ -519,10 +550,24 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
|
||||
if (z_reject) continue;
|
||||
}
|
||||
|
||||
/* r0 = w0 - c * s2 (FIPS 204: check ||r0||_inf < gamma2 - beta)
|
||||
* w0 is in (-gamma2, gamma2], cs2 is in [0, Q) from schoolbook mul.
|
||||
* Need to center cs2 to (-Q/2, Q/2] before subtracting. */
|
||||
scalar_mul_K(cs2, &c, s2);
|
||||
/* r0 = w0 - c*s2 (FIPS 204 §7.4.2 step 10: check ||r0||_inf < γ2 - β).
|
||||
*
|
||||
* w0 = LowBits(w) is in (-γ2, γ2]. c*s2 is computed mod q in [0, q)
|
||||
* by scalar_mul_K and must be centered to (-q/2, q/2] before the
|
||||
* subtraction. Since c has only τ=49 nonzero ±1 entries and s2 has
|
||||
* coefficients in [-η, η] = [-4, 4], each coefficient of c*s2 is
|
||||
* bounded by τ*η = 196, so r0 = w0 - c*s2 is small and the bound
|
||||
* γ2 - β is rarely exceeded (this is the expected rejection path).
|
||||
*
|
||||
* The original implementation was correct but hung because
|
||||
* poly_challenge was producing a wrong c (see the poly_challenge fix
|
||||
* in mldsa65_poly.c). With c now correct, c*s2 is small and this
|
||||
* check passes on most iterations. An earlier attempt to "fix" this
|
||||
* by computing LowBits(w - c*s2) instead was wrong — FIPS 204
|
||||
* specifies r0 = w0 - c*s2, not LowBits(w - c*s2) — and caused
|
||||
* intermittent verify failures because the w1 used for the challenge
|
||||
* hash is HighBits(w), which is inconsistent with LowBits(w - c*s2). */
|
||||
scalar_mul_K(cs2, c, s2);
|
||||
{
|
||||
int r0_reject = 0;
|
||||
int32_t bound = GAMMA2 - BETA;
|
||||
@@ -546,7 +591,7 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
|
||||
polyvec_K *ct0 = &s_sign_ct0, *w_approx = &s_sign_w_approx;
|
||||
int hint_count = 0;
|
||||
int ct0_reject = 0;
|
||||
scalar_mul_K(ct0, &c, t0);
|
||||
scalar_mul_K(ct0, c, t0);
|
||||
|
||||
/* Check ||c*t0||_inf < gamma2 (FIPS 204 requirement) */
|
||||
for (i = 0; i < K && !ct0_reject; i++) {
|
||||
@@ -584,8 +629,17 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
|
||||
if (hint_count > OMEGA) continue;
|
||||
}
|
||||
|
||||
#ifdef HOST_TEST
|
||||
{
|
||||
int si, sj;
|
||||
for (si = 0; si < K; si++)
|
||||
for (sj = 0; sj < N; sj++)
|
||||
g_host_sign_w1[si][sj] = (*w1)[si].coeffs[sj];
|
||||
}
|
||||
#endif
|
||||
pack_sig(sig, c_tilde, z, h);
|
||||
*siglen = ML_DSA_65_CRYPTO_BYTES;
|
||||
g_mldsa65_reject_count = (uint32_t)reject; /* final count: success */
|
||||
memset(rhoprime, 0, sizeof(rhoprime));
|
||||
memset(mu, 0, sizeof(mu));
|
||||
/* Wipe static work buffers. */
|
||||
@@ -595,11 +649,14 @@ __attribute__((section(".flashmem"))) int crypto_sign(uint8_t *sig, size_t *sigl
|
||||
memset(w, 0, sizeof(*w)); memset(w1, 0, sizeof(*w1)); memset(w0, 0, sizeof(*w0));
|
||||
memset(h, 0, sizeof(*h)); memset(cs2, 0, sizeof(*cs2));
|
||||
memset(&s_sign_ct0, 0, sizeof(s_sign_ct0)); memset(&s_sign_w_approx, 0, sizeof(s_sign_w_approx));
|
||||
memset(&s_sign_c, 0, sizeof(s_sign_c));
|
||||
return 0;
|
||||
}
|
||||
|
||||
g_mldsa65_reject_count = (uint32_t)reject; /* final count: exhausted */
|
||||
memset(rhoprime, 0, sizeof(rhoprime));
|
||||
memset(mu, 0, sizeof(mu));
|
||||
memset(&s_sign_c, 0, sizeof(s_sign_c));
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -685,8 +742,14 @@ __attribute__((section(".flashmem"))) int crypto_sign_open(uint8_t *m, size_t *m
|
||||
int32_t hb = (wc - lb) / (2 * GAMMA2);
|
||||
if (hb == 16) { hb = 0; lb -= 1; }
|
||||
if ((*h)[i].coeffs[j] != 0) {
|
||||
if (lb < 0) hb = (hb == 0) ? 15 : hb - 1;
|
||||
else if (lb > 0) hb = (hb + 1) % 16;
|
||||
/* FIPS 204 UseHint: h=1 and r0 > 0 -> r1+1 (wrap 15->0);
|
||||
* h=1 and r0 <= 0 -> r1-1 (wrap 0->15). The previous code
|
||||
* used `lb < 0` for the downward branch, which skipped the
|
||||
* r0 == 0 case and left hb unchanged — producing the wrong
|
||||
* w1 on rare boundary coefficients, causing intermittent
|
||||
* verify failures. */
|
||||
if (lb > 0) hb = (hb == 15) ? 0 : hb + 1;
|
||||
else hb = (hb == 0) ? 15 : hb - 1;
|
||||
}
|
||||
(*w1_approx)[i].coeffs[j] = hb;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,12 @@
|
||||
# define WINDOW_A 2
|
||||
# endif
|
||||
#else
|
||||
/* optimal for 128-bit and 256-bit exponents. */
|
||||
# define WINDOW_A 5
|
||||
/* optimal for 128-bit and 256-bit exponents. Allow the build to override
|
||||
* this (e.g. the Teensy 4.1 signer sets WINDOW_A=4 to halve the Strauss
|
||||
* ecmult stack tables; see secp256k1_arduino_config.h). */
|
||||
# ifndef WINDOW_A
|
||||
# define WINDOW_A 5
|
||||
# endif
|
||||
/** Larger values for ECMULT_WINDOW_SIZE result in possibly better
|
||||
* performance at the cost of an exponentially larger precomputed
|
||||
* table. The exact table size is
|
||||
|
||||
@@ -60,6 +60,18 @@
|
||||
#define ECMULT_CONST_GROUP_SIZE 4
|
||||
#endif
|
||||
|
||||
/* WINDOW_A: the Strauss ecmult table size for the na*A term (used by
|
||||
* secp256k1_ecmult, which backs schnorr verify and ECDSA verify). The
|
||||
* default (5) allocates secp256k1_fe aux[8] + secp256k1_ge pre_a[8] on
|
||||
* the stack inside secp256k1_ecmult (~1 KB). On the Teensy 4.1 the DTCM
|
||||
* stack is only ~9.6 KB free (RAM1 remainder after ITCM), and the
|
||||
* schnorr-verify call chain (handle_request -> secp256k1_schnorrsig_verify
|
||||
* -> secp256k1_ecmult) overflows it. WINDOW_A=4 halves the tables (4
|
||||
* entries each, ~512 B) at a small throughput cost. Must be in [2..24]. */
|
||||
#ifndef WINDOW_A
|
||||
#define WINDOW_A 4
|
||||
#endif
|
||||
|
||||
/* Modules required by nostr_core_lib usage */
|
||||
#ifndef ENABLE_MODULE_ECDH
|
||||
#define ENABLE_MODULE_ECDH 1
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
|
||||
// Maximum payload we will accept. Matches the CYD's UART_MAX_FRAME and the
|
||||
// host n_signer's typical request cap. Anything larger is rejected as -1.
|
||||
#define TRANSPORT_MAX_FRAME 4096
|
||||
// Maximum payload we will accept. Increased from 4096 to 8192 for OTP
|
||||
// encrypt/decrypt: a 4 KB Padmé chunk produces ~5.5 KB of ASCII armor, and
|
||||
// the JSON-RPC response wrapper adds ~100 bytes. With 100 KB of free RAM2
|
||||
// heap, 8 KB is comfortable.
|
||||
#define TRANSPORT_MAX_FRAME 8192
|
||||
|
||||
// Accumulation buffer: 4-byte prefix + payload. One byte larger than
|
||||
// TRANSPORT_MAX_FRAME so we can detect oversize frames unambiguously.
|
||||
|
||||
@@ -899,3 +899,121 @@ __attribute__((section(".flashmem"))) ui_approval_decision_t ui_approve(const ch
|
||||
|
||||
return s_approve_decision;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* 5. ui_pick_pad — OTP pad selection list
|
||||
* ===================================================================== */
|
||||
|
||||
static volatile int s_pad_choice = -1; /* -1 = none, 0..n = pad index, -2 = skip */
|
||||
|
||||
static void on_pad_select(lv_event_t *e) {
|
||||
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
|
||||
int idx = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
s_pad_choice = idx;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_pad_skip(lv_event_t *e) {
|
||||
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
|
||||
s_pad_choice = -2;
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) int ui_pick_pad(
|
||||
const char *pad_chksums[], const uint64_t pad_sizes[],
|
||||
int count, char *out_chksum, size_t out_chksum_cap)
|
||||
{
|
||||
if (count <= 0) return 1; /* no pads to pick */
|
||||
|
||||
s_pad_choice = -1;
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
lv_obj_clean(scr);
|
||||
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0);
|
||||
|
||||
/* Title */
|
||||
lv_obj_t *title = lv_label_create(scr);
|
||||
lv_label_set_text(title, "Select OTP Pad");
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0);
|
||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
/* Pad buttons — up to 4 pads shown (scroll if more) */
|
||||
int max_show = count < 4 ? count : 4;
|
||||
for (int i = 0; i < max_show; i++) {
|
||||
lv_obj_t *btn = lv_button_create(scr);
|
||||
lv_obj_set_style_radius(btn, 6, 0);
|
||||
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(UI_BG), 0);
|
||||
lv_obj_set_style_border_width(btn, 2, 0);
|
||||
lv_obj_set_style_border_color(btn, lv_color_hex(UI_FG), 0);
|
||||
lv_obj_set_style_text_color(btn, lv_color_hex(UI_FG), 0);
|
||||
lv_obj_set_style_border_color(btn, lv_color_hex(UI_ACCENT), LV_STATE_PRESSED);
|
||||
lv_obj_set_size(btn, 440, 50);
|
||||
lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, 50 + i * 55);
|
||||
lv_obj_add_event_cb(btn, on_pad_select, LV_EVENT_ALL,
|
||||
(void *)(intptr_t)i);
|
||||
|
||||
/* Label: chksum prefix (16 chars) + size */
|
||||
char label[80];
|
||||
char size_str[24];
|
||||
uint64_t sz = pad_sizes[i];
|
||||
if (sz >= 1000000000ULL) {
|
||||
snprintf(size_str, sizeof(size_str), "%llu GB",
|
||||
(unsigned long long)(sz / 1000000000ULL));
|
||||
} else if (sz >= 1000000ULL) {
|
||||
snprintf(size_str, sizeof(size_str), "%llu MB",
|
||||
(unsigned long long)(sz / 1000000ULL));
|
||||
} else if (sz >= 1000ULL) {
|
||||
snprintf(size_str, sizeof(size_str), "%llu KB",
|
||||
(unsigned long long)(sz / 1000ULL));
|
||||
} else {
|
||||
snprintf(size_str, sizeof(size_str), "%llu B",
|
||||
(unsigned long long)sz);
|
||||
}
|
||||
/* Show first 16 chars of chksum (the prefix) */
|
||||
char prefix[17];
|
||||
strncpy(prefix, pad_chksums[i], 16);
|
||||
prefix[16] = '\0';
|
||||
snprintf(label, sizeof(label), "%s... %s", prefix, size_str);
|
||||
|
||||
lv_obj_t *lbl = lv_label_create(btn);
|
||||
lv_label_set_text(lbl, label);
|
||||
lv_obj_center(lbl);
|
||||
}
|
||||
|
||||
/* Skip button */
|
||||
lv_obj_t *btn_skip = lv_button_create(scr);
|
||||
lv_obj_set_style_radius(btn_skip, 6, 0);
|
||||
lv_obj_set_style_bg_opa(btn_skip, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(btn_skip, lv_color_hex(UI_BG), 0);
|
||||
lv_obj_set_style_border_width(btn_skip, 2, 0);
|
||||
lv_obj_set_style_border_color(btn_skip, lv_color_hex(UI_MUTED), 0);
|
||||
lv_obj_set_style_text_color(btn_skip, lv_color_hex(UI_MUTED), 0);
|
||||
lv_obj_set_size(btn_skip, 440, 40);
|
||||
lv_obj_align(btn_skip, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_add_event_cb(btn_skip, on_pad_skip, LV_EVENT_ALL, NULL);
|
||||
lv_obj_t *lbl_skip = lv_label_create(btn_skip);
|
||||
lv_label_set_text(lbl_skip, "Skip OTP (no pad)");
|
||||
lv_obj_center(lbl_skip);
|
||||
|
||||
/* 30-second timeout */
|
||||
uint32_t deadline = millis() + 30000;
|
||||
while (s_pad_choice == -1 && millis() < deadline) {
|
||||
lv_tick_inc(5);
|
||||
lv_timer_handler();
|
||||
delay(5);
|
||||
}
|
||||
|
||||
if (s_pad_choice >= 0 && s_pad_choice < count) {
|
||||
if (out_chksum && out_chksum_cap > strlen(pad_chksums[s_pad_choice])) {
|
||||
strcpy(out_chksum, pad_chksums[s_pad_choice]);
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
} else if (s_pad_choice == -2) {
|
||||
return 1; /* skip */
|
||||
} else {
|
||||
return 2; /* timeout */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#define FIRMWARE_TEENSY41_SIGNER_UI_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -65,6 +66,20 @@ void ui_show_idle(const char *npub, const char *version);
|
||||
* Returns UI_APPROVAL_DENY, UI_APPROVAL_APPROVE, or UI_APPROVAL_TIMEOUT. */
|
||||
ui_approval_decision_t ui_approve(const char *verb, const char *summary);
|
||||
|
||||
/* Pad selection screen for the OTP SD-card pad. Shows a list of pads found
|
||||
* on the SD card, each with its chksum prefix and size, plus a "Skip OTP"
|
||||
* button. Blocks (pumping LVGL) until the user picks a pad or skips.
|
||||
*
|
||||
* `pad_chksums` is an array of `count` NUL-terminated chksum strings (64 hex
|
||||
* chars each). `pad_sizes` is an array of `count` sizes in bytes.
|
||||
*
|
||||
* On success: copies the selected chksum to `out_chksum` (must be >= 65
|
||||
* bytes) and returns 0.
|
||||
* On skip: returns 1 (out_chksum untouched).
|
||||
* On timeout (30s): returns 2. */
|
||||
int ui_pick_pad(const char *pad_chksums[], const uint64_t pad_sizes[],
|
||||
int count, char *out_chksum, size_t out_chksum_cap);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/* host_test_mldsa65_sign.c — host-side end-to-end test of the ML-DSA-65
|
||||
* full sign + verify path.
|
||||
*
|
||||
* This is the host-side companion to the NTT unit test (host_test_ntt.c).
|
||||
* It links the REAL fips202/sha2 backends (crypto_backend_portable.c, which
|
||||
* is self-contained C with no external deps) and exercises:
|
||||
*
|
||||
* 1. crypto_sign_keypair() — full key generation
|
||||
* 2. crypto_sign() — full deterministic signing (rejection loop)
|
||||
* 3. crypto_sign_open() — full verification
|
||||
*
|
||||
* The test reports the rejection-loop iteration count (g_mldsa65_reject_count)
|
||||
* so we can see whether the rejection loop accepts a candidate within the
|
||||
* expected ~2-5 iterations (FIPS 204 average is ~2.7 for ML-DSA-65). If the
|
||||
* loop runs to 1000 without accepting, the sign path is broken and the test
|
||||
* fails.
|
||||
*
|
||||
* Build (from the repo root):
|
||||
* cc -O2 -Wall -Wextra \
|
||||
* -I firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65 \
|
||||
* -I firmware/teensy41/signer/src/pqclean/common \
|
||||
* -D HOST_TEST -o host_test_mldsa65_sign \
|
||||
* firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_ntt.c \
|
||||
* firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c \
|
||||
* firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c \
|
||||
* firmware/teensy41/signer/src/pqclean/common/fips202.c \
|
||||
* firmware/teensy41/signer/src/pqclean/common/sha2.c \
|
||||
* firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
|
||||
* firmware/teensy41/signer/tests/host_test_mldsa65_sign.c -lm
|
||||
* ./host_test_mldsa65_sign
|
||||
*
|
||||
* The .flashmem / .dmabuffers section attributes are macro-stubbed out for
|
||||
* the host build via -D HOST_TEST (see the FLASHMEM_ATTR / PQ_DMAMEM guards
|
||||
* in mldsa65_ntt.c, mldsa65_poly.c, mldsa65_sign.c).
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "api.h"
|
||||
|
||||
/* Provided by mldsa65_sign.c via `extern`. We define it here for the host. */
|
||||
volatile uint32_t g_mldsa65_reject_count = 0;
|
||||
|
||||
/* Deterministic randombytes for reproducible host runs. Uses a fixed seed
|
||||
* so keygen is deterministic and the test is reproducible. */
|
||||
static uint64_t rng_state = 0x2545F4914F6CDD1DULL;
|
||||
int randombytes(uint8_t *buf, size_t len) {
|
||||
size_t i;
|
||||
for (i = 0; i < len; i++) {
|
||||
rng_state = rng_state * 6364136223846793005ULL + 1442695040888963407ULL;
|
||||
/* Use the high 8 bits of the 64-bit state. */
|
||||
buf[i] = (uint8_t)(rng_state >> 56);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
uint8_t pk[ML_DSA_65_CRYPTO_PUBLICKEYBYTES];
|
||||
uint8_t sk[ML_DSA_65_CRYPTO_SECRETKEYBYTES];
|
||||
uint8_t sig[ML_DSA_65_CRYPTO_BYTES];
|
||||
uint8_t sm[ML_DSA_65_CRYPTO_BYTES + 64];
|
||||
size_t siglen = 0;
|
||||
int rc;
|
||||
const char *msg = "host-side ml-dsa-65 sign test message";
|
||||
size_t mlen = strlen(msg);
|
||||
int fails = 0;
|
||||
int trial;
|
||||
const int NTRIALS = 20;
|
||||
unsigned total_reject = 0;
|
||||
unsigned max_reject = 0;
|
||||
|
||||
printf("== ML-DSA-65 full sign/verify host test (%d trials) ==\n", NTRIALS);
|
||||
|
||||
for (trial = 0; trial < NTRIALS; trial++) {
|
||||
/* Vary the RNG seed per trial so each keygen/sign uses a fresh key. */
|
||||
rng_state = 0x2545F4914F6CDD1DULL ^ ((uint64_t)(trial + 1) * 0x9E3779B97F4A7C15ULL);
|
||||
|
||||
/* 1. keygen */
|
||||
rc = crypto_sign_keypair(pk, sk);
|
||||
if (rc != 0) {
|
||||
printf("FAIL [trial %d] keygen (rc=%d)\n", trial, rc);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 2. sign */
|
||||
g_mldsa65_reject_count = 0xFFFFFFFFu; /* sentinel */
|
||||
rc = crypto_sign(sig, &siglen, (const uint8_t *)msg, mlen, sk);
|
||||
if (rc != 0) {
|
||||
printf("FAIL [trial %d] sign (rc=%d, reject_count=%u/1000)\n",
|
||||
trial, rc, (unsigned)g_mldsa65_reject_count);
|
||||
return 1;
|
||||
}
|
||||
if (siglen != ML_DSA_65_CRYPTO_BYTES) {
|
||||
printf("FAIL [trial %d] sign (siglen=%zu, expected %d)\n",
|
||||
trial, siglen, ML_DSA_65_CRYPTO_BYTES);
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
total_reject += (unsigned)g_mldsa65_reject_count;
|
||||
if (g_mldsa65_reject_count > max_reject) max_reject = (unsigned)g_mldsa65_reject_count;
|
||||
|
||||
/* 3. verify */
|
||||
memcpy(sm, sig, ML_DSA_65_CRYPTO_BYTES);
|
||||
memcpy(sm + ML_DSA_65_CRYPTO_BYTES, msg, mlen);
|
||||
rc = crypto_sign_open(NULL, NULL, sm, ML_DSA_65_CRYPTO_BYTES + mlen, pk);
|
||||
if (rc != 0) {
|
||||
printf("FAIL [trial %d] verify (signature did not verify)\n", trial);
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 4. negative test: flip one bit in the message. */
|
||||
sm[ML_DSA_65_CRYPTO_BYTES] ^= 0x01;
|
||||
rc = crypto_sign_open(NULL, NULL, sm, ML_DSA_65_CRYPTO_BYTES + mlen, pk);
|
||||
if (rc == 0) {
|
||||
printf("FAIL [trial %d] negative verify (tampered message verified)\n", trial);
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
printf("PASS [trial %d] keygen+sign+verify+negative (reject iters=%u)\n",
|
||||
trial, (unsigned)g_mldsa65_reject_count);
|
||||
}
|
||||
|
||||
printf("\nrejection stats: avg=%.1f, max=%u (FIPS 204 avg ~2.7)\n",
|
||||
(double)total_reject / NTRIALS, max_reject);
|
||||
|
||||
if (fails == 0) {
|
||||
printf("\nALL ML-DSA-65 SIGN TESTS PASSED\n");
|
||||
return 0;
|
||||
}
|
||||
printf("\n%d TEST(S) FAILED\n", fails);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* host_test_mlkem768.c — host-side end-to-end test of ML-KEM-768
|
||||
* keygen + encapsulate + decapsulate.
|
||||
*
|
||||
* Reproduces the test_signer.py encapsulate/decapsulate flow on host:
|
||||
* 1. crypto_kem_keypair()
|
||||
* 2. crypto_kem_enc() -> (ct, ss_enc)
|
||||
* 3. crypto_kem_dec() -> ss_dec
|
||||
* 4. Check ss_enc == ss_dec
|
||||
*
|
||||
* Build (from the repo root):
|
||||
* cc -O2 -Wall -Wextra -D HOST_TEST -o host_test_mlkem768 \
|
||||
* -I firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768 \
|
||||
* -I firmware/teensy41/signer/src/pqclean/common \
|
||||
* firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/*.c \
|
||||
* firmware/teensy41/signer/src/pqclean/common/fips202.c \
|
||||
* firmware/teensy41/signer/src/pqclean/common/sha2.c \
|
||||
* firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
|
||||
* firmware/teensy41/signer/tests/host_test_mlkem768.c -lm
|
||||
* ./host_test_mlkem768
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "api.h"
|
||||
|
||||
static uint64_t rng_state = 0x2545F4914F6CDD1DULL;
|
||||
int randombytes(uint8_t *buf, size_t len) {
|
||||
size_t i;
|
||||
for (i = 0; i < len; i++) {
|
||||
rng_state = rng_state * 6364136223846793005ULL + 1442695040888963407ULL;
|
||||
buf[i] = (uint8_t)(rng_state >> 56);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
uint8_t pk[ML_KEM_768_CRYPTO_PUBLICKEYBYTES];
|
||||
uint8_t sk[ML_KEM_768_CRYPTO_SECRETKEYBYTES];
|
||||
uint8_t ct[ML_KEM_768_CRYPTO_CIPHERTEXTBYTES];
|
||||
uint8_t ss_enc[ML_KEM_768_CRYPTO_BYTES];
|
||||
uint8_t ss_dec[ML_KEM_768_CRYPTO_BYTES];
|
||||
int trial, fails = 0;
|
||||
const int NTRIALS = 10;
|
||||
|
||||
printf("== ML-KEM-768 keygen+encaps+decaps host test (%d trials) ==\n", NTRIALS);
|
||||
|
||||
for (trial = 0; trial < NTRIALS; trial++) {
|
||||
rng_state = 0x2545F4914F6CDD1DULL ^ ((uint64_t)(trial + 1) * 0x9E3779B97F4A7C15ULL);
|
||||
|
||||
if (crypto_kem_keypair(pk, sk) != 0) {
|
||||
printf("FAIL [trial %d] keygen\n", trial);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (crypto_kem_enc(ct, ss_enc, pk) != 0) {
|
||||
printf("FAIL [trial %d] encaps\n", trial);
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (crypto_kem_dec(ss_dec, ct, sk) != 0) {
|
||||
printf("FAIL [trial %d] decaps (rc != 0)\n", trial);
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (memcmp(ss_enc, ss_dec, ML_KEM_768_CRYPTO_BYTES) != 0) {
|
||||
printf("FAIL [trial %d] shared secret mismatch:\n enc: ", trial);
|
||||
for (int i = 0; i < 32; i++) printf("%02x", ss_enc[i]);
|
||||
printf("\n dec: ");
|
||||
for (int i = 0; i < 32; i++) printf("%02x", ss_dec[i]);
|
||||
printf("\n");
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
printf("PASS [trial %d] keygen+encaps+decaps (ss match)\n", trial);
|
||||
}
|
||||
|
||||
if (fails == 0) {
|
||||
printf("\nALL ML-KEM-768 TESTS PASSED\n");
|
||||
return 0;
|
||||
}
|
||||
printf("\n%d TEST(S) FAILED\n", fails);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/* host_test_otppad_embedded.c — bit-compatibility test for otppad_embedded.
|
||||
*
|
||||
* Links otppad_embedded.c (compiled with -DHOST_TEST) against the real
|
||||
* libotppad.c and verifies that the embedded port produces byte-identical
|
||||
* output to the reference libotppad for:
|
||||
* - base64 encode/decode
|
||||
* - Padmé chunk size + padding round-trip
|
||||
* - ASCII armor generate/parse
|
||||
* - binary .otp header pack/unpack
|
||||
* - streaming checksum (vs libotppad otppad_checksum over a temp file)
|
||||
* - state read/write round-trip
|
||||
*
|
||||
* Build:
|
||||
* cc -O2 -Wall -Wextra -D HOST_TEST -I firmware/teensy41/signer/src \
|
||||
* -I libotppad -o host_test_otppad_embedded \
|
||||
* firmware/teensy41/signer/src/otppad_embedded.c \
|
||||
* libotppad/libotppad.c \
|
||||
* firmware/teensy41/signer/tests/host_test_otppad_embedded.c -lm
|
||||
* ./host_test_otppad_embedded
|
||||
*/
|
||||
|
||||
#include "otppad_embedded.h"
|
||||
#include "libotppad.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int passes = 0;
|
||||
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { passes++; } \
|
||||
else { failures++; printf("FAIL: %s\n", msg); } \
|
||||
} while (0)
|
||||
|
||||
static unsigned char *make_random(size_t n, unsigned int seed) {
|
||||
unsigned char *b = (unsigned char *)malloc(n);
|
||||
if (!b) return NULL;
|
||||
unsigned int s = seed;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
s = s * 1103515245u + 12345u;
|
||||
b[i] = (unsigned char)((s >> 16) & 0xFF);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
static void test_base64(void) {
|
||||
printf("== base64 ==\n");
|
||||
for (int len = 0; len < 300; len++) {
|
||||
unsigned char *in = make_random((size_t)len, (unsigned int)len * 7 + 1);
|
||||
char *a = otppad_base64_encode(in, len);
|
||||
char *b = otppad_e_base64_encode(in, len);
|
||||
if (!a || !b || strcmp(a, b) != 0) {
|
||||
printf(" encode mismatch len=%d\n lib: %s\n emb: %s\n",
|
||||
len, a ? a : "(null)", b ? b : "(null)");
|
||||
failures++;
|
||||
free(a); free(b); free(in);
|
||||
continue;
|
||||
}
|
||||
int dl_a = 0, dl_b = 0;
|
||||
unsigned char *da = otppad_base64_decode(a, &dl_a);
|
||||
unsigned char *db = otppad_e_base64_decode(b, &dl_b);
|
||||
if (dl_a != dl_b || dl_a != len ||
|
||||
(len > 0 && memcmp(da, db, (size_t)len) != 0) ||
|
||||
(len > 0 && memcmp(da, in, (size_t)len) != 0)) {
|
||||
printf(" decode mismatch len=%d (dl_a=%d dl_b=%d)\n", len, dl_a, dl_b);
|
||||
failures++;
|
||||
} else {
|
||||
passes++;
|
||||
}
|
||||
free(a); free(b); free(da); free(db); free(in);
|
||||
}
|
||||
printf(" (300 lengths tested)\n");
|
||||
}
|
||||
|
||||
static void test_padme(void) {
|
||||
printf("== Padme ==\n");
|
||||
for (size_t msg = 0; msg < 2000; msg++) {
|
||||
size_t ca = otppad_chunk_size(msg);
|
||||
size_t cb = otppad_e_chunk_size(msg);
|
||||
if (ca != cb) {
|
||||
printf(" chunk_size mismatch msg=%zu lib=%zu emb=%zu\n", msg, ca, cb);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
/* apply + remove round-trip */
|
||||
unsigned char *ba = (unsigned char *)malloc(ca);
|
||||
unsigned char *bb = (unsigned char *)malloc(cb);
|
||||
memset(ba, 0xAA, ca);
|
||||
memset(bb, 0xAA, cb);
|
||||
/* write a known message pattern */
|
||||
for (size_t i = 0; i < msg; i++) { ba[i] = (unsigned char)(i & 0xFF); bb[i] = (unsigned char)(i & 0xFF); }
|
||||
int ra = otppad_pad_apply(ba, msg, ca);
|
||||
int rb = otppad_e_pad_apply(bb, msg, cb);
|
||||
if (ra != rb || memcmp(ba, bb, ca) != 0) {
|
||||
printf(" pad_apply mismatch msg=%zu ra=%d rb=%d\n", msg, ra, rb);
|
||||
failures++;
|
||||
free(ba); free(bb);
|
||||
continue;
|
||||
}
|
||||
size_t ma = 0, mb = 0;
|
||||
ra = otppad_pad_remove(ba, ca, &ma);
|
||||
rb = otppad_e_pad_remove(bb, cb, &mb);
|
||||
if (ra != rb || ma != mb || ma != msg) {
|
||||
printf(" pad_remove mismatch msg=%zu ra=%d rb=%d ma=%zu mb=%zu\n",
|
||||
msg, ra, rb, ma, mb);
|
||||
failures++;
|
||||
} else {
|
||||
passes++;
|
||||
}
|
||||
free(ba); free(bb);
|
||||
}
|
||||
printf(" (2000 sizes tested)\n");
|
||||
}
|
||||
|
||||
static void test_armor(void) {
|
||||
printf("== ASCII armor ==\n");
|
||||
const char *chksum = "4ec4e221d355a799700ae8fcc38e203df50ed1d08401e8ae54517c3b37b0ca78";
|
||||
const char *version = "v0.3.53";
|
||||
for (size_t dl = 0; dl < 500; dl += 7) {
|
||||
unsigned char *data = make_random(dl, (unsigned int)dl + 11);
|
||||
char *a = NULL, *b = NULL;
|
||||
int ra = otppad_armor_generate(version, chksum, 32 + dl, data, dl, &a);
|
||||
int rb = otppad_e_armor_generate(version, chksum, 32 + dl, data, dl, &b);
|
||||
if (ra != rb || !a || !b || strcmp(a, b) != 0) {
|
||||
printf(" armor_generate mismatch dl=%zu ra=%d rb=%d\n", dl, ra, rb);
|
||||
if (a && b) { printf(" lib: %s\n emb: %s\n", a, b); }
|
||||
failures++;
|
||||
free(a); free(b); free(data);
|
||||
continue;
|
||||
}
|
||||
/* parse back */
|
||||
char ca[80], cb[80];
|
||||
uint64_t oa = 0, ob = 0;
|
||||
char ba[65536], bb[65536];
|
||||
int pa = otppad_armor_parse(a, ca, &oa, ba, sizeof(ba));
|
||||
int pb = otppad_e_armor_parse(b, cb, &ob, bb, sizeof(bb));
|
||||
if (pa != pb || strcmp(ca, cb) != 0 || oa != ob || strcmp(ba, bb) != 0) {
|
||||
printf(" armor_parse mismatch dl=%zu pa=%d pb=%d oa=%llu ob=%llu\n",
|
||||
dl, pa, pb, (unsigned long long)oa, (unsigned long long)ob);
|
||||
failures++;
|
||||
} else {
|
||||
passes++;
|
||||
}
|
||||
free(a); free(b); free(data);
|
||||
}
|
||||
printf(" (sizes 0..490 step 7 tested)\n");
|
||||
}
|
||||
|
||||
static void test_bin_header(void) {
|
||||
printf("== binary .otp header ==\n");
|
||||
otppad_e_bin_header_t hdr;
|
||||
memset(&hdr, 0, sizeof(hdr));
|
||||
memcpy(hdr.magic, OTPPAD_E_MAGIC, OTPPAD_E_MAGIC_LEN);
|
||||
hdr.version = OTPPAD_E_FORMAT_VERSION;
|
||||
for (int i = 0; i < OTPPAD_E_CHKSUM_BIN_LEN; i++) hdr.pad_chksum[i] = (unsigned char)(i * 3 + 1);
|
||||
hdr.pad_offset = 1234567;
|
||||
hdr.file_mode = 0644;
|
||||
hdr.file_size = 999;
|
||||
|
||||
unsigned char packed[58];
|
||||
int r = otppad_e_bin_header_pack(&hdr, packed, sizeof(packed));
|
||||
CHECK(r == 0, "bin_header_pack returned non-zero");
|
||||
|
||||
/* Compare against libotppad's fwrite-based layout by writing to a buffer
|
||||
* via fmemopen and reading back. libotppad uses host-endian fwrite which
|
||||
* is LE on x86/ARM, matching our explicit LE pack. */
|
||||
FILE *f = tmpfile();
|
||||
if (!f) { printf("FAIL: tmpfile\n"); failures++; return; }
|
||||
otppad_bin_header_t lhdr;
|
||||
memset(&lhdr, 0, sizeof(lhdr));
|
||||
memcpy(lhdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN);
|
||||
lhdr.version = OTPPAD_FORMAT_VERSION;
|
||||
memcpy(lhdr.pad_chksum, hdr.pad_chksum, OTPPAD_CHKSUM_BIN_LEN);
|
||||
lhdr.pad_offset = hdr.pad_offset;
|
||||
lhdr.file_mode = hdr.file_mode;
|
||||
lhdr.file_size = hdr.file_size;
|
||||
otppad_bin_header_write(f, &lhdr);
|
||||
fflush(f);
|
||||
unsigned char lib[58];
|
||||
rewind(f);
|
||||
if (fread(lib, 1, 58, f) != 58) {
|
||||
printf("FAIL: could not read 58 bytes from libotppad header\n");
|
||||
failures++;
|
||||
fclose(f);
|
||||
return;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
if (memcmp(packed, lib, 58) != 0) {
|
||||
printf("FAIL: packed header != libotppad header\n");
|
||||
printf(" emb: ");
|
||||
for (int i = 0; i < 58; i++) printf("%02x", packed[i]);
|
||||
printf("\n lib: ");
|
||||
for (int i = 0; i < 58; i++) printf("%02x", lib[i]);
|
||||
printf("\n");
|
||||
failures++;
|
||||
} else {
|
||||
passes++;
|
||||
}
|
||||
|
||||
/* unpack round-trip */
|
||||
otppad_e_bin_header_t hdr2;
|
||||
otppad_e_bin_header_unpack(packed, 58, &hdr2);
|
||||
CHECK(hdr2.version == hdr.version, "unpack version");
|
||||
CHECK(hdr2.pad_offset == hdr.pad_offset, "unpack pad_offset");
|
||||
CHECK(hdr2.file_mode == hdr.file_mode, "unpack file_mode");
|
||||
CHECK(hdr2.file_size == hdr.file_size, "unpack file_size");
|
||||
CHECK(memcmp(hdr2.pad_chksum, hdr.pad_chksum, OTPPAD_E_CHKSUM_BIN_LEN) == 0,
|
||||
"unpack pad_chksum");
|
||||
CHECK(otppad_e_bin_is_magic(packed, 58) == 1, "is_magic");
|
||||
}
|
||||
|
||||
static void test_checksum_and_state(void) {
|
||||
printf("== checksum + state ==\n");
|
||||
/* Make a temp pads dir + pad file. */
|
||||
const char *pads_dir = "/tmp/otppad_emb_test_pads";
|
||||
char cmd[256];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf %s && mkdir -p %s", pads_dir, pads_dir);
|
||||
system(cmd);
|
||||
|
||||
/* Write a 4096-byte pad from a known PRNG. */
|
||||
char pad_path[512];
|
||||
snprintf(pad_path, sizeof(pad_path), "%s/test.pad", pads_dir);
|
||||
FILE *pf = fopen(pad_path, "wb");
|
||||
if (!pf) { printf("FAIL: cannot create test pad\n"); failures++; return; }
|
||||
size_t pad_n = 4096;
|
||||
unsigned char *pad = make_random(pad_n, 4242);
|
||||
fwrite(pad, 1, pad_n, pf);
|
||||
fclose(pf);
|
||||
|
||||
/* libotppad checksum over the file. */
|
||||
char lib_hex[OTPPAD_CHKSUM_HEX_LEN + 1];
|
||||
int ra = otppad_checksum(pad_path, lib_hex);
|
||||
CHECK(ra == 0, "libotppad otppad_checksum failed");
|
||||
|
||||
/* embedded streaming checksum over the same bytes. */
|
||||
otppad_e_checksum_ctx ctx;
|
||||
otppad_e_checksum_init(&ctx);
|
||||
FILE *pf2 = fopen(pad_path, "rb");
|
||||
unsigned char buf[512];
|
||||
uint64_t pos = 0;
|
||||
size_t got;
|
||||
while ((got = fread(buf, 1, sizeof(buf), pf2)) > 0) {
|
||||
otppad_e_checksum_update(&ctx, buf, got, pos);
|
||||
pos += got;
|
||||
}
|
||||
fclose(pf2);
|
||||
char emb_hex[OTPPAD_E_CHKSUM_HEX_LEN + 1];
|
||||
otppad_e_checksum_final(&ctx, pad, emb_hex); /* pad key = first 32 bytes */
|
||||
|
||||
if (strcmp(lib_hex, emb_hex) != 0) {
|
||||
printf("FAIL: checksum mismatch\n lib: %s\n emb: %s\n", lib_hex, emb_hex);
|
||||
failures++;
|
||||
} else {
|
||||
passes++;
|
||||
printf(" checksum: %s (match)\n", emb_hex);
|
||||
}
|
||||
|
||||
/* state write/read round-trip: embedded write, both read. */
|
||||
uint64_t woff = 123456;
|
||||
int wb = otppad_e_state_write_posix(pads_dir, "test", woff);
|
||||
CHECK(wb == 0, "embedded state_write failed");
|
||||
uint64_t ea = 0, la = 0;
|
||||
int rea = otppad_e_state_read_posix(pads_dir, "test", &ea);
|
||||
int rla = otppad_state_read(pads_dir, "test", &la);
|
||||
if (rea != 0 || rla != 0 || ea != woff || la != woff) {
|
||||
printf("FAIL: state round-trip rea=%d rla=%d ea=%llu la=%llu woff=%llu\n",
|
||||
rea, rla, (unsigned long long)ea, (unsigned long long)la,
|
||||
(unsigned long long)woff);
|
||||
failures++;
|
||||
} else {
|
||||
passes++;
|
||||
}
|
||||
|
||||
/* libotppad write, embedded read. */
|
||||
uint64_t woff2 = 999;
|
||||
int wl = otppad_state_write(pads_dir, "test", woff2);
|
||||
CHECK(wl == 0, "libotppad state_write failed");
|
||||
uint64_t eb = 0;
|
||||
int reb = otppad_e_state_read_posix(pads_dir, "test", &eb);
|
||||
if (reb != 0 || eb != woff2) {
|
||||
printf("FAIL: cross state read reb=%d eb=%llu\n", reb, (unsigned long long)eb);
|
||||
failures++;
|
||||
} else {
|
||||
passes++;
|
||||
}
|
||||
|
||||
free(pad);
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf %s", pads_dir);
|
||||
system(cmd);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("== otppad_embedded bit-compatibility test ==\n");
|
||||
test_base64();
|
||||
test_padme();
|
||||
test_armor();
|
||||
test_bin_header();
|
||||
test_checksum_and_state();
|
||||
printf("\n%d passed, %d failed\n", passes, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classical-only Teensy 4.1 n_signer test (no OTP, no PQ).
|
||||
|
||||
Runs the full classical + Nostr sequence in one uninterrupted boot and
|
||||
prints exactly which verb succeeded and which one crashed the device.
|
||||
After a crash, re-run to read the CrashReport from the next boot.
|
||||
|
||||
Usage:
|
||||
python3 firmware/teensy41/test_classical.py [--port /dev/ttyACM0]
|
||||
"""
|
||||
import serial, struct, json, time, sys, argparse, hashlib
|
||||
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
|
||||
def send_request(ser, req):
|
||||
payload = json.dumps(req).encode("utf-8")
|
||||
ser.write(struct.pack(">I", len(payload)) + payload)
|
||||
ser.flush()
|
||||
h = b""
|
||||
deadline = time.time() + 30.0
|
||||
while len(h) < 4 and time.time() < deadline:
|
||||
c = ser.read(4 - len(h))
|
||||
if c: h += c
|
||||
else: time.sleep(0.01)
|
||||
if len(h) < 4: raise TimeoutError("hdr timeout")
|
||||
n = struct.unpack(">I", h)[0]
|
||||
if n == 0 or n > 65536: raise ValueError("bad len %d" % n)
|
||||
p = b""
|
||||
while len(p) < n:
|
||||
c = ser.read(n - len(p))
|
||||
if c: p += c
|
||||
else: time.sleep(0.01)
|
||||
return json.loads(p.decode())
|
||||
|
||||
def call(ser, method, params=None, idx=[0]):
|
||||
idx[0] += 1
|
||||
r = {"jsonrpc": "2.0", "id": idx[0], "method": method}
|
||||
if params is not None: r["params"] = params
|
||||
print(f" -> {method} {json.dumps(params) if params else '[]'}", flush=True)
|
||||
resp = send_request(ser, r)
|
||||
ok = "result" in resp
|
||||
print(f" <- {'OK' if ok else 'ERR'} {str(resp)[:160]}", flush=True)
|
||||
return resp
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", default=DEFAULT_PORT)
|
||||
ap.add_argument("--read-crash", action="store_true", help="only drain and print boot/CrashReport text")
|
||||
args = ap.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, BAUD, timeout=2.0)
|
||||
time.sleep(5)
|
||||
boot = b""
|
||||
while ser.in_waiting:
|
||||
boot += ser.read(ser.in_waiting)
|
||||
if boot:
|
||||
print("=== BOOT OUTPUT ===")
|
||||
print(boot.decode("utf-8", errors="replace"))
|
||||
print("=== END BOOT ===")
|
||||
if args.read_crash:
|
||||
ser.close(); return 0
|
||||
|
||||
passed = 0; failed = 0
|
||||
def t(name, fn):
|
||||
nonlocal passed, failed
|
||||
print(f"\n[{name}]", flush=True)
|
||||
try:
|
||||
fn()
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f" !! CRASH at {name}: {e}", flush=True)
|
||||
failed += 1
|
||||
raise
|
||||
|
||||
try:
|
||||
t("get_info", lambda: call(ser, "get_info"))
|
||||
# classical pubkeys
|
||||
pubkeys = {}
|
||||
def gpk(alg):
|
||||
r = call(ser, "get_public_key", [{"algorithm": alg, "index": 0}])
|
||||
pubkeys[alg] = r["result"]["public_key"]
|
||||
t("gpk secp256k1", lambda: gpk("secp256k1"))
|
||||
t("gpk ed25519", lambda: gpk("ed25519"))
|
||||
t("gpk x25519", lambda: gpk("x25519"))
|
||||
# secp256k1 schnorr sign + verify
|
||||
msg = hashlib.sha256(b"test schnorr").hexdigest()
|
||||
sig = [None]
|
||||
def schnorr_sign():
|
||||
r = call(ser, "sign", [msg, {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
|
||||
sig[0] = r["result"]["signature"]
|
||||
t("sign schnorr", schnorr_sign)
|
||||
def schnorr_verify():
|
||||
call(ser, "verify", [msg, sig[0], {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
|
||||
t("verify schnorr", schnorr_verify)
|
||||
# secp256k1 ecdsa sign + verify
|
||||
msg2 = hashlib.sha256(b"test ecdsa").hexdigest()
|
||||
sig2 = [None]
|
||||
def ecdsa_sign():
|
||||
r = call(ser, "sign", [msg2, {"algorithm": "secp256k1", "index": 0, "scheme": "ecdsa"}])
|
||||
sig2[0] = r["result"]["signature"]
|
||||
t("sign ecdsa", ecdsa_sign)
|
||||
def ecdsa_verify():
|
||||
call(ser, "verify", [msg2, sig2[0], {"algorithm": "secp256k1", "index": 0, "scheme": "ecdsa"}])
|
||||
t("verify ecdsa", ecdsa_verify)
|
||||
# ed25519 sign + verify
|
||||
msg3 = b"test ed25519 message".hex()
|
||||
sig3 = [None]
|
||||
def ed_sign():
|
||||
r = call(ser, "sign", [msg3, {"algorithm": "ed25519", "index": 0}])
|
||||
sig3[0] = r["result"]["signature"]
|
||||
t("sign ed25519", ed_sign)
|
||||
def ed_verify():
|
||||
call(ser, "verify", [msg3, sig3[0], {"algorithm": "ed25519", "index": 0}])
|
||||
t("verify ed25519", ed_verify)
|
||||
# x25519 shared secret
|
||||
def x25519():
|
||||
call(ser, "derive_shared_secret", [pubkeys["x25519"], {"algorithm": "x25519", "index": 0}])
|
||||
t("derive_shared_secret x25519", x25519)
|
||||
# derive (HMAC)
|
||||
t("derive", lambda: call(ser, "derive", ["derive-test", {"algorithm": "secp256k1", "index": 1}]))
|
||||
# nostr
|
||||
npub = [None]
|
||||
def ngpk():
|
||||
r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}])
|
||||
npub[0] = r["result"]
|
||||
t("nostr_get_public_key", ngpk)
|
||||
def nse():
|
||||
ev = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello event"}
|
||||
call(ser, "nostr_sign_event", [ev, {"nostr_index": 0}])
|
||||
t("nostr_sign_event", nse)
|
||||
# nip04
|
||||
def nip04():
|
||||
r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", {"nostr_index": 0}])
|
||||
call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
|
||||
t("nip04 round-trip", nip04)
|
||||
# nip44
|
||||
def nip44():
|
||||
r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", {"nostr_index": 0}])
|
||||
call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
|
||||
t("nip44 round-trip", nip44)
|
||||
except Exception as e:
|
||||
print(f"\n!! STOPPED: {e}", flush=True)
|
||||
|
||||
print(f"\nRESULTS: {passed} passed, {failed} failed")
|
||||
ser.close()
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ml-dsa-65 sign test with soft-reboot diagnostic recovery.
|
||||
|
||||
Runs sign; on hang/timeout, triggers a Teensy soft-reboot via DTR/RTS toggle
|
||||
(which preserves DMAMEM) and reads the boot output for the reject-count
|
||||
diagnostic. Falls back to a hard reboot hint if DTR toggle doesn't work.
|
||||
"""
|
||||
import serial, struct, json, time, sys, argparse
|
||||
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def send_request(ser, req, timeout=180.0):
|
||||
payload = json.dumps(req).encode("utf-8")
|
||||
ser.write(struct.pack(">I", len(payload)) + payload)
|
||||
ser.flush()
|
||||
h = b""
|
||||
deadline = time.time() + timeout
|
||||
while len(h) < 4 and time.time() < deadline:
|
||||
c = ser.read(4 - len(h))
|
||||
if c:
|
||||
h += c
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
if len(h) < 4:
|
||||
raise TimeoutError("hdr timeout")
|
||||
n = struct.unpack(">I", h)[0]
|
||||
if n == 0 or n > 65536:
|
||||
raise ValueError("bad len %d" % n)
|
||||
p = b""
|
||||
while len(p) < n and time.time() < deadline:
|
||||
c = ser.read(n - len(p))
|
||||
if c:
|
||||
p += c
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
if len(p) < n:
|
||||
raise TimeoutError("body timeout")
|
||||
return json.loads(p.decode())
|
||||
|
||||
|
||||
def drain(ser, seconds=2.0):
|
||||
buf = b""
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
if ser.in_waiting:
|
||||
buf += ser.read(ser.in_waiting)
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
return buf.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def soft_reboot_and_read(port, wait_boot=8.0):
|
||||
"""Toggle DTR/RTS to trigger Teensy bootloader/reset; preserve DMAMEM.
|
||||
|
||||
Teensy 4.1: setting DTR low then high does not reboot; but a BREAK condition
|
||||
or the 1200-bps touch does. We try the 1200-bps open (Teensy bootloader
|
||||
trigger) — note this may or may not preserve DMAMEM. As a safer soft-reboot
|
||||
we instead pulse RTS/DTR which on some Teensy USB-CDC builds triggers a
|
||||
watchdog reset.
|
||||
"""
|
||||
# Attempt 1: 1200 bps touch (Teensy bootloader reset). This is a hard reset
|
||||
# but is the most reliable way to reboot a hung Teensy.
|
||||
try:
|
||||
s = serial.Serial(port, 1200, timeout=1.0)
|
||||
s.dtr = False
|
||||
s.rts = False
|
||||
time.sleep(0.1)
|
||||
s.dtr = True
|
||||
time.sleep(0.05)
|
||||
s.dtr = False
|
||||
s.close()
|
||||
except Exception as e:
|
||||
print(f" (1200bps touch failed: {e})", flush=True)
|
||||
time.sleep(1.0)
|
||||
# Reopen at normal baud and drain boot
|
||||
try:
|
||||
ser = serial.Serial(port, BAUD, timeout=2.0)
|
||||
except Exception as e:
|
||||
print(f" (reopen failed: {e}; retrying in 5s)", flush=True)
|
||||
time.sleep(5)
|
||||
ser = serial.Serial(port, BAUD, timeout=2.0)
|
||||
boot = drain(ser, seconds=wait_boot)
|
||||
ser.close()
|
||||
return boot
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", default=DEFAULT_PORT)
|
||||
ap.add_argument("--timeout", type=float, default=180.0)
|
||||
args = ap.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, BAUD, timeout=2.0)
|
||||
boot = drain(ser, seconds=8.0)
|
||||
print("=== BOOT OUTPUT (pre-sign) ===")
|
||||
print(boot)
|
||||
print("=== END BOOT ===", flush=True)
|
||||
|
||||
req = {"jsonrpc": "2.0", "id": 1, "method": "sign",
|
||||
"params": ["746573742065643235353139206d657373616765",
|
||||
{"algorithm": "ml-dsa-65", "index": 0}]}
|
||||
print(f"-> sign ml-dsa-65 (timeout={args.timeout}s)", flush=True)
|
||||
result = None
|
||||
try:
|
||||
resp = send_request(ser, req, timeout=args.timeout)
|
||||
ok = "result" in resp
|
||||
print(f"<- {'OK' if ok else 'ERR'} {str(resp)[:300]}", flush=True)
|
||||
result = "pass" if ok else "err"
|
||||
except Exception as e:
|
||||
print(f"!! HANG/TIMEOUT: {e}", flush=True)
|
||||
result = "hang"
|
||||
ser.close()
|
||||
|
||||
if result == "hang":
|
||||
print("\n=== Attempting soft reboot to read DMAMEM diagnostic ===", flush=True)
|
||||
boot = soft_reboot_and_read(args.port, wait_boot=10.0)
|
||||
print("=== BOOT OUTPUT (post-hang reboot) ===")
|
||||
print(boot)
|
||||
print("=== END BOOT ===", flush=True)
|
||||
# Extract reject count
|
||||
for line in boot.splitlines():
|
||||
if "REJECT COUNT" in line:
|
||||
print(f"\n>>> DIAGNOSTIC: {line.strip()}", flush=True)
|
||||
if "CRASH" in line:
|
||||
print(f"\n>>> CRASH: {line.strip()}", flush=True)
|
||||
return 2
|
||||
return 0 if result == "pass" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""test_otp_sd.py — OTP SD-card pad round-trip test for the Teensy 4.1 signer.
|
||||
|
||||
Tests the encrypt/decrypt verbs against the real SD-card pad:
|
||||
1. ASCII armor round-trip (encrypt -> decrypt -> recovered plaintext matches)
|
||||
2. Pad-Offset in the armor header starts at 32 (reserved header)
|
||||
3. Pad-ChkSum in the armor matches the bound pad
|
||||
4. Second encrypt advances the offset by the first chunk size
|
||||
5. Binary .otp round-trip (encrypt binary -> decrypt binary -> matches)
|
||||
6. Binary blob starts with OTP\0 magic + correct header
|
||||
7. Large plaintext (10 KB) round-trip (Padme bucket doubles to 16 KB)
|
||||
8. Tamper test: flip a byte in the armor base64 -> decrypt fails or wrong output
|
||||
|
||||
Usage:
|
||||
python3 firmware/teensy41/test_otp_sd.py [--port /dev/ttyACM0]
|
||||
"""
|
||||
|
||||
import serial
|
||||
import struct
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import argparse
|
||||
import base64
|
||||
import re
|
||||
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def send_request(ser, req: dict) -> dict:
|
||||
"""Send a JSON-RPC request with 4-byte big-endian length prefix, read response."""
|
||||
payload = json.dumps(req).encode("utf-8")
|
||||
header = struct.pack(">I", len(payload))
|
||||
ser.write(header + payload)
|
||||
ser.flush()
|
||||
|
||||
resp_header = b""
|
||||
deadline = time.time() + 60.0
|
||||
while len(resp_header) < 4 and time.time() < deadline:
|
||||
chunk = ser.read(4 - len(resp_header))
|
||||
if chunk:
|
||||
resp_header += chunk
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
if len(resp_header) < 4:
|
||||
raise TimeoutError("Timeout reading response header")
|
||||
|
||||
resp_len = struct.unpack(">I", resp_header)[0]
|
||||
if resp_len == 0 or resp_len > 65536:
|
||||
raise ValueError(f"Invalid response length: {resp_len}")
|
||||
|
||||
resp_payload = b""
|
||||
while len(resp_payload) < resp_len:
|
||||
chunk = ser.read(resp_len - len(resp_payload))
|
||||
if chunk:
|
||||
resp_payload += chunk
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
return json.loads(resp_payload.decode("utf-8"))
|
||||
|
||||
|
||||
def test_verb(ser, name, params=None, id_counter=[0]):
|
||||
id_counter[0] += 1
|
||||
req = {"jsonrpc": "2.0", "id": id_counter[0], "method": name}
|
||||
if params is not None:
|
||||
req["params"] = params
|
||||
|
||||
print(f"\n--- {name} ---")
|
||||
if params:
|
||||
# Don't print huge payloads
|
||||
display_params = []
|
||||
for p in params:
|
||||
if isinstance(p, str) and len(p) > 100:
|
||||
display_params.append(p[:80] + f"... ({len(p)} chars)")
|
||||
else:
|
||||
display_params.append(p)
|
||||
print(f" params: {json.dumps(display_params, indent=2)}")
|
||||
|
||||
try:
|
||||
resp = send_request(ser, req)
|
||||
except Exception as e:
|
||||
print(f" ❌ FAIL: {e}")
|
||||
return None
|
||||
|
||||
if "error" in resp:
|
||||
err = resp["error"]
|
||||
print(f" ❌ FAIL: error code={err.get('code')} message={err.get('message')}")
|
||||
return resp
|
||||
elif "result" in resp:
|
||||
result = resp["result"]
|
||||
# result is a JSON string — parse it
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result_obj = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
result_obj = result
|
||||
else:
|
||||
result_obj = result
|
||||
display = json.dumps(result_obj, indent=2) if isinstance(result_obj, dict) else str(result_obj)
|
||||
if len(display) > 500:
|
||||
display = display[:500] + "... (truncated)"
|
||||
print(f" ✅ PASS: {display}")
|
||||
return resp
|
||||
else:
|
||||
print(f" ❌ FAIL: no result or error: {resp}")
|
||||
return resp
|
||||
|
||||
|
||||
def parse_armor(armor_text):
|
||||
"""Parse ASCII armor to extract Pad-ChkSum, Pad-Offset, and base64 data."""
|
||||
chksum = None
|
||||
offset = None
|
||||
b64_lines = []
|
||||
in_data = False
|
||||
for line in armor_text.split("\n"):
|
||||
line = line.strip()
|
||||
if line == "-----BEGIN OTP MESSAGE-----":
|
||||
continue
|
||||
if line == "-----END OTP MESSAGE-----":
|
||||
break
|
||||
if line.startswith("Pad-ChkSum:"):
|
||||
chksum = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Pad-Offset:"):
|
||||
offset = int(line.split(":", 1)[1].strip())
|
||||
elif line.startswith("Version:"):
|
||||
continue
|
||||
elif line == "":
|
||||
in_data = True
|
||||
elif in_data:
|
||||
b64_lines.append(line)
|
||||
return chksum, offset, "".join(b64_lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Test Teensy 4.1 OTP SD pad")
|
||||
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial port")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Connecting to {args.port}...")
|
||||
ser = serial.Serial(args.port, BAUD, timeout=2.0)
|
||||
time.sleep(6) # wait for boot + SD mount + pad bind
|
||||
|
||||
# Drain boot messages
|
||||
boot = ""
|
||||
while ser.in_waiting:
|
||||
boot += ser.read(ser.in_waiting).decode("utf-8", errors="replace")
|
||||
if boot:
|
||||
print(f"Boot output:\n{boot}")
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
# ---- Test 1: ASCII armor round-trip ----
|
||||
print("\n=== Test 1: ASCII armor round-trip ===")
|
||||
plaintext = b"Hello, OTP SD card pad!"
|
||||
pt_b64 = base64.b64encode(plaintext).decode()
|
||||
|
||||
r = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
|
||||
if not r or "result" not in r:
|
||||
print("❌ encrypt failed")
|
||||
failed += 1
|
||||
ser.close()
|
||||
return 1
|
||||
|
||||
result = r["result"] if isinstance(r["result"], dict) else json.loads(r["result"])
|
||||
armor = result["ciphertext"]
|
||||
pad_chksum = result["pad_chksum"]
|
||||
off_before = int(result["pad_offset_before"])
|
||||
off_after = int(result["pad_offset_after"])
|
||||
|
||||
print(f" pad_chksum: {pad_chksum}")
|
||||
print(f" offset: {off_before} -> {off_after} (consumed {off_after - off_before} bytes)")
|
||||
|
||||
# Parse the armor
|
||||
armor_chksum, armor_offset, armor_b64 = parse_armor(armor)
|
||||
print(f" armor Pad-ChkSum: {armor_chksum}")
|
||||
print(f" armor Pad-Offset: {armor_offset}")
|
||||
|
||||
if armor_chksum != pad_chksum:
|
||||
print(f" ❌ FAIL: armor chksum {armor_chksum} != result chksum {pad_chksum}")
|
||||
failed += 1
|
||||
else:
|
||||
print(f" ✅ armor chksum matches")
|
||||
passed += 1
|
||||
|
||||
if armor_offset != off_before:
|
||||
print(f" ❌ FAIL: armor offset {armor_offset} != result offset_before {off_before}")
|
||||
failed += 1
|
||||
else:
|
||||
print(f" ✅ armor offset matches")
|
||||
passed += 1
|
||||
|
||||
# Decrypt
|
||||
r2 = test_verb(ser, "decrypt", [armor, {"encoding": "ascii"}])
|
||||
if not r2 or "result" not in r2:
|
||||
print("❌ decrypt failed")
|
||||
failed += 1
|
||||
ser.close()
|
||||
return 1
|
||||
|
||||
result2 = r2["result"] if isinstance(r2["result"], dict) else json.loads(r2["result"])
|
||||
recovered = base64.b64decode(result2["plaintext"])
|
||||
print(f" recovered: {recovered}")
|
||||
|
||||
if recovered == plaintext:
|
||||
print(f" ✅ ASCII round-trip SUCCESS")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ ASCII round-trip FAILED: expected {plaintext}, got {recovered}")
|
||||
failed += 1
|
||||
|
||||
# ---- Test 2: Offset advance ----
|
||||
print("\n=== Test 2: Offset advance ===")
|
||||
r3 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
|
||||
if r3 and "result" in r3:
|
||||
result3 = r3["result"] if isinstance(r3["result"], dict) else json.loads(r3["result"])
|
||||
off2_before = int(result3["pad_offset_before"])
|
||||
off2_after = int(result3["pad_offset_after"])
|
||||
print(f" second encrypt offset: {off2_before} -> {off2_after}")
|
||||
if off2_before == off_after:
|
||||
print(f" ✅ offset advanced from first encrypt's end ({off_after})")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ FAIL: expected offset_before={off_after}, got {off2_before}")
|
||||
failed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# ---- Test 3: Binary .otp round-trip ----
|
||||
print("\n=== Test 3: Binary .otp round-trip ===")
|
||||
# Reset offset to 32 for a clean binary test by re-binding
|
||||
# (We can't reset via the API, so just use the current offset)
|
||||
|
||||
r4 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "binary"}])
|
||||
if not r4 or "result" not in r4:
|
||||
print("❌ binary encrypt failed")
|
||||
failed += 1
|
||||
ser.close()
|
||||
return 1
|
||||
|
||||
result4 = r4["result"] if isinstance(r4["result"], dict) else json.loads(r4["result"])
|
||||
bin_b64 = result4["ciphertext"]
|
||||
bin_blob = base64.b64decode(bin_b64)
|
||||
print(f" binary blob size: {len(bin_blob)} bytes (header 58 + padded data {len(bin_blob) - 58})")
|
||||
|
||||
if bin_blob[:4] != b"OTP\0":
|
||||
print(f" ❌ FAIL: binary blob missing OTP magic: {bin_blob[:4]}")
|
||||
failed += 1
|
||||
else:
|
||||
print(f" ✅ binary blob has OTP magic")
|
||||
passed += 1
|
||||
|
||||
# Check header pad_chksum (bytes 6..38, binary)
|
||||
bin_chksum_bytes = bin_blob[6:38]
|
||||
bin_chksum_hex = bin_chksum_bytes.hex()
|
||||
if bin_chksum_hex == pad_chksum:
|
||||
print(f" ✅ binary header chksum matches")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ FAIL: binary header chksum {bin_chksum_hex} != {pad_chksum}")
|
||||
failed += 1
|
||||
|
||||
# Decrypt binary
|
||||
r5 = test_verb(ser, "decrypt", [bin_b64, {"encoding": "binary"}])
|
||||
if not r5 or "result" not in r5:
|
||||
print("❌ binary decrypt failed")
|
||||
failed += 1
|
||||
ser.close()
|
||||
return 1
|
||||
|
||||
result5 = r5["result"] if isinstance(r5["result"], dict) else json.loads(r5["result"])
|
||||
recovered2 = base64.b64decode(result5["plaintext"])
|
||||
print(f" recovered (binary): {recovered2}")
|
||||
|
||||
if recovered2 == plaintext:
|
||||
print(f" ✅ Binary round-trip SUCCESS")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ Binary round-trip FAILED: expected {plaintext}, got {recovered2}")
|
||||
failed += 1
|
||||
|
||||
# ---- Test 4: Large plaintext (2 KB, within 4 KB chunk cap) ----
|
||||
print("\n=== Test 4: Large plaintext (2 KB) ===")
|
||||
large_pt = bytes(range(256)) * 8 # 2048 bytes
|
||||
large_b64 = base64.b64encode(large_pt).decode()
|
||||
|
||||
r6 = test_verb(ser, "encrypt", [large_b64, {"encoding": "ascii"}])
|
||||
if r6 and "result" in r6:
|
||||
result6 = r6["result"] if isinstance(r6["result"], dict) else json.loads(r6["result"])
|
||||
large_off_before = int(result6["pad_offset_before"])
|
||||
large_off_after = int(result6["pad_offset_after"])
|
||||
large_consumed = large_off_after - large_off_before
|
||||
print(f" 10 KB plaintext: offset {large_off_before} -> {large_off_after} (consumed {large_consumed} bytes)")
|
||||
# Padme: 2 KB -> chunk = 4096 (minimum bucket, since 256*2^4=4096 >= 2048+1)
|
||||
if large_consumed == 4096:
|
||||
print(f" ✅ Padme bucket = 4096 (correct for 2 KB)")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ⚠️ Padme bucket = {large_consumed} (expected 16384)")
|
||||
# Not a hard fail — just note it
|
||||
|
||||
# Decrypt
|
||||
large_armor = result6["ciphertext"]
|
||||
r7 = test_verb(ser, "decrypt", [large_armor, {"encoding": "ascii"}])
|
||||
if r7 and "result" in r7:
|
||||
result7 = r7["result"] if isinstance(r7["result"], dict) else json.loads(r7["result"])
|
||||
large_recovered = base64.b64decode(result7["plaintext"])
|
||||
if large_recovered == large_pt:
|
||||
print(f" ✅ Large plaintext round-trip SUCCESS")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ Large plaintext round-trip FAILED (len {len(large_recovered)} vs {len(large_pt)})")
|
||||
failed += 1
|
||||
else:
|
||||
failed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# ---- Test 5: Tamper test ----
|
||||
print("\n=== Test 5: Tamper test ===")
|
||||
# Re-encrypt a small message for the tamper test
|
||||
r8 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
|
||||
if r8 and "result" in r8:
|
||||
result8 = r8["result"] if isinstance(r8["result"], dict) else json.loads(r8["result"])
|
||||
tamper_armor = result8["ciphertext"]
|
||||
|
||||
# Flip a character in the base64 data section
|
||||
lines = tamper_armor.split("\n")
|
||||
tampered = False
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r'^[A-Za-z0-9+/=]+$', line) and len(line) > 10:
|
||||
# Flip the first base64 char
|
||||
c = line[0]
|
||||
if c == 'A':
|
||||
lines[i] = 'B' + line[1:]
|
||||
else:
|
||||
lines[i] = 'A' + line[1:]
|
||||
tampered = True
|
||||
break
|
||||
tamper_armor = "\n".join(lines)
|
||||
|
||||
if tampered:
|
||||
r9 = test_verb(ser, "decrypt", [tamper_armor, {"encoding": "ascii"}])
|
||||
if r9 and "error" in r9:
|
||||
print(f" ✅ Tampered armor correctly rejected (error)")
|
||||
passed += 1
|
||||
elif r9 and "result" in r9:
|
||||
result9 = r9["result"] if isinstance(r9["result"], dict) else json.loads(r9["result"])
|
||||
tampered_recovered = base64.b64decode(result9["plaintext"])
|
||||
if tampered_recovered != plaintext:
|
||||
print(f" ✅ Tampered armor produced wrong plaintext (detected)")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ⚠️ Tampered armor still decrypted correctly (unlikely but possible if flip was in padding)")
|
||||
# Not a hard fail
|
||||
else:
|
||||
print(f" ❌ Tamper test: unexpected response")
|
||||
failed += 1
|
||||
else:
|
||||
print(f" ⚠️ Could not find base64 data to tamper")
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# ---- Summary ----
|
||||
print(f"\n{'='*50}")
|
||||
print(f"OTP SD pad test: {passed} passed, {failed} failed")
|
||||
print(f"{'='*50}")
|
||||
|
||||
ser.close()
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Profile each PQ verb ALONE on a fresh boot for the Teensy 4.1 n_signer.
|
||||
|
||||
For each test case:
|
||||
1. Reflash the firmware (arduino-cli upload) so the device is on a fresh boot.
|
||||
2. Open /dev/ttyACM0, wait 6s for boot, drain boot output
|
||||
(which includes any "LAST OP BEFORE CRASH" / CRASH REPORT from the
|
||||
previous fault, plus the boot banner).
|
||||
3. Send exactly ONE JSON-RPC request over the 4-byte big-endian length
|
||||
prefix wire protocol.
|
||||
4. Try to read the response with a generous timeout. If we get a valid
|
||||
response, record PASS. If we time out / get garbage, record CRASH
|
||||
and re-open the port to drain the *next* boot's diagnostics
|
||||
("LAST OP BEFORE CRASH: id=X seq=Y stack_hw_free=Z heap_free_at_crash=W"
|
||||
plus the CrashReport text).
|
||||
|
||||
Usage:
|
||||
python3 firmware/teensy41/test_pq_profile.py [--port /dev/ttyACM0]
|
||||
python3 firmware/teensy41/test_pq_profile.py --only ml-kem
|
||||
"""
|
||||
import serial, struct, json, time, sys, argparse, re, subprocess, os
|
||||
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
SIGNER_DIR = "firmware/teensy41/signer"
|
||||
FQBN = "teensy:avr:teensy41"
|
||||
|
||||
# OP_ID map (from signer.ino) for human-readable crash ids.
|
||||
OP_ID_NAMES = {
|
||||
1: "get_info", 2: "get_public_key", 3: "sign", 4: "verify",
|
||||
5: "derive_shared_secret", 6: "derive", 7: "nostr_get_public_key",
|
||||
8: "nostr_sign_event", 9: "nostr_mine_event", 10: "nip04",
|
||||
11: "nip44", 12: "encapsulate", 13: "decapsulate", 14: "otp",
|
||||
}
|
||||
|
||||
# Test cases. Each is ONE verb on a fresh boot.
|
||||
TESTS = [
|
||||
{
|
||||
"name": "ml-dsa-65 get_public_key",
|
||||
"method": "get_public_key",
|
||||
"params": [{"algorithm": "ml-dsa-65", "index": 0}],
|
||||
"expect_op_id": 2,
|
||||
},
|
||||
{
|
||||
"name": "slh-dsa-128s get_public_key",
|
||||
"method": "get_public_key",
|
||||
"params": [{"algorithm": "slh-dsa-128s", "index": 0}],
|
||||
"expect_op_id": 2,
|
||||
},
|
||||
{
|
||||
"name": "ml-kem-768 get_public_key",
|
||||
"method": "get_public_key",
|
||||
"params": [{"algorithm": "ml-kem-768", "index": 0}],
|
||||
"expect_op_id": 2,
|
||||
},
|
||||
{
|
||||
"name": "ml-dsa-65 sign",
|
||||
"method": "sign",
|
||||
"params": ["746573742065643235353139206d657373616765",
|
||||
{"algorithm": "ml-dsa-65", "index": 0}],
|
||||
"expect_op_id": 3,
|
||||
},
|
||||
{
|
||||
"name": "slh-dsa-128s sign",
|
||||
"method": "sign",
|
||||
"params": ["746573742065643235353139206d657373616765",
|
||||
{"algorithm": "slh-dsa-128s", "index": 0}],
|
||||
"expect_op_id": 3,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def wait_for_port(port, timeout=30.0, must_exist=True):
|
||||
"""Wait for the serial device node to (re)appear after a reboot/reflash."""
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
if os.path.exists(port):
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
if must_exist:
|
||||
print(f" [port] {port} did not appear within {timeout:.0f}s")
|
||||
return False
|
||||
|
||||
|
||||
def reflash(port, retries=3):
|
||||
"""Upload firmware and return True on success. Retries on failure
|
||||
(the device may be mid-reboot or the port held open)."""
|
||||
for attempt in range(1, retries + 1):
|
||||
print(f" [reflash] (attempt {attempt}/{retries}) arduino-cli upload -p {port} --fqbn {FQBN} {SIGNER_DIR}")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["arduino-cli", "upload", "-p", port, "--fqbn", FQBN, SIGNER_DIR],
|
||||
cwd=os.getcwd(), capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [reflash] exception: {e}")
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
if r.returncode == 0:
|
||||
print(f" [reflash] OK")
|
||||
return True
|
||||
# Common failure: device mid-reboot / port gone. Wait for it to
|
||||
# come back, then retry.
|
||||
print(f" [reflash] rc={r.returncode}")
|
||||
tail = (r.stdout + r.stderr)[-400:]
|
||||
if tail.strip():
|
||||
print(f" [reflash] tail: {tail.strip()}")
|
||||
# If the port is gone, wait for re-enumeration first.
|
||||
wait_for_port(port, timeout=15.0, must_exist=False)
|
||||
time.sleep(1.0)
|
||||
print(f" [reflash] FAILED after {retries} attempts")
|
||||
return False
|
||||
|
||||
|
||||
def open_port(port):
|
||||
return serial.Serial(port, BAUD, timeout=2.0)
|
||||
|
||||
|
||||
def drain(ser, label, seconds=2.0):
|
||||
end = time.time() + seconds
|
||||
buf = b""
|
||||
while time.time() < end:
|
||||
n = ser.in_waiting
|
||||
if n:
|
||||
buf += ser.read(n)
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
if buf:
|
||||
txt = buf.decode("utf-8", errors="replace")
|
||||
print(f"--- {label} ---")
|
||||
print(txt)
|
||||
print(f"--- end {label} ---")
|
||||
return txt
|
||||
return ""
|
||||
|
||||
|
||||
def send_request(ser, req, timeout=90.0):
|
||||
payload = json.dumps(req).encode("utf-8")
|
||||
ser.write(struct.pack(">I", len(payload)) + payload)
|
||||
ser.flush()
|
||||
h = b""
|
||||
deadline = time.time() + timeout
|
||||
while len(h) < 4 and time.time() < deadline:
|
||||
c = ser.read(4 - len(h))
|
||||
if c:
|
||||
h += c
|
||||
else:
|
||||
time.sleep(0.02)
|
||||
if len(h) < 4:
|
||||
raise TimeoutError("response header timeout")
|
||||
n = struct.unpack(">I", h)[0]
|
||||
if n == 0 or n > 65536:
|
||||
raise ValueError(f"bad response len {n} (raw hdr={h!r})")
|
||||
p = b""
|
||||
while len(p) < n and time.time() < deadline:
|
||||
c = ser.read(n - len(p))
|
||||
if c:
|
||||
p += c
|
||||
else:
|
||||
time.sleep(0.02)
|
||||
if len(p) < n:
|
||||
raise TimeoutError(f"response body timeout ({len(p)}/{n})")
|
||||
return json.loads(p.decode("utf-8"))
|
||||
|
||||
|
||||
LAST_OP_RE = re.compile(
|
||||
r"LAST OP BEFORE CRASH: id=(\d+)\s+seq=(\d+)\s+stack_hw_free=(\d+)\s+heap_free_at_crash=(\d+)"
|
||||
)
|
||||
|
||||
|
||||
def parse_last_op(text):
|
||||
m = LAST_OP_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
return {
|
||||
"op_id": int(m.group(1)),
|
||||
"op_name": OP_ID_NAMES.get(int(m.group(1)), "?"),
|
||||
"seq": int(m.group(2)),
|
||||
"stack_hw_free": int(m.group(3)),
|
||||
"heap_free_at_crash": int(m.group(4)),
|
||||
}
|
||||
|
||||
|
||||
def extract_crash_report(text):
|
||||
if "CRASH REPORT" not in text:
|
||||
return None
|
||||
idx = text.find("CRASH REPORT")
|
||||
# Capture up to the next "n_signer booting..." or 1500 chars.
|
||||
end = text.find("n_signer booting", idx)
|
||||
if end == -1:
|
||||
end = idx + 1500
|
||||
return text[idx:end].strip()
|
||||
|
||||
|
||||
def run_one_case(port, case):
|
||||
print(f"\n{'='*70}")
|
||||
print(f"TEST: {case['name']}")
|
||||
print(f" method={case['method']} params={json.dumps(case['params'])}")
|
||||
print(f"{'='*70}")
|
||||
result = {
|
||||
"case": case["name"],
|
||||
"method": case["method"],
|
||||
"params": case["params"],
|
||||
"outcome": None, # "PASS" | "CRASH" | "ERROR"
|
||||
"response": None,
|
||||
"boot_output": None,
|
||||
"crash_report": None,
|
||||
"last_op_before_crash": None,
|
||||
"next_boot_last_op": None,
|
||||
"next_boot_crash_report": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# 1. Reflash for a guaranteed fresh boot.
|
||||
if not reflash(port):
|
||||
result["outcome"] = "ERROR"
|
||||
result["error"] = "reflash failed"
|
||||
return result
|
||||
# 2. Wait for the port to re-enumerate after the reboot, then for
|
||||
# boot (auto-generate mnemonic + derive keys).
|
||||
if not wait_for_port(port, timeout=20.0):
|
||||
result["outcome"] = "ERROR"
|
||||
result["error"] = f"{port} did not re-appear after reflash"
|
||||
print(f" !! {port} did not re-appear after reflash")
|
||||
return result
|
||||
time.sleep(6)
|
||||
|
||||
ser = None
|
||||
for attempt in range(1, 6):
|
||||
try:
|
||||
ser = open_port(port)
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" !! open failed (attempt {attempt}/5): {e}")
|
||||
time.sleep(1.0)
|
||||
if ser is None:
|
||||
result["outcome"] = "ERROR"
|
||||
result["error"] = "open failed after 5 attempts"
|
||||
print(f" !! open failed after 5 attempts")
|
||||
return result
|
||||
|
||||
try:
|
||||
boot_txt = drain(ser, "BOOT OUTPUT", seconds=2.0)
|
||||
result["boot_output"] = boot_txt
|
||||
if "CRASH REPORT" in boot_txt:
|
||||
result["crash_report"] = extract_crash_report(boot_txt)
|
||||
lop = parse_last_op(boot_txt)
|
||||
if lop:
|
||||
result["last_op_before_crash"] = lop
|
||||
print(f" [boot] LAST OP: {lop}")
|
||||
|
||||
# 3. Send exactly one request.
|
||||
req = {"jsonrpc": "2.0", "id": 1, "method": case["method"],
|
||||
"params": case["params"]}
|
||||
print(f" -> send {case['method']}")
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = send_request(ser, req, timeout=90.0)
|
||||
dt = time.time() - t0
|
||||
result["response"] = resp
|
||||
if "result" in resp:
|
||||
result["outcome"] = "PASS"
|
||||
rstr = json.dumps(resp)
|
||||
print(f" <- OK in {dt:.1f}s ({len(rstr)} bytes): {rstr[:120]}...")
|
||||
else:
|
||||
result["outcome"] = "ERROR"
|
||||
print(f" <- ERR in {dt:.1f}s: {json.dumps(resp)[:200]}")
|
||||
except (TimeoutError, ValueError, Exception) as e:
|
||||
dt = time.time() - t0
|
||||
result["outcome"] = "CRASH"
|
||||
result["error"] = f"{type(e).__name__}: {e} (after {dt:.1f}s)"
|
||||
print(f" !! CRASH during {case['method']} after {dt:.1f}s: {e}")
|
||||
try:
|
||||
drain(ser, "PARTIAL OUTPUT AFTER CRASH", seconds=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Wait for the port to come back (Teensy CrashReport usually
|
||||
# auto-reboots; the device may briefly disappear).
|
||||
wait_for_port(port, timeout=20.0, must_exist=False)
|
||||
# Re-open IMMEDIATELY — the device's setup() only waits 3s
|
||||
# (while (!Serial && millis() < 3000)) for the host to open
|
||||
# the port before it stops buffering early boot prints. If we
|
||||
# sleep too long we miss the "LAST OP BEFORE CRASH" /
|
||||
# "CRASH REPORT" lines that are emitted at the very top of
|
||||
# setup().
|
||||
ser2 = None
|
||||
for attempt in range(1, 10):
|
||||
try:
|
||||
ser2 = open_port(port)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.3)
|
||||
if ser2 is None:
|
||||
result["error"] += " | next-boot port never came back"
|
||||
print(f" !! next-boot port never came back")
|
||||
return result
|
||||
try:
|
||||
# Drain immediately and keep draining for a while to
|
||||
# capture the full boot banner.
|
||||
next_txt = drain(ser2, "NEXT-BOOT OUTPUT", seconds=8.0)
|
||||
result["next_boot_last_op"] = parse_last_op(next_txt)
|
||||
result["next_boot_crash_report"] = extract_crash_report(next_txt)
|
||||
if result["next_boot_last_op"]:
|
||||
print(f" [next-boot] LAST OP: {result['next_boot_last_op']}")
|
||||
if result["next_boot_crash_report"]:
|
||||
print(f" [next-boot] CRASH REPORT:\n{result['next_boot_crash_report'][:600]}")
|
||||
else:
|
||||
print(f" [next-boot] NO CRASH REPORT / LAST OP line found in boot output")
|
||||
except Exception as e2:
|
||||
result["error"] += f" | next-boot drain failed: {e2}"
|
||||
print(f" !! next-boot drain failed: {e2}")
|
||||
finally:
|
||||
try:
|
||||
ser2.close()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
# After a successful verb, drain any trailing log output.
|
||||
drain(ser, "TRAILING OUTPUT", seconds=1.0)
|
||||
finally:
|
||||
try:
|
||||
ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", default=DEFAULT_PORT)
|
||||
ap.add_argument("--only", default=None,
|
||||
help="substring filter on case name; run only matching cases")
|
||||
args = ap.parse_args()
|
||||
|
||||
cases = TESTS
|
||||
if args.only:
|
||||
cases = [c for c in TESTS if args.only.lower() in c["name"].lower()]
|
||||
if not cases:
|
||||
print(f"No cases match --only {args.only!r}")
|
||||
return 1
|
||||
|
||||
print(f"Running {len(cases)} PQ profiling case(s) on {args.port}.")
|
||||
print("Each case reflashes the firmware first, so each verb runs on a fresh boot.")
|
||||
results = []
|
||||
for case in cases:
|
||||
r = run_one_case(args.port, case)
|
||||
results.append(r)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("SUMMARY")
|
||||
print(f"{'='*70}")
|
||||
for r in results:
|
||||
line = f" {r['case']:30s} -> {r['outcome']}"
|
||||
if r["next_boot_last_op"]:
|
||||
l = r["next_boot_last_op"]
|
||||
line += (f" | last_op={l['op_name']} seq={l['seq']} "
|
||||
f"stack_hw_free={l['stack_hw_free']} "
|
||||
f"heap_free_at_crash={l['heap_free_at_crash']}")
|
||||
elif r["last_op_before_crash"]:
|
||||
l = r["last_op_before_crash"]
|
||||
line += (f" | boot_last_op={l['op_name']} seq={l['seq']} "
|
||||
f"stack_hw_free={l['stack_hw_free']} "
|
||||
f"heap_free_at_crash={l['heap_free_at_crash']}")
|
||||
if r["error"]:
|
||||
line += f" | err={r['error'][:80]}"
|
||||
print(line)
|
||||
|
||||
with open("firmware/teensy41/pq_profile_results.json", "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nFull results written to firmware/teensy41/pq_profile_results.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Single-verb PQ test on a fresh boot.
|
||||
|
||||
Usage:
|
||||
python3 firmware/teensy41/test_pq_single.py --port /dev/ttyACM0 --verb kem-keygen
|
||||
python3 firmware/teensy41/test_pq_single.py --port /dev/ttyACM0 --verb dsa-sign
|
||||
python3 firmware/teensy41/test_pq_single.py --port /dev/ttyACM0 --read-boot
|
||||
"""
|
||||
import serial, struct, json, time, sys, argparse
|
||||
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def send_request(ser, req, timeout=180.0):
|
||||
payload = json.dumps(req).encode("utf-8")
|
||||
ser.write(struct.pack(">I", len(payload)) + payload)
|
||||
ser.flush()
|
||||
h = b""
|
||||
deadline = time.time() + timeout
|
||||
while len(h) < 4 and time.time() < deadline:
|
||||
c = ser.read(4 - len(h))
|
||||
if c:
|
||||
h += c
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
if len(h) < 4:
|
||||
raise TimeoutError("hdr timeout")
|
||||
n = struct.unpack(">I", h)[0]
|
||||
if n == 0 or n > 65536:
|
||||
raise ValueError("bad len %d" % n)
|
||||
p = b""
|
||||
while len(p) < n and time.time() < deadline:
|
||||
c = ser.read(n - len(p))
|
||||
if c:
|
||||
p += c
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
if len(p) < n:
|
||||
raise TimeoutError("body timeout")
|
||||
return json.loads(p.decode())
|
||||
|
||||
|
||||
def drain_boot(ser, wait=6.0):
|
||||
time.sleep(wait)
|
||||
boot = b""
|
||||
end = time.time() + 2.0
|
||||
while time.time() < end:
|
||||
if ser.in_waiting:
|
||||
boot += ser.read(ser.in_waiting)
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
return boot.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", default=DEFAULT_PORT)
|
||||
ap.add_argument("--verb", choices=["kem-keygen", "dsa-sign"])
|
||||
ap.add_argument("--read-boot", action="store_true")
|
||||
ap.add_argument("--timeout", type=float, default=180.0)
|
||||
args = ap.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, BAUD, timeout=2.0)
|
||||
boot = drain_boot(ser)
|
||||
print("=== BOOT OUTPUT ===")
|
||||
print(boot)
|
||||
print("=== END BOOT ===", flush=True)
|
||||
|
||||
if args.read_boot:
|
||||
ser.close()
|
||||
return 0
|
||||
|
||||
if args.verb == "kem-keygen":
|
||||
req = {"jsonrpc": "2.0", "id": 1, "method": "get_public_key",
|
||||
"params": [{"algorithm": "ml-kem-768", "index": 0}]}
|
||||
print(f"-> get_public_key ml-kem-768", flush=True)
|
||||
try:
|
||||
resp = send_request(ser, req, timeout=args.timeout)
|
||||
ok = "result" in resp
|
||||
print(f"<- {'OK' if ok else 'ERR'} {str(resp)[:300]}", flush=True)
|
||||
ser.close()
|
||||
return 0 if ok else 1
|
||||
except Exception as e:
|
||||
print(f"!! CRASH/TIMEOUT: {e}", flush=True)
|
||||
ser.close()
|
||||
return 2
|
||||
|
||||
if args.verb == "dsa-sign":
|
||||
req = {"jsonrpc": "2.0", "id": 1, "method": "sign",
|
||||
"params": ["746573742065643235353139206d657373616765",
|
||||
{"algorithm": "ml-dsa-65", "index": 0}]}
|
||||
print(f"-> sign ml-dsa-65", flush=True)
|
||||
try:
|
||||
resp = send_request(ser, req, timeout=args.timeout)
|
||||
ok = "result" in resp
|
||||
print(f"<- {'OK' if ok else 'ERR'} {str(resp)[:300]}", flush=True)
|
||||
ser.close()
|
||||
return 0 if ok else 1
|
||||
except Exception as e:
|
||||
print(f"!! HANG/TIMEOUT: {e}", flush=True)
|
||||
ser.close()
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,7 +3,16 @@
|
||||
|
||||
Tests all verbs: get_info, get_public_key (all 6 algorithms), sign (secp256k1,
|
||||
ed25519, ml-dsa-65, slh-dsa-128s), verify, encrypt/decrypt (OTP pad),
|
||||
encapsulate/decapsulate (ML-KEM-768), derive_shared_secret (X25519), derive.
|
||||
encapsulate/decapsulate (ML-KEM-768), derive_shared_secret (X25519), derive,
|
||||
nostr_get_public_key, nostr_sign_event, nostr_nip04_encrypt/decrypt,
|
||||
nostr_nip44_encrypt/decrypt.
|
||||
|
||||
Ordering note: the PQ keygens (ml-dsa-65, slh-dsa-128s, ml-kem-768) are
|
||||
heap-heavy on the Teensy 4.1 (~140 KB free heap). Running all three back to
|
||||
back can exhaust/fragment the heap and crash the device. To keep the suite
|
||||
useful, the classical + nostr verbs (the common case) are tested FIRST on a
|
||||
fresh heap, then the PQ verbs are tested LAST. If a PQ verb crashes the
|
||||
device, the earlier results still stand.
|
||||
|
||||
Sends requests in the canonical n_signer wire format (see api.md §4.3):
|
||||
params is a JSON ARRAY of positional arguments, with the options object
|
||||
@@ -22,10 +31,12 @@ import sys
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import base64
|
||||
|
||||
DEFAULT_PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def send_request(ser, req: dict) -> dict:
|
||||
"""Send a JSON-RPC request with 4-byte big-endian length prefix, read response."""
|
||||
payload = json.dumps(req).encode("utf-8")
|
||||
@@ -33,17 +44,14 @@ def send_request(ser, req: dict) -> dict:
|
||||
ser.write(header + payload)
|
||||
ser.flush()
|
||||
|
||||
# Read response header (4 bytes)
|
||||
resp_header = b""
|
||||
while len(resp_header) < 4:
|
||||
deadline = time.time() + 30.0
|
||||
while len(resp_header) < 4 and time.time() < deadline:
|
||||
chunk = ser.read(4 - len(resp_header))
|
||||
if chunk:
|
||||
resp_header += chunk
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
if len(resp_header) == 0 and time.time() % 5 < 0.1:
|
||||
continue
|
||||
|
||||
if len(resp_header) < 4:
|
||||
raise TimeoutError("Timeout reading response header")
|
||||
|
||||
@@ -51,7 +59,6 @@ def send_request(ser, req: dict) -> dict:
|
||||
if resp_len == 0 or resp_len > 65536:
|
||||
raise ValueError(f"Invalid response length: {resp_len}")
|
||||
|
||||
# Read response payload
|
||||
resp_payload = b""
|
||||
while len(resp_payload) < resp_len:
|
||||
chunk = ser.read(resp_len - len(resp_payload))
|
||||
@@ -59,14 +66,10 @@ def send_request(ser, req: dict) -> dict:
|
||||
resp_payload += chunk
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
|
||||
return json.loads(resp_payload.decode("utf-8"))
|
||||
|
||||
def test_verb(ser, name, params=None, id_counter=[0]):
|
||||
"""Send a request and return the result. Prints pass/fail.
|
||||
|
||||
params must be a JSON array (the canonical n_signer wire format) or None.
|
||||
"""
|
||||
def test_verb(ser, name, params=None, id_counter=[0]):
|
||||
id_counter[0] += 1
|
||||
req = {"jsonrpc": "2.0", "id": id_counter[0], "method": name}
|
||||
if params is not None:
|
||||
@@ -86,7 +89,6 @@ def test_verb(ser, name, params=None, id_counter=[0]):
|
||||
return resp
|
||||
elif "result" in resp:
|
||||
result = resp["result"]
|
||||
# Truncate long values for display
|
||||
display = json.dumps(result, indent=2)
|
||||
if len(display) > 500:
|
||||
display = display[:500] + "... (truncated)"
|
||||
@@ -96,6 +98,7 @@ def test_verb(ser, name, params=None, id_counter=[0]):
|
||||
print(f" ❌ FAIL: no result or error in response: {resp}")
|
||||
return resp
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Test Teensy 4.1 n_signer")
|
||||
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial port (default: /dev/ttyACM0)")
|
||||
@@ -103,7 +106,7 @@ def main():
|
||||
|
||||
print(f"Connecting to {args.port}...")
|
||||
ser = serial.Serial(args.port, BAUD, timeout=2.0)
|
||||
time.sleep(2) # Wait for the device to be ready (auto-generate boot)
|
||||
time.sleep(5) # wait for auto-generate boot + key derivation
|
||||
|
||||
# Drain any boot messages
|
||||
while ser.in_waiting:
|
||||
@@ -113,17 +116,16 @@ def main():
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
pubkeys = {}
|
||||
|
||||
# 1. get_info
|
||||
r = test_verb(ser, "get_info")
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
|
||||
# 2. get_public_key for each algorithm
|
||||
# Canonical format: params = [ { "algorithm": "<alg>", "index": 0 } ]
|
||||
algorithms = ["secp256k1", "ed25519", "x25519", "ml-dsa-65", "slh-dsa-128s", "ml-kem-768"]
|
||||
pubkeys = {}
|
||||
for alg in algorithms:
|
||||
# 2. get_public_key for classical algorithms (lightweight, tested first)
|
||||
classical_algs = ["secp256k1", "ed25519", "x25519"]
|
||||
for alg in classical_algs:
|
||||
r = test_verb(ser, "get_public_key", [{"algorithm": alg, "index": 0}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
@@ -132,10 +134,8 @@ def main():
|
||||
failed += 1
|
||||
|
||||
# 3. sign + verify (secp256k1 schnorr)
|
||||
# Canonical format: params = [ "<msg_hex>", { "algorithm": "secp256k1", "index": 0, "scheme": "schnorr" } ]
|
||||
msg32 = hashlib.sha256(b"test message for signing").digest()
|
||||
msg_hex = msg32.hex()
|
||||
|
||||
r = test_verb(ser, "sign", [msg_hex, {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
|
||||
sig_secp = None
|
||||
if r and "result" in r:
|
||||
@@ -145,7 +145,6 @@ def main():
|
||||
failed += 1
|
||||
|
||||
if sig_secp and "secp256k1" in pubkeys:
|
||||
# verify: params = [ "<msg_hex>", "<sig_hex>", { "algorithm": "secp256k1", "index": 0, "scheme": "schnorr" } ]
|
||||
r = test_verb(ser, "verify", [msg_hex, sig_secp, {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
|
||||
if r and "result" in r and r["result"].get("valid") in (True, "true"):
|
||||
passed += 1
|
||||
@@ -157,10 +156,8 @@ def main():
|
||||
failed += 1
|
||||
|
||||
# 4. sign (ed25519)
|
||||
# Canonical format: params = [ "<msg_hex>", { "algorithm": "ed25519", "index": 0 } ]
|
||||
msg_raw = b"test ed25519 message"
|
||||
msg_raw_hex = msg_raw.hex()
|
||||
|
||||
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "ed25519", "index": 0}])
|
||||
sig_ed = None
|
||||
if r and "result" in r:
|
||||
@@ -180,40 +177,26 @@ def main():
|
||||
print(" ⏭️ SKIP: no signature or pubkey")
|
||||
failed += 1
|
||||
|
||||
# 5. sign (ml-dsa-65)
|
||||
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "ml-dsa-65", "index": 0}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# 6. sign (slh-dsa-128s) — this is slow (~1-2s on Teensy)
|
||||
print("\n--- sign (slh-dsa-128s) — may take 1-2 seconds ---")
|
||||
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "slh-dsa-128s", "index": 0}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# 7. encrypt + decrypt (OTP pad)
|
||||
# Canonical format: params = [ "<payload_b64>", { "algorithm": "otp" } ]
|
||||
# The OTP encrypt/decrypt verbs take a base64 payload and XOR it against
|
||||
# the bound OTP pad, returning the base64 result + pad offset.
|
||||
# 5. encrypt + decrypt (OTP pad)
|
||||
plaintext = b"Hello, OTP pad encryption test!"
|
||||
pt_b64 = __import__("base64").b64encode(plaintext).decode()
|
||||
|
||||
pt_b64 = base64.b64encode(plaintext).decode()
|
||||
r = test_verb(ser, "encrypt", [pt_b64, {"algorithm": "otp"}])
|
||||
ct_b64 = None
|
||||
pad_off_before = None
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
ct_b64 = r["result"].get("result", "")
|
||||
# The pad offset where this ciphertext's pad slice begins. decrypt
|
||||
# must rewind to this offset so the same pad bytes are reused.
|
||||
pad_off_before = r["result"].get("pad_offset_before")
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if ct_b64:
|
||||
r = test_verb(ser, "decrypt", [ct_b64, {"algorithm": "otp"}])
|
||||
if ct_b64 and pad_off_before is not None:
|
||||
r = test_verb(ser, "decrypt",
|
||||
[ct_b64, {"algorithm": "otp", "pad_offset": int(pad_off_before)}])
|
||||
if r and "result" in r:
|
||||
pt_result = __import__("base64").b64decode(r["result"].get("result", ""))
|
||||
pt_result = base64.b64decode(r["result"].get("result", ""))
|
||||
if pt_result == plaintext:
|
||||
print(f" ✅ decrypt matches original plaintext")
|
||||
passed += 1
|
||||
@@ -227,10 +210,102 @@ def main():
|
||||
print(" ⏭️ SKIP: no ciphertext")
|
||||
failed += 1
|
||||
|
||||
# 8. encapsulate + decapsulate (ML-KEM-768)
|
||||
# Canonical format:
|
||||
# encapsulate: params = [ "<peer_pub_hex>", { "algorithm": "ml-kem-768", "index": 0 } ]
|
||||
# decapsulate: params = [ "<ct_hex>", { "algorithm": "ml-kem-768", "index": 0 } ]
|
||||
# 6. derive_shared_secret (X25519)
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
peer_priv = X25519PrivateKey.generate()
|
||||
peer_pub = peer_priv.public_key()
|
||||
peer_pub_hex = peer_pub.public_bytes_raw().hex()
|
||||
r = test_verb(ser, "derive_shared_secret", [peer_pub_hex, {"algorithm": "x25519", "index": 0}])
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
except ImportError:
|
||||
print("\n--- derive_shared_secret (x25519) ---")
|
||||
print(" ⏭️ SKIP: 'cryptography' module not installed")
|
||||
failed += 1
|
||||
|
||||
# 7. derive (secp256k1 at index 1)
|
||||
r = test_verb(ser, "derive", ["derive-test-data", {"algorithm": "secp256k1", "index": 1}])
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
|
||||
# 8. nostr_get_public_key
|
||||
r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}])
|
||||
nostr_pub = None
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
nostr_pub = r["result"]
|
||||
if isinstance(nostr_pub, dict):
|
||||
nostr_pub = nostr_pub.get("public_key", "")
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# 9. nostr_sign_event
|
||||
if nostr_pub:
|
||||
event = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello from test_signer"}
|
||||
r = test_verb(ser, "nostr_sign_event", [event, {"nostr_index": 0}])
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
|
||||
# 10. nostr_nip04_encrypt + decrypt (the bug we fixed)
|
||||
if nostr_pub:
|
||||
nip04_pt = "hello via nip04"
|
||||
r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, {"nostr_index": 0}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
cipher = r["result"]
|
||||
r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, {"nostr_index": 0}])
|
||||
if r and "result" in r and r["result"] == nip04_pt:
|
||||
print(f" ✅ nip04 round-trip plaintext recovered")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ nip04 round-trip mismatch")
|
||||
failed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# 11. nostr_nip44_encrypt + decrypt (the is_nip44 dispatch bug we fixed)
|
||||
if nostr_pub:
|
||||
nip44_pt = "hello via nip44"
|
||||
r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, {"nostr_index": 0}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
cipher44 = r["result"]
|
||||
r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, {"nostr_index": 0}])
|
||||
if r and "result" in r and r["result"] == nip44_pt:
|
||||
print(f" ✅ nip44 round-trip plaintext recovered")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ nip44 round-trip mismatch")
|
||||
failed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# ---- PQ verbs (tested LAST: heap-heavy, may crash the device) ----
|
||||
print("\n=== PQ verbs (heap-heavy; tested last) ===")
|
||||
|
||||
# 12. get_public_key for PQ algorithms
|
||||
pq_algs = ["ml-dsa-65", "slh-dsa-128s", "ml-kem-768"]
|
||||
for alg in pq_algs:
|
||||
r = test_verb(ser, "get_public_key", [{"algorithm": alg, "index": 0}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
pubkeys[alg] = r["result"].get("public_key", "")
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# 13. sign (ml-dsa-65)
|
||||
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "ml-dsa-65", "index": 0}])
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
|
||||
# 14. sign (slh-dsa-128s) — slow (~1-2s on Teensy)
|
||||
print("\n--- sign (slh-dsa-128s) — may take 1-2 seconds ---")
|
||||
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "slh-dsa-128s", "index": 0}])
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
|
||||
# 15. encapsulate + decapsulate (ML-KEM-768)
|
||||
if "ml-kem-768" in pubkeys:
|
||||
r = test_verb(ser, "encapsulate", [pubkeys["ml-kem-768"], {"algorithm": "ml-kem-768", "index": 0}])
|
||||
ct_kem = None
|
||||
@@ -263,35 +338,6 @@ def main():
|
||||
print(" ⏭️ SKIP: no ml-kem-768 pubkey")
|
||||
failed += 1
|
||||
|
||||
# 9. derive_shared_secret (X25519) — need a peer pubkey
|
||||
# Canonical format: params = [ "<peer_pub_hex>", { "algorithm": "x25519", "index": 0 } ]
|
||||
# The peer pubkey is 32 raw bytes -> 64 hex chars.
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
peer_priv = X25519PrivateKey.generate()
|
||||
peer_pub = peer_priv.public_key()
|
||||
peer_pub_bytes = peer_pub.public_bytes_raw()
|
||||
peer_pub_hex = peer_pub_bytes.hex()
|
||||
|
||||
r = test_verb(ser, "derive_shared_secret", [peer_pub_hex, {"algorithm": "x25519", "index": 0}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
except ImportError:
|
||||
print("\n--- derive_shared_secret (x25519) ---")
|
||||
print(" ⏭️ SKIP: 'cryptography' module not installed")
|
||||
failed += 1
|
||||
|
||||
# 10. derive (secp256k1 at index 1)
|
||||
# Canonical format: params = [ "<data>", { "algorithm": "secp256k1", "index": 1 } ]
|
||||
# index is REQUIRED for derive (no default).
|
||||
r = test_verb(ser, "derive", ["derive-test-data", {"algorithm": "secp256k1", "index": 1}])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print(f"RESULTS: {passed} passed, {failed} failed, {passed+failed} total")
|
||||
@@ -300,5 +346,6 @@ def main():
|
||||
ser.close()
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+23
-21
@@ -179,7 +179,8 @@ build_release_binary() {
|
||||
fi
|
||||
|
||||
# Prevent stale artifacts from previous builds being uploaded.
|
||||
rm -f build/nsigner_static_x86_64 build/nsigner_static_arm64
|
||||
rm -f build/nsigner_static_x86_64 build/nsigner_static_arm64 \
|
||||
build/nsigner_client_static_x86_64 build/nsigner_client_static_arm64
|
||||
|
||||
print_status "Building x86_64 static binary (this may take a few minutes with PQ algorithms)..."
|
||||
./build_static.sh 2>&1 | tail -5 || return 1
|
||||
@@ -251,6 +252,8 @@ upload_release_assets() {
|
||||
local binary_path_x86="$2"
|
||||
local tarball_path="$3"
|
||||
local binary_path_arm64="$4"
|
||||
local client_path_x86="${5:-}"
|
||||
local client_path_arm64="${6:-}"
|
||||
|
||||
if [[ ! -f "$HOME/.gitea_token" ]]; then
|
||||
print_warning "No ~/.gitea_token found. Skipping asset uploads."
|
||||
@@ -262,26 +265,23 @@ upload_release_assets() {
|
||||
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/n_signer"
|
||||
local assets_url="$api_url/releases/$release_id/assets"
|
||||
|
||||
if [[ -f "$binary_path_x86" ]]; then
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$binary_path_x86;filename=$(basename "$binary_path_x86")" \
|
||||
-F "name=$(basename "$binary_path_x86")" > /dev/null
|
||||
fi
|
||||
# Helper to upload a single asset
|
||||
upload_asset() {
|
||||
local path="$1"
|
||||
if [[ -f "$path" ]]; then
|
||||
print_status "Uploading $(basename "$path")..."
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$path;filename=$(basename "$path")" \
|
||||
-F "name=$(basename "$path")" > /dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -f "$binary_path_arm64" ]]; then
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$binary_path_arm64;filename=$(basename "$binary_path_arm64")" \
|
||||
-F "name=$(basename "$binary_path_arm64")" > /dev/null
|
||||
fi
|
||||
|
||||
if [[ -f "$tarball_path" ]]; then
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")" \
|
||||
-F "name=$(basename "$tarball_path")" > /dev/null
|
||||
fi
|
||||
upload_asset "$binary_path_x86"
|
||||
upload_asset "$binary_path_arm64"
|
||||
upload_asset "$client_path_x86"
|
||||
upload_asset "$client_path_arm64"
|
||||
upload_asset "$tarball_path"
|
||||
}
|
||||
|
||||
main() {
|
||||
@@ -306,6 +306,8 @@ main() {
|
||||
|
||||
local binary_path_x86="build/nsigner_static_x86_64"
|
||||
local binary_path_arm64="build/nsigner_static_arm64"
|
||||
local client_path_x86="build/nsigner_client_static_x86_64"
|
||||
local client_path_arm64="build/nsigner_client_static_arm64"
|
||||
local tarball_path=""
|
||||
tarball_path=$(create_source_tarball || true)
|
||||
|
||||
@@ -313,7 +315,7 @@ main() {
|
||||
release_id=$(create_gitea_release || true)
|
||||
|
||||
if [[ -n "$release_id" ]]; then
|
||||
upload_release_assets "$release_id" "$binary_path_x86" "$tarball_path" "$binary_path_arm64"
|
||||
upload_release_assets "$release_id" "$binary_path_x86" "$tarball_path" "$binary_path_arm64" "$client_path_x86" "$client_path_arm64"
|
||||
fi
|
||||
|
||||
print_success "Release flow completed"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Audit: n_signer Breaking Changes vs Client Repos
|
||||
|
||||
## 1. The breaking changes made to n_signer
|
||||
|
||||
Three changes on the n_signer wire protocol are breaking for every existing
|
||||
client. All three are landed in `src/` and documented in `README.md` §4.
|
||||
|
||||
### 1.1 Verb renames (legacy names removed)
|
||||
|
||||
Source: [`plans/legacy_verb_aliases.md`](legacy_verb_aliases.md) — COMPLETED.
|
||||
|
||||
| Old wire verb | New wire verb |
|
||||
|--------------------|----------------------------|
|
||||
| `sign_event` | `nostr_sign_event` |
|
||||
| `mine_event` | `nostr_mine_event` |
|
||||
| `nip04_encrypt` | `nostr_nip04_encrypt` |
|
||||
| `nip04_decrypt` | `nostr_nip04_decrypt` |
|
||||
| `nip44_encrypt` | `nostr_nip44_encrypt` |
|
||||
| `nip44_decrypt` | `nostr_nip44_decrypt` |
|
||||
| `get_public_key` (role branch) | `nostr_get_public_key` |
|
||||
|
||||
The role-based `get_public_key` was split: algorithm-based stays
|
||||
`get_public_key`; Nostr-protocol key selection is now `nostr_get_public_key`.
|
||||
The old alias names are **gone** — no shim, no fallthrough.
|
||||
|
||||
### 1.2 Selector model rewrite (nostr_index / index removed for nostr verbs)
|
||||
|
||||
Source: [`plans/role_path_authorization.md`](role_path_authorization.md).
|
||||
|
||||
- `nostr_index` selector → **removed**, rejected with error `2006
|
||||
nostr_index_deprecated` (see [`src/dispatcher.c`](../src/dispatcher.c:1815)).
|
||||
- `index` on `nostr_*` verbs → **removed**, rejected with `2007
|
||||
index_deprecated`.
|
||||
- The **only** accepted selector for `nostr_*` verbs is now `{"role":"<name>",
|
||||
"role_path":"<full-path>"}` sent **together**. Either field alone is
|
||||
rejected: `2008 role_required` / `2009 path_required`
|
||||
([`README.md`](../README.md) §4.6).
|
||||
- No backward compatibility. `--nostr-index` / `--index` on the client CLI are
|
||||
removed; replaced by `--role` + `--path`.
|
||||
|
||||
### 1.3 OTP encoding values changed
|
||||
|
||||
`encrypt` / `decrypt` (algorithm `otp`) now take `encoding` =
|
||||
`"ascii"` (ASCII-armored, default) or `"binary"` (base64 raw `.otp` blob)
|
||||
([`src/dispatcher.c`](../src/dispatcher.c:1452), [`src/otp_pad.c`](../src/otp_pad.c:347)).
|
||||
|
||||
Note: [`client/n_signer_client.c`](../client/n_signer_client.c:51) help text
|
||||
still advertises `--encoding <base64|hex>` — that is a **stale doc string**
|
||||
inside n_signer's own client and should be fixed to `ascii|binary`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Are these reflected in the nostr_core_lib repo? — NO
|
||||
|
||||
`nostr_core_lib` is the shared client library that every C-based n_signer
|
||||
client links against. It is **out of date** and will fail against current
|
||||
n_signer. Specific gaps:
|
||||
|
||||
### 2.1 Still emits the removed `nostr_index` selector
|
||||
|
||||
[`nostr_core_lib/nostr_core/nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c:309)
|
||||
`signer_remote_params_with_selector()` emits `{"nostr_index":N}` when set
|
||||
(lines 320–327). n_signer now rejects this with `2006 nostr_index_deprecated`.
|
||||
|
||||
The public API
|
||||
[`nostr_signer_nsigner_set_nostr_index()`](../../nostr_core_lib/nostr_core/nostr_signer.c:858)
|
||||
still exists and is the documented way to select a key — it is now a dead end.
|
||||
|
||||
### 2.2 Sends `role` without `role_path`
|
||||
|
||||
When `nostr_index` is not set, the same helper emits only `{"role":"..."}`
|
||||
(line 341) with no `role_path`. n_signer now requires both and rejects
|
||||
role-only with `2009 path_required`.
|
||||
|
||||
The `nostr_signer_nsigner_*` factory constructors
|
||||
([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h:51)) take a
|
||||
single `const char* role` parameter — there is no way to pass a `role_path`
|
||||
through the high-level API at all.
|
||||
|
||||
### 2.3 `derive` (HMAC) path is half-broken
|
||||
|
||||
[`nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c:560) builds
|
||||
`{"algorithm":"secp256k1","index":N}` for the `derive` verb. The
|
||||
algorithm-based `derive` verb still accepts `index`, so the `nostr_index` branch
|
||||
works. But the `role`-only branch (line 563) sends `{"role":"..."}` with no
|
||||
`index` — `derive` requires `index` and will reject it.
|
||||
|
||||
### 2.4 Documentation is stale
|
||||
|
||||
[`NSIGNER_INTEGRATION.md`](../../nostr_core_lib/nostr_core/NSIGNER_INTEGRATION.md:123)
|
||||
still tells integrators to use `nostr_index` and `role`-only selectors, and
|
||||
[`plans/nostr_core_lib_client_updates.md`](../../nostr_core_lib/plans/nostr_core_lib_client_updates.md)
|
||||
proposes `nostr_index` support as the chosen design — both predate the
|
||||
selector rewrite.
|
||||
|
||||
### 2.5 What needs to change in nostr_core_lib
|
||||
|
||||
1. Replace the `role`-only + `nostr_index` selector model with a combined
|
||||
`role` + `role_path` selector. Concretely: change the `nostr_signer_nsigner_*`
|
||||
constructors (or add new ones / a selector struct) to accept both a role
|
||||
name and a full path.
|
||||
2. Remove `nostr_signer_nsigner_set_nostr_index` (or repurpose it to set
|
||||
`role` + `role_path` from an index by expanding the NIP-06 template
|
||||
`m/44'/1237'/N'/0/0` client-side).
|
||||
3. Update `signer_remote_params_with_selector` to always emit both `role` and
|
||||
`role_path`.
|
||||
4. Fix the `derive` remote path to always include `index`.
|
||||
5. Update `NSIGNER_INTEGRATION.md`, `nostr_core_lib_client_updates.md`, and
|
||||
`tests/nsigner_client_test.c` (which sends `nostr_get_public_key` with a
|
||||
`nostr_index` selector at line 297).
|
||||
|
||||
---
|
||||
|
||||
## 3. Repos in ~/lt/ that need client edits
|
||||
|
||||
### Tier 1 — Direct n_signer wire clients (BROKEN now)
|
||||
|
||||
These talk the n_signer JSON-RPC protocol directly and will fail against
|
||||
current n_signer:
|
||||
|
||||
| Repo | Files | Problem |
|
||||
|------|-------|---------|
|
||||
| **nostr_core_lib** | `nostr_core/nostr_signer.c`, `nostr_signer.h`, `nsigner_client.c`, `NSIGNER_INTEGRATION.md`, `tests/nsigner_client_test.c`, `examples/note_poster.c` | Emits removed `nostr_index`; sends `role` without `role_path`. Shared lib — fixing this fixes all C clients that link it. |
|
||||
| **nostr_terminal** | `src/nsigner_client.c`, `include/nsigner_client.h`, `src/signer.c`, `src/menu_login.c`, `src/menu_profile.c`, `plans/n_signer_integration.md` | Has its own hand-rolled `nsigner_client` that sends `{"nostr_index":N}` ([`nsigner_client.c`](../../nostr_terminal/src/nsigner_client.c:617)). Selector struct is `has_nostr_index`/`nostr_index`/`role` with no `role_path`. Login menu prompts for "index" only. |
|
||||
| **sovereign_browser** | `src/login_dialog.c`, `src/agent_login.c`, `src/key_store.c`, `src/key_store.h` | Uses `nostr_signer_nsigner_*` from nostr_core_lib + `nostr_signer_nsigner_set_nostr_index`. UI has a nostr_index spin button. Breaks via the lib, and the UI needs a role+path input. |
|
||||
| **laantungir_website** | `scripts/publish_nostr.js`, `scripts/get_nsigner_pubkey.js` | Raw JSON-RPC over qrexec sending `{"nostr_index": N}` ([`publish_nostr.js`](../../laantungir_website/scripts/publish_nostr.js:103)). Will get `2006`. |
|
||||
|
||||
### Tier 2 — Indirect (breaks once Tier 1 lib is fixed, or uses nostr_core_lib local signing only)
|
||||
|
||||
| Repo | Status | Action |
|
||||
|------|--------|--------|
|
||||
| **n_signer** (this repo) | `client/n_signer_client.c` help text says `--encoding <base64\|hex>` but server wants `ascii\|binary`; the client itself already uses `--role`+`--path` correctly per [`role_path_authorization.md`](role_path_authorization.md). | Fix the stale `--encoding` help string. |
|
||||
|
||||
### Not affected (use local nostr_core_lib signing, not n_signer remote)
|
||||
|
||||
These call `nostr_create_and_sign_event` / `nostr_signer_local` with a local
|
||||
private key — they do not speak the n_signer wire protocol and are unaffected:
|
||||
|
||||
- `open_wire` (local `sign_event` helper, not n_signer RPC)
|
||||
- `raspberry_pi_zero_nostr` (local `nostr_create_and_sign_event`)
|
||||
- `esp32_playground` (local `nostr_create_and_sign_event`)
|
||||
|
||||
### Not affected (NIP-46 to arbitrary remote signers, not n_signer)
|
||||
|
||||
These use NIP-46 method names (`sign_event`, `nip04_encrypt`, …) per the NIP-46
|
||||
spec, targeting generic remote signers / browser extensions — not n_signer's
|
||||
renamed verbs. No change needed unless they specifically add an n_signer
|
||||
backend:
|
||||
|
||||
- `primal-web-app` (`src/lib/nip46/nip46.ts`)
|
||||
- `super_ball` (`web/nostr.bundle.js`)
|
||||
- `nips` (spec docs)
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended remediation order
|
||||
|
||||
1. **nostr_core_lib** first — it is the shared dependency. Introduce a
|
||||
`role` + `role_path` selector (struct or new constructors), remove
|
||||
`nostr_index` emission, fix `derive`, update tests + integration doc.
|
||||
2. **sovereign_browser** — update login UI to collect role + path instead of
|
||||
index; switch to the new nostr_core_lib API.
|
||||
3. **nostr_terminal** — rewrite its hand-rolled `nsigner_client` selector to
|
||||
`role` + `role_path`; update login/profile menus and the integration plan.
|
||||
4. **laantungir_website** — switch the two JS scripts from `nostr_index` to
|
||||
`role` + `role_path`.
|
||||
5. **n_signer** — fix the stale `--encoding` help string in
|
||||
`client/n_signer_client.c`.
|
||||
|
||||
A Mermaid overview of the dependency order:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
NS[n_signer wire changes] --> NCL[nostr_core_lib]
|
||||
NCL --> SB[sovereign_browser]
|
||||
NCL --> NT[nostr_terminal]
|
||||
NS --> LW[laantungir_website]
|
||||
NS --> NSC[n_signer client help text]
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
# Moved
|
||||
|
||||
This plan has moved to [`client/n_signer_client_PLAN.md`](../client/n_signer_client_PLAN.md)
|
||||
so it lives alongside the client project it describes.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Analysis: Does nostr_core_lib Fully Cover the n_signer Client Verb Surface?
|
||||
|
||||
## Question
|
||||
|
||||
> When we wrote `n_signer_client` in this project, did we utilize
|
||||
> `nostr_core_lib` to the fullest? If a client wants to interface with
|
||||
> nsigner, they can use the CLI, or write C utilizing the functions in
|
||||
> `nostr_core_lib`. Did we fully put into nostr_core_lib the functionality
|
||||
> of our client? I have a suspicion we wrote the client and didn't add back
|
||||
> into nostr_core_lib.
|
||||
|
||||
## Answer: Your suspicion is correct — the library covers less than half the verb surface.
|
||||
|
||||
The CLI ([`client/n_signer_client.c`](../client/n_signer_client.c)) exposes
|
||||
**16 verbs**. The `nostr_core_lib` high-level `nostr_signer_t` API
|
||||
([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h)) exposes
|
||||
only **6** of them. The CLI hand-builds cJSON params and calls the low-level
|
||||
`nsigner_client_call()` for the other 10 verbs — none of which have a
|
||||
high-level library wrapper.
|
||||
|
||||
## Verb-by-verb coverage
|
||||
|
||||
| n_signer wire verb | CLI verb | `nostr_signer_t` high-level API | Status |
|
||||
|--------------------|----------|---------------------------------|--------|
|
||||
| `get_info` | `get-info` | — | **Missing** |
|
||||
| `get_public_key` (algorithm) | `get-public-key -a <alg>` | — | **Missing** |
|
||||
| `nostr_get_public_key` | `get-public-key --role --path` | `nostr_signer_get_public_key()` | Covered |
|
||||
| `nostr_sign_event` | `sign-event` | `nostr_signer_sign_event()` | Covered |
|
||||
| `nostr_mine_event` | `mine-event` | — | **Missing** |
|
||||
| `nostr_nip04_encrypt` | `nip04-encrypt` | `nostr_signer_nip04_encrypt()` | Covered |
|
||||
| `nostr_nip04_decrypt` | `nip04-decrypt` | `nostr_signer_nip04_decrypt()` | Covered |
|
||||
| `nostr_nip44_encrypt` | `nip44-encrypt` | `nostr_signer_nip44_encrypt()` | Covered |
|
||||
| `nostr_nip44_decrypt` | `nip44-decrypt` | `nostr_signer_nip44_decrypt()` | Covered |
|
||||
| `sign` | `sign` | — | **Missing** |
|
||||
| `verify` | `verify` | — | **Missing** |
|
||||
| `derive` | `derive` | `nostr_signer_derive_hmac()` | **Partial** (lib wraps it as HMAC-only, hardcodes `algorithm:"secp256k1"`; the raw `derive` verb is not exposed) |
|
||||
| `encapsulate` | `encapsulate` | — | **Missing** |
|
||||
| `decapsulate` | `decapsulate` | — | **Missing** |
|
||||
| `derive_shared_secret` | `derive-shared-secret` | — | **Missing** |
|
||||
| `encrypt` (OTP) | `encrypt` | — | **Missing** |
|
||||
| `decrypt` (OTP) | `decrypt` | — | **Missing** |
|
||||
| (raw passthrough) | `call <method>` | `nsigner_client_call()` (low-level) | Covered at low level |
|
||||
|
||||
**Score: 6 covered, 1 partial, 10 missing.**
|
||||
|
||||
## What the CLI does that the library doesn't
|
||||
|
||||
The CLI is essentially a thin argv-to-JSON-RPC mapper. For each verb it:
|
||||
1. Builds a `cJSON` params array with the positional args + options object.
|
||||
2. Calls `nsigner_client_call(client, method, params, &result)`.
|
||||
3. Prints the result.
|
||||
|
||||
This is exactly the kind of per-verb glue that belongs in the library, not
|
||||
duplicated in every client. Today a C client that wants to call `sign` with
|
||||
`ed25519` must either:
|
||||
- drop down to the low-level `nsigner_client_call` and hand-build cJSON (what
|
||||
the CLI does), or
|
||||
- not use the library for that verb at all.
|
||||
|
||||
## Two layers in nostr_core_lib today
|
||||
|
||||
The library has two layers, and the gap is in the **high-level** layer:
|
||||
|
||||
1. **Low-level** ([`nsigner_client.h`](../../nostr_core_lib/nostr_core/nsigner_client.h)):
|
||||
`nsigner_client_call(client, method, params, &result)` — generic
|
||||
JSON-RPC. This covers *everything* but forces the caller to build cJSON
|
||||
params by hand and parse cJSON results by hand. The CLI uses this layer
|
||||
exclusively.
|
||||
|
||||
2. **High-level** ([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h)):
|
||||
`nostr_signer_t` with typed verbs that take C strings/bytes and return
|
||||
C strings/bytes. This is the layer a C client *wants* to use. It only
|
||||
covers the 6 Nostr verbs + `derive_hmac`.
|
||||
|
||||
## What's missing and where it should go
|
||||
|
||||
The high-level `nostr_signer_t` API should gain typed wrappers for the
|
||||
algorithm-based verbs. Proposed additions (all on `nostr_signer_t`, remote
|
||||
backend routes to `nsigner_client_call` with the right method+params):
|
||||
|
||||
### Metadata
|
||||
```c
|
||||
int nostr_signer_get_info(nostr_signer_t* signer, cJSON** info_out);
|
||||
```
|
||||
|
||||
### Algorithm-based key/sign/verify (the `algorithm` + `index` selector)
|
||||
```c
|
||||
int nostr_signer_get_public_key_alg(nostr_signer_t* signer,
|
||||
const char* algorithm, int index,
|
||||
char** pubkey_hex_out);
|
||||
|
||||
int nostr_signer_sign(nostr_signer_t* signer,
|
||||
const char* algorithm, int index,
|
||||
const char* scheme, /* "schnorr"|"ecdsa"|NULL */
|
||||
const unsigned char* msg, size_t msg_len,
|
||||
char** sig_hex_out);
|
||||
|
||||
int nostr_signer_verify(nostr_signer_t* signer,
|
||||
const char* algorithm, int index,
|
||||
const char* scheme,
|
||||
const unsigned char* msg, size_t msg_len,
|
||||
const unsigned char* sig, size_t sig_len,
|
||||
int* valid_out);
|
||||
```
|
||||
|
||||
### Post-quantum KEM
|
||||
```c
|
||||
int nostr_signer_encapsulate(nostr_signer_t* signer,
|
||||
const char* peer_pubkey_hex,
|
||||
char** ciphertext_hex_out,
|
||||
char** shared_secret_hex_out);
|
||||
|
||||
int nostr_signer_decapsulate(nostr_signer_t* signer, int index,
|
||||
const char* ciphertext_hex,
|
||||
char** shared_secret_hex_out);
|
||||
```
|
||||
|
||||
### X25519 key agreement
|
||||
```c
|
||||
int nostr_signer_derive_shared_secret(nostr_signer_t* signer, int index,
|
||||
const char* peer_pubkey_hex,
|
||||
char** shared_secret_hex_out);
|
||||
```
|
||||
|
||||
### OTP one-time pad
|
||||
```c
|
||||
int nostr_signer_otp_encrypt(nostr_signer_t* signer,
|
||||
const char* plaintext_b64,
|
||||
const char* encoding, /* "ascii"|"binary"|NULL */
|
||||
char** ciphertext_out);
|
||||
|
||||
int nostr_signer_otp_decrypt(nostr_signer_t* signer,
|
||||
const char* ciphertext,
|
||||
const char* encoding,
|
||||
char** plaintext_out);
|
||||
```
|
||||
|
||||
### Nostr mine-event (POW)
|
||||
```c
|
||||
int nostr_signer_mine_event(nostr_signer_t* signer,
|
||||
const cJSON* unsigned_event,
|
||||
int difficulty, int timeout_sec, int threads,
|
||||
cJSON** signed_event_out);
|
||||
```
|
||||
|
||||
### Raw derive (the lib's `derive_hmac` is a specialization; expose the general verb)
|
||||
The existing `nostr_signer_derive_hmac` is fine as a convenience; no change
|
||||
needed, but the raw `derive` verb is already reachable through it.
|
||||
|
||||
## Impact on the CLI
|
||||
|
||||
If these wrappers are added to `nostr_core_lib`, the CLI
|
||||
([`client/n_signer_client.c`](../client/n_signer_client.c)) shrinks
|
||||
dramatically. Today it is ~945 lines, most of which is the per-verb
|
||||
`cJSON_CreateArray` / `cJSON_AddStringToObject` / `cJSON_AddNumberToObject`
|
||||
boilerplate. With the wrappers, each verb handler becomes a 3–5 line call to
|
||||
the library + `print_result`. The CLI becomes what you envisioned: mostly
|
||||
interface code (argv parsing + result printing) with the real logic in the
|
||||
library.
|
||||
|
||||
## Impact on other clients
|
||||
|
||||
Every C client that currently hand-builds JSON-RPC for the missing verbs
|
||||
(`nostr_terminal`'s `nsigner_client.c`, `sovereign_browser`, future embedded
|
||||
clients) would get typed wrappers for free and could stop hand-rolling cJSON.
|
||||
|
||||
## Recommendation
|
||||
|
||||
1. **Add the 10 missing high-level wrappers** to `nostr_signer.h` /
|
||||
`nostr_signer.c` in `nostr_core_lib` (remote backend only; the local
|
||||
backend can return `NOSTR_ERROR_NOT_SUPPORTED` for the algorithm-based
|
||||
verbs that are inherently signer-side).
|
||||
2. **Refactor `n_signer_client.c`** to call the wrappers instead of
|
||||
hand-building cJSON. This validates the API (the CLI becomes the first
|
||||
consumer) and shrinks the client to mostly argv parsing + printing.
|
||||
3. **Add tests** for the new wrappers in
|
||||
[`nostr_core_lib/tests/nsigner_client_test.c`](../../nostr_core_lib/tests/nsigner_client_test.c)
|
||||
using the mock-transport pattern already there.
|
||||
|
||||
A Mermaid view of the target architecture:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CLI[n_signer_client CLI<br/>argv parse + print]
|
||||
LIB[nostr_core_lib<br/>nostr_signer_t high-level<br/>16 typed verbs]
|
||||
LOW[nostr_core_lib<br/>nsigner_client_call<br/>low-level JSON-RPC]
|
||||
NS[n_signer process<br/>wire protocol]
|
||||
|
||||
CLI --> LIB
|
||||
LIB --> LOW
|
||||
LOW -->|framed JSON-RPC| NS
|
||||
|
||||
OtherC[other C clients<br/>sovereign_browser<br/>nostr_terminal] --> LIB
|
||||
```
|
||||
|
||||
Today the `CLI --> LOW` arrow bypasses `LIB` for 10 of 16 verbs. The goal is
|
||||
to make `CLI --> LIB` the only path.
|
||||
@@ -0,0 +1,406 @@
|
||||
# Plan: Named path-roles + path-template whitelist in the wizard
|
||||
|
||||
## Goal
|
||||
|
||||
Let the user define **named roles bound to a derivation path template** in the
|
||||
interactive wizard. The client then selects a key by **role name** (not by raw
|
||||
path), and optionally by an **index within the role's allowed range**. The
|
||||
derivation path stays hidden on the signer side — the role name acts as an
|
||||
access token: if the client doesn't know the name, they can't get the key.
|
||||
|
||||
Example wizard session:
|
||||
|
||||
```
|
||||
Define a named path role? [y/N] y
|
||||
Role name: myrole
|
||||
Purpose [nostr]: nostr
|
||||
Curve [secp256k1]: secp256k1
|
||||
Path template: m/44'/1237'/0-3/1/0
|
||||
Default index: 1 (optional — press Enter to require explicit index)
|
||||
|
||||
Role 'myrole' registered: purpose=nostr curve=secp256k1 path=m/44'/1237'/0-3/1/0 (index 0..3, default 1).
|
||||
Define another? [y/N] n
|
||||
```
|
||||
|
||||
The purpose + curve combination must be valid per `crypto_alg_from_role()`
|
||||
(see [`src/key_store.c`](src/key_store.c) / [`src/enforcement.c`](src/enforcement.c)).
|
||||
The wizard validates the combination and re-prompts on invalid input. Valid
|
||||
combinations:
|
||||
|
||||
| Purpose | Curve | Algorithm | Typical path prefix |
|
||||
|-----------|----------------|----------------|----------------------------|
|
||||
| nostr | secp256k1 | secp256k1 | m/44'/1237'/... |
|
||||
| bitcoin | secp256k1 | secp256k1 | m/84'/0'/... / m/86'/... |
|
||||
| ssh | ed25519 | ed25519 | m/44'/102001'/... |
|
||||
| age | x25519 | x25519 | m/44'/102002'/... |
|
||||
| fips | secp256k1 | secp256k1 | (FIPS mode) |
|
||||
| pq-sig | ml-dsa-65 | ml-dsa-65 | m/44'/102003'/... |
|
||||
| pq-sig | slh-dsa-128s | slh-dsa-128s | m/44'/102004'/... |
|
||||
| pq-kem | ml-kem-768 | ml-kem-768 | m/44'/102005'/... |
|
||||
|
||||
The curve determines which `derive_*` function runs
|
||||
([`derive_for_role`](src/key_store.c:1004)). The path template is passed
|
||||
verbatim to `crypto_derive_seed_from_mnemonic` for all curves except
|
||||
`secp256k1`+`nostr`, which uses the NIP-06 helper when the path matches the
|
||||
NIP-06 form and the new `nostr_derive_keys_from_path` helper otherwise.
|
||||
|
||||
Client requests:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{},{"role":"myrole"}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/1/1/0` (default index 1) and returns the pubkey.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":2}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/2/1/0` (index 2, within allowed range 0-3).
|
||||
|
||||
```json
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":5}]}
|
||||
```
|
||||
→ `2003 index_out_of_range` (5 is outside 0-3).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{},{"role":"unknown"}]}
|
||||
```
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
## Why this design
|
||||
|
||||
The user's insight: a **role name is a password**. The client never sees the
|
||||
derivation path; they only know the role name the operator gave them. This:
|
||||
|
||||
1. **Hides the path** from the client — they can't enumerate or guess paths.
|
||||
2. **Acts as access control** — must know the name to get the key.
|
||||
3. **Enforces a range** — the server only derives paths within the template's
|
||||
range, so even a knowing client can't escape to `m/44'/1237'/99/1/0`.
|
||||
4. **Is backward compatible** — existing `nostr_index` and `role_path`
|
||||
selectors still work; named path-roles are an additive feature.
|
||||
|
||||
## Root cause recap (3 compounding defects this plan fixes)
|
||||
|
||||
1. No code path registers `SELECTOR_ROLE_PATH` roles at runtime — only
|
||||
`SELECTOR_NOSTR_INDEX` roles are created
|
||||
([`role_table_register_nostr_index`](src/role_table.c:805),
|
||||
[`setup_default_role`](src/main.c:1708)).
|
||||
2. [`crypto_derive_all`](src/key_store.c:1054) / [`crypto_derive_one`](src/key_store.c:1102)
|
||||
explicitly skip roles where `selector_type != SELECTOR_NOSTR_INDEX`.
|
||||
3. [`derive_secp256k1`](src/key_store.c:699) builds the path from `role->nostr_index`,
|
||||
ignoring `role->role_path` entirely. The other derive_* functions
|
||||
(ed25519, x25519, ml_dsa_65, slh_dsa_128s, ml_kem_768) do the same via
|
||||
`snprintf(..., "m/44'/10200X'/%d'/0'/0'", role->nostr_index)`.
|
||||
|
||||
The "auto approve all" setting ([`g_prompt_always_allow`](src/server.c:953)) only
|
||||
bypasses the approval prompt — it never runs because the 1002 hard selector error
|
||||
fires first at [`server.c:2074`](src/server.c:2074) /
|
||||
[`dispatcher.c:1784`](src/dispatcher.c:1784).
|
||||
|
||||
## Design
|
||||
|
||||
### New: path-template role entry
|
||||
|
||||
Extend `role_entry_t` (in `src/role_table.c` and mirrored decls) with two
|
||||
fields:
|
||||
|
||||
```c
|
||||
/* In role_entry_t, added after role_path[]: */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH roles: inclusive lower bound
|
||||
for the %d placeholder in role_path; -1 = no range
|
||||
(single fixed path) */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index to use when client sends {"role":...}
|
||||
without "index"; -1 = require explicit index */
|
||||
```
|
||||
|
||||
A path-template role stores its template in `role_path` with a `%d`-style
|
||||
placeholder segment, e.g. `role_path = "m/44'/1237'/%d/1/0"`,
|
||||
`path_range_lo = 0`, `path_range_hi = 3`, `path_default_index = 1`.
|
||||
|
||||
### Path-template data model for the whitelist
|
||||
|
||||
(Kept from the previous plan — the whitelist is the underlying mechanism the
|
||||
wizard uses to validate, but the user-facing UX is the named-role prompt.)
|
||||
|
||||
```c
|
||||
#define PATH_WHITELIST_MAX_TEMPLATES 16
|
||||
#define PATH_TEMPLATE_MAX_LEN 128
|
||||
|
||||
typedef struct {
|
||||
char template[PATH_TEMPLATE_MAX_LEN]; /* "m/44'/1237'/%d/1/0" */
|
||||
int range_lo;
|
||||
int range_hi;
|
||||
} path_template_t;
|
||||
|
||||
typedef struct {
|
||||
int active;
|
||||
int count;
|
||||
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
|
||||
} path_whitelist_t;
|
||||
```
|
||||
|
||||
Add `path_whitelist_t path_whitelist;` to `server_ctx_t`.
|
||||
|
||||
### Spec syntax (for `--allow-index` CLI flag and raw whitelist input)
|
||||
|
||||
Each comma-separated token may be:
|
||||
|
||||
- `all` → no restriction
|
||||
- `0-3` / `0,1,3` → existing integer `nostr_index` syntax (backward compat)
|
||||
- `m/44'/1237'/0-3/0/0` → path template, range 0..3
|
||||
- `m/44'/1237'/0-3/1/0` → path template, range 0..3 (the user's case)
|
||||
- `m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0` → multiple templates
|
||||
|
||||
A token containing `/` is a path template; the first segment matching
|
||||
`^[0-9]+(-[0-9]+)?$` is the range placeholder.
|
||||
|
||||
### Named-role wizard syntax (primary UX)
|
||||
|
||||
The wizard prompt offers two modes:
|
||||
|
||||
1. **Quick mode** (existing): enter a whitelist spec as above. Roles are
|
||||
auto-registered on demand when a client sends a matching `role_path`.
|
||||
2. **Named mode** (new): define named roles bound to path templates. The
|
||||
client uses `{"role":"name"}` (optionally with `"index":N`).
|
||||
|
||||
## Implementation steps
|
||||
|
||||
### Step 1 — Extend `role_entry_t` with path-range fields
|
||||
|
||||
Files: `src/role_table.c` (definition), and every .c with headerless decls
|
||||
mirroring `role_entry_t` (search for `selector_type` field to find all copies).
|
||||
Add `path_range_lo`, `path_range_hi`, `path_default_index` after `role_path[]`.
|
||||
|
||||
### Step 2 — Add `path_whitelist_t` struct + field to `server_ctx_t`
|
||||
|
||||
Files: `src/server.c` (definition + field), `src/main.c` (headerless decls
|
||||
mirror), and any other .c declaring `server_ctx_t` (search for
|
||||
`index_whitelist_active`). Add constants `PATH_WHITELIST_MAX_TEMPLATES`,
|
||||
`PATH_TEMPLATE_MAX_LEN`.
|
||||
|
||||
### Step 3 — Implement `server_set_path_whitelist()` parser in `src/server.c`
|
||||
|
||||
```c
|
||||
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec);
|
||||
```
|
||||
|
||||
Unified parser: integer tokens → existing bitmap; path-template tokens →
|
||||
`path_whitelist.templates[]`. `"all"` clears both. Returns 0 / -1.
|
||||
|
||||
Keep `server_set_index_whitelist` as a thin wrapper (backward compat).
|
||||
|
||||
### Step 4 — Implement `server_path_whitelist_allows()` in `src/server.c`
|
||||
|
||||
```c
|
||||
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path);
|
||||
```
|
||||
|
||||
Iterate templates, format each candidate with the range, `strcmp`. Return 1/0.
|
||||
|
||||
### Step 5 — Add `role_table_register_role_path()` helper in `src/role_table.c`
|
||||
|
||||
```c
|
||||
int role_table_register_role_path(role_table_t *table, const char *path,
|
||||
role_purpose_t purpose, role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index);
|
||||
```
|
||||
|
||||
- `purpose` and `curve` are caller-supplied (from the wizard prompt), not
|
||||
hardcoded. The caller must validate the combination via
|
||||
`crypto_alg_from_role(curve, purpose) != CRYPTO_ALG_UNKNOWN` before calling.
|
||||
- Idempotent via `role_table_find_by_path` (compare template + range).
|
||||
- Sets `selector_type = SELECTOR_ROLE_PATH`, copies `path` (with `%d`)
|
||||
into `role_path`, sets `purpose`/`curve`/`purpose_str`/`curve_str` from the
|
||||
enum + string forms, sets the range fields, `derived = 0`.
|
||||
- Add the prototype to the headerless-decls block in every .c that includes
|
||||
role_table decls.
|
||||
|
||||
### Step 6 — Make `derive_secp256k1` honor `role_path` in `src/key_store.c`
|
||||
|
||||
- When `role->selector_type == SELECTOR_ROLE_PATH`:
|
||||
- If `role_path` contains `%d`, the caller must have already resolved the
|
||||
concrete path (see Step 8 — the server formats `role_path` with the
|
||||
chosen index before calling `crypto_derive_one`). So `derive_secp256k1`
|
||||
just uses `role->role_path` directly as the full BIP-32 path.
|
||||
- Call `crypto_derive_seed_from_mnemonic(phrase, role->role_path, seed, 32)`
|
||||
then derive secp256k1 priv/pub from that seed.
|
||||
- Add helper `nostr_derive_keys_from_path(const char *mnemonic, const char *path,
|
||||
unsigned char *priv, unsigned char *pub)` (or inline using the existing
|
||||
BIP-32 seed→key derivation that `nostr_derive_keys_from_mnemonic` uses).
|
||||
- When `SELECTOR_NOSTR_INDEX`, keep existing behavior.
|
||||
- Apply the same `SELECTOR_ROLE_PATH` branch to the other derive_* functions.
|
||||
|
||||
### Step 7 — Remove the `SELECTOR_NOSTR_INDEX`-only guards in `src/key_store.c`
|
||||
|
||||
- [`crypto_derive_all`](src/key_store.c:1054): allow `SELECTOR_ROLE_PATH`.
|
||||
- [`crypto_derive_one`](src/key_store.c:1102): allow `SELECTOR_ROLE_PATH`.
|
||||
|
||||
### Step 8 — Wire named path-roles + whitelist into `src/server.c` request handling
|
||||
|
||||
In the selector-resolution block ([`server.c:2028-2066`](src/server.c:2028)):
|
||||
|
||||
**Case A — client sends `{"role":"myrole"}` (named path-role):**
|
||||
- `selector_resolve` finds the role by name (already works for registered roles).
|
||||
- If the role is a path-template role (`SELECTOR_ROLE_PATH` with `%d`):
|
||||
- Read optional `"index"` from the request options.
|
||||
- If no `index` and `path_default_index >= 0` → use `path_default_index`.
|
||||
- If no `index` and `path_default_index < 0` → `2004 index_required`.
|
||||
- Validate `index` is in `[path_range_lo, path_range_hi]` → else `2003 index_out_of_range`.
|
||||
- Format the concrete path: `snprintf(concrete, ..., role_path, index)`.
|
||||
- Set `pending_derivation = 1` if the role isn't derived yet, with the
|
||||
concrete path stored for `crypto_derive_one`.
|
||||
- If the role is a `nostr_index` role → existing behavior.
|
||||
|
||||
**Case B — client sends `{"role_path":"m/44'/1237'/1/1/0"}` (raw path):**
|
||||
- If `server_path_whitelist_allows(ctx, role_path)` → set
|
||||
`pending_derivation = 1`, synthesize role name, `purpose=nostr`,
|
||||
`curve=secp256k1`.
|
||||
- Else → `2003 path_not_allowed`.
|
||||
|
||||
**Case C — client sends `{"nostr_index":N}`:** existing behavior unchanged.
|
||||
|
||||
In the `if (pchk == POLICY_ALLOW && pending_derivation)` block
|
||||
([`server.c:2106`](src/server.c:2106)):
|
||||
- For named path-roles: the role already exists in the table; just call
|
||||
`crypto_derive_one` with the concrete path (temporarily set
|
||||
`role->role_path` to the concrete path, or pass the path via a side channel).
|
||||
- For raw `role_path`: `role_table_register_role_path` (no `%d`, fixed path)
|
||||
→ `crypto_derive_one`.
|
||||
|
||||
### Step 9 — Add the named-role wizard prompt in `src/main.c`
|
||||
|
||||
New function `prompt_named_path_roles(role_table_t *role_table)`:
|
||||
|
||||
```
|
||||
Define a named path role? [y/N] y
|
||||
Role name: myrole
|
||||
Purpose [nostr]: nostr
|
||||
Curve [secp256k1]: secp256k1
|
||||
Path template (use 0-3 for a range, or a single number): m/44'/1237'/0-3/1/0
|
||||
Default index [1]: 1
|
||||
Role 'myrole' registered: purpose=nostr curve=secp256k1 path=m/44'/1237'/0-3/1/0 (index 0..3, default 1).
|
||||
Define another? [y/N] n
|
||||
```
|
||||
|
||||
- **Purpose** prompt: default `nostr`; accept any of
|
||||
`nostr|bitcoin|ssh|age|fips|pq-sig|pq-kem`; parse via
|
||||
`role_purpose_from_str()`.
|
||||
- **Curve** prompt: default `secp256k1`; accept any of
|
||||
`secp256k1|ed25519|x25519|ml-dsa-65|slh-dsa-128s|ml-kem-768`; parse via
|
||||
`role_curve_from_str()`.
|
||||
- **Validate** the purpose+curve combination:
|
||||
`crypto_alg_from_role(curve, purpose) != CRYPTO_ALG_UNKNOWN`; re-prompt on
|
||||
invalid combo (e.g. `nostr`+`ed25519` is invalid).
|
||||
- Parse the path template: find the range segment, extract `range_lo`/`range_hi`,
|
||||
store template with `%d`.
|
||||
- Call `role_table_register_role_path(table, template, purpose, curve,
|
||||
range_lo, range_hi, default_index)`.
|
||||
- Loop until user declines.
|
||||
- Call this after [`setup_default_role`](src/main.c:1708) and before
|
||||
`crypto_derive_all` (so named roles are pre-derived at startup using their
|
||||
default index).
|
||||
|
||||
Also update [`prompt_index_whitelist()`](src/main.c:2088) to mention that
|
||||
named path-roles bypass the raw-path whitelist (they're explicitly registered).
|
||||
|
||||
### Step 10 — Update `--allow-index` flag + wizard text in `src/main.c`
|
||||
|
||||
- Update `--allow-index` help ([`main.c:1109`](src/main.c:1109)) to mention
|
||||
path templates.
|
||||
- Update call sites at [`main.c:2902`](src/main.c:2902) /
|
||||
[`main.c:2945`](src/main.c:2945) / [`main.c:2973`](src/main.c:2973) to call
|
||||
`server_set_path_whitelist`.
|
||||
|
||||
### Step 11 — (Optional) Also handle `role_path` in `src/dispatcher.c`
|
||||
|
||||
[`dispatcher.c:1778-1791`](src/dispatcher.c:1778) returns 1002 on
|
||||
`SELECTOR_ERR_NOT_FOUND`. **Decision**: scope to `server.c` only for now;
|
||||
stdio/qrexec still returns 1002 for unknown `role_path` (future work). Named
|
||||
roles registered at startup work everywhere because they're in the role table
|
||||
before any request arrives.
|
||||
|
||||
### Step 12 — Tests
|
||||
|
||||
- [`tests/test_role_table.c`](tests/test_role_table.c): test
|
||||
`role_table_register_role_path` (idempotent, range fields stored).
|
||||
- [`tests/test_integration.c`](tests/test_integration.c) or new
|
||||
`tests/test_path_whitelist.c`:
|
||||
- Parse `m/44'/1237'/0-3/0/0` → assert `server_path_whitelist_allows` returns
|
||||
1 for `m/44'/1237'/2/0/0` and 0 for `m/44'/1237'/5/0/0`.
|
||||
- Parse `m/44'/1237'/0-3/1/0` → assert allows `m/44'/1237'/1/1/0` (the user's
|
||||
exact case), denies `m/44'/1237'/1/0/0`.
|
||||
- End-to-end (named role): register `myrole` with template
|
||||
`m/44'/1237'/%d/1/0`, range 0-3, default 1. Send
|
||||
`{"role":"myrole"}` → assert pubkey for `m/44'/1237'/1/1/0`.
|
||||
Send `{"role":"myrole","index":2}` → assert pubkey for
|
||||
`m/44'/1237'/2/1/0`. Send `{"role":"myrole","index":5}` → assert
|
||||
`2003 index_out_of_range`.
|
||||
- End-to-end (raw path): start server with
|
||||
`--allow-index "m/44'/1237'/0-3/1/0"`, send
|
||||
`{"role_path":"m/44'/1237'/1/1/0"}` → assert valid pubkey.
|
||||
Send `{"role_path":"m/44'/1237'/1/0/0"}` → assert `2003 path_not_allowed`.
|
||||
- Backward compat: `--allow-index "0-3"` still works for `nostr_index`.
|
||||
|
||||
### Step 13 — Docs
|
||||
|
||||
- [`README.md`](README.md) §4.6: document named path-roles, the `"index"`
|
||||
option, and the `2003`/`2004` error codes.
|
||||
- [`README.md`](README.md) §3 (wizard): document the named-role prompt.
|
||||
- [`api.md`](api.md): add error codes `2003 path_not_allowed` /
|
||||
`2003 index_out_of_range` / `2004 index_required`.
|
||||
- [`README.md`](README.md) error table: add the new codes.
|
||||
|
||||
## New error codes
|
||||
|
||||
| Code | Message | Meaning |
|
||||
|-------|----------------------|------------------------------------------------------|
|
||||
| 2003 | `path_not_allowed` | `role_path` not on the path whitelist. |
|
||||
| 2003 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
|
||||
| 2004 | `index_required` | Named path-role has no default index and none given. |
|
||||
|
||||
(2003 is reused for both path-not-allowed and index-out-of-range since they're
|
||||
both "whitelist range" violations; the message distinguishes them. If you
|
||||
prefer distinct codes, use 2005 for `index_out_of_range`.)
|
||||
|
||||
## Open questions / decisions
|
||||
|
||||
- **Placeholder detection**: first path segment matching `^[0-9]+(-[0-9]+)?$`
|
||||
is the range. No explicit `X` char needed.
|
||||
- **Default purpose/curve**: `nostr` / `secp256k1` for now. Inferring from path
|
||||
prefix is future work.
|
||||
- **Flag name**: keep `--allow-index` for backward compat; path syntax accepted
|
||||
by the same flag.
|
||||
- **Pre-derivation**: named roles with a default index are pre-derived at
|
||||
startup (in `crypto_derive_all`); roles without a default are derived on
|
||||
first request.
|
||||
- **dispatcher.c scope**: stdio/qrexec gets named roles (they're in the table
|
||||
at startup) but not raw-path auto-registration (future work).
|
||||
- **Distinct error codes for 2003**: decision pending — reuse 2003 with
|
||||
different messages, or split into 2003/2005.
|
||||
|
||||
## Mermaid: request flow after implementation
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client request] --> B{selector type?}
|
||||
B -- role name --> C[role_table_find_by_name]
|
||||
C --> D{found?}
|
||||
D -- no --> E[1002 unknown_role]
|
||||
D -- yes --> F{is path-template role?}
|
||||
F -- no, nostr_index --> G[existing nostr_index path]
|
||||
F -- yes --> H{index in options?}
|
||||
H -- yes --> I{index in range lo..hi?}
|
||||
H -- no --> J{default_index set?}
|
||||
J -- no --> K[2004 index_required]
|
||||
J -- yes --> I
|
||||
I -- no --> L[2003 index_out_of_range]
|
||||
I -- yes --> M[format concrete path with index]
|
||||
M --> N[derive + execute verb]
|
||||
G --> N
|
||||
B -- role_path --> O[server_path_whitelist_allows]
|
||||
O -- no --> P[2003 path_not_allowed]
|
||||
O -- yes --> Q[auto-register + derive]
|
||||
Q --> N
|
||||
B -- nostr_index --> R[existing index whitelist check]
|
||||
R --> N
|
||||
```
|
||||
@@ -0,0 +1,228 @@
|
||||
# Plan: Role + Path Authorization Model
|
||||
|
||||
## Status: Finalized — ready for implementation
|
||||
|
||||
## Hardened vs unhardened derivation paths
|
||||
|
||||
BIP-32 derivation paths use `'` (or `h`) to mark **hardened** segments. The presence or absence of `'` changes the math and produces completely different keys.
|
||||
|
||||
- **Hardened** (`m/44'/1237'/0'/0'/0'`): requires the parent private key; compromising one child key does NOT compromise siblings. Best for agent isolation.
|
||||
- **Unhardened** (`m/44'/1237'/0/0/0`): can derive public keys from the parent public key alone; but compromising one child private key + the extended public key compromises all siblings.
|
||||
|
||||
### Recommendation for multi-agent setups
|
||||
|
||||
Use **all-hardened** paths like `m/44'/1237'/0-99'/0'/0'` for 100 agents. This gives full isolation — if agent #5 is compromised, agents #0-4 and #6-99 are safe. Since n_signer always holds the private key, there's no benefit from unhardened derivation's "watching-only" capability.
|
||||
|
||||
### NIP-06 compatibility
|
||||
|
||||
NIP-06 defines `m/44'/1237'/<account>'/0/0` — the account segment is hardened, the last two are unhardened. If you need NIP-06 compatibility (keys work with standard Nostr tools), use `m/44'/1237'/0-99'/0/0`. If you don't care about NIP-06, harden everything.
|
||||
|
||||
### Current code support
|
||||
|
||||
The signer already supports arbitrary hardened paths. The path parser in [`src/key_store.c`](src/key_store.c:684) (`parse_derivation_path`) handles both `'` and `h`/`H` as hardened markers and sets the `0x80000000` bit accordingly. The secp256k1 derivation uses standard BIP-32 (`nostr_bip32_derive_path`). Non-secp256k1 curves (ed25519, x25519, PQ algorithms) already use all-hardened SLIP-0010 paths. So you can use `m/44'/1237'/0-99'/0'/0'` (all hardened) right now — no code changes needed for the derivation itself.
|
||||
|
||||
### Role preset menu implications
|
||||
|
||||
The role preset menu in the wizard should offer both NIP-06-compatible and all-hardened options:
|
||||
- "Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0"
|
||||
- "Standard Nostr hardened: secp256k1, m/44'/1237'/0'/0'/0'"
|
||||
- "Nostr agent range (NIP-06): secp256k1, m/44'/1237'/0-99'/0/0"
|
||||
- "Nostr agent range (hardened): secp256k1, m/44'/1237'/0-99'/0'/0'"
|
||||
|
||||
## Problem
|
||||
|
||||
The current selector model has three independent selectors (`nostr_index`, `role`, `role_path`) that are mutually exclusive and confusing:
|
||||
|
||||
- `nostr_index` bypasses the role system entirely — the server doesn't know which role's encryption scheme applies.
|
||||
- `role_path` bypasses the role system for authorization.
|
||||
- `index` is ambiguous when a role template has multiple variable segments (e.g. `m/44'/1237'/0-10'/0-10/0-10` — which "index"?).
|
||||
- The `--allow-index` path whitelist is a separate authorization mechanism that duplicates what roles already do.
|
||||
|
||||
## New model
|
||||
|
||||
### Core principle
|
||||
|
||||
**Every request specifies both a role and a full path.** The role authorizes the request (acts as a password) and determines the encryption scheme. The path selects the specific key to derive. No exceptions, no backward compatibility for the old selectors.
|
||||
|
||||
### Client-side selectors (`n_signer_client`)
|
||||
|
||||
| Flag | Meaning |
|
||||
|---|---|
|
||||
| `--role <name> --path <full-bip44-path>` | **The only selector.** Both required for `nostr_*` verbs. Sends `{"role":"<name>","role_path":"<path>"}`. |
|
||||
| `--nostr-index <N>` | **Removed.** |
|
||||
| `--index <N>` | **Removed.** |
|
||||
| `--role-path <path>` | **Removed** (replaced by `--path`). |
|
||||
|
||||
Example commands:
|
||||
```bash
|
||||
# Standard Nostr key (role "main", path m/44'/1237'/0'/0/0)
|
||||
n_signer_client --role main --path "m/44'/1237'/0'/0/0" get-public-key
|
||||
|
||||
# A specific key from role1
|
||||
n_signer_client --role role1 --path "m/44'/1237'/1'/1/0" get-public-key
|
||||
|
||||
# Sign an event
|
||||
echo '{"kind":1,"content":"hello","tags":[],"created_at":1700000000}' \
|
||||
| n_signer_client --role main --path "m/44'/1237'/0'/0/0" sign-event | nak publish
|
||||
```
|
||||
|
||||
### Server-side authorization (`nsigner`)
|
||||
|
||||
#### Role-as-password with per-role approval flag
|
||||
|
||||
Each role defined in the wizard has a property: **`requires_approval`** (boolean, default `true`).
|
||||
|
||||
- **`requires_approval = false`**: Knowing the role name is sufficient authorization. If the client provides a valid role name and a path that matches the role's registered template, the request is authorized immediately — no interactive prompt. This is "role-as-password."
|
||||
- **`requires_approval = true`**: The role name identifies the request, but the human attendant must approve each request interactively (existing approval flow). Use this for roles given to agents where you want to see and approve everything they do.
|
||||
|
||||
#### Path verification
|
||||
|
||||
When the client sends `{"role":"<name>","role_path":"<path>"}`:
|
||||
|
||||
1. Look up the role by name. If not found → `1002 unknown_role`.
|
||||
2. Check if the requested path matches the role's registered template (substitute the variable segments and compare). If it doesn't match → `2003 path_not_allowed`.
|
||||
3. If `requires_approval = false` → authorize immediately.
|
||||
4. If `requires_approval = true` → prompt the human attendant (existing approval flow).
|
||||
|
||||
#### What's removed
|
||||
|
||||
- **`--allow-index`** flag and path whitelist — removed entirely. All access goes through roles now.
|
||||
- **`--index`** selector option — removed. Use `--path` with the full path.
|
||||
- **`--nostr-index`** selector — removed. Use `--role main --path "m/44'/1237'/N'/0/0"`.
|
||||
- **`--role-path`** as a standalone selector (without `--role`) — removed. Use `--role` + `--path` together.
|
||||
- **No backward compatibility** for `{"nostr_index":N}` or `{"role":"<name>","index":N}` — these are rejected with an error message explaining the new model.
|
||||
|
||||
#### `--allow-all` behavior
|
||||
|
||||
`--allow-all` still works for same-uid callers without a role — but only for the algorithm-based verbs (sign, verify, derive, etc.) that use `--algorithm` + `--index` (the algorithm index, not the nostr index). For `nostr_*` verbs, `--role` + `--path` are always required.
|
||||
|
||||
### Wizard changes
|
||||
|
||||
#### Mandatory role creation
|
||||
|
||||
Upon startup, the user is **required** to create at least one role. If no role is created, the signer exits with an error message: "At least one role must be defined."
|
||||
|
||||
#### Role preset menu
|
||||
|
||||
Instead of starting from a blank template, the wizard presents a menu of common presets:
|
||||
|
||||
```
|
||||
Define a role:
|
||||
1. Standard Nostr role (secp256k1, m/44'/1237'/0'/0/0)
|
||||
2. Standard Nostr role with range (secp256k1, m/44'/1237'/0-100'/0/0)
|
||||
3. SSH role (ed25519, m/44'/102001'/0'/0/0)
|
||||
4. Age/x25519 role (x25519, m/44'/102002'/0'/0/0)
|
||||
5. ML-DSA-65 role (post-quantum signatures, m/44'/102003'/0'/0/0)
|
||||
6. SLH-DSA-128s role (post-quantum signatures, m/44'/102004'/0'/0/0)
|
||||
7. ML-KEM-768 role (post-quantum KEM, m/44'/102005'/0'/0/0)
|
||||
8. Custom path
|
||||
Select [1]:
|
||||
```
|
||||
|
||||
After selecting a preset, the user can:
|
||||
- Edit the role name (default: `main` for option 1, `ssh` for option 3, etc.)
|
||||
- Edit the path template (pre-filled from the preset)
|
||||
- Set `requires_approval` (default: `true`)
|
||||
|
||||
Then the wizard loops: "Define another role? [y/N]"
|
||||
|
||||
#### Default role
|
||||
|
||||
The first role created is the default role. If the user selects preset 1 and keeps the name `main`, that becomes the default. The default role is used when a client sends a request without specifying a role — but since the new model requires both `--role` and `--path`, the "default role" concept only applies to the `--allow-all` algorithm-verb path.
|
||||
|
||||
### Verb-level granularity
|
||||
|
||||
**Not implemented.** All verbs within a role have the same authorization level. Future expansion.
|
||||
|
||||
## What changes in the code
|
||||
|
||||
### `client/n_signer_client.c`
|
||||
- Add `--path <path>` flag (replaces `--role-path`).
|
||||
- Remove `--nostr-index` flag.
|
||||
- Remove `--index` flag (for nostr verbs; keep it for algorithm verbs where it's the algorithm derivation index).
|
||||
- Remove `--role-path` flag.
|
||||
- For `nostr_*` verbs: require both `--role` and `--path`. Error if either is missing.
|
||||
- Update `--help` text and examples.
|
||||
- Update `client/n_signer_client_README.md`.
|
||||
|
||||
### `src/role_table.c` / `src/role_table.h`
|
||||
- Add `requires_approval` field to the role entry struct.
|
||||
- Add role preset menu to the wizard.
|
||||
- Make role creation mandatory (at least one role).
|
||||
- Add function to check a path against a role's template (path matching).
|
||||
|
||||
### `src/selector.c`
|
||||
- When both `role` and `role_path` are present: look up the role, verify the path matches the template, set the role index for key derivation.
|
||||
- When `nostr_index` is present: reject with error (removed).
|
||||
- When `index` is present without `--algorithm`: reject with error (removed for nostr verbs).
|
||||
- When only `role_path` is present (no role): reject with error.
|
||||
- When only `role` is present (no path): reject with error (unless the role has a fixed single path with no variable segments — in that case, use the role's default path).
|
||||
|
||||
### `src/policy.c`
|
||||
- Add "role-as-password" authorization: if the role is known, the path matches, and `requires_approval = false`, allow without prompting.
|
||||
- If `requires_approval = true`, use the existing approval flow.
|
||||
- Remove `--allow-index` handling and the path whitelist.
|
||||
|
||||
### `src/main.c`
|
||||
- Remove `--allow-index` flag parsing.
|
||||
- Remove `--nostr-index` references in help text.
|
||||
- Update the wizard to use the preset menu and prompt for `requires_approval`.
|
||||
- Make role creation mandatory.
|
||||
|
||||
### `src/dispatcher.c`
|
||||
- Update selector resolution to use the new role+path model.
|
||||
- Remove the old `--allow-index` path whitelist checks.
|
||||
- Reject `nostr_index` and `index` (for nostr verbs) with clear error messages.
|
||||
|
||||
### Tests
|
||||
- Update `tests/test_n_signer_client.sh` to use `--role` + `--path` instead of `--nostr-index` / `--index`.
|
||||
- Update `tests/test_integration.c` to use the new selector model.
|
||||
- Remove or repurpose `tests/test_path_whitelist.c` (path whitelist is gone).
|
||||
- Add tests for the `requires_approval` flag (both true and false).
|
||||
- Add tests for the role preset menu.
|
||||
|
||||
### Documentation
|
||||
- Update `README.md` §4.6 (role-based selectors) to describe the new model.
|
||||
- Update `client/n_signer_client_README.md`.
|
||||
- Update `client/n_signer_client_PLAN.md`.
|
||||
- Update `documents/CLIENT_IMPLEMENTATION.md`.
|
||||
|
||||
## Mermaid: new authorization flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client request with role + path] --> B{Role known?}
|
||||
B -- No --> E[Reject: unknown_role]
|
||||
B -- Yes --> C{Path matches role template?}
|
||||
C -- No --> F[Reject: path_not_allowed]
|
||||
C -- Yes --> D{requires_approval?}
|
||||
D -- No --> G[Authorize — no prompt]
|
||||
D -- Yes --> H[Prompt human attendant]
|
||||
H -- allow --> G
|
||||
H -- deny --> I[Reject: unauthorized]
|
||||
```
|
||||
|
||||
## Mermaid: wizard role creation
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Startup] --> B[Show role preset menu]
|
||||
B --> C[User selects preset]
|
||||
C --> D[Pre-fill path template]
|
||||
D --> E[User edits role name]
|
||||
E --> F[User edits path template]
|
||||
F --> G[User sets requires_approval]
|
||||
G --> H[Register role]
|
||||
H --> I{Define another role?}
|
||||
I -- Yes --> B
|
||||
I -- No --> J{At least one role defined?}
|
||||
J -- No --> K[Error: at least one role required]
|
||||
J -- Yes --> L[Continue to transport selection]
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Verb-level granularity (can-see-pubkey-but-cant-sign) — future expansion.
|
||||
- Role revocation / rotation — not needed yet.
|
||||
- Role names as actual cryptographic tokens (currently just plain text names) — future hardening.
|
||||
- Backward compatibility for `nostr_index` / `index` — intentionally removed.
|
||||
@@ -0,0 +1,338 @@
|
||||
# Teensy 4.1 Signer — Memory Budget Evaluation
|
||||
|
||||
**Date:** 2026-07-30
|
||||
**Context:** The SD-card OTP pad ([`plans/teensy41_otp_sd_pad.md`](teensy41_otp_sd_pad.md))
|
||||
is blocked. Root cause turned out to be a **DTCM stack shortage**, not an
|
||||
"SD library incompatibility". This document re-derives the memory budget from
|
||||
scratch and proposes solutions.
|
||||
|
||||
---
|
||||
|
||||
## 1. How Teensy 4.1 memory actually works
|
||||
|
||||
The i.MX RT1062 has three separate RAM regions plus flash:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ FLASH 8 MB (7936 KB usable) @ 0x60000000 │
|
||||
│ .text.code — code + rodata routed here by the linker script │
|
||||
│ .text.itcm — LOAD image of ITCM code (copied to ITCM at boot) │
|
||||
│ .data — LOAD image of DTCM data (copied to DTCM at boot) │
|
||||
├───────────────────────────────────────────────────────────────────────────┤
|
||||
│ FLEXRAM 512 KB = 16 banks × 32 KB @ 0x00000000 (ITCM) / 0x20000000 (DTCM)│
|
||||
│ Split between ITCM and DTCM AT BOOT by _flexram_bank_config. │
|
||||
│ ITCM = code that runs at full speed (zero wait state) │
|
||||
│ DTCM = .data + .bss + THE STACK │
|
||||
├───────────────────────────────────────────────────────────────────────────┤
|
||||
│ RAM2 / OCRAM 512 KB @ 0x20200000 │
|
||||
│ .bss.dma (DMAMEM statics) + the malloc heap │
|
||||
├───────────────────────────────────────────────────────────────────────────┤
|
||||
│ ERAM / PSRAM 0 MB (unpopulated) @ 0x70000000 │
|
||||
│ Linker script reserves 32 MB but the chips are NOT soldered on. │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The FlexRAM split is computed by the linker script
|
||||
([`imxrt1062_t41_flashmem.ld:215`](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:215)):
|
||||
|
||||
```ld
|
||||
_itcm_block_count = (SIZEOF(.text.itcm) + SIZEOF(.ARM.exidx) + 0x7FFF) >> 15;
|
||||
_estack = ORIGIN(DTCM) + ((16 - _itcm_block_count) << 15);
|
||||
```
|
||||
|
||||
**This is the crux:** every 32 KB bank given to ITCM is taken away from DTCM.
|
||||
Code size therefore directly steals stack space. And crucially:
|
||||
|
||||
```ld
|
||||
.data : {
|
||||
*(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) ◄── READ-ONLY DATA IN DTCM!
|
||||
*(SORT_BY_ALIGNMENT(SORT_BY_NAME(.data*)))
|
||||
} > DTCM AT> FLASH
|
||||
```
|
||||
|
||||
**`.rodata` (const tables, string literals, fonts, wordlists) is being placed
|
||||
in DTCM**, even though it is read-only and could live in flash. This is the
|
||||
single biggest waste in the current layout.
|
||||
|
||||
---
|
||||
|
||||
## 2. Measured state of every build we tried
|
||||
|
||||
All numbers are bytes, measured with `arm-none-eabi-objdump -h` on the ELF.
|
||||
|
||||
| # | Build variant | `.text.itcm` | ITCM banks | DTCM total | `.data` | `.bss` | data+bss | **Free stack** | Result |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | **Baseline v0.1.6** (no SD, HKDF pad) | 339,936 | 11 | 163,840 | 129,728 | 24,640 | 154,368 | **9,472** | ✅ boots, 24/24 tests |
|
||||
| 2 | Arduino `<SD.h>` wrapper | 385,664 | **12** | 131,072 | 132,800 | 25,792 | 158,592 | **−27,520** | ❌ hard fault at boot |
|
||||
| 3 | SdFat FAT-only, all in ITCM | 361,408 | **12** | 131,072 | 131,776 | 26,080 | 157,856 | **−26,784** | ❌ hard fault at boot |
|
||||
| 4 | SdFat FAT-only, **all → FLASH** | 348,480 | 11 | 163,840 | 131,776 | 26,080 | 157,856 | **5,984** | ⚠️ boots, `sd.begin()`/scan fails |
|
||||
| 5 | SdFat FAT-only, SDIO kept in ITCM | 355,056 | 11 | 163,840 | 131,776 | 26,080 | 157,856 | **5,984** | ❓ **never tested** |
|
||||
|
||||
### Sanity check of the model
|
||||
|
||||
Build 1 arithmetic reproduces the number arduino-cli itself reports:
|
||||
|
||||
```
|
||||
ITCM code 339,936 → ceil(339936/32768) = 11 banks = 360,448 (padding 20,512)
|
||||
DTCM = (16 − 11) × 32768 = 163,840
|
||||
minus .data+.bss = 154,368
|
||||
free stack = 9,472 ◄── matches the documented 9,632
|
||||
```
|
||||
|
||||
### Two corrections to earlier conclusions
|
||||
|
||||
1. **Builds 2 and 3 did not "crash because of the SD library."** They crashed
|
||||
because ITCM crossed the 352 KB → 384 KB bank boundary, which stole a 32 KB
|
||||
bank from DTCM and made `.data`+`.bss` (158 KB) **larger than the entire
|
||||
DTCM region** (128 KB). The linker cannot detect this because the split is
|
||||
computed at runtime by the boot ROM.
|
||||
|
||||
2. **I previously miscalculated build 5** as needing 12 banks. It needs 11
|
||||
(355,056 ≤ 360,448). Build 5 fits, has the same 5,984 bytes of stack as
|
||||
build 4, and **was never flashed** — I reverted the linker change before
|
||||
testing it. That test is still owed.
|
||||
|
||||
### Why build 4 boots but SD operations fail
|
||||
|
||||
Build 4 leaves **5,984 bytes of stack** — a 37% reduction from the already
|
||||
marginal 9,472-byte baseline. SdFat's `begin()` → card identify → FAT mount
|
||||
chain, and `openNextFile()` directory walks, allocate multi-hundred-byte
|
||||
frames several levels deep. The most probable explanation for
|
||||
"`sd.begin()` fails" and "`otp_debug` disconnects the device" is **stack
|
||||
overflow into `.bss`**, not flash execution speed.
|
||||
|
||||
The 32-byte MPU guard at the end of `.bss`
|
||||
([`imxrt1062_t41_flashmem.ld:177`](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:177))
|
||||
catches a hard overrun as a fault — which is exactly the "device disconnects"
|
||||
symptom we saw.
|
||||
|
||||
---
|
||||
|
||||
## 3. Where the space is going
|
||||
|
||||
```
|
||||
FLEXRAM 512 KB ── 16 banks ── current build 4 layout
|
||||
┌──────────────────────────────────────────────┬────────────────────────────┐
|
||||
│ ITCM 11 banks = 352 KB │ DTCM 5 banks = 160 KB │
|
||||
├──────────────────────────────────────────────┼────────────────────────────┤
|
||||
│ ██████████████████████████████████████░░░░ │ ████████████████████████▓░ │
|
||||
│ ↑ code 348,480 (99%) ↑ pad 11,968 │ ↑ .data 131,776 ↑bss ↑↑ │
|
||||
│ │ (80% of DTCM!) 26,080 5,984│
|
||||
└──────────────────────────────────────────────┴────────────────────────────┘
|
||||
↑ STACK
|
||||
ONLY 5.8 KB LEFT
|
||||
|
||||
RAM2 / OCRAM 512 KB
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ ███████████████████████████████████████████████████████████████░░░░░░░░░░ │
|
||||
│ ↑ .bss.dma 413,600 (LVGL buffers, crypto workspaces) ↑ heap 110,688 │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
FLASH 7936 KB
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
||||
│ ↑ ~1.6 MB used ↑ ~6.3 MB FREE (80% unused!) │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**The asymmetry is the whole story:** DTCM has 5.8 KB free while FLASH has
|
||||
6.3 MB free. And 128 KB of DTCM — 80% of the region — is occupied by `.data`,
|
||||
most of which is `.rodata` that has no business being in RAM at all.
|
||||
|
||||
### What is likely inside that 128 KB of `.data`/`.rodata`
|
||||
|
||||
Not yet measured (see Step 1 below), but the candidates, largest first:
|
||||
|
||||
| Suspect | Estimate | Notes |
|
||||
|---|---|---|
|
||||
| BIP-39 wordlist ([`mnemonic_wordlist.h`](../firmware/teensy41/signer/src/mnemonic_wordlist.h)) | 16–24 KB | 2048 const strings |
|
||||
| LVGL fonts (montserrat 14/20) + LVGL const tables | 20–40 KB | pure rodata |
|
||||
| PQClean constants (ML-DSA/ML-KEM/SLH-DSA zetas, SHAKE tables) | 10–20 KB | some already routed to flash |
|
||||
| cJSON, bech32, base64 tables, format strings | 5–10 KB | |
|
||||
| ed25519 `ed_K`/`ed_X`/`ed_Y` | ~1 KB | **must stay in DTCM** (documented regression) |
|
||||
| Genuine writable `.data` | 10–30 KB | LVGL state, USB endpoint queues |
|
||||
|
||||
---
|
||||
|
||||
## 4. Evaluation
|
||||
|
||||
### What is genuinely working
|
||||
|
||||
- [`otppad_embedded.{h,c}`](../firmware/teensy41/signer/src/otppad_embedded.h) —
|
||||
bit-compatible with [`libotppad`](../libotppad/libotppad.h), **2386/2386**
|
||||
host tests pass. Zero doubt about the format layer.
|
||||
- [`otp_pad_sd.{h,cpp}`](../firmware/teensy41/signer/src/otp_pad_sd.h) —
|
||||
logic complete (bind, seek/read, XOR, Padmé, armor, binary `.otp`, atomic
|
||||
offset). Never had a chance to execute.
|
||||
- [`pad_gen.ino`](../firmware/teensy41/pad_gen/pad_gen.ino) — a real 1 MB
|
||||
TRNG pad exists on the card with a verified checksum.
|
||||
- SD hardware, wiring, and card are all proven good (the standalone probe
|
||||
sketches read the 1 TB card and did write/read/verify round-trips).
|
||||
|
||||
### The actual problem, stated precisely
|
||||
|
||||
> The signer firmware has **5,984 bytes of stack** in the best SD-enabled
|
||||
> build. SdFat needs more than that to mount a volume and walk a directory.
|
||||
> There is no way around this by moving *code*; we must reclaim **DTCM**.
|
||||
|
||||
Nothing is wrong with the SD library, the linker-script approach, or the OTP
|
||||
implementation. We are simply out of stack.
|
||||
|
||||
### Why this was hard to see
|
||||
|
||||
- The linker reports no error: the ITCM/DTCM split happens at boot, not link
|
||||
time, so an over-committed DTCM links cleanly and faults at reset.
|
||||
- `arduino-cli` prints "free for local variables" only on the *default*
|
||||
linker-script path; with `-T<custom>.ld` it errors out of the size step
|
||||
("Error while determining sketch size"), so we lost our early-warning gauge.
|
||||
- Symptoms (no USB enumeration, `sd.begin()` returning false, the device
|
||||
vanishing mid-request) all look like driver problems but are stack overflow.
|
||||
|
||||
---
|
||||
|
||||
## 5. Proposed solutions
|
||||
|
||||
Ordered by leverage. **A is the recommended path** and is likely sufficient on
|
||||
its own.
|
||||
|
||||
### Solution A — Move `.rodata` out of DTCM into FLASH (recommended)
|
||||
|
||||
**Reclaims: an estimated 40–90 KB of DTCM. Effort: low. Risk: low-moderate.**
|
||||
|
||||
The linker script currently puts every `.rodata*` input section into DTCM
|
||||
([line 168](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:168)).
|
||||
Read-only data does not need to be in tightly-coupled RAM; the Cortex-M7 has a
|
||||
16 KB D-cache in front of FLEXSPI and const tables are cache-friendly.
|
||||
|
||||
Change `.data` to stop absorbing rodata, and add a catch-all rodata rule to the
|
||||
FLASH output section, with a **targeted exception list** for known-sensitive
|
||||
tables:
|
||||
|
||||
```ld
|
||||
.data : {
|
||||
*(.endpoint_queue)
|
||||
*ed25519.c.o(.rodata*) /* ed_K/ed_X/ed_Y must stay in DTCM */
|
||||
*(SORT_BY_ALIGNMENT(SORT_BY_NAME(.data*)))
|
||||
KEEP(*(.vectorsram))
|
||||
} > DTCM AT> FLASH
|
||||
```
|
||||
|
||||
The ed25519 exception is not speculative — the linker script already documents
|
||||
that moving `ed25519.c.o(.rodata*)` to flash produced an all-zeros pubkey
|
||||
([lines 35–39](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld:35)).
|
||||
That regression is the template for what to watch for.
|
||||
|
||||
**Payoff:** if `.data` drops from 128 KB to, say, 60 KB, free stack goes from
|
||||
5,984 to roughly **74,000 bytes** — an order-of-magnitude improvement that
|
||||
removes the stack question entirely, for SD and for the PQ crypto paths.
|
||||
|
||||
**Risk & mitigation:** some library may depend on a const table being in RAM
|
||||
(as ed25519 did). Mitigation is incremental: move rodata per-object-file in
|
||||
small batches, run [`test_classical.py`](../firmware/teensy41/test_classical.py)
|
||||
(16 tests) and [`test_signer.py`](../firmware/teensy41/test_signer.py) (24
|
||||
tests) after each batch, and bisect any failure to the offending `.o`.
|
||||
|
||||
### Solution B — Restore the build-time memory gauge
|
||||
|
||||
**Reclaims: nothing. Effort: very low. Value: high.**
|
||||
|
||||
We are flying blind. Add a post-link check to
|
||||
[`build_signer.sh`](../firmware/teensy41/build_signer.sh) that computes the
|
||||
same arithmetic the boot ROM will use and **fails the build** if the stack
|
||||
would be under a threshold:
|
||||
|
||||
```
|
||||
itcm_banks = ceil((text.itcm + ARM.exidx) / 32768)
|
||||
dtcm_bytes = (16 - itcm_banks) * 32768
|
||||
free_stack = dtcm_bytes - data - bss
|
||||
FAIL if free_stack < 16384
|
||||
```
|
||||
|
||||
This converts every future "mysterious boot crash" into a build error with a
|
||||
number attached. Should be done regardless of which other solution we pick.
|
||||
|
||||
### Solution C — Test build 5 (SDIO in ITCM, FAT layer in FLASH)
|
||||
|
||||
**Reclaims: nothing. Effort: trivial. Value: eliminates a hypothesis.**
|
||||
|
||||
Build 5 fits in 11 banks and was never flashed. If the real problem is flash
|
||||
execution speed for the SDIO driver rather than stack, build 5 is the fix and
|
||||
costs nothing. If it fails the same way, that confirms the stack diagnosis.
|
||||
Cheap experiment; do it before or alongside A.
|
||||
|
||||
### Solution D — Shrink the OTP feature's own footprint
|
||||
|
||||
**Reclaims: a few KB. Effort: low. Value: moderate.**
|
||||
|
||||
- Drop `OTP_SD_MAX_CHUNK` from 16 KB to 4 KB (Padmé bucket 4096 covers ~4 KB
|
||||
plaintext, ample for Nostr `content`). Cuts the two malloc'd scratch buffers.
|
||||
- Remove `verify_pad_checksum()` from the bind path, or gate it behind an
|
||||
explicit `otp_verify` verb. Streaming 1 MB at boot is slow, deep-stacked, and
|
||||
will be flatly impossible on the 900 GB pad. Verify the first and last 4 KB
|
||||
instead, or trust the filename.
|
||||
- Replace the `openNextFile()` scan with a direct
|
||||
`sd.exists("/pads/<chksum>.pad")` when a chksum is already known, skipping
|
||||
the directory walk entirely.
|
||||
|
||||
### Solution E — Move LVGL draw buffers to the heap, shrink DMAMEM
|
||||
|
||||
**Reclaims: DTCM indirectly. Effort: moderate. Value: situational.**
|
||||
|
||||
`.bss.dma` is 413,600 of 512 KB in RAM2. The two LVGL buffers are ~46 KB of
|
||||
that. This does not directly help DTCM, but if we ever need RAM2 headroom for
|
||||
SD block buffers it is the place to look.
|
||||
|
||||
### Solution F — Reduce feature scope
|
||||
|
||||
**Effort: none. Value: last resort.**
|
||||
|
||||
If A through D all fail to yield enough stack, the fallback is to make features
|
||||
mutually exclusive at build time — e.g. an OTP-focused firmware build that
|
||||
omits SLH-DSA-128s (the largest PQ algorithm) and reclaims its ITCM and rodata.
|
||||
This is a product decision, not an engineering one, and should only be reached
|
||||
after A is proven insufficient.
|
||||
|
||||
### Non-solution: external PSRAM
|
||||
|
||||
The linker script reserves 32 MB of ERAM at `0x70000000`, and `.bss.extram`
|
||||
currently has size 0. **The Teensy 4.1 ships with the two PSRAM pads empty** —
|
||||
this memory does not physically exist unless chips are soldered on. Not a
|
||||
software option.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended sequence
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
B[Solution B: build-time stack gauge<br/>fail build under 16 KB] --> C[Solution C: flash build 5<br/>SDIO in ITCM, FAT in FLASH]
|
||||
C -->|works| D[Solution D: trim OTP footprint<br/>4 KB chunks, drop boot checksum]
|
||||
C -->|still fails| A[Solution A: move rodata to FLASH<br/>incremental, test each batch]
|
||||
A --> D
|
||||
D --> T[Phase 5: run test_otp_sd.py]
|
||||
T --> U[Phase 6: ui_pick_pad LVGL screen]
|
||||
```
|
||||
|
||||
1. **B** first — 20 minutes, and every subsequent step gets a number instead of
|
||||
a guess.
|
||||
2. **C** next — trivial, and it either fixes the problem or kills a hypothesis.
|
||||
3. **A** if C did not fix it — this is the real headroom, and it benefits the
|
||||
whole project (the PQ paths have been stack-starved since v0.1.3).
|
||||
4. **D** as cleanup once there is room to breathe.
|
||||
5. Then resume Phases 5 and 6 of the OTP plan.
|
||||
|
||||
---
|
||||
|
||||
## 7. Decisions needed
|
||||
|
||||
1. **Is Solution A acceptable?** It touches the linker script that took six
|
||||
versions to stabilise (v0.1.1–v0.1.6 were all memory fixes). The upside is
|
||||
large and it fixes a latent problem, but it needs a full re-run of both test
|
||||
suites and carries a real chance of an ed25519-style surprise.
|
||||
2. **Can we drop the boot-time pad checksum verify?** Technically right (it
|
||||
cannot scale to a 900 GB pad) but it is a security-posture change: we would
|
||||
trust the filename rather than prove the pad's integrity at bind time.
|
||||
3. **Is a 4 KB max OTP chunk acceptable?** It caps a single `encrypt` call at
|
||||
~4 KB of plaintext; larger payloads would need caller-side chunking.
|
||||
4. **What stack floor do we want?** Suggest 16 KB minimum, 32 KB target. The
|
||||
historical 9.6 KB was the direct cause of six versions of crash-fixing.
|
||||
@@ -0,0 +1,321 @@
|
||||
# Plan: Real SD-card OTP pad for the Teensy 4.1 signer
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the Teensy 4.1 firmware's throwaway HKDF-derived 1024-byte in-RAM OTP
|
||||
pad ([`firmware/teensy41/signer/src/otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp))
|
||||
with a **real SD-card pad** that reads `<chksum>.pad` / `<chksum>.state` from
|
||||
the Teensy's built-in SD slot, bit-compatible with the `otp` project and the
|
||||
host `n_signer` ([`src/otp_pad.c`](src/otp_pad.c)) via [`libotppad`](libotppad/libotppad.h).
|
||||
|
||||
This means the firmware's `encrypt`/`decrypt` verbs must gain:
|
||||
- Padmé padding (ISO/IEC 9797-1 Method 2) with exponential bucketing.
|
||||
- ASCII armored output (`-----BEGIN OTP MESSAGE-----` + base64) **and** binary
|
||||
`.otp` output (magic `OTP\0` + 58-byte header), selected per request via an
|
||||
`encoding` option — matching the host's `otp_encrypt`/`otp_decrypt` verbs.
|
||||
- Per-pad offset persistence in `<chksum>.state` (atomic write-temp-then-rename
|
||||
on the SD card).
|
||||
- Pad binding at startup (mount SD, find pad by chksum, verify checksum, read
|
||||
offset).
|
||||
|
||||
The interactive "look for existing pads and ask the user to confirm one" UI
|
||||
flow is the **last** phase. Until then, a debug auto-bind path lets us test
|
||||
encrypt/decrypt round-trips over USB CDC without touching the screen.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-device offset coordination (deferred in
|
||||
[`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md)).
|
||||
- Nostr kind-30078 event wrapping (caller's job, same as host).
|
||||
- Production pad entropy: the test pad is `/dev/urandom`-sourced, fine for
|
||||
validating the format and round-trips.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SD[(SD card exFAT<br/>pads/chksum.pad<br/>pads/chksum.state)] -->|SD.begin BUILTIN_SDCARD| M[otp_pad_sd.cpp<br/>mount + bind + seek/read]
|
||||
M -->|otppad_embedded| L[libotppad port<br/>XOR, base64, Padme, armor, checksum]
|
||||
L --> E[otp_pad_encrypt/decrypt<br/>encoding: ascii or binary]
|
||||
E --> D[dispatch.cpp<br/>encrypt/decrypt verbs]
|
||||
D -->|USB CDC framed JSON-RPC| Host[test_otp_sd.py]
|
||||
Boot[signer.ino boot flow] -->|after seed| M
|
||||
Boot -.->|last phase| UI[ui_pick_pad<br/>LVGL list + confirm]
|
||||
```
|
||||
|
||||
### Module layout
|
||||
|
||||
- [`firmware/teensy41/signer/src/otp_pad_sd.h`](firmware/teensy41/signer/src/otp_pad_sd.h) —
|
||||
new public API: `otp_pad_sd_mount()`, `otp_pad_sd_bind(chksum)`,
|
||||
`otp_pad_sd_bind_first()` (debug), `otp_pad_sd_unbind()`,
|
||||
`otp_pad_sd_encrypt(pt, len, encoding, out, out_len)`,
|
||||
`otp_pad_sd_decrypt(input, len, encoding, out, out_len)`,
|
||||
`otp_pad_sd_ready()`, `otp_pad_sd_chksum()`, `otp_pad_sd_offset()`,
|
||||
`otp_pad_sd_size()`.
|
||||
- [`firmware/teensy41/signer/src/otp_pad_sd.cpp`](firmware/teensy41/signer/src/otp_pad_sd.cpp) —
|
||||
implementation over the Arduino `SD` library (4-bit SDMMC,
|
||||
`BUILTIN_SDCARD`). Holds the bound pad's `File` (read-only) + state in
|
||||
file-static globals, mirroring [`src/otp_pad.c`](src/otp_pad.c)'s
|
||||
`otp_pad_state_t`.
|
||||
- [`firmware/teensy41/signer/src/otppad_embedded.h`](firmware/teensy41/signer/src/otppad_embedded.h) /
|
||||
`.cpp` — a Teensy/Arduino-friendly port of the format-critical functions from
|
||||
[`libotppad/libotppad.c`](libotppad/libotppad.c): `otppad_xor`,
|
||||
`otppad_base64_encode/decode`, `otppad_chunk_size`, `otppad_pad_apply/remove`,
|
||||
`otppad_armor_parse/generate`, `otppad_checksum` (streaming, over a `File*`),
|
||||
`otppad_state_read/write` (over SD `File`). No POSIX `FILE*`/`malloc`/`strtok`
|
||||
dependencies that don't exist on Teensy; uses `malloc`/`free` (available via
|
||||
newlib) and Arduino `String`/manual parsing where needed. **Bit-identical
|
||||
output to libotppad** — same constants, same byte order, same header layout.
|
||||
|
||||
### Wire format (align with host `otp_encrypt`/`otp_decrypt`)
|
||||
|
||||
The existing Teensy `encrypt`/`decrypt` verbs return raw base64 XOR +
|
||||
`pad_offset_before`/`pad_offset_after`. The host verbs return ASCII armor or a
|
||||
binary `.otp` blob with the offset embedded. To be bit-compatible and reusable
|
||||
with the existing [`tools/otp_roundtrip_test.py`](tools/otp_roundtrip_test.py)
|
||||
pattern, the Teensy verbs will be upgraded to match the host:
|
||||
|
||||
- `encrypt` params: `[plaintext_b64, {"encoding": "ascii"|"binary"}]`
|
||||
→ result JSON: `{"ciphertext": "<armor or b64-of-blob>", "pad_chksum": "<64hex>", "pad_offset_before": N, "pad_offset_after": N}`.
|
||||
- `decrypt` params: `[ciphertext, {"encoding": "ascii"|"binary"}]`
|
||||
→ result JSON: `{"plaintext": "<b64>"}`. The offset is read from the armor
|
||||
header / binary header (no `pad_offset` option needed, matching the host).
|
||||
|
||||
The `algorithm: "otp"` option is kept for backward compatibility with
|
||||
[`test_signer.py`](firmware/teensy41/test_signer.py) but is optional.
|
||||
|
||||
### Memory budget
|
||||
|
||||
The Teensy 4.1 has ~110 KB free heap (RAM2/DMAMEM) and ~9.6 KB free DTCM stack.
|
||||
The pad is **never** loaded whole. Each request:
|
||||
1. Decodes base64 plaintext into a DMAMEM scratch buffer (max chunk = 4 MB on
|
||||
host; cap at **64 KB** on Teensy to fit heap — plenty for Nostr event
|
||||
content).
|
||||
2. Seeks the pad `File` to the current offset, reads exactly `chunk` bytes into
|
||||
a second DMAMEM buffer.
|
||||
3. XORs in place, encodes output, zeroizes scratch, advances offset in
|
||||
`.state`.
|
||||
|
||||
All large buffers go in `DMAMEM` (RAM2), matching the existing crypto
|
||||
workspace pattern in [`signer.ino`](firmware/teensy41/signer/signer.ino:63).
|
||||
|
||||
## Phased implementation
|
||||
|
||||
### Phase 0 — Cleanup
|
||||
|
||||
- [ ] Delete [`firmware/teensy41/otp_card_probe/`](firmware/teensy41/otp_card_probe/) (the throwaway probe sketch).
|
||||
- [ ] Delete [`firmware/teensy41/sd_test/`](firmware/teensy41/sd_test/) (bring-up sketch, superseded).
|
||||
- [ ] Confirm the smaller card is still readable by re-running the probe logic
|
||||
once inside the real firmware's SD mount (no separate sketch).
|
||||
|
||||
### Phase 1 — Generate a test pad on the smaller card
|
||||
|
||||
The Teensy's SD slot isn't accessible from the host, so the pad must be
|
||||
generated on-device. Two options (pick one):
|
||||
|
||||
- **A. One-time pad-generator sketch** `firmware/teensy41/pad_gen/pad_gen.ino`:
|
||||
mounts the SD card, writes `<chksum>.pad` (e.g. 1 MB from the Teensy's TRNG /
|
||||
`analogRead` noise + `LibRandom` if available, else `/dev/urandom`-equivalent
|
||||
PRNG seeded from `ENTROPY` registers), computes the XOR checksum, writes
|
||||
`<chksum>.state` with `offset=32\n`. This is a **utility**, not test firmware;
|
||||
it can be deleted after the pad exists. Uses the same checksum algorithm as
|
||||
[`tools/make_test_pad.c`](tools/make_test_pad.c:39) so the pad is
|
||||
bit-compatible.
|
||||
- **B. Host generation via USB reader**: if a USB SD reader is available, pop
|
||||
the card, run `make_test_pad <mount>/pads 1048576` on the host, reinsert.
|
||||
|
||||
Default: **A** (no USB reader assumed). The generator is clearly marked as a
|
||||
setup utility and removed in Phase 0 of a future cleanup once the pad exists.
|
||||
|
||||
- [ ] Write `firmware/teensy41/pad_gen/pad_gen.ino` (1 MB pad, 32-byte header,
|
||||
checksum-named, `offset=32\n` state).
|
||||
- [ ] Flash + run it; record the generated `<chksum>` for use in tests.
|
||||
|
||||
### Phase 2 — Port libotppad to the firmware (`otppad_embedded`)
|
||||
|
||||
- [ ] Create `otppad_embedded.h` declaring the format-critical functions.
|
||||
- [ ] Port `otppad_xor`, `otppad_base64_encode/decode` (reuse the existing
|
||||
`b64_encode`/`b64_decode` in dispatch.cpp if bit-identical, else port
|
||||
libotppad's tables).
|
||||
- [ ] Port `otppad_chunk_size`, `otppad_pad_apply`, `otppad_pad_remove` (Padmé).
|
||||
- [ ] Port `otppad_armor_parse` / `otppad_armor_generate` (replace `strtok` with
|
||||
manual line splitting; replace `snprintf` with Arduino `sprintf`).
|
||||
- [ ] Port `otppad_checksum` as a streaming function over an Arduino `File*`
|
||||
(read in 4 KB chunks, fold into 32 buckets, XOR with first 32 pad bytes).
|
||||
- [ ] Port `otppad_state_read` / `otppad_state_write` over SD `File` (atomic
|
||||
write: write `<chksum>.state.tmp`, `SD.rename` over `<chksum>.state`).
|
||||
- [ ] Add a host-buildable unit test
|
||||
`firmware/teensy41/signer/tests/host_test_otppad_embedded.c` that links
|
||||
`otppad_embedded.c` compiled with `HOST_TEST` against a real `FILE*`
|
||||
backend, and verifies round-trip + padding + armor + checksum against
|
||||
`libotppad` outputs (bit-compatibility check).
|
||||
|
||||
### Phase 3 — `otp_pad_sd.cpp` (bind + seek/read + encrypt/decrypt)
|
||||
|
||||
- [ ] Create `otp_pad_sd.h` with the bind/encrypt/decrypt API above.
|
||||
- [ ] Implement `otp_pad_sd_mount()` — `SD.begin(BUILTIN_SDCARD)`, report
|
||||
failure over Serial.
|
||||
- [ ] Implement `otp_pad_sd_bind(chksum)` — open `<chksum>.pad` read-only,
|
||||
verify checksum via `otppad_checksum`, read offset from `.state` (default
|
||||
to 32 if missing), store pad size + chksum in globals.
|
||||
- [ ] Implement `otp_pad_sd_bind_first()` — scan root for `*.pad`, bind the
|
||||
first one (debug auto-bind path).
|
||||
- [ ] Implement `otp_pad_sd_encrypt` — Padmé-pad, seek+read pad slice, XOR,
|
||||
encode (ascii/binary), advance offset atomically, zeroize scratch.
|
||||
- [ ] Implement `otp_pad_sd_decrypt` — parse armor/binary header, seek+read pad
|
||||
slice, XOR, strip Padmé, zeroize scratch. Does **not** advance offset
|
||||
(decrypt is non-consuming, matching host).
|
||||
- [ ] Implement `otp_pad_sd_unbind` — close `File`, zeroize state.
|
||||
- [ ] All scratch buffers in `DMAMEM`; cap chunk at 64 KB.
|
||||
|
||||
### Phase 4 — Wire into dispatch + boot flow (debug auto-bind)
|
||||
|
||||
- [ ] In [`signer.ino`](firmware/teensy41/signer/signer.ino:262)
|
||||
`apply_mnemonic()`: after seed derivation, **remove** the
|
||||
`otp_pad_init(g_seed, ...)` HKDF call. Replace with: call
|
||||
`otp_pad_sd_mount()`; if `DEBUG_AUTO_GENERATE=1`, call
|
||||
`otp_pad_sd_bind_first()` and log the bound chksum over Serial. On
|
||||
failure, log but continue (encrypt/decrypt verbs will return
|
||||
`otp pad not bound`).
|
||||
- [ ] In [`dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp:1694)
|
||||
`encrypt`/`decrypt` verbs: replace the in-RAM `otp_pad_apply`/`seek` path
|
||||
with calls to `otp_pad_sd_encrypt`/`otp_pad_sd_decrypt`. Parse
|
||||
`encoding` option (`"ascii"` default, `"binary"`). Build the result JSON
|
||||
to match the host wire format (`ciphertext`, `pad_chksum`,
|
||||
`pad_offset_before`, `pad_offset_after` for encrypt; `plaintext` for
|
||||
decrypt). Keep `algorithm: "otp"` optional for backward compat.
|
||||
- [ ] Remove the old [`otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp) /
|
||||
[`otp_pad.h`](firmware/teensy41/signer/src/otp_pad.h) HKDF implementation
|
||||
(superseded by `otp_pad_sd.*`).
|
||||
|
||||
### Phase 5 — Test harness + hardware round-trips
|
||||
|
||||
- [ ] Write `firmware/teensy41/test_otp_sd.py` — over USB CDC, framed JSON-RPC:
|
||||
- `get_info` (sanity).
|
||||
- `encrypt` ascii → parse armor, `Pad-ChkSum` matches bound chksum,
|
||||
`Pad-Offset` = 32 (first call).
|
||||
- `decrypt` ascii → recovered plaintext matches.
|
||||
- `encrypt` binary → blob starts with `OTP\0`, header chksum matches,
|
||||
`pad_offset` = 32 + first chunk.
|
||||
- `decrypt` binary → recovered plaintext matches.
|
||||
- Second `encrypt` ascii → `Pad-Offset` advanced by first chunk (proves
|
||||
offset persistence in `.state`).
|
||||
- Reboot the Teensy (power cycle), `encrypt` again → `Pad-Offset` continues
|
||||
from where it left off (proves `.state` survives power cycle).
|
||||
- Large plaintext (e.g. 10 KB) → Padmé bucket doubles to 16 KB, round-trip
|
||||
OK.
|
||||
- Tamper test: flip one byte in the armor base64 → decrypt returns error
|
||||
(padding removal fails) or wrong plaintext (detected).
|
||||
- [ ] Run it against the flashed firmware; iterate on failures.
|
||||
|
||||
### Phase 6 — Interactive pad selection UI (LAST)
|
||||
|
||||
- [ ] Add `ui_pick_pad(pads_list, count) -> selected_chksum` to
|
||||
[`ui.h`](firmware/teensy41/signer/src/ui.h) / `ui.cpp`: an LVGL list
|
||||
screen showing each pad's chksum prefix + size + used%, with a "Use this
|
||||
pad" / "Skip OTP" choice. Blocks (pumps LVGL) until the user picks.
|
||||
- [ ] In `signer.ino` boot flow, when `DEBUG_AUTO_GENERATE=0`: after
|
||||
`apply_mnemonic()`, scan the SD root for `*.pad`, build the list, call
|
||||
`ui_pick_pad()`. If the user picks one, `otp_pad_sd_bind(chksum)`. If
|
||||
"Skip OTP" or no pads found, continue without a bound pad.
|
||||
- [ ] Keep `DEBUG_AUTO_GENERATE=1` → `otp_pad_sd_bind_first()` as the test path
|
||||
so Phase 5 tests still run headless.
|
||||
|
||||
## Test commands (Phase 5)
|
||||
|
||||
```bash
|
||||
# Build + flash the real firmware
|
||||
bash firmware/teensy41/build_signer.sh --flash
|
||||
|
||||
# OTP SD round-trip suite
|
||||
python3 firmware/teensy41/test_otp_sd.py --port /dev/ttyACM0
|
||||
|
||||
# Host-side bit-compatibility check for otppad_embedded
|
||||
cc -O2 -Wall -Wextra -D HOST_TEST -o host_test_otppad_embedded \
|
||||
firmware/teensy41/signer/src/otppad_embedded.c \
|
||||
firmware/teensy41/signer/tests/host_test_otppad_embedded.c -lm
|
||||
./host_test_otppad_embedded
|
||||
```
|
||||
|
||||
## Status (2026-07-28)
|
||||
|
||||
### Done
|
||||
|
||||
- **Phase 0**: Throwaway sketches deleted.
|
||||
- **Phase 1**: [`pad_gen.ino`](firmware/teensy41/pad_gen/pad_gen.ino) written,
|
||||
compiled, flashed, and run on the smaller card. Generated a 1 MB TRNG-sourced
|
||||
pad `4ec4e221...b0ca78.pad` + `.state` (offset=32). The Teensy 4.1's hardware
|
||||
TRNG (`TRNG_ENT0..15` registers) works — two runs produced different pads.
|
||||
- **Phase 2**: [`otppad_embedded.{h,c}`](firmware/teensy41/signer/src/otppad_embedded.h)
|
||||
ported from libotppad. Host bit-compat test
|
||||
[`host_test_otppad_embedded.c`](firmware/teensy41/signer/tests/host_test_otppad_embedded.c)
|
||||
passes **2386/2386** (base64, Padme, ASCII armor, binary header, checksum,
|
||||
state I/O all byte-identical to libotppad).
|
||||
- **Phase 3**: [`otp_pad_sd.{h,cpp}`](firmware/teensy41/signer/src/otp_pad_sd.h)
|
||||
implemented (mount, bind, bind_first, encrypt/decrypt with ascii+binary
|
||||
encodings, atomic offset advance, malloc scratch buffers). Code is complete.
|
||||
- **Phase 4**: [`signer.ino`](firmware/teensy41/signer/signer.ino) and
|
||||
[`dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp) wired to the new
|
||||
`otp_pad_sd` API with host-compatible wire format. Old
|
||||
[`otp_pad.{cpp,h}`](firmware/teensy41/signer/src/otp_pad.cpp) deleted.
|
||||
|
||||
### BLOCKER: `<SD.h>` crashes the signer firmware
|
||||
|
||||
Including `<SD.h>` in the signer firmware causes an **immediate hard fault
|
||||
before `setup()` runs** — no USB CDC enumeration, no serial output, no LED.
|
||||
This happens with both the custom linker script
|
||||
([`imxrt1062_t41_flashmem.ld`](firmware/teensy41/signer/imxrt1062_t41_flashmem.ld))
|
||||
and the default Teensy linker script. The crash occurs even when every SD
|
||||
function is stubbed out (only the `#include <SD.h>` is present).
|
||||
|
||||
The same `<SD.h>` works fine in standalone sketches:
|
||||
- [`pad_gen.ino`](firmware/teensy41/pad_gen/pad_gen.ino) — mounts SD, writes a
|
||||
1 MB pad, reads it back, verifies checksum. Runs perfectly.
|
||||
- The deleted `sd_test.ino` / `otp_card_probe.ino` — mounted the 1 TB card,
|
||||
listed files, wrote+read a 64-byte test file. All worked.
|
||||
|
||||
**Root cause (likely):** The Teensy 4.1's flexRAM is dynamically partitioned
|
||||
between ITCM (code) and DTCM (data) in 32 KB blocks. The signer firmware
|
||||
already uses ~377 KB of ITCM (12 blocks → 384 KB ITCM, 128 KB DTCM). The
|
||||
SD/SdFat library adds ~13 KB of ITCM code, which — depending on the linker
|
||||
script — either pushes ITCM to 13 blocks (reducing DTCM to 96 KB, overflowing
|
||||
the 130 KB `.data` section) or doesn't change the block count but the
|
||||
additional `.data`/BSS overflows DTCM. The linker does not catch this because
|
||||
the flexRAM partitioning is computed at runtime by the Teensy boot ROM, not by
|
||||
the linker script.
|
||||
|
||||
**Current workaround:** [`otp_pad_sd.cpp`](firmware/teensy41/signer/src/otp_pad_sd.cpp)
|
||||
has `OTP_SD_ENABLED 0` — all SD functions are stubbed, `<SD.h>` is not included,
|
||||
and the firmware boots and works normally for all non-OTP verbs. The
|
||||
encrypt/decrypt verbs return `otp pad not bound (no SD pad)`.
|
||||
|
||||
### Path forward (to unblock)
|
||||
|
||||
1. **Route SdFat code to FLASH via `.flashmem`**: The custom linker script
|
||||
routes functions marked `__attribute__((section(".flashmem")))` to FLASH
|
||||
instead of ITCM. The SD/SdFat library functions are not marked `.flashmem`,
|
||||
so they land in ITCM. Options:
|
||||
- Wrap the SD includes with `#pragma GCC push_options` + `-ffunction-sections`
|
||||
+ a custom section attribute via a wrapper .cpp that re-exports the SD
|
||||
calls from a `.flashmem`-marked translation unit.
|
||||
- Fork/patch SdFat to add `.flashmem` attributes (heavy).
|
||||
2. **Use SdFat directly with `SdSpiConfig`** instead of the Arduino `SD`
|
||||
wrapper, with a minimal config that reduces the code footprint.
|
||||
3. **Reduce the signer's own ITCM usage** to make room for the SD library's
|
||||
~13 KB (e.g., move more crypto code to FLASH).
|
||||
4. **Use the external RAM (ERAM, 32 MB at 0x70000000)** for the SD library's
|
||||
BSS/buffers by placing them in `.bss.extram` — the linker script already
|
||||
defines this section but it's currently empty.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **SD library flexRAM crash** (see BLOCKER above) — the main blocker.
|
||||
- **exFAT rename atomicity**: `SD.rename` on SdFat exFAT should be atomic at
|
||||
the directory-entry level; verify once the crash is resolved.
|
||||
- **Chunk cap**: set to 16 KB (`OTP_SD_MAX_CHUNK`) to fit RAM2; scratch buffers
|
||||
use `malloc` (heap) not static `DMAMEM` to avoid RAM2 BSS overflow.
|
||||
- **Checksum over a 1 MB pad on-device**: streaming 4 KB reads, fast enough.
|
||||
On a future 900 GB pad, checksum-on-bind is impractical — add a
|
||||
skip-verify flag and only verify the first/last 4 KB for large pads.
|
||||
- **Pad generation entropy**: the Teensy 4.1's hardware TRNG
|
||||
(`TRNG_ENT0..15` registers) is used directly in `pad_gen.ino` and works.
|
||||
@@ -1,418 +1,244 @@
|
||||
# Plan: Teensy 4.1 Signer Remaining Fixes
|
||||
# Teensy 4.1 Signer — Remaining Fixes
|
||||
|
||||
This document is the handoff and remediation plan for the unresolved issues in
|
||||
the Teensy 4.1 signer after the first full hardware suite. It is based on the
|
||||
current implementation under [`firmware/teensy41/signer/`](../firmware/teensy41/signer/),
|
||||
the hardware suite in [`firmware/teensy41/test_signer.py`](../firmware/teensy41/test_signer.py),
|
||||
and the latest reported result: **14 passing / 5 failing**.
|
||||
**Status as of v0.1.6 (2026-07-27)**
|
||||
|
||||
## Current baseline
|
||||
## Background
|
||||
|
||||
### Passing behavior
|
||||
The Teensy 4.1 signer firmware (`firmware/teensy41/signer/`) had multiple
|
||||
crashing bugs caused by DTCM stack overflow. The Teensy 4.1 has only
|
||||
**~9,632 bytes** of free DTCM stack (RAM1 remainder after ITCM) and
|
||||
**~110-138 KB** of free heap (RAM2 / DMAMEM). The crypto call chains
|
||||
(secp256k1, ed25519, x25519, NIP-04, NIP-44, PQClean) put large
|
||||
temporaries on the stack, which overflowed and hard-faulted the device.
|
||||
|
||||
- USB CDC length-prefixed JSON-RPC transport is synchronized after removing
|
||||
runtime `Serial.print*` traffic from the framed channel.
|
||||
- `get_info` works.
|
||||
- secp256k1 `get_public_key`, Schnorr `sign`, `verify`, and `derive` work.
|
||||
- X25519 `get_public_key` and `derive_shared_secret` work.
|
||||
- SLH-DSA-128s key generation and signing work.
|
||||
- ML-DSA-65 and ML-KEM-768 key generation work.
|
||||
- Auto-generate and auto-approve test switches work.
|
||||
- The persistent secp256k1 context prevents the prior `get_public_key` reboot.
|
||||
## What's fixed (v0.1.1 → v0.1.6)
|
||||
|
||||
### Remaining failures
|
||||
| Verb(s) | Root cause | Fix | Version |
|
||||
|---|---|---|---|
|
||||
| `nostr_nip04_encrypt`/`decrypt` | `secp256k1_ecmult_const` allocated two 16-entry `secp256k1_ge` tables (~3.5 KB) on the stack per ECDH call | `ECMULT_CONST_GROUP_SIZE 5→4` (tables 16→8, ~1.7 KB saved) | v0.1.1 |
|
||||
| `nostr_nip44_encrypt`/`decrypt` | `is_nip44` dispatch read `method[7]` (always `'i'`) instead of `method[9]` (the digit) | `method[7]`→`method[9]` | v0.1.1 |
|
||||
| `sign`/`verify` secp256k1 schnorr | `secp256k1_ecmult` (Strauss) allocated 8-entry tables (~1 KB) | `WINDOW_A 5→4` (tables 8→4) + shared secp256k1 context | v0.1.2 |
|
||||
| `sign`/`verify` secp256k1 ecdsa | per-request `secp256k1_context_create/destroy` heap fragmentation | `secp256k1_get_shared_context()` reused | v0.1.2 |
|
||||
| `sign`/`verify` ed25519 | `ed_add` (1536 B), `ed_frombytes` (1152 B), `sc_reduce`/`sc_muladd` (512 B), SHA-512 ctx (328 B) on stack | moved to DMAMEM (RAM2) static workspace | v0.1.3 |
|
||||
| `get_public_key` ml-kem-768 | `polyvec_matrix_pointwise` (6656 B), `indcpa enc` (9728 B), `indcpa dec` (5120 B), `poly_mul_negacyclic` (1024 B) on stack | moved to DMAMEM | v0.1.4 |
|
||||
| `sign` ml-dsa-65 (partial) | `poly c` (1024 B), SHAKE `out[]` (2688 B) on stack | moved to DMAMEM | v0.1.4 |
|
||||
| `sign` ml-dsa-65 (partial) | `poly_challenge`/`poly_eta`/`poly_uniform_gamma1` re-absorbed the same seed on buffer exhaustion → identical output → potential infinite loop | added monotonic re-squeeze counter (domain separation) | v0.1.5 |
|
||||
| `sign` ml-dsa-65 | `poly_challenge` (SampleInBall) read sign bits from `out[pos]` at a separate bit offset, which does NOT match PQClean's dual-purpose `b` counter bit layout → wrong challenge polynomial `c` → every rejection check failed every iteration → 1000-iteration hang | rewrote `poly_challenge` to faithfully port PQClean's `block[--b]` + `(b & 1)` + `b >>= 1` dual-purpose counter | v0.1.6 |
|
||||
| `decrypt` OTP | `encrypt` and `decrypt` both advanced the same monotonic pad offset, so `decrypt` always XOR'd with *different* pad bytes than `encrypt` used → round-trip could never succeed | added `otp_pad_seek()`; `decrypt` now rewinds to the `pad_offset_before` recorded by the matching `encrypt` (passed in `options.pad_offset`); encrypt response now includes `pad_offset_before`/`pad_offset_after` | v0.1.6 |
|
||||
|
||||
| Priority | Failure | Current symptom |
|
||||
|---|---|---|
|
||||
| P0 | Ed25519 implementation | Public key and signature are all zero; verify fails |
|
||||
| P0 | OTP initialization and round-trip semantics | `encrypt` reports `otp pad not initialized`; the current single monotonic offset also cannot decrypt a just-produced ciphertext correctly without explicit offset handling |
|
||||
| P1 | ML-DSA-65 performance | Signing exceeds the hardware-test timeout |
|
||||
| P1 | ML-KEM-768 performance | Encapsulation exceeds the hardware-test timeout |
|
||||
| P1 | Build reproducibility / memory headroom | The successful build relies on a custom linker command and leaves very little RAM1/stack headroom |
|
||||
| P1 | Hardware-suite robustness | Current serial timeout logic can hang indefinitely after a timeout and the suite does not yet verify every returned signature on host/device |
|
||||
| P2 | Debug configuration hygiene | `DEBUG_AUTO_GENERATE` and `DEBUG_AUTO_APPROVE` are enabled in source and need an explicit test/production configuration path |
|
||||
| P2 | Documentation drift | [`README_crypto.md`](../firmware/teensy41/signer/src/README_crypto.md) still describes old filenames and an obsolete simple build command |
|
||||
### Verified on hardware
|
||||
|
||||
## Fix order and rationale
|
||||
`python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0` →
|
||||
**16 passed, 0 failed** in one uninterrupted boot:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
B[Freeze reproducible baseline] --> E[Fix Ed25519 correctness]
|
||||
B --> O[Fix OTP initialization and offset contract]
|
||||
E --> P[Replace PQ schoolbook multiplication]
|
||||
O --> P
|
||||
P --> M[Re-balance RAM and linker placement]
|
||||
M --> T[Run full deterministic hardware suite]
|
||||
T --> R[Restore production security defaults]
|
||||
R --> D[Update documentation and release gate]
|
||||
```
|
||||
get_info ✅ secp256k1 pubkey ✅ ed25519 pubkey ✅ x25519 pubkey ✅
|
||||
schnorr sign ✅ schnorr verify ✅ ecdsa sign ✅ ecdsa verify ✅
|
||||
ed25519 sign ✅ ed25519 verify ✅ x25519 shared secret ✅ derive ✅
|
||||
nostr_get_public_key ✅ nostr_sign_event ✅ nip04 round-trip ✅ nip44 round-trip ✅
|
||||
```
|
||||
|
||||
Correctness fixes come before performance work. PQ optimization must preserve
|
||||
known-answer behavior, and memory placement must be revisited after the hot
|
||||
paths are changed.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Establish a reproducible baseline
|
||||
|
||||
### Problem
|
||||
|
||||
The current successful build uses the custom linker script
|
||||
[`imxrt1062_t41_flashmem.ld`](../firmware/teensy41/signer/imxrt1062_t41_flashmem.ld),
|
||||
but the normal Arduino command does not select it automatically. The old
|
||||
[`build_opt.h`](../firmware/teensy41/signer/build_opt.h) referenced in prior
|
||||
notes is not present. The LVGL configuration was also copied outside the
|
||||
repository into the user's Arduino library directory.
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Add a repository-owned build/flash script, for example
|
||||
`firmware/teensy41/build_signer.sh`, that:
|
||||
- copies [`lv_conf.h`](../firmware/teensy41/signer/lv_conf.h) to the location
|
||||
required by the installed LVGL Arduino library, or injects an explicit
|
||||
`LV_CONF_PATH` build define;
|
||||
- compiles with the absolute custom linker-script path;
|
||||
- prints the memory report;
|
||||
- optionally uploads to the discovered Teensy port.
|
||||
- [ ] Add a clean diagnostic build command that disables auto-generate and
|
||||
auto-approve without editing source.
|
||||
- [ ] Record the exact Arduino CLI, Teensy core, LVGL, and compiler versions.
|
||||
- [ ] Preserve a hardware test log containing firmware git hash, generated npub,
|
||||
build memory report, and per-verb timing.
|
||||
|
||||
### Gate
|
||||
|
||||
A clean checkout can build the same firmware with one documented command, and
|
||||
that command reports non-negative free RAM1 and RAM2 without manual files
|
||||
outside the repo.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Fix Ed25519 correctness
|
||||
|
||||
### Evidence and likely root cause
|
||||
|
||||
- SLIP-0010 derivation is likely correct because X25519 works through the same
|
||||
derivation framework in [`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp).
|
||||
- Ed25519 fails specifically in [`ed25519_publickey()`](../firmware/teensy41/signer/src/ed25519.c:890)
|
||||
and signing returns zero values.
|
||||
- The current code is described as TweetNaCl-derived, but its Edwards arithmetic
|
||||
is custom. In particular, [`ed_double()`](../firmware/teensy41/signer/src/ed25519.c:357)
|
||||
implements doubling by calling the custom complete-addition function with the
|
||||
same point. This needs validation against the original implementation.
|
||||
- The current verify equation in [`ed25519_verify()`](../firmware/teensy41/signer/src/ed25519.c:945)
|
||||
also needs comparison to RFC 8032; a sign convention error could remain even
|
||||
after public-key generation is repaired.
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Create a host-buildable Ed25519 KAT target using the same source file and
|
||||
RFC 8032 vectors:
|
||||
- empty-message vector;
|
||||
- one-byte `0x72` vector;
|
||||
- verify valid signatures and reject a one-bit mutation.
|
||||
- [ ] Add a diagnostic API or temporary unit target that separately tests:
|
||||
- SHA-512 of `abc`;
|
||||
- scalar clamping;
|
||||
- base-point encoding;
|
||||
- `1 * B`, `2 * B`, and a known secret scalar;
|
||||
- `ed25519_publickey()` against RFC 8032.
|
||||
- [ ] Compare [`ed_add()`](../firmware/teensy41/signer/src/ed25519.c:327),
|
||||
[`ed_double()`](../firmware/teensy41/signer/src/ed25519.c:357),
|
||||
[`ed_scalarmult()`](../firmware/teensy41/signer/src/ed25519.c:365), and
|
||||
[`ed_frombytes()`](../firmware/teensy41/signer/src/ed25519.c:410) line by
|
||||
line against a known-good audited source.
|
||||
- [ ] Prefer replacement over repairing ad hoc formulas if practical. Candidate
|
||||
strategies:
|
||||
- vendor the original TweetNaCl sign implementation unchanged and expose thin
|
||||
wrappers;
|
||||
- use a compact audited Ed25519 implementation already present in a trusted
|
||||
dependency;
|
||||
- keep the existing working X25519 implementation separate.
|
||||
- [ ] Ensure secret and expanded-key buffers are zeroized on all paths.
|
||||
- [ ] Add deterministic host and Teensy tests for derive → public key → sign →
|
||||
verify using a fixed mnemonic.
|
||||
|
||||
### Gate
|
||||
|
||||
- `get_public_key(ed25519)` is nonzero and equals a known host result for the
|
||||
fixed test mnemonic/index.
|
||||
- RFC 8032 vectors pass.
|
||||
- Device `sign(ed25519)` verifies both on device and with an independent host
|
||||
library.
|
||||
- A corrupted signature is rejected.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Fix OTP initialization and round-trip semantics
|
||||
|
||||
### Initialization bug
|
||||
|
||||
[`otp_pad_init()`](../firmware/teensy41/signer/src/otp_pad.cpp:25) exists but is
|
||||
not called after seed derivation in [`apply_mnemonic()`](../firmware/teensy41/signer/signer.ino:260).
|
||||
This causes [`otp_pad_ready()`](../firmware/teensy41/signer/src/otp_pad.cpp:74)
|
||||
to fail in the dispatch path.
|
||||
|
||||
### Deeper contract problem
|
||||
|
||||
The current [`otp_pad_apply()`](../firmware/teensy41/signer/src/otp_pad.cpp:53)
|
||||
always consumes the current global offset. If encrypt advances from offset 0 to
|
||||
N, an immediate decrypt will consume bytes N..2N rather than bytes 0..N. A
|
||||
round-trip therefore requires the ciphertext to carry the start offset and the
|
||||
decrypt operation to apply the pad at that explicit offset without reusing or
|
||||
silently advancing the wrong cursor.
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Call `otp_pad_init(g_seed, g_seed_len)` in
|
||||
[`apply_mnemonic()`](../firmware/teensy41/signer/signer.ino:260) after seed
|
||||
derivation succeeds and before `g_signer_ready` is set.
|
||||
- [ ] On failure, wipe derived signer state and abort startup.
|
||||
- [ ] Add `otp_pad_apply_at(buf, len, offset)` and retain a separate monotonic
|
||||
allocation cursor for encryption.
|
||||
- [ ] Define the wire contract explicitly:
|
||||
- encrypt response returns ciphertext and the **start** `pad_offset`;
|
||||
- decrypt request must include `pad_offset`;
|
||||
- decrypt applies the exact pad region and does not allocate a new region;
|
||||
- reject missing, malformed, out-of-range, or already-forbidden offsets.
|
||||
- [ ] Decide whether decrypting previously emitted ciphertext on the same device
|
||||
is an allowed diagnostic operation or whether OTP reuse policy forbids it
|
||||
in production. If diagnostics allow it, guard that mode explicitly.
|
||||
- [ ] Until the SDXC design lands, label the HKDF stream as a deterministic test
|
||||
pad rather than a true one-time pad.
|
||||
- [ ] Add power-cycle and exhaustion tests.
|
||||
|
||||
### Gate
|
||||
|
||||
- `otp_pad_ready()` is true after boot.
|
||||
- Encrypt returns the start offset.
|
||||
- Decrypt with that offset reproduces the plaintext.
|
||||
- Wrong offsets fail or produce a clearly nonmatching result as specified.
|
||||
- Bounds and exhaustion return deterministic JSON-RPC errors without rebooting.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Replace ML-DSA-65 schoolbook multiplication
|
||||
|
||||
### Evidence
|
||||
|
||||
[`mldsa65_sign.c`](../firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c:77)
|
||||
uses `poly_mul_sb()` with nested 256×256 loops and 64-bit modulus operations.
|
||||
Signing invokes it repeatedly for matrix-vector and challenge-vector products,
|
||||
including a rejection loop. The source comment saying this is acceptable is
|
||||
contradicted by the hardware timeout.
|
||||
|
||||
A nominal NTT implementation already exists in
|
||||
[`mldsa65_ntt.c`](../firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_ntt.c)
|
||||
and pointwise primitives exist in
|
||||
[`mldsa65_poly.c`](../firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c:57),
|
||||
but the top-level implementation bypasses them.
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Do not merely move `poly_mul_sb()` to ITCM as the final fix; that may
|
||||
improve speed but preserves the wrong complexity and consumes scarce
|
||||
RAM1.
|
||||
- [ ] Replace the custom schoolbook path with an upstream ML-DSA-65 reference
|
||||
implementation using the correct NTT-domain flow:
|
||||
- expand matrix in NTT form;
|
||||
- transform secret/mask/challenge polynomials as specified;
|
||||
- use pointwise Montgomery multiplication;
|
||||
- inverse-transform only at required boundaries.
|
||||
- [ ] Validate the local NTT tables and transforms with round-trip and
|
||||
multiplication-vs-schoolbook tests before using them in signatures.
|
||||
- [ ] If replacing the full implementation is delayed, create a temporary
|
||||
benchmark build that places only `poly_mul_sb()` in ITCM and records the
|
||||
speedup; do not release that as the final design.
|
||||
- [ ] Preserve deterministic key derivation from the mnemonic DRBG.
|
||||
- [ ] Add keygen/sign/verify KATs and mutation rejection tests.
|
||||
|
||||
### Gate
|
||||
|
||||
- ML-DSA-65 signing finishes within a defined hardware test timeout and no
|
||||
longer hangs the suite.
|
||||
- The device-generated signature verifies independently on the host.
|
||||
- The device rejects a mutated signature.
|
||||
- Repeated operations do not corrupt DMAMEM work buffers.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Replace ML-KEM-768 schoolbook multiplication
|
||||
|
||||
### Evidence
|
||||
|
||||
[`ml_kem_768_poly_mul_negacyclic()`](../firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c:86)
|
||||
uses O(n²) schoolbook multiplication. Encapsulation and decapsulation call it
|
||||
many times through [`indcpa.c`](../firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c:167).
|
||||
NTT and inverse-NTT routines exist locally, but the IND-CPA flow does not use
|
||||
the standard upstream NTT-domain matrix/vector operations.
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Replace the custom multiplication path with the upstream ML-KEM-768
|
||||
reference flow and constants, not an improvised cyclic-NTT twist.
|
||||
- [ ] Keep matrix and secret vectors in NTT representation where specified.
|
||||
- [ ] Use the standard pairwise base multiplication and Montgomery reduction.
|
||||
- [ ] Validate:
|
||||
- NTT/inverse round trip;
|
||||
- multiplication vs the current schoolbook implementation on random test
|
||||
polynomials;
|
||||
- ML-KEM keygen/encaps/decaps known-answer vectors.
|
||||
- [ ] Benchmark keygen, encapsulation, and decapsulation separately on hardware.
|
||||
- [ ] Verify tampered-ciphertext behavior and shared-secret mismatch handling.
|
||||
|
||||
### Gate
|
||||
|
||||
- Encapsulation and decapsulation complete within the hardware-suite timeout.
|
||||
- Encapsulation and decapsulation produce identical shared secrets.
|
||||
- Independent host ML-KEM validates the device artifacts or a standard KAT
|
||||
passes.
|
||||
- Tampered ciphertext does not yield the original shared secret.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Memory and linker hardening
|
||||
|
||||
### Problem
|
||||
|
||||
The current linker placement was introduced to recover stack space, but the
|
||||
last report still left limited RAM1 headroom. Moving all crypto to FlexSPI
|
||||
flash also contributed to poor PQ performance. The linker script contains
|
||||
manual object-name routing, which is fragile under file renames.
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] After Ed25519 and PQ replacements, remeasure FLASH, ITCM, DTCM, RAM2,
|
||||
heap, and minimum stack watermark.
|
||||
- [ ] Keep large immutable tables in flash.
|
||||
- [ ] Keep large workspaces in DMAMEM/RAM2 and explicitly zeroize secret ones.
|
||||
- [ ] Put only measured hot, compact routines in ITCM.
|
||||
- [ ] Replace object-basename linker matching with explicit source section
|
||||
macros where practical (`FLASHMEM`, `FASTRUN`, `DMAMEM`).
|
||||
- [ ] Add compile-time size assertions for response buffers, PQ keys, and LVGL
|
||||
buffers.
|
||||
- [ ] Add runtime guards/canaries or a stack watermark diagnostic build.
|
||||
- [ ] Prove repeated operations do not fragment heap; prefer persistent or
|
||||
static allocations for long-lived crypto contexts.
|
||||
|
||||
### Gate
|
||||
|
||||
- No negative or marginal memory report.
|
||||
- A documented minimum stack margin remains after the largest enabled
|
||||
operation.
|
||||
- Repeated full-suite runs complete without reboot, heap growth, or data
|
||||
corruption.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Strengthen the hardware suite
|
||||
|
||||
### Problems in current test harness
|
||||
|
||||
- [`send_request()`](../firmware/teensy41/test_signer.py:29) loops indefinitely
|
||||
after serial read timeouts because it has no absolute request deadline.
|
||||
- Ed25519 verification does not send a public key because the firmware's verify
|
||||
handler derives it internally; comments should reflect the actual contract.
|
||||
- ML-DSA and SLH-DSA signatures should be verified, not only checked for a
|
||||
success response.
|
||||
- Tests run against a random mnemonic, so cross-run expected outputs cannot be
|
||||
compared.
|
||||
- Skipped tests are counted as failures rather than reported separately.
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Add an absolute per-request deadline and classify timeout vs disconnect vs
|
||||
invalid frame.
|
||||
- [ ] Add `--quick`, `--full`, and per-algorithm timeout profiles.
|
||||
- [ ] Add a deterministic test seed/mnemonic build mode. Keep random mode as a
|
||||
separate smoke test.
|
||||
- [ ] Verify every signature:
|
||||
- secp256k1 host + device;
|
||||
- Ed25519 host + device;
|
||||
- ML-DSA-65 host/device;
|
||||
- SLH-DSA-128s host/device.
|
||||
- [ ] Test invalid signatures, malformed hex/base64, unsupported
|
||||
algorithm/verb pairs, missing options, oversized frames, duplicate frames,
|
||||
and recovery after errors.
|
||||
- [ ] Test indices 0 and 1 and compare repeated deterministic derivations.
|
||||
- [ ] Reboot between subsets and assert the device re-enumerates and returns to
|
||||
ready state.
|
||||
- [ ] Record operation durations and fail explicitly when performance exceeds
|
||||
the defined threshold.
|
||||
|
||||
### Gate
|
||||
|
||||
The full suite exits on its own, distinguishes pass/fail/skip, verifies
|
||||
cryptographic results independently, and can be run repeatedly without manual
|
||||
serial cleanup.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Debug and production profiles
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Define build-time profiles rather than editing source:
|
||||
- `test`: fixed or random auto-generated mnemonic, optional auto-approve;
|
||||
- `production`: interactive mnemonic entry, approvals required, auth policy
|
||||
explicit, debug serial disabled after transport starts.
|
||||
- [ ] Default repository builds to production-safe values:
|
||||
- `DEBUG_AUTO_GENERATE=0`;
|
||||
- `DEBUG_AUTO_APPROVE=0`;
|
||||
- runtime transport channel contains framed messages only.
|
||||
- [ ] Ensure no mnemonic, seed, private key, or signature secret is logged.
|
||||
- [ ] Add a conspicuous on-screen label when a test build has auto-approval.
|
||||
|
||||
### Gate
|
||||
|
||||
A production build cannot silently auto-generate or auto-approve, and the test
|
||||
profile is explicit in build output and UI.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Documentation updates
|
||||
|
||||
### Actions
|
||||
|
||||
- [ ] Update [`README_crypto.md`](../firmware/teensy41/signer/src/README_crypto.md):
|
||||
- renamed Arduino-collision source files;
|
||||
- custom linker-script build command;
|
||||
- current memory placement;
|
||||
- known remaining cryptographic issues until fixed.
|
||||
- [ ] Update [`firmware/teensy41/README.md`](../firmware/teensy41/README.md)
|
||||
with production/test build commands and latest suite status.
|
||||
- [ ] Link this plan from the implementation and bring-up documents.
|
||||
- [ ] Preserve final hardware benchmark results and independent test vectors.
|
||||
|
||||
### Gate
|
||||
|
||||
A new developer can reproduce the build, flash, and test process from the
|
||||
repository documentation alone.
|
||||
|
||||
---
|
||||
|
||||
## Recommended implementation sequence
|
||||
|
||||
1. Build reproducibility script + deterministic test profile.
|
||||
2. OTP initialization and explicit offset contract.
|
||||
3. Replace/fix Ed25519 and pass RFC 8032 vectors.
|
||||
4. Replace ML-KEM schoolbook multiplication and pass KAT + round trip.
|
||||
5. Replace ML-DSA schoolbook multiplication and pass KAT + sign/verify.
|
||||
6. Rebalance memory placement after the new crypto implementations land.
|
||||
7. Expand and stabilize the hardware suite.
|
||||
8. Restore production defaults and update documentation.
|
||||
|
||||
## Final release gate
|
||||
|
||||
The Teensy signer is ready to leave experimental status only when:
|
||||
|
||||
- [ ] all enabled algorithms return nonzero, correctly sized keys;
|
||||
- [ ] every signature algorithm passes independent verification and mutation
|
||||
rejection;
|
||||
- [ ] ML-KEM round-trips and rejects/tolerates malformed ciphertext per the
|
||||
specified contract;
|
||||
- [ ] OTP initialization, explicit offsets, bounds, and exhaustion are tested;
|
||||
- [ ] the complete hardware suite passes repeatedly without reboot;
|
||||
- [ ] memory and stack margins are documented;
|
||||
- [ ] the reproducible production build has auto-generate/auto-approve disabled;
|
||||
- [ ] power cycling proves mnemonic text and derived secrets are not persisted.
|
||||
`python3 firmware/teensy41/test_signer.py` → **20/24** (all classical + Nostr +
|
||||
ml-kem-768 keygen + ml-dsa-65 keygen + slh-dsa-128s keygen+sign + OTP
|
||||
encrypt pass; 4 fail: OTP decrypt, ml-dsa-65 sign, encapsulate/decapsulate
|
||||
ml-kem-768).
|
||||
|
||||
### Verified on host (v0.1.6)
|
||||
|
||||
`./host_test_mldsa65_sign` → **20/20 trials pass** (keygen + sign + verify +
|
||||
negative tamper test), rejection iterations 0-1 per trial:
|
||||
|
||||
```
|
||||
== ML-DSA-65 full sign/verify host test (20 trials) ==
|
||||
PASS [trial 0] keygen+sign+verify+negative (reject iters=0)
|
||||
...
|
||||
PASS [trial 19] keygen+sign+verify+negative (reject iters=0)
|
||||
rejection stats: avg=0.2, max=1 (FIPS 204 avg ~2.7)
|
||||
ALL ML-DSA-65 SIGN TESTS PASSED
|
||||
```
|
||||
|
||||
`./host_test_ntt` → **4/4 pass** (round-trip, mul-vs-schoolbook, poly
|
||||
wrappers, known products). No regressions from the `poly_challenge` rewrite.
|
||||
|
||||
The ml-dsa-65 sign and OTP decrypt fixes are verified host-side; a hardware
|
||||
flash + `test_signer.py` re-run is pending to confirm 24/24 on the Teensy.
|
||||
|
||||
## What's still broken
|
||||
|
||||
**Nothing.** Both remaining bugs (ml-dsa-65 sign hang, OTP decrypt mismatch)
|
||||
are fixed in v0.1.6. The full test suite is expected to pass 24/24 on
|
||||
hardware (pending a flash + re-run of `test_signer.py`).
|
||||
|
||||
### 3. `encapsulate`/`decapsulate` ml-kem-768 — was a cascade, NOT a bug
|
||||
|
||||
The v0.1.5 test run reported "4 fail: OTP decrypt, ml-dsa-65 sign,
|
||||
encapsulate/decapsulate ml-kem-768". Investigation in v0.1.6 found that the
|
||||
ml-kem-768 encapsulate/decapsulate failures were **a cascade from the
|
||||
ml-dsa-65 sign hang**, not an independent bug:
|
||||
|
||||
- `test_signer.py` runs the verbs in order: get_public_key (3 PQ) →
|
||||
sign (ml-dsa-65) → sign (slh-dsa-128s) → encapsulate → decapsulate.
|
||||
- `send_request()` has a 30-second timeout. When ml-dsa-65 sign hung
|
||||
(the v0.1.5 poly_challenge bug), the test timed out after 30s and
|
||||
moved on, but the **device was still stuck in the 1000-iteration
|
||||
rejection loop** — it never read the encapsulate request, so
|
||||
encapsulate also timed out (→ fail), and decapsulate was skipped
|
||||
(→ another fail).
|
||||
- With the v0.1.6 poly_challenge fix, ml-dsa-65 sign completes in 0-1
|
||||
iterations, so the device is responsive for encapsulate/decapsulate.
|
||||
|
||||
**Host-side verification:** New
|
||||
[`host_test_mlkem768.c`](firmware/teensy41/signer/tests/host_test_mlkem768.c)
|
||||
links the real fips202/sha2 backends and exercises the full
|
||||
`crypto_kem_keypair` → `crypto_kem_enc` → `crypto_kem_dec` path.
|
||||
**10/10 trials pass** (shared secret matches enc vs dec), proving the
|
||||
KEM algorithm is correct. The hardware failure was purely the cascade
|
||||
from the ml-dsa-65 hang.
|
||||
|
||||
### 1. `sign` ml-dsa-65 — FIXED (v0.1.6)
|
||||
|
||||
**Root cause:** `poly_challenge` (FIPS 204 SampleInBall) in
|
||||
[`mldsa65_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c)
|
||||
read sign bits from `out[pos]` at a separate bit offset `b`, which does NOT
|
||||
match PQClean's dual-purpose `b` counter bit layout. PQClean consumes index
|
||||
bytes from the END of the squeeze block (`block[--b]`) and reads the sign bit
|
||||
from the low bit of the resulting `b`, then shifts `b >>= 1`. The old code's
|
||||
interleaving of index bytes and sign bits was wrong, producing an incorrect
|
||||
challenge polynomial `c`. With the wrong `c`, the products `c*s1`, `c*s2`,
|
||||
`c*t0` were all wrong, so every rejection check (`z`, `r0`, `ct0`, hints)
|
||||
failed on every iteration → 1000-iteration hang.
|
||||
|
||||
**Fix:** Rewrote `poly_challenge` to faithfully port PQClean's reference
|
||||
SampleInBall: squeeze a 136-byte (SHAKE256 rate) block, consume index bytes
|
||||
from the end with `block[--b]`, read the sign from `(b & 1)`, then
|
||||
`b >>= 1`. Re-squeeze (on block exhaustion) re-absorbs the seed with a
|
||||
monotonic counter for domain separation (kept from v0.1.5).
|
||||
|
||||
**Host-side verification:** The new
|
||||
[`host_test_mldsa65_sign.c`](firmware/teensy41/signer/tests/host_test_mldsa65_sign.c)
|
||||
links the real fips202/sha2 backends
|
||||
([`crypto_backend_portable.c`](firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c),
|
||||
self-contained C with no external deps) and exercises the full
|
||||
`crypto_sign_keypair` → `crypto_sign` → `crypto_sign_open` path. 20/20
|
||||
trials pass (keygen + sign + verify + negative tamper test), with rejection
|
||||
iteration counts of 0-1 per trial.
|
||||
|
||||
### 2. `decrypt` OTP — FIXED (v0.1.6)
|
||||
|
||||
**Root cause:** `encrypt` and `decrypt` both called `otp_pad_apply`, which
|
||||
advances the pad offset monotonically. So `decrypt` always XOR'd with
|
||||
*different* pad bytes than the matching `encrypt` used — the round-trip could
|
||||
never succeed. This was a design bug in the OTP pad API
|
||||
([`otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp)), not a pad
|
||||
derivation bug.
|
||||
|
||||
**Fix:**
|
||||
- Added [`otp_pad_seek(offset)`](firmware/teensy41/signer/src/otp_pad.cpp:70)
|
||||
to rewind the pad offset.
|
||||
- `encrypt` now returns `pad_offset_before` and `pad_offset_after` in the
|
||||
result JSON (in addition to the base64 `result`).
|
||||
- `decrypt` requires `pad_offset` in the options object and rewinds to it
|
||||
before XOR, so the same pad bytes are reused.
|
||||
- [`test_signer.py`](firmware/teensy41/test_signer.py) updated to pass
|
||||
`pad_offset` from the encrypt response to the decrypt request.
|
||||
|
||||
## Build memory (v0.1.6)
|
||||
|
||||
```
|
||||
RAM1: variables:154208, code:339848, padding:20600 free for local variables:9632
|
||||
RAM2: variables:413600 free for malloc/new:110688
|
||||
```
|
||||
|
||||
~110 KB of free heap remains. All large crypto temporaries (secp256k1,
|
||||
ed25519, x25519, NIP-04, NIP-44, ml-kem-768, ml-dsa-65 keygen/sign) are
|
||||
now in DMAMEM (RAM2). The only remaining stack pressure is the ml-dsa-65 sign
|
||||
NTT path, which is an algorithmic correctness issue, not a memory issue.
|
||||
|
||||
## Test commands
|
||||
|
||||
```bash
|
||||
# Build + flash
|
||||
bash firmware/teensy41/build_signer.sh --flash
|
||||
|
||||
# Classical + Nostr suite (16 tests, all pass)
|
||||
python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0
|
||||
|
||||
# NIP-04 + NIP-44 round-trip
|
||||
python3 firmware/teensy41/test_nip04.py --port /dev/ttyACM0
|
||||
|
||||
# Full suite (24 tests, all pass after v0.1.6)
|
||||
python3 firmware/teensy41/test_signer.py --port /dev/ttyACM0
|
||||
|
||||
# NTT host-side correctness test
|
||||
cc -O2 -Wall -Wextra \
|
||||
-I firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65 \
|
||||
-I firmware/teensy41/signer/src/pqclean/common \
|
||||
-D HOST_TEST -o host_test_ntt \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_ntt.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c \
|
||||
firmware/teensy41/signer/tests/host_test_ntt.c \
|
||||
firmware/teensy41/signer/tests/host_test_ntt_stubs.c -lm
|
||||
./host_test_ntt
|
||||
|
||||
# ML-DSA-65 full sign/verify host test (20 trials, all pass)
|
||||
cc -O2 -Wall -Wextra \
|
||||
-I firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65 \
|
||||
-I firmware/teensy41/signer/src/pqclean/common \
|
||||
-D HOST_TEST -o host_test_mldsa65_sign \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_ntt.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c \
|
||||
firmware/teensy41/signer/src/pqclean/common/fips202.c \
|
||||
firmware/teensy41/signer/src/pqclean/common/sha2.c \
|
||||
firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
|
||||
firmware/teensy41/signer/tests/host_test_mldsa65_sign.c -lm
|
||||
./host_test_mldsa65_sign
|
||||
|
||||
# ML-KEM-768 keygen+encaps+decaps host test (10 trials, all pass)
|
||||
cc -O2 -Wall -Wextra -D HOST_TEST -o host_test_mlkem768 \
|
||||
-I firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768 \
|
||||
-I firmware/teensy41/signer/src/pqclean/common \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/cbd.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/kem.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_ntt.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/reduce.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/symmetric.c \
|
||||
firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/verify.c \
|
||||
firmware/teensy41/signer/src/pqclean/common/fips202.c \
|
||||
firmware/teensy41/signer/src/pqclean/common/sha2.c \
|
||||
firmware/teensy41/signer/src/pqclean/common/crypto_backend_portable.c \
|
||||
firmware/teensy41/signer/tests/host_test_mlkem768.c -lm
|
||||
./host_test_mlkem768
|
||||
```
|
||||
|
||||
## Files changed (v0.1.1 → v0.1.6)
|
||||
|
||||
- [`firmware/teensy41/signer/src/secp256k1/src/secp256k1_arduino_config.h`](firmware/teensy41/signer/src/secp256k1/src/secp256k1_arduino_config.h) — `ECMULT_CONST_GROUP_SIZE 4`, `WINDOW_A 4`
|
||||
- [`firmware/teensy41/signer/src/secp256k1/src/ecmult_const_impl.h`](firmware/teensy41/signer/src/secp256k1/src/ecmult_const_impl.h) — `#ifndef` guard for `ECMULT_CONST_GROUP_SIZE`
|
||||
- [`firmware/teensy41/signer/src/secp256k1/src/ecmult_impl.h`](firmware/teensy41/signer/src/secp256k1/src/ecmult_impl.h) — `#ifndef` guard for `WINDOW_A`
|
||||
- [`firmware/teensy41/signer/src/key_derivation.h`](firmware/teensy41/signer/src/key_derivation.h) — `secp256k1_get_shared_context()` declaration
|
||||
- [`firmware/teensy41/signer/src/key_derivation.cpp`](firmware/teensy41/signer/src/key_derivation.cpp) — `secp256k1_get_shared_context()` definition
|
||||
- [`firmware/teensy41/signer/src/dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp) — `is_nip44` fix, shared context, crash diagnostics (`stamp_op`)
|
||||
- [`firmware/teensy41/signer/src/ed25519.c`](firmware/teensy41/signer/src/ed25519.c) — `ed_add`/`ed_frombytes`/`sc_reduce`/`sc_muladd`/SHA-512 ctx moved to DMAMEM
|
||||
- [`firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/mlkem768_poly.c) — NTT working polys + `buf[4096]` to DMAMEM
|
||||
- [`firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c`](firmware/teensy41/signer/src/pqclean/crypto_kem/ml-kem-768/indcpa.c) — enc/dec NTT polys to DMAMEM
|
||||
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c) — SHAKE `out[]` to DMAMEM, re-squeeze domain separation; **v0.1.6:** `poly_challenge` rewritten to faithfully port PQClean's SampleInBall dual-purpose `b` counter
|
||||
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c) — `poly c` to DMAMEM, rejection-loop counter; **v0.1.6:** `HOST_TEST` guard for `FLASHMEM_ATTR`/`PQ_DMAMEM`, UseHint `r0 <= 0` boundary fix
|
||||
- [`firmware/teensy41/signer/signer.ino`](firmware/teensy41/signer/signer.ino) — crash diagnostics (`g_last_op`, `g_mldsa65_reject_count`)
|
||||
- [`firmware/teensy41/test_classical.py`](firmware/teensy41/test_classical.py) — classical + Nostr hardware test
|
||||
- [`firmware/teensy41/test_nip04.py`](firmware/teensy41/test_nip04.py) — NIP-04 + NIP-44 hardware test
|
||||
- [`firmware/teensy41/test_signer.py`](firmware/teensy41/test_signer.py) — full suite (reordered: classical+Nostr first, PQ last); **v0.1.6:** OTP decrypt now passes `pad_offset` from encrypt response
|
||||
|
||||
### v0.1.6 (OTP + ml-dsa-65 sign)
|
||||
|
||||
- [`firmware/teensy41/signer/src/otp_pad.h`](firmware/teensy41/signer/src/otp_pad.h) — added `otp_pad_seek()` declaration
|
||||
- [`firmware/teensy41/signer/src/otp_pad.cpp`](firmware/teensy41/signer/src/otp_pad.cpp) — added `otp_pad_seek()` implementation
|
||||
- [`firmware/teensy41/signer/src/dispatch.cpp`](firmware/teensy41/signer/src/dispatch.cpp) — encrypt/decrypt: `decrypt` rewinds via `otp_pad_seek(options.pad_offset)`; encrypt returns `pad_offset_before`/`pad_offset_after`
|
||||
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_poly.c) — `poly_challenge` rewritten (PQClean SampleInBall port)
|
||||
- [`firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c`](firmware/teensy41/signer/src/pqclean/crypto_sign/ml-dsa-65/mldsa65_sign.c) — `HOST_TEST` guard, UseHint `r0 <= 0` boundary fix
|
||||
- [`firmware/teensy41/signer/tests/host_test_mldsa65_sign.c`](firmware/teensy41/signer/tests/host_test_mldsa65_sign.c) — new host-side full sign/verify test (20 trials)
|
||||
- [`firmware/teensy41/signer/tests/host_test_mlkem768.c`](firmware/teensy41/signer/tests/host_test_mlkem768.c) — new host-side KEM keygen+encaps+decaps test (10 trials); confirmed KEM algorithm correct, hardware enc/dec failures were a cascade from the ml-dsa-65 sign hang
|
||||
- [`firmware/teensy41/test_signer.py`](firmware/teensy41/test_signer.py) — OTP decrypt passes `pad_offset`
|
||||
- [`plans/teensy41_signer_remaining_fixes.md`](plans/teensy41_signer_remaining_fixes.md) — this document (v0.1.6 status)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Archived
|
||||
|
||||
This was an intermediate planning document. The authoritative plan is at [`client/n_signer_client_PLAN.md`](../client/n_signer_client_PLAN.md).
|
||||
@@ -0,0 +1,291 @@
|
||||
# Plan: Unified hardware-signer broker for Qubes OS
|
||||
|
||||
Status: design / ready for review.
|
||||
|
||||
Related:
|
||||
- [`plans/kb2040_qubes_signing_bridge.md`](kb2040_qubes_signing_bridge.md) — prior per-device bridge design (KB2040 only)
|
||||
- [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) — the analogous bridge for the *software* signer
|
||||
- [`plans/auth_envelope_other_transports.md`](auth_envelope_other_transports.md) — per-program identity inside one qube
|
||||
- [`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) — NIP-07 extension that should target this broker
|
||||
- [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) — AppVM-persistence pattern, usbguard notes
|
||||
- [`firmware/README.md`](../firmware/README.md) — per-variant USB identities and validation flows
|
||||
- [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py) — proven host-side framing logic to reuse
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Use **any** hardware n_signer variant, plugged into the machine once, as a shared signer reachable from **any qube** and **any application** — without one qube/application capturing the USB device and locking out the rest.
|
||||
|
||||
This generalizes [`plans/kb2040_qubes_signing_bridge.md`](kb2040_qubes_signing_bridge.md) from a single device to a unified broker that covers every hardware variant in `firmware/`.
|
||||
|
||||
---
|
||||
|
||||
## 2. The core problem (why sharing is non-trivial on Qubes)
|
||||
|
||||
Two Qubes constraints combine to make "share one USB signer" hard:
|
||||
|
||||
1. **USB is routed at whole-device granularity.** `qvm-usb attach` moves the entire USB device (all interfaces) to one qube. For composite devices (KB2040 HID+CDC, Feather CDC+WebUSB), attaching the signing interface to an app qube also detaches the HID interface from dom0's input proxy → media keys die globally.
|
||||
2. **A USB endpoint is exclusively owned by one process in one qube.** Two qubes cannot each open the CDC/WebUSB node at the same time. Whichever qube opens it captures it.
|
||||
|
||||
A third constraint applies specifically to the browser:
|
||||
|
||||
3. **Browser WebUSB / Web Serial can only open a device attached to the browser's own qube.** A device owned by `sys-usb` is invisible to a browser in `personal`/`work`. So a browser using WebUSB is *forced* to capture the device — which is exactly the behavior the user wants to escape.
|
||||
|
||||
The only way to share is: **no app qube opens the device directly.** Keep the device in one owner qube, run a broker there that holds the single exclusive handle, and multiplex all callers over qrexec.
|
||||
|
||||
---
|
||||
|
||||
## 3. Chosen design: a unified broker in the USB-owner qube
|
||||
|
||||
One long-lived **broker daemon** runs in the owner qube (default `sys-usb`). It:
|
||||
|
||||
- discovers and opens the hardware signer's serial/WebUSB endpoint by **VID:PID** (or BLE address, future),
|
||||
- holds the **single exclusive handle** for the device lifetime,
|
||||
- listens on a local UNIX socket (`/run/nsigner-hw.sock`),
|
||||
- accepts one framed JSON-RPC request per qrexec connection,
|
||||
- **serializes** concurrent callers with an internal lock/queue so frames never interleave on the wire,
|
||||
- forwards the frame to the device, relays the framed response back,
|
||||
- reopens the device on re-enumeration (unplug/replug, 1200-baud touch, CH340 re-enumeration).
|
||||
|
||||
The broker is **hardware-agnostic at the JSON-RPC layer**: every variant speaks the same algorithm-based API ([`README.md`](../README.md) §4) over the same `4-byte big-endian length + UTF-8 JSON` framing. Per-variant logic is isolated in a small **transport adapter**.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
HW[Hardware signer: KB2040, Feather, CYD, Teensy, IR dongle]
|
||||
subgraph OWNER[owner qube: sys-usb default]
|
||||
ADAPT[transport adapter: open by VID:PID]
|
||||
BRK[broker: exclusive handle, serialize, reopen]
|
||||
SVC[qrexec service qubes.NsignerHwRpc]
|
||||
end
|
||||
subgraph DOM0[dom0]
|
||||
INPUT[input proxy: media keys for composite HID]
|
||||
POL[qrexec policy: ask plus deny-by-default]
|
||||
end
|
||||
subgraph Q[any caller qube]
|
||||
APP[CLI, nostr_terminal, NIP-07 extension native helper]
|
||||
end
|
||||
|
||||
HW --> ADAPT
|
||||
ADAPT --> BRK
|
||||
HW -. composite HID .-> INPUT
|
||||
APP -->|qrexec framed JSON-RPC| POL --> SVC --> BRK --> ADAPT --> HW
|
||||
ADAPT --> BRK --> SVC --> POL --> APP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Unified transport adapter model
|
||||
|
||||
The broker core talks to a registry of adapters. Each adapter implements a tiny interface (open / read_frame / write_frame / close / status). Most variants collapse to "open a serial node by VID:PID":
|
||||
|
||||
| Variant | USB identity | Node | Adapter notes |
|
||||
|---|---|---|---|
|
||||
| KB2040 hidden signer | composite HID + CDC, `239a:cafe` | `/dev/ttyACM*` | CDC-ACM; HID stays on dom0 input proxy because device never leaves sys-usb |
|
||||
| Feather S3 TFT | TinyUSB composite CDC + WebUSB vendor, `303a:4001` | `/dev/ttyACM*` | CDC-ACM (preferred); WebUSB vendor endpoint is an alternative adapter, not needed when broker owns CDC |
|
||||
| CYD ESP32-2432S028 | CH340 serial, `1a86:7523` | `/dev/ttyUSB*` | serial; **clear DTR/RTS on open** to avoid ESP32 auto-reset (see [`firmware/README.md`](../firmware/README.md) §CYD note) |
|
||||
| Teensy 4.1 | USB CDC | `/dev/ttyACM*` | CDC-ACM |
|
||||
| IR air-gap dongle | USB CDC dumb pipe | `/dev/ttyACM*` | CDC-ACM; dongle is a transparent byte pipe |
|
||||
| BLE wearable (concept) | BLE GATT | n/a | future adapter: BLE scan + GATT characteristic; stub for now |
|
||||
|
||||
Adapter selection: broker config lists one or more `(VID, PID)` tuples (or a BLE address) and tries them in order until one opens. This lets the operator point the broker at whichever device is plugged in, without changing the broker core.
|
||||
|
||||
Reference logic to reuse: [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py) already does VID:PID discovery + framed read/write. The broker is essentially that logic plus a unix-socket server and a serialize lock.
|
||||
|
||||
---
|
||||
|
||||
## 5. The browser path (the crux of the capture problem)
|
||||
|
||||
**Recommendation: the browser must NOT use WebUSB/Web Serial in the shared model.** It should reach the broker via qrexec through a NIP-07 native-messaging extension.
|
||||
|
||||
Why this is the only sharing-compatible path:
|
||||
|
||||
- WebUSB/Web Serial can only see a device `qvm-usb`-attached to the browser's own qube. That attach captures the whole device (and kills media keys for composite devices). It is the capture the user is trying to eliminate.
|
||||
- The NIP-07 extension already planned in [`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) has a "native messaging bridge" transport. Point that native helper at `qrexec-client-vm sys-usb qubes.NsignerHwRpc` and the browser joins the shared model with zero USB capture.
|
||||
|
||||
Supported modes (both documented, qrexec is the default):
|
||||
|
||||
| Mode | How | Sharing? | Media keys (composite)? |
|
||||
|---|---|---|---|
|
||||
| **qrexec / NIP-07** (recommended) | browser extension native helper → `qrexec-client-vm sys-usb qubes.NsignerHwRpc` | ✅ all qubes share | ✅ preserved |
|
||||
| **WebUSB direct-attach** (opt-out) | `qvm-usb attach personal <device>`, browser opens WebUSB | ❌ browser qube captures device | ❌ media dies globally while attached |
|
||||
|
||||
The direct-attach mode is documented as "this opts out of sharing; use only for isolated single-qube workflows." The default and recommended path is qrexec.
|
||||
|
||||
### 5.1 Concrete finding: nostr_login_lite is the capture problem
|
||||
|
||||
`nostr_login_lite` (sibling project at `~/lt/nostr_login_lite`) is the concrete instance of the browser-capture problem. Its `nsigner` auth method opens the hardware signer **directly** via browser USB APIs — there is no intermediary:
|
||||
|
||||
- [`src/signers/nsigner-webusb.js`](../nostr_login_lite/src/signers/nsigner-webusb.js:11) calls `navigator.usb.requestDevice(...)` then `device.open()` / `claimInterface()` — raw WebUSB.
|
||||
- [`src/signers/nsigner-webserial.js`](../nostr_login_lite/src/signers/nsigner-webserial.js:10) calls `navigator.serial.requestPort()` then `port.open({baudRate:115200,...})` — raw Web Serial.
|
||||
|
||||
Both APIs can only see USB devices routed to the qube the browser runs in. On a normal Linux host the browser sees every USB device; on Qubes the browser sees **only** devices `qvm-usb attach`ed to its qube. So when `nostr_login_lite` connects via the `nsigner` method, it **forces** the device to be attached to the browser's qube — which is exactly the capture this plan exists to eliminate. The library is doing the capturing; it is not a workaround for it.
|
||||
|
||||
Implication for this plan: `nostr_login_lite` needs a **new transport** — a `nsigner-qrexec` signer module that shells out to `qrexec-client-vm sys-usb qubes.NsignerHwRpc` with framed JSON-RPC, instead of opening USB directly. Its public RPC surface (`getPublicKey`, `signEvent`, `nip04Encrypt/Decrypt`, `nip44Encrypt/Decrypt`) is already transport-agnostic — the existing WebUSB and Web Serial classes are two transports implementing the same surface; a qrexec transport would be a third. This is a small, well-scoped addition to `nostr_login_lite` and is the bridge between this broker plan and the browser.
|
||||
|
||||
---
|
||||
|
||||
## 6. Owner-qube decision
|
||||
|
||||
**Recommendation: `sys-usb` (default).** Offer a dedicated `nsigner-usb` qube as a hardened variant.
|
||||
|
||||
| | `sys-usb` (default) | dedicated `nsigner-usb` |
|
||||
|---|---|---|
|
||||
| Media-key input proxy | unchanged — device stays in sys-usb, HID flows to dom0 as today | must re-proxy HID from `nsigner-usb` to dom0 via qrexec input policy (larger change) |
|
||||
| Isolation | broker shares sys-usb's broader USB visibility | broker in a minimal qube that owns only the signer |
|
||||
| Setup complexity | lowest | higher (per-device auto-attach + input-policy migration) |
|
||||
| Trust scope | sys-usb can see sign requests; mitigated by on-device approval + dom0 `ask` | smaller blast radius |
|
||||
|
||||
Decision: **default to `sys-usb`** because (a) it preserves media keys for composite devices with no input-policy migration, (b) it matches the prior per-device plan, and (c) the user's chosen approval model (physical button every signature) is the real trust anchor, making sys-usb's visibility acceptable. Document `nsigner-usb` as an optional hardened path for users who want stronger isolation and are willing to migrate the input proxy (mainly relevant for composite devices).
|
||||
|
||||
---
|
||||
|
||||
## 7. Approval UX
|
||||
|
||||
User chose: **physical button press on the device for every signature** (highest assurance).
|
||||
|
||||
Implications:
|
||||
|
||||
- Device must be in **signer mode** for `sign_event` to work (e.g. KB2040 PLAY+PREV chord). `get_public_key` works in either mode.
|
||||
- Each remote qrexec `sign_event` call **blocks** at the broker until the user physically approves at the hardware.
|
||||
- dom0 `ask` adds a **second, per-call Qubes prompt** identifying the calling qube — defense in depth. Keep it.
|
||||
- The broker must surface, to the caller qube:
|
||||
- `2015 "not in signer mode"` (and any other device error) clearly and actionable,
|
||||
- a "waiting for physical approval" state so the caller knows why it is blocking (optional: a heartbeat/progress frame; v1 can simply block with a timeout).
|
||||
- Optional firmware enhancement (later): forward the source-qube name to the device so the OLED shows "approve kind 1 from qubes:personal?" — requires a firmware caller-field addition; not needed for v1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Identity and enforcement layers
|
||||
|
||||
Unlike the software-signer bridge ([`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md)), there is **no separate persistent nsigner process with a mnemonic** — the hardware holds the keys and performs approval. So the enforcement stack is:
|
||||
|
||||
1. **dom0 qrexec policy** (`ask`/`deny`, per calling qube) — first gate.
|
||||
2. **Hardware physical approval** — final gate, per signature.
|
||||
|
||||
The broker is a **dumb relay**: it does not run n_signer's policy/approval engine, because the hardware is the approval surface. The broker reads `QREXEC_REMOTE_DOMAIN` only to (optionally) log/forward the source qube for display; it is not an enforcement point.
|
||||
|
||||
Per-application granularity inside one qube (the auth-envelope story in [`plans/auth_envelope_other_transports.md`](auth_envelope_other_transports.md)) would require the **firmware** to verify kind-27235 envelopes — a future firmware enhancement, out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
## 9. Concurrency and re-enumeration
|
||||
|
||||
- **Concurrency:** the broker holds one exclusive device handle. An internal mutex + request queue guarantees that concurrent qrexec calls never interleave frames on the wire. Calls are serviced one at a time; others wait.
|
||||
- **Re-enumeration:** unplug/replug, 1200-baud touch reset, or CH340 re-enumeration changes `/dev/ttyACM*` or `/dev/ttyUSB*`. The broker rediscovers by VID:PID and reopens transparently. A call in flight when the device drops returns a clear "device disconnected" error.
|
||||
- **CYD auto-reset:** opening `/dev/ttyUSB*` can reset the ESP32 via CH340 DTR/RTS. The broker clears DTR/RTS immediately after open. Document the 10 µF EN↔GND capacitor mod ([`firmware/README.md`](../firmware/README.md) §CYD) as the hardware-level fix.
|
||||
|
||||
---
|
||||
|
||||
## 10. Components
|
||||
|
||||
### A. Broker daemon (runs in owner qube)
|
||||
|
||||
- Discovers/opens the device via the adapter registry (VID:PID list or BLE address).
|
||||
- Holds the single exclusive handle for the device lifetime.
|
||||
- Listens on `/run/nsigner-hw.sock`.
|
||||
- Accepts one framed JSON-RPC request per connection, forwards to device, returns framed response.
|
||||
- Serializes access with a mutex + queue.
|
||||
- Reopens on re-enumeration.
|
||||
- v1 implementation: Python (pragmatic, reuses [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py)), at `packaging/qubes/hw_bridge/nsigner_hw_broker.py`.
|
||||
- Long-term: a `nsigner hw-broker` C subcommand that ships in the static binary and reuses the existing framing code (mirrors the `nsigner bridge` subcommand in [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) §5.2).
|
||||
|
||||
### B. qrexec service entrypoint (runs in owner qube)
|
||||
|
||||
`packaging/qubes/rpc/qubes.NsignerHwRpc` — a thin stateless relay:
|
||||
|
||||
1. Read one framed request from qrexec stdin.
|
||||
2. Connect to `/run/nsigner-hw.sock`, relay the frame, read the framed reply.
|
||||
3. Write the framed reply to qrexec stdout.
|
||||
4. Exit.
|
||||
|
||||
Mirrors the shape of [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc). Distinct service name (`qubes.NsignerHwRpc`) so the hardware and software paths never collide.
|
||||
|
||||
### C. dom0 policy
|
||||
|
||||
`packaging/qubes/policy.d/41-nsigner-hw.policy`:
|
||||
|
||||
```
|
||||
qubes.NsignerHwRpc * @anyvm @tag:nsigner-hw-bridge ask default_target=sys-usb
|
||||
qubes.NsignerHwRpc * @anyvm @anyvm deny
|
||||
```
|
||||
|
||||
- `ask` + deny-by-default mirrors [`40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy).
|
||||
- Tag the owner qube: `qvm-tags sys-usb add nsigner-hw-bridge`.
|
||||
|
||||
### D. Caller helper (any qube)
|
||||
|
||||
Extend [`documents/qubes_client_examples.md`](../documents/qubes_client_examples.md) with `qubes.NsignerHwRpc` examples (shell + Python): framed `get_public_key` / `sign_event` over `qrexec-client-vm sys-usb qubes.NsignerHwRpc`.
|
||||
|
||||
### E. Browser integration (recommended path)
|
||||
|
||||
Wire the NIP-07 extension's native-messaging helper ([`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) §5) to call `qrexec-client-vm sys-usb qubes.NsignerHwRpc`. Document WebUSB direct-attach as an opt-out that breaks sharing.
|
||||
|
||||
### F. Install scripts
|
||||
|
||||
- `install-hw-bridge.sh` (owner-qube side): broker + service + udev rules + autostart, AppVM-persistent via `/rw/config/rc.local` + template package (pattern in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) §5.5).
|
||||
- `install-hw-policy.sh` (dom0 side): install `41-nsigner-hw.policy`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Packaging and persistence
|
||||
|
||||
`sys-usb` is usually an AppVM: root filesystem resets at reboot. Persist via:
|
||||
|
||||
- broker + service installed into the template (or `/rw/bind`-mounted),
|
||||
- udev rules for the CDC/serial node permissions inside sys-usb (reuse the `99-rp2040.rules` / `99-nsigner-webusb.rules` approach in [`firmware/README.md`](../firmware/README.md)),
|
||||
- `/rw/config/rc.local` starts the broker at boot,
|
||||
- `qvm-tags sys-usb add nsigner-hw-bridge` and dom0 policy persist in dom0.
|
||||
|
||||
---
|
||||
|
||||
## 12. Security model
|
||||
|
||||
- **Private keys never leave the hardware.** The broker only relays opaque frames; it cannot extract keys.
|
||||
- **Owner-qube trust scope:** sys-usb can see what you ask to sign and could deny/forge requests. Mitigated by (a) on-device physical approval per signature, (b) dom0 `ask` per calling qube, (c) deny-by-default policy.
|
||||
- **No mnemonic on disk/argv/env:** the broker holds no key material at all — the hardware is the key store.
|
||||
- **No off-host connectivity:** qrexec is intra-host IPC; no network.
|
||||
- **Hardened variant:** a dedicated `nsigner-usb` qube shrinks the broker's blast radius at the cost of input-proxy migration for composite devices.
|
||||
|
||||
---
|
||||
|
||||
## 13. Risks and edge cases
|
||||
|
||||
- **Signer-mode requirement:** remote `sign_event` fails with `2015` unless the device is in signer mode; broker returns a clear, actionable error.
|
||||
- **Device re-enumeration:** broker rediscover by VID:PID; in-flight call returns "device disconnected."
|
||||
- **Concurrency:** mutex + queue in broker; concurrent qube calls serialized.
|
||||
- **CYD DTR/RTS reset:** clear DTR/RTS on open; document 10 µF capacitor mod.
|
||||
- **Composite HID:** keep device in sys-usb; do **not** `qvm-usb attach` to app qubes or media dies.
|
||||
- **Browser WebUSB capture:** documented as opt-out; recommended path is qrexec/NIP-07.
|
||||
- **Blocking approval UX:** a sign call blocks until physical approval; broker should expose a timeout and a "waiting for approval" state so callers do not hang silently.
|
||||
- **sys-usb AppVM persistence:** broker install must survive reboot via template + `/rw/config/rc.local`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Implementation checklist
|
||||
|
||||
Code:
|
||||
- [ ] Broker daemon `packaging/qubes/hw_bridge/nsigner_hw_broker.py`: adapter registry (VID:PID open), exclusive handle, unix socket, serialize lock/queue, reopen-on-reenumerate; reuse framing from [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py).
|
||||
- [ ] Adapter config covering KB2040 `239a:cafe`, Feather `303a:4001`, CYD `1a86:7523`, Teensy CDC, IR dongle CDC; CYD adapter clears DTR/RTS on open.
|
||||
- [ ] (Optional, later) `nsigner hw-broker` C subcommand replacing the Python broker.
|
||||
|
||||
Packaging:
|
||||
- [ ] qrexec service `packaging/qubes/rpc/qubes.NsignerHwRpc`: relay one frame stdin→socket→stdout.
|
||||
- [ ] dom0 policy `packaging/qubes/policy.d/41-nsigner-hw.policy`: `ask` + deny-by-default, target `sys-usb`.
|
||||
- [ ] `install-hw-bridge.sh` (owner qube: broker + service + udev + autostart, AppVM-persistent).
|
||||
- [ ] `install-hw-policy.sh` (dom0).
|
||||
|
||||
Docs and callers:
|
||||
- [ ] Extend [`documents/qubes_client_examples.md`](../documents/qubes_client_examples.md) with `qubes.NsignerHwRpc` shell + Python examples.
|
||||
- [ ] Document the browser qrexec/NIP-07 path and the WebUSB direct-attach opt-out.
|
||||
- [ ] Document owner-qube choice (sys-usb default, nsigner-usb hardened variant) and the composite-HID input-proxy tradeoff.
|
||||
|
||||
Verification runbook:
|
||||
- [ ] Media keys still work globally (composite device stays in sys-usb).
|
||||
- [ ] `get_public_key` from a caller qube succeeds.
|
||||
- [ ] `sign_event` from a caller qube blocks until physical approval, then succeeds.
|
||||
- [ ] `sign_event` with device not in signer mode returns clear `2015` error.
|
||||
- [ ] Deny from an untagged/unsupported qube.
|
||||
- [ ] Two qubes signing concurrently are serialized (no frame interleaving).
|
||||
- [ ] Survive unplug/replug: broker reopens, next call succeeds.
|
||||
- [ ] Browser via NIP-07 native helper → qrexec signs without capturing USB.
|
||||
@@ -118,6 +118,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -143,6 +149,12 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
@@ -165,6 +177,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates */
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -176,6 +193,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
@@ -318,9 +337,12 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -1787,6 +1809,21 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
if (rc == SELECTOR_ERR_NO_DEFAULT) {
|
||||
return make_error_response(id_str, 1003, "no_default_role");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_PATH_MISMATCH) {
|
||||
return make_error_response(id_str, 2003, "path_not_allowed");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_NOSTR_INDEX_DEPRECATED) {
|
||||
return make_error_response(id_str, 2006, "nostr_index is deprecated — use --role main --path m/44'/1237'/N'/0/0 instead");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_INDEX_DEPRECATED) {
|
||||
return make_error_response(id_str, 2007, "index is deprecated for nostr verbs — use --path with the full path instead");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_ROLE_REQUIRED) {
|
||||
return make_error_response(id_str, 2008, "--role is required when using --path");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_PATH_REQUIRED) {
|
||||
return make_error_response(id_str, 2009, "--path is required for roles with variable path templates");
|
||||
}
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +180,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
+211
-9
@@ -118,6 +118,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -176,6 +182,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
@@ -673,6 +681,128 @@ int socket_name_random(char *out, size_t out_len);
|
||||
|
||||
#define NSIGNER_ENCRYPT_OUTPUT_MAX 65536
|
||||
|
||||
/*
|
||||
* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/1/1/0") into a
|
||||
* uint32_t array suitable for nostr_bip32_derive_path(). Hardened segments
|
||||
* are indicated by a trailing ' (or h). Returns the number of path components
|
||||
* on success, or -1 on parse error. max_path is the max number of entries
|
||||
* in the path_out array.
|
||||
*/
|
||||
static int parse_bip44_path(const char *path_str, uint32_t *path_out, int max_path) {
|
||||
char buf[ROLE_PATH_MAX];
|
||||
char *p;
|
||||
int count = 0;
|
||||
|
||||
if (path_str == NULL || path_out == NULL || max_path <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
strncpy(buf, path_str, sizeof(buf) - 1);
|
||||
buf[sizeof(buf) - 1] = '\0';
|
||||
|
||||
/* Skip leading "m" or "m/" */
|
||||
p = buf;
|
||||
if (*p == 'm' || *p == 'M') {
|
||||
p++;
|
||||
if (*p == '/') {
|
||||
p++;
|
||||
} else if (*p != '\0') {
|
||||
return -1; /* "m" must be followed by '/' or end */
|
||||
}
|
||||
}
|
||||
|
||||
while (*p != '\0' && count < max_path) {
|
||||
char *slash = strchr(p, '/');
|
||||
char seg[24];
|
||||
size_t seg_len;
|
||||
int hardened = 0;
|
||||
char *endptr = NULL;
|
||||
long val;
|
||||
|
||||
if (slash != NULL) {
|
||||
seg_len = (size_t)(slash - p);
|
||||
} else {
|
||||
seg_len = strlen(p);
|
||||
}
|
||||
if (seg_len == 0 || seg_len >= sizeof(seg)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(seg, p, seg_len);
|
||||
seg[seg_len] = '\0';
|
||||
|
||||
/* Check for hardened marker ' or h at end */
|
||||
if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' || seg[seg_len - 1] == 'H') {
|
||||
hardened = 1;
|
||||
seg[seg_len - 1] = '\0';
|
||||
}
|
||||
|
||||
val = strtol(seg, &endptr, 10);
|
||||
if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
path_out[count] = (uint32_t)val;
|
||||
if (hardened) {
|
||||
path_out[count] |= 0x80000000u;
|
||||
}
|
||||
count++;
|
||||
|
||||
p = (slash != NULL) ? slash + 1 : "";
|
||||
if (*p == '\0') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/*
|
||||
* Derive a secp256k1 key from an explicit BIP-44 path string.
|
||||
* Uses BIP-32 derivation (nostr_bip32_key_from_seed + nostr_bip32_derive_path).
|
||||
* priv_out and pub_out must each be at least 32 bytes. Returns 0 on success,
|
||||
* -1 on failure.
|
||||
*/
|
||||
static int derive_secp256k1_from_path(const char *mnemonic, const char *path_str,
|
||||
unsigned char *priv_out, unsigned char *pub_out) {
|
||||
unsigned char bip39_seed[64];
|
||||
nostr_hd_key_t master_key;
|
||||
nostr_hd_key_t derived_key;
|
||||
uint32_t path[16];
|
||||
int path_len;
|
||||
|
||||
if (mnemonic == NULL || path_str == NULL || priv_out == NULL || pub_out == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
path_len = parse_bip44_path(path_str, path, (int)(sizeof(path) / sizeof(path[0])));
|
||||
if (path_len <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (nostr_bip39_mnemonic_to_seed(mnemonic, "", bip39_seed, sizeof(bip39_seed)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (nostr_bip32_key_from_seed(bip39_seed, sizeof(bip39_seed), &master_key) != 0) {
|
||||
secure_memzero(bip39_seed, sizeof(bip39_seed));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (nostr_bip32_derive_path(&master_key, path, (size_t)path_len, &derived_key) != 0) {
|
||||
secure_memzero(bip39_seed, sizeof(bip39_seed));
|
||||
secure_memzero(&master_key, sizeof(master_key));
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(priv_out, derived_key.private_key, 32);
|
||||
memcpy(pub_out, derived_key.public_key + 1, 32); /* x-only (drop compression prefix) */
|
||||
|
||||
secure_memzero(bip39_seed, sizeof(bip39_seed));
|
||||
secure_memzero(&master_key, sizeof(master_key));
|
||||
secure_memzero(&derived_key, sizeof(derived_key));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Derive a secp256k1 (Nostr) key for a role into the variable-length
|
||||
* derived_key_t. Returns 0 on success, -1 on failure.
|
||||
@@ -682,6 +812,7 @@ static int derive_secp256k1(derived_key_t *dst, const role_entry_t *role,
|
||||
unsigned char priv[32];
|
||||
unsigned char pub[32];
|
||||
const crypto_alg_sizes_t *sz;
|
||||
int rc;
|
||||
|
||||
sz = crypto_alg_get_sizes(CRYPTO_ALG_SECP256K1);
|
||||
if (sz == NULL) {
|
||||
@@ -696,8 +827,14 @@ static int derive_secp256k1(derived_key_t *dst, const role_entry_t *role,
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic),
|
||||
role->nostr_index, priv, pub) != 0) {
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH) {
|
||||
rc = derive_secp256k1_from_path(mnemonic_get_phrase(mnemonic),
|
||||
role->role_path, priv, pub);
|
||||
} else {
|
||||
rc = nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic),
|
||||
role->nostr_index, priv, pub);
|
||||
}
|
||||
if (rc != 0) {
|
||||
secure_memzero(priv, sizeof(priv));
|
||||
secure_memzero(pub, sizeof(pub));
|
||||
secure_buf_free(&dst->private_key);
|
||||
@@ -747,7 +884,12 @@ static int derive_ed25519(derived_key_t *dst, const role_entry_t *role,
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(path, sizeof(path), "m/44'/102001'/%d'/0'/0'", role->nostr_index);
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH) {
|
||||
strncpy(path, role->role_path, sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
} else {
|
||||
snprintf(path, sizeof(path), "m/44'/102001'/%d'/0'/0'", role->nostr_index);
|
||||
}
|
||||
|
||||
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
|
||||
seed, sizeof(seed)) != 0) {
|
||||
@@ -805,7 +947,12 @@ static int derive_x25519(derived_key_t *dst, const role_entry_t *role,
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(path, sizeof(path), "m/44'/102002'/%d'/0'/0'", role->nostr_index);
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH) {
|
||||
strncpy(path, role->role_path, sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
} else {
|
||||
snprintf(path, sizeof(path), "m/44'/102002'/%d'/0'/0'", role->nostr_index);
|
||||
}
|
||||
|
||||
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
|
||||
seed, sizeof(seed)) != 0) {
|
||||
@@ -861,7 +1008,12 @@ static int derive_ml_dsa_65(derived_key_t *dst, const role_entry_t *role,
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(path, sizeof(path), "m/44'/102003'/%d'/0'/0'", role->nostr_index);
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH) {
|
||||
strncpy(path, role->role_path, sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
} else {
|
||||
snprintf(path, sizeof(path), "m/44'/102003'/%d'/0'/0'", role->nostr_index);
|
||||
}
|
||||
|
||||
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
|
||||
seed, sizeof(seed)) != 0) {
|
||||
@@ -914,7 +1066,12 @@ static int derive_slh_dsa_128s(derived_key_t *dst, const role_entry_t *role,
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(path, sizeof(path), "m/44'/102004'/%d'/0'/0'", role->nostr_index);
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH) {
|
||||
strncpy(path, role->role_path, sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
} else {
|
||||
snprintf(path, sizeof(path), "m/44'/102004'/%d'/0'/0'", role->nostr_index);
|
||||
}
|
||||
|
||||
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
|
||||
seed, sizeof(seed)) != 0) {
|
||||
@@ -967,7 +1124,12 @@ static int derive_ml_kem_768(derived_key_t *dst, const role_entry_t *role,
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(path, sizeof(path), "m/44'/102005'/%d'/0'/0'", role->nostr_index);
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH) {
|
||||
strncpy(path, role->role_path, sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
} else {
|
||||
snprintf(path, sizeof(path), "m/44'/102005'/%d'/0'/0'", role->nostr_index);
|
||||
}
|
||||
|
||||
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
|
||||
seed, sizeof(seed)) != 0) {
|
||||
@@ -1047,15 +1209,48 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
|
||||
for (i = 0; i < table->count; ++i) {
|
||||
role_entry_t *role = &table->entries[i];
|
||||
derived_key_t *dst = &store->keys[i];
|
||||
char saved_path[ROLE_PATH_MAX];
|
||||
int substituted = 0;
|
||||
|
||||
role->derived = 0;
|
||||
role->pubkey_hex[0] = '\0';
|
||||
|
||||
if (role->selector_type != SELECTOR_NOSTR_INDEX) {
|
||||
if (role->selector_type != SELECTOR_NOSTR_INDEX &&
|
||||
role->selector_type != SELECTOR_ROLE_PATH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Template roles (path contains "%d") cannot be derived as-is because
|
||||
* "%d" is not a valid BIP-44 segment. If the role has a default index,
|
||||
* substitute it into the path temporarily so a key can be pre-derived
|
||||
* for the default index. Roles without a default index are skipped
|
||||
* here (they will be derived on-demand when a client supplies a path). */
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") != NULL) {
|
||||
if (role->path_default_index < 0) {
|
||||
continue;
|
||||
}
|
||||
/* Save the template and substitute the default index */
|
||||
strncpy(saved_path, role->role_path, sizeof(saved_path) - 1);
|
||||
saved_path[sizeof(saved_path) - 1] = '\0';
|
||||
snprintf(role->role_path, sizeof(role->role_path),
|
||||
"%s", saved_path);
|
||||
/* Format the template (in saved_path) with the default index */
|
||||
{
|
||||
char concrete[ROLE_PATH_MAX];
|
||||
snprintf(concrete, sizeof(concrete), saved_path, role->path_default_index);
|
||||
strncpy(role->role_path, concrete, sizeof(role->role_path) - 1);
|
||||
role->role_path[sizeof(role->role_path) - 1] = '\0';
|
||||
}
|
||||
substituted = 1;
|
||||
}
|
||||
|
||||
if (derive_for_role(dst, role, mnemonic) != 0) {
|
||||
if (substituted) {
|
||||
/* Restore the template path */
|
||||
strncpy(role->role_path, saved_path, sizeof(role->role_path) - 1);
|
||||
role->role_path[sizeof(role->role_path) - 1] = '\0';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1063,6 +1258,12 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
|
||||
role->pubkey_hex[sizeof(role->pubkey_hex) - 1] = '\0';
|
||||
role->derived = 1;
|
||||
|
||||
if (substituted) {
|
||||
/* Restore the template path (keep derived=1 + pubkey) */
|
||||
strncpy(role->role_path, saved_path, sizeof(role->role_path) - 1);
|
||||
role->role_path[sizeof(role->role_path) - 1] = '\0';
|
||||
}
|
||||
|
||||
derived_count++;
|
||||
}
|
||||
|
||||
@@ -1099,7 +1300,8 @@ int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_st
|
||||
dst->alg = CRYPTO_ALG_UNKNOWN;
|
||||
dst->valid = 0;
|
||||
|
||||
if (role->selector_type != SELECTOR_NOSTR_INDEX) {
|
||||
if (role->selector_type != SELECTOR_NOSTR_INDEX &&
|
||||
role->selector_type != SELECTOR_ROLE_PATH) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
+1207
-119
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +180,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -116,6 +116,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -141,6 +147,12 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
@@ -163,6 +175,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates *//
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -174,6 +191,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
@@ -321,6 +340,16 @@ int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/*
|
||||
* Role-aware policy check: if the role has requires_approval=0 (role-as-password),
|
||||
* returns POLICY_ALLOW immediately without checking policy entries.
|
||||
* Otherwise falls through to policy_check().
|
||||
*/
|
||||
int policy_check_with_role(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
const role_entry_t *role,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -1260,3 +1289,27 @@ int policy_check_algorithm(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
return POLICY_NO_MATCH;
|
||||
}
|
||||
|
||||
int policy_check_with_role(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
const role_entry_t *role,
|
||||
policy_source_t *out_source) {
|
||||
if (out_source != NULL) {
|
||||
*out_source = POLICY_SOURCE_DEFAULT;
|
||||
}
|
||||
|
||||
if (table == NULL || caller_id == NULL || verb == NULL || role_name == NULL || purpose == NULL) {
|
||||
return POLICY_NO_MATCH;
|
||||
}
|
||||
|
||||
/* Role-as-password: if the role has requires_approval=0, authorize immediately */
|
||||
if (role != NULL && role->requires_approval == 0) {
|
||||
if (out_source != NULL) {
|
||||
*out_source = POLICY_SOURCE_DEFAULT;
|
||||
}
|
||||
return POLICY_ALLOW;
|
||||
}
|
||||
|
||||
/* Otherwise, fall through to the standard policy check */
|
||||
return policy_check(table, caller_id, verb, role_name, purpose, out_source);
|
||||
}
|
||||
|
||||
+13
-1
@@ -126,6 +126,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -167,7 +173,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
|
||||
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
|
||||
role_purpose_t purpose, role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index,
|
||||
const int *allowed_indices, int allowed_count);
|
||||
/* from selector.h */
|
||||
|
||||
|
||||
@@ -187,6 +197,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -116,6 +116,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -142,6 +148,31 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
* For fixed paths (no %d), does an exact string comparison.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/*
|
||||
* Extract the numeric index from a concrete derivation path that matches
|
||||
* a role's path template (containing a single "%d" placeholder).
|
||||
* Returns the extracted index on success, or -1 if the path does not match
|
||||
* the template or no %d placeholder exists in the template.
|
||||
* For fixed paths (no %d), returns -1 (no variable index).
|
||||
*/
|
||||
int role_path_extract_index(const char *path, const char *template);
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template
|
||||
* AND the extracted index falls within the role's allowed range/set.
|
||||
* Returns 1 if the path matches and the index is allowed, 0 otherwise.
|
||||
* For fixed paths (no %d), this is equivalent to role_path_matches_template().
|
||||
*/
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
|
||||
@@ -166,6 +197,11 @@ int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates *//
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -177,6 +213,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
@@ -321,9 +359,12 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -709,6 +750,7 @@ int socket_name_random(char *out, size_t out_len);
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int str_eq(const char *a, const char *b) {
|
||||
@@ -832,6 +874,241 @@ int role_table_register_nostr_index(role_table_t *table, int nostr_index) {
|
||||
return role_table_add(table, &role);
|
||||
}
|
||||
|
||||
/*
|
||||
* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path
|
||||
* template (with optional %d placeholder and range/set). Idempotent: if a role
|
||||
* with the same path template already exists, returns 0.
|
||||
*
|
||||
* `path` may contain a "%d" placeholder (for ranged/set templates) or be a
|
||||
* fixed path (no placeholder). range_lo/range_hi specify the allowed index
|
||||
* range for the placeholder; for fixed paths, pass range_lo == range_hi == 0.
|
||||
* If allowed_indices != NULL and allowed_count > 0, the set form is used
|
||||
* instead of the range. default_index is the index used when a client sends
|
||||
* {"role":"name"} without an explicit "index"; -1 means require an explicit
|
||||
* index.
|
||||
*/
|
||||
int role_table_register_role_path(role_table_t *table, const char *name,
|
||||
const char *path, role_purpose_t purpose,
|
||||
role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index,
|
||||
const int *allowed_indices, int allowed_count) {
|
||||
role_entry_t role;
|
||||
int i;
|
||||
|
||||
if (table == NULL || name == NULL || path == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Idempotent: check if a role with this path template already exists */
|
||||
for (i = 0; i < table->count; ++i) {
|
||||
if (table->entries[i].selector_type == SELECTOR_ROLE_PATH &&
|
||||
strcmp(table->entries[i].role_path, path) == 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
memset(&role, 0, sizeof(role));
|
||||
|
||||
strncpy(role.name, name, sizeof(role.name) - 1);
|
||||
role.name[sizeof(role.name) - 1] = '\0';
|
||||
|
||||
strncpy(role.purpose_str, role_purpose_to_str(purpose), sizeof(role.purpose_str) - 1);
|
||||
role.purpose_str[sizeof(role.purpose_str) - 1] = '\0';
|
||||
|
||||
strncpy(role.curve_str, role_curve_to_str(curve), sizeof(role.curve_str) - 1);
|
||||
role.curve_str[sizeof(role.curve_str) - 1] = '\0';
|
||||
|
||||
role.purpose = purpose;
|
||||
role.curve = curve;
|
||||
role.selector_type = SELECTOR_ROLE_PATH;
|
||||
strncpy(role.role_path, path, sizeof(role.role_path) - 1);
|
||||
role.role_path[sizeof(role.role_path) - 1] = '\0';
|
||||
role.nostr_index = -1;
|
||||
role.path_range_lo = range_lo;
|
||||
role.path_range_hi = range_hi;
|
||||
role.path_default_index = default_index;
|
||||
if (allowed_indices != NULL && allowed_count > 0) {
|
||||
int copy_n = allowed_count;
|
||||
if (copy_n > (int)(sizeof(role.path_allowed_indices) / sizeof(role.path_allowed_indices[0]))) {
|
||||
copy_n = (int)(sizeof(role.path_allowed_indices) / sizeof(role.path_allowed_indices[0]));
|
||||
}
|
||||
memcpy(role.path_allowed_indices, allowed_indices, (size_t)copy_n * sizeof(int));
|
||||
role.path_allowed_count = copy_n;
|
||||
} else {
|
||||
role.path_allowed_count = 0;
|
||||
}
|
||||
role.derived = 0;
|
||||
|
||||
return role_table_add(table, &role);
|
||||
}
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
* For fixed paths (no %d), does an exact string comparison.
|
||||
*
|
||||
* Examples:
|
||||
* template "m/44'/1237'/0'/0/0" matches path "m/44'/1237'/0'/0/0" only
|
||||
* template "m/44'/1237'/%d'/0/0" matches "m/44'/1237'/5'/0/0" for any %d value
|
||||
* template "m/44'/1237'/%d/0/0" matches "m/44'/1237'/5/0/0" (unhardened)
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template) {
|
||||
const char *p = path;
|
||||
const char *t = template;
|
||||
|
||||
if (path == NULL || template == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (*t != '\0' && *p != '\0') {
|
||||
if (*t == '%' && *(t + 1) == 'd') {
|
||||
/* %d placeholder — skip one path segment in the path */
|
||||
t += 2; /* skip "%d" */
|
||||
/* Skip optional hardened marker after %d */
|
||||
if (*t == '\'' || *t == 'h' || *t == 'H') {
|
||||
t++;
|
||||
}
|
||||
/* Skip the corresponding segment in the path (digits, possibly with ' or h) */
|
||||
if (*p == '/') {
|
||||
/* Path has a slash where we expect a segment — mismatch */
|
||||
return 0;
|
||||
}
|
||||
while (*p != '\0' && *p != '/') {
|
||||
p++;
|
||||
}
|
||||
/* If template has more after %d, it should start with '/' */
|
||||
if (*t == '/' && *p == '/') {
|
||||
t++;
|
||||
p++;
|
||||
} else if (*t == '\0' && *p == '\0') {
|
||||
/* Both at end — exact match */
|
||||
return 1;
|
||||
} else if (*t == '\0' && *p == '/') {
|
||||
/* Template ended but path has trailing slash — no match */
|
||||
return 0;
|
||||
} else if (*t == '/' && *p == '\0') {
|
||||
/* Path ended but template has more — no match */
|
||||
return 0;
|
||||
}
|
||||
/* If one has a separator and the other doesn't, let the loop continue */
|
||||
} else if (*t == *p) {
|
||||
t++;
|
||||
p++;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Both should be at the end */
|
||||
return (*t == '\0' && *p == '\0') ? 1 : 0;
|
||||
}
|
||||
|
||||
int role_path_extract_index(const char *path, const char *template) {
|
||||
const char *p = path;
|
||||
const char *t = template;
|
||||
const char *seg_start;
|
||||
char seg_buf[32];
|
||||
size_t seg_len;
|
||||
long val;
|
||||
char *endp;
|
||||
|
||||
if (path == NULL || template == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* If template has no %d, there is no variable index to extract */
|
||||
if (strstr(template, "%d") == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (*t != '\0' && *p != '\0') {
|
||||
if (*t == '%' && *(t + 1) == 'd') {
|
||||
/* %d placeholder — extract the corresponding path segment */
|
||||
t += 2; /* skip "%d" */
|
||||
/* Skip optional hardened marker after %d in template */
|
||||
if (*t == '\'' || *t == 'h' || *t == 'H') {
|
||||
t++;
|
||||
}
|
||||
/* Extract the segment from the path (up to next '/' or end) */
|
||||
if (*p == '/') {
|
||||
return -1; /* path has a slash where a segment is expected */
|
||||
}
|
||||
seg_start = p;
|
||||
while (*p != '\0' && *p != '/') {
|
||||
p++;
|
||||
}
|
||||
seg_len = (size_t)(p - seg_start);
|
||||
if (seg_len == 0 || seg_len >= sizeof(seg_buf)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(seg_buf, seg_start, seg_len);
|
||||
seg_buf[seg_len] = '\0';
|
||||
/* Strip optional trailing hardened marker from the segment */
|
||||
if (seg_len > 0 &&
|
||||
(seg_buf[seg_len - 1] == '\'' || seg_buf[seg_len - 1] == 'h' ||
|
||||
seg_buf[seg_len - 1] == 'H')) {
|
||||
seg_buf[seg_len - 1] = '\0';
|
||||
}
|
||||
endp = NULL;
|
||||
val = strtol(seg_buf, &endp, 10);
|
||||
if (*endp != '\0' || val < 0) {
|
||||
return -1;
|
||||
}
|
||||
return (int)val;
|
||||
} else if (*t == *p) {
|
||||
t++;
|
||||
p++;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role) {
|
||||
int index;
|
||||
|
||||
if (path == NULL || role == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Fixed path (no %d) — just check structural match */
|
||||
if (strstr(role->role_path, "%d") == NULL) {
|
||||
return role_path_matches_template(path, role->role_path);
|
||||
}
|
||||
|
||||
/* Template path — check structural match first */
|
||||
if (!role_path_matches_template(path, role->role_path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Extract the index and check it against the allowed range/set */
|
||||
index = role_path_extract_index(path, role->role_path);
|
||||
if (index < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (role->path_allowed_count > 0) {
|
||||
/* Set form: check if index is in the allowed set */
|
||||
int j;
|
||||
for (j = 0; j < role->path_allowed_count; j++) {
|
||||
if (role->path_allowed_indices[j] == index) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Range form: check lo..hi */
|
||||
if (role->path_range_lo < 0 || role->path_range_hi < 0) {
|
||||
/* No range configured — deny (fail-closed) */
|
||||
return 0;
|
||||
}
|
||||
return (index >= role->path_range_lo && index <= role->path_range_hi) ? 1 : 0;
|
||||
}
|
||||
|
||||
role_purpose_t role_purpose_from_str(const char *s) {
|
||||
if (str_eq(s, "nostr")) {
|
||||
return PURPOSE_NOSTR;
|
||||
|
||||
@@ -118,6 +118,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -176,6 +182,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
+81
-14
@@ -116,6 +116,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -142,6 +148,17 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Check whether a concrete path matches a role's template AND the extracted
|
||||
* index is within the role's allowed range/set. Returns 1 if allowed, 0 not. */
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
|
||||
@@ -163,6 +180,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates */
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -174,6 +196,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
@@ -318,9 +342,12 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -716,7 +743,6 @@ void selector_request_init(selector_request_t *req) {
|
||||
}
|
||||
|
||||
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out) {
|
||||
int selector_count = 0;
|
||||
role_entry_t *match = NULL;
|
||||
|
||||
if (out != NULL) {
|
||||
@@ -727,31 +753,62 @@ int selector_resolve(const selector_request_t *req, role_table_t *table, role_en
|
||||
return SELECTOR_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
selector_count += req->has_role ? 1 : 0;
|
||||
selector_count += req->has_nostr_index ? 1 : 0;
|
||||
selector_count += req->has_role_path ? 1 : 0;
|
||||
/* ---- Deprecated selectors: reject with clear error messages ---- */
|
||||
|
||||
if (selector_count > 1) {
|
||||
return SELECTOR_ERR_AMBIGUOUS;
|
||||
/* nostr_index is deprecated */
|
||||
if (req->has_nostr_index) {
|
||||
return SELECTOR_ERR_NOSTR_INDEX_DEPRECATED;
|
||||
}
|
||||
|
||||
if (selector_count == 1) {
|
||||
if (req->has_role) {
|
||||
match = role_table_find_by_name(table, req->role_name);
|
||||
} else if (req->has_nostr_index) {
|
||||
match = role_table_find_by_nostr_index(table, req->nostr_index);
|
||||
} else if (req->has_role_path) {
|
||||
match = role_table_find_by_path(table, req->role_path);
|
||||
}
|
||||
/* index without role is deprecated for nostr verbs (handled in dispatcher) */
|
||||
if (req->has_index && !req->has_role) {
|
||||
return SELECTOR_ERR_INDEX_DEPRECATED;
|
||||
}
|
||||
|
||||
/* role_path without role is not allowed */
|
||||
if (req->has_role_path && !req->has_role) {
|
||||
return SELECTOR_ERR_ROLE_REQUIRED;
|
||||
}
|
||||
|
||||
/* ---- New model: role + role_path combined ---- */
|
||||
|
||||
if (req->has_role && req->has_role_path) {
|
||||
/* Combined selector: look up role by name, verify path matches template */
|
||||
match = role_table_find_by_name(table, req->role_name);
|
||||
if (match == NULL) {
|
||||
return SELECTOR_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Verify the requested path matches the role's template AND that the
|
||||
* extracted index falls within the role's allowed range/set. This
|
||||
* rejects paths like m/44'/1237'/0'/0/0 against a template
|
||||
* m/44'/1237'/%d'/0/0 with range 1-100. */
|
||||
if (!role_path_matches_with_range(req->role_path, match)) {
|
||||
return SELECTOR_ERR_PATH_MISMATCH;
|
||||
}
|
||||
|
||||
*out = match;
|
||||
return SELECTOR_OK;
|
||||
}
|
||||
|
||||
if (req->has_role && !req->has_role_path) {
|
||||
/* Role specified without path — check if role has a fixed path (no %d) */
|
||||
match = role_table_find_by_name(table, req->role_name);
|
||||
if (match == NULL) {
|
||||
return SELECTOR_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* If the role has a fixed path (no variable segments), use it */
|
||||
if (strstr(match->role_path, "%d") == NULL) {
|
||||
*out = match;
|
||||
return SELECTOR_OK;
|
||||
}
|
||||
|
||||
/* Role has variable path template — path is required */
|
||||
return SELECTOR_ERR_PATH_REQUIRED;
|
||||
}
|
||||
|
||||
/* No selectors at all — try default role */
|
||||
match = role_table_get_default(table);
|
||||
if (match == NULL) {
|
||||
return SELECTOR_ERR_NO_DEFAULT;
|
||||
@@ -771,6 +828,16 @@ const char *selector_strerror(int err) {
|
||||
return "role_not_found";
|
||||
case SELECTOR_ERR_NO_DEFAULT:
|
||||
return "no_default_role";
|
||||
case SELECTOR_ERR_PATH_MISMATCH:
|
||||
return "path_mismatch";
|
||||
case SELECTOR_ERR_NOSTR_INDEX_DEPRECATED:
|
||||
return "nostr_index is deprecated — use --role main --path m/44'/1237'/N'/0/0 instead";
|
||||
case SELECTOR_ERR_INDEX_DEPRECATED:
|
||||
return "index is deprecated for nostr verbs — use --path with the full path instead";
|
||||
case SELECTOR_ERR_ROLE_REQUIRED:
|
||||
return "--role is required when using --path";
|
||||
case SELECTOR_ERR_PATH_REQUIRED:
|
||||
return "--path is required for roles with variable path templates";
|
||||
default:
|
||||
return "unknown_selector_error";
|
||||
}
|
||||
|
||||
+582
-26
@@ -119,6 +119,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -144,6 +150,20 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Extract the numeric index from a concrete path matching a %d template.
|
||||
* Returns the index, or -1 if no %d or no match. */
|
||||
int role_path_extract_index(const char *path, const char *template);
|
||||
|
||||
/* Check whether a concrete path matches a role's template AND the extracted
|
||||
* index is within the role's allowed range/set. Returns 1 if allowed, 0 not. */
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
@@ -160,7 +180,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
|
||||
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
|
||||
role_purpose_t purpose, role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index,
|
||||
const int *allowed_indices, int allowed_count);
|
||||
/* from selector.h */
|
||||
|
||||
|
||||
@@ -169,6 +193,11 @@ int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates */
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -180,6 +209,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
@@ -332,6 +363,16 @@ int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/*
|
||||
* Role-aware policy check: if the role has requires_approval=0 (role-as-password),
|
||||
* returns POLICY_ALLOW immediately without checking policy entries.
|
||||
* Otherwise falls through to policy_check().
|
||||
*/
|
||||
int policy_check_with_role(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
const role_entry_t *role,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -677,6 +718,24 @@ typedef struct {
|
||||
#define INDEX_WHITELIST_MAX 256 /* nostr_index range 0-255 */
|
||||
#define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8) /* 32 bytes */
|
||||
|
||||
#define PATH_WHITELIST_MAX_TEMPLATES 16
|
||||
#define PATH_TEMPLATE_MAX_LEN 128
|
||||
#define PATH_TEMPLATE_MAX_INDICES 64 /* max allowed indices per template (for sets) */
|
||||
|
||||
typedef struct {
|
||||
char template[PATH_TEMPLATE_MAX_LEN]; /* e.g. "m/44'/1237'/%d/1/0" — one %d placeholder */
|
||||
int range_lo; /* inclusive lower bound (for range form) */
|
||||
int range_hi; /* inclusive upper bound (== range_lo for single) */
|
||||
int allowed_indices[PATH_TEMPLATE_MAX_INDICES]; /* explicit set of allowed indices */
|
||||
int allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} path_template_t;
|
||||
|
||||
typedef struct {
|
||||
int active; /* 1 if any path templates are configured */
|
||||
int count;
|
||||
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
|
||||
} path_whitelist_t;
|
||||
|
||||
typedef struct {
|
||||
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||
char last_error[256];
|
||||
@@ -692,6 +751,7 @@ typedef struct {
|
||||
int bridge_source_trusted; /* when set, unix connections send a qrexec_source preamble */
|
||||
int index_whitelist_active; /* 1 if index whitelist is set (not "all") */
|
||||
unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE]; /* bitmap of allowed nostr_index values */
|
||||
path_whitelist_t path_whitelist; /* path-template whitelist for role_path requests */
|
||||
} server_ctx_t;
|
||||
|
||||
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||
@@ -1301,6 +1361,12 @@ static int extract_method_and_selector(const char *json,
|
||||
selector_req->has_role_path = 1;
|
||||
json_copy_string(selector_req->role_path, sizeof(selector_req->role_path), tmp->valuestring, "");
|
||||
}
|
||||
|
||||
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "index");
|
||||
if (cJSON_IsNumber(tmp)) {
|
||||
selector_req->has_index = 1;
|
||||
selector_req->index = tmp->valueint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1334,6 +1400,7 @@ void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_exp
|
||||
ctx->bridge_source_trusted = 0;
|
||||
ctx->index_whitelist_active = 0;
|
||||
memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist));
|
||||
memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist));
|
||||
if (!g_auth_nonce_cache_inited) {
|
||||
auth_nonce_cache_init(&g_auth_nonce_cache);
|
||||
g_auth_nonce_cache_inited = 1;
|
||||
@@ -1428,6 +1495,313 @@ int server_index_whitelist_allows(const server_ctx_t *ctx, int nostr_index) {
|
||||
return whitelist_get_bit(ctx->index_whitelist, nostr_index);
|
||||
}
|
||||
|
||||
/*
|
||||
* Parse a single path-template token (e.g. "m/44'/1237'/0-3/1/0") into a
|
||||
* path_template_t. The first path segment matching ^[0-9]+(-[0-9]+)?$ is
|
||||
* treated as the range placeholder and replaced with "%d" in the stored
|
||||
* template. Returns 0 on success, -1 on parse error.
|
||||
*/
|
||||
static int parse_path_template_token(path_template_t *out, const char *token) {
|
||||
char buf[PATH_TEMPLATE_MAX_LEN];
|
||||
char *p;
|
||||
char *seg;
|
||||
int found_range = 0;
|
||||
|
||||
if (out == NULL || token == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
strncpy(buf, token, sizeof(buf) - 1);
|
||||
buf[sizeof(buf) - 1] = '\0';
|
||||
|
||||
/* buf starts with "m/" — split by '/' and find the first numeric/range segment */
|
||||
memset(out->template, 0, sizeof(out->template));
|
||||
out->range_lo = 0;
|
||||
out->range_hi = 0;
|
||||
|
||||
/* Build the output template, replacing the first numeric segment with %d */
|
||||
p = buf;
|
||||
seg = strchr(p, '/');
|
||||
if (seg != NULL) {
|
||||
/* copy up to and including the first '/' */
|
||||
size_t prefix_len = (size_t)(seg - p) + 1;
|
||||
if (prefix_len >= sizeof(out->template)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out->template, p, prefix_len);
|
||||
out->template[prefix_len] = '\0';
|
||||
p = seg + 1;
|
||||
} else {
|
||||
/* no '/' — not a valid path template */
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (p != NULL && *p != '\0') {
|
||||
char *next_slash = strchr(p, '/');
|
||||
size_t seg_len;
|
||||
char seg_buf[32];
|
||||
|
||||
if (next_slash != NULL) {
|
||||
seg_len = (size_t)(next_slash - p);
|
||||
} else {
|
||||
seg_len = strlen(p);
|
||||
}
|
||||
if (seg_len >= sizeof(seg_buf)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(seg_buf, p, seg_len);
|
||||
seg_buf[seg_len] = '\0';
|
||||
|
||||
if (!found_range) {
|
||||
/* Check if this segment is a number, range "N-M", or set "A+B+C" */
|
||||
char *plus = strchr(seg_buf, '+');
|
||||
char *dash = strchr(seg_buf, '-');
|
||||
|
||||
if (plus != NULL) {
|
||||
/* Set form: "1+34+54" or "1+3-5+10" — parse each + separated entry */
|
||||
int set_count = 0;
|
||||
char *tok = seg_buf;
|
||||
int set_ok = 1;
|
||||
|
||||
while (tok != NULL && *tok != '\0') {
|
||||
char *next_plus = strchr(tok, '+');
|
||||
if (next_plus != NULL) *next_plus = '\0';
|
||||
|
||||
/* Each token is either "N" or "N-M" */
|
||||
char *sub_dash = strchr(tok, '-');
|
||||
if (sub_dash != NULL) {
|
||||
*sub_dash = '\0';
|
||||
char *e1 = NULL, *e2 = NULL;
|
||||
long lo = strtol(tok, &e1, 10);
|
||||
long hi = strtol(sub_dash + 1, &e2, 10);
|
||||
if (*e1 != '\0' || *e2 != '\0' || lo < 0 || hi < 0 || lo > hi) {
|
||||
set_ok = 0; break;
|
||||
}
|
||||
for (long vi = lo; vi <= hi && set_count < PATH_TEMPLATE_MAX_INDICES; vi++) {
|
||||
out->allowed_indices[set_count++] = (int)vi;
|
||||
}
|
||||
} else {
|
||||
char *e = NULL;
|
||||
long val = strtol(tok, &e, 10);
|
||||
if (*e != '\0' || val < 0) { set_ok = 0; break; }
|
||||
if (set_count < PATH_TEMPLATE_MAX_INDICES) {
|
||||
out->allowed_indices[set_count++] = (int)val;
|
||||
}
|
||||
}
|
||||
|
||||
tok = (next_plus != NULL) ? next_plus + 1 : NULL;
|
||||
}
|
||||
|
||||
if (set_ok && set_count > 0) {
|
||||
found_range = 1;
|
||||
out->allowed_count = set_count;
|
||||
out->range_lo = out->allowed_indices[0];
|
||||
out->range_hi = out->allowed_indices[set_count - 1];
|
||||
if (strlen(out->template) + 3 >= sizeof(out->template)) return -1;
|
||||
strcat(out->template, "%d");
|
||||
strcat(out->template, "/");
|
||||
} else {
|
||||
/* not a valid set — treat as literal segment */
|
||||
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) return -1;
|
||||
strcat(out->template, seg_buf);
|
||||
strcat(out->template, "/");
|
||||
}
|
||||
} else if (dash != NULL) {
|
||||
/* Range form: "N-M" */
|
||||
*dash = '\0';
|
||||
char *endptr1 = NULL, *endptr2 = NULL;
|
||||
long lo = strtol(seg_buf, &endptr1, 10);
|
||||
long hi = strtol(dash + 1, &endptr2, 10);
|
||||
if (*endptr1 != '\0' || *endptr2 != '\0' || lo < 0 || hi < 0 || lo > hi) {
|
||||
/* not a numeric range — treat as literal segment */
|
||||
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) {
|
||||
return -1;
|
||||
}
|
||||
strcat(out->template, seg_buf);
|
||||
strcat(out->template, "/");
|
||||
} else {
|
||||
found_range = 1;
|
||||
out->range_lo = (int)lo;
|
||||
out->range_hi = (int)hi;
|
||||
if (strlen(out->template) + 3 >= sizeof(out->template)) {
|
||||
return -1;
|
||||
}
|
||||
strcat(out->template, "%d");
|
||||
strcat(out->template, "/");
|
||||
}
|
||||
} else {
|
||||
/* Single number */
|
||||
char *endptr = NULL;
|
||||
long val = strtol(seg_buf, &endptr, 10);
|
||||
if (*endptr != '\0' || val < 0) {
|
||||
/* not a number — treat as literal segment */
|
||||
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) {
|
||||
return -1;
|
||||
}
|
||||
strcat(out->template, seg_buf);
|
||||
strcat(out->template, "/");
|
||||
} else {
|
||||
found_range = 1;
|
||||
out->range_lo = (int)val;
|
||||
out->range_hi = (int)val;
|
||||
if (strlen(out->template) + 3 >= sizeof(out->template)) {
|
||||
return -1;
|
||||
}
|
||||
strcat(out->template, "%d");
|
||||
strcat(out->template, "/");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* literal segment after the range */
|
||||
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) {
|
||||
return -1;
|
||||
}
|
||||
strcat(out->template, seg_buf);
|
||||
strcat(out->template, "/");
|
||||
}
|
||||
|
||||
p = (next_slash != NULL) ? next_slash + 1 : NULL;
|
||||
}
|
||||
|
||||
/* Remove trailing '/' from template */
|
||||
{
|
||||
size_t tlen = strlen(out->template);
|
||||
if (tlen > 0 && out->template[tlen - 1] == '/') {
|
||||
out->template[tlen - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
if (!found_range) {
|
||||
return -1; /* a path template must contain a numeric/range segment */
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Unified whitelist parser: accepts both integer nostr_index tokens
|
||||
* ("0-3", "1,3,4") and path-template tokens ("m/44'/1237'/0-3/1/0").
|
||||
* "all" clears both whitelists. Returns 0 on success, -1 on parse error.
|
||||
*/
|
||||
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec) {
|
||||
char buf[512];
|
||||
char *p;
|
||||
|
||||
if (ctx == NULL || spec == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* "all" means no restriction */
|
||||
if (strcmp(spec, "all") == 0) {
|
||||
ctx->index_whitelist_active = 0;
|
||||
memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist));
|
||||
memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist));
|
||||
return 0;
|
||||
}
|
||||
|
||||
strncpy(buf, spec, sizeof(buf) - 1);
|
||||
buf[sizeof(buf) - 1] = '\0';
|
||||
|
||||
/* Reset both whitelists before parsing */
|
||||
memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist));
|
||||
ctx->index_whitelist_active = 0;
|
||||
memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist));
|
||||
|
||||
p = buf;
|
||||
while (p != NULL && *p != '\0') {
|
||||
char *comma = strchr(p, ',');
|
||||
if (comma != NULL) {
|
||||
*comma = '\0';
|
||||
}
|
||||
|
||||
/* Skip empty tokens */
|
||||
if (*p == '\0') {
|
||||
p = (comma != NULL) ? comma + 1 : NULL;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Is this a path template? (contains '/') */
|
||||
if (strchr(p, '/') != NULL) {
|
||||
if (ctx->path_whitelist.count >= PATH_WHITELIST_MAX_TEMPLATES) {
|
||||
return -1;
|
||||
}
|
||||
if (parse_path_template_token(
|
||||
&ctx->path_whitelist.templates[ctx->path_whitelist.count], p) != 0) {
|
||||
return -1;
|
||||
}
|
||||
ctx->path_whitelist.count++;
|
||||
ctx->path_whitelist.active = 1;
|
||||
} else {
|
||||
/* Integer nostr_index token: "N" or "N-M" */
|
||||
char *dash = strchr(p, '-');
|
||||
if (dash != NULL) {
|
||||
*dash = '\0';
|
||||
char *endptr1 = NULL, *endptr2 = NULL;
|
||||
long lo = strtol(p, &endptr1, 10);
|
||||
long hi = strtol(dash + 1, &endptr2, 10);
|
||||
if (*endptr1 != '\0' || *endptr2 != '\0' || lo < 0 || hi < 0 ||
|
||||
lo >= INDEX_WHITELIST_MAX || hi >= INDEX_WHITELIST_MAX || lo > hi) {
|
||||
return -1;
|
||||
}
|
||||
for (long i = lo; i <= hi; i++) {
|
||||
whitelist_set_bit(ctx->index_whitelist, (int)i);
|
||||
}
|
||||
} else {
|
||||
char *endptr = NULL;
|
||||
long idx = strtol(p, &endptr, 10);
|
||||
if (*endptr != '\0' || idx < 0 || idx >= INDEX_WHITELIST_MAX) {
|
||||
return -1;
|
||||
}
|
||||
whitelist_set_bit(ctx->index_whitelist, (int)idx);
|
||||
}
|
||||
ctx->index_whitelist_active = 1;
|
||||
}
|
||||
|
||||
p = (comma != NULL) ? comma + 1 : NULL;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Check if a role_path is allowed by the path whitelist.
|
||||
* Returns 1 if allowed, 0 if not.
|
||||
*/
|
||||
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path) {
|
||||
int i;
|
||||
|
||||
if (ctx == NULL || role_path == NULL) {
|
||||
return 0;
|
||||
}
|
||||
if (!ctx->path_whitelist.active) {
|
||||
/* No path whitelist configured — deny by default (fail-closed for paths) */
|
||||
return 0;
|
||||
}
|
||||
for (i = 0; i < ctx->path_whitelist.count; i++) {
|
||||
const path_template_t *tpl = &ctx->path_whitelist.templates[i];
|
||||
char candidate[PATH_TEMPLATE_MAX_LEN];
|
||||
if (tpl->allowed_count > 0) {
|
||||
/* Set form: check each allowed index */
|
||||
int j;
|
||||
for (j = 0; j < tpl->allowed_count; j++) {
|
||||
snprintf(candidate, sizeof(candidate), tpl->template, tpl->allowed_indices[j]);
|
||||
if (strcmp(candidate, role_path) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* Range form: iterate lo..hi */
|
||||
int idx;
|
||||
for (idx = tpl->range_lo; idx <= tpl->range_hi; idx++) {
|
||||
snprintf(candidate, sizeof(candidate), tpl->template, idx);
|
||||
if (strcmp(candidate, role_path) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int server_start(server_ctx_t *ctx) {
|
||||
int fd;
|
||||
struct sockaddr_un addr;
|
||||
@@ -1626,7 +2000,7 @@ int server_start(server_ctx_t *ctx) {
|
||||
}
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(@%s) failed: %s (and failed to generate retry socket name)",
|
||||
"bind(%s) failed: %s (and failed to generate retry socket name)",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
close(fd);
|
||||
@@ -1636,13 +2010,13 @@ int server_start(server_ctx_t *ctx) {
|
||||
if (errno == EADDRINUSE && ctx->socket_name_explicit) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(@%s) failed: %s (explicit --socket-name is already in use)",
|
||||
"bind(%s) failed: %s (explicit --socket-name is already in use)",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
} else {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(@%s) failed: %s",
|
||||
"bind(%s) failed: %s",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
}
|
||||
@@ -1809,6 +2183,8 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
int pending_derivation = 0;
|
||||
int hard_selector_error = 0;
|
||||
int derivation_error = 0;
|
||||
char concrete_path[ROLE_PATH_MAX]; /* concrete path for named path-role with index */
|
||||
concrete_path[0] = '\0';
|
||||
char activity[256];
|
||||
const char *verdict = "DENIED";
|
||||
const char *source_label = "no-match";
|
||||
@@ -2028,7 +2404,8 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) {
|
||||
if (ctx->dispatcher->role_table != NULL) {
|
||||
selector_rc = selector_resolve(&selector_req, ctx->dispatcher->role_table, &role);
|
||||
if (selector_rc == SELECTOR_OK && role != NULL) {
|
||||
if (selector_rc == SELECTOR_OK && role != NULL &&
|
||||
role->selector_type == SELECTOR_NOSTR_INDEX) {
|
||||
json_copy_string(role_name, sizeof(role_name), role->name, "main");
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
|
||||
} else if (selector_rc == SELECTOR_ERR_NOT_FOUND && selector_req.has_nostr_index) {
|
||||
@@ -2039,9 +2416,96 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
(void)snprintf(role_name, sizeof(role_name), "nostr_idx_%d", selector_req.nostr_index);
|
||||
}
|
||||
json_copy_string(purpose, sizeof(purpose), "nostr", "nostr");
|
||||
} else if (selector_rc == SELECTOR_OK && role != NULL &&
|
||||
role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") == NULL) {
|
||||
/* Fixed-path named role — no index needed, derive if not yet done.
|
||||
* For role+role_path requests, selector_resolve already verified
|
||||
* the path matches the template exactly. For role-only requests
|
||||
* on a fixed-path role, the role's own path is used. */
|
||||
json_copy_string(role_name, sizeof(role_name), role->name, "main");
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
|
||||
if (!role->derived) {
|
||||
pending_derivation = 1;
|
||||
}
|
||||
} else if (selector_rc == SELECTOR_OK && role != NULL &&
|
||||
role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") != NULL) {
|
||||
/* Named path-role with template. Two sub-cases:
|
||||
* (a) role + role_path: the client supplied a concrete path.
|
||||
* selector_resolve already verified it matches the template
|
||||
* AND the extracted index is within the allowed range/set.
|
||||
* We use the client's path directly for derivation.
|
||||
* (b) role only (no role_path): use --index or path_default_index,
|
||||
* range-check it, and format the concrete path. */
|
||||
json_copy_string(role_name, sizeof(role_name), role->name, "main");
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
|
||||
|
||||
if (selector_req.has_role_path) {
|
||||
/* Case (a): client supplied a concrete path that was already
|
||||
* validated by selector_resolve. Use it directly.
|
||||
*
|
||||
* Always re-derive for template roles: the client may request
|
||||
* a different concrete path (e.g. a different account index)
|
||||
* than the one previously derived and cached on the role.
|
||||
* The derivation block below swaps in the concrete path,
|
||||
* clears derived/pubkey, re-derives, and restores the
|
||||
* template — so forcing pending_derivation here is safe and
|
||||
* correct. Without this, a second request with a different
|
||||
* path would return the stale cached pubkey from the first. */
|
||||
snprintf(concrete_path, sizeof(concrete_path),
|
||||
"%s", selector_req.role_path);
|
||||
pending_derivation = 1;
|
||||
} else {
|
||||
/* Case (b): role only — resolve index from --index or default */
|
||||
int chosen_index;
|
||||
if (selector_req.has_index) {
|
||||
chosen_index = selector_req.index;
|
||||
} else if (role->path_default_index >= 0) {
|
||||
chosen_index = role->path_default_index;
|
||||
} else {
|
||||
hard_selector_error = -201; /* index_required sentinel */
|
||||
chosen_index = -1;
|
||||
}
|
||||
if (chosen_index >= 0) {
|
||||
int index_ok;
|
||||
if (role->path_allowed_count > 0) {
|
||||
/* Set form: check if index is in the allowed set */
|
||||
int j;
|
||||
index_ok = 0;
|
||||
for (j = 0; j < role->path_allowed_count; j++) {
|
||||
if (role->path_allowed_indices[j] == chosen_index) {
|
||||
index_ok = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* Range form: check lo..hi */
|
||||
index_ok = (chosen_index >= role->path_range_lo &&
|
||||
chosen_index <= role->path_range_hi);
|
||||
}
|
||||
if (!index_ok) {
|
||||
hard_selector_error = -202; /* index_out_of_range sentinel */
|
||||
} else {
|
||||
/* Format the concrete path and store it for derivation */
|
||||
snprintf(concrete_path, sizeof(concrete_path),
|
||||
role->role_path, chosen_index);
|
||||
if (!role->derived) {
|
||||
pending_derivation = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (selector_rc == SELECTOR_ERR_PATH_MISMATCH) {
|
||||
/* role + role_path supplied, but the path doesn't match the
|
||||
* role's template or the index is outside the allowed range.
|
||||
* Reject — do NOT auto-create a new pathrole entry. */
|
||||
hard_selector_error = -200; /* path_not_allowed sentinel */
|
||||
} else if (selector_rc == SELECTOR_ERR_AMBIGUOUS ||
|
||||
selector_rc == SELECTOR_ERR_NOT_FOUND ||
|
||||
selector_rc == SELECTOR_ERR_NO_DEFAULT) {
|
||||
selector_rc == SELECTOR_ERR_NO_DEFAULT ||
|
||||
selector_rc == SELECTOR_ERR_ROLE_REQUIRED ||
|
||||
selector_rc == SELECTOR_ERR_PATH_REQUIRED) {
|
||||
hard_selector_error = selector_rc;
|
||||
}
|
||||
}
|
||||
@@ -2074,9 +2538,24 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
} else if (hard_selector_error == SELECTOR_ERR_NOT_FOUND) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":1002,\"message\":\"unknown_role\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == SELECTOR_ERR_ROLE_REQUIRED) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2006,\"message\":\"role_required\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == SELECTOR_ERR_PATH_REQUIRED) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2007,\"message\":\"path_required\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == -200) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2003,\"message\":\"path_not_allowed\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == -201) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2004,\"message\":\"index_required\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == -202) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2005,\"message\":\"index_out_of_range\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == 0) {
|
||||
/* Normal path: run policy_check (skip if whitelist already denied) */
|
||||
pchk = policy_check(ctx->policy, caller.caller_id, method, role_name, purpose, &policy_src);
|
||||
/* Normal path: run policy_check_with_role (role-as-password if requires_approval=0) */
|
||||
pchk = policy_check_with_role(ctx->policy, caller.caller_id, method, role_name, purpose, role, &policy_src);
|
||||
}
|
||||
/* else: hard_selector_error == -100 (whitelist deny) — keep pchk=POLICY_DENY */
|
||||
|
||||
@@ -2110,8 +2589,61 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
if (ctx->dispatcher == NULL ||
|
||||
ctx->dispatcher->role_table == NULL ||
|
||||
ctx->dispatcher->key_store == NULL ||
|
||||
ctx->dispatcher->mnemonic == NULL ||
|
||||
role_table_register_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index) != 0) {
|
||||
ctx->dispatcher->mnemonic == NULL) {
|
||||
derivation_error = 1;
|
||||
} else if (role != NULL && role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") == NULL) {
|
||||
/* Fixed-path named role (found by role name, or by role+role_path
|
||||
* which selector_resolve verified matches the fixed template) —
|
||||
* derive directly using the role's own path. */
|
||||
new_role = role;
|
||||
role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]);
|
||||
if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count ||
|
||||
crypto_derive_one(ctx->dispatcher->key_store,
|
||||
ctx->dispatcher->role_table,
|
||||
ctx->dispatcher->mnemonic,
|
||||
role_index) != 0) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
json_copy_string(role_name, sizeof(role_name), new_role->name, role_name);
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr");
|
||||
}
|
||||
} else if (concrete_path[0] != '\0') {
|
||||
/* Named path-role with template — derive the concrete path.
|
||||
* The role already exists in the table; we temporarily set its
|
||||
* role_path to the concrete path for derivation, then restore. */
|
||||
char saved_path[ROLE_PATH_MAX];
|
||||
new_role = role; /* the role resolved by selector_resolve */
|
||||
if (new_role == NULL) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]);
|
||||
if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
/* Swap in the concrete path */
|
||||
strncpy(saved_path, new_role->role_path, sizeof(saved_path) - 1);
|
||||
saved_path[sizeof(saved_path) - 1] = '\0';
|
||||
strncpy(new_role->role_path, concrete_path, sizeof(new_role->role_path) - 1);
|
||||
new_role->role_path[sizeof(new_role->role_path) - 1] = '\0';
|
||||
new_role->derived = 0;
|
||||
new_role->pubkey_hex[0] = '\0';
|
||||
|
||||
if (crypto_derive_one(ctx->dispatcher->key_store,
|
||||
ctx->dispatcher->role_table,
|
||||
ctx->dispatcher->mnemonic,
|
||||
role_index) != 0) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
json_copy_string(role_name, sizeof(role_name), new_role->name, role_name);
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr");
|
||||
}
|
||||
/* Restore the template path (keep derived=1 + pubkey from concrete derivation) */
|
||||
strncpy(new_role->role_path, saved_path, sizeof(new_role->role_path) - 1);
|
||||
new_role->role_path[sizeof(new_role->role_path) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
} else if (role_table_register_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index) != 0) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
new_role = role_table_find_by_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index);
|
||||
@@ -2157,14 +2689,26 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
response = NULL;
|
||||
verdict = "ALLOWED";
|
||||
source_label = "async-mine";
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
"ALLOWED",
|
||||
"async-mine");
|
||||
if (concrete_path[0] != '\0') {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s,%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
concrete_path,
|
||||
"ALLOWED",
|
||||
"async-mine");
|
||||
} else {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
"ALLOWED",
|
||||
"async-mine");
|
||||
}
|
||||
if (cb != NULL) {
|
||||
cb(activity, cb_data);
|
||||
}
|
||||
@@ -2211,14 +2755,26 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
source_label = "no-match";
|
||||
}
|
||||
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
verdict,
|
||||
source_label);
|
||||
if (concrete_path[0] != '\0') {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s,%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
concrete_path,
|
||||
verdict,
|
||||
source_label);
|
||||
} else {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
verdict,
|
||||
source_label);
|
||||
}
|
||||
|
||||
if (cb != NULL) {
|
||||
cb(activity, cb_data);
|
||||
|
||||
@@ -118,6 +118,12 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -176,6 +182,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -55,6 +55,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
typedef struct { role_entry_t entries[ROLE_TABLE_MAX_ENTRIES]; int count; } role_table_t;
|
||||
void role_table_init(role_table_t *table);
|
||||
|
||||
+12
-1
@@ -116,6 +116,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -157,7 +162,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
|
||||
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
|
||||
role_purpose_t purpose, role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index,
|
||||
const int *allowed_indices, int allowed_count);
|
||||
/* from selector.h */
|
||||
|
||||
|
||||
@@ -177,6 +186,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -101,6 +101,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -118,7 +123,7 @@ role_purpose_t role_purpose_from_str(const char *s);
|
||||
role_curve_t role_curve_from_str(const char *s);
|
||||
const char *role_purpose_to_str(role_purpose_t p);
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
@@ -136,6 +141,8 @@ typedef struct {
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
@@ -116,6 +116,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +179,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -118,6 +118,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -176,6 +181,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -80,6 +80,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -97,7 +102,7 @@ role_purpose_t role_purpose_from_str(const char *s);
|
||||
role_curve_t role_curve_from_str(const char *s);
|
||||
const char *role_purpose_to_str(role_purpose_t p);
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
|
||||
@@ -113,6 +118,8 @@ typedef struct {
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
@@ -97,6 +97,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -128,6 +133,8 @@ typedef struct {
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
@@ -73,6 +73,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -101,6 +106,8 @@ typedef struct {
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
@@ -116,6 +116,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +179,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+1118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
* test_path_whitelist.c — tests for the path-template whitelist and
|
||||
* named path-role functionality.
|
||||
*
|
||||
* Covers:
|
||||
* - server_set_path_whitelist parsing (integer + path-template tokens)
|
||||
* - server_path_whitelist_allows matching
|
||||
* - role_table_register_role_path (idempotent, range fields)
|
||||
* - derive_secp256k1_from_path (BIP-44 path parsing + derivation)
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include <cJSON.h>
|
||||
|
||||
/* from secure_mem.h */
|
||||
typedef struct {
|
||||
void *data;
|
||||
size_t size;
|
||||
int locked;
|
||||
} secure_buf_t;
|
||||
|
||||
int secure_buf_alloc(secure_buf_t *buf, size_t size);
|
||||
void secure_buf_free(secure_buf_t *buf);
|
||||
void secure_memzero(void *ptr, size_t len);
|
||||
|
||||
/* from mnemonic.h */
|
||||
#define MNEMONIC_MAX_LEN 256
|
||||
typedef struct {
|
||||
secure_buf_t buf;
|
||||
int loaded;
|
||||
int word_count;
|
||||
} mnemonic_state_t;
|
||||
|
||||
void mnemonic_init(mnemonic_state_t *state);
|
||||
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||
void mnemonic_unload(mnemonic_state_t *state);
|
||||
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||
|
||||
/* from role_table.h */
|
||||
#define ROLE_NAME_MAX 64
|
||||
#define ROLE_PATH_MAX 128
|
||||
#define ROLE_PURPOSE_MAX 32
|
||||
#define ROLE_CURVE_MAX 16
|
||||
#define ROLE_PUBKEY_HEX_MAX 66
|
||||
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||
|
||||
typedef enum {
|
||||
PURPOSE_NOSTR = 0,
|
||||
PURPOSE_BITCOIN,
|
||||
PURPOSE_SSH,
|
||||
PURPOSE_AGE,
|
||||
PURPOSE_FIPS,
|
||||
PURPOSE_PQ_SIG,
|
||||
PURPOSE_PQ_KEM,
|
||||
PURPOSE_UNKNOWN
|
||||
} role_purpose_t;
|
||||
|
||||
typedef enum {
|
||||
CURVE_SECP256K1 = 0,
|
||||
CURVE_ED25519,
|
||||
CURVE_X25519,
|
||||
CURVE_ML_DSA_65,
|
||||
CURVE_SLH_DSA_128S,
|
||||
CURVE_ML_KEM_768,
|
||||
CURVE_UNKNOWN
|
||||
} role_curve_t;
|
||||
|
||||
typedef enum {
|
||||
SELECTOR_NOSTR_INDEX,
|
||||
SELECTOR_ROLE_PATH
|
||||
} role_selector_type_t;
|
||||
|
||||
typedef struct {
|
||||
char name[ROLE_NAME_MAX];
|
||||
char purpose_str[ROLE_PURPOSE_MAX];
|
||||
char curve_str[ROLE_CURVE_MAX];
|
||||
role_purpose_t purpose;
|
||||
role_curve_t curve;
|
||||
role_selector_type_t selector_type;
|
||||
int nostr_index;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo;
|
||||
int path_range_hi;
|
||||
int path_default_index;
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||
int count;
|
||||
} role_table_t;
|
||||
|
||||
void role_table_init(role_table_t *table);
|
||||
int role_table_add(role_table_t *table, const role_entry_t *entry);
|
||||
role_entry_t *role_table_find_by_name(role_table_t *table, const char *name);
|
||||
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||
role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
role_curve_t role_curve_from_str(const char *s);
|
||||
const char *role_purpose_to_str(role_purpose_t p);
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
|
||||
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
|
||||
role_purpose_t purpose, role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index,
|
||||
const int *allowed_indices, int allowed_count);
|
||||
|
||||
/* from selector.h */
|
||||
#define SELECTOR_OK 0
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1
|
||||
#define SELECTOR_ERR_NOT_FOUND -2
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3
|
||||
|
||||
typedef struct {
|
||||
int has_role;
|
||||
char role_name[ROLE_NAME_MAX];
|
||||
int has_nostr_index;
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index;
|
||||
int index;
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||
|
||||
/* from enforcement.h */
|
||||
#define ENFORCE_OK 0
|
||||
#define ENFORCE_ERR_PURPOSE -1
|
||||
#define ENFORCE_ERR_CURVE -2
|
||||
#define ENFORCE_ERR_UNKNOWN_VERB -3
|
||||
#define ENFORCE_ERR_ALGORITHM -4
|
||||
|
||||
#define VERB_SIGN "sign"
|
||||
#define VERB_VERIFY "verify"
|
||||
#define VERB_ENCAPSULATE "encapsulate"
|
||||
#define VERB_DECAPSULATE "decapsulate"
|
||||
#define VERB_DERIVE_SHARED "derive_shared_secret"
|
||||
#define VERB_DERIVE "derive"
|
||||
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||
|
||||
#define VERB_NOSTR_GET_PUBLIC_KEY "nostr_get_public_key"
|
||||
#define VERB_NOSTR_SIGN_EVENT "nostr_sign_event"
|
||||
#define VERB_NOSTR_MINE_EVENT "nostr_mine_event"
|
||||
#define VERB_NOSTR_NIP44_ENCRYPT "nostr_nip44_encrypt"
|
||||
#define VERB_NOSTR_NIP44_DECRYPT "nostr_nip44_decrypt"
|
||||
#define VERB_NOSTR_NIP04_ENCRYPT "nostr_nip04_encrypt"
|
||||
#define VERB_NOSTR_NIP04_DECRYPT "nostr_nip04_decrypt"
|
||||
|
||||
#define VERB_ENCRYPT "encrypt"
|
||||
#define VERB_DECRYPT "decrypt"
|
||||
|
||||
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||
|
||||
/* from pq_crypto.h */
|
||||
typedef enum {
|
||||
CRYPTO_ALG_SECP256K1 = 0,
|
||||
CRYPTO_ALG_ED25519,
|
||||
CRYPTO_ALG_X25519,
|
||||
CRYPTO_ALG_ML_DSA_65,
|
||||
CRYPTO_ALG_SLH_DSA_128S,
|
||||
CRYPTO_ALG_ML_KEM_768,
|
||||
CRYPTO_ALG_UNKNOWN
|
||||
} crypto_alg_t;
|
||||
|
||||
typedef struct {
|
||||
size_t priv_key_len;
|
||||
size_t pub_key_len;
|
||||
size_t sig_len;
|
||||
size_t ciphertext_len;
|
||||
size_t shared_secret_len;
|
||||
} crypto_alg_sizes_t;
|
||||
|
||||
const crypto_alg_sizes_t *crypto_alg_get_sizes(crypto_alg_t alg);
|
||||
crypto_alg_t crypto_alg_from_role(role_curve_t curve, role_purpose_t purpose);
|
||||
const char *crypto_alg_to_str(crypto_alg_t alg);
|
||||
crypto_alg_t crypto_alg_from_str(const char *s);
|
||||
|
||||
/* from key_store.h */
|
||||
#define KEY_STORE_MAX_ROLES ROLE_TABLE_MAX_ENTRIES
|
||||
|
||||
typedef struct {
|
||||
secure_buf_t private_key;
|
||||
secure_buf_t public_key;
|
||||
char pubkey_hex[8192]; /* hex-encoded public key (PQ pubkeys are large) */
|
||||
char npub[128]; /* bech32 npub (secp256k1 only, empty for others) */
|
||||
crypto_alg_t alg;
|
||||
int valid;
|
||||
} derived_key_t;
|
||||
|
||||
typedef struct {
|
||||
derived_key_t keys[KEY_STORE_MAX_ROLES];
|
||||
int count;
|
||||
} key_store_t;
|
||||
|
||||
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index);
|
||||
|
||||
/* nostr init/cleanup */
|
||||
int nostr_init(void);
|
||||
void nostr_cleanup(void);
|
||||
|
||||
/* from server.h (minimal subset for whitelist tests) */
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define INDEX_WHITELIST_MAX 256
|
||||
#define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8)
|
||||
|
||||
#define PATH_WHITELIST_MAX_TEMPLATES 16
|
||||
#define PATH_TEMPLATE_MAX_LEN 128
|
||||
|
||||
typedef struct {
|
||||
char template[PATH_TEMPLATE_MAX_LEN];
|
||||
int range_lo;
|
||||
int range_hi;
|
||||
int allowed_indices[64];
|
||||
int allowed_count;
|
||||
} path_template_t;
|
||||
|
||||
typedef struct {
|
||||
int active;
|
||||
int count;
|
||||
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
|
||||
} path_whitelist_t;
|
||||
|
||||
typedef struct {
|
||||
char socket_name[SERVER_SOCKET_NAME_MAX];
|
||||
char last_error[256];
|
||||
int listen_fd;
|
||||
int running;
|
||||
int listen_mode;
|
||||
int stdio_handled;
|
||||
void *dispatcher; /* dummy */
|
||||
void *policy; /* dummy */
|
||||
int socket_name_explicit;
|
||||
int auth_mode;
|
||||
int auth_skew_seconds;
|
||||
int bridge_source_trusted;
|
||||
int index_whitelist_active;
|
||||
unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE];
|
||||
path_whitelist_t path_whitelist;
|
||||
} server_ctx_t;
|
||||
|
||||
int server_set_index_whitelist(server_ctx_t *ctx, const char *spec);
|
||||
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec);
|
||||
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path);
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static int tests_run = 0;
|
||||
static int tests_passed = 0;
|
||||
|
||||
static void check(const char *desc, int condition) {
|
||||
tests_run++;
|
||||
if (condition) {
|
||||
tests_passed++;
|
||||
printf("PASS: %s\n", desc);
|
||||
} else {
|
||||
printf("FAIL: %s\n", desc);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
server_ctx_t ctx;
|
||||
|
||||
/* ---- Test 1: server_set_path_whitelist with "all" ---- */
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
check("set_path_whitelist 'all' returns 0",
|
||||
server_set_path_whitelist(&ctx, "all") == 0);
|
||||
check("'all' sets index_whitelist_active=0",
|
||||
ctx.index_whitelist_active == 0);
|
||||
check("'all' sets path_whitelist.active=0",
|
||||
ctx.path_whitelist.active == 0);
|
||||
|
||||
/* ---- Test 2: integer-only spec (backward compat) ---- */
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
check("set_path_whitelist '0-3' returns 0",
|
||||
server_set_path_whitelist(&ctx, "0-3") == 0);
|
||||
check("'0-3' sets index_whitelist_active=1",
|
||||
ctx.index_whitelist_active == 1);
|
||||
check("'0-3' does not set path_whitelist.active",
|
||||
ctx.path_whitelist.active == 0);
|
||||
|
||||
/* ---- Test 3: path-template spec ---- */
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
check("set_path_whitelist 'm/44\\'/1237\\'/0-3/1/0' returns 0",
|
||||
server_set_path_whitelist(&ctx, "m/44'/1237'/0-3/1/0") == 0);
|
||||
check("path template sets path_whitelist.active=1",
|
||||
ctx.path_whitelist.active == 1);
|
||||
check("path template count=1",
|
||||
ctx.path_whitelist.count == 1);
|
||||
check("path template range_lo=0",
|
||||
ctx.path_whitelist.templates[0].range_lo == 0);
|
||||
check("path template range_hi=3",
|
||||
ctx.path_whitelist.templates[0].range_hi == 3);
|
||||
|
||||
/* ---- Test 4: server_path_whitelist_allows matching ---- */
|
||||
check("path_whitelist_allows m/44'/1237'/1/1/0 (in range)",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/1/1/0") == 1);
|
||||
check("path_whitelist_allows m/44'/1237'/0/1/0 (in range)",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/0/1/0") == 1);
|
||||
check("path_whitelist_allows m/44'/1237'/3/1/0 (in range)",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/3/1/0") == 1);
|
||||
check("path_whitelist denies m/44'/1237'/4/1/0 (out of range)",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/4/1/0") == 0);
|
||||
check("path_whitelist denies m/44'/1237'/1/0/0 (wrong change)",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/1/0/0") == 0);
|
||||
|
||||
/* ---- Test 5: multiple path templates ---- */
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
check("set_path_whitelist with two templates returns 0",
|
||||
server_set_path_whitelist(&ctx,
|
||||
"m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0") == 0);
|
||||
check("two templates: count=2",
|
||||
ctx.path_whitelist.count == 2);
|
||||
check("two templates: allows m/44'/1237'/2/0/0",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/2/0/0") == 1);
|
||||
check("two templates: allows m/44'/1237'/2/1/0",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/2/1/0") == 1);
|
||||
check("two templates: denies m/44'/1237'/2/2/0",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/2/2/0") == 0);
|
||||
|
||||
/* ---- Test 6: no path whitelist configured → deny ---- */
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
check("no path whitelist denies all paths (fail-closed)",
|
||||
server_path_whitelist_allows(&ctx, "m/44'/1237'/1/1/0") == 0);
|
||||
|
||||
/* ---- Test 7: role_table_register_role_path ---- */
|
||||
{
|
||||
role_table_t table;
|
||||
role_table_init(&table);
|
||||
check("register_role_path returns 0",
|
||||
role_table_register_role_path(&table, "myrole",
|
||||
"m/44'/1237'/%d/1/0",
|
||||
PURPOSE_NOSTR, CURVE_SECP256K1,
|
||||
0, 3, 1, NULL, 0) == 0);
|
||||
role_entry_t *r = role_table_find_by_name(&table, "myrole");
|
||||
check("registered role found by name", r != NULL);
|
||||
check("registered role is SELECTOR_ROLE_PATH",
|
||||
r != NULL && r->selector_type == SELECTOR_ROLE_PATH);
|
||||
check("registered role path_range_lo=0",
|
||||
r != NULL && r->path_range_lo == 0);
|
||||
check("registered role path_range_hi=3",
|
||||
r != NULL && r->path_range_hi == 3);
|
||||
check("registered role path_default_index=1",
|
||||
r != NULL && r->path_default_index == 1);
|
||||
check("registered role purpose=NOSTR",
|
||||
r != NULL && r->purpose == PURPOSE_NOSTR);
|
||||
check("registered role curve=SECP256K1",
|
||||
r != NULL && r->curve == CURVE_SECP256K1);
|
||||
|
||||
/* Idempotent: registering the same path again returns 0, no duplicate */
|
||||
check("register_role_path idempotent returns 0",
|
||||
role_table_register_role_path(&table, "other",
|
||||
"m/44'/1237'/%d/1/0",
|
||||
PURPOSE_NOSTR, CURVE_SECP256K1,
|
||||
0, 3, 1, NULL, 0) == 0);
|
||||
check("idempotent: no duplicate added",
|
||||
table.count == 1);
|
||||
}
|
||||
|
||||
/* ---- Test 8: end-to-end derivation with role_path ---- */
|
||||
{
|
||||
role_table_t table;
|
||||
key_store_t key_store;
|
||||
mnemonic_state_t mnemonic;
|
||||
const char *valid_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
int rc;
|
||||
|
||||
role_table_init(&table);
|
||||
mnemonic_init(&mnemonic);
|
||||
|
||||
/* Load mnemonic */
|
||||
rc = mnemonic_load(&mnemonic, valid_12);
|
||||
check("mnemonic load succeeds", rc == 0);
|
||||
|
||||
/* Register a fixed-path role (no %d) */
|
||||
rc = role_table_register_role_path(&table, "testpath",
|
||||
"m/44'/1237'/1/1/0",
|
||||
PURPOSE_NOSTR, CURVE_SECP256K1,
|
||||
0, 0, -1, NULL, 0);
|
||||
check("register fixed-path role returns 0", rc == 0);
|
||||
|
||||
/* Derive all keys */
|
||||
if (nostr_init() != 0) {
|
||||
check("nostr_init succeeds", 0);
|
||||
mnemonic_unload(&mnemonic);
|
||||
printf("\n%d/%d tests passed\n", tests_passed, tests_run);
|
||||
return (tests_passed == tests_run) ? 0 : 1;
|
||||
}
|
||||
check("nostr_init succeeds", 1);
|
||||
memset(&key_store, 0, sizeof(key_store));
|
||||
rc = crypto_derive_all(&key_store, &table, &mnemonic);
|
||||
check("crypto_derive_all with path role succeeds", rc >= 0);
|
||||
|
||||
/* Find the role and check it was derived */
|
||||
role_entry_t *r = role_table_find_by_name(&table, "testpath");
|
||||
check("testpath role found", r != NULL);
|
||||
check("testpath role derived", r != NULL && r->derived == 1);
|
||||
check("testpath pubkey is 64 hex chars",
|
||||
r != NULL && strlen(r->pubkey_hex) == 64);
|
||||
|
||||
/* Verify the pubkey matches the expected NIP-06 index-1 derivation
|
||||
* (m/44'/1237'/1'/0/0) — this is a sanity check that the path
|
||||
* derivation produces a valid key. The path m/44'/1237'/1/1/0 is
|
||||
* different from NIP-06 so the pubkey should differ from index 1. */
|
||||
{
|
||||
role_table_t nip06_table;
|
||||
key_store_t nip06_store;
|
||||
role_table_init(&nip06_table);
|
||||
role_table_register_nostr_index(&nip06_table, 1);
|
||||
memset(&nip06_store, 0, sizeof(nip06_store));
|
||||
crypto_derive_all(&nip06_store, &nip06_table, &mnemonic);
|
||||
role_entry_t *nip06_r = role_table_find_by_nostr_index(&nip06_table, 1);
|
||||
check("NIP-06 index 1 derived",
|
||||
nip06_r != NULL && nip06_r->derived == 1);
|
||||
check("path m/44'/1237'/1/1/0 differs from NIP-06 index 1 (m/44'/1237'/1'/0/0)",
|
||||
r != NULL && nip06_r != NULL &&
|
||||
strcmp(r->pubkey_hex, nip06_r->pubkey_hex) != 0);
|
||||
}
|
||||
|
||||
mnemonic_unload(&mnemonic);
|
||||
}
|
||||
|
||||
nostr_cleanup();
|
||||
printf("\n%d/%d tests passed\n", tests_passed, tests_run);
|
||||
return (tests_passed == tests_run) ? 0 : 1;
|
||||
}
|
||||
@@ -116,6 +116,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +179,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -98,6 +98,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -115,7 +120,7 @@ role_purpose_t role_purpose_from_str(const char *s);
|
||||
role_curve_t role_curve_from_str(const char *s);
|
||||
const char *role_purpose_to_str(role_purpose_t p);
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
@@ -133,6 +138,8 @@ typedef struct {
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
@@ -95,6 +95,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -112,7 +117,7 @@ role_purpose_t role_purpose_from_str(const char *s);
|
||||
role_curve_t role_curve_from_str(const char *s);
|
||||
const char *role_purpose_to_str(role_purpose_t p);
|
||||
const char *role_curve_to_str(role_curve_t c);
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
|
||||
/* from selector.h */
|
||||
@@ -130,6 +135,8 @@ typedef struct {
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
+12
-1
@@ -116,6 +116,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -157,7 +162,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
|
||||
|
||||
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
|
||||
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
|
||||
role_purpose_t purpose, role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index,
|
||||
const int *allowed_indices, int allowed_count);
|
||||
/* from selector.h */
|
||||
|
||||
|
||||
@@ -177,6 +186,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -116,6 +116,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +179,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -97,6 +97,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
|
||||
int derived;
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -128,6 +133,8 @@ typedef struct {
|
||||
int nostr_index;
|
||||
int has_role_path;
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
@@ -116,6 +116,11 @@ typedef struct {
|
||||
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
|
||||
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
|
||||
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +179,8 @@ typedef struct {
|
||||
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_index; /* 1 if "index" field was present (for named path-roles) */
|
||||
int index; /* index value for named path-role template */
|
||||
} selector_request_t;
|
||||
|
||||
/* Initialize a selector request (all fields zeroed/unset) */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>n_signer CYD Web Serial Demo</title>
|
||||
<title>n_signer USB Test</title>
|
||||
<script>
|
||||
/* §12 Dark mode: set class synchronously before paint. */
|
||||
(function () {
|
||||
@@ -342,7 +342,7 @@
|
||||
<div class="divHeaderButtons">
|
||||
<button id="btnHamburger" title="Menu" aria-label="Open menu">≡</button>
|
||||
</div>
|
||||
<div class="divHeaderText">n_signer CYD Web Serial</div>
|
||||
<div class="divHeaderText">n_signer USB Test</div>
|
||||
<div class="divHeaderButtons"></div>
|
||||
</div>
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
<!-- Connection card (full width) -->
|
||||
<section class="divPostItem full">
|
||||
<h2>Connection</h2>
|
||||
<p class="note">Connect to the CYD (CH340 serial) via Web Serial, then exercise every algorithm and verb in the n_signer API. Chrome/Edge/Brave/Opera only.</p>
|
||||
<p class="note">Connect to the signer (Teensy 4.1 USB CDC, CYD CH340 serial, etc.) via Web Serial, then exercise every algorithm and verb in the n_signer API. Chrome/Edge/Brave/Opera only.</p>
|
||||
<div class="row">
|
||||
<button id="connectBtn" class="btn">Connect Web Serial</button>
|
||||
<button id="disconnectBtn" class="btn secondary" disabled>Disconnect</button>
|
||||
@@ -464,7 +464,7 @@
|
||||
<section class="divPostItem section">
|
||||
<h2>nostr_sign_event</h2>
|
||||
<label for="nseContent">content</label>
|
||||
<textarea id="nseContent" class="inpStyle">hello from cyd webserial demo</textarea>
|
||||
<textarea id="nseContent" class="inpStyle">hello from usb test</textarea>
|
||||
<label for="nseIdx">nostr_index</label>
|
||||
<input id="nseIdx" type="number" value="0" min="0" class="inpStyle" />
|
||||
<div class="row">
|
||||
@@ -478,7 +478,7 @@
|
||||
<h2>nostr_mine_event</h2>
|
||||
<p class="warn">Slow on ESP32 — uses single-threaded PoW. Keep difficulty low.</p>
|
||||
<label for="nmeContent">content</label>
|
||||
<textarea id="nmeContent" class="inpStyle">mined by cyd</textarea>
|
||||
<textarea id="nmeContent" class="inpStyle">mined by usb test</textarea>
|
||||
<label for="nmeIdx">nostr_index</label>
|
||||
<input id="nmeIdx" type="number" value="0" min="0" class="inpStyle" />
|
||||
<label for="nmeDiff">difficulty (leading zero bits)</label>
|
||||
@@ -530,9 +530,9 @@
|
||||
<!-- OTP encrypt / decrypt -->
|
||||
<section class="divPostItem section">
|
||||
<h2>encrypt / decrypt (otp)</h2>
|
||||
<p class="note">OTP pad is derived from the mnemonic on the CYD. Offset advances monotonically.</p>
|
||||
<label for="otpPlain">plaintext (base64)</label>
|
||||
<textarea id="otpPlain" class="inpStyle">SGVsbG8sIE9UUCB3b3JsZCE=</textarea>
|
||||
<p class="note">OTP pad is bound from the SD card (Teensy 4.1) or derived from the mnemonic (CYD). Offset advances monotonically.</p>
|
||||
<label for="otpPlain">plaintext</label>
|
||||
<textarea id="otpPlain" class="inpStyle">Secret message.</textarea>
|
||||
<label for="otpCipher">ciphertext (for decrypt, base64)</label>
|
||||
<textarea id="otpCipher" class="inpStyle" placeholder="filled by encrypt"></textarea>
|
||||
<label for="otpEnc">encoding</label>
|
||||
@@ -555,17 +555,17 @@
|
||||
<div id="divSideNav">
|
||||
<button id="btnCloseSideNav" title="Close menu" aria-label="Close menu">×</button>
|
||||
<div id="divSideNavBody">
|
||||
<h3>n_signer CYD Web Serial Demo</h3>
|
||||
<p>A browser control panel for the CYD (ESP32-2432S028) signer firmware. Speaks the n_signer JSON-RPC protocol over Web Serial at 115200 baud.</p>
|
||||
<h3>n_signer USB Test</h3>
|
||||
<p>A browser control panel for any n_signer hardware signer (Teensy 4.1, CYD ESP32-2432S028, etc.). Speaks the n_signer JSON-RPC protocol over Web Serial at 115200 baud.</p>
|
||||
|
||||
<h3>Getting started</h3>
|
||||
<p>1. Plug the CYD into a USB port (CH340 enumerates as /dev/ttyUSB*).</p>
|
||||
<p>2. Click <code>Connect Web Serial</code> and pick the CYD port.</p>
|
||||
<p>1. Plug the signer into a USB port (Teensy 4.1 enumerates as /dev/ttyACM*, CYD CH340 enumerates as /dev/ttyUSB*).</p>
|
||||
<p>2. Click <code>Connect Web Serial</code> and pick the signer's port.</p>
|
||||
<p>3. <code>get_info</code> fires automatically and reports firmware version + supported verbs.</p>
|
||||
<p>4. Enter a mnemonic on the CYD touchscreen to enable the signing verbs.</p>
|
||||
<p>4. Enter a mnemonic on the signer's touchscreen to enable the signing verbs.</p>
|
||||
|
||||
<h3>Auth envelope</h3>
|
||||
<p>Every request is signed with a demo secp256k1 key (priv = 0x0102…2020). The CYD verifies the kind-27235 auth event and replays-protects by event id.</p>
|
||||
<p>Every request is signed with a demo secp256k1 key (priv = 0x0102…2020). The signer verifies the kind-27235 auth event and replays-protects by event id.</p>
|
||||
|
||||
<h3>Transports</h3>
|
||||
<p>Frames are 4-byte big-endian length + UTF-8 JSON payload. Same wire format as the host Unix-socket and TCP transports.</p>
|
||||
@@ -632,7 +632,7 @@
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", bodyHash],
|
||||
];
|
||||
const content = "cyd-webserial-demo";
|
||||
const content = "usb-test";
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await sha256Hex(utf8(ser));
|
||||
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
@@ -695,7 +695,7 @@
|
||||
setTimeout(() => {
|
||||
if (pendingResolve === resolve) {
|
||||
pendingResolve = null;
|
||||
reject(new Error("timeout (30s) — check the CYD screen for an approval prompt"));
|
||||
reject(new Error("timeout (30s) — check the signer screen for an approval prompt"));
|
||||
}
|
||||
}, 65000);
|
||||
});
|
||||
@@ -747,7 +747,7 @@
|
||||
rxBuffer = new Uint8Array(0);
|
||||
readLoop();
|
||||
setStatus("Connected", "ok");
|
||||
log("Connected to CYD via Web Serial @ 115200 baud");
|
||||
log("Connected to signer via Web Serial @ 115200 baud");
|
||||
document.querySelectorAll("button[id$='Btn']").forEach(b => {
|
||||
if (b !== connectBtn && b !== disconnectBtn) b.disabled = false;
|
||||
});
|
||||
@@ -860,10 +860,21 @@
|
||||
callVerb("nostr_mine_event", [event, { nostr_index: idx, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
|
||||
});
|
||||
|
||||
const nip04Enc = () => {
|
||||
const nip04Enc = async () => {
|
||||
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value, idx = Number($("nip04Idx").value || 0);
|
||||
if (!peer) { $("nip04Out").textContent = "✗ enter peer pubkey"; return; }
|
||||
callVerb("nostr_nip04_encrypt", [peer, msg, { nostr_index: idx }], $("nip04Out"));
|
||||
const params = [peer, msg, { nostr_index: idx }];
|
||||
$("nip04Out").textContent = "→ nostr_nip04_encrypt " + JSON.stringify(params);
|
||||
try {
|
||||
const auth = await buildAuth("nostr_nip04_encrypt", params);
|
||||
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "nostr_nip04_encrypt", params, auth });
|
||||
$("nip04Out").textContent += "\n← " + pretty(resp);
|
||||
if (resp && resp.result) {
|
||||
$("nip04Cipher").value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
$("nip04Out").textContent += "\n✗ " + e.message;
|
||||
}
|
||||
};
|
||||
const nip04Dec = () => {
|
||||
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0);
|
||||
@@ -873,10 +884,21 @@
|
||||
$("nip04EncBtn").addEventListener("click", nip04Enc);
|
||||
$("nip04DecBtn").addEventListener("click", nip04Dec);
|
||||
|
||||
const nip44Enc = () => {
|
||||
const nip44Enc = async () => {
|
||||
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value, idx = Number($("nip44Idx").value || 0);
|
||||
if (!peer) { $("nip44Out").textContent = "✗ enter peer pubkey"; return; }
|
||||
callVerb("nostr_nip44_encrypt", [peer, msg, { nostr_index: idx }], $("nip44Out"));
|
||||
const params = [peer, msg, { nostr_index: idx }];
|
||||
$("nip44Out").textContent = "→ nostr_nip44_encrypt " + JSON.stringify(params);
|
||||
try {
|
||||
const auth = await buildAuth("nostr_nip44_encrypt", params);
|
||||
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "nostr_nip44_encrypt", params, auth });
|
||||
$("nip44Out").textContent += "\n← " + pretty(resp);
|
||||
if (resp && resp.result) {
|
||||
$("nip44Cipher").value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
$("nip44Out").textContent += "\n✗ " + e.message;
|
||||
}
|
||||
};
|
||||
const nip44Dec = () => {
|
||||
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0);
|
||||
@@ -886,14 +908,55 @@
|
||||
$("nip44EncBtn").addEventListener("click", nip44Enc);
|
||||
$("nip44DecBtn").addEventListener("click", nip44Dec);
|
||||
|
||||
$("otpEncBtn").addEventListener("click", () => {
|
||||
/* OTP encrypt expects base64 plaintext; decrypt returns base64 plaintext.
|
||||
The UI lets the user type plain text, so we encode/decode transparently. */
|
||||
function utf8ToB64(str) {
|
||||
return btoa(unescape(encodeURIComponent(str)));
|
||||
}
|
||||
function b64ToUtf8(b64) {
|
||||
try { return decodeURIComponent(escape(atob(b64))); }
|
||||
catch (e) { return atob(b64); }
|
||||
}
|
||||
$("otpEncBtn").addEventListener("click", async () => {
|
||||
const pt = $("otpPlain").value, enc = $("otpEnc").value;
|
||||
callVerb("encrypt", [pt, { algorithm: "otp", encoding: enc }], $("otpOut"));
|
||||
const ptB64 = utf8ToB64(pt);
|
||||
const params = [ptB64, { algorithm: "otp", encoding: enc }];
|
||||
$("otpOut").textContent = "→ encrypt " + JSON.stringify(params);
|
||||
try {
|
||||
const auth = await buildAuth("encrypt", params);
|
||||
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "encrypt", params, auth });
|
||||
$("otpOut").textContent += "\n← " + pretty(resp);
|
||||
if (resp && resp.result) {
|
||||
/* OTP result is a JSON string: {"ciphertext": ...} */
|
||||
let r = resp.result;
|
||||
if (typeof r === "string") { try { r = JSON.parse(r); } catch (_) { /* leave as string */ } }
|
||||
const ct = (r && r.ciphertext) ? r.ciphertext : (typeof resp.result === "string" ? resp.result : null);
|
||||
if (ct) { $("otpCipher").value = ct; }
|
||||
}
|
||||
} catch (e) {
|
||||
$("otpOut").textContent += "\n✗ " + e.message;
|
||||
}
|
||||
});
|
||||
$("otpDecBtn").addEventListener("click", () => {
|
||||
$("otpDecBtn").addEventListener("click", async () => {
|
||||
const ct = $("otpCipher").value, enc = $("otpEnc").value;
|
||||
if (!ct) { $("otpOut").textContent = "✗ paste ciphertext first (from encrypt)"; return; }
|
||||
callVerb("decrypt", [ct, { algorithm: "otp", encoding: enc }], $("otpOut"));
|
||||
const params = [ct, { algorithm: "otp", encoding: enc }];
|
||||
$("otpOut").textContent = "→ decrypt " + JSON.stringify(params);
|
||||
try {
|
||||
const auth = await buildAuth("decrypt", params);
|
||||
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "decrypt", params, auth });
|
||||
$("otpOut").textContent += "\n← " + pretty(resp);
|
||||
if (resp && resp.result) {
|
||||
let r = resp.result;
|
||||
if (typeof r === "string") { try { r = JSON.parse(r); } catch (_) { /* leave as string */ } }
|
||||
const ptB64 = (r && r.plaintext) ? r.plaintext : (typeof resp.result === "string" ? resp.result : null);
|
||||
if (ptB64) {
|
||||
$("otpOut").textContent += "\nplaintext: " + b64ToUtf8(ptB64);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
$("otpOut").textContent += "\n✗ " + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
if (!("serial" in navigator)) {
|
||||
Reference in New Issue
Block a user