Compare commits

...

14 Commits

Author SHA1 Message Date
Laan Tungir
ca18e1e42d v0.0.58 - Added derive verb: HMAC-SHA256(privkey, data) for secp256k1, enabling opaque d-tag derivation via nsigner remote backend without exposing the privkey 2026-07-20 19:56:51 -04:00
Laan Tungir
b3421c3e40 v0.0.57 - Migrated to unified nostr_ prefixed verb names; removed legacy verb aliases (sign_data, ssh_sign, verify_signature, kem_encapsulate, kem_decapsulate, otp_encrypt, otp_decrypt); split get_public_key into algorithm-based get_public_key and role-based nostr_get_public_key; OTP now selected via algorithm:otp instead of curve:otp; consolidated API docs from api.md into README.md 2026-07-20 17:29:45 -04:00
Laan Tungir
96ab9741ef v0.0.56 - Fix connection display scroll issue: use full screen clear instead of tui_clear_continuous for modal connections view 2026-07-20 10:11:09 -04:00
Laan Tungir
2af12868e2 v0.0.55 - Remove redundant 'Press d for connection instructions' hint line from TUI (already in hotkey menu) 2026-07-20 09:55:10 -04:00
Laan Tungir
a0a5987ffa v0.0.54 - TUI: show full derivation path in Roles table, move connection instructions to on-demand 'd' hotkey display with spaced transport blocks 2026-07-20 09:49:20 -04:00
Laan Tungir
0b0ec5eb1a v0.0.53 - Fixed SIGILL crash in multi-listen mode: pfds array was too small (3) for 3 listeners + stdin (4), causing stack buffer overflow 2026-07-20 09:13:18 -04:00
Laan Tungir
0355744103 v0.0.52 - Added api.md with unified verb scheme, fixed TUI status display in README, added TCP/HTTP port auto-increment on EADDRINUSE (up to 5 tries) 2026-07-20 09:07:21 -04:00
Laan Tungir
db274ce487 v0.0.51 - Document memfd_secret as future secret-memory backing in README §2.5 (mlock remains current path; memfd_secret unusable on Qubes Xen guests due to SIGBUS on page materialization) 2026-07-19 14:35:00 -04:00
Laan Tungir
56f37e092d v0.0.50 - Clean up README: rename 4c.1 to 'Verbs' (no past-tense references), remove section 11 (Implemented adjuncts and future work) and section 12 (Document map) 2026-07-19 14:07:04 -04:00
Laan Tungir
a017dc40e0 v0.0.49 - Added general encrypt/decrypt verbs with curve-based routing (otp, secp256k1 NIP-04/44, x25519, ml-kem-768) and updated README documentation 2026-07-19 11:38:44 -04:00
Laan Tungir
05c055503d v0.0.48 - Added OTP one-time pad encryption (otp_encrypt/otp_decrypt verbs), HTTP listener mode (--listen http:HOST:PORT), interactive OTP pad auto-scan on USB drives, raised SERVER_MAX_MSG_SIZE to 16MB, updated README with curl examples and current API documentation 2026-07-19 11:07:07 -04:00
Laan Tungir
a7c6de2dcd v0.0.47 - Clean up main menu layout and hotkeys 2026-07-16 17:59:58 -04:00
Laan Tungir
16a6da817c Auto-fix Gitea release timestamp via SSH after release creation (workaround for Gitea created_unix=0 bug) 2026-07-16 16:30:15 -04:00
Laan Tungir
1cf541b02d Fix install script: use git tags API with version sorting instead of releases API (works around Gitea epoch-zero timestamp bug) 2026-07-16 16:13:11 -04:00
80 changed files with 6848 additions and 1877 deletions

View File

@@ -0,0 +1,13 @@
alarm impact educate burden vague honey horn buyer sight vocal age render
index 0
{
"index": 0,
"nsec": "nsec1z2lrfamae2dzax7dmnlhv497uuxe4mw0m3w694upx5x54q6dgttqvzrrwl",
"npub": "npub1j7d7yf47w8k2kseknqjr3045jvm00u0wnt3433kk6vu67d2zamcs8ynuw4",
"npubHex": "979be226be71ecab4336982438beb49336f7f1ee9ae358c6d6d339af3542eef1",
"nsecHex": "12be34f77dca9a2e9bcddcff7654bee70d9aedcfdc5da2d781350d4a834d42d6",
"fipsIpv6": "fd55:b7c6:536e:26ee:a79:6e25:6f05:a85f",
"strDerivationPath": "m/44'/1237'/0'/0/0"
}

View File

@@ -55,6 +55,7 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev
# Copy source files
COPY src/ /build/src/
COPY libotppad/ /build/libotppad/
COPY resources/tui_continuous/ /build/resources/tui_continuous/
COPY resources/pqclean/ /build/resources/pqclean/
@@ -76,6 +77,7 @@ RUN ARCH="$(uname -m)"; \
-I/build/resources/pqclean/crypto_sign/ml-dsa-65 \
-I/build/resources/pqclean/crypto_sign/slh-dsa-128s \
-I/build/resources/pqclean/crypto_kem/ml-kem-768 \
-I/build/libotppad \
/build/src/main.c \
/build/src/secure_mem.c \
/build/src/mnemonic.c \
@@ -92,6 +94,9 @@ RUN ARCH="$(uname -m)"; \
/build/src/miner.c \
/build/src/pq_crypto.c \
/build/src/pq_drbg.c \
/build/src/otp_pad.c \
/build/src/http_listener.c \
/build/libotppad/libotppad.c \
/build/resources/pqclean/crypto_sign/ml-dsa-65/sign.c \
/build/resources/pqclean/crypto_sign/ml-dsa-65/poly.c \
/build/resources/pqclean/crypto_sign/ml-dsa-65/ntt.c \

View File

@@ -1,5 +1,5 @@
CC := gcc
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous -Iresources/pqclean -Iresources/pqclean/crypto_sign/ml-dsa-65 -Iresources/pqclean/crypto_sign/slh-dsa-128s -Iresources/pqclean/crypto_kem/ml-kem-768 -Iresources/pqclean/common
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE -Isrc -Ilibotppad -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous -Iresources/pqclean -Iresources/pqclean/crypto_sign/ml-dsa-65 -Iresources/pqclean/crypto_sign/slh-dsa-128s -Iresources/pqclean/crypto_kem/ml-kem-768 -Iresources/pqclean/common
LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
SRC_DIR := src
@@ -52,6 +52,9 @@ SOURCES := \
$(SRC_DIR)/miner.c \
$(SRC_DIR)/pq_crypto.c \
$(SRC_DIR)/pq_drbg.c \
$(SRC_DIR)/otp_pad.c \
$(SRC_DIR)/http_listener.c \
libotppad/libotppad.c \
$(PQCLEAN_SOURCES) \
resources/tui_continuous/tui_continuous.c
@@ -179,7 +182,7 @@ examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPL
$(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 -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
$(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)
$(TEST_MNEMONIC_INPUT_TARGET): $(TEST_DIR)/test_mnemonic_input.c
@mkdir -p $(BUILD_DIR)
@@ -199,7 +202,7 @@ $(TEST_ENFORCEMENT_TARGET): $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcemen
$(TEST_DISPATCHER_TARGET): $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/miner.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/miner.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_DISPATCHER_TARGET) $(LDFLAGS)
$(CC) $(CFLAGS) $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/miner.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_DISPATCHER_TARGET) $(LDFLAGS)
$(TEST_POLICY_TARGET): $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c
@mkdir -p $(BUILD_DIR)
@@ -223,7 +226,7 @@ $(TEST_QREXEC_AUTH_TARGET): $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envel
$(TEST_MINE_EVENT_TARGET): $(TEST_DIR)/test_mine_event.c $(SRC_DIR)/miner.c $(SRC_DIR)/key_store.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/dispatcher.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_mine_event.c $(SRC_DIR)/miner.c $(SRC_DIR)/key_store.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/dispatcher.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MINE_EVENT_TARGET) $(LDFLAGS)
$(CC) $(CFLAGS) $(TEST_DIR)/test_mine_event.c $(SRC_DIR)/miner.c $(SRC_DIR)/key_store.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/dispatcher.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)/otp_pad.c libotppad/libotppad.c -o $(TEST_MINE_EVENT_TARGET) $(LDFLAGS)
$(TEST_PQ_CRYPTO_TARGET): $(TEST_DIR)/test_pq_crypto.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/role_table.c
@mkdir -p $(BUILD_DIR)
@@ -231,27 +234,27 @@ $(TEST_PQ_CRYPTO_TARGET): $(TEST_DIR)/test_pq_crypto.c $(SRC_DIR)/pq_crypto.c $(
$(TEST_ED25519_X25519_TARGET): $(TEST_DIR)/test_ed25519_x25519.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
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_ed25519_x25519.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 -o $(TEST_ED25519_X25519_TARGET) $(LDFLAGS)
$(CC) $(CFLAGS) $(TEST_DIR)/test_ed25519_x25519.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)/otp_pad.c libotppad/libotppad.c -o $(TEST_ED25519_X25519_TARGET) $(LDFLAGS)
$(TEST_ML_DSA_65_TARGET): $(TEST_DIR)/test_ml_dsa_65.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
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_ml_dsa_65.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 -o $(TEST_ML_DSA_65_TARGET) $(LDFLAGS)
$(CC) $(CFLAGS) $(TEST_DIR)/test_ml_dsa_65.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)/otp_pad.c libotppad/libotppad.c -o $(TEST_ML_DSA_65_TARGET) $(LDFLAGS)
$(TEST_SLH_DSA_128S_TARGET): $(TEST_DIR)/test_slh_dsa_128s.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
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_slh_dsa_128s.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 -o $(TEST_SLH_DSA_128S_TARGET) $(LDFLAGS)
$(CC) $(CFLAGS) $(TEST_DIR)/test_slh_dsa_128s.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)/otp_pad.c libotppad/libotppad.c -o $(TEST_SLH_DSA_128S_TARGET) $(LDFLAGS)
$(TEST_ML_KEM_768_TARGET): $(TEST_DIR)/test_ml_kem_768.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
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_ml_kem_768.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 -o $(TEST_ML_KEM_768_TARGET) $(LDFLAGS)
$(CC) $(CFLAGS) $(TEST_DIR)/test_ml_kem_768.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)/otp_pad.c libotppad/libotppad.c -o $(TEST_ML_KEM_768_TARGET) $(LDFLAGS)
$(TEST_PUBKEY_FORMAT_TARGET): $(TEST_DIR)/test_pubkey_format.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
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_pubkey_format.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 -o $(TEST_PUBKEY_FORMAT_TARGET) $(LDFLAGS)
$(CC) $(CFLAGS) $(TEST_DIR)/test_pubkey_format.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)/otp_pad.c libotppad/libotppad.c -o $(TEST_PUBKEY_FORMAT_TARGET) $(LDFLAGS)
$(TEST_ALGORITHM_API_TARGET): $(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
$(TEST_ALGORITHM_API_TARGET): $(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
@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 -o $(TEST_ALGORITHM_API_TARGET) $(LDFLAGS)
$(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)
$(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c
@mkdir -p $(BUILD_DIR)

843
README.md

File diff suppressed because it is too large Load Diff

15
api.md Normal file
View File

@@ -0,0 +1,15 @@
# n_signer API
The complete, authoritative API reference is now in [`README.md`](README.md) §4 (API).
It covers:
- **§4.1 Request format** — JSON-RPC 2.0-style request shape.
- **§4.2 Response format** — success/error shapes and the full error-code table.
- **§4.3 Verbs** — the verb table (positional params + options), the `scheme` option for secp256k1, and the enforcement matrix.
- **§4.4 Algorithms** — the algorithm table (secp256k1, ed25519, x25519, ml-dsa-65, slh-dsa-128s, ml-kem-768, otp), derivation paths, key sizes, and the OTP one-time-pad model.
- **§4.5 Examples** — worked request/response examples for every verb.
- **§4.6 Role-based selectors** — `nostr_index` / `role` / `role_path` for the `nostr_*` verbs.
- **§4.7 Pre-approval** — `--preapprove` syntax for algorithm-based and Nostr verbs.
For the security model, transports, and operational behavior, see [`README.md`](README.md) §1§3 and §5§8. For the migration plan from the legacy verb names, see [`plans/legacy_verb_aliases.md`](plans/legacy_verb_aliases.md).

View File

@@ -22,8 +22,8 @@ for the full integration contract.
|---|---|
| `nsigner_client_t` (stack) | `nsigner_client_t*` (heap) or `nostr_signer_t*` |
| `nsigner_client_init` / `connect_unix` / `close` | `nsigner_transport_open_unix` + `nsigner_client_new` / `nsigner_client_free` |
| `nsigner_client_get_public_key` | `nostr_signer_get_public_key` or `nsigner_client_call(..., "get_public_key", ...)` |
| `nsigner_client_sign_event` | `nostr_signer_sign_event` or `nsigner_client_call(..., "sign_event", ...)` |
| `nsigner_client_get_public_key` | `nostr_signer_get_public_key` or `nsigner_client_call(..., "nostr_get_public_key", ...)` |
| `nsigner_client_sign_event` | `nostr_signer_sign_event` or `nsigner_client_call(..., "nostr_sign_event", ...)` |
| `nsigner_client_set_auth` | `nsigner_client_set_auth` or `nostr_signer_nsigner_set_auth` |
| `nsigner_client_request` / `request_raw` | `nsigner_client_call` (returns parsed cJSON result) |
@@ -42,51 +42,47 @@ n_signer supports six algorithms: `secp256k1` (Nostr), `ed25519` (SSH),
`x25519` (age/ECDH), `ml-dsa-65` (PQ signatures, FIPS 204), `slh-dsa-128s`
(PQ hash-based signatures, FIPS 205), and `ml-kem-768` (PQ KEM, FIPS 203).
Use `nsigner_client_call(client, "<verb>", params, &result)` with these verbs:
| Verb | Algorithms | Description |
|---|---|---|
| `get_public_key` | all | Returns the role's public key (see format below) |
| `sign_event` | secp256k1 | Sign a Nostr event (existing) |
| `nip44_encrypt` / `nip44_decrypt` | secp256k1 | NIP-44 (existing) |
| `nip04_encrypt` / `nip04_decrypt` | secp256k1 | NIP-04 (existing) |
| `mine_event` | secp256k1 | NIP-13 PoW mining + sign (existing) |
| `sign_data` | ed25519, ml-dsa-65, slh-dsa-128s | Sign arbitrary bytes (hex) |
| `verify_signature` | ed25519, ml-dsa-65, slh-dsa-128s | Verify a signature against the role's pubkey |
| `ssh_sign` | ed25519 | Sign an SSH authentication challenge |
| `kem_encapsulate` | ml-kem-768 | Encapsulate with a peer's ML-KEM public key |
| `kem_decapsulate` | ml-kem-768 | Decapsulate a ciphertext with the role's ML-KEM private key |
### Algorithm-based API (new)
In addition to the role-based verbs above, the signer supports algorithm-based verbs where the caller specifies `algorithm` and `index` directly, without needing a role:
The API has two verb families (see [`README.md`](../README.md#4-api) §4 for the full spec):
**Algorithm-based verbs** — the caller specifies `algorithm` and `index` in the
options object. No role table entry is needed.
| Verb | Algorithms | Description |
|---|---|---|
| `get_public_key` | all key-deriving algorithms | Returns the derived public key (structured) |
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Sign arbitrary bytes (hex) |
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Verify a signature |
| `encapsulate` | ml-kem-768 | KEM encapsulation with peer's public key |
| `decapsulate` | ml-kem-768 | KEM decapsulation with derived private key |
| `derive_shared_secret` | x25519 | ECDH key agreement |
| `get_public_key` (with `algorithm`) | all | Get public key for a derived key |
| `derive` | secp256k1 | `HMAC-SHA256(privkey, data)` — key-derived MAC for opaque identifiers (`index` required) |
| `encrypt` / `decrypt` | otp | One-time pad encrypt/decrypt (`algorithm:"otp"`) |
**Nostr protocol verbs** — select a secp256k1 NIP-06 key via `nostr_index` (or
`role`/`role_path`). These are role-based.
| Verb | Description |
|---|---|
| `nostr_get_public_key` | Returns the role's secp256k1 public key |
| `nostr_sign_event` | Sign a Nostr event |
| `nostr_mine_event` | NIP-13 PoW mining + sign |
| `nostr_nip44_encrypt` / `nostr_nip44_decrypt` | NIP-44 encrypt/decrypt |
| `nostr_nip04_encrypt` / `nostr_nip04_decrypt` | NIP-04 encrypt/decrypt |
Example: `nsigner_client_call(client, "sign", "[\"68656c6c6f\",{\"algorithm\":\"ed25519\",\"index\":0}]", &result)`
For secp256k1, the optional `scheme` parameter selects `"schnorr"` (default) or `"ecdsa"`.
Old verbs (`sign_data`, `ssh_sign`, `verify_signature`, `kem_encapsulate`, `kem_decapsulate`) also accept the `algorithm` parameter and map to the new verbs. Without `algorithm`, they use the role-based path (backward compatible).
### `get_public_key` response format
- **secp256k1 (backward compatible):** `result` is a plain hex string
(`cJSON_IsString(result)` is true, 64 hex chars).
- **secp256k1 with `{"format":"structured"}` option:** `result` is a JSON
string containing `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
- **All other algorithms:** `result` is always a JSON string containing
`{"algorithm":"<alg>","public_key":"<hex>","key_id":"<16 hex>"}`.
The algorithm-based `get_public_key` always returns a structured JSON string:
`{"algorithm":"<alg>","public_key":"<hex>","key_id":"<16 hex>"}`.
The role-based `nostr_get_public_key` returns a plain 64-hex-char secp256k1
public key by default, or the structured form with `{"format":"structured"}`.
Clients should parse the `result` string with `cJSON_Parse` to extract the
`algorithm`, `public_key`, and `key_id` fields for non-secp256k1 algorithms.
`algorithm`, `public_key`, and `key_id` fields when the result is a JSON object.
### Key sizes

View File

@@ -3,12 +3,12 @@
* via Qubes qrexec and performing all three core operations:
*
* 1. get_public_key — retrieve a Nostr public key by nostr_index
* 2. sign_event — sign a Nostr event (kind 1 text note)
* 3. nip44_encrypt — encrypt a message to a peer (and decrypt it back)
* 2. nostr_sign_event — sign a Nostr event (kind 1 text note)
* 3. nostr_nip44_encrypt — encrypt a message to a peer (and decrypt it back)
*
* Note: mine_event (NIP-13 PoW) is also available via the JSON-RPC interface.
* See demo_javascript.js and demo_python.py for mine_event usage examples.
* The high-level nostr_signer API does not yet wrap mine_event.
* Note: nostr_mine_event (NIP-13 PoW) is also available via the JSON-RPC interface.
* See demo_javascript.js and demo_python.py for nostr_mine_event usage examples.
* The high-level nostr_signer API does not yet wrap nostr_mine_event.
*
* This uses the high-level nostr_signer API from nostr_core_lib:
* - nostr_signer_nsigner_qrexec() — qrexec transport (no network)
@@ -132,7 +132,7 @@ static int demo_sign_event(nostr_signer_t *signer, const char *pubkey_hex) {
char *signed_json = NULL;
int rc;
printf("\n=== Demo 2: sign_event (kind 1 text note) ===\n");
printf("\n=== Demo 2: nostr_sign_event (kind 1 text note) ===\n");
/* Build an unsigned Nostr event (kind 1 text note) */
unsigned_event = cJSON_CreateObject();
@@ -163,7 +163,7 @@ static int demo_sign_event(nostr_signer_t *signer, const char *pubkey_hex) {
/* Sign it */
rc = nostr_signer_sign_event(signer, unsigned_event, &signed_event);
if (rc != NOSTR_SUCCESS) {
print_error("sign_event", rc);
print_error("nostr_nostr_sign_event", rc);
cJSON_Delete(unsigned_event);
return rc;
}
@@ -204,14 +204,14 @@ static int demo_nip44(nostr_signer_t *signer, const char *pubkey_hex) {
char *decrypted = NULL;
int rc;
printf("\n=== Demo 3: nip44_encrypt / nip44_decrypt ===\n");
printf("\n=== Demo 3: nostr_nip44_encrypt / nostr_nip44_decrypt ===\n");
printf(" plaintext: \"%s\"\n", plaintext);
printf(" peer pubkey: %s (self)\n", pubkey_hex);
/* Encrypt */
rc = nostr_signer_nip44_encrypt(signer, pubkey_hex, plaintext, &ciphertext);
if (rc != NOSTR_SUCCESS) {
print_error("nip44_encrypt", rc);
print_error("nostr_nostr_nip44_encrypt", rc);
return rc;
}
@@ -220,7 +220,7 @@ static int demo_nip44(nostr_signer_t *signer, const char *pubkey_hex) {
/* Decrypt (using our own pubkey as the sender) */
rc = nostr_signer_nip44_decrypt(signer, pubkey_hex, ciphertext, &decrypted);
if (rc != NOSTR_SUCCESS) {
print_error("nip44_decrypt", rc);
print_error("nostr_nostr_nip44_decrypt", rc);
free(ciphertext);
return rc;
}

View File

@@ -4,8 +4,8 @@
* running n_signer via Qubes qrexec and performing all three core operations:
*
* 1. get_public_key — retrieve a Nostr public key by nostr_index
* 2. sign_event — sign a Nostr event (kind 1 text note)
* 3. nip44_encrypt — encrypt a message to a peer (and decrypt it back)
* 2. nostr_sign_event — sign a Nostr event (kind 1 text note)
* 3. nostr_nip44_encrypt — encrypt a message to a peer (and decrypt it back)
*
* Uses qrexec-client-vm (Qubes OS inter-qube IPC). No auth envelope needed —
* identity comes from QREXEC_REMOTE_DOMAIN on the server side.
@@ -120,7 +120,7 @@ async function demoGetPublicKey(targetQube, nostrIndex) {
* Demo 2: Sign a Nostr event (kind 1 text note).
*/
async function demoSignEvent(targetQube, nostrIndex, pubkeyHex) {
console.log("\n=== Demo 2: sign_event (kind 1 text note) ===");
console.log("\n=== Demo 2: nostr_sign_event (kind 1 text note) ===");
const unsignedEvent = {
kind: 1,
@@ -135,12 +135,12 @@ async function demoSignEvent(targetQube, nostrIndex, pubkeyHex) {
const response = await callNsigner(targetQube, {
id: "2",
method: "sign_event",
method: "nostr_nostr_sign_event",
params: [JSON.stringify(unsignedEvent), { nostr_index: nostrIndex }],
});
if (response.error) {
throw new Error(`sign_event failed: ${JSON.stringify(response.error)}`);
throw new Error(`nostr_sign_event failed: ${JSON.stringify(response.error)}`);
}
const signedEvent = JSON.parse(response.result);
@@ -159,19 +159,19 @@ async function demoSignEvent(targetQube, nostrIndex, pubkeyHex) {
async function demoNip44(targetQube, nostrIndex, pubkeyHex) {
const plaintext = "Secret message from n_signer JavaScript demo!";
console.log("\n=== Demo 3: nip44_encrypt / nip44_decrypt ===");
console.log("\n=== Demo 3: nostr_nip44_encrypt / nostr_nip44_decrypt ===");
console.log(` plaintext: "${plaintext}"`);
console.log(` peer pubkey: ${pubkeyHex} (self)`);
// Encrypt
const encResponse = await callNsigner(targetQube, {
id: "3",
method: "nip44_encrypt",
method: "nostr_nostr_nip44_encrypt",
params: [pubkeyHex, plaintext, { nostr_index: nostrIndex }],
});
if (encResponse.error) {
throw new Error(`nip44_encrypt failed: ${JSON.stringify(encResponse.error)}`);
throw new Error(`nostr_nip44_encrypt failed: ${JSON.stringify(encResponse.error)}`);
}
const ciphertext = encResponse.result;
@@ -180,12 +180,12 @@ async function demoNip44(targetQube, nostrIndex, pubkeyHex) {
// Decrypt
const decResponse = await callNsigner(targetQube, {
id: "4",
method: "nip44_decrypt",
method: "nostr_nostr_nip44_decrypt",
params: [pubkeyHex, ciphertext, { nostr_index: nostrIndex }],
});
if (decResponse.error) {
throw new Error(`nip44_decrypt failed: ${JSON.stringify(decResponse.error)}`);
throw new Error(`nostr_nip44_decrypt failed: ${JSON.stringify(decResponse.error)}`);
}
const decrypted = decResponse.result;
@@ -199,7 +199,7 @@ async function demoNip44(targetQube, nostrIndex, pubkeyHex) {
}
async function demoMineEvent(targetQube, nostrIndex) {
console.log("\n--- Demo 4: mine_event (NIP-13 Proof-of-Work) ---");
console.log("\n--- Demo 4: nostr_mine_event (NIP-13 Proof-of-Work) ---");
const event = {
kind: 1,
@@ -210,7 +210,7 @@ async function demoMineEvent(targetQube, nostrIndex) {
console.log(" Mining with difficulty=4, threads=4, timeout_sec=30...");
const response = await callNsigner(targetQube, {
id: "5",
method: "mine_event",
method: "nostr_nostr_mine_event",
params: [JSON.stringify(event), {
difficulty: 4,
threads: 4,
@@ -220,7 +220,7 @@ async function demoMineEvent(targetQube, nostrIndex) {
});
if (response.error) {
throw new Error(`mine_event failed: ${JSON.stringify(response.error)}`);
throw new Error(`nostr_mine_event failed: ${JSON.stringify(response.error)}`);
}
const result = JSON.parse(response.result);

View File

@@ -4,8 +4,8 @@ demo_python.py — comprehensive Python demo for connecting to a running n_signe
via Qubes qrexec and performing all three core operations:
1. get_public_key — retrieve a Nostr public key by nostr_index
2. sign_event — sign a Nostr event (kind 1 text note)
3. nip44_encrypt — encrypt a message to a peer (and decrypt it back)
2. nostr_sign_event — sign a Nostr event (kind 1 text note)
3. nostr_nip44_encrypt — encrypt a message to a peer (and decrypt it back)
Uses qrexec-client-vm (Qubes OS inter-qube IPC). No auth envelope needed —
identity comes from QREXEC_REMOTE_DOMAIN on the server side.
@@ -148,7 +148,7 @@ def demo_get_public_key(target_qube, nostr_index):
def demo_sign_event(target_qube, nostr_index, pubkey_hex):
"""Demo 2: Sign a Nostr event (kind 1 text note)."""
print("\n=== Demo 2: sign_event (kind 1 text note) ===")
print("\n=== Demo 2: nostr_sign_event (kind 1 text note) ===")
unsigned_event = {
"kind": 1,
@@ -165,13 +165,13 @@ def demo_sign_event(target_qube, nostr_index, pubkey_hex):
target_qube,
{
"id": "2",
"method": "sign_event",
"method": "nostr_nostr_sign_event",
"params": [json.dumps(unsigned_event, separators=(",", ":")), {"nostr_index": nostr_index}],
},
)
if "error" in response:
raise RuntimeError(f"sign_event failed: {json.dumps(response['error'])}")
raise RuntimeError(f"nostr_sign_event failed: {json.dumps(response['error'])}")
signed_event = json.loads(response["result"])
print(" Signed event:")
@@ -185,7 +185,7 @@ def demo_nip44(target_qube, nostr_index, pubkey_hex):
"""Demo 3: NIP-44 encrypt and decrypt."""
plaintext = "Secret message from n_signer Python demo!"
print("\n=== Demo 3: nip44_encrypt / nip44_decrypt ===")
print("\n=== Demo 3: nostr_nip44_encrypt / nostr_nip44_decrypt ===")
print(f' plaintext: "{plaintext}"')
print(f" peer pubkey: {pubkey_hex} (self)")
@@ -194,13 +194,13 @@ def demo_nip44(target_qube, nostr_index, pubkey_hex):
target_qube,
{
"id": "3",
"method": "nip44_encrypt",
"method": "nostr_nostr_nip44_encrypt",
"params": [pubkey_hex, plaintext, {"nostr_index": nostr_index}],
},
)
if "error" in enc_response:
raise RuntimeError(f"nip44_encrypt failed: {json.dumps(enc_response['error'])}")
raise RuntimeError(f"nostr_nip44_encrypt failed: {json.dumps(enc_response['error'])}")
ciphertext = enc_response["result"]
print(f" ciphertext: {ciphertext}")
@@ -210,13 +210,13 @@ def demo_nip44(target_qube, nostr_index, pubkey_hex):
target_qube,
{
"id": "4",
"method": "nip44_decrypt",
"method": "nostr_nostr_nip44_decrypt",
"params": [pubkey_hex, ciphertext, {"nostr_index": nostr_index}],
},
)
if "error" in dec_response:
raise RuntimeError(f"nip44_decrypt failed: {json.dumps(dec_response['error'])}")
raise RuntimeError(f"nostr_nip44_decrypt failed: {json.dumps(dec_response['error'])}")
decrypted = dec_response["result"]
print(f' decrypted: "{decrypted}"')
@@ -227,8 +227,8 @@ def demo_nip44(target_qube, nostr_index, pubkey_hex):
raise RuntimeError("Round-trip FAILED: plaintext does not match decrypted")
def demo_mine_event(target_qube, nostr_index):
print("\n--- Demo 4: mine_event (NIP-13 Proof-of-Work) ---")
def demo_nostr_mine_event(target_qube, nostr_index):
print("\n--- Demo 4: nostr_mine_event (NIP-13 Proof-of-Work) ---")
event = {"kind": 1, "content": "Hello Nostr with PoW!", "tags": []}
@@ -237,7 +237,7 @@ def demo_mine_event(target_qube, nostr_index):
target_qube,
{
"id": "5",
"method": "mine_event",
"method": "nostr_nostr_mine_event",
"params": [json.dumps(event), {
"difficulty": 4,
"threads": 4,
@@ -248,7 +248,7 @@ def demo_mine_event(target_qube, nostr_index):
)
if "error" in response:
raise RuntimeError(f"mine_event failed: {json.dumps(response['error'])}")
raise RuntimeError(f"nostr_mine_event failed: {json.dumps(response['error'])}")
result = json.loads(response["result"])
print(f" achieved_difficulty: {result['achieved_difficulty']}")
@@ -282,7 +282,7 @@ def main():
pubkey_hex = demo_get_public_key(target_qube, nostr_index)
demo_sign_event(target_qube, nostr_index, pubkey_hex)
demo_nip44(target_qube, nostr_index, pubkey_hex)
demo_mine_event(target_qube, nostr_index)
demo_nostr_mine_event(target_qube, nostr_index)
print("\n=== Summary ===")
print("All demos completed successfully.")

View File

@@ -136,6 +136,7 @@ In addition to the role-based verbs above, the signer supports algorithm-based v
- `encapsulate` — KEM encapsulation (params: `[peer_pubkey_hex, {algorithm}]`)
- `decapsulate` — KEM decapsulation (params: `[ciphertext_hex, {algorithm, index}]`)
- `derive_shared_secret` — ECDH key agreement (params: `[peer_pubkey_hex, {algorithm, index}]`)
- `derive``HMAC-SHA256(privkey, data)` key-derived MAC (params: `[data, {algorithm:"secp256k1", index}]`; `index` required). Returns `{algorithm, key_id, digest}` where `digest` is 64 hex chars. Use for deterministic opaque identifiers (e.g. NIP-33 `d` tags) keyed by the derived private key.
- `get_public_key` with `algorithm` parameter — returns structured JSON
Algorithm names: `secp256k1`, `ed25519`, `ml-dsa-65`, `slh-dsa-128s`, `x25519`, `ml-kem-768`

View File

@@ -61,13 +61,13 @@ Operational assumptions:
Run `nsigner` in TCP listen mode:
```bash
./build/nsigner --listen tcp:[::]:8080
./build/nsigner --listen tcp:[::]:11111
```
Or bind to a specific FIPS ULA address:
```bash
./build/nsigner --listen tcp:[fd00::1234]:8080
./build/nsigner --listen tcp:[fd00::1234]:11111
```
Behavior notes:

View File

@@ -140,11 +140,11 @@ def main() -> int:
req = {
"jsonrpc": "2.0",
"id": "2",
"method": "sign_event",
"method": "nostr_sign_event",
"params": params,
}
if not no_auth:
req["auth"] = build_auth_envelope("sign_event", params, caller_priv)
req["auth"] = build_auth_envelope("nostr_sign_event", params, caller_priv)
body = json.dumps(req, separators=(",", ":")).encode("utf-8")
frame = struct.pack(">I", len(body)) + body

View File

@@ -460,7 +460,7 @@
};
const params = [unsignedEvent, getIndexOptions()];
const resp = await rpcCall("sign_event", params, "web-sign-kind1");
const resp = await rpcCall("nostr_sign_event", params, "web-sign-kind1");
signOutEl.textContent = pretty(resp?.result ?? resp);
} catch (e) {
signOutEl.textContent = String(e);
@@ -472,7 +472,7 @@
const peer = requirePeerHex(nip04PeerEl.value);
const msg = String(nip04MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nip04_encrypt", params, "web-nip04-enc");
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;
@@ -488,7 +488,7 @@
const peer = requirePeerHex(nip04DecPeerEl.value);
const ciphertext = String(nip04CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nip04_decrypt", params, "web-nip04-dec");
const resp = await rpcCall("nostr_nip04_decrypt", params, "web-nip04-dec");
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
} catch (e) {
nip04DecOutEl.textContent = String(e);
@@ -500,7 +500,7 @@
const peer = requirePeerHex(nip44PeerEl.value);
const msg = String(nip44MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nip44_encrypt", params, "web-nip44-enc");
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;
@@ -516,7 +516,7 @@
const peer = requirePeerHex(nip44DecPeerEl.value);
const ciphertext = String(nip44CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nip44_decrypt", params, "web-nip44-dec");
const resp = await rpcCall("nostr_nip44_decrypt", params, "web-nip44-dec");
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);

View File

@@ -8,7 +8,7 @@ import time
from coincurve import PrivateKey
HOST = "npub15uqyclnr3er7r8uhka7f0ae2yt4gkjat8gxdan04q0e6xrnwmtjswcyla3.fips"
PORT = 8080
PORT = 11111
# Demo caller key (32 bytes). Replace with your stable caller key in real use.
PRIVKEY = bytes(range(1, 33))

View File

@@ -4,14 +4,14 @@
* bech32 npub for each.
*
* This is a cross-qube test client for Qubes OS: the signer runs in the
* nostr_signer qube listening on tcp:[::]:8080, and this client runs in
* nostr_signer qube listening on tcp:[::]:11111, and this client runs in
* a different qube connecting to the signer's FIPS address.
*
* Usage:
* ./get_pubkey_tcp <host> <port>
* ./get_pubkey_tcp npub1xxx...fips 8080
* ./get_pubkey_tcp npub1xxx...fips 11111
*
* If no arguments are given, defaults to localhost:8080.
* If no arguments are given, defaults to localhost:11111.
*
* Output: for each index, prints:
* index 0: hex=<64 hex chars> npub=npub1...
@@ -95,7 +95,7 @@ static int query_pubkey(const char *host, int port, int nostr_index,
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "get_public_key", params, &result) != NOSTR_SUCCESS) {
if (nsigner_client_call(client, "nostr_get_public_key", params, &result) != NOSTR_SUCCESS) {
fprintf(stderr, "request failed for index %d: %s\n", nostr_index,
nsigner_client_last_error(client));
params = NULL; /* nsigner_client_call took ownership even on failure */
@@ -139,7 +139,7 @@ cleanup:
int main(int argc, char **argv) {
const char *host = "127.0.0.1";
int port = 8080;
int port = 11111;
char hex0[65], npub0[128];
char hex1[65], npub1[128];
int failures = 0;

View File

@@ -59,7 +59,7 @@ int main(int argc, char **argv) {
goto cleanup;
}
if (nsigner_client_call(client, "get_public_key", params, &result) != NOSTR_SUCCESS) {
if (nsigner_client_call(client, "nostr_get_public_key", params, &result) != NOSTR_SUCCESS) {
fprintf(stderr, "request failed: %s\n", nsigner_client_last_error(client));
goto cleanup;
}

View File

@@ -167,7 +167,7 @@ def cmd_get_public_key(args):
def cmd_sign_event(args):
event = json.loads(args.event)
print(json.dumps(rpc(args, "sign_event", {"event": event}), indent=2))
print(json.dumps(rpc(args, "nostr_sign_event", {"event": event}), indent=2))
def build_parser():

View File

@@ -5,14 +5,14 @@
* and bech32 npub for each.
*
* This is a cross-qube test client for Qubes OS: the signer runs in the
* nostr_signer qube listening on tcp:[::]:8080, and this client runs in
* nostr_signer qube listening on tcp:[::]:11111, and this client runs in
* a different qube connecting to the signer's FIPS address.
*
* Usage:
* node n_signer_qube_example.js [host] [port]
* node n_signer_qube_example.js fd56:d7c3:f605:719d:15b:18a0:fb06:982f 8080
* node n_signer_qube_example.js fd56:d7c3:f605:719d:15b:18a0:fb06:982f 11111
*
* If no arguments are given, defaults to localhost:8080.
* If no arguments are given, defaults to localhost:11111.
*
* Protocol:
* - 4-byte big-endian length prefix + JSON payload (TCP framing)
@@ -214,7 +214,7 @@ function hexToNpub(pubkeyHex) {
async function main() {
const host = process.argv[2] || "127.0.0.1";
const port = parseInt(process.argv[3] || "8080", 10);
const port = parseInt(process.argv[3] || "11111", 10);
console.log(`Connecting to n_signer at ${host}:${port}`);
console.log("Querying get_public_key for nostr_index 0 and 1...\n");

219
examples/otp_nostr_30078.py Normal file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
otp_nostr_30078.py — example: encrypt data with OTP, wrap in a Nostr kind 30078
event, sign it with n_signer, and print the signed event for publishing.
Workflow:
1. Call n_signer's `otp_encrypt` verb to encrypt plaintext with the bound OTP pad.
2. Build a Nostr kind 30078 (replaceable parameterized) event with the ASCII-armored
ciphertext as the `content` field.
3. Call n_signer's `sign_event` verb to sign the event with the secp256k1 key.
4. Print the signed event JSON, ready to publish to Nostr relays.
This is a demo — it does not actually publish to a relay. To publish, send the
signed event to your preferred Nostr relay using a library like nostr-tools,
nostril, or nak.
Usage:
python3 examples/otp_nostr_30078.py "Your secret message here"
Requirements:
- n_signer running with --otp-pad-dir / --otp-pad bound, and a secp256k1
role (e.g. "main") available for sign_event.
- This script connects to n_signer via stdio (one process per request).
See plans/otp_nostr_integration.md for the full design.
"""
import base64
import hashlib
import json
import os
import struct
import subprocess
import sys
import time
NSIGNER = "./build/nsigner"
PAD_DIR = "/media/user/Music/pads"
PAD_SPEC = "333e9902db839d9d"
MNEMONIC_FILE = ".test_mnemonic"
MNEMONIC_TMP = ".test_mnemonic_otp_30078.tmp"
def send_framed(proc, obj):
payload = json.dumps(obj).encode()
proc.stdin.write(struct.pack(">I", len(payload)))
proc.stdin.write(payload)
proc.stdin.flush()
def recv_framed(proc):
"""Read a framed response, skipping any banner text on stdout."""
buf = b""
while True:
b = proc.stdout.read(1)
if not b:
return None
buf = (buf + b)[-4:]
if len(buf) < 4:
continue
(length,) = struct.unpack(">I", buf)
if 1 <= length <= 1024 * 1024:
peek = proc.stdout.read(1)
if peek == b"{":
body = peek + proc.stdout.read(length - 1)
return json.loads(body.decode())
else:
buf = (buf + peek)[-4:]
def run_one_request(req_obj):
"""Run nsigner in stdio mode for a single framed request/response."""
shell_cmd = (
f"exec 3<{MNEMONIC_TMP}; "
f"exec {NSIGNER} --listen stdio --mnemonic-fd 3 "
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
f"--otp-allow-blkback --allow-all"
)
proc = subprocess.Popen(
["bash", "-c", shell_cmd],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
import time as _time
_time.sleep(1.0)
if proc.poll() is not None:
err = proc.stderr.read().decode()
print(f"ERROR: nsigner exited early (code {proc.returncode})")
print(f"stderr: {err}")
return None
send_framed(proc, req_obj)
resp = recv_framed(proc)
proc.stdin.close()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
return resp
def compute_event_id(event):
"""Compute the Nostr event ID (SHA-256 of the canonical serialized event)."""
# Nostr event serialization: [0, pubkey, created_at, kind, tags, content]
serialized = json.dumps([
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
], separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(serialized.encode()).hexdigest()
def main():
plaintext = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "Secret OTP message"
print(f"Plaintext: {plaintext}")
# Prepare the mnemonic temp file.
with open(MNEMONIC_FILE) as f:
mnemonic = f.read().strip()
with open(MNEMONIC_TMP, "w") as f:
f.write(mnemonic + "\n")
try:
# Step 1: Get the public key for the "main" role
print("\n=== Step 1: get_public_key ===")
resp = run_one_request({
"id": "1",
"method": "get_public_key",
"params": [{"role": "main"}],
})
if resp is None or "result" not in resp:
print("ERROR: get_public_key failed")
print(f"Response: {resp}")
return 1
# The result is a plain hex string for secp256k1 backward compat.
pubkey_hex = resp["result"].strip('"')
print(f"Public key: {pubkey_hex}")
# Step 2: Encrypt the plaintext with OTP
print("\n=== Step 2: otp_encrypt ===")
pt_b64 = base64.b64encode(plaintext.encode()).decode()
resp = run_one_request({
"id": "2",
"method": "encrypt",
"params": [pt_b64, {"algorithm": "otp", "encoding": "ascii"}],
})
if resp is None or "result" not in resp:
print("ERROR: otp_encrypt failed")
print(f"Response: {resp}")
return 1
enc_result = json.loads(resp["result"])
ciphertext = enc_result["ciphertext"]
pad_chksum = enc_result["pad_chksum"]
pad_offset = enc_result["pad_offset_after"]
print(f"Pad checksum: {pad_chksum}")
print(f"Pad offset after encrypt: {pad_offset}")
print(f"Ciphertext (first 60 chars): {ciphertext[:60]}...")
# Step 3: Build the Nostr kind 30078 event
print("\n=== Step 3: Build kind 30078 event ===")
# Use a unique d-tag based on the pad checksum and offset.
d_tag = f"otp-{pad_chksum[:16]}-{pad_offset}"
event = {
"pubkey": pubkey_hex,
"created_at": int(time.time()),
"kind": 30078,
"tags": [
["d", d_tag],
["otp-pad", pad_chksum[:16]],
["otp-version", "v0.0.2-otp"],
["otp-encoding", "ascii"],
],
"content": ciphertext,
}
# Compute the event ID.
event_id = compute_event_id(event)
event["id"] = event_id
print(f"Event ID: {event_id}")
print(f"d-tag: {d_tag}")
# Step 4: Sign the event with n_signer
print("\n=== Step 4: sign_event ===")
# sign_event expects the event JSON as the first param (without id/sig).
# The signer computes the id and signature internally.
event_for_signing = {
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"kind": event["kind"],
"tags": event["tags"],
"content": event["content"],
}
resp = run_one_request({
"id": "3",
"method": "nostr_sign_event",
"params": [json.dumps(event_for_signing), {"role": "main"}],
})
if resp is None or "result" not in resp:
print("ERROR: sign_event failed")
print(f"Response: {resp}")
return 1
sig = resp["result"].strip('"')
event["sig"] = sig
print(f"Signature: {sig[:60]}...")
# Step 5: Print the signed event
print("\n=== Signed Nostr event (ready to publish) ===")
print(json.dumps(event, indent=2))
print(f"\nTo publish: send this event to a Nostr relay.")
print(f"To decrypt: call otp_decrypt with the content field.")
return 0
finally:
try:
os.unlink(MNEMONIC_TMP)
except OSError:
pass
if __name__ == "__main__":
sys.exit(main())

View File

@@ -49,7 +49,8 @@ static int get_structured_pubkey(nsigner_client_t *client, const char *role,
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "role", role);
cJSON_AddStringToObject(opts, "algorithm", "ml-kem-768");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
@@ -99,11 +100,12 @@ static int kem_encapsulate(nsigner_client_t *client, const char *role,
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "role", role);
cJSON_AddStringToObject(opts, "algorithm", "ml-kem-768");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "kem_encapsulate", params, &result) != NOSTR_SUCCESS) {
if (nsigner_client_call(client, "encapsulate", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return -1;
}
@@ -147,11 +149,12 @@ static char *kem_decapsulate(nsigner_client_t *client, const char *role,
cJSON_Delete(params);
return NULL;
}
cJSON_AddStringToObject(opts, "role", role);
cJSON_AddStringToObject(opts, "algorithm", "ml-kem-768");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "kem_decapsulate", params, &result) != NOSTR_SUCCESS) {
if (nsigner_client_call(client, "decapsulate", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return NULL;
}

View File

@@ -46,7 +46,8 @@ static int get_structured_pubkey(nsigner_client_t *client, const char *role,
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "role", role);
cJSON_AddStringToObject(opts, "algorithm", "ml-dsa-65");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
@@ -89,11 +90,12 @@ static char *sign_data(nsigner_client_t *client, const char *role,
cJSON_Delete(params);
return NULL;
}
cJSON_AddStringToObject(opts, "role", role);
cJSON_AddStringToObject(opts, "algorithm", "ml-dsa-65");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "sign_data", params, &result) != NOSTR_SUCCESS) {
if (nsigner_client_call(client, "sign", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return NULL;
}

View File

@@ -73,7 +73,7 @@ int main(int argc, char **argv) {
cJSON_AddItemToArray(params, opts);
opts = NULL; /* owned by params now */
if (nsigner_client_call(client, "sign_event", params, &result) != NOSTR_SUCCESS) {
if (nsigner_client_call(client, "nostr_sign_event", params, &result) != NOSTR_SUCCESS) {
fprintf(stderr, "request failed: %s\n", nsigner_client_last_error(client));
goto cleanup;
}

View File

@@ -47,7 +47,8 @@ static int get_structured_pubkey(nsigner_client_t *client, const char *role,
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "role", role);
cJSON_AddStringToObject(opts, "algorithm", "ed25519");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
@@ -97,11 +98,12 @@ static char *ssh_sign(nsigner_client_t *client, const char *role,
cJSON_Delete(params);
return NULL;
}
cJSON_AddStringToObject(opts, "role", role);
cJSON_AddStringToObject(opts, "algorithm", "ed25519");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "ssh_sign", params, &result) != NOSTR_SUCCESS) {
if (nsigner_client_call(client, "sign", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return NULL;
}

View File

@@ -219,20 +219,27 @@ create_gitea_release() {
token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/n_signer"
# Include created_at timestamp to work around Gitea bug where releases
# created without a timestamp get epoch zero and don't appear in the
# releases list or web UI.
local now_iso
now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local response
response=$(curl -s -X POST "$api_url/releases" \
-H "Authorization: token $token" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$NEW_VERSION\", \"target_commitish\": \"master\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\", \"draft\": false, \"prerelease\": false, \"created_at\": \"$now_iso\"}")
-d "{\"tag_name\": \"$NEW_VERSION\", \"target_commitish\": \"master\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\", \"draft\": false, \"prerelease\": false}")
if echo "$response" | grep -q '"id"'; then
echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2
local release_id
release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
# Work around Gitea bug: releases created via API get created_unix=0
# (epoch zero) in the database, causing them to not appear in the
# releases list or web UI. Fix the timestamp directly in the DB.
local now_unix
now_unix=$(date +%s)
print_status "Fixing release timestamp in Gitea database (workaround for Gitea bug)..."
ssh -o ConnectTimeout=10 ubuntu@laantungir.net \
"sudo sqlite3 /data/gitea/gitea.db \"UPDATE release SET created_unix=$now_unix WHERE id=$release_id;\"" \
2>/dev/null || print_warning "Could not fix release timestamp via SSH (release may not appear in web UI)"
echo "$release_id"
else
print_error "Failed to create Gitea release: $response"
return 1

View File

@@ -84,12 +84,21 @@ resolve_nsigner_version() {
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
fi
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases" \
| jq -r '.[0].tag_name // empty' || true)"
# Use the git tags API and sort by version number, instead of the releases
# API which sorts by created_at timestamp. Gitea has a bug where some
# releases get epoch-zero timestamps and don't appear in the releases list.
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/n_signer/tags?limit=50" \
| jq -r '[.[].name] | map(select(startswith("v"))) | sort_by(ltrimstr("v") | split(".") | map(tonumber)) | last // empty' || true)"
if [[ -z "${latest_tag}" ]]; then
# Fallback: try the releases API (old behavior)
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases" \
| jq -r '.[0].tag_name // empty' || true)"
fi
if [[ -z "${latest_tag}" ]]; then
err "Could not resolve latest n_signer release tag from API."
err "Set NSIGNER_VERSION explicitly (e.g. NSIGNER_VERSION=v0.0.11)."
err "Set NSIGNER_VERSION explicitly (e.g. NSIGNER_VERSION=v0.0.46)."
exit 1
fi
@@ -176,7 +185,7 @@ write_signer_start_script() {
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
LISTEN_TARGET="${NSIGNER_LISTEN_TARGET:-tcp:[::]:8080}"
LISTEN_TARGET="${NSIGNER_LISTEN_TARGET:-tcp:[::]:11111}"
# If first arg is "qrexec", start in unix bridge mode for Qubes qrexec.
# Otherwise, pass any extra args through to nsigner (e.g. --allow-all).

25
libotppad/Makefile Normal file
View File

@@ -0,0 +1,25 @@
CC ?= gcc
CFLAGS ?= -Wall -Wextra -std=c99 -O2
AR ?= ar
OBJS = libotppad.o
LIB = libotppad.a
all: $(LIB)
$(LIB): $(OBJS)
$(AR) rcs $@ $(OBJS)
libotppad.o: libotppad.c libotppad.h
$(CC) $(CFLAGS) -c libotppad.c -o libotppad.o
test: test_libotppad
./test_libotppad
test_libotppad: test_libotppad.c $(LIB)
$(CC) $(CFLAGS) -I. test_libotppad.c -L. -lotppad -o test_libotppad
clean:
rm -f $(OBJS) $(LIB) test_libotppad
.PHONY: all test clean

398
libotppad/libotppad.c Normal file
View File

@@ -0,0 +1,398 @@
/*
* libotppad.c — implementation of libotppad.h.
*
* Extracted from the otp project (src/crypto.c, src/padding.c, src/pads.c)
* and made self-contained: no main.h, no global state, no UI.
*/
#define _POSIX_C_SOURCE 200809L
#include "libotppad.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
/* ------------------------------------------------------------------ */
/* XOR transform */
/* ------------------------------------------------------------------ */
int otppad_xor(const unsigned char *data, size_t data_len,
const unsigned char *pad_data, unsigned char *result) {
if (!data || !pad_data || !result) {
return 1;
}
for (size_t i = 0; i < data_len; i++) {
result[i] = data[i] ^ pad_data[i];
}
return 0;
}
/* ------------------------------------------------------------------ */
/* Base64 */
/* ------------------------------------------------------------------ */
static const char b64_chars[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static const int b64_decode_table[256] = {
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63,
52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-2,-1,-1,
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,
15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,
-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,
41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
};
char *otppad_base64_encode(const unsigned char *input, int length) {
if (!input || length < 0) return NULL;
int output_length = 4 * ((length + 2) / 3);
char *encoded = (char *)malloc((size_t)output_length + 1);
if (!encoded) return NULL;
int i, j;
for (i = 0, j = 0; i < length;) {
uint32_t octet_a = i < length ? input[i++] : 0;
uint32_t octet_b = i < length ? input[i++] : 0;
uint32_t octet_c = i < length ? input[i++] : 0;
uint32_t triple = (octet_a << 16) + (octet_b << 8) + octet_c;
encoded[j++] = b64_chars[(triple >> 18) & 63];
encoded[j++] = b64_chars[(triple >> 12) & 63];
encoded[j++] = b64_chars[(triple >> 6) & 63];
encoded[j++] = b64_chars[triple & 63];
}
for (int pad = 0; pad < (3 - length % 3) % 3; pad++) {
encoded[output_length - 1 - pad] = '=';
}
encoded[output_length] = '\0';
return encoded;
}
unsigned char *otppad_base64_decode(const char *input, int *output_length) {
if (!input || !output_length) return NULL;
int input_length = (int)strlen(input);
if (input_length % 4 != 0) return NULL;
*output_length = input_length / 4 * 3;
if (input[input_length - 1] == '=') (*output_length)--;
if (input[input_length - 2] == '=') (*output_length)--;
unsigned char *decoded = (unsigned char *)malloc((size_t)*output_length);
if (!decoded) return NULL;
int i, j;
for (i = 0, j = 0; i < input_length;) {
int sa = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
int sb = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
int sc = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
int sd = input[i] == '=' ? 0 & i++ : b64_decode_table[(unsigned char)input[i++]];
if (sa == -1 || sb == -1 || sc == -1 || sd == -1) {
free(decoded);
return NULL;
}
uint32_t triple = ((uint32_t)sa << 18) + ((uint32_t)sb << 12) +
((uint32_t)sc << 6) + (uint32_t)sd;
if (j < *output_length) decoded[j++] = (triple >> 16) & 255;
if (j < *output_length) decoded[j++] = (triple >> 8) & 255;
if (j < *output_length) decoded[j++] = triple & 255;
}
return decoded;
}
/* ------------------------------------------------------------------ */
/* Padmé padding */
/* ------------------------------------------------------------------ */
size_t otppad_chunk_size(size_t msg_len) {
size_t chunk = 256;
while (chunk < msg_len + 1) {
chunk *= 2;
}
return chunk;
}
int otppad_pad_apply(unsigned char *buffer, size_t msg_len, size_t chunk_size) {
if (!buffer) return 1;
if (chunk_size < msg_len + 1) return 2;
buffer[msg_len] = 0x80;
if (chunk_size > msg_len + 1) {
memset(buffer + msg_len + 1, 0x00, chunk_size - msg_len - 1);
}
return 0;
}
int otppad_pad_remove(const unsigned char *buffer, size_t chunk_size,
size_t *msg_len) {
if (!buffer || !msg_len) return 1;
if (chunk_size == 0) return 2;
for (int i = (int)chunk_size - 1; i >= 0; i--) {
if (buffer[i] == 0x80) {
*msg_len = (size_t)i;
return 0;
} else if (buffer[i] != 0x00) {
return 3;
}
}
return 4;
}
void otppad_chunk_format(size_t chunk_size, char *buffer, size_t buffer_size) {
if (!buffer || buffer_size == 0) return;
if (chunk_size < 1024) {
snprintf(buffer, buffer_size, "%zu bytes", chunk_size);
} else if (chunk_size < 1024 * 1024) {
snprintf(buffer, buffer_size, "%.1f KB", chunk_size / 1024.0);
} else if (chunk_size < 1024 * 1024 * 1024) {
snprintf(buffer, buffer_size, "%.1f MB", chunk_size / (1024.0 * 1024.0));
} else {
snprintf(buffer, buffer_size, "%.1f GB",
chunk_size / (1024.0 * 1024.0 * 1024.0));
}
}
/* ------------------------------------------------------------------ */
/* ASCII armored message format */
/* ------------------------------------------------------------------ */
int otppad_armor_parse(const char *message, char *chksum, uint64_t *offset,
char *base64_data, size_t base64_buf_size) {
if (!message || !chksum || !offset || !base64_data || base64_buf_size == 0) {
return 1;
}
size_t msg_len = strlen(message);
char *copy = (char *)malloc(msg_len + 1);
if (!copy) return 1;
strcpy(copy, message);
char *line = strtok(copy, "\n");
int found_begin = 0, in_data = 0, found_chksum = 0, found_offset = 0;
chksum[0] = '\0';
*offset = 0;
base64_data[0] = '\0';
while (line != NULL) {
if (strcmp(line, OTPPAD_ARMOR_BEGIN) == 0) {
found_begin = 1;
} else if (strcmp(line, OTPPAD_ARMOR_END) == 0) {
break;
} else if (found_begin) {
if (strncmp(line, "Pad-ChkSum: ", 12) == 0) {
strncpy(chksum, line + 12, OTPPAD_CHKSUM_HEX_LEN);
chksum[OTPPAD_CHKSUM_HEX_LEN] = '\0';
found_chksum = 1;
} else if (strncmp(line, "Pad-Offset: ", 12) == 0) {
*offset = strtoull(line + 12, NULL, 10);
found_offset = 1;
} else if (strlen(line) == 0) {
in_data = 1;
} else if (in_data) {
strncat(base64_data, line, base64_buf_size - strlen(base64_data) - 1);
} else if (strncmp(line, "Version:", 8) != 0 &&
strncmp(line, "Pad-", 4) != 0) {
strncat(base64_data, line, base64_buf_size - strlen(base64_data) - 1);
}
}
line = strtok(NULL, "\n");
}
free(copy);
if (!found_begin || !found_chksum || !found_offset) {
return 2;
}
return 0;
}
int otppad_armor_generate(const char *version, const char *chksum,
uint64_t offset,
const unsigned char *encrypted_data, size_t data_length,
char **ascii_output) {
if (!chksum || !encrypted_data || !ascii_output) return 1;
char *b64 = otppad_base64_encode(encrypted_data, (int)data_length);
if (!b64) return 2;
size_t b64_len = strlen(b64);
size_t total = 256 + b64_len + (b64_len / 64) + 64;
*ascii_output = (char *)malloc(total);
if (!*ascii_output) {
free(b64);
return 3;
}
char line[256];
strcpy(*ascii_output, OTPPAD_ARMOR_BEGIN);
strcat(*ascii_output, "\n");
snprintf(line, sizeof(line), "Version: %s\n", version ? version : "v0");
strcat(*ascii_output, line);
snprintf(line, sizeof(line), "Pad-ChkSum: %s\n", chksum);
strcat(*ascii_output, line);
snprintf(line, sizeof(line), "Pad-Offset: %llu\n",
(unsigned long long)offset);
strcat(*ascii_output, line);
strcat(*ascii_output, "\n");
int b64_len_int = (int)b64_len;
for (int i = 0; i < b64_len_int; i += 64) {
snprintf(line, sizeof(line), "%.64s\n", b64 + i);
strcat(*ascii_output, line);
}
strcat(*ascii_output, OTPPAD_ARMOR_END);
strcat(*ascii_output, "\n");
free(b64);
return 0;
}
/* ------------------------------------------------------------------ */
/* Binary .otp file format */
/* ------------------------------------------------------------------ */
int otppad_bin_header_write(FILE *fp, const otppad_bin_header_t *hdr) {
if (!fp || !hdr) return 1;
if (fwrite(OTPPAD_MAGIC, 1, OTPPAD_MAGIC_LEN, fp) != OTPPAD_MAGIC_LEN) return 2;
if (fwrite(&hdr->version, sizeof(uint16_t), 1, fp) != 1) return 3;
if (fwrite(hdr->pad_chksum, 1, OTPPAD_CHKSUM_BIN_LEN, fp) != OTPPAD_CHKSUM_BIN_LEN) return 4;
if (fwrite(&hdr->pad_offset, sizeof(uint64_t), 1, fp) != 1) return 5;
if (fwrite(&hdr->file_mode, sizeof(uint32_t), 1, fp) != 1) return 6;
if (fwrite(&hdr->file_size, sizeof(uint64_t), 1, fp) != 1) return 7;
return 0;
}
int otppad_bin_header_read(FILE *fp, otppad_bin_header_t *hdr) {
if (!fp || !hdr) return 1;
memset(hdr, 0, sizeof(*hdr));
if (fread(hdr->magic, 1, OTPPAD_MAGIC_LEN, fp) != OTPPAD_MAGIC_LEN) return 2;
if (fread(&hdr->version, sizeof(uint16_t), 1, fp) != 1) return 3;
if (fread(hdr->pad_chksum, 1, OTPPAD_CHKSUM_BIN_LEN, fp) != OTPPAD_CHKSUM_BIN_LEN) return 4;
if (fread(&hdr->pad_offset, sizeof(uint64_t), 1, fp) != 1) return 5;
if (fread(&hdr->file_mode, sizeof(uint32_t), 1, fp) != 1) return 6;
if (fread(&hdr->file_size, sizeof(uint64_t), 1, fp) != 1) return 7;
return 0;
}
int otppad_bin_is_magic(const unsigned char *buf, size_t len) {
if (!buf || len < OTPPAD_MAGIC_LEN) return 0;
return memcmp(buf, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN) == 0;
}
/* ------------------------------------------------------------------ */
/* Per-pad .state file */
/* ------------------------------------------------------------------ */
int otppad_state_read(const char *pads_dir, const char *chksum, uint64_t *offset) {
if (!pads_dir || !chksum || !offset) return 1;
char path[1024];
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
FILE *f = fopen(path, "r");
if (!f) return 2;
char line[128];
if (!fgets(line, sizeof(line), f)) {
fclose(f);
return 3;
}
fclose(f);
if (strncmp(line, "offset=", 7) != 0) {
return 4;
}
*offset = strtoull(line + 7, NULL, 10);
return 0;
}
int otppad_state_write(const char *pads_dir, const char *chksum, uint64_t offset) {
if (!pads_dir || !chksum) return 1;
char path[1024];
char tmp[1100];
snprintf(path, sizeof(path), "%s/%s.state", pads_dir, chksum);
snprintf(tmp, sizeof(tmp), "%s/%s.state.tmp.XXXXXX", pads_dir, chksum);
int tfd = mkstemp(tmp);
if (tfd < 0) return 2;
FILE *f = fdopen(tfd, "w");
if (!f) {
close(tfd);
unlink(tmp);
return 3;
}
if (fprintf(f, "offset=%llu\n", (unsigned long long)offset) < 0) {
fclose(f);
unlink(tmp);
return 4;
}
if (fclose(f) != 0) {
unlink(tmp);
return 5;
}
if (rename(tmp, path) != 0) {
unlink(tmp);
return 6;
}
return 0;
}
/* ------------------------------------------------------------------ */
/* Pad checksum */
/* ------------------------------------------------------------------ */
int otppad_checksum(const char *pad_path, char *checksum_hex) {
if (!pad_path || !checksum_hex) return 1;
FILE *file = fopen(pad_path, "rb");
if (!file) return 2;
unsigned char checksum[OTPPAD_CHKSUM_BIN_LEN];
unsigned char buffer[64 * 1024];
size_t bytes_read;
size_t total_bytes = 0;
memset(checksum, 0, OTPPAD_CHKSUM_BIN_LEN);
while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0) {
for (size_t i = 0; i < bytes_read; i++) {
size_t pos = total_bytes + i;
unsigned char bucket = (unsigned char)(pos % OTPPAD_CHKSUM_BIN_LEN);
checksum[bucket] ^= (unsigned char)buffer[i] ^
(unsigned char)((pos >> 8) & 0xFF) ^
(unsigned char)((pos >> 16) & 0xFF) ^
(unsigned char)((pos >> 24) & 0xFF);
}
total_bytes += bytes_read;
}
fclose(file);
file = fopen(pad_path, "rb");
if (!file) return 3;
unsigned char pad_key[OTPPAD_CHKSUM_BIN_LEN];
if (fread(pad_key, 1, OTPPAD_CHKSUM_BIN_LEN, file) != OTPPAD_CHKSUM_BIN_LEN) {
fclose(file);
return 4;
}
fclose(file);
unsigned char enc[OTPPAD_CHKSUM_BIN_LEN];
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
enc[i] = checksum[i] ^ pad_key[i];
}
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
sprintf(checksum_hex + (i * 2), "%02x", enc[i]);
}
checksum_hex[OTPPAD_CHKSUM_HEX_LEN] = '\0';
return 0;
}

201
libotppad/libotppad.h Normal file
View File

@@ -0,0 +1,201 @@
/*
* libotppad.h — format-critical helpers for one-time-pad encryption.
*
* Bit-compatible with the `otp` project (https://git.laantungir.net/laantungir/otp):
* - XOR transform
* - ASCII armored message format ("-----BEGIN OTP MESSAGE-----")
* - Binary .otp file format (magic "OTP\0")
* - ISO/IEC 9797-1 Method 2 (Padmé) padding with exponential bucketing
* - Per-pad .state file ("offset=<n>\n")
* - 256-bit XOR pad checksum (position-dependent, XORed with first 32 pad bytes)
*
* This library is self-contained: it does not depend on the `otp` project's
* main.h, global state, or UI code. Both `otp` and `n_signer` link against it.
*
* License: same as the otp project.
*/
#ifndef LIBOTPPAD_H
#define LIBOTPPAD_H
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
#define OTPPAD_CHKSUM_HEX_LEN 64 /* 32 bytes -> 64 hex chars */
#define OTPPAD_CHKSUM_BIN_LEN 32
#define OTPPAD_HEADER_RESERVED 32 /* bytes reserved at start of pad */
#define OTPPAD_MAGIC "OTP\0" /* 4-byte binary file magic */
#define OTPPAD_MAGIC_LEN 4
#define OTPPAD_FORMAT_VERSION 1 /* binary .otp format version */
#define OTPPAD_ARMOR_BEGIN "-----BEGIN OTP MESSAGE-----"
#define OTPPAD_ARMOR_END "-----END OTP MESSAGE-----"
/* ------------------------------------------------------------------ */
/* XOR transform */
/* ------------------------------------------------------------------ */
/*
* XOR `data_len` bytes of `data` with `pad_data` into `result`.
* `data`, `pad_data`, `result` must each be at least `data_len` bytes.
* `result` may alias `data` or `pad_data`.
* Returns 0 on success, non-zero on null pointer.
*/
int otppad_xor(const unsigned char *data, size_t data_len,
const unsigned char *pad_data, unsigned char *result);
/* ------------------------------------------------------------------ */
/* Base64 */
/* ------------------------------------------------------------------ */
/* Encode `length` bytes of `input` as a NUL-terminated base64 string.
* Caller frees the returned string. Returns NULL on allocation failure. */
char *otppad_base64_encode(const unsigned char *input, int length);
/* Decode NUL-terminated base64 `input` into bytes.
* Caller frees the returned buffer. *output_length receives the byte count.
* Returns NULL on invalid input or allocation failure. */
unsigned char *otppad_base64_decode(const char *input, int *output_length);
/* ------------------------------------------------------------------ */
/* Padmé padding (ISO/IEC 9797-1 Method 2) + exponential bucketing */
/* ------------------------------------------------------------------ */
/* Calculate the bucket size for a message of `msg_len` bytes.
* Starts at 256 bytes and doubles until `chunk >= msg_len + 1`. */
size_t otppad_chunk_size(size_t msg_len);
/* Apply Padmé padding to `buffer` (must hold `chunk_size` bytes).
* Writes 0x80 at `buffer[msg_len]` then zeroes to `chunk_size`.
* Returns 0 on success, non-zero on error. */
int otppad_pad_apply(unsigned char *buffer, size_t msg_len, size_t chunk_size);
/* Remove Padmé padding: scan backwards for 0x80, set *msg_len to its index.
* Returns 0 on success, non-zero on invalid padding. */
int otppad_pad_remove(const unsigned char *buffer, size_t chunk_size,
size_t *msg_len);
/* Human-readable chunk size string (e.g. "256 bytes", "1.0 KB"). */
void otppad_chunk_format(size_t chunk_size, char *buffer, size_t buffer_size);
/* ------------------------------------------------------------------ */
/* ASCII armored message format */
/* ------------------------------------------------------------------ */
/*
* Parse an ASCII-armored OTP message.
*
* `chksum` must be at least OTPPAD_CHKSUM_HEX_LEN+1 bytes.
* `base64_data` must be at least `base64_buf_size` bytes; the decoded
* ciphertext is NOT returned here — only the raw base64 text. Use
* otppad_base64_decode() to get the bytes.
*
* On success returns 0 and sets `chksum`, `*offset`, and `base64_data`.
* Returns non-zero on malformed input.
*/
int otppad_armor_parse(const char *message, char *chksum, uint64_t *offset,
char *base64_data, size_t base64_buf_size);
/*
* Build an ASCII-armored OTP message.
*
* `version` is the version string for the "Version:" header (e.g. "v0.3.53").
* `chksum` is the 64-char hex pad checksum.
* `offset` is the pad offset where the slice begins.
* `encrypted_data` / `data_length` is the ciphertext to base64-encode.
*
* On success returns 0 and sets `*ascii_output` to a malloc'd NUL-terminated
* string. Caller frees `*ascii_output`.
*/
int otppad_armor_generate(const char *version, const char *chksum,
uint64_t offset,
const unsigned char *encrypted_data, size_t data_length,
char **ascii_output);
/* ------------------------------------------------------------------ */
/* Binary .otp file format */
/* ------------------------------------------------------------------ */
/*
* Binary .otp header (58 bytes). All fields are little-endian on disk
* (written via fwrite of host-endian integers — matches the otp project,
* which is x86/ARM little-endian in practice).
*
* Offset Size Field
* 0 4 Magic "OTP\0"
* 4 2 Version (uint16, currently 1)
* 6 32 Pad checksum (binary, 32 bytes)
* 38 8 Pad offset (uint64)
* 46 4 Original file mode (uint32)
* 50 8 Original file size (uint64, NOT padded size)
* 58 var Encrypted (padded) data
*/
typedef struct {
char magic[OTPPAD_MAGIC_LEN];
uint16_t version;
unsigned char pad_chksum[OTPPAD_CHKSUM_BIN_LEN];
uint64_t pad_offset;
uint32_t file_mode;
uint64_t file_size; /* original (unpadded) size */
} otppad_bin_header_t;
/* Write a binary .otp header to `fp`. Returns 0 on success. */
int otppad_bin_header_write(FILE *fp, const otppad_bin_header_t *hdr);
/* Read a binary .otp header from `fp`. Returns 0 on success, non-zero on
* malformed input. Does not validate the magic — use otppad_bin_is_magic()
* first if needed. */
int otppad_bin_header_read(FILE *fp, otppad_bin_header_t *hdr);
/* Return 1 if the first 4 bytes of `buf` match the OTP magic. */
int otppad_bin_is_magic(const unsigned char *buf, size_t len);
/* ------------------------------------------------------------------ */
/* Per-pad .state file */
/* ------------------------------------------------------------------ */
/*
* Read the offset from `<pads_dir>/<chksum>.state`.
* Returns 0 on success and sets *offset. Non-zero on error.
*/
int otppad_state_read(const char *pads_dir, const char *chksum, uint64_t *offset);
/*
* Atomically write the offset to `<pads_dir>/<chksum>.state`.
* Writes to a temp file then renames, so a crash cannot corrupt the state.
* Returns 0 on success, non-zero on error.
*/
int otppad_state_write(const char *pads_dir, const char *chksum, uint64_t offset);
/* ------------------------------------------------------------------ */
/* Pad checksum */
/* ------------------------------------------------------------------ */
/*
* Compute the 256-bit XOR checksum of the pad file at `pad_path`.
* `checksum_hex` must be at least OTPPAD_CHKSUM_HEX_LEN+1 bytes.
*
* Algorithm (matches otp/src/crypto.c:calculate_checksum):
* - XOR every byte into one of 32 buckets, selected by (position % 32),
* also XORing in bytes (pos>>8),(pos>>16),(pos>>24) of the position.
* - XOR the resulting 32-byte checksum with the first 32 bytes of the pad
* (the "pad key").
* - Hex-encode the 32-byte result.
*
* Returns 0 on success, non-zero on error.
*/
int otppad_checksum(const char *pad_path, char *checksum_hex);
#ifdef __cplusplus
}
#endif
#endif /* LIBOTPPAD_H */

BIN
libotppad/test_libotppad Executable file

Binary file not shown.

213
libotppad/test_libotppad.c Normal file
View File

@@ -0,0 +1,213 @@
/*
* test_libotppad.c — unit tests for libotppad.
*
* Covers: XOR, base64 round-trip, Padmé padding round-trip, ASCII armor
* round-trip, binary header round-trip, .state file round-trip, and pad
* checksum against a known pad.
*/
#define _POSIX_C_SOURCE 200809L
#include "libotppad.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
static int failures = 0;
#define CHECK(cond, msg) do { \
if (!(cond)) { \
fprintf(stderr, "FAIL: %s (%s:%d)\n", (msg), __FILE__, __LINE__); \
failures++; \
} else { \
printf("ok: %s\n", (msg)); \
} \
} while (0)
static void test_xor(void) {
unsigned char data[] = {0x01, 0x02, 0x03, 0x04, 0x05};
unsigned char pad[] = {0xff, 0xee, 0xdd, 0xcc, 0xbb};
unsigned char out[5];
CHECK(otppad_xor(data, 5, pad, out) == 0, "xor returns 0");
CHECK(out[0] == 0xfe && out[1] == 0xec && out[2] == 0xde &&
out[3] == 0xc8 && out[4] == 0xbe, "xor values correct");
/* in-place aliasing */
CHECK(otppad_xor(data, 5, pad, data) == 0, "xor in-place");
CHECK(data[0] == 0xfe, "xor in-place value");
}
static void test_base64(void) {
const char *in = "Hello, World!";
int len = (int)strlen(in);
char *enc = otppad_base64_encode((const unsigned char *)in, len);
CHECK(enc != NULL, "base64 encode");
int dlen = 0;
unsigned char *dec = otppad_base64_decode(enc, &dlen);
CHECK(dec != NULL, "base64 decode");
CHECK(dlen == len, "base64 length preserved");
CHECK(memcmp(dec, in, len) == 0, "base64 round-trip");
free(enc);
free(dec);
/* empty input */
enc = otppad_base64_encode((const unsigned char *)"", 0);
CHECK(enc != NULL && strcmp(enc, "") == 0, "base64 empty");
free(enc);
}
static void test_padding(void) {
/* 10-byte message -> 256-byte bucket */
size_t chunk = otppad_chunk_size(10);
CHECK(chunk == 256, "chunk size for 10 bytes is 256");
/* 300-byte message -> 512-byte bucket */
chunk = otppad_chunk_size(300);
CHECK(chunk == 512, "chunk size for 300 bytes is 512");
/* 256-byte message -> 512 (needs +1 for 0x80) */
chunk = otppad_chunk_size(256);
CHECK(chunk == 512, "chunk size for 256 bytes is 512 (needs +1)");
unsigned char buf[512];
const char *msg = "test message";
size_t msg_len = strlen(msg);
memset(buf, 0xaa, sizeof(buf));
memcpy(buf, msg, msg_len);
chunk = otppad_chunk_size(msg_len);
CHECK(otppad_pad_apply(buf, msg_len, chunk) == 0, "pad apply");
CHECK(buf[msg_len] == 0x80, "pad marker at msg_len");
size_t recovered;
CHECK(otppad_pad_remove(buf, chunk, &recovered) == 0, "pad remove");
CHECK(recovered == msg_len, "pad remove recovers length");
CHECK(memcmp(buf, msg, msg_len) == 0, "pad round-trip preserves message");
}
static void test_armor(void) {
const char *chksum =
"333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e";
unsigned char ct[] = {0xde, 0xad, 0xbe, 0xef, 0x10, 0x20, 0x30, 0x40};
uint64_t offset = 12345;
char *armor = NULL;
CHECK(otppad_armor_generate("v0.3.53", chksum, offset, ct, sizeof(ct),
&armor) == 0,
"armor generate");
CHECK(armor != NULL && strstr(armor, "BEGIN OTP MESSAGE") != NULL,
"armor has begin marker");
char parsed_chk[OTPPAD_CHKSUM_HEX_LEN + 1];
uint64_t parsed_off;
char parsed_b64[8192];
CHECK(otppad_armor_parse(armor, parsed_chk, &parsed_off, parsed_b64,
sizeof(parsed_b64)) == 0,
"armor parse");
CHECK(strcmp(parsed_chk, chksum) == 0, "armor chksum round-trip");
CHECK(parsed_off == offset, "armor offset round-trip");
int dlen = 0;
unsigned char *dec = otppad_base64_decode(parsed_b64, &dlen);
CHECK(dec != NULL && dlen == (int)sizeof(ct), "armor b64 length");
CHECK(dec && memcmp(dec, ct, sizeof(ct)) == 0, "armor ciphertext round-trip");
free(armor);
free(dec);
}
static void test_bin_header(void) {
otppad_bin_header_t hdr;
memset(&hdr, 0, sizeof(hdr));
memcpy(hdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN);
hdr.version = OTPPAD_FORMAT_VERSION;
memset(hdr.pad_chksum, 0xab, OTPPAD_CHKSUM_BIN_LEN);
hdr.pad_offset = 999;
hdr.file_mode = 0644;
hdr.file_size = 4096;
const char *path = "test_bin_header.tmp";
FILE *fp = fopen(path, "wb");
CHECK(fp != NULL, "open bin header tmp for write");
CHECK(otppad_bin_header_write(fp, &hdr) == 0, "bin header write");
fclose(fp);
fp = fopen(path, "rb");
CHECK(fp != NULL, "open bin header tmp for read");
otppad_bin_header_t hdr2;
CHECK(otppad_bin_header_read(fp, &hdr2) == 0, "bin header read");
fclose(fp);
unlink(path);
CHECK(hdr2.version == hdr.version, "bin header version");
CHECK(hdr2.pad_offset == hdr.pad_offset, "bin header offset");
CHECK(hdr2.file_mode == hdr.file_mode, "bin header file_mode");
CHECK(hdr2.file_size == hdr.file_size, "bin header file_size");
CHECK(memcmp(hdr2.pad_chksum, hdr.pad_chksum, OTPPAD_CHKSUM_BIN_LEN) == 0,
"bin header chksum");
CHECK(otppad_bin_is_magic((const unsigned char *)hdr2.magic, 4),
"bin is_magic");
}
static void test_state(void) {
const char *dir = "test_state_dir.tmp";
mkdir(dir, 0755);
const char *chksum =
"333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e";
uint64_t off;
CHECK(otppad_state_read(dir, chksum, &off) != 0, "state read missing fails");
CHECK(otppad_state_write(dir, chksum, 42) == 0, "state write");
CHECK(otppad_state_read(dir, chksum, &off) == 0, "state read");
CHECK(off == 42, "state value");
CHECK(otppad_state_write(dir, chksum, 1000) == 0, "state overwrite");
CHECK(otppad_state_read(dir, chksum, &off) == 0, "state read 2");
CHECK(off == 1000, "state value 2");
/* cleanup */
char path[1024];
snprintf(path, sizeof(path), "%s/%s.state", dir, chksum);
unlink(path);
rmdir(dir);
}
static void test_checksum(void) {
/* Build a tiny pad: 64 bytes, first 32 are the "key", rest arbitrary. */
const char *dir = "test_chksum_dir.tmp";
const char *chksum_expected =
"333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e";
mkdir(dir, 0755);
/* Use the real test pad if it exists; otherwise skip this test. */
const char *real_pad =
"/media/user/Music/pads/333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.pad";
FILE *fp = fopen(real_pad, "rb");
if (!fp) {
printf("ok: checksum test skipped (no real pad at %s)\n", real_pad);
rmdir(dir);
return;
}
fclose(fp);
char chk[OTPPAD_CHKSUM_HEX_LEN + 1];
CHECK(otppad_checksum(real_pad, chk) == 0, "checksum computes");
CHECK(strcmp(chk, chksum_expected) == 0,
"checksum matches expected (bit-compatible with otp)");
rmdir(dir);
}
int main(void) {
test_xor();
test_base64();
test_padding();
test_armor();
test_bin_header();
test_state();
test_checksum();
if (failures == 0) {
printf("\nALL TESTS PASSED\n");
return 0;
}
printf("\n%d TEST(S) FAILED\n", failures);
return 1;
}

View File

@@ -0,0 +1,32 @@
#!/bin/bash
# Run in dom0 to allow the "nostr" qube to call n_signer without a Qubes popup.
set -euo pipefail
POLICY_FILE="/etc/qubes/policy.d/40-nsigner.policy"
SIGNER_QUBE="nostr_signer"
CALLER_QUBE="nostr"
SIGNER_TAG="nsigner-signer"
echo "Installing n_signer qrexec policy..."
sudo mkdir -p /etc/qubes/policy.d
sudo tee "$POLICY_FILE" >/dev/null <<EOF
# Qubes OS qrexec policy for nsigner
qubes.NsignerRpc * ai @tag:${SIGNER_TAG} allow target=${SIGNER_QUBE}
qubes.NsignerRpc * ${CALLER_QUBE} @tag:${SIGNER_TAG} allow target=${SIGNER_QUBE}
qubes.NsignerRpc * @anyvm @tag:${SIGNER_TAG} ask default_target=${SIGNER_QUBE}
qubes.NsignerRpc * @anyvm @anyvm deny
EOF
sudo chmod 0644 "$POLICY_FILE"
echo "Tagging ${SIGNER_QUBE} as ${SIGNER_TAG}..."
qvm-tags "$SIGNER_QUBE" add "$SIGNER_TAG"
echo
echo "Installed policy:"
sudo cat "$POLICY_FILE"
echo
echo "Tags on ${SIGNER_QUBE}:"
qvm-tags "$SIGNER_QUBE" list
echo
echo "Done. Calls from ${CALLER_QUBE} to ${SIGNER_QUBE} are now allowed without a Qubes popup."

538
plans/derive_hmac.md Normal file
View File

@@ -0,0 +1,538 @@
# Plan: `derive` verb — HMAC-SHA256 from a derived private key
## Problem
sovereign_browser stores bookmarks as NIP-51 kind 30003 parameterized-replaceable
events, one per folder. The `d` tag must be a **deterministic function of the
folder path** so that multiple devices editing the same logical folder produce
the same `d` tag and the relay's NIP-33 replaceability keeps only the latest
event. A random `d` tag would break cross-device sync (each device would emit a
new event for the same folder, with no dedup).
The browser currently computes the `d` tag locally as:
```
hmac_key = HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")
d = HMAC-SHA256(hmac_key, path) → 64 hex chars
```
This only works when the privkey is in browser memory (login methods
`generate` / `import`). When the signer is the **nsigner remote backend**, the
privkey is held by n_signer and never exposed to the browser, so
[`compute_hmac_key()`](../sovereign_browser/src/bookmarks.c:194) returns -1 and
[`path_to_d_tag`](../sovereign_browser/src/bookmarks.c:221) falls back to
**plaintext** path d tags — a privacy regression that leaks folder names to
relays.
## Decision
Add a single new algorithm-based verb to n_signer:
```
derive(data) = HMAC-SHA256(privkey, data) → 64 hex chars
```
- `privkey` is the secp256k1 private key derived on demand from the mnemonic at
`(algorithm: "secp256k1", index: N)` via the existing
[`alg_key_cache_derive`](src/key_store.c:1624).
- `data` is an arbitrary caller-supplied UTF-8 string (or hex-encoded bytes).
- Returns the 32-byte HMAC digest as 64 lowercase hex chars.
This is a **single-step** scheme. The browser will switch from its two-step
scheme to single-step by concatenating the label and path into the data string
before calling `derive`:
```
d = derive("sovereign-browser/bookmarks-folder-id-v1:" + path)
= HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1:" + path)
```
The label prefix provides domain separation (so the same path used by a
different application produces a different d tag). This is cryptographically
equivalent to the two-step scheme for the privacy properties that matter
(determinism, opacity, per-user-ness, no label/path recovery from the hash).
### Why single-step over two-step
1. **Generic primitive.** `derive(data) = HMAC(privkey, data)` is a clean
building block any caller can use for any purpose — bookmark d tags,
per-resource identifiers, per-app secrets, etc. A two-step verb bakes a
specific construction into the RPC.
2. **No state across calls.** A two-step scheme where the browser calls
`derive(LABEL)` then `derive(path)` cannot work because step 2 needs
`hmac_key` as the HMAC **key**, not `privkey` — and a privkey-keyed-only
primitive always uses `privkey` as the key. Single-step avoids this entirely.
3. **Migration cost is acceptable.** The browser already has a migration pattern
for legacy d tags (detect on load, re-publish with new d tag, kind-5 delete
old). The same pattern handles the two-step → single-step transition.
### Migration of existing two-step bookmark events
Existing bookmark events on relays have `d = HMAC-SHA256(HMAC-SHA256(privkey, LABEL), path)`.
After the switch, the browser will compute `d = HMAC-SHA256(privkey, LABEL + ":" + path)`,
which is a different hash. On next `bookmarks_init`:
1. Fetch all kind 30003 events as today.
2. For each event, decrypt content, read `path`.
3. Compute the **new** single-step d tag for that path.
4. If the event's `d` tag does **not** match the new d tag (i.e. it's an old
two-step tag), re-publish the event with the new d tag and emit a kind-5
deletion for the old event id.
5. This is the same logic the browser already uses for legacy plaintext d tags
(see [`plans/bookmarks-tree-view.md`](../sovereign_browser/plans/bookmarks-tree-view.md:61)
§"Why this is safe and nostr-ish" — "Legacy events with plaintext `d = "General"`
will be detected on load... decrypted, and re-published in the new HMAC-`d`
format; the old events get a kind 5 deletion.").
The migration is **self-healing**: each device migrates the events it sees, and
once all devices have upgraded, no old two-step d tags remain on relays.
## n_signer changes
### 1. New verb constant
[`src/enforcement.c`](src/enforcement.c:209) (and the headerless decls block in
every other src file that carries it):
```c
#define VERB_DERIVE "derive"
```
Add it to the algorithm-based verb set in
[`is_algorithm_verb()`](src/dispatcher.c:817):
```c
return (strcmp(method, VERB_SIGN) == 0 ||
strcmp(method, VERB_VERIFY) == 0 ||
strcmp(method, VERB_ENCAPSULATE) == 0 ||
strcmp(method, VERB_DECAPSULATE) == 0 ||
strcmp(method, VERB_DERIVE_SHARED) == 0 ||
strcmp(method, VERB_GET_PUBLIC_KEY) == 0 ||
strcmp(method, VERB_DERIVE) == 0);
```
### 2. Enforcement
[`enforce_verb_algorithm()`](src/enforcement.c:765): `derive` is valid for
`secp256k1` only (the privkey is a 32-byte scalar suitable as an HMAC key; PQ
private keys are not). Add:
```c
/* derive: HMAC-SHA256(privkey, data). secp256k1 only (32-byte scalar key). */
if (strcmp(verb, VERB_DERIVE) == 0) {
if (alg == CRYPTO_ALG_SECP256K1) {
return ENFORCE_OK;
}
return ENFORCE_ERR_ALGORITHM;
}
```
### 3. Dispatcher handler
[`handle_algorithm_verb()`](src/dispatcher.c:880): add a new branch after the
existing `derive_shared_secret` branch. Request shape:
```json
{"id":"...","method":"derive","params":["<data>",{"algorithm":"secp256k1","index":0}]}
```
- `params[0]` = the data string (UTF-8).
- `options.algorithm` = `"secp256k1"` (required).
- `options.index` = derivation index (**required** — no default). The dispatcher
returns error `-32602 missing_index` if `index` is absent.
Handler:
```c
/* ---- derive (secp256k1 HMAC-SHA256) ---- */
if (strcmp(method, VERB_DERIVE) == 0) {
cJSON *data_item = cJSON_GetArrayItem(params_item, 0);
cJSON *index_item = NULL;
const char *data_str;
const unsigned char *priv;
unsigned char mac[32];
char mac_hex[65];
char *result;
if (!cJSON_IsString(data_item) || data_item->valuestring == NULL) {
return make_error_response(id_str, -32602, "invalid_params");
}
data_str = data_item->valuestring;
/* index is required for derive (no default). */
if (!cJSON_IsObject(options_item)) {
return make_error_response(id_str, -32602, "missing_index");
}
index_item = cJSON_GetObjectItemCaseSensitive(options_item, "index");
if (!cJSON_IsNumber(index_item)) {
return make_error_response(id_str, -32602, "missing_index");
}
index = index_item->valueint;
/* Derive the secp256k1 key on demand. */
if (alg_key_cache_derive(ctx->alg_key_cache, ctx->mnemonic, alg, index) != 0) {
return make_error_response(id_str, -32602, "key_derivation_failed");
}
key_entry = alg_key_cache_get(ctx->alg_key_cache, alg, index);
if (key_entry == NULL || !key_entry->valid) {
return make_error_response(id_str, -32602, "key_derivation_failed");
}
priv = (const unsigned char *)key_entry->private_key.data;
/* HMAC-SHA256(privkey, data) */
if (nostr_hmac_sha256(priv, sz->priv_key_len,
(const unsigned char *)data_str, strlen(data_str),
mac) != 0) {
return make_error_response(id_str, -32602, "hmac_failed");
}
nostr_bytes_to_hex(mac, 32, mac_hex);
secure_memzero(mac, sizeof(mac));
result = build_alg_result_json(alg_name, key_entry->key_id,
"digest", mac_hex);
if (result == NULL) {
return make_error_response(id_str, -32602, "invalid_params");
}
return make_success_response(id_str, result);
}
```
`nostr_hmac_sha256` is already available via `<nostr_core/utils.h>` (linked into
n_signer through nostr_core_lib). `secure_memzero` clears the stack-local mac.
Response:
```json
{"id":"...","result":"{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}"}
```
### 4. Headerless decls
The `VERB_DERIVE` define must be added to the `NSIGNER_HEADERLESS_DECLS_BEGIN`
block in every file that carries it. The same block already exists in:
- [`src/dispatcher.c`](src/dispatcher.c:209)
- [`src/enforcement.c`](src/enforcement.c:210)
- [`src/key_store.c`](src/key_store.c:208)
- [`src/main.c`](src/main.c:215)
- [`src/policy.c`](src/policy.c:209)
- [`src/selector.c`](src/selector.c:209)
- [`src/server.c`](src/server.c:215)
- [`src/role_table.c`](src/role_table.c:212)
- [`src/mnemonic.c`](src/mnemonic.c:209)
- [`src/secure_mem.c`](src/secure_mem.c:211)
- [`src/socket_name.c`](src/socket_name.c:211)
- [`src/pq_crypto.c`](src/pq_crypto.c:222)
- [`tests/test_*.c`](tests/) (each test file that carries the block)
### 5. Tests
Add a test in [`tests/test_algorithm_api.c`](tests/test_algorithm_api.c) (or a
new `tests/test_derive.c`):
1. Load a known mnemonic, derive secp256k1 key at index 0.
2. Call `derive` with `data = "test-data"`.
3. Independently compute `HMAC-SHA256(privkey, "test-data")` using
`nostr_hmac_sha256` and compare to the RPC result.
4. Assert determinism: same input → same digest.
5. Assert opaqueness: different inputs → different digests.
6. Assert rejection for non-secp256k1 algorithms (e.g. `algorithm: "ed25519"`
→ error 1010 `algorithm_not_supported_for_verb`).
7. Assert rejection when mnemonic is not loaded (error 1006).
### 6. Documentation
- [`README.md`](README.md) §4.3 verb table: add `derive` row.
- [`README.md`](README.md) §4.4 algorithms: note `derive` is secp256k1-only.
- [`api.md`](api.md): add worked example.
- [`client/README.md`](client/README.md): add `derive` to the verb table.
- [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md):
add `derive` to the algorithm-based verb list with example request/response.
## nostr_core_lib client changes
[`nostr_core_lib/nostr_core/nostr_signer.h`](../sovereign_browser/nostr_core_lib/nostr_core/nostr_signer.h)
and
[`nostr_core_lib/nostr_core/nostr_signer.c`](../sovereign_browser/nostr_core_lib/nostr_core/nostr_signer.c):
Add a new high-level API:
```c
/* Compute HMAC-SHA256(privkey, data) using the signer's derived secp256k1
* private key. Returns the 32-byte digest as 64 lowercase hex chars + NUL.
* For the local backend, computes directly. For the nsigner remote backend,
* calls the "derive" verb.
* data must be a NUL-terminated UTF-8 string.
* Returns NOSTR_SUCCESS or an error code. */
int nostr_signer_derive_hmac(nostr_signer_t* signer,
const char* data,
char out_digest_hex[65]);
```
### Local backend
```c
static int signer_local_derive_hmac(nostr_signer_t* signer,
const char* data,
char out_digest_hex[65]) {
unsigned char mac[32];
if (!signer || !data || !out_digest_hex) {
return NOSTR_ERROR_INVALID_INPUT;
}
if (nostr_hmac_sha256(signer->u.local.private_key, 32,
(const unsigned char*)data, strlen(data),
mac) != 0) {
return NOSTR_ERROR_CRYPTO_FAILED;
}
nostr_bytes_to_hex(mac, 32, out_digest_hex);
memset(mac, 0, sizeof(mac));
return NOSTR_SUCCESS;
}
```
### Remote (nsigner) backend
```c
static int signer_remote_derive_hmac(nostr_signer_t* signer,
const char* data,
char out_digest_hex[65]) {
cJSON* params;
cJSON* result = NULL;
const char* result_str;
int rc;
params = cJSON_CreateArray();
if (params == NULL) return NOSTR_ERROR_MEMORY_FAILED;
cJSON_AddItemToArray(params, cJSON_CreateString(data));
params = signer_remote_params_with_selector(params,
signer->u.remote.role,
signer->u.remote.has_nostr_index,
signer->u.remote.nostr_index);
if (params == NULL) return NOSTR_ERROR_MEMORY_FAILED;
/* The remote derive verb needs algorithm:"secp256k1" in the options. */
{
cJSON* opts = cJSON_GetArrayItem(params, cJSON_GetArraySize(params) - 1);
if (opts != NULL && cJSON_IsObject(opts)) {
cJSON_AddStringToObject(opts, "algorithm", "secp256k1");
}
}
rc = nsigner_client_call(signer->u.remote.client, "derive", params, &result);
if (rc != NOSTR_SUCCESS) return rc;
/* result is a JSON string: {"algorithm":"secp256k1","key_id":"...","digest":"<64hex>"} */
if (!cJSON_IsString(result) || result->valuestring == NULL) {
cJSON_Delete(result);
return NOSTR_ERROR_NIP46_INVALID_RESPONSE;
}
{
cJSON* parsed = cJSON_Parse(result->valuestring);
cJSON* digest_item;
const char* digest_str;
cJSON_Delete(result);
if (parsed == NULL) return NOSTR_ERROR_NIP46_INVALID_RESPONSE;
digest_item = cJSON_GetObjectItemCaseSensitive(parsed, "digest");
if (!cJSON_IsString(digest_item) || digest_item->valuestring == NULL ||
strlen(digest_item->valuestring) != 64) {
cJSON_Delete(parsed);
return NOSTR_ERROR_NIP46_INVALID_RESPONSE;
}
digest_str = digest_item->valuestring;
memcpy(out_digest_hex, digest_str, 64);
out_digest_hex[64] = '\0';
cJSON_Delete(parsed);
}
return NOSTR_SUCCESS;
}
```
**Note on the options object:** the existing
`signer_remote_params_with_selector` appends a selector object as the last
params element. For algorithm-based verbs, the selector object must also carry
`algorithm` and `index`. The implementation must ensure the options object
already exists (or create one) before adding `algorithm`. If
`has_nostr_index` is false and `role` is empty, `signer_remote_params_with_selector`
does not append an options object — in that case the derive handler must append
one with just `algorithm`. This is a small extension to the helper or a local
construction in `signer_remote_derive_hmac`.
### Test
[`nostr_core_lib/tests/nsigner_client_test.c`](../sovereign_browser/nostr_core_lib/tests/nsigner_client_test.c):
add a test that calls `nostr_signer_derive_hmac` on a local signer with a known
privkey + known data, and compares against a reference HMAC-SHA256 computed
with `nostr_hmac_sha256` directly.
## sovereign_browser changes
### 1. Switch to single-step d tag
[`src/bookmarks.c`](../sovereign_browser/src/bookmarks.c:194): replace
`compute_hmac_key` + `path_to_d_tag` with a single function that calls the
signer:
```c
/* New label prefix — domain-separated, baked into the data string. */
#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1"
static char *path_to_d_tag(const char *path) {
if (path == NULL) path = "";
/* Build "LABEL:" + path */
char *data = g_strdup_printf("%s:%s", BOOKMARKS_HMAC_KEY_LABEL, path);
char digest_hex[65];
int rc;
rc = nostr_signer_derive_hmac(g_signer, data, digest_hex);
g_free(data);
if (rc != NOSTR_SUCCESS) {
/* No signer or derive failed — fall back to plaintext (read-only mode). */
return g_strdup(path);
}
return g_strdup(digest_hex);
}
```
Remove `g_hmac_key`, `g_have_hmac_key`, `compute_hmac_key`, and the
`g_privkey` / `g_have_privkey` state (the privkey is no longer needed in
browser memory — the signer holds it). This is a **security improvement**: the
browser no longer carries the privkey in its own RAM when using the nsigner
backend.
### 2. Migration of existing two-step d tags
In `bookmarks_init` (or the relay-fetch load path), after decrypting an event
and reading its `path`:
1. Compute the new single-step d tag: `path_to_d_tag(path)`.
2. Compare to the event's actual `d` tag.
3. If they differ (old two-step tag), re-publish the event with the new d tag
and emit a kind-5 deletion for the old event id.
This reuses the existing legacy-plaintext-d-tag migration logic. The detection
predicate changes from "d tag is not 64 hex chars" to "d tag is 64 hex chars
but does not match `path_to_d_tag(path)`".
### 3. Remove privkey plumbing
- [`src/main.c`](../sovereign_browser/src/main.c:69): remove `privkey_hex` from
`g_state` (or stop populating it).
- [`src/main.c`](../sovereign_browser/src/main.c:652): remove the
`strncpy(g_state.privkey_hex, ...)` line.
- [`src/bookmarks.h`](../sovereign_browser/src/bookmarks.h): change
`bookmarks_init` signature to drop the `privkey_hex` parameter.
- All callers of `bookmarks_init` updated.
### 4. Test
[`tests/test_bookmarks_tree.c`](../sovereign_browser/tests/test_bookmarks_tree.c):
update `path_to_d_tag` mirror to the single-step scheme:
```c
static char *path_to_d_tag(const unsigned char *privkey, const char *path) {
char *data = g_strdup_printf("%s:%s", LABEL, path ? path : "");
unsigned char mac[32];
char *hex;
if (nostr_hmac_sha256(privkey, 32,
(const unsigned char*)data, strlen(data),
mac) != 0) {
g_free(data);
return NULL;
}
g_free(data);
hex = g_strdup_printf(/* 64 hex chars */ ...);
return hex;
}
```
All existing tests (determinism, opaqueness, 64-hex format, is_hmac_d_tag,
per-user-ness, empty path) pass unchanged — they test properties, not the
specific construction.
## Architecture diagram
```mermaid
sequenceDiagram
participant Browser as sovereign_browser
participant Signer as n_signer
participant Relay as Nostr relay
Note over Browser: User saves bookmark to "Work/Projects/Secret"
Browser->>Signer: derive("sovereign-browser/bookmarks-folder-id-v1:Work/Projects/Secret", {algorithm:"secp256k1", index:0})
Signer->>Signer: alg_key_cache_derive(secp256k1, 0)
Signer->>Signer: HMAC-SHA256(privkey, data)
Signer-->>Browser: {"digest":"<64hex>"}
Browser->>Signer: nostr_nip44_encrypt(self_pubkey, JSON{path, bookmarks})
Signer-->>Browser: ciphertext
Browser->>Relay: publish kind 30003, d=<64hex>, content=<ciphertext>
Note over Browser: Other device starts up
Browser->>Relay: fetch kind 30003 by pubkey
Relay-->>Browser: events
Browser->>Signer: nostr_nip44_decrypt(self_pubkey, content)
Signer-->>Browser: JSON{path:"Work/Projects/Secret", bookmarks:[...]}
Note over Browser: path recovered from decrypted content, not from d tag
```
## File change summary
### n_signer (this repo)
| File | Change |
|------|--------|
| [`src/enforcement.c`](src/enforcement.c) | Add `VERB_DERIVE` define + `derive` case in `enforce_verb_algorithm` (secp256k1 only) |
| [`src/dispatcher.c`](src/dispatcher.c) | Add `VERB_DERIVE` to `is_algorithm_verb` + `derive` branch in `handle_algorithm_verb` |
| [`src/key_store.c`](src/key_store.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/main.c`](src/main.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/policy.c`](src/policy.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/selector.c`](src/selector.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/server.c`](src/server.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/role_table.c`](src/role_table.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/mnemonic.c`](src/mnemonic.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/secure_mem.c`](src/secure_mem.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/socket_name.c`](src/socket_name.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`src/pq_crypto.c`](src/pq_crypto.c) | Add `VERB_DERIVE` define (headerless decls) |
| [`tests/test_algorithm_api.c`](tests/test_algorithm_api.c) | Add `derive` test cases |
| [`README.md`](README.md) | Add `derive` to verb table + algorithm notes |
| [`api.md`](api.md) | Add `derive` worked example |
| [`client/README.md`](client/README.md) | Add `derive` to verb table |
| [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md) | Add `derive` section |
### nostr_core_lib (sovereign_browser repo)
| File | Change |
|------|--------|
| [`nostr_core/nostr_signer.h`](../sovereign_browser/nostr_core_lib/nostr_core/nostr_signer.h) | Add `nostr_signer_derive_hmac` declaration |
| [`nostr_core/nostr_signer.c`](../sovereign_browser/nostr_core_lib/nostr_core/nostr_signer.c) | Add local + remote `derive_hmac` implementations |
| [`tests/nsigner_client_test.c`](../sovereign_browser/nostr_core_lib/tests/nsigner_client_test.c) | Add `derive_hmac` test |
### sovereign_browser
| File | Change |
|------|--------|
| [`src/bookmarks.c`](../sovereign_browser/src/bookmarks.c) | Replace two-step `compute_hmac_key` + `path_to_d_tag` with single-step `path_to_d_tag` calling `nostr_signer_derive_hmac`; add two-step→single-step migration on load; remove `g_privkey`/`g_hmac_key` state |
| [`src/bookmarks.h`](../sovereign_browser/src/bookmarks.h) | Drop `privkey_hex` param from `bookmarks_init` |
| [`src/main.c`](../sovereign_browser/src/main.c) | Remove `privkey_hex` from `g_state` + stop populating it; update `bookmarks_init` call |
| [`tests/test_bookmarks_tree.c`](../sovereign_browser/tests/test_bookmarks_tree.c) | Update `path_to_d_tag` mirror to single-step scheme |
## Open questions
1. **Should `derive` accept hex-encoded binary data, or only UTF-8 strings?**
Default: UTF-8 strings only (simpler, covers the bookmark use case). If a
caller needs binary data, they hex-encode it and we add a `data_encoding`
option later. Decision can be deferred.
2. **`index` is required.** Unlike other algorithm-based verbs that default
`index` to 0, `derive` requires the caller to specify `index` explicitly.
This forces conscious selection of which derived key to use as the HMAC key,
avoiding accidental cross-identity d-tag collisions. The dispatcher returns
`-32602 missing_index` if `index` is absent.
3. **Should `derive` be added to the `--preapprove` algorithm-based policy
syntax?**
Yes — it flows through the same `policy_check_algorithm` path as other
algorithm-based verbs. Operators can pre-approve
`caller=...,algorithm=secp256k1,index=0,verb=derive`. No code change needed
beyond the verb define; policy matching is by string.

274
plans/http_wss_listener.md Normal file
View File

@@ -0,0 +1,274 @@
# Plan: HTTP and WebSocket (WSS) Listener Modes for n_signer
## Goal
Add two new listener modes to n_signer so that standard HTTP clients (curl) and
WebSocket clients (browsers, relay-style tools) can send JSON-RPC requests
directly, without the custom 4-byte length-prefixed framing.
## Current state
n_signer supports these listener modes:
- `unix` — abstract namespace Unix socket, 4-byte framed JSON
- `stdio` — stdin/stdout, 4-byte framed JSON, one request per invocation
- `qrexec` — same as stdio but with `QREXEC_REMOTE_DOMAIN` caller identity
- `tcp:HOST:PORT` — TCP, 4-byte framed JSON
All modes use the same dispatcher (`dispatcher_handle_request`) which takes a
JSON string and returns a JSON string. The only difference between modes is the
transport framing and caller-identity extraction.
## Proposed modes
### HTTP mode: `--listen http:HOST:PORT`
**Protocol:** HTTP/1.1 POST with JSON body → JSON response.
```
POST / HTTP/1.1
Content-Type: application/json
{"id":"1","method":"otp_encrypt","params":["SGVsbG8=",{"encoding":"ascii"}]}
HTTP/1.1 200 OK
Content-Type: application/json
{"id":"1","result":"{...}"}
```
**curl example:**
```bash
curl -s -X POST http://127.0.0.1:11111/ \
-H 'Content-Type: application/json' \
-d '{"id":"1","method":"get_public_key","params":[{"role":"main"}]}'
```
**Implementation:**
- Parse the HTTP request line + headers (minimal parser — just enough for POST)
- Read the Content-Length bytes as the JSON-RPC request
- Call `dispatcher_handle_request()`
- Write the HTTP response with the JSON result
**Difficulty: Low.** The HTTP parser can be minimal (~100 lines). No need for
a full HTTP server — just POST with a JSON body. No chunked encoding, no
keep-alive, no static file serving. One request per connection (connection
close after response), or simple keep-alive loop.
**Caller identity:** `tcp:<peer-addr>` (same as TCP mode). No `QREXEC_REMOTE_DOMAIN`.
**HTTPS variant:** `--listen https:HOST:PORT` wraps the HTTP listener in TLS.
Requires `--tls-cert <path>` and `--tls-key <path>` flags. OpenSSL is already
linked. The HTTP parser stays the same; TLS is layered underneath (accept
connection → TLS handshake → then HTTP). See Phase 3 below.
### WebSocket mode: `--listen wss:HOST:PORT` (or `ws:HOST:PORT`)
**Protocol:** WebSocket with JSON text frames.
```
Client → Server: text frame with JSON-RPC request
Server → Client: text frame with JSON-RPC response
```
**Browser example:**
```javascript
const ws = new WebSocket("ws://127.0.0.1:11111");
ws.onopen = () => {
ws.send(JSON.stringify({id:"1", method:"get_public_key", params:[{role:"main"}]}));
};
ws.onmessage = (e) => {
console.log(JSON.parse(e.data));
};
```
**Implementation:**
- HTTP upgrade handshake (Sec-WebSocket-Key → Sec-WebSocket-Accept)
- WebSocket frame parser (opcode, mask, payload length — 7/16/64 bit)
- Text frames (opcode 0x1) carry JSON-RPC requests
- Response as text frames (opcode 0x1, unmasked from server)
- One JSON-RPC request per text frame; one response per request
**Difficulty: Medium.** The WebSocket handshake is straightforward (SHA-1 +
base64 of the key + magic GUID). The frame parser needs to handle:
- 7-bit, 16-bit, and 64-bit payload lengths
- Client-to-server masking (XOR with 4-byte mask)
- Ping/pong frames (opcode 0x9/0xA)
- Close frame (opcode 0x8)
- Fragmentation (continuation frames, opcode 0x0) — can defer/skip for v1
A minimal implementation is ~200-300 lines. No need for a full RFC 6455
compliance — just enough for curl, browsers, and common WebSocket clients.
**WSS (TLS) variant:** `wss:HOST:PORT` wraps the WebSocket in TLS. This
requires linking against OpenSSL (already a dependency) and adding TLS
handshake + certificate handling. Difficulty: Medium-High due to cert
management. For v1, `ws:` (plaintext) is sufficient for localhost/FIPS-mesh
use; `wss:` can be added later.
**Caller identity:** `ws:<peer-addr>` or `wss:<peer-addr>`.
## Security concerns
### 1. Network exposure
**Unix sockets** are only accessible from the same host/qube — no network
exposure. **HTTP/WS** listeners bind to a TCP port, which is potentially
reachable from other hosts/qubes on the network.
**Mitigation:**
- Default bind address: `127.0.0.1` (localhost only), not `[::]` (all
interfaces). The user must explicitly pass `--listen http:0.0.0.0:11111` to
expose it.
- The existing policy/approval system is the real gate — network access alone
doesn't grant signing. Unknown callers get `PROMPT_EVERY_REQUEST` by default.
### 2. No authentication in HTTP/WS mode
The current TCP mode has no authentication either — it relies on the
policy/approval prompt. HTTP/WS would be the same. Anyone who can reach the
port can send requests, but they still need approval for sensitive operations.
**Mitigation:**
- The `--auth required` mode (auth envelopes) could be extended to HTTP/WS.
The client would include a signed auth envelope in an HTTP header
(e.g. `X-Nsigner-Auth: <envelope>`) or as a WebSocket subprotocol header.
- For v1, rely on localhost binding + policy/approval prompts, same as TCP.
### 3. CORS for browser access
If the WebSocket listener is accessed from a browser running on a different
origin, CORS headers are needed for the HTTP upgrade handshake.
**Mitigation:**
- Add `Access-Control-Allow-Origin: *` to the WebSocket upgrade response
(the policy/approval system is the real security boundary, not CORS).
- Or restrict to same-origin only (no CORS header → browser blocks
cross-origin).
### 4. Request size limits — MUST be raised for OTP blob encryption
The current `SERVER_MAX_MSG_SIZE` is 65536 bytes (64KB). This is too small
for OTP encryption of images or blobs:
- A 48KB image → base64 in JSON param = ~64KB → hits the limit
- A 1MB image → base64 = ~1.33MB → way over
- OTP Padmé padding rounds up to the next power of 2, and the response
contains the base64-encoded ciphertext, which is even larger
**This is a pre-existing limitation that affects ALL transport modes**, not
just HTTP/WS. It should be fixed before or alongside the HTTP/WS work.
**Proposed change:** raise `SERVER_MAX_MSG_SIZE` to 16MB (16777216). This
accommodates:
- Up to ~12MB plaintext (base64 = ~16MB in the JSON param)
- The OTP response (base64-encoded padded ciphertext) for the same
- The `stdin_buf[SERVER_MAX_MSG_SIZE + 1]` stack buffer in `client_main`
should be changed to a heap allocation to avoid a 16MB stack frame
The `transport_recv_framed` function already takes a max-size parameter, so
the change is: update the constant, change the stack buffer to malloc, and
verify the dispatcher doesn't have other hardcoded size assumptions.
**For HTTP/WS:** enforce the same `SERVER_MAX_MSG_SIZE` limit via
`Content-Length` checking in the HTTP parser.
### 5. TLS for WSS
For `wss:` mode, TLS is required. This means:
- Certificate management (self-signed for local/FIPS mesh, or CA-signed for
public-facing).
- The signer would need a `--tls-cert` and `--tls-key` flag.
- Pinning: clients should verify the cert fingerprint, not just trust the CA.
**For v1:** skip WSS/TLS. Provide `ws:` (plaintext) only, suitable for
localhost and FIPS mesh (which has its own network-level isolation). Add
`wss:` later when there's a real public-facing use case.
### 6. Connection flooding
HTTP/WS listeners are susceptible to connection flooding (many open
connections, slowloris-style attacks). The current single-threaded poll loop
handles one connection at a time, which naturally limits flood impact but
also limits throughput.
**Mitigation:**
- Connection timeout (close idle connections after N seconds).
- Max connections limit.
- For v1, the single-threaded model is fine — it's a signer, not a web server.
## Implementation plan
### Phase 0: Raise SERVER_MAX_MSG_SIZE (prerequisite)
- [ ] Change `SERVER_MAX_MSG_SIZE` from 65536 to 16777216 (16MB) in all
`.c` files where it's defined (the headerless-decls pattern means it's
redefined in every file).
- [ ] Change the `stdin_buf[SERVER_MAX_MSG_SIZE + 1]` stack buffer in
`client_main` to a heap allocation (malloc/free) to avoid a 16MB stack
frame.
- [ ] Verify `transport_recv_framed` handles the larger size correctly (it
already takes max-size as a parameter).
- [ ] Test with a large OTP encrypt/decrypt round-trip (e.g. 1MB plaintext).
### Phase 1: HTTP listener mode
- [ ] Add `NSIGNER_LISTEN_HTTP` to the listen mode enum.
- [ ] Parse `--listen http:HOST:PORT` in the CLI flag parser.
- [ ] Implement a minimal HTTP/1.1 parser in `src/http_listener.c`:
- Read request line (method, path, HTTP version)
- Read headers (only need Content-Length)
- Read body (Content-Length bytes)
- Reject non-POST methods with 405
- Reject oversized bodies with 413
- [ ] Call `dispatcher_handle_request()` with the body, write JSON response
with `200 OK` + `Content-Type: application/json`.
- [ ] Caller identity: `http:<peer-ip>:<peer-port>`.
- [ ] Default bind: `127.0.0.1` (not `[::]`).
- [ ] Add to interactive transport menu as option 4.
- [ ] Test with curl.
### Phase 2: WebSocket listener mode
- [ ] Add `NSIGNER_LISTEN_WS` to the listen mode enum.
- [ ] Parse `--listen ws:HOST:PORT` in the CLI flag parser.
- [ ] Implement WebSocket handshake + frame parser in `src/ws_listener.c`:
- HTTP upgrade handshake (Sec-WebSocket-Key → Accept)
- Frame parser (opcode, mask, payload length)
- Text frames → JSON-RPC request → dispatcher → text frame response
- Ping/pong handling
- Close frame handling
- [ ] Caller identity: `ws:<peer-ip>:<peer-port>`.
- [ ] Default bind: `127.0.0.1`.
- [ ] Add to interactive transport menu as option 5.
- [ ] Test with a browser or `websocat`.
### Phase 3: TLS mode (HTTPS + WSS)
- [ ] Add `--tls-cert <path>` and `--tls-key <path>` CLI flags.
- [ ] Add `--listen https:HOST:PORT` — HTTP listener wrapped in TLS.
- [ ] Add `--listen wss:HOST:PORT` — WebSocket listener wrapped in TLS.
- [ ] TLS handshake via OpenSSL (already a dependency):
- Load cert/key from files
- `SSL_CTX_new(TLS_server_method())`
- `SSL_CTX_use_certificate_file()` / `SSL_CTX_use_PrivateKey_file()`
- Per-connection: `SSL_new()``SSL_set_fd()``SSL_accept()`
- Replace read/write calls with `SSL_read()` / `SSL_write()`
- [ ] Self-signed cert generation helper (or document `openssl req` command).
- [ ] Certificate pinning guidance for clients (verify fingerprint, not just CA).
- [ ] curl usage: `curl --cacert <cert.pem>` or `curl -k` (insecure, for testing).
- [ ] Not strictly needed for localhost/FIPS mesh use, but good for defense in depth.
## Difficulty assessment
| Mode | Difficulty | New code | Dependencies | Risk |
|---|---|---|---|---|
| HTTP | Low | ~150 lines | None (minimal parser) | Low — simple POST/JSON |
| WS | Medium | ~300 lines | None (minimal frame parser) | Medium — frame parsing edge cases |
| HTTPS | Medium | ~150 + ~80 TLS | OpenSSL (already linked) | Medium — cert management |
| WSS | Medium-High | ~300 + ~80 TLS | OpenSSL (already linked) | Higher — cert + WS edge cases |
## Recommendation
Start with HTTP mode (Phase 1) — it's the simplest and immediately enables
curl access. Add WebSocket (Phase 2) if browser access is needed. Defer WSS
(Phase 3) until there's a real public-facing use case.

View File

@@ -44,7 +44,7 @@ Each selected transport gets configured with sensible defaults:
|---|---|---|
| 1. Unix socket | `--listen unix` | socket name `nsigner` (or random if collision) |
| 2. Qrexec bridge | `--listen unix --bridge-source-trusted` | socket name `nsigner`, accepts qrexec preamble |
| 3. TCP | `--listen tcp:[::]:8080` | bind all interfaces, port 8080 |
| 3. TCP | `--listen tcp:[::]:11111` | bind all interfaces, port 11111 |
| 4. Qrexec one-shot | `--listen qrexec` | one request via stdin, then exit |
**Choices 1 and 2 can coexist** — they're both unix listeners, just with different identity handling. Choice 2 implies choice 1's socket. If both are selected, the bridge-source-trusted flag is set on the single unix listener (it handles both local and bridge connections).
@@ -66,7 +66,7 @@ Currently `main()` creates one `server_ctx_t` and polls `server.listen_fd` + `ST
```text
Connections
listen: unix @nsigner (bridge-source-trusted)
listen: tcp [::]:8080
listen: tcp [::]:11111
client: nsigner --socket-name nsigner client '<json>'
```
@@ -116,7 +116,7 @@ After confirmation, the selected listeners are started and the status display re
### 3.3 Transport setup from menu selection
- [ ] Unix: `server_init` + `server_start` with `NSIGNER_LISTEN_UNIX`, socket name `nsigner`.
- [ ] Qrexec bridge: same as unix + `server_set_bridge_source_trusted(&server, 1)`.
- [ ] TCP: `server_init` + `server_start` with `NSIGNER_LISTEN_TCP`, target `tcp:[::]:8080`.
- [ ] TCP: `server_init` + `server_start` with `NSIGNER_LISTEN_TCP`, target `tcp:[::]:11111`.
- [ ] Qrexec one-shot: existing `NSIGNER_LISTEN_QREXEC` path (stdin, one request, exit).
### 3.4 TUI status display
@@ -126,7 +126,7 @@ After confirmation, the selected listeners are started and the status display re
### 3.5 Testing
- [ ] Interactive: start nsigner with no `--listen`, select unix+TCP, verify both listeners work.
- [ ] Interactive: select qrexec bridge, verify bridge connections get `qubes:<vm>` identity.
- [ ] CLI: `--listen tcp:[::]:8080` still works (skips menu).
- [ ] CLI: `--listen tcp:[::]:11111` still works (skips menu).
- [ ] CLI: `--listen unix --bridge-source-trusted` still works (skips menu).
- [ ] Non-TTY: `--mnemonic-stdin` without `--listen` defaults to unix (no menu).
@@ -138,6 +138,6 @@ After confirmation, the selected listeners are started and the status display re
2. **Should there be a hotkey to add/remove transports at runtime?** E.g. press `t` in the TUI to bring up the transport menu again. This is a nice-to-have but adds complexity. **Decision: defer — the menu is startup-only for now.**
3. **TCP port selection in the menu?** The menu could ask for a port number if TCP is selected. **Decision: default to 8080, let the user override with `--listen tcp:...` if they need a different port. Keep the menu simple.**
3. **TCP port selection in the menu?** The menu could ask for a port number if TCP is selected. **Decision: default to 11111, let the user override with `--listen tcp:...` if they need a different port. Keep the menu simple.**
4. **Socket name selection?** Same — default to `nsigner`, override with `--socket-name` if needed. **Decision: default, no prompt.**

View File

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

View File

@@ -0,0 +1,340 @@
# Plan: OTP-encrypted Nostr events via n_signer
## Goal
Store encrypted blobs on Nostr (kind `30078` replaceable parameterized events) that are
**information-theoretically secure** — unbreakable by any computer, quantum or classical,
forever — because they are encrypted with a one-time pad (OTP) sourced from the
[`otp`](../otp) project.
A client program sends plaintext (or a ciphertext) to `n_signer` over its existing
JSON-RPC transport. `n_signer` performs the OTP XOR against pad material it reads from a
USB drive, advances the per-pad offset, and returns the ciphertext (or plaintext). The
caller then wraps the result in a Nostr `30078` event and signs/publishes it via the
existing `sign_event` verb.
Both output encodings are supported, matching the standalone `otp` tool:
- **ASCII armored** (`-----BEGIN OTP MESSAGE-----` + base64): text-safe, for embedding
directly in Nostr event `content` (kind `30078`).
- **Binary** (`.otp` structured header + raw encrypted bytes): for uploading to Blossom
servers as a blob and referencing from a Nostr event by SHA-256 hash. The caller
receives the binary blob base64-encoded in the JSON-RPC response and decodes it
before uploading.
For testing, pad material can be generated from local entropy (`/dev/urandom` or
keyboard entropy) using the `otp` tool. The eventual production target is a USB drive
holding the pad, accessed by `n_signer` at runtime. A future microcontroller hardware
signer that carries the pad onboard is explicitly out of scope for this plan and is
tracked separately.
## Design summary
- `n_signer` gains two new verbs: `otp_encrypt` and `otp_decrypt`.
- Pad material lives on a USB drive (file path supplied at startup). `n_signer` reads
only the slice it needs, XORs in `mlock`'d RAM, and writes the new offset back to the
pad's `.state` file on the USB drive.
- The pad is **not** loaded whole into RAM; it is seeked-and-read per request. This
preserves the spirit of the zero-filesystem-footprint model (no pad material is ever
copied onto the host disk; the only on-disk artifact is the offset counter on the USB
drive itself, which is required for multi-device coordination).
- The encrypted payload is returned in either ASCII-armored or binary `.otp` format,
selected per request via an `encoding` option. ASCII armor is base64-safe for JSON
and Nostr event `content`; binary `.otp` is for Blossom blob uploads.
- The caller is responsible for building and publishing the `30078` event; `n_signer`
only does the OTP transform and (separately) signs the event when asked.
```mermaid
flowchart LR
Client[Client program] -->|otp_encrypt JSON-RPC| NS[n_signer]
NS -->|seek + read slice| USB[USB pad file .pad]
NS -->|read/advance offset| State[USB .state file]
NS -->|XOR in mlock RAM| CT[Ciphertext blob]
NS -->|return ascii-armored| Client
Client -->|wrap in 30078 event| Event[Nostr event JSON]
Client -->|sign_event| NS
NS -->|schnorr sig| Client
Client -->|publish| Relay[Nostr relay]
```
## Architecture decisions
### 1. New verbs, not a new transport
OTP operations are just new verbs on the existing dispatcher
([`src/dispatcher.c`](src/dispatcher.c:1)). They use the same framed JSON-RPC,
policy, approval, and enforcement machinery as `sign_event` / `nip44_encrypt`. No new
transport is needed.
### 2. Pad storage on USB, not in mnemonic RAM
The OTP pad is far too large to live in `mlock`'d RAM (gigabytes) and is not
mnemonic-derived. It lives on a USB drive mounted at a path `n_signer` is told at
startup via a new `--otp-pad-dir <path>` flag. `n_signer` opens the pad file
read-only, seeks to the current offset, reads exactly `chunk_size` bytes (after
Padmé padding), XORs against the (padded) plaintext in a small `mlock`'d scratch
buffer, and writes the advanced offset back to `<chksum>.state` on the USB drive.
This is a deliberate, narrow exception to the "zero filesystem footprint" rule: the
only filesystem artifact `n_signer` touches is the offset counter on the USB drive
itself, which is mandatory for pad-reuse avoidance across devices. No pad bytes and
no plaintext ever touch the host disk.
### 2a. Qubes OS USB access strategy
On Qubes, the signer qube must have sole access to the pad-bearing USB drive. The
chosen strategy is **PCI USB controller passthrough** (Option A): an entire USB
controller is assigned to the signer qube via `qvm-pci attach`, so dom0, `sys-usb`,
and every other qube are blind to the pad device. The drive appears as a normal
`/dev/sd*` inside the signer qube.
Fallback if no spare controller is available: **`qvm-block attach` from `sys-usb`**
(Option B), accepting that `sys-usb` briefly enumerates the device and block I/O
transits dom0's blkback (a traffic-analysis concern, not a plaintext-leak concern
since pad bytes stay encrypted-on-disk).
Code-level guard (Option D): `n_signer` refuses to open `--otp-pad-dir` unless the
underlying device is a directly-owned PCI device (`/dev/sd*` from a passthrough
controller) when running under Qubes; blkback devices (`/dev/xvdi`) are rejected
unless `--otp-allow-blkback` is explicitly passed. This makes "sole access" a
code-level invariant, not just an operator convention.
Offset writes use atomic write-temp-then-rename so a crash mid-write cannot corrupt
the `.state` file. The offset is advanced **only after** the XOR succeeds and the
ciphertext is handed back to the caller.
### 3. Reuse the `otp` project's file formats and padding
- Pad file format: `<chksum>.pad` raw random bytes, with a 32-byte header reserved
(matches [`../otp/src/pads.c`](../otp/src/pads.c:1) `offset=32` initial reservation).
- State file format: `<chksum>.state` containing `offset=<n>\n` (matches
[`../otp/src/pads.c:284`](../otp/src/pads.c:284) `read_state_offset`).
- ASCII armor format: `-----BEGIN OTP MESSAGE-----` with `Pad-ChkSum` and
`Pad-Offset` headers (matches [`../otp/src/crypto.c`](../otp/src/crypto.c:1)
`parse_ascii_message` / `generate_ascii_armor`).
- Padding: exponential bucketing + ISO/IEC 9797-1 Method 2 (Padmé) from
[`../otp/src/padding.c`](../otp/src/padding.c:1).
This means pads generated by the standalone `otp` CLI are bit-compatible with pads
consumed by `n_signer`, and ciphertexts produced by either tool are interchangeable.
### 4. Code sharing strategy
Rather than vendoring a copy of the `otp` source into `n_signer`, extract the
format-critical functions into a small shared static library `libotppad` that both
projects link against. Candidates to extract:
- `universal_xor_operation` ([`../otp/src/crypto.c:35`](../otp/src/crypto.c:35))
- `parse_ascii_message` / `generate_ascii_armor`
- `calculate_chunk_size` / `apply_padme_padding` / `remove_padme_padding`
([`../otp/src/padding.c`](../otp/src/padding.c:1))
- `read_state_offset` / `write_state_offset`
([`../otp/src/pads.c:284`](../otp/src/pads.c:284))
- `calculate_checksum` (pad identification)
`n_signer` then only needs to implement: USB pad directory config, the two new
dispatcher verbs, the seek-read-XOR-write-offset loop, and approval/policy wiring.
### 5. Nostr event shape (kind 30078)
The caller builds the event; `n_signer` does not. Two payload patterns:
**A. ASCII armor in event content** (text-safe, self-contained):
```json
{
"kind": 30078,
"content": "-----BEGIN OTP MESSAGE-----\nVersion: v0.3.53\nPad-ChkSum: <64hex>\nPad-Offset: <n>\n\n<base64>\n-----END OTP MESSAGE-----",
"tags": [
["d", "<caller-chosen-d-tag>"],
["otp-pad", "<16-char chksum prefix>"],
["otp-version", "v0.3.53"],
["otp-encoding", "ascii"]
],
...
}
```
**B. Binary `.otp` uploaded to Blossom, referenced by hash** (for binaries/large blobs):
```json
{
"kind": 30078,
"content": "",
"tags": [
["d", "<caller-chosen-d-tag>"],
["otp-pad", "<16-char chksum prefix>"],
["otp-version", "v0.3.53"],
["otp-encoding", "binary"],
["blob", "<sha256-hex>", "<mimetype>", "<size-bytes>"],
["url", "<blossom-url>"]
],
...
}
```
In both cases the `Pad-Offset` (in the ASCII armor header, or in the binary `.otp`
file header) is what a decrypting device uses to seek into its copy of the same pad.
The `otp-pad` tag lets a reader find the right pad without parsing the payload first.
The `otp-encoding` tag tells the reader whether to look in `content` or follow the
`blob`/`url` tags to Blossom.
### 6. Multi-device offset coordination (deferred)
Out of scope for v1. The `.state` file on the device's USB drive is the local source
of truth for how far that device has consumed the pad, and the `Pad-Offset` header
in each ciphertext's ASCII armor / binary header records which slice was used. That
is sufficient for single-device operation.
If multi-device pad sharing is added later (two devices holding copies of the same
`.pad`), a dedicated signed coordination event would be needed so devices never
reuse a slice. That event does not have to be kind `30078` and is not designed here.
Note: kind `30078` is a Nostr application-data convention, not part of the OTP spec —
the `otp` project has no Nostr code today.
## Phased implementation
### Phase 0 — Test pad on the USB drive ✅ Done
- [x] Created `pads/` directory on the mounted USB drive.
- [x] Generated a 1 MB test pad from `/dev/urandom` with a 32-byte reserved header,
using [`tools/make_test_pad.c`](tools/make_test_pad.c:1).
- [x] Computed the pad's 256-bit XOR checksum (matching the `otp` project's
[`../otp/src/crypto.c:242`](../otp/src/crypto.c:242) `calculate_checksum`
algorithm) and named the pad file by that checksum.
- [x] Wrote the initial `.state` file (`offset=32\n`).
- [x] Verified the checksum matches the filename via an independent re-computation.
**Actual test pad on this qube:**
| Field | Value |
|---|---|
| USB drive | SanDisk 3.2 Gen1, 466 GB, `/dev/sda1` |
| Mount point | `/media/user/Music` (FAT32, label "Music") |
| Pad directory | `/media/user/Music/pads` |
| Pad file | `333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.pad` |
| State file | `333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.state` |
| Size | 1,048,576 bytes (1 MB) |
| Chksum prefix | `333e9902db839d9d` |
| Initial offset | 32 (header reserved) |
Note: the drive is currently mounted at `/media/user/Music` (its FAT32 label is
"Music"), not at `/media/user/USBDISK`. For `n_signer` testing, pass
`--otp-pad-dir /media/user/Music/pads`. This pad is for local-entropy testing only;
production pads will be generated with the `otp` CLI (keyboard/TRNG entropy) or a
future hardware signer.
### Phase 1 — Shared `libotppad` extraction
- [ ] Create `libotppad/` directory with the format-critical functions extracted from
[`../otp/src/crypto.c`](../otp/src/crypto.c:1), [`../otp/src/padding.c`](../otp/src/padding.c:1),
and [`../otp/src/pads.c`](../otp/src/pads.c:1).
- [ ] Add a `libotppad.h` public header declaring: `universal_xor_operation`,
`parse_ascii_message`, `generate_ascii_armor`, `calculate_chunk_size`,
`apply_padme_padding`, `remove_padme_padding`, `read_state_offset`,
`write_state_offset`, `calculate_checksum`.
- [ ] Refactor the `otp` project to link against `libotppad` instead of its own copies.
- [ ] Add unit tests for `libotppad` (round-trip encrypt/decrypt, padding edge cases,
state file read/write).
### Phase 2 — `n_signer` USB pad directory support
- [ ] Add `--otp-pad-dir <path>` CLI flag to [`src/main.c`](src/main.c:1); store the
path in a new `otp_pad_state_t` alongside the existing mnemonic/role state.
- [ ] Add a `--otp-pad <chksum-or-prefix>` flag (or interactive selector) to pick
**the single pad** that is active for this session. One pad per session — the
pad is bound at startup and cannot be switched without restarting `n_signer`.
- [ ] Implement `otp_pad_open(chksum)` / `otp_pad_read_slice(offset, len)` /
`otp_pad_advance_offset(delta)` helpers in a new `src/otp_pad.c`.
- [ ] Validate the pad's checksum matches the requested chksum before first use.
- [ ] Refuse to operate if the pad directory is on the same filesystem as `/` (require
it to be a removable mount — best-effort check via `statvfs`).
### Phase 3 — `otp_encrypt` / `otp_decrypt` verbs
- [ ] Add `VERB_OTP_ENCRYPT "otp_encrypt"` and `VERB_OTP_DECRYPT "otp_decrypt"` to
[`src/dispatcher.c`](src/dispatcher.c:1).
- [ ] Request shapes (plaintext is base64 in the JSON param for both text and
binary; the pad is the one bound at startup, so no `pad` field is needed):
```json
{ "id": "1", "method": "otp_encrypt",
"params": [ "<plaintext-base64>", { "encoding": "ascii|binary" } ] }
```
```json
{ "id": "2", "method": "otp_decrypt",
"params": [ "<ciphertext-ascii-armor-or-base64-otp-blob>", { "encoding": "ascii|binary" } ] }
```
- [ ] `otp_encrypt` flow: padmé-pad plaintext → seek to offset → read slice → XOR in
`mlock`'d scratch → advance offset → return ciphertext in requested encoding
(`ascii` → ASCII-armored string, `binary` → base64-encoded `.otp` blob in the
JSON result).
- [ ] `otp_decrypt` flow: accept either ASCII armor or base64-encoded binary `.otp`
blob → parse header → seek to `Pad-Offset` → read slice → XOR in `mlock`'d
scratch → strip padding → return plaintext (in requested encoding).
- [ ] Add an `encoding` option to both verbs: `"encoding": "ascii"` (default) or
`"encoding": "binary"`. For `otp_encrypt`, controls output format. For
`otp_decrypt`, tells the signer what format the input is in (auto-detection by
magic bytes `OTP\0` is a fallback).
- [ ] Wire both verbs into the policy/enforcement table
([`src/policy.c`](src/policy.c:1), [`src/enforcement.c`](src/enforcement.c:1))
with **per-session grant** approval: the first `otp_encrypt` / `otp_decrypt`
call for the session prompts the user; once granted, subsequent calls on the
same pad in the same session do not re-prompt. This matches the `[a] always
allow this session` hotkey behavior already in [`README.md`](README.md:1).
- [ ] Add approval-prompt display fields: pad chksum prefix, offset before/after,
plaintext length bucket.
### Phase 4 — Local-entropy test path
- [ ] Document the test workflow: generate a small pad with the `otp` CLI
(`./otp generate 1MB`) into a directory, point `n_signer` at it with
`--otp-pad-dir`, run `otp_encrypt` round-trips from a client.
- [ ] Add an integration test in [`tests/test_integration.c`](tests/test_integration.c:1)
that: starts `n_signer` with a temp pad dir, calls `otp_encrypt` then
`otp_decrypt`, asserts round-trip equality, and asserts the offset advanced by
the padded chunk size.
- [ ] Add a test that confirms a ciphertext produced by `n_signer` can be decrypted by
the standalone `otp` CLI (cross-compatibility).
### Phase 5 — Nostr 30078 client example
- [ ] Add `examples/otp_nostr_30078.c` showing: call `otp_encrypt` → build a kind 30078
event with the ASCII armor as `content` → call `sign_event` → print the signed
event for publishing.
- [ ] Add a matching `examples/otp_nostr_30078_decrypt.c` showing: fetch a 30078 event
→ call `otp_decrypt` with its `content` → print recovered plaintext.
- [ ] Document the workflow in [`documents/`](documents/) and link from
[`README.md`](README.md:1).
### Phase 6 (deferred) — USB-bound pad hardening
- [ ] Detect removable-mount requirement more strictly (udev properties).
- [ ] Optional: read-only mount enforcement, pad integrity re-check on each request.
- [ ] Optional: per-pad `mlock`'d offset cache so a crash mid-request does not corrupt
the `.state` file (write-offset-after-success-only is already the plan).
### Phase 7 (explicitly deferred) — Microcontroller hardware signer with onboard pad
Tracked separately. When it lands, the `--otp-pad-dir` path is replaced by a transport
call (serial/WebUSB) to a pad-serving firmware, and the verbs stay identical.
## Decisions
1. **Plaintext encoding in `otp_encrypt` params.** Accept base64 in the JSON param
for both text and binary plaintext; the signer decodes it. Output encoding is the
`ascii` vs `binary` option described above.
2. **One pad per session.** The pad is bound at `n_signer` startup via
`--otp-pad-dir` + `--otp-pad` and cannot be switched without restarting the
signer. Simpler and safer for v1.
3. **No pad-heartbeat event in v1.** The `.state` file on the USB drive is the local
source of truth; the `Pad-Offset` header in each ciphertext records the slice
used. Multi-device pad sharing and any coordination event are deferred. (Kind
`30078` is a Nostr application-data convention, not part of the OTP spec.)
4. **Per-session grant approval.** The first `otp_encrypt` / `otp_decrypt` call in a
session prompts the user; once granted, subsequent calls on the same pad do not
re-prompt. Matches the existing `[a] always allow this session` behavior.
5. **Qubes USB strategy.** PCI USB controller passthrough (Option A) is the target;
`qvm-block` from `sys-usb` (Option B) is the fallback. For development/testing,
the USB drive is already accessible in this qube at `/media/user/Music` (see
Phase 0). Code-level guard (Option D) rejects blkback devices unless
`--otp-allow-blkback` is passed.

263
plans/ssh_agent_proxy.md Normal file
View File

@@ -0,0 +1,263 @@
# Plan: SSH Agent Proxy Bridge for n_signer
## 1. Goal
Allow untrusted Qubes qubes to SSH into remote servers using an ed25519 private key held safely in n_signer, without the private key ever leaving the signer qube.
The untrusted qube runs a small proxy program that implements the OpenSSH `ssh-agent` protocol. OpenSSH talks to the proxy as if it were a normal `ssh-agent`. The proxy forwards signing requests to n_signer via qrexec. The private key never touches the untrusted qube's memory.
## 2. Architecture
```mermaid
graph LR
SSH[ssh client in untrusted qube] -->|SSH_AUTH_SOCK| PROXY[nsigner-ssh-agent proxy]
PROXY -->|qrexec qubes.NsignerRpc| SIGNER[n_signer in nostr_signer qube]
SIGNER -->|derives ed25519 key from mnemonic| KEY[ed25519 private key in mlock'd RAM]
SSH -->|SSH protocol| SERVER[remote SSH server]
```
**Flow:**
1. User in untrusted qube runs `ssh user@server`
2. OpenSSH connects to the proxy via `SSH_AUTH_SOCK`
3. OpenSSH sends `SSH_AGENTC_REQUEST_IDENTITIES` — proxy fetches the ed25519 public key from n_signer and returns it
4. OpenSSH sends the public key to the remote server as part of `SSH_MSG_USERAUTH_REQUEST`
5. The server sends back a challenge (the data to sign)
6. OpenSSH sends `SSH_AGENTC_SIGN_REQUEST` with the challenge data to the proxy
7. The proxy forwards the data to n_signer via qrexec: `{"method":"sign","params":["<hex>","algorithm":"ed25519","index":0]}`
8. n_signer signs the data with the ed25519 private key and returns the signature
9. The proxy returns the signature to OpenSSH
10. OpenSSH sends the signed authentication response to the server
**Key security property:** The private key exists only in n_signer's mlock'd memory in the `nostr_signer` qube. The untrusted qube never sees it. The proxy only ever handles public keys and signing requests/responses.
## 3. The ssh-agent protocol
OpenSSH's agent protocol is defined in `draft-miller-ssh-agent` (PROTOCOL.agent in the OpenSSH source). It uses a Unix socket with a simple message framing:
**Message format:**
```
uint32 message_length
byte message_type
byte[] message_data
```
**Messages the proxy must handle:**
### SSH_AGENTC_REQUEST_IDENTITIES (11)
Request the list of keys the agent holds.
**Response: SSH_AGENT_IDENTITIES_ANSWER (12)**
```
uint32 num_keys
string key_blob_1 (public key in SSH wire format)
string key_comment_1 (human-readable comment)
string key_blob_2
string key_comment_2
...
```
The proxy returns one key: the ed25519 public key from n_signer, formatted as an SSH ed25519 public key blob.
**SSH ed25519 public key blob format:**
```
string "ssh-ed25519"
string <32-byte public key>
```
### SSH_AGENTC_SIGN_REQUEST (13)
```
string key_blob (the public key to sign with)
string data (the data to sign)
uint32 flags (SSH_AGENT_SIGN_FLAG_* — currently 0 or SSH_AGENT_FLAG_RSA_SHA2_256/512 for RSA only)
```
**Response: SSH_AGENT_SIGN_RESPONSE (14)**
```
string signature_blob
```
**SSH ed25519 signature blob format:**
```
string "ssh-ed25519"
string <64-byte signature>
```
### Other messages
- `SSH_AGENTC_REMOVE_ALL_IDENTITIES` (11) — return `SSH_AGENT_SUCCESS`
- `SSH_AGENTC_REMOVE_IDENTITY` (18) — return `SSH_AGENT_SUCCESS`
- `SSH_AGENTC_LOCK` / `SSH_AGENTC_UNLOCK` (22/23) — return `SSH_AGENT_SUCCESS`
- `SSH_AGENTC_ADD_IDENTITY` (17) — return `SSH_AGENT_FAILURE` (we don't allow adding keys)
- Any unknown message — return `SSH_AGENT_FAILURE` (5)
## 4. Implementation
### 4.1 New program: `nsigner-ssh-agent`
A small C program (~400-500 lines) that:
1. Creates a Unix socket at a configurable path (default: `$XDG_RUNTIME_DIR/nsigner-ssh-agent.sock`)
2. Listens for connections from OpenSSH
3. On `SSH_AGENTC_REQUEST_IDENTITIES`:
- Calls n_signer via qrexec to get the ed25519 public key: `{"method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}`
- Parses the structured response to extract the 32-byte public key
- Formats it as an SSH ed25519 key blob
- Returns `SSH_AGENT_IDENTITIES_ANSWER` with one key
4. On `SSH_AGENTC_SIGN_REQUEST`:
- Extracts the data to sign from the request
- Calls n_signer via qrexec: `{"method":"sign","params":["<data_hex>",{"algorithm":"ed25519","index":0}]}`
- Parses the response to extract the 64-byte signature
- Formats it as an SSH ed25519 signature blob
- Returns `SSH_AGENT_SIGN_RESPONSE`
5. On other messages: returns appropriate responses (see §3)
**Key caching:** The proxy caches the public key after the first `REQUEST_IDENTITIES` call (it doesn't change during a session). The private key is never cached — each sign request goes to n_signer.
**Multiple keys:** The proxy can support multiple ed25519 keys by using different `index` values. Configuration via command-line args or environment variables:
```bash
nsigner-ssh-agent --index 0 --index 1 --index 2
```
Each index produces a different ed25519 key, all derived from the same mnemonic.
### 4.2 qrexec communication
The proxy communicates with n_signer via `qrexec-client-vm nostr_signer qubes.NsignerRpc`, using the same 4-byte big-endian length framing as the existing qrexec examples.
Each qrexec call is a one-shot: spawn `qrexec-client-vm`, send one framed request, receive one framed response, exit. This matches the existing qrexec protocol.
### 4.3 Configuration
**In the untrusted qube:**
```bash
# Start the proxy in the background
nsigner-ssh-agent --socket $XDG_RUNTIME_DIR/nsigner-ssh-agent.sock &
export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/nsigner-ssh-agent.sock
export SSH_AUTH_SIGNER_QUBE=nostr_signer
# Now use ssh normally
ssh user@server
```
**In the nostr_signer qube:**
- n_signer must be running with the mnemonic loaded
- The `qubes.NsignerRpc` service must be installed
- The qrexec policy must allow the untrusted qube to call `qubes.NsignerRpc`
**Qubes policy (`packaging/qubes/policy.d/40-nsigner.policy`):**
```
nsigner.NsignerRpc +untrusted-qube nostr_signer allow
```
Or with the deny-by-default model from n_signer's policy:
```
nsigner.NsignerRpc +untrusted-qube nostr_signer ask
```
The `ask` policy shows a Qubes dom0 prompt each time the untrusted qube tries to sign, giving the user a chance to approve/deny.
### 4.4 n_signer policy
Use n_signer's `--preapprove` to pre-approve the untrusted qube for ed25519 signing:
```bash
nsigner --preapprove caller=qubes:untrusted-qube,algorithm=ed25519,index=0,verb=sign,get_public_key
```
Or use the deny-by-default prompt model — each sign request shows a prompt in the signer qube's TUI:
```
Caller: qubes:untrusted-qube
Action: sign with ed25519
Key: index 0 (key_id: 1fd9c73a93189484)
[a] approve [d] deny
```
### 4.5 File structure
```
src/nsigner_ssh_agent.c — the proxy program
packaging/qubes/
install-ssh-agent.sh — install the proxy in an AppVM
ssh-agent.desktop — autostart the proxy on qube boot
```
### 4.6 Build
The proxy is a separate binary, not part of the main nsigner binary. It links against:
- cJSON (for JSON-RPC parsing)
- libnostr_core (for the qrexec transport framing)
Makefile target:
```makefile
$(BUILD_DIR)/nsigner-ssh-agent: src/nsigner_ssh_agent.c
$(CC) $(CFLAGS) src/nsigner_ssh_agent.c -o $(BUILD_DIR)/nsigner-ssh-agent $(LDFLAGS)
```
## 5. Security considerations
### 5.1 The untrusted qube sees the public key
The ed25519 public key is sent to the untrusted qube. This is fine — public keys are not secret. The untrusted qube could share the public key with anyone, but that doesn't compromise the private key.
### 5.2 The untrusted qube controls what is signed
The untrusted qube constructs the SSH authentication message and sends it to the proxy for signing. The proxy forwards it to n_signer, which signs it without inspecting the content.
**Risk:** A compromised untrusted qube could ask n_signer to sign arbitrary data, not just SSH authentication messages. The ed25519 signature could be used for purposes other than SSH.
**Mitigation:** This is the same trust model as any ssh-agent. A compromised process with access to `SSH_AUTH_SOCK` can sign arbitrary data. The n_signer policy model (deny-by-default with per-request approval) provides an additional layer: the user can see each sign request at the signer's TUI and approve/deny it.
### 5.3 The proxy holds no secrets
The proxy process in the untrusted qube holds no private key material. It only has:
- The Unix socket path
- The qrexec target qube name
- The cached public key (non-secret)
- The ed25519 index to use
If the proxy process is compromised, the attacker gains the ability to forward sign requests to n_signer — but n_signer's policy still gates each request.
### 5.4 Qubes qrexec authentication
The qrexec framework authenticates the source qube. n_signer sees the caller as `qubes:<source-vm>`. This means:
- The signer knows which qube is requesting the signature
- The Qubes dom0 policy controls which qubes can call the service
- The n_signer policy can approve specific qubes for specific algorithms/verbs
### 5.5 No private key on disk
The ed25519 private key is never written to disk. It's derived from the mnemonic in n_signer's mlock'd RAM on each startup. The proxy doesn't have the mnemonic. The untrusted qube doesn't have the mnemonic. Only the `nostr_signer` qube has the mnemonic (entered at startup, never persisted).
## 6. What is explicitly out of scope
1. **RSA key support** — the proxy only supports ed25519. RSA SSH keys require different signing (PKCS#1 v1.5 or PSS) and different key derivation. ed25519 is the modern default.
2. **SSH certificate support** — OpenSSH certificates (ssh-ed25519-cert-v01@openssh.com) are not supported. These require a CA key to sign the certificate, which is a separate workflow.
3. **Agent forwarding**`ssh -A` (forwarding the agent to a remote host) is not supported. The proxy only accepts local Unix socket connections.
4. **Multiple signer qubes** — the proxy talks to one n_signer instance. Multiple signers would require multiple proxy instances.
5. **Hybrid PQ SSH keys** — OpenSSH doesn't support PQ signing keys yet. When it does, the proxy can be extended to use ML-DSA-65 via n_signer's `sign` verb with `algorithm=ml-dsa-65`.
## 7. Implementation phases
### Phase 1: Core proxy
- Implement `nsigner-ssh-agent.c` with ssh-agent protocol handling
- Implement qrexec communication with n_signer
- Support `SSH_AGENTC_REQUEST_IDENTITIES` and `SSH_AGENTC_SIGN_REQUEST`
- Test with a real SSH connection
### Phase 2: Qubes integration
- Install script for AppVMs
- Autostart on qube boot
- Qubes policy documentation
- n_signer `--preapprove` documentation
### Phase 3: Hardening
- Connection rate limiting (prevent sign-request flooding)
- Optional: inspect the sign request to verify it looks like an SSH auth message
- Optional: support multiple ed25519 indices (multiple SSH identities)
- Optional: support `SSH_AGENTC_REQUEST_EXTENSION` for OpenSSH extensions
## 8. Open questions
1. **Should the proxy inspect the sign request data?** The proxy could check that the data being signed looks like an SSH authentication message (starts with the session ID, has the right message type). This would prevent the untrusted qube from using the signer for non-SSH signing. However, it's fragile (SSH protocol may change) and doesn't match how normal ssh-agents work. Default: no inspection, rely on n_signer's policy.
2. **Should the proxy support `ssh-add -l`?** `ssh-add -l` lists the keys the agent holds, which maps to `SSH_AGENTC_REQUEST_IDENTITIES`. Yes, this is already supported by the protocol handling.
3. **Should the proxy cache the public key across connections?** Yes — the public key doesn't change during a session. Cache it after the first `REQUEST_IDENTITIES` call. Clear the cache on `SIGHUP` or when the qrexec call fails.
4. **Should we support `sk-ssh-ed25519@openssh.com` (security key) key type?** This is the YubiKey key type. It adds a "flags" byte to the signature (e.g., "require user presence"). Not needed for n_signer, but could be useful for compatibility with systems that expect security key signatures. Default: no, use standard `ssh-ed25519`.

View File

@@ -0,0 +1,151 @@
# Plan: Move Connection Instructions to On-Demand Display
## Problem
The Connections section in the running TUI is verbose and confusing. It mixes server addresses with client command examples, and when multiple transports are active (Unix + TCP + HTTP) it takes up a large chunk of the status screen, pushing the Roles and Activity sections down. The connection instructions are only needed once when a user wants to know how to reach the signer — they don't need to be permanently visible.
## Solution
Remove the Connections section from the default `render_status()` display. Add a new hotkey **`d`** (display connection instructions) that prints the connection info on demand, then returns to the normal status view on the next refresh/keystroke.
## Design
### Default status display (after change)
The Connections section is removed. The display shows: Roles, Activity, status line, hotkey menu. A one-line hint at the top reminds the user that `d` shows connection instructions.
```text
================================================================================
n_signer v0.0.53
================================================================================
> Main Menu
Press d for connection instructions
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
ops nostr secp256k1 nostr_index
backup bitcoin secp256k1 role_path
Activity (latest first)
16:03:11 allow caller=uid:1000 method=get_public_key role=main
16:02:44 prompt caller=uid:1000 method=sign_event role=ops
16:02:46 allow caller=uid:1000 method=sign_event role=ops
15:59:10 deny caller=uid:1001 method=sign_event error=unauthorized
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
l lock/reunlock
r refresh
a toggle auto-approve
d display connections
q/x quit
```
### On-demand connection display (press `d`)
When the user presses `d`, the screen clears and shows **only** the connection instructions, with clear spacing between transports. Each transport block shows the **connection string** (the address/endpoint to reach), a blank line, then an **Example:** with the client command indented beneath. No "Server:" / "Client:" labels — just the address and an example. A footer tells the user how to return.
```text
================================================================================
n_signer v0.0.53
================================================================================
> Connection Instructions
Unix socket
@nsigner_hairy_dog
Example:
nsigner --socket-name nsigner_hairy_dog client '<json>'
Qrexec (bridge-source-trusted):
qrexec-client-vm <target_qube> qubes.NsignerRpc
FIPS
http://npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd.fips:11111
Example:
curl -X POST http://npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd.fips:11111/ \
-H 'Content-Type: application/json' \
-d '<json>'
HTTP
http://127.0.0.1:11112
Example:
curl -X POST http://127.0.0.1:11112/ \
-H 'Content-Type: application/json' \
-d '<json>'
OTP pad: 333e9902db839d9d... (offset 288 / 1048576 bytes)
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
Press any key to return
```
### Key differences from current display
1. **Spacing between transports** — each transport gets its own titled block with blank lines separating it from the next, instead of a flat list of `Server:` / `Client:` lines.
2. **Connection string + example, no labels** — each block shows the bare connection string (e.g. `http://127.0.0.1:11112`), then an `Example:` with the client command indented beneath. No confusing "Server:" / "Client:" labels.
3. **On-demand only** — the default running display no longer shows connections at all, just a one-line hint. The user presses `d` when they need the instructions, reads them, then presses any key to return.
4. **OTP pad status** — shown at the bottom of the connection display (it's connection-related: which pad is bound and how much has been consumed).
## Implementation Steps
1. **[`src/main.c`](src/main.c) — `render_status()`** (line 1451):
- Remove the Connections section (lines 1464-1471).
- Add a one-line hint after the top frame: `tui_print("Press d for connection instructions");`
- Add a blank line after the hint.
2. **[`src/main.c`](src/main.c) — new function `render_connections()`**:
- Clears the screen and renders the top frame.
- Iterates `g_connection_info` but formats it with the spaced layout shown above. Since `g_connection_info` already stores formatted strings like `"Server: unix @nsigner_hairy_dog"` and `" Client: nsigner --socket-name ... client '<json>'"`, either:
- **Option A**: Reformat the stored strings into blocks by detecting `Server:` lines as transport boundaries and printing blank lines + indentation.
- **Option B**: Store connection info in a structured form (transport type, server address, client command(s)) and render the block layout from the structured data.
- Option B is cleaner but requires changing `connection_info_add()` and all its call sites. Option A is a smaller change. **Recommend Option A** for now — parse the existing flat list into blocks.
- **FIPS section**: The connection string is the npub-based FIPS URL (`http://<npub>.fips:<port>`), which FIPS resolves to the signer's TCP endpoint. The example uses `curl` since FIPS makes the endpoint HTTP-reachable for clients with FIPS installed. The raw TCP bind address (`tcp:[::]:11111`) is not shown — it's an implementation detail; the npub URL is what clients use.
- Print OTP pad status if bound.
- Print the status line at the bottom.
- Print "Press any key to return" footer.
3. **[`src/main.c`](src/main.c) — `g_main_menu_items`** (line 825):
- Add `{"^_d^: display connections", 'd'}` to the menu array.
4. **[`src/main.c`](src/main.c) — event loop** (line ~3068, the stdin keystroke handler):
- Add a case for `'d'`:
- Call `render_connections()`.
- Read one keystroke from stdin (blocking `read()`).
- Call `render_status()` to return to the normal display.
5. **[`README.md`](README.md) §3.2**:
- Update the example TUI layout to match the new default display (no Connections section, `d` in hotkeys).
- Add a note that pressing `d` shows the connection instructions.
6. **[`api.md`](api.md) §5 Transports**:
- No changes needed — the transport documentation is already separate from the TUI display.
## Files Changed
| File | Change |
|------|--------|
| [`src/main.c`](src/main.c) | Remove Connections from `render_status()`, add `render_connections()`, add `d` hotkey + menu item + event loop case |
| [`README.md`](README.md) | Update §3.2 example display to match new layout |
| [`api.md`](api.md) | No changes |
## Verification
- `make dev` builds clean.
- Start nsigner with all transports selected. Default display shows no Connections section, just the "Press d for connection instructions" hint.
- Press `d` — connection instructions appear with clear spacing between transports.
- Press any key — returns to normal status display.
- All other hotkeys (`l`, `r`, `a`, `q`) still work.

File diff suppressed because it is too large Load Diff

View File

@@ -202,43 +202,35 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
/* New algorithm-based verb defines (algorithm is a parameter, not implicit) */
/* Algorithm-based verb defines (algorithm is a parameter, not implicit).
* Callers must supply `algorithm` explicitly — no implicit defaults. */
#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 ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
/* Nostr protocol verbs (secp256k1 NIP-06, role-based selector). */
#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"
/* New algorithm-based verb defines (algorithm is a parameter, not implicit) */
#define VERB_SIGN "sign"
#define VERB_VERIFY "verify"
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
/* OTP verbs (one-time pad; selected via algorithm:"otp"). */
#define VERB_ENCRYPT "encrypt"
#define VERB_DECRYPT "decrypt"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -637,7 +629,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {
@@ -710,69 +702,17 @@ int socket_name_random(char *out, size_t out_len);
#include <string.h>
/* Check whether a verb is a Nostr protocol verb (role-based, secp256k1 NIP-06). */
static int is_nostr_verb(const char *verb) {
if (verb == NULL) {
return 0;
}
/* get_public_key is a universal verb — allowed for all algorithms.
* It is handled separately in enforce_verb_role() and excluded here. */
return (strcmp(verb, VERB_SIGN_EVENT) == 0) ||
(strcmp(verb, VERB_NIP44_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NIP44_DECRYPT) == 0) ||
(strcmp(verb, VERB_NIP04_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NIP04_DECRYPT) == 0) ||
(strcmp(verb, VERB_MINE_EVENT) == 0);
}
static int is_sign_data_verb(const char *verb) {
if (verb == NULL) {
return 0;
}
return (strcmp(verb, VERB_SIGN_DATA) == 0) ||
(strcmp(verb, VERB_VERIFY_SIG) == 0);
}
static int is_kem_verb(const char *verb) {
if (verb == NULL) {
return 0;
}
return (strcmp(verb, VERB_KEM_ENCAPS) == 0) ||
(strcmp(verb, VERB_KEM_DECAPS) == 0);
}
/* Check whether a (purpose, curve) pair is allowed for sign_data / verify_signature.
* Returns ENFORCE_OK, ENFORCE_ERR_PURPOSE, or ENFORCE_ERR_CURVE. */
static int enforce_sign_data_role(const role_entry_t *role) {
switch (role->purpose) {
case PURPOSE_SSH:
if (role->curve == CURVE_ED25519) {
return ENFORCE_OK;
}
return ENFORCE_ERR_CURVE;
case PURPOSE_PQ_SIG:
if (role->curve == CURVE_ML_DSA_65 ||
role->curve == CURVE_SLH_DSA_128S) {
return ENFORCE_OK;
}
return ENFORCE_ERR_CURVE;
default:
return ENFORCE_ERR_PURPOSE;
}
}
/* Check whether a (purpose, curve) pair is allowed for kem_encapsulate/decapsulate.
* Returns ENFORCE_OK, ENFORCE_ERR_PURPOSE, or ENFORCE_ERR_CURVE. */
static int enforce_kem_role(const role_entry_t *role) {
if (role->purpose != PURPOSE_PQ_KEM) {
return ENFORCE_ERR_PURPOSE;
}
if (role->curve != CURVE_ML_KEM_768) {
return ENFORCE_ERR_CURVE;
}
return ENFORCE_OK;
return (strcmp(verb, VERB_NOSTR_SIGN_EVENT) == 0) ||
(strcmp(verb, VERB_NOSTR_MINE_EVENT) == 0) ||
(strcmp(verb, VERB_NOSTR_NIP44_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NOSTR_NIP44_DECRYPT) == 0) ||
(strcmp(verb, VERB_NOSTR_NIP04_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NOSTR_NIP04_DECRYPT) == 0);
}
int enforce_verb_role(const char *verb, const role_entry_t *role) {
@@ -780,14 +720,15 @@ int enforce_verb_role(const char *verb, const role_entry_t *role) {
return ENFORCE_ERR_UNKNOWN_VERB;
}
/* get_public_key is a universal verb — allowed for any role whose
* (curve, purpose) maps to a known algorithm via crypto_alg_from_role().
* This lets clients retrieve public keys for all 6 algorithms. */
if (strcmp(verb, VERB_GET_PUBLIC_KEY) == 0) {
crypto_alg_t alg = crypto_alg_from_role(role->curve, role->purpose);
if (alg == CRYPTO_ALG_UNKNOWN) {
/* nostr_get_public_key: returns the role's secp256k1 (NIP-06) public key.
* Requires PURPOSE_NOSTR + CURVE_SECP256K1. */
if (strcmp(verb, VERB_NOSTR_GET_PUBLIC_KEY) == 0) {
if (role->purpose != PURPOSE_NOSTR) {
return ENFORCE_ERR_PURPOSE;
}
if (role->curve != CURVE_SECP256K1) {
return ENFORCE_ERR_CURVE;
}
return ENFORCE_OK;
}
@@ -802,41 +743,25 @@ int enforce_verb_role(const char *verb, const role_entry_t *role) {
return ENFORCE_OK;
}
/* ssh_sign: PURPOSE_SSH + CURVE_ED25519 only. */
if (strcmp(verb, VERB_SSH_SIGN) == 0) {
if (role->purpose != PURPOSE_SSH) {
return ENFORCE_ERR_PURPOSE;
}
if (role->curve != CURVE_ED25519) {
return ENFORCE_ERR_CURVE;
}
return ENFORCE_OK;
}
/* sign_data / verify_signature: SSH+ed25519 or PQ-SIG algorithms. */
if (is_sign_data_verb(verb)) {
return enforce_sign_data_role(role);
}
/* kem_encapsulate / kem_decapsulate: PQ-KEM + ML-KEM-768 only. */
if (is_kem_verb(verb)) {
return enforce_kem_role(role);
}
/* All other verbs (sign, verify, encapsulate, decapsulate,
* derive_shared_secret, get_public_key, encrypt, decrypt) are
* algorithm-based and handled by enforce_verb_algorithm() before
* role resolution. Anything reaching here is unrecognized. */
return ENFORCE_ERR_UNKNOWN_VERB;
}
/* ---- Algorithm-based enforcement (new) ----
/* ---- Algorithm-based enforcement ----
*
* enforce_verb_algorithm() checks whether a verb is valid for a given
* algorithm, WITHOUT consulting the role table or purpose field. This is
* the enforcement path for the new algorithm-based verbs (sign, verify,
* the enforcement path for the algorithm-based verbs (sign, verify,
* encapsulate, decapsulate, derive_shared_secret, get_public_key).
*
* The Nostr-specific verbs (sign_event, nip44_*, nip04_*, mine_event) are
* always secp256k1 — that's a Nostr protocol requirement, not a policy
* choice. They are accepted here only when alg == CRYPTO_ALG_SECP256K1
* so that algorithm-based policy entries can approve them too.
* The Nostr-specific verbs (nostr_sign_event, nostr_nip44_*, nostr_nip04_*,
* nostr_mine_event) are always secp256k1 — that's a Nostr protocol
* requirement, not a policy choice. They are accepted here only when
* alg == CRYPTO_ALG_SECP256K1 so that algorithm-based policy entries can
* approve them too.
*/
int enforce_verb_algorithm(const char *verb, crypto_alg_t alg) {
if (verb == NULL) {
@@ -878,17 +803,22 @@ int enforce_verb_algorithm(const char *verb, crypto_alg_t alg) {
return ENFORCE_ERR_ALGORITHM;
}
/* Nostr verbs: always secp256k1 (protocol requirement). */
if (is_nostr_verb(verb) || strcmp(verb, VERB_MINE_EVENT) == 0) {
/* derive: HMAC-SHA256(privkey, data). secp256k1 only (32-byte scalar key). */
if (strcmp(verb, VERB_DERIVE) == 0) {
if (alg == CRYPTO_ALG_SECP256K1) {
return ENFORCE_OK;
}
return ENFORCE_ERR_ALGORITHM;
}
/* Nostr verbs: always secp256k1 (protocol requirement). */
if (is_nostr_verb(verb)) {
if (alg == CRYPTO_ALG_SECP256K1) {
return ENFORCE_OK;
}
return ENFORCE_ERR_ALGORITHM;
}
/* Old verb aliases map to the new verbs and are handled by the
* dispatcher before reaching enforcement. If they reach here,
* treat them as unknown (the dispatcher should have remapped). */
return ENFORCE_ERR_UNKNOWN_VERB;
}

220
src/http_listener.c Normal file
View File

@@ -0,0 +1,220 @@
/*
* http_listener.c — minimal HTTP/1.1 parser for n_signer's HTTP listener mode.
*
* Only supports POST with a JSON body. No chunked encoding, no keep-alive,
* no static file serving. One request per connection (connection close after
* response).
*
* This is intentionally minimal — n_signer is a signer, not a web server.
*/
#define _POSIX_C_SOURCE 200809L
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
/* ------------------------------------------------------------------ */
/* Read a line from fd into buf (up to buf_size-1 chars, NUL-terminated).
* Returns line length (excluding NUL) on success, -1 on error/EOF.
* The line includes the trailing \r\n if present. */
static int read_line_fd(int fd, char *buf, size_t buf_size) {
size_t pos = 0;
while (pos < buf_size - 1) {
char c;
ssize_t n = read(fd, &c, 1);
if (n <= 0) {
if (pos == 0) return -1;
break;
}
buf[pos++] = c;
if (c == '\n') break;
}
buf[pos] = '\0';
return (int)pos;
}
/* Read exactly n bytes from fd into buf. Returns 0 on success, -1 on error. */
static int read_n_bytes(int fd, char *buf, size_t n) {
size_t got = 0;
while (got < n) {
ssize_t r = read(fd, buf + got, n - got);
if (r <= 0) return -1;
got += (size_t)r;
}
return 0;
}
/* Write all bytes to fd. Returns 0 on success, -1 on error. */
static int write_all(int fd, const char *buf, size_t len) {
size_t put = 0;
while (put < len) {
ssize_t w = write(fd, buf + put, len - put);
if (w <= 0) {
if (errno == EINTR) continue;
return -1;
}
put += (size_t)w;
}
return 0;
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
/*
* Read an HTTP POST request from fd and return the JSON body.
*
* On success returns 0 and sets *out_body to a malloc'd NUL-terminated
* string (caller frees). Returns -1 on error.
*/
int http_recv_request(int fd, char **out_body, size_t max_body_size) {
char line[2048];
long content_length = -1;
int is_post = 0;
if (!out_body) return -1;
*out_body = NULL;
/* Read request line: "METHOD PATH HTTP/1.1\r\n" */
if (read_line_fd(fd, line, sizeof(line)) < 0) {
return -1;
}
/* Parse method. */
if (strncmp(line, "POST", 4) == 0) {
is_post = 1;
} else if (strncmp(line, "OPTIONS", 7) == 0) {
/* CORS preflight — read remaining headers, then return a special
* marker so the caller can send CORS headers. */
while (read_line_fd(fd, line, sizeof(line)) > 0) {
if (strcmp(line, "\r\n") == 0 || strcmp(line, "\n") == 0) break;
}
*out_body = strdup(""); /* empty body signals OPTIONS */
return 0;
}
if (!is_post) {
/* Not POST — read remaining headers so we can send a 405. */
while (read_line_fd(fd, line, sizeof(line)) > 0) {
if (strcmp(line, "\r\n") == 0 || strcmp(line, "\n") == 0) break;
}
return -2; /* method not allowed */
}
/* Read headers until empty line. */
while (read_line_fd(fd, line, sizeof(line)) > 0) {
/* Strip trailing \r\n. */
size_t len = strlen(line);
while (len > 0 && (line[len-1] == '\r' || line[len-1] == '\n')) {
line[--len] = '\0';
}
if (len == 0) break; /* end of headers */
/* Parse Content-Length (case-insensitive). */
if (strncasecmp(line, "Content-Length:", 15) == 0) {
const char *p = line + 15;
while (*p == ' ' || *p == '\t') p++;
content_length = atol(p);
}
}
if (content_length < 0) {
return -3; /* missing Content-Length */
}
if ((size_t)content_length > max_body_size) {
/* Drain the body so the connection isn't left half-open. */
char tmp[4096];
long remaining = content_length;
while (remaining > 0) {
size_t to_read = (size_t)remaining;
if (to_read > sizeof(tmp)) to_read = sizeof(tmp);
ssize_t r = read(fd, tmp, to_read);
if (r <= 0) break;
remaining -= r;
}
return -4; /* body too large */
}
/* Read the body. */
char *body = (char *)malloc((size_t)content_length + 1);
if (!body) return -1;
if (read_n_bytes(fd, body, (size_t)content_length) != 0) {
free(body);
return -1;
}
body[content_length] = '\0';
*out_body = body;
return 0;
}
/*
* Send an HTTP response with a JSON body.
*/
int http_send_response(int fd, const char *json_body) {
if (!json_body) json_body = "";
size_t body_len = strlen(json_body);
char header[512];
int hlen = snprintf(header, sizeof(header),
"HTTP/1.1 200 OK\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %zu\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Access-Control-Allow-Methods: POST, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n"
"Connection: close\r\n"
"\r\n",
body_len);
if (write_all(fd, header, (size_t)hlen) != 0) return -1;
if (write_all(fd, json_body, body_len) != 0) return -1;
return 0;
}
/*
* Send an HTTP error response.
*/
int http_send_error(int fd, int status, const char *message) {
if (!message) message = "error";
char body[512];
int blen = snprintf(body, sizeof(body),
"{\"error\":{\"code\":%d,\"message\":\"%s\"}}", status, message);
char header[512];
int hlen = snprintf(header, sizeof(header),
"HTTP/1.1 %d %s\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Connection: close\r\n"
"\r\n",
status,
(status == 405) ? "Method Not Allowed" :
(status == 413) ? "Payload Too Large" :
(status == 400) ? "Bad Request" : "Internal Server Error",
blen);
if (write_all(fd, header, (size_t)hlen) != 0) return -1;
if (write_all(fd, body, (size_t)blen) != 0) return -1;
return 0;
}
/*
* Send a CORS preflight response (for OPTIONS requests).
*/
int http_send_cors_preflight(int fd) {
const char *resp =
"HTTP/1.1 204 No Content\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Access-Control-Allow-Methods: POST, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n"
"Access-Control-Max-Age: 86400\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n"
"\r\n";
return write_all(fd, resp, strlen(resp));
}

40
src/http_listener.h Normal file
View File

@@ -0,0 +1,40 @@
/*
* http_listener.h — minimal HTTP/1.1 parser for n_signer's HTTP listener mode.
*/
#ifndef NSIGNER_HTTP_LISTENER_H
#define NSIGNER_HTTP_LISTENER_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Read an HTTP POST request from fd and return the JSON body.
* On success returns 0 and sets *out_body to a malloc'd NUL-terminated
* string (caller frees). Returns -1 on read error, -2 on method not allowed,
* -3 on missing Content-Length, -4 on body too large.
*/
int http_recv_request(int fd, char **out_body, size_t max_body_size);
/*
* Send an HTTP 200 response with a JSON body.
*/
int http_send_response(int fd, const char *json_body);
/*
* Send an HTTP error response.
*/
int http_send_error(int fd, int status, const char *message);
/*
* Send a CORS preflight response (for OPTIONS requests).
*/
int http_send_cors_preflight(int fd);
#ifdef __cplusplus
}
#endif
#endif /* NSIGNER_HTTP_LISTENER_H */

View File

@@ -210,20 +210,17 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#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"
/*
* Check whether `verb` is allowed to execute against `role`.
@@ -612,7 +609,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

File diff suppressed because it is too large Load Diff

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -631,7 +634,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

585
src/otp_pad.c Normal file
View File

@@ -0,0 +1,585 @@
/*
* otp_pad.c — OTP pad state for n_signer.
*
* One pad per session (by design — see plans/otp_nostr_integration.md).
* The pad is bound at startup via otp_pad_bind() and accessed by the
* otp encrypt / otp decrypt dispatcher verbs via otp_pad_get_state().
*
* The pad file is opened read-only and kept open for the lifetime of the
* process. The per-pad .state file (offset counter) is read and written
* via libotppad. Offset writes are atomic (temp + rename) inside libotppad.
*
* Pad bytes are never loaded whole into RAM. Each request seeks to the
* current offset and reads exactly the slice it needs into a small
* mlock'd scratch buffer.
*/
#define _POSIX_C_SOURCE 200809L
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <dirent.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <errno.h>
#include "libotppad.h"
#include "otp_pad.h"
/* from secure_mem.h (headerless decls pattern) */
extern void secure_memzero(void *ptr, size_t len);
/* ------------------------------------------------------------------ */
/* OTP pad state */
/* ------------------------------------------------------------------ */
#define OTP_PAD_DIR_MAX 512
#define OTP_PAD_CHKSUM_MAX 128
#define OTP_PAD_PATH_MAX (OTP_PAD_DIR_MAX + OTP_PAD_CHKSUM_MAX + 16)
#define OTP_SCRATCH_MAX (4 * 1024 * 1024) /* 4 MB max chunk */
typedef struct {
int bound; /* 1 if a pad is bound */
char pads_dir[OTP_PAD_DIR_MAX]; /* directory holding .pad/.state */
char chksum[OTP_PAD_CHKSUM_MAX]; /* 64-hex-char pad checksum */
char pad_path[OTP_PAD_PATH_MAX]; /* full path to .pad file */
FILE *pad_fp; /* read-only FILE* on .pad */
uint64_t pad_size; /* total pad file size in bytes */
int allow_blkback; /* 1 if --otp-allow-blkback passed */
/* mlock'd scratch buffer for XOR */
void *scratch_data;
size_t scratch_size;
int scratch_locked;
} otp_pad_state_t;
static otp_pad_state_t g_otp_pad = {0};
otp_pad_state_t *otp_pad_get_state(void) {
return &g_otp_pad;
}
int otp_pad_is_bound(void) {
return g_otp_pad.bound;
}
const char *otp_pad_chksum(void) {
return g_otp_pad.bound ? g_otp_pad.chksum : NULL;
}
const char *otp_pad_dir(void) {
return g_otp_pad.bound ? g_otp_pad.pads_dir : NULL;
}
/* ------------------------------------------------------------------ */
/* Removable-mount check (Qubes guard) */
/* ------------------------------------------------------------------ */
/* Return 1 if `path` lives on a blkback device (e.g. /dev/xvdi via qvm-block),
* 0 if it's a directly-owned device (e.g. /dev/sda via PCI passthrough),
* -1 on error. Best-effort: compares the fs source device name. */
static int is_blkback_mount(const char *path) {
struct stat st;
if (stat(path, &st) != 0) return -1;
/* Read the mount source for the filesystem containing `path`. */
FILE *mtab = fopen("/proc/mounts", "r");
if (!mtab) return -1;
char line[1024];
int found = 0;
int is_blkback = 0;
size_t best_len = 0;
while (fgets(line, sizeof(line), mtab)) {
char source[512], mount[512], fstype[64];
if (sscanf(line, "%511s %511s %63s", source, mount, fstype) < 3) continue;
size_t mlen = strlen(mount);
/* Pick the longest matching mount prefix. */
if (strncmp(path, mount, mlen) == 0 &&
(path[mlen] == '/' || path[mlen] == '\0') &&
mlen > best_len) {
best_len = mlen;
found = 1;
/* blkback devices show up as /dev/xvd* in the guest. */
is_blkback = (strncmp(source, "/dev/xvd", 8) == 0);
}
}
fclose(mtab);
if (!found) return -1;
return is_blkback;
}
/* ------------------------------------------------------------------ */
/* Find a pad by chksum prefix in pads_dir */
/* ------------------------------------------------------------------ */
/* Resolve a chksum-or-prefix to a full 64-char chksum by scanning pads_dir.
* Returns 0 on success and fills `out_chksum` (must be >= OTP_PAD_CHKSUM_MAX).
* Returns -1 if not found, -2 if ambiguous (multiple matches). */
static int resolve_pad_chksum(const char *pads_dir, const char *prefix,
char *out_chksum) {
DIR *d = opendir(pads_dir);
if (!d) return -1;
struct dirent *e;
int matches = 0;
char found[OTPPAD_CHKSUM_HEX_LEN + 1] = {0};
size_t plen = strlen(prefix);
while ((e = readdir(d)) != NULL) {
size_t nlen = strlen(e->d_name);
if (nlen < 5 || strcmp(e->d_name + nlen - 4, ".pad") != 0) continue;
size_t base_len = nlen - 4; /* without ".pad" */
if (base_len != OTPPAD_CHKSUM_HEX_LEN) continue;
if (plen == 0 || strncmp(e->d_name, prefix, plen) == 0) {
memcpy(found, e->d_name, base_len);
found[base_len] = '\0';
matches++;
}
}
closedir(d);
if (matches == 0) return -1;
if (matches > 1) return -2;
strncpy(out_chksum, found, OTPPAD_CHKSUM_HEX_LEN);
out_chksum[OTPPAD_CHKSUM_HEX_LEN] = '\0';
return 0;
}
/* ------------------------------------------------------------------ */
/* Bind / unbind */
/* ------------------------------------------------------------------ */
/* Bind a pad at startup. Returns 0 on success, non-zero on error.
* `pads_dir` is the directory containing <chksum>.pad and <chksum>.state.
* `pad_spec` is a full 64-char chksum or a unique prefix.
* `allow_blkback` non-zero skips the blkback guard (for qvm-block testing). */
int otp_pad_bind(const char *pads_dir, const char *pad_spec, int allow_blkback) {
if (!pads_dir || !pad_spec) return 1;
if (g_otp_pad.bound) return 2; /* already bound */
/* Removable-mount guard. */
int blkback = is_blkback_mount(pads_dir);
if (blkback < 0) {
/* Could not determine — warn but continue (best-effort). */
fprintf(stderr, "otp_pad: warning: could not determine mount type for %s\n",
pads_dir);
} else if (blkback && !allow_blkback) {
fprintf(stderr, "otp_pad: %s is on a blkback device (qvm-block).\n",
pads_dir);
fprintf(stderr, " Refusing to bind for pad secrecy. Use PCI USB "
"controller passthrough, or pass --otp-allow-blkback "
"to override (not recommended for production pads).\n");
return 3;
}
/* Resolve the pad chksum. */
char chksum[OTPPAD_CHKSUM_HEX_LEN + 1];
if (strlen(pad_spec) == OTPPAD_CHKSUM_HEX_LEN) {
strncpy(chksum, pad_spec, OTPPAD_CHKSUM_HEX_LEN);
chksum[OTPPAD_CHKSUM_HEX_LEN] = '\0';
/* Verify the file exists. */
char path[OTP_PAD_PATH_MAX];
snprintf(path, sizeof(path), "%s/%s.pad", pads_dir, chksum);
if (access(path, R_OK) != 0) {
fprintf(stderr, "otp_pad: pad file not found: %s\n", path);
return 4;
}
} else {
int r = resolve_pad_chksum(pads_dir, pad_spec, chksum);
if (r == -1) {
fprintf(stderr, "otp_pad: no pad matching prefix '%s' in %s\n",
pad_spec, pads_dir);
return 5;
} else if (r == -2) {
fprintf(stderr, "otp_pad: ambiguous pad prefix '%s' (multiple matches)\n",
pad_spec);
return 6;
}
}
/* Build the pad path and open it read-only. */
char pad_path[OTP_PAD_PATH_MAX];
snprintf(pad_path, sizeof(pad_path), "%s/%s.pad", pads_dir, chksum);
FILE *fp = fopen(pad_path, "rb");
if (!fp) {
fprintf(stderr, "otp_pad: cannot open %s: %s\n", pad_path, strerror(errno));
return 7;
}
/* Determine pad size. */
struct stat st;
if (fstat(fileno(fp), &st) != 0) {
fprintf(stderr, "otp_pad: fstat failed: %s\n", strerror(errno));
fclose(fp);
return 8;
}
if (st.st_size < (off_t)OTPPAD_HEADER_RESERVED) {
fprintf(stderr, "otp_pad: pad too small (%lld bytes, need >= %d)\n",
(long long)st.st_size, OTPPAD_HEADER_RESERVED);
fclose(fp);
return 9;
}
/* Verify the pad checksum matches the filename. */
char computed[OTPPAD_CHKSUM_HEX_LEN + 1];
if (otppad_checksum(pad_path, computed) != 0) {
fprintf(stderr, "otp_pad: checksum computation failed\n");
fclose(fp);
return 10;
}
if (strcmp(computed, chksum) != 0) {
fprintf(stderr, "otp_pad: checksum mismatch (file says %s, computed %s)\n",
chksum, computed);
fclose(fp);
return 11;
}
/* Read the current offset. */
uint64_t offset;
if (otppad_state_read(pads_dir, chksum, &offset) != 0) {
/* No state file — initialize at the reserved header. */
offset = OTPPAD_HEADER_RESERVED;
if (otppad_state_write(pads_dir, chksum, offset) != 0) {
fprintf(stderr, "otp_pad: cannot write initial state file\n");
fclose(fp);
return 12;
}
}
if (offset < OTPPAD_HEADER_RESERVED) {
fprintf(stderr, "otp_pad: offset %llu < reserved header %d\n",
(unsigned long long)offset, OTPPAD_HEADER_RESERVED);
fclose(fp);
return 13;
}
if (offset > (uint64_t)st.st_size) {
fprintf(stderr, "otp_pad: offset %llu past end of pad (%lld)\n",
(unsigned long long)offset, (long long)st.st_size);
fclose(fp);
return 14;
}
/* Commit. */
strncpy(g_otp_pad.pads_dir, pads_dir, OTP_PAD_DIR_MAX - 1);
strncpy(g_otp_pad.chksum, chksum, OTP_PAD_CHKSUM_MAX - 1);
strncpy(g_otp_pad.pad_path, pad_path, OTP_PAD_PATH_MAX - 1);
g_otp_pad.pad_fp = fp;
g_otp_pad.pad_size = (uint64_t)st.st_size;
g_otp_pad.allow_blkback = allow_blkback;
g_otp_pad.bound = 1;
fprintf(stderr, "otp_pad: bound pad %s (%llu bytes, offset=%llu)\n",
chksum, (unsigned long long)st.st_size,
(unsigned long long)offset);
return 0;
}
void otp_pad_unbind(void) {
if (g_otp_pad.pad_fp) {
fclose(g_otp_pad.pad_fp);
g_otp_pad.pad_fp = NULL;
}
if (g_otp_pad.scratch_data) {
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
if (g_otp_pad.scratch_locked) {
munlock(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
}
free(g_otp_pad.scratch_data);
g_otp_pad.scratch_data = NULL;
g_otp_pad.scratch_size = 0;
g_otp_pad.scratch_locked = 0;
}
secure_memzero(&g_otp_pad, sizeof(g_otp_pad));
}
/* ------------------------------------------------------------------ */
/* Core encrypt/decrypt transform */
/* ------------------------------------------------------------------ */
/* Ensure the scratch buffer is at least `size` bytes, mlock'd. */
static int ensure_scratch(size_t size) {
if (size > OTP_SCRATCH_MAX) return -1;
if (g_otp_pad.scratch_size >= size && g_otp_pad.scratch_data) return 0;
/* Grow. */
if (g_otp_pad.scratch_data) {
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
if (g_otp_pad.scratch_locked) {
munlock(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
}
free(g_otp_pad.scratch_data);
g_otp_pad.scratch_data = NULL;
g_otp_pad.scratch_size = 0;
g_otp_pad.scratch_locked = 0;
}
g_otp_pad.scratch_data = malloc(size);
if (!g_otp_pad.scratch_data) return -2;
g_otp_pad.scratch_size = size;
if (mlock(g_otp_pad.scratch_data, size) == 0) {
g_otp_pad.scratch_locked = 1;
}
return 0;
}
/* Read `len` bytes from the pad at `offset` into `out` (must be mlock'd by
* caller). Returns 0 on success, non-zero on error. */
static int read_pad_slice(uint64_t offset, size_t len, unsigned char *out) {
if (!g_otp_pad.pad_fp) return -1;
if (fseek(g_otp_pad.pad_fp, (long)offset, SEEK_SET) != 0) return -2;
size_t got = fread(out, 1, len, g_otp_pad.pad_fp);
if (got != len) return -3;
return 0;
}
/* ------------------------------------------------------------------ */
/* Public encrypt/decrypt entrypoints (called by dispatcher verbs) */
/* ------------------------------------------------------------------ */
/* OTPPAD_ENCRYPT_OK etc. are returned via *out_result. */
/* Encrypt: takes plaintext bytes, returns malloc'd ASCII armor or binary blob.
*
* `encoding` is "ascii" or "binary".
* On success returns 0 and sets *out_payload (malloc'd, caller frees),
* *out_payload_len, and *out_new_offset.
*/
int otp_pad_encrypt(const unsigned char *plaintext, size_t pt_len,
const char *encoding,
char **out_payload, size_t *out_payload_len,
uint64_t *out_new_offset) {
if (!g_otp_pad.bound) return 1; /* not bound */
if (!plaintext || !out_payload || !out_payload_len || !out_new_offset) return 2;
*out_payload = NULL;
*out_payload_len = 0;
/* Pad the plaintext. */
size_t chunk = otppad_chunk_size(pt_len);
if (ensure_scratch(chunk) != 0) return 3;
unsigned char *buf = (unsigned char *)g_otp_pad.scratch_data;
memcpy(buf, plaintext, pt_len);
if (otppad_pad_apply(buf, pt_len, chunk) != 0) return 4;
/* Read current offset. */
uint64_t offset;
if (otppad_state_read(g_otp_pad.pads_dir, g_otp_pad.chksum, &offset) != 0) {
return 5;
}
if (offset + chunk > g_otp_pad.pad_size) {
return 6; /* pad exhausted */
}
/* Read the pad slice into a second scratch buffer. */
/* Reuse the same scratch: read pad into second half, XOR in place.
* For simplicity, allocate a separate pad-slice buffer. */
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
if (!pad_slice) return 7;
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
free(pad_slice);
return 8;
}
/* XOR. */
for (size_t i = 0; i < chunk; i++) {
buf[i] ^= pad_slice[i];
}
secure_memzero(pad_slice, chunk);
free(pad_slice);
/* Advance offset atomically. */
uint64_t new_offset = offset + chunk;
if (otppad_state_write(g_otp_pad.pads_dir, g_otp_pad.chksum, new_offset) != 0) {
return 9;
}
/* Encode output. */
if (encoding && strcmp(encoding, "binary") == 0) {
/* Binary .otp: header + encrypted (padded) data. */
otppad_bin_header_t hdr;
memset(&hdr, 0, sizeof(hdr));
memcpy(hdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN);
hdr.version = OTPPAD_FORMAT_VERSION;
/* pad_chksum is binary 32 bytes — convert hex to bytes. */
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
unsigned int byte;
sscanf(g_otp_pad.chksum + i * 2, "%02x", &byte);
hdr.pad_chksum[i] = (unsigned char)byte;
}
hdr.pad_offset = offset;
hdr.file_mode = 0644;
hdr.file_size = pt_len; /* original (unpadded) size */
/* Build the blob in memory (avoid fmemopen — it can misbehave
* with NUL bytes on some platforms). */
size_t blob_size = 58 + chunk;
unsigned char *blob = (unsigned char *)malloc(blob_size);
if (!blob) return 10;
unsigned char *p = blob;
memcpy(p, OTPPAD_MAGIC, 4); p += 4;
memcpy(p, &hdr.version, 2); p += 2;
memcpy(p, hdr.pad_chksum, OTPPAD_CHKSUM_BIN_LEN); p += OTPPAD_CHKSUM_BIN_LEN;
memcpy(p, &hdr.pad_offset, 8); p += 8;
memcpy(p, &hdr.file_mode, 4); p += 4;
memcpy(p, &hdr.file_size, 8); p += 8;
/* p is now at byte 58. Copy the encrypted (padded) data. */
memcpy(p, buf, chunk);
*out_payload = (char *)blob;
*out_payload_len = blob_size;
} else {
/* ASCII armor. */
char *armor = NULL;
if (otppad_armor_generate(NSIGNER_OTP_VERSION, g_otp_pad.chksum, offset,
buf, chunk, &armor) != 0) {
return 14;
}
*out_payload = armor;
*out_payload_len = strlen(armor);
}
*out_new_offset = new_offset;
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
return 0;
}
/* Decrypt: takes ASCII armor or binary blob, returns malloc'd plaintext.
*
* `encoding` is "ascii" or "binary" (auto-detected if NULL).
* On success returns 0 and sets *out_plaintext (malloc'd, caller frees),
* *out_pt_len.
*/
int otp_pad_decrypt(const char *input, size_t input_len,
const char *encoding,
unsigned char **out_plaintext, size_t *out_pt_len) {
if (!g_otp_pad.bound) return 1;
if (!input || !out_plaintext || !out_pt_len) return 2;
*out_plaintext = NULL;
*out_pt_len = 0;
uint64_t offset;
size_t chunk;
unsigned char *ciphertext = NULL;
size_t ct_len = 0;
int is_binary;
if (encoding && strcmp(encoding, "binary") == 0) {
is_binary = 1;
} else if (encoding && strcmp(encoding, "ascii") == 0) {
is_binary = 0;
} else {
/* Auto-detect by magic bytes. */
is_binary = (input_len >= 4 && memcmp(input, OTPPAD_MAGIC, 4) == 0);
}
if (!is_binary) {
/* ASCII armor. */
char chksum[OTPPAD_CHKSUM_HEX_LEN + 1];
char b64[65536];
if (otppad_armor_parse(input, chksum, &offset, b64, sizeof(b64)) != 0) {
return 3;
}
if (strcmp(chksum, g_otp_pad.chksum) != 0) {
return 4; /* pad mismatch */
}
int dlen = 0;
ciphertext = otppad_base64_decode(b64, &dlen);
if (!ciphertext) return 5;
ct_len = (size_t)dlen;
chunk = ct_len;
} else {
/* Binary .otp blob — parse directly from the buffer (avoid fmemopen
* which can misbehave with NUL bytes on some platforms). */
if (input_len < 58) return 6;
const unsigned char *p = (const unsigned char *)input;
otppad_bin_header_t hdr;
memset(&hdr, 0, sizeof(hdr));
memcpy(hdr.magic, p, 4); p += 4;
memcpy(&hdr.version, p, 2); p += 2;
memcpy(hdr.pad_chksum, p, OTPPAD_CHKSUM_BIN_LEN); p += OTPPAD_CHKSUM_BIN_LEN;
memcpy(&hdr.pad_offset, p, 8); p += 8;
memcpy(&hdr.file_mode, p, 4); p += 4;
memcpy(&hdr.file_size, p, 8); p += 8;
/* p is now at byte 58. */
if (memcmp(hdr.magic, OTPPAD_MAGIC, OTPPAD_MAGIC_LEN) != 0) {
return 8;
}
/* Convert binary chksum to hex for comparison. */
char chksum_hex[OTPPAD_CHKSUM_HEX_LEN + 1];
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
sprintf(chksum_hex + i * 2, "%02x", hdr.pad_chksum[i]);
}
chksum_hex[OTPPAD_CHKSUM_HEX_LEN] = '\0';
if (strcmp(chksum_hex, g_otp_pad.chksum) != 0) {
return 9;
}
offset = hdr.pad_offset;
/* Remaining bytes after the 58-byte header are the ciphertext. */
ct_len = input_len - 58;
chunk = ct_len;
ciphertext = (unsigned char *)malloc(ct_len);
if (!ciphertext) return 10;
memcpy(ciphertext, p, ct_len);
}
if (ensure_scratch(chunk) != 0) {
free(ciphertext); return 12;
}
unsigned char *buf = (unsigned char *)g_otp_pad.scratch_data;
/* Read pad slice. */
unsigned char *pad_slice = (unsigned char *)malloc(chunk);
if (!pad_slice) { free(ciphertext); return 13; }
if (read_pad_slice(offset, chunk, pad_slice) != 0) {
free(pad_slice); free(ciphertext); return 14;
}
/* XOR (in-place into scratch). */
for (size_t i = 0; i < chunk; i++) {
buf[i] = ciphertext[i] ^ pad_slice[i];
}
secure_memzero(pad_slice, chunk);
free(pad_slice);
secure_memzero(ciphertext, ct_len);
free(ciphertext);
/* Strip padding. */
size_t pt_len;
if (otppad_pad_remove(buf, chunk, &pt_len) != 0) {
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
return 15;
}
/* Return plaintext. */
unsigned char *pt = (unsigned char *)malloc(pt_len ? pt_len : 1);
if (!pt) {
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
return 16;
}
memcpy(pt, buf, pt_len);
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
*out_plaintext = pt;
*out_pt_len = pt_len;
return 0;
}
/* ------------------------------------------------------------------ */
/* Helpers for the dispatcher */
/* ------------------------------------------------------------------ */
uint64_t otp_pad_current_offset(void) {
if (!g_otp_pad.bound) return 0;
uint64_t off;
if (otppad_state_read(g_otp_pad.pads_dir, g_otp_pad.chksum, &off) != 0) {
return 0;
}
return off;
}
uint64_t otp_pad_size(void) {
return g_otp_pad.bound ? g_otp_pad.pad_size : 0;
}

76
src/otp_pad.h Normal file
View File

@@ -0,0 +1,76 @@
/*
* otp_pad.h — OTP pad state for n_signer (one pad per session).
*
* Bound at startup via otp_pad_bind(); accessed by the otp encrypt /
* otp decrypt dispatcher verbs. See plans/otp_nostr_integration.md.
*/
#ifndef NSIGNER_OTP_PAD_H
#define NSIGNER_OTP_PAD_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Version string stamped into ASCII-armored output. */
#define NSIGNER_OTP_VERSION "v0.0.2-otp"
/* Returns 1 if a pad has been bound at startup, 0 otherwise. */
int otp_pad_is_bound(void);
/* Return the bound pad's 64-char hex checksum, or NULL if unbound. */
const char *otp_pad_chksum(void);
/* Return the bound pad's directory, or NULL if unbound. */
const char *otp_pad_dir(void);
/* Current offset (bytes consumed) of the bound pad, or 0 if unbound. */
uint64_t otp_pad_current_offset(void);
/* Total size in bytes of the bound pad, or 0 if unbound. */
uint64_t otp_pad_size(void);
/*
* Bind a pad at startup. Returns 0 on success, non-zero on error.
* `pads_dir` is the directory containing <chksum>.pad and <chksum>.state.
* `pad_spec` is a full 64-char chksum or a unique prefix.
* `allow_blkback` non-zero skips the blkback guard (for qvm-block testing).
*/
int otp_pad_bind(const char *pads_dir, const char *pad_spec, int allow_blkback);
/* Unbind and zeroize all pad state. Idempotent. */
void otp_pad_unbind(void);
/*
* Encrypt plaintext bytes with the bound pad.
*
* `encoding` is "ascii" (default) or "binary".
* On success returns 0 and sets:
* *out_payload — malloc'd, caller frees (NUL-terminated for ascii)
* *out_payload_len — length of payload
* *out_new_offset — pad offset after this encryption
*/
int otp_pad_encrypt(const unsigned char *plaintext, size_t pt_len,
const char *encoding,
char **out_payload, size_t *out_payload_len,
uint64_t *out_new_offset);
/*
* Decrypt a ciphertext (ASCII armor or binary .otp blob) with the bound pad.
*
* `encoding` is "ascii", "binary", or NULL (auto-detect by magic bytes).
* On success returns 0 and sets:
* *out_plaintext — malloc'd, caller frees
* *out_pt_len — length of plaintext
*/
int otp_pad_decrypt(const char *input, size_t input_len,
const char *encoding,
unsigned char **out_plaintext, size_t *out_pt_len);
#ifdef __cplusplus
}
#endif
#endif /* NSIGNER_OTP_PAD_H */

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -631,7 +634,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

View File

@@ -221,6 +221,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -230,22 +231,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -671,7 +674,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

View File

@@ -211,6 +211,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -220,22 +221,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -634,7 +637,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

View File

@@ -210,6 +210,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -219,22 +220,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -633,7 +636,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -631,7 +634,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

View File

@@ -5,6 +5,7 @@
#include <stdint.h>
#include <sys/types.h>
#include <cJSON.h>
#include "http_listener.h"
/* from secure_mem.h */
@@ -213,6 +214,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -222,22 +224,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -644,12 +648,13 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
#define NSIGNER_LISTEN_UNIX 0
#define NSIGNER_LISTEN_STDIO 1
#define NSIGNER_LISTEN_QREXEC 2
#define NSIGNER_LISTEN_TCP 3
#define NSIGNER_LISTEN_HTTP 4
#define NSIGNER_AUTH_OFF 0
#define NSIGNER_AUTH_OPTIONAL 1
@@ -1449,13 +1454,25 @@ int server_start(server_ctx_t *ctx) {
return 0;
}
if (ctx->listen_mode == NSIGNER_LISTEN_TCP) {
if (ctx->listen_mode == NSIGNER_LISTEN_TCP ||
ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
int family;
uint16_t port;
char host[64];
int one = 1;
int bind_attempt;
int bound = 0;
int prc = parse_tcp_target(ctx->socket_name, &family, host, sizeof(host), &port);
/* HTTP mode uses the same TCP socket setup, just with an "http:" prefix
* instead of "tcp:". Convert to tcp: for parse_tcp_target. */
char tcp_target[128];
if (ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
snprintf(tcp_target, sizeof(tcp_target), "tcp:%s", ctx->socket_name + 5);
} else {
snprintf(tcp_target, sizeof(tcp_target), "%s", ctx->socket_name);
}
int prc = parse_tcp_target(tcp_target, &family, host, sizeof(host), &port);
if (prc != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
@@ -1464,54 +1481,95 @@ int server_start(server_ctx_t *ctx) {
return -1;
}
fd = socket(family, SOCK_STREAM, 0);
if (fd < 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"socket(tcp) failed: %s",
strerror(errno));
/* Try the requested port, then increment up to 5 times if it's in use.
* This handles both real conflicts and false positives (e.g. when TCP
* [::]:11111 and HTTP 127.0.0.1:11111 are started together in
* multi-listen mode, the second bind can fail with EADDRINUSE even
* though no other process holds the port). */
for (bind_attempt = 0; bind_attempt < 5 && !bound; ++bind_attempt) {
uint16_t try_port = (uint16_t)(port + bind_attempt);
if (try_port < port) {
/* port wrapped past 65535 — stop retrying */
break;
}
fd = socket(family, SOCK_STREAM, 0);
if (fd < 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"socket(tcp) failed: %s",
strerror(errno));
return -1;
}
(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (family == AF_INET) {
struct sockaddr_in addr4;
memset(&addr4, 0, sizeof(addr4));
addr4.sin_family = AF_INET;
addr4.sin_port = htons(try_port);
if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr4, sizeof(addr4)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
if (errno == EADDRINUSE) {
continue; /* try next port */
}
return -1;
}
} else {
struct sockaddr_in6 addr6;
memset(&addr6, 0, sizeof(addr6));
addr6.sin6_family = AF_INET6;
addr6.sin6_port = htons(try_port);
if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET6) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr6, sizeof(addr6)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
if (errno == EADDRINUSE) {
continue; /* try next port */
}
return -1;
}
}
bound = 1;
port = try_port;
}
if (!bound) {
/* All 5 attempts failed with EADDRINUSE. ctx->last_error already
* holds the most recent bind failure message. */
return -1;
}
(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (family == AF_INET) {
struct sockaddr_in addr4;
memset(&addr4, 0, sizeof(addr4));
addr4.sin_family = AF_INET;
addr4.sin_port = htons(port);
if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr4, sizeof(addr4)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
return -1;
}
} else {
struct sockaddr_in6 addr6;
memset(&addr6, 0, sizeof(addr6));
addr6.sin6_family = AF_INET6;
addr6.sin6_port = htons(port);
if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET6) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr6, sizeof(addr6)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
return -1;
/* Update ctx->socket_name to reflect the actual bound port, so the
* status display and error messages show the real address. */
{
const char *prefix = (ctx->listen_mode == NSIGNER_LISTEN_HTTP) ? "http:" : "tcp:";
if (family == AF_INET6) {
snprintf(ctx->socket_name, sizeof(ctx->socket_name),
"%s[%s]:%u", prefix, host, (unsigned)port);
} else {
snprintf(ctx->socket_name, sizeof(ctx->socket_name),
"%s%s:%u", prefix, host, (unsigned)port);
}
}
@@ -1705,7 +1763,7 @@ int server_get_caller(int fd, caller_identity_t *out) {
return 0;
}
/* ---- Async mine_event support ---- */
/* ---- Async nostr_mine_event support ---- */
typedef struct {
int client_fd;
@@ -1775,6 +1833,14 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
}
return -1;
}
/* Set client fd to blocking mode (the listen socket is non-blocking,
* and accept() may inherit that flag on some platforms). */
{
int cflags = fcntl(client_fd, F_GETFL, 0);
if (cflags >= 0) {
(void)fcntl(client_fd, F_SETFL, cflags & ~O_NONBLOCK);
}
}
}
if (server_get_caller(client_fd, &caller) != 0) {
@@ -1784,6 +1850,12 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
return -1;
}
/* HTTP mode: override caller kind so auth envelopes are not required
* (HTTP relies on localhost binding + policy/approval prompts). */
if (ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
caller.kind = NSIGNER_LISTEN_HTTP;
}
/*
* Bridge-source-trusted path: when the unix listener is marked as a
* trusted bridge socket (started with --bridge-source-trusted), each
@@ -1839,7 +1911,30 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
cJSON_Delete(proot);
}
if (transport_recv_framed(client_fd, &request, SERVER_MAX_MSG_SIZE) != 0) {
int is_http = (ctx->listen_mode == NSIGNER_LISTEN_HTTP);
if (is_http) {
/* HTTP mode: parse the HTTP request to get the JSON body. */
int http_rc = http_recv_request(client_fd, &request, SERVER_MAX_MSG_SIZE);
if (http_rc == 0 && request != NULL && request[0] == '\0') {
/* OPTIONS preflight — send CORS headers. */
(void)http_send_cors_preflight(client_fd);
free(request);
close(client_fd);
return 1;
}
if (http_rc != 0 || request == NULL) {
if (http_rc == -2) {
(void)http_send_error(client_fd, 405, "method_not_allowed");
} else if (http_rc == -4) {
(void)http_send_error(client_fd, 413, "payload_too_large");
} else {
(void)http_send_error(client_fd, 400, "bad_request");
}
if (client_fd != STDIN_FILENO) close(client_fd);
return 1;
}
} else if (transport_recv_framed(client_fd, &request, SERVER_MAX_MSG_SIZE) != 0) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32700,\"message\":\"parse_error\"}}");
if (response != NULL) {
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
@@ -1899,7 +1994,11 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
response = strdup(errbuf);
}
if (response != NULL) {
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
if (is_http) {
(void)http_send_response(client_fd, response);
} else {
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
}
free(response);
}
free(request);
@@ -2036,12 +2135,12 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
if (pchk == POLICY_ALLOW && !derivation_error) {
/*
* mine_event is a long-running operation (potentially seconds to minutes).
* nostr_mine_event is a long-running operation (potentially seconds to minutes).
* Spawn a detached thread so the server stays responsive. The thread
* owns the client_fd and request, and sends the response when mining
* completes. We skip the synchronous response path below.
*/
if (strcmp(method, "mine_event") == 0 && client_fd != STDIN_FILENO) {
if (strcmp(method, "nostr_mine_event") == 0 && client_fd != STDIN_FILENO) {
mine_thread_arg_t *marg = malloc(sizeof(mine_thread_arg_t));
if (marg != NULL) {
pthread_t mine_tid;
@@ -2092,7 +2191,11 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
}
if (response != NULL) {
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
if (is_http) {
(void)http_send_response(client_fd, response);
} else {
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
}
}
if (pchk == POLICY_ALLOW && policy_src == POLICY_SOURCE_PREAPPROVE) {
@@ -2135,7 +2238,9 @@ void server_stop(server_ctx_t *ctx) {
}
if (ctx->listen_fd >= 0) {
if (ctx->listen_mode == NSIGNER_LISTEN_UNIX || ctx->listen_mode == NSIGNER_LISTEN_TCP) {
if (ctx->listen_mode == NSIGNER_LISTEN_UNIX ||
ctx->listen_mode == NSIGNER_LISTEN_TCP ||
ctx->listen_mode == NSIGNER_LISTEN_HTTP) {
close(ctx->listen_fd);
}
ctx->listen_fd = -1;

View File

@@ -210,6 +210,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -219,22 +220,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -633,7 +636,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */
typedef struct {

View File

@@ -78,31 +78,31 @@ else
fi
echo
echo "[2/7] sign_event"
echo "[2/7] nostr_sign_event"
now_ts="$(date +%s)"
event_json='{"kind":1,"content":"hello from tests/test.sh","tags":[],"created_at":__TS__}'
event_json="${event_json/__TS__/${now_ts}}"
escaped_event_json="${event_json//\"/\\\"}"
req_sign="{\"id\":\"2\",\"method\":\"sign_event\",\"params\":[\"${escaped_event_json}\",{\"role\":\"main\"}]}"
req_sign="{\"id\":\"2\",\"method\":\"nostr_sign_event\",\"params\":[\"${escaped_event_json}\",{\"role\":\"main\"}]}"
resp_sign="$(run_request "$req_sign")"
echo "request : $req_sign"
echo "response: $resp_sign"
if printf '%s' "$resp_sign" | grep -q '"result"'; then
echo "[ok] sign_event returned result"
echo "[ok] nostr_sign_event returned result"
else
echo "[warn] sign_event did not return result"
echo "[warn] nostr_sign_event did not return result"
fi
echo
echo "[3/7] sign_event with nostr_index=0"
req_sign_idx="{\"id\":\"3\",\"method\":\"sign_event\",\"params\":[\"${escaped_event_json}\",{\"nostr_index\":0}]}"
echo "[3/7] nostr_sign_event with nostr_index=0"
req_sign_idx="{\"id\":\"3\",\"method\":\"nostr_sign_event\",\"params\":[\"${escaped_event_json}\",{\"nostr_index\":0}]}"
resp_sign_idx="$(run_request "$req_sign_idx")"
echo "request : $req_sign_idx"
echo "response: $resp_sign_idx"
echo
echo "[4/7] role selector error case (unknown role)"
req_bad_role='{"id":"4","method":"sign_event","params":["{}",{"role":"does_not_exist"}]}'
req_bad_role='{"id":"4","method":"nostr_nostr_sign_event","params":["{}",{"role":"does_not_exist"}]}'
resp_bad_role="$(run_request "$req_bad_role")"
echo "request : $req_bad_role"
echo "response: $resp_bad_role"
@@ -115,13 +115,13 @@ fi
echo
echo "[5/7] NIP-04 self round-trip"
if [[ -n "$pubkey" ]]; then
req_nip04_enc="{\"id\":\"5\",\"method\":\"nip04_encrypt\",\"params\":[\"${pubkey}\",\"hello_nip04_from_test_sh\"]}"
req_nip04_enc="{\"id\":\"5\",\"method\":\"nostr_nostr_nip04_encrypt\",\"params\":[\"${pubkey}\",\"hello_nip04_from_test_sh\"]}"
resp_nip04_enc="$(run_request "$req_nip04_enc")"
echo "request : $req_nip04_enc"
echo "response: $resp_nip04_enc"
cipher_nip04="$(printf '%s' "$resp_nip04_enc" | extract_result_string)"
if [[ -n "$cipher_nip04" ]]; then
req_nip04_dec="{\"id\":\"6\",\"method\":\"nip04_decrypt\",\"params\":[\"${pubkey}\",\"${cipher_nip04}\"]}"
req_nip04_dec="{\"id\":\"6\",\"method\":\"nostr_nostr_nip04_decrypt\",\"params\":[\"${pubkey}\",\"${cipher_nip04}\"]}"
resp_nip04_dec="$(run_request "$req_nip04_dec")"
echo "request : $req_nip04_dec"
echo "response: $resp_nip04_dec"
@@ -140,13 +140,13 @@ fi
echo
echo "[6/7] NIP-44 self round-trip"
if [[ -n "$pubkey" ]]; then
req_nip44_enc="{\"id\":\"7\",\"method\":\"nip44_encrypt\",\"params\":[\"${pubkey}\",\"hello_nip44_from_test_sh\"]}"
req_nip44_enc="{\"id\":\"7\",\"method\":\"nostr_nostr_nip44_encrypt\",\"params\":[\"${pubkey}\",\"hello_nip44_from_test_sh\"]}"
resp_nip44_enc="$(run_request "$req_nip44_enc")"
echo "request : $req_nip44_enc"
echo "response: $resp_nip44_enc"
cipher_nip44="$(printf '%s' "$resp_nip44_enc" | extract_result_string)"
if [[ -n "$cipher_nip44" ]]; then
req_nip44_dec="{\"id\":\"8\",\"method\":\"nip44_decrypt\",\"params\":[\"${pubkey}\",\"${cipher_nip44}\"]}"
req_nip44_dec="{\"id\":\"8\",\"method\":\"nostr_nostr_nip44_decrypt\",\"params\":[\"${pubkey}\",\"${cipher_nip44}\"]}"
resp_nip44_dec="$(run_request "$req_nip44_dec")"
echo "request : $req_nip44_dec"
echo "response: $resp_nip44_dec"

View File

@@ -74,18 +74,15 @@ role_curve_t role_curve_from_str(const char *s);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);
@@ -367,12 +364,20 @@ int main(void) {
enforce_verb_algorithm(VERB_DERIVE_SHARED, CRYPTO_ALG_X25519) == ENFORCE_OK);
check("enforce derive_shared_secret + ed25519 -> ALGORITHM err",
enforce_verb_algorithm(VERB_DERIVE_SHARED, CRYPTO_ALG_ED25519) == ENFORCE_ERR_ALGORITHM);
check("enforce derive + secp256k1 -> OK",
enforce_verb_algorithm(VERB_DERIVE, CRYPTO_ALG_SECP256K1) == ENFORCE_OK);
check("enforce derive + ed25519 -> ALGORITHM err",
enforce_verb_algorithm(VERB_DERIVE, CRYPTO_ALG_ED25519) == ENFORCE_ERR_ALGORITHM);
check("enforce derive + x25519 -> ALGORITHM err",
enforce_verb_algorithm(VERB_DERIVE, CRYPTO_ALG_X25519) == ENFORCE_ERR_ALGORITHM);
check("enforce derive + ml-kem-768 -> ALGORITHM err",
enforce_verb_algorithm(VERB_DERIVE, CRYPTO_ALG_ML_KEM_768) == ENFORCE_ERR_ALGORITHM);
check("enforce get_public_key + any alg -> OK",
enforce_verb_algorithm(VERB_GET_PUBLIC_KEY, CRYPTO_ALG_ED25519) == ENFORCE_OK);
check("enforce sign_event + secp256k1 -> OK",
enforce_verb_algorithm(VERB_SIGN_EVENT, CRYPTO_ALG_SECP256K1) == ENFORCE_OK);
check("enforce sign_event + ed25519 -> ALGORITHM err",
enforce_verb_algorithm(VERB_SIGN_EVENT, CRYPTO_ALG_ED25519) == ENFORCE_ERR_ALGORITHM);
check("enforce nostr_sign_event + secp256k1 -> OK",
enforce_verb_algorithm(VERB_NOSTR_SIGN_EVENT, CRYPTO_ALG_SECP256K1) == ENFORCE_OK);
check("enforce nostr_sign_event + ed25519 -> ALGORITHM err",
enforce_verb_algorithm(VERB_NOSTR_SIGN_EVENT, CRYPTO_ALG_ED25519) == ENFORCE_ERR_ALGORITHM);
/* ---- get_public_key with algorithm parameter ---- */
resp = dispatcher_handle_request(&g_dispatcher,
@@ -588,51 +593,51 @@ int main(void) {
resp_has(resp, "algorithm_not_supported_for_verb") || resp_has(resp, "\"code\":1010"));
free(resp);
/* ---- Old verb alias: sign_data with algorithm=ed25519 ---- */
/* ---- Verb: sign with algorithm=ed25519 ---- */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"alias1\",\"method\":\"sign_data\",\"params\":[\"68656c6c6f\",{\"algorithm\":\"ed25519\",\"index\":0}]}");
check("sign_data alias with algorithm=ed25519 returns result",
"{\"id\":\"alias1\",\"method\":\"sign\",\"params\":[\"68656c6c6f\",{\"algorithm\":\"ed25519\",\"index\":0}]}");
check("sign with algorithm=ed25519 returns result",
resp_has(resp, "\"result\""));
check("sign_data alias has signature",
check("sign has signature",
resp_has(resp, "signature"));
free(resp);
/* ---- Old verb alias: ssh_sign with algorithm=ed25519 ---- */
/* ---- Verb: sign (ssh) with algorithm=ed25519 ---- */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"alias2\",\"method\":\"ssh_sign\",\"params\":[\"68656c6c6f\",{\"algorithm\":\"ed25519\",\"index\":0}]}");
check("ssh_sign alias with algorithm=ed25519 returns result",
"{\"id\":\"alias2\",\"method\":\"sign\",\"params\":[\"68656c6c6f\",{\"algorithm\":\"ed25519\",\"index\":0}]}");
check("sign with algorithm=ed25519 returns result",
resp_has(resp, "\"result\""));
free(resp);
/* ---- Old verb alias: kem_encapsulate with algorithm=ml-kem-768 ---- */
/* ---- Verb: encapsulate with algorithm=ml-kem-768 ---- */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"gpk5\",\"method\":\"get_public_key\",\"params\":[{\"algorithm\":\"ml-kem-768\",\"index\":1}]}");
pub_hex = extract_result_field(resp, "public_key");
free(resp);
if (pub_hex) {
snprintf(request, sizeof(request),
"{\"id\":\"alias3\",\"method\":\"kem_encapsulate\",\"params\":[\"%s\",{\"algorithm\":\"ml-kem-768\"}]}",
"{\"id\":\"alias3\",\"method\":\"encapsulate\",\"params\":[\"%s\",{\"algorithm\":\"ml-kem-768\"}]}",
pub_hex);
resp = dispatcher_handle_request(&g_dispatcher, request);
check("kem_encapsulate alias with algorithm=ml-kem-768 returns result",
check("encapsulate with algorithm=ml-kem-768 returns result",
resp_has(resp, "\"result\""));
check("kem_encapsulate alias has ciphertext",
check("encapsulate has ciphertext",
resp_has(resp, "ciphertext"));
free(resp);
free(pub_hex);
}
/* ---- Backward compat: sign_event with role still works ---- */
/* ---- nostr_sign_event with role still works ---- */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"bc1\",\"method\":\"sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello\\\",\\\"tags\\\":[]}\",{\"role\":\"main\"}]}");
check("sign_event with role=main (backward compat) returns signed event",
"{\"id\":\"bc1\",\"method\":\"nostr_sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello\\\",\\\"tags\\\":[]}\",{\"role\":\"main\"}]}");
check("nostr_sign_event with role=main returns signed event",
resp_has(resp, "pubkey") && resp_has(resp, "sig"));
free(resp);
/* ---- Backward compat: get_public_key with role still works ---- */
/* ---- nostr_get_public_key with role still works ---- */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"bc2\",\"method\":\"get_public_key\",\"params\":[{\"role\":\"main\"}]}");
check("get_public_key with role=main (backward compat) returns hex",
"{\"id\":\"bc2\",\"method\":\"nostr_get_public_key\",\"params\":[{\"role\":\"main\"}]}");
check("nostr_get_public_key with role=main returns hex",
resp_has(resp, "\"result\""));
free(resp);
@@ -682,6 +687,96 @@ int main(void) {
check("policy_check_algorithm: wrong caller -> NO_MATCH", rc == POLICY_NO_MATCH);
}
/* ---- derive: HMAC-SHA256(privkey, data) — secp256k1 ---- */
/* Successful call returns a 64-hex digest. */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dv1\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\",\"index\":0}]}");
check("derive secp256k1 returns result", resp_has(resp, "\"result\""));
check("derive secp256k1 has digest field", resp_has(resp, "digest"));
check("derive secp256k1 has algorithm secp256k1", resp_has(resp, "secp256k1"));
{
char *digest_hex = extract_result_field(resp, "digest");
check("derive secp256k1 digest is 64 hex chars",
digest_hex != NULL && strlen(digest_hex) == 64);
free(digest_hex);
}
free(resp);
/* Determinism: same input -> same digest. */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dv2\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\",\"index\":0}]}");
{
char *digest2 = extract_result_field(resp, "digest");
/* Re-run first call to compare (deterministic). */
char *resp_a = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dv1b\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\",\"index\":0}]}");
char *digest_a = extract_result_field(resp_a, "digest");
check("derive determinism: same input -> same digest",
digest2 != NULL && digest_a != NULL && strcmp(digest2, digest_a) == 0);
free(digest2); free(digest_a); free(resp_a);
}
free(resp);
/* Opaqueness: different inputs -> different digests. */
{
char *resp_b = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dv3\",\"method\":\"derive\",\"params\":[\"other-data\",{\"algorithm\":\"secp256k1\",\"index\":0}]}");
char *resp_c = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dv1c\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\",\"index\":0}]}");
char *dig_b = extract_result_field(resp_b, "digest");
char *dig_c = extract_result_field(resp_c, "digest");
check("derive opaqueness: different inputs -> different digests",
dig_b != NULL && dig_c != NULL && strcmp(dig_b, dig_c) != 0);
free(dig_b); free(dig_c); free(resp_b); free(resp_c);
}
/* Cross-check against nostr_hmac_sha256 with the derived private key. */
{
const unsigned char *priv = crypto_get_private_key(&g_key_store, 0);
unsigned char ref_mac[32];
char ref_hex[65];
char *resp_ref = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dvref\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\",\"index\":0}]}");
char *got_hex = extract_result_field(resp_ref, "digest");
if (priv != NULL && nostr_hmac_sha256(priv, 32,
(const unsigned char *)"test-data", strlen("test-data"),
ref_mac) == 0) {
nostr_bytes_to_hex(ref_mac, 32, ref_hex);
check("derive matches reference HMAC-SHA256(privkey, data)",
got_hex != NULL && strcmp(got_hex, ref_hex) == 0);
} else {
check("derive reference HMAC computation available", 0);
}
free(got_hex); free(resp_ref);
}
/* index is required: omitting it returns missing_index. */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dv4\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\"}]}");
check("derive without index returns missing_index",
resp_has(resp, "missing_index"));
free(resp);
/* Non-secp256k1 algorithm is rejected. */
resp = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dv5\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"ed25519\",\"index\":0}]}");
check("derive ed25519 returns algorithm error",
resp_has(resp, "algorithm_not_supported_for_verb") || resp_has(resp, "\"code\":1010"));
free(resp);
/* Different index -> different digest (different privkey). */
{
char *r0 = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dvi0\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\",\"index\":0}]}");
char *r1 = dispatcher_handle_request(&g_dispatcher,
"{\"id\":\"dvi1\",\"method\":\"derive\",\"params\":[\"test-data\",{\"algorithm\":\"secp256k1\",\"index\":1}]}");
char *d0 = extract_result_field(r0, "digest");
char *d1 = extract_result_field(r1, "digest");
check("derive different index -> different digest",
d0 != NULL && d1 != NULL && strcmp(d0, d1) != 0);
free(d0); free(d1); free(r0); free(r1);
}
/* ---- Cleanup ---- */
crypto_wipe(&g_key_store);
alg_key_cache_wipe(&g_alg_key_cache);

View File

@@ -110,7 +110,7 @@ int main(void) {
check_condition("nostr_crypto_init", nostr_crypto_init() == 0);
auth_nonce_cache_init(&cache);
req = make_request_json(privkey, "req-1", "sign_event", (int)time(NULL));
req = make_request_json(privkey, "req-1", "nostr_sign_event", (int)time(NULL));
check_condition("build valid request", req != NULL);
if (req != NULL) {
check_condition("valid auth envelope accepted",
@@ -150,7 +150,7 @@ int main(void) {
check_condition("parse second request", root != NULL);
method_item = (root != NULL) ? cJSON_GetObjectItemCaseSensitive(root, "method") : NULL;
if (method_item != NULL) {
cJSON_SetValuestring(method_item, "sign_event");
cJSON_SetValuestring(method_item, "nostr_sign_event");
}
tampered = (root != NULL) ? cJSON_PrintUnformatted(root) : NULL;
cJSON_Delete(root);
@@ -172,7 +172,7 @@ int main(void) {
free(req);
}
req = make_request_json(privkey, "req-3", "sign_event", (int)time(NULL));
req = make_request_json(privkey, "req-3", "nostr_sign_event", (int)time(NULL));
check_condition("build replay request", req != NULL);
if (req != NULL) {
int first_ok = auth_envelope_verify_request(req,
@@ -198,7 +198,7 @@ int main(void) {
free(req);
}
req = make_request_json(privkey, "req-4", "sign_event", (int)time(NULL) - 1000);
req = make_request_json(privkey, "req-4", "nostr_sign_event", (int)time(NULL) - 1000);
check_condition("build stale request", req != NULL);
if (req != NULL) {
check_condition("stale request rejected",

View File

@@ -211,6 +211,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -220,22 +221,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -813,51 +816,51 @@ int main(void) {
}
}
/* 1. Valid get_public_key no selector -> default main, real pubkey */
/* 1. Valid nostr_get_public_key no selector -> default main, real pubkey */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}");
check_condition("get_public_key default role returns derived hex",
"{\"id\":\"1\",\"method\":\"nostr_get_public_key\",\"params\":[\"\"]}");
check_condition("nostr_get_public_key default role returns derived hex",
response_has(resp, "\"id\":\"1\"") && !response_has(resp, "not_yet_derived") && response_has(resp, "\"result\":\""));
free(resp);
/* 2. sign_event with role main */
/* 2. nostr_sign_event with role main */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"2\",\"method\":\"sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello\\\",\\\"tags\\\":[]}\",{\"role\":\"main\"}]}");
check_condition("sign_event with role=main returns signed event",
"{\"id\":\"2\",\"method\":\"nostr_sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello\\\",\\\"tags\\\":[]}\",{\"role\":\"main\"}]}");
check_condition("nostr_sign_event with role=main returns signed event",
response_has(resp, "\"id\":\"2\"") && response_has(resp, "pubkey") && response_has(resp, "sig") && response_has(resp, "created_at"));
free(resp);
/* 3. sign_event with nostr_index 0 */
/* 3. nostr_sign_event with nostr_index 0 */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"3\",\"method\":\"sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello2\\\",\\\"tags\\\":[]}\",{\"nostr_index\":0}]}");
check_condition("sign_event with nostr_index=0 returns signed event",
"{\"id\":\"3\",\"method\":\"nostr_sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello2\\\",\\\"tags\\\":[]}\",{\"nostr_index\":0}]}");
check_condition("nostr_sign_event with nostr_index=0 returns signed event",
response_has(resp, "\"id\":\"3\"") && response_has(resp, "pubkey") && response_has(resp, "sig") && response_has(resp, "created_at"));
free(resp);
/* 4. ambiguous selector role + nostr_index */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"4\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"main\",\"nostr_index\":0}]}");
"{\"id\":\"4\",\"method\":\"nostr_sign_event\",\"params\":[\"{}\",{\"role\":\"main\",\"nostr_index\":0}]}");
check_condition("ambiguous selector returns 1001",
response_has(resp, "\"id\":\"4\"") && response_has(resp, "\"code\":1001"));
free(resp);
/* 5. unknown role */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"5\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"nonexistent\"}]}");
"{\"id\":\"5\",\"method\":\"nostr_sign_event\",\"params\":[\"{}\",{\"role\":\"nonexistent\"}]}");
check_condition("unknown role returns 1002 with unknown_role",
response_has(resp, "\"id\":\"5\"") && response_has(resp, "\"code\":1002") && response_has(resp, "unknown_role"));
free(resp);
/* 6. purpose mismatch: sign_event against ssh role */
/* 6. purpose mismatch: nostr_sign_event against ssh role */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"6\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"ssh_key\"}]}");
"{\"id\":\"6\",\"method\":\"nostr_sign_event\",\"params\":[\"{}\",{\"role\":\"ssh_key\"}]}");
check_condition("purpose mismatch returns 1004",
response_has(resp, "\"id\":\"6\"") && response_has(resp, "\"code\":1004"));
free(resp);
/* 7. invalid JSON */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"7\",\"method\":\"sign_event\",\"params\":[\"{}\"]");
"{\"id\":\"7\",\"method\":\"nostr_sign_event\",\"params\":[\"{}\"]");
check_condition("invalid JSON returns -32700",
response_has(resp, "\"code\":-32700"));
free(resp);
@@ -872,7 +875,7 @@ int main(void) {
/* 9. mnemonic not loaded */
mnemonic_unload(&mnemonic);
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"9\",\"method\":\"get_public_key\",\"params\":[\"\"]}");
"{\"id\":\"9\",\"method\":\"nostr_get_public_key\",\"params\":[\"\"]}");
check_condition("mnemonic not loaded returns 1006",
response_has(resp, "\"id\":\"9\"") && response_has(resp, "\"code\":1006"));
free(resp);

View File

@@ -9,11 +9,11 @@
* - x25519 ECDH roundtrip: two sides derive matching shared secret
* - x25519 determinism: same seed -> same keypair
* - SLIP-0010 derivation: deterministic seed from mnemonic+path
* - Integration: derive ed25519 key via crypto_derive_one, sign_data via
* - Integration: derive ed25519 key via crypto_derive_one, sign via
* dispatcher, verify signature
* - Enforcement: sign_data allowed on ssh+ed25519, rejected on nostr+secp256k1
* - Enforcement: ssh_sign allowed on ssh+ed25519
* - Enforcement: kem_encapsulate/decapsulate rejected on ssh/ed25519
* - Enforcement: sign allowed on ssh+ed25519, rejected on nostr+secp256k1
* - Enforcement: sign allowed on ssh+ed25519
* - Enforcement: encapsulate/decapsulate rejected on ssh/ed25519
*/
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
#include <stddef.h>
@@ -150,19 +150,22 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_PURPOSE -1
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define ENFORCE_ERR_ALGORITHM -4
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#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_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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);
@@ -692,7 +695,7 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Integration: sign_data via dispatcher ---- */
/* ---- Integration: sign via dispatcher ---- */
{
role_table_t table;
role_entry_t ssh_role;
@@ -717,27 +720,27 @@ int main(void) {
dispatcher_init(&dispatcher, &table, &mnemonic_state, &key_store, &g_alg_key_cache);
/* sign_data request */
/* sign request */
snprintf(request, sizeof(request),
"{\"id\":\"test1\",\"method\":\"sign_data\",\"params\":[\"%s\",{\"role\":\"ssh_main\"}]}",
"{\"id\":\"test1\",\"method\":\"sign\",\"params\":[\"%s\",{\"algorithm\":\"ed25519\",\"index\":0}]}",
msg_hex);
resp = dispatcher_handle_request(&dispatcher, request);
check_condition("sign_data via dispatcher returns result",
check_condition("sign via dispatcher returns result",
resp != NULL && response_has(resp, "\"result\""));
check_condition("sign_data result contains signature",
check_condition("sign result contains signature",
resp != NULL && response_has(resp, "signature"));
check_condition("sign_data result contains algorithm ed25519",
check_condition("sign result contains algorithm ed25519",
resp != NULL && response_has(resp, "ed25519"));
free(resp);
/* ssh_sign request */
/* sign request */
snprintf(request, sizeof(request),
"{\"id\":\"test2\",\"method\":\"ssh_sign\",\"params\":[\"%s\",{\"role\":\"ssh_main\"}]}",
"{\"id\":\"test2\",\"method\":\"sign\",\"params\":[\"%s\",{\"algorithm\":\"ed25519\",\"index\":0}]}",
msg_hex);
resp = dispatcher_handle_request(&dispatcher, request);
check_condition("ssh_sign via dispatcher returns result",
check_condition("sign via dispatcher returns result",
resp != NULL && response_has(resp, "\"result\""));
check_condition("ssh_sign result contains signature",
check_condition("sign result contains signature",
resp != NULL && response_has(resp, "signature"));
free(resp);
@@ -745,38 +748,39 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Enforcement: sign_data on ssh+ed25519 ---- */
/* ---- Enforcement: sign on ssh+ed25519 ---- */
{
role_entry_t ssh_ed = make_ssh_ed25519_entry("ssh", 0);
role_entry_t nostr_secp = make_nostr_secp_entry("nostr", 0);
role_entry_t pq_kem = make_pq_kem_entry("kem", 0);
check_condition("enforce sign_data + ssh/ed25519 -> OK",
enforce_verb_role(VERB_SIGN_DATA, &ssh_ed) == ENFORCE_OK);
/* sign/verify are algorithm-based now: checked via enforce_verb_algorithm(). */
check_condition("enforce sign + ed25519 -> OK",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_ED25519) == ENFORCE_OK);
check_condition("enforce sign_data + nostr/secp256k1 -> PURPOSE err",
enforce_verb_role(VERB_SIGN_DATA, &nostr_secp) == ENFORCE_ERR_PURPOSE);
check_condition("enforce sign + secp256k1 -> OK",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_SECP256K1) == ENFORCE_OK);
check_condition("enforce ssh_sign + ssh/ed25519 -> OK",
enforce_verb_role(VERB_SSH_SIGN, &ssh_ed) == ENFORCE_OK);
check_condition("enforce verify + ed25519 -> OK",
enforce_verb_algorithm(VERB_VERIFY, CRYPTO_ALG_ED25519) == ENFORCE_OK);
check_condition("enforce ssh_sign + nostr/secp256k1 -> PURPOSE err",
enforce_verb_role(VERB_SSH_SIGN, &nostr_secp) == ENFORCE_ERR_PURPOSE);
check_condition("enforce sign + ml-kem-768 -> ALGORITHM err",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_ML_KEM_768) == ENFORCE_ERR_ALGORITHM);
check_condition("enforce sign_event + ssh/ed25519 -> PURPOSE err",
enforce_verb_role(VERB_SIGN_EVENT, &ssh_ed) == ENFORCE_ERR_PURPOSE);
check_condition("enforce nostr_sign_event + ssh/ed25519 -> PURPOSE err",
enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &ssh_ed) == ENFORCE_ERR_PURPOSE);
check_condition("enforce kem_encapsulate + pq-kem/ml-kem-768 -> OK",
enforce_verb_role(VERB_KEM_ENCAPS, &pq_kem) == ENFORCE_OK);
check_condition("enforce encapsulate + ml-kem-768 -> OK",
enforce_verb_algorithm(VERB_ENCAPSULATE, CRYPTO_ALG_ML_KEM_768) == ENFORCE_OK);
check_condition("enforce kem_decapsulate + pq-kem/ml-kem-768 -> OK",
enforce_verb_role(VERB_KEM_DECAPS, &pq_kem) == ENFORCE_OK);
check_condition("enforce decapsulate + ml-kem-768 -> OK",
enforce_verb_algorithm(VERB_DECAPSULATE, CRYPTO_ALG_ML_KEM_768) == ENFORCE_OK);
check_condition("enforce kem_encapsulate + ssh/ed25519 -> PURPOSE err",
enforce_verb_role(VERB_KEM_ENCAPS, &ssh_ed) == ENFORCE_ERR_PURPOSE);
check_condition("enforce encapsulate + ed25519 -> ALGORITHM err",
enforce_verb_algorithm(VERB_ENCAPSULATE, CRYPTO_ALG_ED25519) == ENFORCE_ERR_ALGORITHM);
}
/* ---- Dispatcher: kem_encapsulate with invalid pubkey length ---- */
/* ---- Dispatcher: encapsulate with invalid pubkey length ---- */
{
role_table_t table;
role_entry_t kem_role;
@@ -784,7 +788,7 @@ int main(void) {
key_store_t key_store;
dispatcher_ctx_t dispatcher;
char *resp;
const char *request = "{\"id\":\"k1\",\"method\":\"kem_encapsulate\",\"params\":[\"00\",{\"role\":\"kem_main\"}]}";
const char *request = "{\"id\":\"k1\",\"method\":\"encapsulate\",\"params\":[\"00\",{\"algorithm\":\"ml-kem-768\",\"index\":0}]}";
role_table_init(&table);
kem_role = make_pq_kem_entry("kem_main", 0);
@@ -798,11 +802,11 @@ int main(void) {
alg_key_cache_init(&g_alg_key_cache);
dispatcher_init(&dispatcher, &table, &mnemonic_state, &key_store, &g_alg_key_cache);
/* kem_encapsulate is now implemented (Phase 5). With a too-short
/* encapsulate is now implemented (Phase 5). With a too-short
* pubkey hex ("00" = 1 byte, but ML-KEM-768 needs 1184 bytes),
* the dispatcher should return an invalid_pubkey_length error. */
resp = dispatcher_handle_request(&dispatcher, request);
check_condition("kem_encapsulate with short pubkey returns error",
check_condition("encapsulate with short pubkey returns error",
resp != NULL && response_has(resp, "\"error\""));
free(resp);
@@ -810,7 +814,7 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Dispatcher: verify_signature roundtrip ---- */
/* ---- Dispatcher: verify roundtrip ---- */
{
role_table_t table;
role_entry_t ssh_role;
@@ -839,10 +843,10 @@ int main(void) {
/* Sign */
snprintf(sign_req, sizeof(sign_req),
"{\"id\":\"s1\",\"method\":\"sign_data\",\"params\":[\"%s\",{\"role\":\"ssh_main\"}]}",
"{\"id\":\"s1\",\"method\":\"sign\",\"params\":[\"%s\",{\"algorithm\":\"ed25519\",\"index\":0}]}",
msg_hex);
sign_resp = dispatcher_handle_request(&dispatcher, sign_req);
check_condition("verify roundtrip: sign_data succeeds", sign_resp != NULL);
check_condition("verify roundtrip: sign succeeds", sign_resp != NULL);
/* Extract signature from result */
if (sign_resp != NULL) {
@@ -867,28 +871,28 @@ int main(void) {
/* Verify */
if (sig_str != NULL) {
snprintf(verify_req, sizeof(verify_req),
"{\"id\":\"v1\",\"method\":\"verify_signature\",\"params\":[\"%s\",\"%s\",{\"role\":\"ssh_main\"}]}",
"{\"id\":\"v1\",\"method\":\"verify\",\"params\":[\"%s\",\"%s\",{\"algorithm\":\"ed25519\",\"index\":0}]}",
msg_hex, sig_str);
verify_resp = dispatcher_handle_request(&dispatcher, verify_req);
check_condition("verify_signature returns valid:true",
check_condition("verify returns valid:true",
verify_resp != NULL && response_has(verify_resp, "valid") &&
response_has(verify_resp, "true"));
free(verify_resp);
/* Verify with wrong message */
snprintf(verify_req, sizeof(verify_req),
"{\"id\":\"v2\",\"method\":\"verify_signature\",\"params\":[\"00ff\",\"%s\",{\"role\":\"ssh_main\"}]}",
"{\"id\":\"v2\",\"method\":\"verify\",\"params\":[\"00ff\",\"%s\",{\"algorithm\":\"ed25519\",\"index\":0}]}",
sig_str);
verify_resp = dispatcher_handle_request(&dispatcher, verify_req);
check_condition("verify_signature wrong msg returns valid:false",
check_condition("verify wrong msg returns valid:false",
verify_resp != NULL && response_has(verify_resp, "valid") &&
response_has(verify_resp, "false"));
free(verify_resp);
free((void *)sig_str);
} else {
check_condition("verify_signature returns valid:true", 0);
check_condition("verify_signature wrong msg returns valid:false", 0);
check_condition("verify returns valid:true", 0);
check_condition("verify wrong msg returns valid:false", 0);
}
free(sign_resp);

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -739,43 +742,43 @@ int main(void) {
role_entry_t age_x = make_role("age", "x25519");
check_condition(
"sign_event + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_SIGN_EVENT, &nostr_secp) == ENFORCE_OK
"nostr_sign_event + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &nostr_secp) == ENFORCE_OK
);
check_condition(
"get_public_key + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_GET_PUBLIC_KEY, &nostr_secp) == ENFORCE_OK
"nostr_get_public_key + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NOSTR_GET_PUBLIC_KEY, &nostr_secp) == ENFORCE_OK
);
check_condition(
"nip44_encrypt + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NIP44_ENCRYPT, &nostr_secp) == ENFORCE_OK
"nostr_nip44_encrypt + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NOSTR_NIP44_ENCRYPT, &nostr_secp) == ENFORCE_OK
);
check_condition(
"nip44_decrypt + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NIP44_DECRYPT, &nostr_secp) == ENFORCE_OK
"nostr_nip44_decrypt + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NOSTR_NIP44_DECRYPT, &nostr_secp) == ENFORCE_OK
);
check_condition(
"nip04_encrypt + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NIP04_ENCRYPT, &nostr_secp) == ENFORCE_OK
"nostr_nip04_encrypt + nostr/secp256k1 -> ENFORCE_OK",
enforce_verb_role(VERB_NOSTR_NIP04_ENCRYPT, &nostr_secp) == ENFORCE_OK
);
check_condition(
"sign_event + bitcoin/secp256k1 -> ENFORCE_ERR_PURPOSE",
enforce_verb_role(VERB_SIGN_EVENT, &bitcoin_secp) == ENFORCE_ERR_PURPOSE
"nostr_sign_event + bitcoin/secp256k1 -> ENFORCE_ERR_PURPOSE",
enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &bitcoin_secp) == ENFORCE_ERR_PURPOSE
);
check_condition(
"sign_event + nostr/ed25519 -> ENFORCE_ERR_CURVE",
enforce_verb_role(VERB_SIGN_EVENT, &nostr_ed) == ENFORCE_ERR_CURVE
"nostr_sign_event + nostr/ed25519 -> ENFORCE_ERR_CURVE",
enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &nostr_ed) == ENFORCE_ERR_CURVE
);
check_condition(
"sign_event + ssh/ed25519 -> ENFORCE_ERR_PURPOSE",
enforce_verb_role(VERB_SIGN_EVENT, &ssh_ed) == ENFORCE_ERR_PURPOSE
"nostr_sign_event + ssh/ed25519 -> ENFORCE_ERR_PURPOSE",
enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &ssh_ed) == ENFORCE_ERR_PURPOSE
);
check_condition(
@@ -784,8 +787,8 @@ int main(void) {
);
check_condition(
"nip44_encrypt + age/x25519 -> ENFORCE_ERR_PURPOSE",
enforce_verb_role(VERB_NIP44_ENCRYPT, &age_x) == ENFORCE_ERR_PURPOSE
"nostr_nip44_encrypt + age/x25519 -> ENFORCE_ERR_PURPOSE",
enforce_verb_role(VERB_NOSTR_NIP44_ENCRYPT, &age_x) == ENFORCE_ERR_PURPOSE
);
printf("%d/10 tests passed\n", g_passes);

View File

@@ -210,6 +210,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -219,22 +220,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -1001,13 +1004,13 @@ int main(void) {
nsigner_client_free(c);
c = NULL;
/* sign_event (fresh connection) */
/* nostr_sign_event (fresh connection) */
t = nsigner_transport_open_unix(SOCKET_NAME_A, 5000);
if (t != NULL) {
c = nsigner_client_new(t);
if (c == NULL) {
t->close(t);
check_condition("connect signer socket for sign_event", 0);
check_condition("connect signer socket for nostr_sign_event", 0);
} else {
params = cJSON_CreateArray();
if (params != NULL) {
@@ -1018,10 +1021,10 @@ int main(void) {
cJSON_AddItemToArray(params, opts);
opts = NULL;
}
if (nsigner_client_call(c, "sign_event", params, &se_result) == NOSTR_SUCCESS) {
check_condition("sign_event has result", se_result != NULL);
if (nsigner_client_call(c, "nostr_sign_event", params, &se_result) == NOSTR_SUCCESS) {
check_condition("nostr_sign_event has result", se_result != NULL);
} else {
check_condition("sign_event request roundtrip", 0);
check_condition("nostr_sign_event request roundtrip", 0);
}
params = NULL; /* nsigner_client_call took ownership */
cJSON_Delete(se_result);
@@ -1029,7 +1032,7 @@ int main(void) {
}
}
} else {
check_condition("connect signer socket for sign_event", 0);
check_condition("connect signer socket for nostr_sign_event", 0);
}
cJSON_Delete(params);
@@ -1052,7 +1055,7 @@ int main(void) {
if (p) { p += 10; strncpy(pub_a, p, 64); pub_a[64]='\0'; }
check_condition("single-instance public key request", pub_a[0] != '\0');
snprintf(req, sizeof(req), "{\"id\":\"5\",\"method\":\"nip04_encrypt\",\"params\":[\"%s\",\"hello_nip04\"]}", pub_a);
snprintf(req, sizeof(req), "{\"id\":\"5\",\"method\":\"nostr_nip04_encrypt\",\"params\":[\"%s\",\"hello_nip04\"]}", pub_a);
free(resp_a); resp_a = NULL;
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
p = strstr(resp_a, "\"result\":\"");
@@ -1062,7 +1065,7 @@ int main(void) {
while (p[i] && p[i] != '\"' && i < sizeof(cipher)-1) { cipher[i]=p[i]; i++; }
cipher[i]='\0';
}
snprintf(req, sizeof(req), "{\"id\":\"6\",\"method\":\"nip04_decrypt\",\"params\":[\"%s\",\"%s\"]}", pub_a, cipher);
snprintf(req, sizeof(req), "{\"id\":\"6\",\"method\":\"nostr_nip04_decrypt\",\"params\":[\"%s\",\"%s\"]}", pub_a, cipher);
free(resp_a); resp_a = NULL;
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
check_condition("nip04 round-trip plaintext recovered", strstr(resp_a, "hello_nip04") != NULL);
@@ -1074,7 +1077,7 @@ int main(void) {
}
memset(cipher, 0, sizeof(cipher));
snprintf(req, sizeof(req), "{\"id\":\"7\",\"method\":\"nip44_encrypt\",\"params\":[\"%s\",\"hello_nip44\"]}", pub_a);
snprintf(req, sizeof(req), "{\"id\":\"7\",\"method\":\"nostr_nip44_encrypt\",\"params\":[\"%s\",\"hello_nip44\"]}", pub_a);
free(resp_a); resp_a = NULL;
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
p = strstr(resp_a, "\"result\":\"");
@@ -1084,7 +1087,7 @@ int main(void) {
while (p[i] && p[i] != '\"' && i < sizeof(cipher)-1) { cipher[i]=p[i]; i++; }
cipher[i]='\0';
}
snprintf(req, sizeof(req), "{\"id\":\"8\",\"method\":\"nip44_decrypt\",\"params\":[\"%s\",\"%s\"]}", pub_a, cipher);
snprintf(req, sizeof(req), "{\"id\":\"8\",\"method\":\"nostr_nip44_decrypt\",\"params\":[\"%s\",\"%s\"]}", pub_a, cipher);
free(resp_a); resp_a = NULL;
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
check_condition("nip44 round-trip plaintext recovered", strstr(resp_a, "hello_nip44") != NULL);

View File

@@ -1,4 +1,4 @@
/* Test for mine_event verb (NIP-13 Proof-of-Work) */
/* Test for nostr_mine_event verb (NIP-13 Proof-of-Work) */
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
#include <stddef.h>
#include <stdint.h>
@@ -126,18 +126,14 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);
@@ -497,7 +493,7 @@ static int response_has(const char *response, const char *needle) {
return (response != NULL && needle != NULL && strstr(response, needle) != NULL);
}
/* Parse a dispatcher response and extract the result object (for mine_event).
/* Parse a dispatcher response and extract the result object (for nostr_mine_event).
* Returns a newly-allocated cJSON result object, or NULL on failure.
* Caller must delete the returned object. */
static cJSON *extract_result_object(const char *response) {
@@ -580,67 +576,67 @@ int main(void) {
derived = crypto_derive_all(&key_store, &table, &mnemonic);
check_condition("crypto_derive_all derives at least one key", derived >= 1);
/* 1. mine_event with low difficulty (2) and short timeout (5 sec) — should reach target */
/* 1. nostr_mine_event with low difficulty (2) and short timeout (5 sec) — should reach target */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"1\",\"method\":\"mine_event\",\"params\":["
"{\"id\":\"1\",\"method\":\"nostr_mine_event\",\"params\":["
"\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello pow\\\",\\\"tags\\\":[]}\","
"{\"difficulty\":2,\"threads\":2,\"timeout_sec\":5}]}");
check_condition("mine_event low difficulty returns result object",
check_condition("nostr_mine_event low difficulty returns result object",
response_has(resp, "\"id\":\"1\"") && response_has(resp, "\"result\"") && response_has(resp, "achieved_difficulty"));
check_condition("mine_event low difficulty returns signed event with nonce tag",
check_condition("nostr_mine_event low difficulty returns signed event with nonce tag",
response_has(resp, "nonce") && response_has(resp, "sig") && response_has(resp, "pubkey"));
check_condition("mine_event low difficulty target_reached is true",
check_condition("nostr_mine_event low difficulty target_reached is true",
check_target_reached(resp, 1));
free(resp);
/* 2. mine_event with high difficulty (30) and short timeout (2 sec) — should NOT reach target */
/* 2. nostr_mine_event with high difficulty (30) and short timeout (2 sec) — should NOT reach target */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"2\",\"method\":\"mine_event\",\"params\":["
"{\"id\":\"2\",\"method\":\"nostr_mine_event\",\"params\":["
"\"{\\\"kind\\\":1,\\\"content\\\":\\\"hard pow\\\",\\\"tags\\\":[]}\","
"{\"difficulty\":30,\"threads\":4,\"timeout_sec\":2}]}");
check_condition("mine_event high difficulty returns result object",
check_condition("nostr_mine_event high difficulty returns result object",
response_has(resp, "\"id\":\"2\"") && response_has(resp, "\"result\"") && response_has(resp, "achieved_difficulty"));
check_condition("mine_event high difficulty target_reached is false",
check_condition("nostr_mine_event high difficulty target_reached is false",
check_target_reached(resp, 0));
check_condition("mine_event high difficulty still returns a signed event",
check_condition("nostr_mine_event high difficulty still returns a signed event",
response_has(resp, "nonce") && response_has(resp, "sig"));
free(resp);
/* 3. mine_event with only timeout (no difficulty) — should mine for full duration */
/* 3. nostr_mine_event with only timeout (no difficulty) — should mine for full duration */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"3\",\"method\":\"mine_event\",\"params\":["
"{\"id\":\"3\",\"method\":\"nostr_mine_event\",\"params\":["
"\"{\\\"kind\\\":1,\\\"content\\\":\\\"timeout only\\\",\\\"tags\\\":[]}\","
"{\"threads\":2,\"timeout_sec\":2}]}");
check_condition("mine_event timeout-only returns result",
check_condition("nostr_mine_event timeout-only returns result",
response_has(resp, "\"id\":\"3\"") && response_has(resp, "\"result\"") && response_has(resp, "achieved_difficulty"));
check_condition("mine_event timeout-only target_reached is false (no target set)",
check_condition("nostr_mine_event timeout-only target_reached is false (no target set)",
check_target_reached(resp, 0));
free(resp);
/* 4. mine_event with neither difficulty nor timeout — should error */
/* 4. nostr_mine_event with neither difficulty nor timeout — should error */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"4\",\"method\":\"mine_event\",\"params\":["
"{\"id\":\"4\",\"method\":\"nostr_mine_event\",\"params\":["
"\"{\\\"kind\\\":1,\\\"content\\\":\\\"no params\\\",\\\"tags\\\":[]}\","
"{\"threads\":2}]}");
check_condition("mine_event with no termination condition returns 1007",
check_condition("nostr_mine_event with no termination condition returns 1007",
response_has(resp, "\"id\":\"4\"") && response_has(resp, "\"code\":1007") && response_has(resp, "no_termination_condition"));
free(resp);
/* 5. mine_event with only difficulty (no timeout) — should use safety timeout and reach low target */
/* 5. nostr_mine_event with only difficulty (no timeout) — should use safety timeout and reach low target */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"5\",\"method\":\"mine_event\",\"params\":["
"{\"id\":\"5\",\"method\":\"nostr_mine_event\",\"params\":["
"\"{\\\"kind\\\":1,\\\"content\\\":\\\"difficulty only\\\",\\\"tags\\\":[]}\","
"{\"difficulty\":1,\"threads\":2}]}");
check_condition("mine_event difficulty-only returns result and reaches target",
check_condition("nostr_mine_event difficulty-only returns result and reaches target",
response_has(resp, "\"id\":\"5\"") && response_has(resp, "\"result\"") && check_target_reached(resp, 1));
free(resp);
/* 6. mine_event with invalid event JSON */
/* 6. nostr_mine_event with invalid event JSON */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"6\",\"method\":\"mine_event\",\"params\":["
"{\"id\":\"6\",\"method\":\"nostr_mine_event\",\"params\":["
"\"not valid json\","
"{\"difficulty\":1,\"timeout_sec\":2}]}");
check_condition("mine_event with invalid event returns error",
check_condition("nostr_mine_event with invalid event returns error",
response_has(resp, "\"id\":\"6\"") && (response_has(resp, "\"code\":1008") || response_has(resp, "\"code\":-32602")));
free(resp);
@@ -651,7 +647,7 @@ int main(void) {
int pow_diff;
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"7\",\"method\":\"mine_event\",\"params\":["
"{\"id\":\"7\",\"method\":\"nostr_mine_event\",\"params\":["
"\"{\\\"kind\\\":1,\\\"content\\\":\\\"verify pow\\\",\\\"tags\\\":[]}\","
"{\"difficulty\":4,\"threads\":4,\"timeout_sec\":5}]}");

View File

@@ -8,8 +8,8 @@
* - ML-DSA-65 sign/verify with wrong key: verify fails
* - DRBG determinism: same seed -> same randombytes output
* - Integration: derive ML-DSA-65 key from mnemonic via crypto_derive_one,
* sign data via sign_data verb through dispatcher, verify signature
* - Enforcement: sign_data allowed on pq-sig+ml-dsa-65
* sign data via sign verb through dispatcher, verify signature
* - Enforcement: sign allowed on pq-sig+ml-dsa-65
*/
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
#include <stddef.h>
@@ -142,19 +142,22 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_PURPOSE -1
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define ENFORCE_ERR_ALGORITHM -4
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#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_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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);
@@ -682,7 +685,7 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Integration: sign_data via dispatcher ---- */
/* ---- Integration: sign via dispatcher ---- */
{
role_table_t table;
role_entry_t pq_role;
@@ -705,16 +708,16 @@ int main(void) {
dispatcher_init(&dispatcher, &table, &mnemonic_state, &g_key_store, &g_alg_key_cache);
/* sign_data request */
/* sign request */
snprintf(request, sizeof(request),
"{\"id\":\"test1\",\"method\":\"sign_data\",\"params\":[\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"test1\",\"method\":\"sign\",\"params\":[\"%s\",{\"algorithm\":\"ml-dsa-65\",\"index\":0}]}",
msg_hex);
resp = dispatcher_handle_request(&dispatcher, request);
check_condition("sign_data via dispatcher returns result",
check_condition("sign via dispatcher returns result",
resp != NULL && response_has(resp, "\"result\""));
check_condition("sign_data result contains signature",
check_condition("sign result contains signature",
resp != NULL && response_has(resp, "signature"));
check_condition("sign_data result contains algorithm ml-dsa-65",
check_condition("sign result contains algorithm ml-dsa-65",
resp != NULL && response_has(resp, "ml-dsa-65"));
free(resp);
@@ -722,7 +725,7 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Integration: verify_signature roundtrip via dispatcher ---- */
/* ---- Integration: verify roundtrip via dispatcher ---- */
{
role_table_t table;
role_entry_t pq_role;
@@ -749,10 +752,10 @@ int main(void) {
/* Sign */
snprintf(sign_req, sizeof(sign_req),
"{\"id\":\"s1\",\"method\":\"sign_data\",\"params\":[\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"s1\",\"method\":\"sign\",\"params\":[\"%s\",{\"algorithm\":\"ml-dsa-65\",\"index\":0}]}",
msg_hex);
sign_resp = dispatcher_handle_request(&dispatcher, sign_req);
check_condition("verify roundtrip: sign_data succeeds", sign_resp != NULL);
check_condition("verify roundtrip: sign succeeds", sign_resp != NULL);
/* Extract signature from result */
if (sign_resp != NULL) {
@@ -777,28 +780,28 @@ int main(void) {
/* Verify */
if (sig_str != NULL) {
snprintf(verify_req, sizeof(verify_req),
"{\"id\":\"v1\",\"method\":\"verify_signature\",\"params\":[\"%s\",\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"v1\",\"method\":\"verify\",\"params\":[\"%s\",\"%s\",{\"algorithm\":\"ml-dsa-65\",\"index\":0}]}",
msg_hex, sig_str);
verify_resp = dispatcher_handle_request(&dispatcher, verify_req);
check_condition("verify_signature returns valid:true",
check_condition("verify returns valid:true",
verify_resp != NULL && response_has(verify_resp, "valid") &&
response_has(verify_resp, "true"));
free(verify_resp);
/* Verify with wrong message */
snprintf(verify_req, sizeof(verify_req),
"{\"id\":\"v2\",\"method\":\"verify_signature\",\"params\":[\"00ff\",\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"v2\",\"method\":\"verify\",\"params\":[\"00ff\",\"%s\",{\"algorithm\":\"ml-dsa-65\",\"index\":0}]}",
sig_str);
verify_resp = dispatcher_handle_request(&dispatcher, verify_req);
check_condition("verify_signature wrong msg returns valid:false",
check_condition("verify wrong msg returns valid:false",
verify_resp != NULL && response_has(verify_resp, "valid") &&
response_has(verify_resp, "false"));
free(verify_resp);
free((void *)sig_str);
} else {
check_condition("verify_signature returns valid:true", 0);
check_condition("verify_signature wrong msg returns valid:false", 0);
check_condition("verify returns valid:true", 0);
check_condition("verify wrong msg returns valid:false", 0);
}
free(sign_resp);
@@ -806,22 +809,26 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Enforcement: sign_data on pq-sig+ml-dsa-65 ---- */
/* ---- Enforcement: sign on pq-sig+ml-dsa-65 ---- */
{
role_entry_t pq_sig = make_pq_sig_ml_dsa_65_entry("pq", 0);
role_entry_t nostr_secp = make_nostr_secp_entry("nostr", 0);
check_condition("enforce sign_data + pq-sig/ml-dsa-65 -> OK",
enforce_verb_role(VERB_SIGN_DATA, &pq_sig) == ENFORCE_OK);
/* sign/verify are algorithm-based now. */
check_condition("enforce sign + ml-dsa-65 -> OK",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_ML_DSA_65) == ENFORCE_OK);
check_condition("enforce sign_data + nostr/secp256k1 -> PURPOSE err",
enforce_verb_role(VERB_SIGN_DATA, &nostr_secp) == ENFORCE_ERR_PURPOSE);
check_condition("enforce sign + secp256k1 -> OK",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_SECP256K1) == ENFORCE_OK);
check_condition("enforce verify_signature + pq-sig/ml-dsa-65 -> OK",
enforce_verb_role(VERB_VERIFY_SIG, &pq_sig) == ENFORCE_OK);
check_condition("enforce verify + ml-dsa-65 -> OK",
enforce_verb_algorithm(VERB_VERIFY, CRYPTO_ALG_ML_DSA_65) == ENFORCE_OK);
check_condition("enforce sign_event + pq-sig/ml-dsa-65 -> PURPOSE err",
enforce_verb_role(VERB_SIGN_EVENT, &pq_sig) == ENFORCE_ERR_PURPOSE);
check_condition("enforce sign + ml-kem-768 -> ALGORITHM err",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_ML_KEM_768) == ENFORCE_ERR_ALGORITHM);
check_condition("enforce nostr_sign_event + pq-sig/ml-dsa-65 -> PURPOSE err",
enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &pq_sig) == ENFORCE_ERR_PURPOSE);
}
/* Cleanup */

View File

@@ -8,7 +8,7 @@
* - Decaps with wrong ciphertext: different shared secret (implicit rejection)
* - Integration: derive ML-KEM-768 key from mnemonic, encaps via dispatcher,
* decaps via dispatcher, shared secrets match
* - Enforcement: kem_encapsulate/kem_decapsulate allowed on pq-kem+ml-kem-768
* - Enforcement: encapsulate/decapsulate allowed on pq-kem+ml-kem-768
*/
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
#include <stddef.h>
@@ -112,19 +112,22 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_PURPOSE -1
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define ENFORCE_ERR_ALGORITHM -4
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#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_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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);
@@ -521,7 +524,7 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Integration: kem_encapsulate / kem_decapsulate via dispatcher ---- */
/* ---- Integration: encapsulate / decapsulate via dispatcher ---- */
{
role_table_t table;
role_entry_t pq_role;
@@ -552,18 +555,18 @@ int main(void) {
check_condition("dispatcher integration: pubkey available", pub_hex != NULL);
if (pub_hex != NULL && decaps_req != NULL) {
/* kem_encapsulate request */
/* encapsulate request */
snprintf(encaps_req, sizeof(encaps_req),
"{\"id\":\"e1\",\"method\":\"kem_encapsulate\",\"params\":[\"%s\",{\"role\":\"pq_kem\"}]}",
"{\"id\":\"e1\",\"method\":\"encapsulate\",\"params\":[\"%s\",{\"algorithm\":\"ml-kem-768\",\"index\":0}]}",
pub_hex);
encaps_resp = dispatcher_handle_request(&dispatcher, encaps_req);
check_condition("kem_encapsulate via dispatcher returns result",
check_condition("encapsulate via dispatcher returns result",
encaps_resp != NULL && response_has(encaps_resp, "\"result\""));
check_condition("kem_encapsulate result contains ciphertext",
check_condition("encapsulate result contains ciphertext",
encaps_resp != NULL && response_has(encaps_resp, "ciphertext"));
check_condition("kem_encapsulate result contains shared_secret",
check_condition("encapsulate result contains shared_secret",
encaps_resp != NULL && response_has(encaps_resp, "shared_secret"));
check_condition("kem_encapsulate result contains algorithm ml-kem-768",
check_condition("encapsulate result contains algorithm ml-kem-768",
encaps_resp != NULL && response_has(encaps_resp, "ml-kem-768"));
/* Extract ciphertext and shared_secret from result */
@@ -584,18 +587,18 @@ int main(void) {
cJSON_Delete(encaps_json);
}
}
check_condition("kem_encapsulate: extracted ciphertext hex", ct_str != NULL);
check_condition("kem_encapsulate: extracted shared_secret hex", ss_encaps_str != NULL);
check_condition("encapsulate: extracted ciphertext hex", ct_str != NULL);
check_condition("encapsulate: extracted shared_secret hex", ss_encaps_str != NULL);
/* kem_decapsulate request */
/* decapsulate request */
if (ct_str != NULL) {
snprintf(decaps_req, 6000,
"{\"id\":\"d1\",\"method\":\"kem_decapsulate\",\"params\":[\"%s\",{\"role\":\"pq_kem\"}]}",
"{\"id\":\"d1\",\"method\":\"decapsulate\",\"params\":[\"%s\",{\"algorithm\":\"ml-kem-768\",\"index\":0}]}",
ct_str);
decaps_resp = dispatcher_handle_request(&dispatcher, decaps_req);
check_condition("kem_decapsulate via dispatcher returns result",
check_condition("decapsulate via dispatcher returns result",
decaps_resp != NULL && response_has(decaps_resp, "\"result\""));
check_condition("kem_decapsulate result contains shared_secret",
check_condition("decapsulate result contains shared_secret",
decaps_resp != NULL && response_has(decaps_resp, "shared_secret"));
/* Extract shared_secret from decaps result */
@@ -626,8 +629,8 @@ int main(void) {
free((void *)ss_decaps_str);
free(decaps_resp);
} else {
check_condition("kem_decapsulate via dispatcher returns result", 0);
check_condition("kem_decapsulate result contains shared_secret", 0);
check_condition("decapsulate via dispatcher returns result", 0);
check_condition("decapsulate result contains shared_secret", 0);
check_condition("dispatcher encaps/decaps shared secrets match", 0);
}
@@ -641,22 +644,23 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Enforcement: kem_encapsulate/kem_decapsulate on pq-kem+ml-kem-768 ---- */
/* ---- Enforcement: encapsulate/decapsulate on pq-kem+ml-kem-768 ---- */
{
role_entry_t pq_kem = make_pq_kem_ml_kem_768_entry("pqk", 0);
role_entry_t nostr_secp = make_nostr_secp_entry("nostr", 0);
check_condition("enforce kem_encapsulate + pq-kem/ml-kem-768 -> OK",
enforce_verb_role(VERB_KEM_ENCAPS, &pq_kem) == ENFORCE_OK);
/* encapsulate/decapsulate are algorithm-based now. */
check_condition("enforce encapsulate + ml-kem-768 -> OK",
enforce_verb_algorithm(VERB_ENCAPSULATE, CRYPTO_ALG_ML_KEM_768) == ENFORCE_OK);
check_condition("enforce kem_encapsulate + nostr/secp256k1 -> PURPOSE err",
enforce_verb_role(VERB_KEM_ENCAPS, &nostr_secp) == ENFORCE_ERR_PURPOSE);
check_condition("enforce encapsulate + secp256k1 -> ALGORITHM err",
enforce_verb_algorithm(VERB_ENCAPSULATE, CRYPTO_ALG_SECP256K1) == ENFORCE_ERR_ALGORITHM);
check_condition("enforce kem_decapsulate + pq-kem/ml-kem-768 -> OK",
enforce_verb_role(VERB_KEM_DECAPS, &pq_kem) == ENFORCE_OK);
check_condition("enforce decapsulate + ml-kem-768 -> OK",
enforce_verb_algorithm(VERB_DECAPSULATE, CRYPTO_ALG_ML_KEM_768) == ENFORCE_OK);
check_condition("enforce kem_decapsulate + nostr/secp256k1 -> PURPOSE err",
enforce_verb_role(VERB_KEM_DECAPS, &nostr_secp) == ENFORCE_ERR_PURPOSE);
check_condition("enforce decapsulate + ed25519 -> ALGORITHM err",
enforce_verb_algorithm(VERB_DECAPSULATE, CRYPTO_ALG_ED25519) == ENFORCE_ERR_ALGORITHM);
}
/* ---- Cleanup ---- */

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/
@@ -801,10 +804,10 @@ int main(void) {
check_condition("parse_preapprove_spec rejects non-numeric nostr_index", rc == -1);
policy_init_default(&table, uid);
rc = policy_check(&table, same_uid, "sign_event", "main", "nostr", NULL);
rc = policy_check(&table, same_uid, "nostr_sign_event", "main", "nostr", NULL);
check_condition("policy_init_default prompts same uid", rc == POLICY_PROMPT);
rc = policy_check(&table, other_uid, "sign_event", "main", "nostr", NULL);
rc = policy_check(&table, other_uid, "nostr_sign_event", "main", "nostr", NULL);
check_condition("policy_init_default prompts different uid", rc == POLICY_PROMPT);
/* Insert before catch-all: matching caller allowed, non-matching still prompted */
@@ -820,9 +823,9 @@ int main(void) {
rc = policy_table_insert_before_last(&table, &grant);
check_condition("policy_table_insert_before_last succeeds", rc == 0);
}
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("insert_before_last matching caller returns POLICY_ALLOW", rc == POLICY_ALLOW);
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
rc = policy_check(&table, "uid:2000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("insert_before_last non-matching caller returns POLICY_PROMPT", rc == POLICY_PROMPT);
/* Preapprove parse + insert: matching caller allowed, non-matching prompted */
@@ -831,70 +834,70 @@ int main(void) {
check_condition("preapprove parse for policy insert succeeds", rc == 0);
rc = policy_table_insert_before_last(&table, &parsed);
check_condition("preapprove insert_before_last succeeds", rc == 0);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("preapprove allows matching caller+role", rc == POLICY_ALLOW);
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
rc = policy_check(&table, "uid:2000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("preapprove leaves non-matching caller at POLICY_PROMPT", rc == POLICY_PROMPT);
/* Exact caller, verb, role, purpose + never => allow */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("exact match returns POLICY_ALLOW", rc == POLICY_ALLOW);
/* Wildcard caller + deny => deny */
policy_table_init(&table);
add_single_entry(&table, "*", "sign_event", "main", "nostr", PROMPT_DENY);
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "*", "nostr_sign_event", "main", "nostr", PROMPT_DENY);
rc = policy_check(&table, "uid:2000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("wildcard caller with deny returns POLICY_DENY", rc == POLICY_DENY);
/* Caller no match => POLICY_NO_MATCH */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:9999", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:9999", "nostr_sign_event", "main", "nostr", NULL);
check_condition("caller mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
/* Verb not in list => no match */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "get_public_key", "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("verb mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
/* Role not in list => no match */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "sign_event", "throwaway", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "throwaway", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("role mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
/* Purpose not in list => no match */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "sign_event", "main", "bitcoin", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "main", "bitcoin", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("purpose mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
/* Empty verbs list means all verbs */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", NULL, "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "nip44_encrypt", "main", "nostr", NULL);
rc = policy_check(&table, "uid:1000", "nostr_nip44_encrypt", "main", "nostr", NULL);
check_condition("empty verbs list matches any verb", rc == POLICY_ALLOW);
/* prompt=first_per_boot => POLICY_PROMPT */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_FIRST_PER_BOOT);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "main", "nostr", PROMPT_FIRST_PER_BOOT);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("first_per_boot returns POLICY_PROMPT", rc == POLICY_PROMPT);
/* prompt=every_request => POLICY_PROMPT */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_EVERY_REQUEST);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "main", "nostr", PROMPT_EVERY_REQUEST);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("every_request returns POLICY_PROMPT", rc == POLICY_PROMPT);
/* First matching entry wins */
policy_table_init(&table);
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_DENY);
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "main", "nostr", PROMPT_DENY);
add_single_entry(&table, "uid:1000", "nostr_sign_event", "main", "nostr", PROMPT_NEVER);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", NULL);
check_condition("first matching entry wins", rc == POLICY_DENY);
/* Verify out_source for preapprove, session-grant, and default catch-all */
@@ -931,15 +934,15 @@ int main(void) {
check_condition("source test default catch-all add", rc == 0);
src = POLICY_SOURCE_DEFAULT;
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", &src);
rc = policy_check(&table, "uid:1000", "nostr_sign_event", "main", "nostr", &src);
check_condition("policy_check reports preapprove source", rc == POLICY_ALLOW && src == POLICY_SOURCE_PREAPPROVE);
src = POLICY_SOURCE_DEFAULT;
rc = policy_check(&table, "uid:1001", "sign_event", "main", "nostr", &src);
rc = policy_check(&table, "uid:1001", "nostr_sign_event", "main", "nostr", &src);
check_condition("policy_check reports session-grant source", rc == POLICY_ALLOW && src == POLICY_SOURCE_SESSION_GRANT);
src = POLICY_SOURCE_PREAPPROVE;
rc = policy_check(&table, "uid:9999", "sign_event", "main", "nostr", &src);
rc = policy_check(&table, "uid:9999", "nostr_sign_event", "main", "nostr", &src);
check_condition("policy_check resets source for prompt/default", rc == POLICY_PROMPT && src == POLICY_SOURCE_DEFAULT);
}

View File

@@ -148,12 +148,13 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);

View File

@@ -145,18 +145,14 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);
@@ -539,16 +535,23 @@ int main(void) {
dispatcher_init(&dispatcher, &table, &mnemonic_state, &g_key_store, &g_alg_key_cache);
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[{\"role\":\"main\"}]}");
"{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[{\"algorithm\":\"secp256k1\",\"index\":0}]}");
check_condition("secp256k1 get_public_key returns a response", resp != NULL);
/* Algorithm-based get_public_key always returns a structured object. */
pr = parse_result(resp, &result_str, &result_obj);
check_condition("secp256k1 get_public_key result is a plain string (not object)",
pr == 1 && result_str != NULL);
check_condition("secp256k1 plain hex result is 64 hex chars",
result_str != NULL && strlen(result_str) == 64);
check_condition("secp256k1 plain hex result has no 'algorithm' field",
!response_has(resp, "algorithm"));
check_condition("secp256k1 get_public_key result is a JSON object",
pr == 2 && result_obj != NULL);
if (result_obj != NULL) {
cJSON *pk_item = cJSON_GetObjectItemCaseSensitive(result_obj, "public_key");
check_condition("secp256k1 structured has public_key (64 hex chars)",
pk_item != NULL && cJSON_IsString(pk_item) &&
strlen(pk_item->valuestring) == 64);
} else {
check_condition("secp256k1 structured has public_key (64 hex chars)", 0);
}
check_condition("secp256k1 structured has algorithm field",
response_has(resp, "algorithm"));
free(result_str);
cJSON_Delete(result_obj);
@@ -584,7 +587,7 @@ int main(void) {
dispatcher_init(&dispatcher, &table, &mnemonic_state, &g_key_store, &g_alg_key_cache);
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"2\",\"method\":\"get_public_key\",\"params\":[{\"role\":\"main\",\"format\":\"structured\"}]}");
"{\"id\":\"2\",\"method\":\"get_public_key\",\"params\":[{\"algorithm\":\"secp256k1\",\"index\":0,\"format\":\"structured\"}]}");
check_condition("secp256k1 structured get_public_key returns a response", resp != NULL);
pr = parse_result(resp, &result_str, &result_obj);
@@ -645,7 +648,7 @@ int main(void) {
dispatcher_init(&dispatcher, &table, &mnemonic_state, &g_key_store, &g_alg_key_cache);
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"3\",\"method\":\"get_public_key\",\"params\":[{\"role\":\"ssh_main\"}]}");
"{\"id\":\"3\",\"method\":\"get_public_key\",\"params\":[{\"algorithm\":\"ed25519\",\"index\":0}]}");
check_condition("ed25519 get_public_key returns a response", resp != NULL);
pr = parse_result(resp, &result_str, &result_obj);
@@ -710,7 +713,7 @@ int main(void) {
check_condition("ML-DSA-65 sizes available", sz != NULL);
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"4\",\"method\":\"get_public_key\",\"params\":[{\"role\":\"pq_sig\"}]}");
"{\"id\":\"4\",\"method\":\"get_public_key\",\"params\":[{\"algorithm\":\"ml-dsa-65\",\"index\":0}]}");
check_condition("ML-DSA-65 get_public_key returns a response", resp != NULL);
pr = parse_result(resp, &result_str, &result_obj);
@@ -775,7 +778,7 @@ int main(void) {
check_condition("ML-KEM-768 sizes available", sz != NULL);
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"5\",\"method\":\"get_public_key\",\"params\":[{\"role\":\"kem_main\"}]}");
"{\"id\":\"5\",\"method\":\"get_public_key\",\"params\":[{\"algorithm\":\"ml-kem-768\",\"index\":0}]}");
check_condition("ML-KEM-768 get_public_key returns a response", resp != NULL);
pr = parse_result(resp, &result_str, &result_obj);

View File

@@ -304,7 +304,7 @@ int main(void) {
check_condition("nostr_init for auth signing", nostr_init() == 0);
/* no auth => legacy qubes:personal preapprove should match */
if (run_qrexec_once("{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}", &resp) == 0) {
if (run_qrexec_once("{\"id\":\"1\",\"method\":\"nostr_get_public_key\",\"params\":[\"\"]}", &resp) == 0) {
check_condition("qrexec optional/no-auth still allows legacy qubes caller preapprove",
strstr(resp, "\"result\":") != NULL);
free(resp);
@@ -313,7 +313,7 @@ int main(void) {
check_condition("qrexec optional/no-auth request roundtrip", 0);
}
auth_req = build_auth_request(privkey, "2", "get_public_key");
auth_req = build_auth_request(privkey, "2", "nostr_get_public_key");
check_condition("build qrexec auth request", auth_req != NULL);
if (auth_req != NULL) {
if (run_qrexec_once(auth_req, &resp) == 0) {
@@ -328,7 +328,7 @@ int main(void) {
auth_req = NULL;
}
auth_req = build_auth_request(privkey, "3", "get_public_key");
auth_req = build_auth_request(privkey, "3", "nostr_get_public_key");
check_condition("build second qrexec auth request", auth_req != NULL);
if (auth_req != NULL) {
cJSON *root = cJSON_Parse(auth_req);
@@ -338,7 +338,7 @@ int main(void) {
if (root != NULL) {
method_item = cJSON_GetObjectItemCaseSensitive(root, "method");
if (cJSON_IsString(method_item)) {
cJSON_SetValuestring(method_item, "sign_event");
cJSON_SetValuestring(method_item, "nostr_sign_event");
}
tampered = cJSON_PrintUnformatted(root);
cJSON_Delete(root);

View File

@@ -211,6 +211,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -220,22 +221,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/

View File

@@ -8,8 +8,8 @@
* - SLH-DSA-128s sign/verify with wrong key: verify fails
* - DRBG determinism: same seed -> same randombytes output
* - Integration: derive SLH-DSA-128s key from mnemonic via crypto_derive_one,
* sign data via sign_data verb through dispatcher, verify signature
* - Enforcement: sign_data allowed on pq-sig+slh-dsa-128s
* sign data via sign verb through dispatcher, verify signature
* - Enforcement: sign allowed on pq-sig+slh-dsa-128s
*/
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
#include <stddef.h>
@@ -142,19 +142,22 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_PURPOSE -1
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define ENFORCE_ERR_ALGORITHM -4
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#define VERB_MINE_EVENT "mine_event"
#define VERB_SIGN_DATA "sign_data"
#define VERB_VERIFY_SIG "verify_signature"
#define VERB_SSH_SIGN "ssh_sign"
#define VERB_KEM_ENCAPS "kem_encapsulate"
#define VERB_KEM_DECAPS "kem_decapsulate"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#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_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"
int enforce_verb_role(const char *verb, const role_entry_t *role);
const char *enforce_strerror(int err);
@@ -682,7 +685,7 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Integration: sign_data via dispatcher ---- */
/* ---- Integration: sign via dispatcher ---- */
{
role_table_t table;
role_entry_t pq_role;
@@ -705,16 +708,16 @@ int main(void) {
dispatcher_init(&dispatcher, &table, &mnemonic_state, &g_key_store, &g_alg_key_cache);
/* sign_data request */
/* sign request */
snprintf(request, sizeof(request),
"{\"id\":\"test1\",\"method\":\"sign_data\",\"params\":[\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"test1\",\"method\":\"sign\",\"params\":[\"%s\",{\"algorithm\":\"slh-dsa-128s\",\"index\":0}]}",
msg_hex);
resp = dispatcher_handle_request(&dispatcher, request);
check_condition("sign_data via dispatcher returns result",
check_condition("sign via dispatcher returns result",
resp != NULL && response_has(resp, "\"result\""));
check_condition("sign_data result contains signature",
check_condition("sign result contains signature",
resp != NULL && response_has(resp, "signature"));
check_condition("sign_data result contains algorithm slh-dsa-128s",
check_condition("sign result contains algorithm slh-dsa-128s",
resp != NULL && response_has(resp, "slh-dsa-128s"));
free(resp);
@@ -722,7 +725,7 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Integration: verify_signature roundtrip via dispatcher ---- */
/* ---- Integration: verify roundtrip via dispatcher ---- */
{
role_table_t table;
role_entry_t pq_role;
@@ -752,10 +755,10 @@ int main(void) {
/* Sign */
snprintf(sign_req, sizeof(sign_req),
"{\"id\":\"s1\",\"method\":\"sign_data\",\"params\":[\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"s1\",\"method\":\"sign\",\"params\":[\"%s\",{\"algorithm\":\"slh-dsa-128s\",\"index\":0}]}",
msg_hex);
sign_resp = dispatcher_handle_request(&dispatcher, sign_req);
check_condition("verify roundtrip: sign_data succeeds", sign_resp != NULL);
check_condition("verify roundtrip: sign succeeds", sign_resp != NULL);
/* Extract signature from result */
if (sign_resp != NULL) {
@@ -780,28 +783,28 @@ int main(void) {
/* Verify */
if (sig_str != NULL && verify_req != NULL) {
snprintf(verify_req, 20000,
"{\"id\":\"v1\",\"method\":\"verify_signature\",\"params\":[\"%s\",\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"v1\",\"method\":\"verify\",\"params\":[\"%s\",\"%s\",{\"algorithm\":\"slh-dsa-128s\",\"index\":0}]}",
msg_hex, sig_str);
verify_resp = dispatcher_handle_request(&dispatcher, verify_req);
check_condition("verify_signature returns valid:true",
check_condition("verify returns valid:true",
verify_resp != NULL && response_has(verify_resp, "valid") &&
response_has(verify_resp, "true"));
free(verify_resp);
/* Verify with wrong message */
snprintf(verify_req, 20000,
"{\"id\":\"v2\",\"method\":\"verify_signature\",\"params\":[\"00ff\",\"%s\",{\"role\":\"pq_main\"}]}",
"{\"id\":\"v2\",\"method\":\"verify\",\"params\":[\"00ff\",\"%s\",{\"algorithm\":\"slh-dsa-128s\",\"index\":0}]}",
sig_str);
verify_resp = dispatcher_handle_request(&dispatcher, verify_req);
check_condition("verify_signature wrong msg returns valid:false",
check_condition("verify wrong msg returns valid:false",
verify_resp != NULL && response_has(verify_resp, "valid") &&
response_has(verify_resp, "false"));
free(verify_resp);
free((void *)sig_str);
} else {
check_condition("verify_signature returns valid:true", 0);
check_condition("verify_signature wrong msg returns valid:false", 0);
check_condition("verify returns valid:true", 0);
check_condition("verify wrong msg returns valid:false", 0);
}
free(verify_req);
@@ -810,19 +813,23 @@ int main(void) {
mnemonic_unload(&mnemonic_state);
}
/* ---- Enforcement: sign_data on pq-sig+slh-dsa-128s ---- */
/* ---- Enforcement: sign on pq-sig+slh-dsa-128s ---- */
{
role_entry_t pq_sig = make_pq_sig_slh_dsa_128s_entry("pq", 0);
role_entry_t nostr_secp = make_nostr_secp_entry("nostr", 0);
check_condition("enforce sign_data + pq-sig/slh-dsa-128s -> OK",
enforce_verb_role(VERB_SIGN_DATA, &pq_sig) == ENFORCE_OK);
/* sign/verify are algorithm-based now. */
check_condition("enforce sign + slh-dsa-128s -> OK",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_SLH_DSA_128S) == ENFORCE_OK);
check_condition("enforce sign_data + nostr/secp256k1 -> PURPOSE err",
enforce_verb_role(VERB_SIGN_DATA, &nostr_secp) == ENFORCE_ERR_PURPOSE);
check_condition("enforce sign + secp256k1 -> OK",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_SECP256K1) == ENFORCE_OK);
check_condition("enforce verify_signature + pq-sig/slh-dsa-128s -> OK",
enforce_verb_role(VERB_VERIFY_SIG, &pq_sig) == ENFORCE_OK);
check_condition("enforce verify + slh-dsa-128s -> OK",
enforce_verb_algorithm(VERB_VERIFY, CRYPTO_ALG_SLH_DSA_128S) == ENFORCE_OK);
check_condition("enforce sign + ml-kem-768 -> ALGORITHM err",
enforce_verb_algorithm(VERB_SIGN, CRYPTO_ALG_ML_KEM_768) == ENFORCE_ERR_ALGORITHM);
}
/* ---- Cleanup ---- */

View File

@@ -208,6 +208,7 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
@@ -217,22 +218,24 @@ const char *selector_strerror(int err);
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
/* Known verbs */
#define VERB_SIGN_EVENT "sign_event"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
#define VERB_NIP44_DECRYPT "nip44_decrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
#define VERB_NIP04_DECRYPT "nip04_decrypt"
#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_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"
/*
* Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
*
* The enforcement rules are:
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
* - All nostr verbs (nostr_sign_event, nostr_get_public_key, nostr_nip44_*, nostr_nip04_*) require:
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/

Binary file not shown.

141
tools/http_test.py Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
http_test.py — test the HTTP listener mode with curl-like requests.
Starts nsigner in HTTP mode, sends requests via urllib, and verifies
the responses.
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
NSIGNER = "./build/nsigner"
PAD_DIR = "/media/user/Music/pads"
PAD_SPEC = "333e9902db839d9d"
MNEMONIC_FILE = ".test_mnemonic"
MNEMONIC_TMP = ".test_mnemonic_http.tmp"
PORT = 11111
def main():
# Reset pad offset
state_path = f"{PAD_DIR}/333e9902db839d9d7f1f6aaa30f392a77c9abd011dd6274d9d3cf167361a789e.state"
with open(state_path, "w") as f:
f.write("offset=32\n")
# Prepare mnemonic temp file
with open(MNEMONIC_FILE) as f:
mnemonic = f.read().strip()
with open(MNEMONIC_TMP, "w") as f:
f.write(mnemonic + "\n")
# Start nsigner in HTTP mode
shell_cmd = (
f"exec 3<{MNEMONIC_TMP} 2>/dev/null; "
f"exec {NSIGNER} --listen http:127.0.0.1:{PORT} --mnemonic-fd 3 "
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
f"--otp-allow-blkback --allow-all"
)
proc = subprocess.Popen(
["bash", "-c", shell_cmd],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
time.sleep(2.0) # let the signer start
if proc.poll() is not None:
err = proc.stderr.read().decode()
print(f"ERROR: nsigner exited early (code {proc.returncode})")
print(f"stderr: {err}")
try:
os.unlink(MNEMONIC_TMP)
except OSError:
pass
return 1
try:
url = f"http://127.0.0.1:{PORT}/"
# Test 1: get_public_key
print("=== Test 1: get_public_key via HTTP ===")
req_data = json.dumps({
"id": "1",
"method": "get_public_key",
"params": [{"role": "main"}],
}).encode()
req = urllib.request.Request(url, data=req_data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode())
print(f"Response: {json.dumps(result)[:120]}...")
if "result" not in result:
print("ERROR: no result in get_public_key response")
return 1
pubkey = result["result"].strip('"')
print(f"Public key: {pubkey}")
# Test 2: otp_encrypt
print("\n=== Test 2: otp_encrypt via HTTP ===")
import base64
pt_b64 = base64.b64encode(b"Hello, OTP via HTTP!").decode()
req_data = json.dumps({
"id": "2",
"method": "otp_encrypt",
"params": [pt_b64, {"encoding": "ascii"}],
}).encode()
req = urllib.request.Request(url, data=req_data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode())
print(f"Response: {json.dumps(result)[:200]}...")
if "result" not in result:
print("ERROR: no result in otp_encrypt response")
return 1
enc_result = json.loads(result["result"])
ciphertext = enc_result["ciphertext"]
print(f"Pad offset: {enc_result['pad_offset_before']} -> {enc_result['pad_offset_after']}")
print(f"Ciphertext (first 60 chars): {ciphertext[:60]}...")
# Test 3: otp_decrypt
print("\n=== Test 3: otp_decrypt via HTTP ===")
req_data = json.dumps({
"id": "3",
"method": "otp_decrypt",
"params": [ciphertext, {"encoding": "ascii"}],
}).encode()
req = urllib.request.Request(url, data=req_data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode())
print(f"Response: {json.dumps(result)[:200]}...")
if "result" not in result:
print("ERROR: no result in otp_decrypt response")
return 1
dec_result = json.loads(result["result"])
recovered = base64.b64decode(dec_result["plaintext"]).decode()
print(f"Recovered plaintext: {recovered}")
if recovered == "Hello, OTP via HTTP!":
print("\n=== HTTP ROUND-TRIP SUCCESS ===")
return 0
else:
print("\n=== HTTP ROUND-TRIP FAILED ===")
print(f"Expected: Hello, OTP via HTTP!")
print(f"Got: {recovered}")
return 1
finally:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
try:
os.unlink(MNEMONIC_TMP)
except OSError:
pass
if __name__ == "__main__":
sys.exit(main())

BIN
tools/make_test_pad Executable file

Binary file not shown.

228
tools/make_test_pad.c Normal file
View File

@@ -0,0 +1,228 @@
/*
* make_test_pad.c — generate a small test OTP pad on a target directory.
*
* Bit-compatible with the `otp` project's pad format:
* - <chksum>.pad : raw random bytes, first 32 bytes are the "reserved header"
* (also used as the key to encrypt the checksum).
* - <chksum>.state : text file "offset=32\n" (32-byte header reserved).
* - chksum is the 64-hex-char XOR checksum as computed by
* otp/src/crypto.c:calculate_checksum (position-dependent XOR folded into
* 32 buckets, then XORed with the first 32 pad bytes).
*
* Usage:
* make_test_pad <pad_dir> <size_bytes>
*
* Example:
* make_test_pad /media/user/USBDISK/pads 1048576
*
* Entropy source: /dev/urandom (local-entropy test path only — NOT for production
* pads; production pads should use the `otp` CLI with keyboard/TRNG entropy or a
* future hardware signer).
*/
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
#define CHKSUM_HEX_LEN 64
#define CHKSUM_BIN_LEN 32
#define HEADER_RESERVED 32
#define BUF_SIZE (64 * 1024)
static int compute_checksum(const char *pad_path, char *checksum_hex) {
FILE *file = fopen(pad_path, "rb");
if (!file) {
fprintf(stderr, "compute_checksum: cannot open %s: %s\n",
pad_path, strerror(errno));
return 1;
}
unsigned char checksum[CHKSUM_BIN_LEN];
unsigned char buffer[BUF_SIZE];
size_t bytes_read;
size_t total_bytes = 0;
memset(checksum, 0, CHKSUM_BIN_LEN);
while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0) {
for (size_t i = 0; i < bytes_read; i++) {
size_t pos = total_bytes + i;
unsigned char bucket = (unsigned char)(pos % CHKSUM_BIN_LEN);
checksum[bucket] ^= buffer[i] ^
(unsigned char)((pos >> 8) & 0xFF) ^
(unsigned char)((pos >> 16) & 0xFF) ^
(unsigned char)((pos >> 24) & 0xFF);
}
total_bytes += bytes_read;
}
fclose(file);
/* XOR the checksum with the first 32 bytes of the pad (the "pad key"). */
file = fopen(pad_path, "rb");
if (!file) {
fprintf(stderr, "compute_checksum: cannot reopen %s: %s\n",
pad_path, strerror(errno));
return 1;
}
unsigned char pad_key[CHKSUM_BIN_LEN];
if (fread(pad_key, 1, CHKSUM_BIN_LEN, file) != CHKSUM_BIN_LEN) {
fprintf(stderr, "compute_checksum: pad too small for header key\n");
fclose(file);
return 1;
}
fclose(file);
unsigned char encrypted_checksum[CHKSUM_BIN_LEN];
for (int i = 0; i < CHKSUM_BIN_LEN; i++) {
encrypted_checksum[i] = checksum[i] ^ pad_key[i];
}
for (int i = 0; i < CHKSUM_BIN_LEN; i++) {
sprintf(checksum_hex + (i * 2), "%02x", encrypted_checksum[i]);
}
checksum_hex[CHKSUM_HEX_LEN] = '\0';
return 0;
}
static int write_random(const char *path, size_t size) {
int urand = open("/dev/urandom", O_RDONLY);
if (urand < 0) {
fprintf(stderr, "cannot open /dev/urandom: %s\n", strerror(errno));
return 1;
}
int out = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out < 0) {
fprintf(stderr, "cannot create %s: %s\n", path, strerror(errno));
close(urand);
return 1;
}
unsigned char buffer[BUF_SIZE];
size_t written = 0;
while (written < size) {
size_t chunk = size - written;
if (chunk > sizeof(buffer)) chunk = sizeof(buffer);
ssize_t got = 0;
while ((size_t)got < chunk) {
ssize_t r = read(urand, buffer + got, chunk - (size_t)got);
if (r < 0) {
if (errno == EINTR) continue;
fprintf(stderr, "read urandom failed: %s\n", strerror(errno));
close(out);
close(urand);
return 1;
}
if (r == 0) {
fprintf(stderr, "urandom EOF (unexpected)\n");
close(out);
close(urand);
return 1;
}
got += r;
}
ssize_t put = 0;
while (put < got) {
ssize_t w = write(out, buffer + put, (size_t)got - (size_t)put);
if (w < 0) {
if (errno == EINTR) continue;
fprintf(stderr, "write %s failed: %s\n", path, strerror(errno));
close(out);
close(urand);
return 1;
}
put += w;
}
written += (size_t)got;
}
close(out);
close(urand);
return 0;
}
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s <pad_dir> <size_bytes>\n", argv[0]);
return 1;
}
const char *pad_dir = argv[1];
size_t size = (size_t)strtoull(argv[2], NULL, 10);
if (size < 64) {
fprintf(stderr, "size must be at least 64 bytes\n");
return 1;
}
/* Ensure pad_dir exists. */
struct stat st;
if (stat(pad_dir, &st) != 0) {
if (mkdir(pad_dir, 0755) != 0) {
fprintf(stderr, "cannot create %s: %s\n", pad_dir, strerror(errno));
return 1;
}
} else if (!S_ISDIR(st.st_mode)) {
fprintf(stderr, "%s exists but is not a directory\n", pad_dir);
return 1;
}
/* Write the pad to a temporary name first, then rename by checksum. */
char tmp_path[1024];
snprintf(tmp_path, sizeof(tmp_path), "%s/.tmp_pad_XXXXXX", pad_dir);
/* mkstemp would be cleaner, but we want a stable name for the rename. */
int tfd = mkstemp(tmp_path);
if (tfd < 0) {
fprintf(stderr, "mkstemp failed: %s\n", strerror(errno));
return 1;
}
close(tfd);
if (write_random(tmp_path, size) != 0) {
unlink(tmp_path);
return 1;
}
char chksum[CHKSUM_HEX_LEN + 1];
if (compute_checksum(tmp_path, chksum) != 0) {
unlink(tmp_path);
return 1;
}
char final_path[1024];
char state_path[1024];
snprintf(final_path, sizeof(final_path), "%s/%s.pad", pad_dir, chksum);
snprintf(state_path, sizeof(state_path), "%s/%s.state", pad_dir, chksum);
if (rename(tmp_path, final_path) != 0) {
fprintf(stderr, "rename %s -> %s failed: %s\n",
tmp_path, final_path, strerror(errno));
unlink(tmp_path);
return 1;
}
/* Write initial state file: offset=32 (header reserved). */
FILE *state = fopen(state_path, "w");
if (!state) {
fprintf(stderr, "cannot create %s: %s\n", state_path, strerror(errno));
return 1;
}
fprintf(state, "offset=%d\n", HEADER_RESERVED);
fclose(state);
printf("Created test pad:\n");
printf(" pad: %s\n", final_path);
printf(" state: %s\n", state_path);
printf(" size: %zu bytes\n", size);
printf(" chksum: %s\n", chksum);
printf(" chksum prefix (16 chars): %.16s\n", chksum);
return 0;
}

213
tools/otp_roundtrip_test.py Normal file
View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
otp_roundtrip_test.py — end-to-end test of otp_encrypt / otp_decrypt verbs.
Sends framed JSON-RPC requests to nsigner --listen stdio and checks the
round-trip: plaintext -> otp_encrypt -> otp_decrypt -> recovered plaintext.
Framing: 4-byte big-endian length prefix + JSON payload.
Usage: python3 tools/otp_roundtrip_test.py
"""
import base64
import json
import os
import struct
import subprocess
import sys
import tempfile
import time
NSIGNER = "./build/nsigner"
PAD_DIR = "/media/user/Music/pads"
PAD_SPEC = "333e9902db839d9d"
MNEMONIC_FILE = ".test_mnemonic"
def send_framed(proc, obj):
payload = json.dumps(obj).encode()
proc.stdin.write(struct.pack(">I", len(payload)))
proc.stdin.write(payload)
proc.stdin.flush()
def recv_framed(proc):
"""Read a framed response, skipping any banner text the signer writes
to stdout before the first frame. The banner is line-based ASCII; a
valid frame starts with a 4-byte big-endian length followed by '{'."""
# Read 4 bytes at a time, sliding window, until we find a frame header.
buf = b""
while True:
b = proc.stdout.read(1)
if not b:
return None
buf = (buf + b)[-4:]
if len(buf) < 4:
continue
(length,) = struct.unpack(">I", buf)
# Sanity: frame length should be reasonable (1..1MB) and the next
# byte after the header should be '{' (start of JSON).
if 1 <= length <= 1024 * 1024:
# Peek: read one more byte to check for '{'.
peek = proc.stdout.read(1)
if peek == b'{':
body = peek + proc.stdout.read(length - 1)
return json.loads(body.decode())
else:
# Not a frame; prepend peek to the stream by including it
# in the sliding window.
buf = (buf + peek)[-4:]
# Otherwise keep scanning.
def main():
if not os.path.isfile(NSIGNER):
print(f"ERROR: {NSIGNER} not found. Run 'make dev' first.")
return 1
with open(MNEMONIC_FILE) as f:
mnemonic = f.read().strip()
plaintext = b"Hello, OTP world!"
pt_b64 = base64.b64encode(plaintext).decode()
print(f"Plaintext: {plaintext.decode()}")
print(f"Plaintext base64: {pt_b64}")
# Write the mnemonic to a fixed temp file and pass it as fd 3 to nsigner
# via a bash wrapper (so stdin stays free for framed requests).
mnem_path = ".test_mnemonic_otp_roundtrip.tmp"
with open(mnem_path, "w") as f:
f.write(mnemonic + "\n")
def run_one_request(req_obj):
"""Run nsigner in stdio mode for a single framed request/response.
Returns the parsed JSON response or None."""
shell_cmd = (
f"exec 3<{mnem_path}; "
f"exec {NSIGNER} --listen stdio --mnemonic-fd 3 "
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
f"--otp-allow-blkback --allow-all"
)
proc = subprocess.Popen(
["bash", "-c", shell_cmd],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=False,
)
time.sleep(1.5) # let the signer bind the pad and be ready
if proc.poll() is not None:
err = proc.stderr.read().decode()
print(f"ERROR: nsigner exited early (code {proc.returncode})")
print(f"stderr: {err}")
return None
send_framed(proc, req_obj)
resp = recv_framed(proc)
proc.stdin.close()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
return resp
# --- otp_encrypt ---
print("\n=== Sending otp_encrypt ===")
resp = run_one_request({
"id": "1",
"method": "otp_encrypt",
"params": [pt_b64, {"encoding": "ascii"}],
})
print(f"Encrypt response: {json.dumps(resp)}")
if resp is None or "result" not in resp:
print("ERROR: no result in encrypt response")
try:
os.unlink(mnem_path)
except OSError:
pass
return 1
result_obj = json.loads(resp["result"])
ciphertext = result_obj["ciphertext"]
off_before = result_obj["pad_offset_before"]
off_after = result_obj["pad_offset_after"]
print(f"Pad offset: {off_before} -> {off_after} "
f"(consumed {off_after - off_before} bytes)")
print(f"Ciphertext (first 80 chars): {ciphertext[:80]}...")
# --- otp_decrypt (separate invocation; offset persists in .state) ---
print("\n=== Sending otp_decrypt ===")
resp2 = run_one_request({
"id": "2",
"method": "otp_decrypt",
"params": [ciphertext, {"encoding": "ascii"}],
})
print(f"Decrypt response: {json.dumps(resp2)}")
if resp2 is None or "result" not in resp2:
print("ERROR: no result in decrypt response")
return 1
result2 = json.loads(resp2["result"])
recovered_b64 = result2["plaintext"]
recovered = base64.b64decode(recovered_b64)
print(f"\nRecovered plaintext: {recovered.decode()}")
if recovered == plaintext:
print("\n=== ASCII ROUND-TRIP SUCCESS ===")
else:
print("\n=== ASCII ROUND-TRIP FAILED ===")
print(f"Expected: {plaintext.decode()}")
print(f"Got: {recovered.decode()}")
return 1
# --- Binary encoding round-trip ---
# Reset the pad offset for a clean binary test.
state_path = (f"{PAD_DIR}/{result_obj['pad_chksum']}.state")
with open(state_path, "w") as sf:
sf.write("offset=32\n")
print("\n=== Sending otp_encrypt (binary) ===")
resp3 = run_one_request({
"id": "3",
"method": "otp_encrypt",
"params": [pt_b64, {"encoding": "binary"}],
})
print(f"Binary encrypt response: {json.dumps(resp3)}")
if resp3 is None or "result" not in resp3:
print("ERROR: no result in binary encrypt response")
return 1
result3 = json.loads(resp3["result"])
bin_b64 = result3["ciphertext"]
# The binary ciphertext is base64-encoded in the JSON result.
bin_blob = base64.b64decode(bin_b64)
print(f"Binary blob size: {len(bin_blob)} bytes "
f"(header 58 + padded data {len(bin_blob) - 58})")
if not bin_blob[:4] == b"OTP\0":
print("ERROR: binary blob missing OTP magic")
return 1
print("\n=== Sending otp_decrypt (binary) ===")
resp4 = run_one_request({
"id": "4",
"method": "otp_decrypt",
"params": [bin_b64, {"encoding": "binary"}],
})
print(f"Binary decrypt response: {json.dumps(resp4)}")
if resp4 is None or "result" not in resp4:
print("ERROR: no result in binary decrypt response")
return 1
result4 = json.loads(resp4["result"])
recovered2 = base64.b64decode(result4["plaintext"])
print(f"\nRecovered plaintext (binary path): {recovered2.decode()}")
if recovered2 == plaintext:
print("\n=== BINARY ROUND-TRIP SUCCESS ===")
return 0
else:
print("\n=== BINARY ROUND-TRIP FAILED ===")
print(f"Expected: {plaintext.decode()}")
print(f"Got: {recovered2.decode()}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,94 @@
#!/bin/bash
# otp_roundtrip_test.sh — end-to-end test of otp_encrypt / otp_decrypt verbs.
#
# Sends framed JSON-RPC requests to nsigner --listen stdio and checks the
# round-trip: plaintext -> otp_encrypt -> otp_decrypt -> recovered plaintext.
#
# Usage: ./tools/otp_roundtrip_test.sh
set -euo pipefail
NSIGNER="./build/nsigner"
PAD_DIR="/media/user/Music/pads"
PAD_SPEC="333e9902db839d9d"
MNEMONIC_FILE=".test_mnemonic"
if [ ! -x "$NSIGNER" ]; then
echo "ERROR: $NSIGNER not found. Run 'make dev' first."
exit 1
fi
# Helper: send a framed JSON-RPC request to nsigner stdio and print the response.
# Framing: 4-byte big-endian length prefix + JSON payload.
send_request() {
local json="$1"
local len=${#json}
# 4-byte big-endian length
printf '\\x%02x\\x%02x\\x%02x\\x%02x' \
$(( (len >> 24) & 0xff )) $(( (len >> 16) & 0xff )) \
$(( (len >> 8) & 0xff )) $(( len & 0xff ))
printf '%s' "$json"
}
# Build the plaintext base64. "Hello, OTP world!" -> base64
PLAINTEXT_B64=$(printf 'Hello, OTP world!' | base64)
echo "Plaintext base64: $PLAINTEXT_B64"
# Build the encrypt request
ENCRYPT_REQ=$(cat <<EOF
{"id":"1","method":"otp_encrypt","params":["$PLAINTEXT_B64",{"encoding":"ascii"}]}
EOF
)
echo "=== Sending otp_encrypt ==="
RESPONSE=$( (printf '\\x00\\x00\\x00\\x%02x' ${#ENCRYPT_REQ}; printf '%s' "$ENCRYPT_REQ") | \
(exec 3<"$MNEMONIC_FILE"; "$NSIGNER" --listen stdio --mnemonic-fd 3 \
--otp-pad-dir "$PAD_DIR" --otp-pad "$PAD_SPEC" --otp-allow-blkback --allow-all) 2>/dev/null \
| tail -1 )
echo "Encrypt response: $RESPONSE"
# Extract the ciphertext field (rough parse — look for "ciphertext":"...")
CIPHERTEXT=$(echo "$RESPONSE" | sed -n 's/.*"ciphertext":"\([^"]*\)".*/\1/p')
if [ -z "$CIPHERTEXT" ]; then
echo "ERROR: no ciphertext in response"
exit 1
fi
echo "Ciphertext (first 80 chars): ${CIPHERTEXT:0:80}..."
# Build the decrypt request — the ciphertext is the ASCII armor (with newlines
# escaped as \n in JSON). We need to properly JSON-escape it.
# Use python to do the JSON encoding safely.
DECRYPT_REQ=$(python3 -c "
import json, sys
ct = sys.argv[1]
print(json.dumps({'id':'2','method':'otp_decrypt','params':[ct,{'encoding':'ascii'}]}))
" "$CIPHERTEXT")
echo "=== Sending otp_decrypt ==="
RESPONSE2=$( (printf '\\x00\\x00\\x00\\x%02x' ${#DECRYPT_REQ}; printf '%s' "$DECRYPT_REQ") | \
(exec 3<"$MNEMONIC_FILE"; "$NSIGNER" --listen stdio --mnemonic-fd 3 \
--otp-pad-dir "$PAD_DIR" --otp-pad "$PAD_SPEC" --otp-allow-blkback --allow-all) 2>/dev/null \
| tail -1 )
echo "Decrypt response: $RESPONSE2"
# Extract and decode the recovered plaintext
RECOVERED_B64=$(echo "$RESPONSE2" | sed -n 's/.*"plaintext":"\([^"]*\)".*/\1/p')
if [ -z "$RECOVERED_B64" ]; then
echo "ERROR: no plaintext in decrypt response"
exit 1
fi
RECOVERED=$(echo "$RECOVERED_B64" | base64 -d 2>/dev/null)
echo "Recovered plaintext: $RECOVERED"
if [ "$RECOVERED" = "Hello, OTP world!" ]; then
echo ""
echo "=== ROUND-TRIP SUCCESS ==="
exit 0
else
echo ""
echo "=== ROUND-TRIP FAILED ==="
echo "Expected: Hello, OTP world!"
echo "Got: $RECOVERED"
exit 1
fi