Compare commits

...

33 Commits

Author SHA1 Message Date
Laan Tungir
64fbd5c874 v0.1.0 - CYD firmware v0.0.2: algorithm-based API upgrade (all verbs, all algorithms), vendored Keccak/SHAKE + PSA ed25519/x25519 for IDF v5.4, Web Serial test page, CYD docs, Teensy 4.1 port plan (1TB SDXC OTP pad), brainstorming READMEs for BLE/IR/NFC/FPGA signer concepts 2026-07-21 13:16:04 -04:00
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
Laan Tungir
8015742e29 v0.0.46 - Post-quantum crypto expansion: ML-DSA-65, SLH-DSA-128s, ML-KEM-768, ed25519, x25519, algorithm-based API 2026-07-16 16:11:46 -04:00
Laan Tungir
09f3ec2f7c Fix Gitea release creation: include created_at timestamp to work around Gitea bug where releases get epoch zero timestamp 2026-07-16 16:10:00 -04:00
Laan Tungir
5744b83288 v0.0.47 - Fix increment_and_push.sh build output visibility 2026-07-16 15:38:52 -04:00
Laan Tungir
21892c108e Fix increment_and_push.sh hanging by showing build output instead of suppressing it 2026-07-16 15:37:10 -04:00
Laan Tungir
344add841c v0.0.48 - . 2026-07-16 15:35:20 -04:00
Laan Tungir
3e3013dde1 v0.0.47 - . 2026-07-16 15:33:25 -04:00
Laan Tungir
11d3760d7b v0.0.46 - Fix release tagging for post-quantum crypto expansion 2026-07-16 15:27:34 -04:00
Laan Tungir
c5f1a70658 v0.0.47 - Added post-quantum cryptography (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) and standard ECC (ed25519, x25519) support with algorithm-based API 2026-07-16 15:14:57 -04:00
Laan Tungir
6fd7b8ce1f v0.0.45 - Display qrexec service name (qubes.NsignerRpc) in signer connection info; use human-readable timestamps in activity log; fix static release build by adding miner.c to Dockerfile.alpine-musl 2026-07-11 19:12:34 -04:00
Laan Tungir
1b5af2fd33 v0.0.44 - Add JavaScript and Python demo programs (client/demo_javascript.js, client/demo_python.py) demonstrating get_public_key, sign_event, and nip44 encrypt/decrypt via qrexec 2026-07-11 15:01:48 -04:00
Laan Tungir
9b47883330 v0.0.43 - Add C99 demo program (client/demo_c99.c) demonstrating get_public_key, sign_event, and nip44 encrypt/decrypt via qrexec; sync nostr_core_lib with reconnect fix 2026-07-11 14:55:16 -04:00
Laan Tungir
9a8657f663 v0.0.42 - Add C qrexec client example using high-level nostr_signer API with nostr_index selector; sync nostr_core_lib with qrexec transport and index support 2026-07-11 14:28:09 -04:00
Laan Tungir
478c3a569e v0.0.41 - Fix activity log label for index whitelist denials — shows 'DENIED:index-not-approved' instead of 'DENIED:no-match' 2026-07-11 13:57:13 -04:00
Laan Tungir
10208e5fac v0.0.40 - Add interactive index whitelist prompt after transport selection — users can restrict nostr_index values without CLI flags 2026-07-11 13:46:23 -04:00
Laan Tungir
922a45ce3a v0.0.39 - Remove qrexec one-shot (option 4) from interactive transport menu — only persistent listeners remain 2026-07-11 13:42:59 -04:00
Laan Tungir
d28f691aae v0.0.38 - Add --allow-index whitelist for nostr_index restriction — supports list (1,3,4), range (0-3), mixed (0,2-3), or 'all' 2026-07-11 13:11:36 -04:00
Laan Tungir
2e8ce777d8 v0.0.37 - Add interactive multi-transport selection menu at startup — users can select one or more transports (unix, qrexec bridge, TCP) from a menu instead of memorizing CLI flags 2026-07-11 11:31:21 -04:00
Laan Tungir
8ebdb50789 v0.0.36 - Update README with qrexec bridge docs, update start_nsigner.sh to support qrexec mode and extra args, add setup scripts 2026-07-11 11:09:41 -04:00
133 changed files with 32113 additions and 1283 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,7 +55,9 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev
# Copy source files # Copy source files
COPY src/ /build/src/ COPY src/ /build/src/
COPY libotppad/ /build/libotppad/
COPY resources/tui_continuous/ /build/resources/tui_continuous/ COPY resources/tui_continuous/ /build/resources/tui_continuous/
COPY resources/pqclean/ /build/resources/pqclean/
# Build nsigner as a fully static binary # Build nsigner as a fully static binary
RUN ARCH="$(uname -m)"; \ RUN ARCH="$(uname -m)"; \
@@ -70,6 +72,12 @@ RUN ARCH="$(uname -m)"; \
-I/build/nostr_core_lib/nostr_core \ -I/build/nostr_core_lib/nostr_core \
-I/build/nostr_core_lib/cjson \ -I/build/nostr_core_lib/cjson \
-I/build/resources/tui_continuous \ -I/build/resources/tui_continuous \
-I/build/resources/pqclean \
-I/build/resources/pqclean/common \
-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/main.c \
/build/src/secure_mem.c \ /build/src/secure_mem.c \
/build/src/mnemonic.c \ /build/src/mnemonic.c \
@@ -83,6 +91,33 @@ RUN ARCH="$(uname -m)"; \
/build/src/key_store.c \ /build/src/key_store.c \
/build/src/socket_name.c \ /build/src/socket_name.c \
/build/src/auth_envelope.c \ /build/src/auth_envelope.c \
/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 \
/build/resources/pqclean/crypto_sign/slh-dsa-128s/sign.c \
/build/resources/pqclean/crypto_sign/slh-dsa-128s/hash.c \
/build/resources/pqclean/crypto_sign/slh-dsa-128s/thash.c \
/build/resources/pqclean/crypto_sign/slh-dsa-128s/utils.c \
/build/resources/pqclean/crypto_sign/slh-dsa-128s/wots.c \
/build/resources/pqclean/crypto_sign/slh-dsa-128s/fors.c \
/build/resources/pqclean/crypto_sign/slh-dsa-128s/address.c \
/build/resources/pqclean/common/fips202.c \
/build/resources/pqclean/common/sha2.c \
/build/resources/pqclean/common/crypto_backend_openssl.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/reduce.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/ntt.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/cbd.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/verify.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/symmetric.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/poly.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/indcpa.c \
/build/resources/pqclean/crypto_kem/ml-kem-768/kem.c \
/build/resources/tui_continuous/tui_continuous.c \ /build/resources/tui_continuous/tui_continuous.c \
"$NOSTR_LIB" \ "$NOSTR_LIB" \
-o /build/nsigner_static \ -o /build/nsigner_static \

141
Makefile
View File

@@ -1,5 +1,5 @@
CC := gcc CC := gcc
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous 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 LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
SRC_DIR := src SRC_DIR := src
@@ -10,6 +10,31 @@ EXAMPLES_DIR := examples
TARGET_DEV := $(BUILD_DIR)/nsigner TARGET_DEV := $(BUILD_DIR)/nsigner
# PQClean ML-DSA-65 sources (Phase 3)
PQCLEAN_DIR := resources/pqclean
PQCLEAN_SOURCES := \
$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65/sign.c \
$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65/poly.c \
$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65/ntt.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/sign.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/hash.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/thash.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/utils.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/wots.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/fors.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/address.c \
$(PQCLEAN_DIR)/common/fips202.c \
$(PQCLEAN_DIR)/common/sha2.c \
$(PQCLEAN_DIR)/common/crypto_backend_openssl.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/reduce.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/ntt.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/cbd.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/verify.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/symmetric.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/poly.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/indcpa.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/kem.c
SOURCES := \ SOURCES := \
$(SRC_DIR)/main.c \ $(SRC_DIR)/main.c \
$(SRC_DIR)/secure_mem.c \ $(SRC_DIR)/secure_mem.c \
@@ -24,6 +49,13 @@ SOURCES := \
$(SRC_DIR)/key_store.c \ $(SRC_DIR)/key_store.c \
$(SRC_DIR)/socket_name.c \ $(SRC_DIR)/socket_name.c \
$(SRC_DIR)/auth_envelope.c \ $(SRC_DIR)/auth_envelope.c \
$(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 resources/tui_continuous/tui_continuous.c
HEADERS := HEADERS :=
@@ -40,16 +72,29 @@ TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope
TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth
TEST_MNEMONIC_INPUT_TARGET := $(BUILD_DIR)/test_mnemonic_input TEST_MNEMONIC_INPUT_TARGET := $(BUILD_DIR)/test_mnemonic_input
TEST_MINE_EVENT_TARGET := $(BUILD_DIR)/test_mine_event
TEST_PQ_CRYPTO_TARGET := $(BUILD_DIR)/test_pq_crypto
TEST_ED25519_X25519_TARGET := $(BUILD_DIR)/test_ed25519_x25519
TEST_ML_DSA_65_TARGET := $(BUILD_DIR)/test_ml_dsa_65
TEST_SLH_DSA_128S_TARGET := $(BUILD_DIR)/test_slh_dsa_128s
TEST_ML_KEM_768_TARGET := $(BUILD_DIR)/test_ml_kem_768
TEST_PUBKEY_FORMAT_TARGET := $(BUILD_DIR)/test_pubkey_format
TEST_ALGORITHM_API_TARGET := $(BUILD_DIR)/test_algorithm_api
EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client
EXAMPLE_GET_PUBKEY_TCP_TARGET := $(BUILD_DIR)/example_get_pubkey_tcp EXAMPLE_GET_PUBKEY_TCP_TARGET := $(BUILD_DIR)/example_get_pubkey_tcp
EXAMPLE_GET_PUBKEY_QREXEC_TARGET := $(BUILD_DIR)/example_get_pubkey_qrexec
EXAMPLE_PQ_SIGN_TARGET := $(BUILD_DIR)/example_pq_sign
EXAMPLE_PQ_KEM_TARGET := $(BUILD_DIR)/example_pq_kem
EXAMPLE_SSH_SIGN_TARGET := $(BUILD_DIR)/example_ssh_sign
DEMO_C99_TARGET := $(BUILD_DIR)/demo_c99
.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean .PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api examples test-client clean
all: dev all: dev
lib: lib:
cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,19,44 cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,13,19,44
dev: lib $(TARGET_DEV) dev: lib $(TARGET_DEV)
@@ -72,7 +117,7 @@ static-arm64:
firmware-feather: firmware-feather:
cd firmware/feather_s3_tft && idf.py build cd firmware/feather_s3_tft && idf.py build
test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-client
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV) test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
./$(TEST_INTEGRATION_TARGET) ./$(TEST_INTEGRATION_TARGET)
@@ -107,13 +152,37 @@ test-auth-envelope: $(TEST_AUTH_ENVELOPE_TARGET)
test-qrexec-auth: $(TEST_QREXEC_AUTH_TARGET) $(TARGET_DEV) test-qrexec-auth: $(TEST_QREXEC_AUTH_TARGET) $(TARGET_DEV)
./$(TEST_QREXEC_AUTH_TARGET) ./$(TEST_QREXEC_AUTH_TARGET)
test-mine-event: $(TEST_MINE_EVENT_TARGET) $(TARGET_DEV)
./$(TEST_MINE_EVENT_TARGET)
test-pq-crypto: $(TEST_PQ_CRYPTO_TARGET)
./$(TEST_PQ_CRYPTO_TARGET)
test-ed25519-x25519: $(TEST_ED25519_X25519_TARGET)
./$(TEST_ED25519_X25519_TARGET)
test-ml-dsa-65: $(TEST_ML_DSA_65_TARGET)
./$(TEST_ML_DSA_65_TARGET)
test-slh-dsa-128s: $(TEST_SLH_DSA_128S_TARGET)
./$(TEST_SLH_DSA_128S_TARGET)
test-ml-kem-768: $(TEST_ML_KEM_768_TARGET)
./$(TEST_ML_KEM_768_TARGET)
test-pubkey-format: $(TEST_PUBKEY_FORMAT_TARGET)
./$(TEST_PUBKEY_FORMAT_TARGET)
test-algorithm-api: $(TEST_ALGORITHM_API_TARGET)
./$(TEST_ALGORITHM_API_TARGET)
test-client: examples test-client: examples
examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(EXAMPLE_PQ_SIGN_TARGET) $(EXAMPLE_PQ_KEM_TARGET) $(EXAMPLE_SSH_SIGN_TARGET) $(DEMO_C99_TARGET)
$(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
@mkdir -p $(BUILD_DIR) @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 $(TEST_MNEMONIC_INPUT_TARGET): $(TEST_DIR)/test_mnemonic_input.c
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
@@ -127,13 +196,13 @@ $(TEST_SELECTOR_TARGET): $(TEST_DIR)/test_selector.c $(SRC_DIR)/selector.c $(SRC
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_selector.c $(SRC_DIR)/selector.c $(SRC_DIR)/role_table.c -o $(TEST_SELECTOR_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(TEST_DIR)/test_selector.c $(SRC_DIR)/selector.c $(SRC_DIR)/role_table.c -o $(TEST_SELECTOR_TARGET) $(LDFLAGS)
$(TEST_ENFORCEMENT_TARGET): $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(TEST_ENFORCEMENT_TARGET): $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/pq_crypto.c
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c -o $(TEST_ENFORCEMENT_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/pq_crypto.c -o $(TEST_ENFORCEMENT_TARGET) $(LDFLAGS)
$(TEST_DISPATCHER_TARGET): $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(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) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.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_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 $(TEST_POLICY_TARGET): $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
@@ -155,6 +224,38 @@ $(TEST_QREXEC_AUTH_TARGET): $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envel
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envelope.c -o $(TEST_QREXEC_AUTH_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envelope.c -o $(TEST_QREXEC_AUTH_TARGET) $(LDFLAGS)
$(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 $(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)
$(CC) $(CFLAGS) $(TEST_DIR)/test_pq_crypto.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/role_table.c -o $(TEST_PQ_CRYPTO_TARGET) $(LDFLAGS)
$(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 $(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 $(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 $(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 $(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 $(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 $(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 $(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 $(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_public_key_client.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_public_key_client.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS)
@@ -167,5 +268,25 @@ $(EXAMPLE_GET_PUBKEY_TCP_TARGET): $(EXAMPLES_DIR)/get_pubkey_tcp.c
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_pubkey_tcp.c -o $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_pubkey_tcp.c -o $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(LDFLAGS)
$(EXAMPLE_GET_PUBKEY_QREXEC_TARGET): $(EXAMPLES_DIR)/get_pubkey_qrexec.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_pubkey_qrexec.c -o $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(LDFLAGS)
$(EXAMPLE_PQ_SIGN_TARGET): $(EXAMPLES_DIR)/pq_sign_example.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/pq_sign_example.c -o $(EXAMPLE_PQ_SIGN_TARGET) $(LDFLAGS)
$(EXAMPLE_PQ_KEM_TARGET): $(EXAMPLES_DIR)/pq_kem_example.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/pq_kem_example.c -o $(EXAMPLE_PQ_KEM_TARGET) $(LDFLAGS)
$(EXAMPLE_SSH_SIGN_TARGET): $(EXAMPLES_DIR)/ssh_sign_example.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/ssh_sign_example.c -o $(EXAMPLE_SSH_SIGN_TARGET) $(LDFLAGS)
$(DEMO_C99_TARGET): $(CLIENT_DIR)/demo_c99.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(CLIENT_DIR)/demo_c99.c -o $(DEMO_C99_TARGET) $(LDFLAGS)
clean: clean:
rm -rf $(BUILD_DIR) rm -rf $(BUILD_DIR)

705
README.md
View File

@@ -20,7 +20,8 @@ This is a **program, not a daemon**:
- purpose/curve enforcement - purpose/curve enforcement
- request dispatch - request dispatch
- interactive terminal UI - interactive terminal UI
- transport adapter(s) - transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP)
- OTP one-time pad encryption (optional, with USB pad)
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state. You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
@@ -28,42 +29,28 @@ You run it when you need signing. You stop it when you are done. Closing the ter
### 2.1 Zero filesystem footprint ### 2.1 Zero filesystem footprint
At runtime, `n_signer` writes nothing to disk: At runtime, `n_signer` writes nothing to disk: no config files, no logs, no PID files, no lock files, no socket pathname artifacts. On Linux desktop, local IPC uses abstract namespace Unix sockets (`@name` semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
- no config files
- no logs
- no PID files
- no lock files
- no socket pathname artifacts
On Linux desktop, local IPC uses abstract namespace Unix sockets (`@name` semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
### 2.2 Crash = total wipe ### 2.2 Crash = total wipe
All sensitive and operational state exists only in-process RAM (mlock'd where applicable): All sensitive and operational state exists only in-process RAM (mlock'd where applicable): mnemonic-derived key material, role table, policy/approval decisions for the live session, activity display buffer. If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design.
- mnemonic-derived key material
- role table
- policy/approval decisions for the live session
- activity display buffer
If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. There is no persistence layer to scrape post-crash.
### 2.3 Single binary, no external dependencies ### 2.3 Single binary, no external dependencies
Runtime target is one statically-linked musl executable: Runtime target is one statically-linked musl executable: no shared libraries required at runtime, no interpreter/runtime VM dependency, no sidecar services, no helper daemon binaries. This reduces deployment variability and shrinks the runtime trust surface.
- no shared libraries required at runtime
- no interpreter/runtime VM dependency
- no sidecar services
- no helper daemon binaries
This reduces deployment variability and shrinks the runtime trust surface.
### 2.4 Always-attended operation ### 2.4 Always-attended operation
`n_signer` is intentionally human-attended. It stays attached to a terminal and can require explicit keystroke approval for unknown callers or sensitive actions. Human presence is part of the security model. `n_signer` is intentionally human-attended. It stays attached to a terminal and can require explicit keystroke approval for unknown callers or sensitive actions. Human presence is part of the security model.
### 2.5 Secret memory backing: `mlock` today, `memfd_secret` where supported
Sensitive buffers (mnemonic, master seed, per-role private keys) live in `mlock`'d RAM via [`secure_buf_alloc`](src/secure_mem.c) and are zeroized with `secure_memzero` on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.
`memfd_secret(2)` (Linux `CONFIG_SECRETMEM`) is a stronger backing: pages are invisible to `/proc/pid/mem`, `ptrace`, `kcore`, and hibernation dumps, and are kernel-guaranteed to be wiped on exit. It is the intended tier-1 upgrade for the `secure_buf_alloc` path on hosts that can materialize secretmem pages — bare metal and KVM guests.
It is **not** used yet because Qubes OS VMs are Xen guests: the `memfd_secret` syscall succeeds and returns a valid `/secretmem` fd, but the first page-fault into the mapping raises `SIGBUS` (the Xen hypervisor cannot back the restricted pages). Since Qubes integration is a primary deployment target, the `mlock` path remains correct and universal. A future implementation will probe `memfd_secret` by writing a canary byte into a trial mapping and fall back to `mlock` on `SIGBUS`/`ENOSYS`, enabling the stronger backing automatically on non-Xen hosts.
## 3. How it works ## 3. How it works
### 3.1 Startup phase (TUI input mode) ### 3.1 Startup phase (TUI input mode)
@@ -74,9 +61,12 @@ When started, `n_signer` immediately enters terminal input mode:
- On `E`: prompt for mnemonic with terminal echo disabled, then validate. - On `E`: prompt for mnemonic with terminal echo disabled, then validate.
- On `G`: generate a fresh 12-word BIP-39 mnemonic from `getrandom(2)`, display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step. - On `G`: generate a fresh 12-word BIP-39 mnemonic from `getrandom(2)`, display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
2. Build in-memory role/selector state from the mnemonic. 2. Build in-memory role/selector state from the mnemonic.
3. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override). 3. **Interactive transport selection** (if no `--listen` flag given and stdin is a TTY): choose one or more of: Local Unix socket, Qubes qrexec bridge, FIPS/TCP listener (framed JSON), HTTP listener (curl-friendly).
4. Initialize transport endpoints and bind the socket. 4. **Index whitelist** (optional): restrict which `nostr_index` values this session can access.
5. Switch to running status display, with the signer name and socket address shown in the banner. 5. **OTP pad selection** (optional): auto-scans attached USB drives for OTP pads and offers to bind one. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md).
6. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
7. Initialize transport endpoints and bind the socket.
8. Switch to running status display.
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash. No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
@@ -89,48 +79,56 @@ These modes avoid putting mnemonic material in argv/environment and are designed
### 3.2 Running phase (status display + signer) ### 3.2 Running phase (status display + signer)
After unlock, terminal becomes a live status and control console. Example layout: After unlock, the terminal becomes a live status and control console rendered by [`render_status()`](src/main.c). The top frame shows the program name and version; below it are the Roles and Activity sections, followed by a single status line. Connection instructions are not shown by default — press `d` to display them on demand. Example layout:
```text ```text
n_signer v0.x | foreground session active n_signer v0.0.53 > Main Menu
transport: unix-abstract:@nsigner
session: unlocked (RAM-only)
Roles Roles:
----- Role Purpose Curve Derivation path
main purpose=nostr curve=secp256k1 selector=role:main main nostr secp256k1 m/44'/1237'/0'/0/0
ops purpose=nostr curve=secp256k1 selector=nostr_index:7 nostr_idx_1 nostr secp256k1 m/44'/1237'/1'/0/0
backup purpose=bitcoin curve=secp256k1 selector=role_path:m/84'/0'/0'/0/5 backup bitcoin secp256k1 m/84'/0'/0'/0/5
Pending approvals Activity (latest first):
----------------- 16:03:11 allow caller=uid:1000 method=nostr_get_public_key role=main
(none) 16:02:44 prompt caller=uid:1000 method=nostr_sign_event role=ops
16:02:46 allow caller=uid:1000 method=nostr_sign_event role=ops
15:59:10 deny caller=uid:1001 method=nostr_sign_event error=unauthorized
Activity (latest first) session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
-----------------------
16:03:11 allow caller=uid:1000 method=get_public_key role=main
16:02:44 prompt caller=uid:1000 method=sign_event role=ops
16:02:46 allow caller=uid:1000 method=sign_event role=ops
15:59:10 deny caller=uid:1001 method=sign_event error=unauthorized
Hotkeys l lock/reunlock
------- r refresh
a toggle auto-approve(prompt) for this session a toggle auto-approve
r refresh d display connections
l lock/reunlock session q/x quit
q quit
``` ```
The **Derivation path** column shows the full BIP-44 path for each role's key. For `nostr_index` roles this is `m/44'/1237'/<n>'/0/0` (NIP-06); for `role_path` roles it's the explicit path.
The signer's name (`nsigner_hairy_dog` in this example) appears in the **status line** at the bottom (`signer=nsigner_hairy_dog`). See [§4.1](#41-linux-desktop-abstract-namespace-unix-socket) for how the name is generated.
### Connection instructions (press `d`)
Pressing `d` clears the screen and shows each active transport as a titled block with the connection string and an example client command. Press any key to return to the status display.
Hotkeys (active while the status display is shown):
- `a` — toggle auto-approve (prompt) for this session
- `r` — refresh the display
- `d` — display connection instructions (press any key to return)
- `l` — lock / re-unlock the session
- `q` — quit
### 3.3 Approval prompts ### 3.3 Approval prompts
When a request needs confirmation, `n_signer` interrupts the status view with a prompt and waits for a local keystroke. When a request needs confirmation, `n_signer` interrupts the status view with a prompt and waits for a local keystroke.
Example:
```text ```text
Approval required Approval required
caller: uid:1000 caller: uid:1000
method: sign_event method: nostr_sign_event
selector: role=ops selector: role=ops
purpose/curve: nostr/secp256k1 purpose/curve: nostr/secp256k1
@@ -145,125 +143,431 @@ No response is emitted to caller until the local user decides.
- `l` locks the session in-place: signing stops until mnemonic is re-entered. - `l` locks the session in-place: signing stops until mnemonic is re-entered.
- terminal close or process termination has the same effect as quit: total state wipe. - terminal close or process termination has the same effect as quit: total state wipe.
## 4. Mnemonic-rooted role model ## 4. API
`n_signer` derives many role-scoped keys from one mnemonic root. Requests select a role using explicit selectors, then enforcement checks operation compatibility. `n_signer` exposes a JSON-RPC 2.0-style request/response protocol. Every request is a single JSON object; every response is a single JSON object. This section is the complete, authoritative description of the API.
### 4.1 Nostr shorthand: nostr_index For the migration plan from the legacy verb names, see [`plans/legacy_verb_aliases.md`](plans/legacy_verb_aliases.md).
Nostr shorthand keeps the explicit index selector: ### 4.1 Request format
`m/44'/1237'/<nostr_index>'/0/0` ```json
{ "id": "<string>", "method": "<verb>", "params": [ <arg0>, <arg1>, ..., { <options> } ] }
Use `nostr_index` only for Nostr-indexed roles.
### 4.2 Full derivation path: role_path
For non-Nostr or advanced layouts, caller may use full `role_path` selector.
Security rule: `role_path` must match a pre-registered role entry. Unregistered ad-hoc derivation requests are rejected.
### 4.3 What memorizing your seed phrase gets you
A single memorized mnemonic can deterministically recover multiple key domains through role definitions, not just one identity.
Examples include Nostr roles, Bitcoin branches, and future application-specific paths. See [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md) for the maintained use-case catalog and caveats.
## 5. Wire contract (JSON-RPC)
Request shape is JSON-RPC with NIP-46-style methods and optional trailing selector options.
```jsonc
{ "id": "1", "method": "get_public_key", "params": [] }
{ "id": "2", "method": "sign_event", "params": ["<event_json>", { "role": "main" }] }
{ "id": "3", "method": "sign_event", "params": ["<event_json>", { "nostr_index": 7 }] }
{ "id": "4", "method": "sign_event", "params": ["<event_json>", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] }
``` ```
Implemented signer verbs in this build: - `id` — caller-supplied string echoed verbatim in the response. Used to match requests to responses.
- `method` — the verb name (see [§4.2](#42-verbs)).
- `params` — a JSON array. Positional arguments come first; the **last array element** is conventionally an options object. The options object is optional for most verbs.
- `get_public_key` ### 4.2 Response format
- `sign_event`
- `nip04_encrypt` / `nip04_decrypt`
- `nip44_encrypt` / `nip44_decrypt`
Selector resolution order: Success:
```json
{ "id": "<string>", "result": <value> }
```
1. `role` `result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it.
2. `nostr_index`
3. `role_path`
4. default role `main`
Conflicting selectors are rejected (`ambiguous_role_selector`). Error:
```json
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
```
Representative error codes: Error codes:
- `invalid_request` | Code | Message | Meaning |
- `method_not_found` |-------|-------------------------------|--------------------------------------------------------------------|
- `ambiguous_role_selector` | -32700| `parse_error` | Request was not valid JSON. |
- `unknown_role` | -32600| `invalid_request` | Missing `id`, `method`, or `params`, or `params` is not an array. |
- `purpose_mismatch` | -32601| `method_not_found` | Unknown verb, or verb not valid for the selected algorithm. |
- `curve_mismatch` | -32602| `invalid_params` | Malformed arguments (bad hex, wrong length, missing field, etc.). |
- `unauthorized` | 1001 | `ambiguous_role_selector` | More than one role selector was supplied. |
- `approval_denied` | 1002 | `unknown_role` | No role matched the selector. |
- `internal_error` | 1003 | `no_default_role` | No selector given and no `main` role exists. |
| 1004 | `purpose_mismatch` | Role's purpose is not valid for this verb. |
| 1005 | `curve_mismatch` | Role's curve is not valid for this verb. |
| 1006 | `mnemonic_not_loaded` | No mnemonic is loaded in the signer. |
| 1007 | `no_termination_condition` | `nostr_mine_event` called without `difficulty` or `timeout_sec`. |
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
## 6. Purpose and curve enforcement ### 4.3 Verbs
Selector resolution chooses *which* role. Enforcement decides *whether the requested method is valid* for that role. All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `nostr_index` and implement Nostr-protocol-specific serialization on top of the raw crypto.
Example: | Verb | Algorithms | Positional params | Options |
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
| `get_public_key` | all key-deriving algorithms | — | `algorithm`, `index` |
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>` | `algorithm`, `index`, `scheme`* |
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>`, `<signature_hex>` | `algorithm`, `index`, `scheme`* |
| `encapsulate` | ml-kem-768 | `<peer_pubkey_hex>` | `algorithm` |
| `decapsulate` | ml-kem-768 | `<ciphertext_hex>` | `algorithm`, `index` |
| `derive_shared_secret` | x25519 | `<peer_pubkey_hex>` | `algorithm`, `index` |
| `derive` | secp256k1 | `<data>` | `algorithm`, `index` (required) |
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `nostr_index`, `format` |
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index` |
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` |
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
- `sign_event` requires `purpose="nostr"` and `curve="secp256k1"`. \* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
- If caller selects a Bitcoin-role key for `sign_event`, request fails with `purpose_mismatch`.
This prevents cross-protocol misuse inside one mnemonic-rooted signer process. #### Enforcement matrix
## 7. Transport | Verb | Valid algorithms |
|----------------------------|-----------------------------------------------|
| `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
| `encapsulate` / `decapsulate` | ml-kem-768 |
| `derive_shared_secret` | x25519 |
| `derive` | secp256k1 |
| `encrypt` / `decrypt` | otp |
| `get_public_key` | all key-deriving algorithms |
| `nostr_*` | secp256k1 (Nostr protocol) |
### 7.1 Linux desktop: abstract namespace Unix socket Any unlisted `(verb, algorithm)` pair is rejected with `algorithm_not_supported_for_verb` (1010).
Primary local transport is AF_UNIX abstract namespace. ### 4.4 Algorithms
Each running `nsigner` process binds to a unique abstract name of the form `@nsigner_<word1>_<word2>`, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. `@nsigner_hairy_dog`). This lets multiple signers coexist on one host. All keys derive deterministically from the loaded BIP-39 mnemonic. The caller selects an algorithm by name and a derivation `index` (an integer `<n>` substituted into the algorithm's derivation path). OTP is the exception — it does not derive a key, it consumes a bound one-time pad (see [§4.4.3](#443-otp)).
Properties: #### 4.4.1 Algorithm table
- no pathname in filesystem | Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
- endpoint lifetime bound to process/kernel namespace |-----------------|-----------------|---------------|---------------------------------------|-------------------------------|
- no stale socket files | `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
- caller identity via peer credentials (`SO_PEERCRED`) | `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
- per-launch random name avoids collisions between concurrent instances and leaks no seed-derived identifier | `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0/0'` → DRBG | 4032 / 1952 |
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
#### 4.4.2 Key derivation
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces PQClean's `randombytes()` callback during keygen. Same mnemonic, same index, same key pair every time.
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
The PQ implementations are vendored from [PQClean](https://github.com/PQClean/PQClean) (public domain / CC0). All six algorithms are always compiled in on every target. The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
#### 4.4.3 OTP
The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response.
### 4.5 Examples
#### `get_public_key`
```json
{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }
```
Response:
```json
{ "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" }
```
`key_id` is the first 16 hex characters of the public key — a short display identifier.
#### `sign`
```json
{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }
```
Response:
```json
{ "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" }
```
The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`:
```json
{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }
```
#### `verify`
```json
{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }
```
Response:
```json
{ "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" }
```
The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library.
#### `encapsulate` (ML-KEM-768)
```json
{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }
```
Response:
```json
{ "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
```
`peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`.
#### `decapsulate` (ML-KEM-768)
```json
{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }
```
Response:
```json
{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
```
#### `derive_shared_secret` (X25519)
```json
{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }
```
Response:
```json
{ "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" }
```
`peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
#### `derive` (secp256k1 HMAC-SHA256)
```json
{ "id": "10", "method": "derive", "params": [ "<data>", { "algorithm": "secp256k1", "index": 0 } ] }
```
Response:
```json
{ "id": "10", "result": "{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}" }
```
Computes `HMAC-SHA256(privkey, data)` where `privkey` is the secp256k1 private key derived on demand at `(algorithm: "secp256k1", index: N)`. `data` is an arbitrary caller-supplied UTF-8 string. Returns the 32-byte digest as 64 lowercase hex chars.
`index` is **required** (no default) — forces conscious selection of which derived key to use as the HMAC key. Omitting it returns `missing_index`.
This is a generic key-derived MAC primitive. Callers domain-separate by prefixing their own label into `data` (e.g. `"myapp/identifier-v1:<path>"`). The private key never leaves the signer; only the digest is returned. Use cases include deterministic, per-user, opaque identifiers for NIP-33 parameterized-replaceable events (e.g. bookmark folder `d` tags) where the same logical name must produce the same `d` tag across devices.
#### `encrypt` / `decrypt` (OTP)
```json
{ "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] }
{ "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] }
```
`encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used.
`encrypt` response:
```json
{
"id": "8",
"result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}"
}
```
`decrypt` response:
```json
{ "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" }
```
If no pad is bound at startup, the error is `-32601` `otp_pad_not_bound`.
#### `nostr_get_public_key`
```json
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "nostr_index": 0 } ] }
```
Response (default): a plain 64-hex-char secp256k1 public key string.
Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
#### `nostr_sign_event`
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
```json
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "nostr_index": 0 } ] }
```
`<event_json>` is the unsigned event object:
```json
{ "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" }
```
Response: the signed event JSON string, with `id` and `sig` populated.
#### `nostr_mine_event`
Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive.
```json
{
"id": "12",
"method": "nostr_mine_event",
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 } ]
}
```
| Option | Required | Default | Meaning |
|---------------|-------------------------------|---------|---------------------------------------------------------------|
| `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. |
| `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. |
| `threads` | no | 1 | Mining threads (clamped to 1..32). |
At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned.
Response:
```json
{
"id": "12",
"result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}"
}
```
Errors:
- `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given.
- `1008` `mining_failed` — internal mining error.
#### `nostr_nip04_encrypt` / `nostr_nip04_decrypt`
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
```json
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
```
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
#### `nostr_nip44_encrypt` / `nostr_nip44_decrypt`
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
```json
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
```
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
### 4.6 Role-based selectors (Nostr verbs)
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Supported selectors:
| Selector | Meaning |
|----------------|--------------------------------------------------|
| `nostr_index` | NIP-06 index `n` → path `m/44'/1237'/<n>'/0/0` |
| `role` | Name of a pre-registered role entry |
| `role_path` | Full BIP-44 derivation path (must match a registered role) |
Selector resolution order: `role``nostr_index``role_path` → default role `main`. Conflicting selectors are rejected with `ambiguous_role_selector` (1001). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
### 4.7 Pre-approval
Pre-approval entries skip the interactive prompt for matching requests. They are configured at startup with `--preapprove`.
Algorithm-based:
```bash
nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign,verify
nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsulate
```
Nostr (role-based):
```bash
nsigner --preapprove caller=uid:1000,nostr_index=0,verb=nostr_sign_event,nostr_get_public_key
```
A `*` wildcard matches any caller, role, or verb. Index ranges use `min-max` syntax. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).
## 5. Transports
The API is transport-independent. The same JSON request works over every transport; only the framing differs.
| Transport | `--listen` flag | Framing | Caller identity |
|-----------|--------------------------------|------------------------------------------|----------------------------|
| Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED``uid:<n>` |
| stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid |
| qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` |
| FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) |
| HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) |
### 5.1 HTTP examples
Start the signer:
```bash
nsigner --listen http:127.0.0.1:11111 --allow-all
```
Get a public key:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
```
Sign a Nostr event:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
```
OTP encrypt:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}'
```
### 5.2 Unix socket examples (framed mode)
```bash
# get_public_key
nsigner --socket-name nsigner client \
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
# Sign a Nostr event
nsigner --socket-name nsigner client \
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
# ed25519 sign
nsigner --socket-name nsigner client \
'{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}'
```
### 5.3 Linux desktop: abstract namespace Unix socket
Primary local transport is AF_UNIX abstract namespace. Each running `nsigner` process binds to a unique abstract name of the form `@nsigner_<word1>_<word2>`, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. `@nsigner_hairy_dog`). This lets multiple signers coexist on one host.
Properties: no pathname in filesystem; endpoint lifetime bound to process/kernel namespace; no stale socket files; caller identity via peer credentials (`SO_PEERCRED`); per-launch random name avoids collisions and leaks no seed-derived identifier.
Naming rules: Naming rules:
- Default: random pick at startup, displayed in the status line.
- Default: random pick at startup, displayed in the TUI banner. - Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name.
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name (useful for scripts and tests). - Collision: if the chosen random name is already bound, `nsigner` retries with a fresh pair up to 8 times before erroring out.
- Collision: if the chosen random name is already bound by another process, `nsigner` retries with a fresh pair up to 8 times before erroring out with a hint to use an explicit name override.
Discovery: Discovery:
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`. - `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
- `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout. - `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
- `nsigner --listen qrexec` is the same stdio framing mode, but caller identity can be derived from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`). - `nsigner --listen qrexec` is the same stdio framing, but caller identity comes from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
- `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables TCP listening for non-AF_UNIX clients (for example `tcp:127.0.0.1:8080`, `tcp:[::]:8080`, or `tcp:[fd00::1234]:8080`). - `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables FIPS/TCP listening (framed JSON, not HTTP).
- `nsigner bridge --to <socket-name>` is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as the `qubes.NsignerRpc` service entrypoint. See [§8.3](#83-qubes-os-qube) and [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md). - `nsigner --listen http:HOST:PORT` enables HTTP listening for curl-friendly access. CORS headers included for browser access. Defaults to localhost; pass `http:0.0.0.0:PORT` to expose externally.
- `--bridge-source-trusted` (unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed `{"qrexec_source":"<vm>"}` preamble before the request, and the caller identity is composed as `qubes:<vm>` — matching the native qrexec identity path. This enables a persistent signer (mnemonic in mlock'd RAM) to receive qrexec-routed requests without spawning a fresh process per call. - `nsigner bridge --to <socket-name>` is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as the `qubes.NsignerRpc` service entrypoint. See [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md).
- `--bridge-source-trusted` (unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed `{"qrexec_source":"<vm>"}` preamble before the request, and the caller identity is composed as `qubes:<vm>`.
### 7.2 ESP32 MCU: TinyUSB composite (CDC + WebUSB) ### 5.4 ESP32 MCU: TinyUSB composite (CDC + WebUSB)
On Feather S3 TFT, MCU targets run the same core signer modules behind a TinyUSB composite transport: On Feather S3 TFT, MCU targets run the same core signer modules behind a TinyUSB composite transport: CDC-ACM framed JSON-RPC for serial tooling (`/dev/ttyACM*`) and Vendor/WebUSB framed JSON-RPC for browser tooling. Core signer logic stays the same; only transport/UI bindings change.
- CDC-ACM framed JSON-RPC for serial tooling (`/dev/ttyACM*`) ### 5.5 Caller verification
- Vendor/WebUSB framed JSON-RPC for browser tooling
Core signer logic stays the same; only transport/UI bindings change.
### 7.3 NIP-46 relay flow (both platforms)
NIP-46 request/response flow can be bridged on both desktop and MCU transports. The JSON-RPC signer contract remains consistent while the outer carrier differs.
### 7.4 Caller verification
Every transport must provide concrete caller identity before policy evaluation. Every transport must provide concrete caller identity before policy evaluation.
@@ -273,153 +577,131 @@ Every transport must provide concrete caller identity before policy evaluation.
Identity verification and interactive approval are separate layers. Passing identity checks does not bypass prompt requirements. Identity verification and interactive approval are separate layers. Passing identity checks does not bypass prompt requirements.
## 8. Platform targets ## 6. Platform targets
### 8.1 Linux desktop (primary) ### 6.1 Linux desktop (primary)
Primary deployment is a local, foreground terminal program with abstract namespace socket transport. Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
### 8.2 ESP32 / MCU ### 6.2 ESP32 / MCU
MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps transport/UI for constrained hardware. The Feather path currently uses TinyUSB composite USB (CDC + WebUSB) plus TFT/buttons for attended approvals. MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps transport/UI for constrained hardware. The Feather path currently uses TinyUSB composite USB (CDC + WebUSB) plus TFT/buttons for attended approvals.
### 8.3 Qubes OS qube ### 6.3 Qubes OS qube
Qubes deployment runs `n_signer` in a dedicated signer qube (e.g. `nostr_signer`) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation). Qubes deployment runs `n_signer` in a dedicated signer qube (e.g. `nostr_signer`) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
Two transport paths are supported: Three transport paths are supported:
**FIPS/TCP** — the signer listens on `tcp:[::]:8080` and FIPS carries traffic between qubes as an IPv6 mesh substrate. See [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md). **FIPS/TCP** — the signer listens on `tcp:[::]:11111` and FIPS carries traffic between qubes as an IPv6 mesh substrate. See [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md).
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `nsigner bridge` relay (the `qubes.NsignerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>` (from `QREXEC_REMOTE_DOMAIN`), relayed via a trusted preamble. See [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md) for the full design. **HTTP** — the signer listens on `http:127.0.0.1:11111` for curl-friendly access within the same qube. No auth envelopes required (relies on localhost binding + policy/approval prompts).
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `nsigner bridge` relay (the `qubes.NsignerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>`. See [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md) for the full design.
#### Qrexec bridge setup #### Qrexec bridge setup
**In the signer qube** (`nostr_signer`): **In the signer qube** (`nostr_signer`):
```bash ```bash
# Install nsigner and the qrexec service
bash setup_signer_qube.sh # from packaging/qubes/ bash setup_signer_qube.sh # from packaging/qubes/
# Start the persistent signer (mnemonic entered at terminal, in mlock'd RAM)
~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted ~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted
``` ```
**In dom0**: **In dom0**:
```bash ```bash
# Install policy and tag the signer qube
bash setup_dom0.sh nostr_signer # from packaging/qubes/ bash setup_dom0.sh nostr_signer # from packaging/qubes/
``` ```
The dom0 policy allows trusted caller qubes without a popup (memory isolation is the real security boundary) and asks for confirmation from any other qube. The signer's own approval prompt at the `nostr_signer` terminal is the operation-level gate. The dom0 policy allows trusted caller qubes without a popup (memory isolation is the real security boundary) and asks for confirmation from any other qube. The signer's own approval prompt at the `nostr_signer` terminal is the operation-level gate.
**From a caller qube**: **From a caller qube**:
```bash ```bash
# JavaScript example (uses qrexec-client-vm, no auth envelope needed)
node examples/n_signer_qube_example_qrexec.js nostr_signer node examples/n_signer_qube_example_qrexec.js nostr_signer
``` ```
Setup scripts and policy are in [`packaging/qubes/`](packaging/qubes/). See also [`documents/QUBES_OS.md`](documents/QUBES_OS.md) and [`documents/qubes_client_examples.md`](documents/qubes_client_examples.md). Setup scripts and policy are in [`packaging/qubes/`](packaging/qubes/). See also [`documents/QUBES_OS.md`](documents/QUBES_OS.md) and [`documents/qubes_client_examples.md`](documents/qubes_client_examples.md).
## 9. Usage ## 7. Usage
### 9.1 Run the program ### 7.1 Run the program
```bash ```bash
nsigner nsigner
``` ```
Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the TUI banner shows the randomly assigned signer name and its abstract socket address. Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the running status display shows the randomly assigned signer name and its abstract socket address.
To force a specific socket name (e.g. for scripted clients): To force a specific socket name (e.g. for scripted clients):
```bash ```bash
nsigner --name my_test_signer nsigner --name my_test_signer
``` ```
Qubes/qrexec service mode (single framed request over stdin/stdout): Other transport modes:
```bash ```bash
nsigner --listen qrexec nsigner --listen qrexec # Qubes qrexec (single framed request over stdin/stdout)
nsigner --listen stdio # Generic stdio (single framed request over stdin/stdout)
nsigner --listen tcp:[::]:11111 # FIPS/TCP (framed JSON, no TUI)
nsigner --listen http:127.0.0.1:11111 # HTTP (curl-friendly, no TUI)
``` ```
Generic stdio transport mode (single framed request over stdin/stdout): With OTP pad bound:
```bash ```bash
nsigner --listen stdio nsigner --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --allow-all
``` ```
TCP transport mode (no TUI; serves requests until terminated): Qrexec bridge mode (stateless relay to a persistent signer's unix socket):
```bash
nsigner --listen tcp:[::]:8080
```
Qrexec bridge mode (stateless relay to a persistent signer's unix socket; used as the `qubes.NsignerRpc` service):
```bash ```bash
nsigner bridge --to nsigner nsigner bridge --to nsigner
``` ```
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble): Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
```bash ```bash
nsigner --listen unix --socket-name nsigner --bridge-source-trusted nsigner --listen unix --socket-name nsigner --bridge-source-trusted
``` ```
### 9.2 Send a request (client mode) ### 7.2 Send a request (client mode)
From another terminal, target the signer by its socket name: From another terminal, target the signer by its socket name:
```bash ```bash
nsigner --socket-name nsigner_hairy_dog client '{"id":"1","method":"get_public_key","params":[]}' nsigner --socket-name nsigner_hairy_dog client '{"id":"1","method":"nostr_get_public_key","params":[]}'
``` ```
If only one signer is running you can omit the override and the client will use the default discovery rule. If only one signer is running you can omit the override and the client will use the default discovery rule.
Example signing request: Example signing request:
```bash ```bash
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}' nsigner -n nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
``` ```
### 9.3 List running signers ### 7.3 List running signers
```bash ```bash
nsigner list nsigner list
``` ```
Prints the abstract socket names of any currently running `nsigner` instances, e.g.: Prints the abstract socket names of any currently running `nsigner` instances, e.g.:
```text ```text
@nsigner_hairy_dog @nsigner_hairy_dog
@nsigner_brave_canyon @nsigner_brave_canyon
``` ```
### 9.4 Example session ### 7.4 Example session
Terminal A: Terminal A:
```text ```text
$ nsigner $ nsigner
[unlock] enter mnemonic: [unlock] enter mnemonic:
[ok] session unlocked System is ready and waiting for connections on @nsigner_hairy_dog.
signer name : hairy dog [prompt] caller=uid:1000 method=nostr_sign_event role=main -> allow? (y/n)
socket : @nsigner_hairy_dog
[listen] unix-abstract:@nsigner_hairy_dog
[prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n)
``` ```
Terminal B: Terminal B:
```text ```text
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}' $ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
{"id":"2","result":"<signed_event_json>"} {"id":"2","result":"<signed_event_json>"}
``` ```
## 10. Build and versioning ## 8. Build and versioning
Build outputs a single executable artifact. Build outputs a single executable artifact.
@@ -431,48 +713,13 @@ Build outputs a single executable artifact.
- [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`) - [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`)
Local dev build: Local dev build:
```bash ```bash
make dev make dev
./build/nsigner --version ./build/nsigner --version
``` ```
Static build: Static build:
```bash ```bash
./build_static.sh ./build_static.sh
./build/nsigner_static_x86_64 --version ./build/nsigner_static_x86_64 --version
``` ```
## 11. Implemented adjuncts and future work
### Implemented PoC
- **MCU / USB signer (Feather ESP32-S3 Reverse TFT).** Working PoC in [`firmware/feather_s3_tft`](firmware/feather_s3_tft). Single TinyUSB composite USB device exposes both CDC-ACM and WebUSB Vendor interfaces. Same dispatcher serves both transports with auth envelope verification, on-device TFT prompts, and physical button approval. See [`firmware/README.md`](firmware/README.md) and [`plans/feather_tinyusb_composite.md`](plans/feather_tinyusb_composite.md).
### Future work (deferred)
These items are designed and worth doing, but are not in the current implementation scope. They are listed here so they are not lost.
- **`--listen http:[addr]:port` mode.** Today the TCP listener speaks 4-byte big-endian length-prefixed framed JSON-RPC, which is correct for low-overhead local IPC but is not directly reachable from web browsers (`fetch`, `XMLHttpRequest`, `curl`). A small additional listener that wraps the same dispatcher in minimal HTTP/1.1 (`POST /rpc`, `Content-Type: application/json`, `Content-Length`-framed body, JSON response) would let standard HTTP clients talk to `nsigner` without any custom framing code. The existing auth envelope (`kind:27235`) and JSON-RPC contract are unchanged; only the outer framing differs. CORS allow on the response would let browser extensions and (with TLS) HTTPS pages reach a remote `nsigner` over FIPS or any other carrier. See the discussion in [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md) section 9 ("Next hardening steps").
- **NIP-46 bunker / relay transport.** Tracked in [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md).
- **Browser extension.** Tracked in [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md). NIP-07 surface forwarding to a running `nsigner` instance.
## 12. Document map
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
- [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md): client integration contract and framing behavior
- [`documents/QUBES_OS.md`](documents/QUBES_OS.md): Qubes OS deployment/integration checklist for dedicated signer qubes
- [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md): Tier-1 FIPS deployment runbook using loopback TCP listener
- [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md): design for the qrexec → unix-socket bridge transport (persistent signer, no mnemonic on disk)
- [`packaging/qubes/`](packaging/qubes/): qrexec service script, dom0 policy, and setup scripts for Qubes deployment
- [`examples/n_signer_qube_example_qrexec.js`](examples/n_signer_qube_example_qrexec.js): JavaScript caller example using qrexec (no network, no auth envelope)
- [`examples/n_signer_qube_example_fips.js`](examples/n_signer_qube_example_fips.js): JavaScript caller example using FIPS/TCP with auth envelope
- [`examples/get_pubkey_tcp.c`](examples/get_pubkey_tcp.c): C caller example using TCP transport with auth envelope
- [`plans/nsigner.md`](plans/nsigner.md): implementation plan and sequencing
- [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md): seed phrase domain/use catalog and caveats
- [`firmware/feather_s3_tft`](firmware/feather_s3_tft): Feather ESP32-S3 Reverse TFT firmware (TinyUSB composite CDC + WebUSB signer PoC)
- [`plans/feather_tinyusb_composite.md`](plans/feather_tinyusb_composite.md): firmware Phase 7b plan and outcome (TinyUSB composite USB transport)
- [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md): browser extension exposing NIP-07 over `nsigner`
- [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md): deferred NIP-46 relay-mode signer transport
- [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration

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_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_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_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(..., "sign_event", ...)` | | `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_set_auth` | `nsigner_client_set_auth` or `nostr_signer_nsigner_set_auth` |
| `nsigner_client_request` / `request_raw` | `nsigner_client_call` (returns parsed cJSON result) | | `nsigner_client_request` / `request_raw` | `nsigner_client_call` (returns parsed cJSON result) |
@@ -36,6 +36,75 @@ for the full integration contract.
The `nsigner ... client '<json>'` subcommand in [`src/main.c`](../src/main.c) is The `nsigner ... client '<json>'` subcommand in [`src/main.c`](../src/main.c) is
unaffected — it has its own raw framing pass-through and never used this directory. unaffected — it has its own raw framing pass-through and never used this directory.
## Multi-Algorithm and Post-Quantum Verbs
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).
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 |
| `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"`.
### `get_public_key` response format
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 when the result is a JSON object.
### Key sizes
| Algorithm | Pub key | Priv key | Signature | Ciphertext | Shared secret |
|---|---|---|---|---|---|
| secp256k1 | 32 B | 32 B | 64 B | — | — |
| ed25519 | 32 B | 32 B | 64 B | — | — |
| x25519 | 32 B | 32 B | — | — | 32 B |
| ML-DSA-65 | 1952 B | 4032 B | 3309 B | — | — |
| SLH-DSA-128s | 32 B | 64 B | 7856 B | — | — |
| ML-KEM-768 | 1184 B | 2400 B | — | 1088 B | 32 B |
### Example clients
- [`examples/pq_sign_example.c`](../examples/pq_sign_example.c) — ML-DSA-65 sign
- [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) — ML-KEM-768 encaps/decaps
- [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) — ed25519 SSH sign
See [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md)
section 11 for the full multi-algorithm specification, derivation paths, and
example request/response transcripts.
## Why ## Why
Per [`plans/nsigner_integration_plan.md`](../resources/nostr_core_lib/plans/nsigner_integration_plan.md) Per [`plans/nsigner_integration_plan.md`](../resources/nostr_core_lib/plans/nsigner_integration_plan.md)

322
client/demo_c99.c Normal file
View File

@@ -0,0 +1,322 @@
/*
* demo_c99.c — comprehensive C99 demo for connecting to a 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. 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: 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)
* - nostr_signer_nsigner_set_nostr_index() — select key by NIP-06 index
* - nostr_signer_get_public_key() — get pubkey
* - nostr_signer_sign_event() — sign an event
* - nostr_signer_nip44_encrypt() — encrypt
* - nostr_signer_nip44_decrypt() — decrypt
*
* Prerequisites:
* - n_signer running in the target qube with --bridge-source-trusted
* - qubes.NsignerRpc service installed in the target qube
* - dom0 qrexec policy allowing this qube to call the service
*
* Build (from n_signer repo root):
* make examples
*
* Usage:
* ./build/demo_c99 <target_qube> [nostr_index]
* ./build/demo_c99 nostr_signer 1
*
* If no nostr_index is given, defaults to 0.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <signal.h>
#include "nostr_common.h"
#include "nostr_signer.h"
#include "nip019.h"
#include "../cjson/cJSON.h"
/* Helper: convert 32-byte hex pubkey to bech32 npub */
static int hex_to_npub(const char *hex, char *out_npub, size_t out_sz) {
unsigned char bytes[32];
int i;
if (strlen(hex) != 64) {
return -1;
}
for (i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(hex + 2 * i, "%2x", &byte) != 1) {
return -1;
}
bytes[i] = (unsigned char)byte;
}
return nostr_key_to_bech32(bytes, "npub", out_npub);
}
/* Helper: print an error with a human-readable description */
static void print_error(const char *operation, int rc) {
const char *desc = "unknown error";
switch (rc) {
case NOSTR_ERROR_INVALID_INPUT:
desc = "invalid input";
break;
case NOSTR_ERROR_CRYPTO_FAILED:
desc = "crypto operation failed";
break;
case NOSTR_ERROR_IO_FAILED:
desc = "I/O failed (transport error)";
break;
case NOSTR_ERROR_NETWORK_FAILED:
desc = "network failed";
break;
case NOSTR_ERROR_NSIGNER_POLICY_DENIED:
desc = "policy denied (caller not approved at signer terminal)";
break;
case NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED:
desc = "index not in signer's whitelist";
break;
default:
/* Try to print the numeric code */
fprintf(stderr, " %s failed: error code %d\n", operation, rc);
return;
}
fprintf(stderr, " %s failed: %s (code %d)\n", operation, desc, rc);
}
/*
* Demo 1: Get a public key by nostr_index.
* Returns the hex pubkey in `out_hex` (must be 65 bytes).
*/
static int demo_get_public_key(nostr_signer_t *signer, int nostr_index,
char *out_hex, size_t hex_sz) {
char npub[128];
int rc;
printf("\n=== Demo 1: get_public_key (nostr_index=%d) ===\n", nostr_index);
rc = nostr_signer_get_public_key(signer, out_hex);
if (rc != NOSTR_SUCCESS) {
print_error("get_public_key", rc);
return rc;
}
if (hex_to_npub(out_hex, npub, sizeof(npub)) == 0) {
printf(" pubkey hex: %s\n", out_hex);
printf(" npub: %s\n", npub);
} else {
printf(" pubkey hex: %s\n", out_hex);
printf(" (npub conversion failed)\n");
}
return NOSTR_SUCCESS;
}
/*
* Demo 2: Sign a Nostr event (kind 1 text note).
* The signed event JSON is printed.
*/
static int demo_sign_event(nostr_signer_t *signer, const char *pubkey_hex) {
cJSON *unsigned_event = NULL;
cJSON *signed_event = NULL;
char *signed_json = NULL;
int rc;
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();
if (unsigned_event == NULL) {
fprintf(stderr, " failed to create event JSON\n");
return NOSTR_ERROR_MEMORY_FAILED;
}
cJSON_AddNumberToObject(unsigned_event, "kind", 1);
cJSON_AddStringToObject(unsigned_event, "content", "Hello from n_signer C99 demo!");
cJSON_AddNumberToObject(unsigned_event, "created_at", (int)time(NULL));
/* tags: empty array */
cJSON_AddItemToObject(unsigned_event, "tags", cJSON_CreateArray());
/* pubkey: the signer will fill this in, but we include it for completeness */
cJSON_AddStringToObject(unsigned_event, "pubkey", pubkey_hex);
printf(" Unsigned event:\n");
{
char *tmp = cJSON_PrintUnformatted(unsigned_event);
if (tmp) {
printf(" %s\n", tmp);
free(tmp);
}
}
/* Sign it */
rc = nostr_signer_sign_event(signer, unsigned_event, &signed_event);
if (rc != NOSTR_SUCCESS) {
print_error("nostr_nostr_sign_event", rc);
cJSON_Delete(unsigned_event);
return rc;
}
/* Print the signed event */
signed_json = cJSON_Print(signed_event);
if (signed_json) {
printf(" Signed event:\n");
printf(" %s\n", signed_json);
free(signed_json);
}
/* Extract and show the signature and event id */
{
cJSON *id = cJSON_GetObjectItemCaseSensitive(signed_event, "id");
cJSON *sig = cJSON_GetObjectItemCaseSensitive(signed_event, "sig");
if (id && cJSON_IsString(id)) {
printf(" event id: %s\n", id->valuestring);
}
if (sig && cJSON_IsString(sig)) {
printf(" signature: %s\n", sig->valuestring);
}
}
cJSON_Delete(signed_event);
cJSON_Delete(unsigned_event);
return NOSTR_SUCCESS;
}
/*
* Demo 3: NIP-44 encrypt and decrypt.
* Encrypts a message to ourselves (using our own pubkey as the peer),
* then decrypts it to verify round-trip.
*/
static int demo_nip44(nostr_signer_t *signer, const char *pubkey_hex) {
const char *plaintext = "Secret message from n_signer C99 demo!";
char *ciphertext = NULL;
char *decrypted = NULL;
int rc;
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("nostr_nostr_nip44_encrypt", rc);
return rc;
}
printf(" ciphertext: %s\n", ciphertext);
/* Decrypt (using our own pubkey as the sender) */
rc = nostr_signer_nip44_decrypt(signer, pubkey_hex, ciphertext, &decrypted);
if (rc != NOSTR_SUCCESS) {
print_error("nostr_nostr_nip44_decrypt", rc);
free(ciphertext);
return rc;
}
printf(" decrypted: \"%s\"\n", decrypted);
/* Verify round-trip */
if (strcmp(plaintext, decrypted) == 0) {
printf(" ✓ Round-trip verified: plaintext matches decrypted\n");
} else {
printf(" ✗ Round-trip FAILED: plaintext does not match decrypted\n");
rc = NOSTR_ERROR_CRYPTO_FAILED;
}
free(ciphertext);
free(decrypted);
return rc;
}
int main(int argc, char **argv) {
const char *target_qube;
const char *service_name = "qubes.NsignerRpc";
int nostr_index = 0;
nostr_signer_t *signer = NULL;
char pubkey_hex[65];
int rc;
/* Ignore SIGPIPE — qrexec subprocess may close pipes abruptly */
(void)signal(SIGPIPE, SIG_IGN);
if (argc < 2) {
fprintf(stderr, "Usage: %s <target_qube> [nostr_index]\n", argv[0]);
fprintf(stderr, "Example: %s nostr_signer 1\n", argv[0]);
return 1;
}
target_qube = argv[1];
if (argc > 2) {
nostr_index = atoi(argv[2]);
}
/* Initialize the crypto subsystem */
if (nostr_init() != NOSTR_SUCCESS) {
fprintf(stderr, "Failed to initialize crypto subsystem\n");
return 1;
}
printf("=== n_signer C99 Demo ===\n");
printf("Target qube: %s\n", target_qube);
printf("Service: %s\n", service_name);
printf("nostr_index: %d\n", nostr_index);
printf("\n");
/* Create a high-level signer backed by qrexec transport */
printf("Connecting to n_signer via qrexec...\n");
signer = nostr_signer_nsigner_qrexec(target_qube, service_name, NULL, 30000);
if (signer == NULL) {
fprintf(stderr, "Failed to create qrexec signer.\n");
fprintf(stderr, "Is qrexec-client-vm available? Is the service installed?\n");
nostr_cleanup();
return 1;
}
printf("Connected.\n");
/* Select key by nostr_index (NIP-06 m/44'/1237'/N'/0/0) */
rc = nostr_signer_nsigner_set_nostr_index(signer, nostr_index);
if (rc != NOSTR_SUCCESS) {
print_error("set_nostr_index", rc);
nostr_signer_free(signer);
nostr_cleanup();
return 1;
}
/* Demo 1: Get public key */
rc = demo_get_public_key(signer, nostr_index, pubkey_hex, sizeof(pubkey_hex));
if (rc != NOSTR_SUCCESS) {
goto cleanup;
}
/* Demo 2: Sign an event */
rc = demo_sign_event(signer, pubkey_hex);
if (rc != NOSTR_SUCCESS) {
goto cleanup;
}
/* Demo 3: NIP-44 encrypt/decrypt */
rc = demo_nip44(signer, pubkey_hex);
cleanup:
printf("\n=== Summary ===\n");
if (rc == NOSTR_SUCCESS) {
printf("All demos completed successfully.\n");
} else {
printf("Demo failed with error code %d.\n", rc);
}
nostr_signer_free(signer);
nostr_cleanup();
return (rc == NOSTR_SUCCESS) ? 0 : 1;
}

268
client/demo_javascript.js Normal file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env node
/**
* demo_javascript.js — comprehensive JavaScript demo for connecting to a
* 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. 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.
*
* Prerequisites:
* - n_signer running in the target qube with --bridge-source-trusted
* - qubes.NsignerRpc service installed in the target qube
* - dom0 qrexec policy allowing this qube to call the service
* - nostr-tools and @noble/secp256k1 npm packages installed
*
* Install dependencies (from n_signer repo root):
* npm install nostr-tools @noble/secp256k1
*
* Usage:
* node client/demo_javascript.js <target_qube> [nostr_index]
* node client/demo_javascript.js nostr_signer 1
*
* If no nostr_index is given, defaults to 0.
*/
const { spawn } = require("child_process");
const crypto = require("crypto");
const secp = require("@noble/secp256k1");
const { nip19 } = require("nostr-tools");
// @noble/secp256k1 v3 requires sync sha256/hmacSha256
secp.hashes.sha256 = (msg) => new Uint8Array(crypto.createHash("sha256").update(msg).digest());
secp.hashes.hmacSha256 = (key, msg) =>
new Uint8Array(crypto.createHmac("sha256", key).update(msg).digest());
/**
* Call n_signer via qrexec. Sends one framed JSON-RPC request, receives one
* framed response. Each call spawns a fresh qrexec-client-vm process.
*
* Framing: 4-byte big-endian length prefix + JSON payload.
* No auth envelope needed for qrexec (identity from QREXEC_REMOTE_DOMAIN).
*/
function callNsigner(targetQube, request) {
return new Promise((resolve, reject) => {
const payload = Buffer.from(JSON.stringify(request), "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(payload.length, 0);
const framed = Buffer.concat([header, payload]);
const proc = spawn("qrexec-client-vm", [targetQube, "qubes.NsignerRpc"], {
stdio: ["pipe", "pipe", "pipe"],
});
const stdoutChunks = [];
const stderrChunks = [];
proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
proc.on("error", (err) => {
reject(new Error(`failed to spawn qrexec-client-vm: ${err.message}`));
});
proc.on("close", (code) => {
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString("utf8");
reject(new Error(`qrexec-client-vm exited with code ${code}: ${stderr.trim()}`));
return;
}
const buf = Buffer.concat(stdoutChunks);
if (buf.length < 4) {
reject(new Error("short response (missing frame header)"));
return;
}
const len = buf.readUInt32BE(0);
const body = buf.subarray(4, 4 + len);
if (body.length !== len) {
reject(new Error(`short response payload: expected ${len}, got ${body.length}`));
return;
}
try {
resolve(JSON.parse(body.toString("utf8")));
} catch (e) {
reject(new Error(`failed to parse response: ${e.message}`));
}
});
proc.stdin.write(framed);
proc.stdin.end();
});
}
/**
* Demo 1: Get a public key by nostr_index.
*/
async function demoGetPublicKey(targetQube, nostrIndex) {
console.log(`\n=== Demo 1: get_public_key (nostr_index=${nostrIndex}) ===`);
const response = await callNsigner(targetQube, {
id: "1",
method: "get_public_key",
params: [{ nostr_index: nostrIndex }],
});
if (response.error) {
throw new Error(`get_public_key failed: ${JSON.stringify(response.error)}`);
}
const pubkeyHex = response.result;
const npub = nip19.npubEncode(pubkeyHex);
console.log(` pubkey hex: ${pubkeyHex}`);
console.log(` npub: ${npub}`);
return pubkeyHex;
}
/**
* Demo 2: Sign a Nostr event (kind 1 text note).
*/
async function demoSignEvent(targetQube, nostrIndex, pubkeyHex) {
console.log("\n=== Demo 2: nostr_sign_event (kind 1 text note) ===");
const unsignedEvent = {
kind: 1,
content: "Hello from n_signer JavaScript demo!",
created_at: Math.floor(Date.now() / 1000),
tags: [],
pubkey: pubkeyHex,
};
console.log(" Unsigned event:");
console.log(` ${JSON.stringify(unsignedEvent)}`);
const response = await callNsigner(targetQube, {
id: "2",
method: "nostr_nostr_sign_event",
params: [JSON.stringify(unsignedEvent), { nostr_index: nostrIndex }],
});
if (response.error) {
throw new Error(`nostr_sign_event failed: ${JSON.stringify(response.error)}`);
}
const signedEvent = JSON.parse(response.result);
console.log(" Signed event:");
console.log(` ${JSON.stringify(signedEvent)}`);
console.log(` event id: ${signedEvent.id}`);
console.log(` signature: ${signedEvent.sig}`);
return signedEvent;
}
/**
* Demo 3: NIP-44 encrypt and decrypt.
* Encrypts a message to ourselves (using our own pubkey as the peer),
* then decrypts it to verify round-trip.
*/
async function demoNip44(targetQube, nostrIndex, pubkeyHex) {
const plaintext = "Secret message from n_signer JavaScript demo!";
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: "nostr_nostr_nip44_encrypt",
params: [pubkeyHex, plaintext, { nostr_index: nostrIndex }],
});
if (encResponse.error) {
throw new Error(`nostr_nip44_encrypt failed: ${JSON.stringify(encResponse.error)}`);
}
const ciphertext = encResponse.result;
console.log(` ciphertext: ${ciphertext}`);
// Decrypt
const decResponse = await callNsigner(targetQube, {
id: "4",
method: "nostr_nostr_nip44_decrypt",
params: [pubkeyHex, ciphertext, { nostr_index: nostrIndex }],
});
if (decResponse.error) {
throw new Error(`nostr_nip44_decrypt failed: ${JSON.stringify(decResponse.error)}`);
}
const decrypted = decResponse.result;
console.log(` decrypted: "${decrypted}"`);
if (plaintext === decrypted) {
console.log(" ✓ Round-trip verified: plaintext matches decrypted");
} else {
throw new Error("Round-trip FAILED: plaintext does not match decrypted");
}
}
async function demoMineEvent(targetQube, nostrIndex) {
console.log("\n--- Demo 4: nostr_mine_event (NIP-13 Proof-of-Work) ---");
const event = {
kind: 1,
content: "Hello Nostr with PoW!",
tags: [],
};
console.log(" Mining with difficulty=4, threads=4, timeout_sec=30...");
const response = await callNsigner(targetQube, {
id: "5",
method: "nostr_nostr_mine_event",
params: [JSON.stringify(event), {
difficulty: 4,
threads: 4,
timeout_sec: 30,
nostr_index: nostrIndex,
}],
});
if (response.error) {
throw new Error(`nostr_mine_event failed: ${JSON.stringify(response.error)}`);
}
const result = JSON.parse(response.result);
console.log(` achieved_difficulty: ${result.achieved_difficulty}`);
console.log(` target_reached: ${result.target_reached}`);
console.log(` elapsed_sec: ${result.elapsed_sec}`);
console.log(` attempts: ${result.attempts}`);
const minedEvent = JSON.parse(result.event);
console.log(` event id: ${minedEvent.id}`);
console.log(` nonce tag: ${JSON.stringify(minedEvent.tags[0])}`);
if (result.target_reached) {
console.log(" ✓ Target difficulty reached!");
} else {
console.log(` (Target not reached, best effort: ${result.achieved_difficulty} bits)`);
}
}
async function main() {
const targetQube = process.argv[2] || "nostr_signer";
const nostrIndex = parseInt(process.argv[3] || "0", 10);
console.log("=== n_signer JavaScript Demo ===");
console.log(`Target qube: ${targetQube}`);
console.log(`Service: qubes.NsignerRpc`);
console.log(`nostr_index: ${nostrIndex}`);
console.log("\nConnecting to n_signer via qrexec...");
try {
const pubkeyHex = await demoGetPublicKey(targetQube, nostrIndex);
await demoSignEvent(targetQube, nostrIndex, pubkeyHex);
await demoNip44(targetQube, nostrIndex, pubkeyHex);
await demoMineEvent(targetQube, nostrIndex);
console.log("\n=== Summary ===");
console.log("All demos completed successfully.");
} catch (e) {
console.error("\n=== Summary ===");
console.error(`Demo failed: ${e.message}`);
process.exit(1);
}
}
main();

296
client/demo_python.py Normal file
View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
demo_python.py — comprehensive Python demo for connecting to a 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. 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.
Prerequisites:
- n_signer running in the target qube with --bridge-source-trusted
- qubes.NsignerRpc service installed in the target qube
- dom0 qrexec policy allowing this qube to call the service
- Python 3 with no external dependencies (uses only stdlib)
Usage:
python3 client/demo_python.py <target_qube> [nostr_index]
python3 client/demo_python.py nostr_signer 1
If no nostr_index is given, defaults to 0.
"""
import json
import struct
import subprocess
import sys
import time
# --- Bech32 encoder (NIP-19 npub conversion, no external dependencies) ---
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def bech32_polymod(values):
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for v in values:
b = chk >> 25
chk = (chk & 0x1FFFFFF) << 5 ^ v
for i in range(5):
chk ^= generator[i] if ((b >> i) & 1) else 0
return chk
def bech32_hrp_expand(hrp):
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def bech32_create_checksum(hrp, data):
values = bech32_hrp_expand(hrp) + data
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
def bech32_encode(hrp, data):
combined = data + bech32_create_checksum(hrp, data)
return hrp + "1" + "".join([CHARSET[d] for d in combined])
def convertbits(data, frombits, tobits, pad=True):
acc = 0
bits = 0
ret = []
maxv = (1 << tobits) - 1
max_acc = (1 << (frombits + tobits - 1)) - 1
for value in data:
acc = ((acc << frombits) | value) & max_acc
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad and bits:
ret.append((acc << (tobits - bits)) & maxv)
return ret
def hex_to_npub(pubkey_hex):
"""Convert a 32-byte hex pubkey to bech32 npub format (NIP-19)."""
pubkey_bytes = bytes.fromhex(pubkey_hex)
data = convertbits(pubkey_bytes, 8, 5)
return bech32_encode("npub", data)
# --- n_signer qrexec client ---
def call_nsigner(target_qube, request):
"""
Call n_signer via qrexec. Sends one framed JSON-RPC request, receives one
framed response. Each call spawns a fresh qrexec-client-vm process.
Framing: 4-byte big-endian length prefix + JSON payload.
No auth envelope needed for qrexec (identity from QREXEC_REMOTE_DOMAIN).
"""
payload = json.dumps(request, separators=(",", ":")).encode("utf-8")
frame = struct.pack(">I", len(payload)) + payload
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = proc.communicate(frame)
if proc.returncode != 0:
raise RuntimeError(
f"qrexec-client-vm exited with code {proc.returncode}: "
f"{err.decode('utf-8', 'replace').strip()}"
)
if len(out) < 4:
raise RuntimeError("short response (missing frame header)")
length = struct.unpack(">I", out[:4])[0]
body = out[4 : 4 + length]
if len(body) != length:
raise RuntimeError(
f"short response payload: expected {length}, got {len(body)}"
)
return json.loads(body.decode("utf-8"))
# --- Demos ---
def demo_get_public_key(target_qube, nostr_index):
"""Demo 1: Get a public key by nostr_index."""
print(f"\n=== Demo 1: get_public_key (nostr_index={nostr_index}) ===")
response = call_nsigner(
target_qube,
{"id": "1", "method": "get_public_key", "params": [{"nostr_index": nostr_index}]},
)
if "error" in response:
raise RuntimeError(f"get_public_key failed: {json.dumps(response['error'])}")
pubkey_hex = response["result"]
npub = hex_to_npub(pubkey_hex)
print(f" pubkey hex: {pubkey_hex}")
print(f" npub: {npub}")
return pubkey_hex
def demo_sign_event(target_qube, nostr_index, pubkey_hex):
"""Demo 2: Sign a Nostr event (kind 1 text note)."""
print("\n=== Demo 2: nostr_sign_event (kind 1 text note) ===")
unsigned_event = {
"kind": 1,
"content": "Hello from n_signer Python demo!",
"created_at": int(time.time()),
"tags": [],
"pubkey": pubkey_hex,
}
print(" Unsigned event:")
print(f" {json.dumps(unsigned_event, separators=(',', ':'))}")
response = call_nsigner(
target_qube,
{
"id": "2",
"method": "nostr_nostr_sign_event",
"params": [json.dumps(unsigned_event, separators=(",", ":")), {"nostr_index": nostr_index}],
},
)
if "error" in response:
raise RuntimeError(f"nostr_sign_event failed: {json.dumps(response['error'])}")
signed_event = json.loads(response["result"])
print(" Signed event:")
print(f" {json.dumps(signed_event, separators=(',', ':'))}")
print(f" event id: {signed_event['id']}")
print(f" signature: {signed_event['sig']}")
return signed_event
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: nostr_nip44_encrypt / nostr_nip44_decrypt ===")
print(f' plaintext: "{plaintext}"')
print(f" peer pubkey: {pubkey_hex} (self)")
# Encrypt
enc_response = call_nsigner(
target_qube,
{
"id": "3",
"method": "nostr_nostr_nip44_encrypt",
"params": [pubkey_hex, plaintext, {"nostr_index": nostr_index}],
},
)
if "error" in enc_response:
raise RuntimeError(f"nostr_nip44_encrypt failed: {json.dumps(enc_response['error'])}")
ciphertext = enc_response["result"]
print(f" ciphertext: {ciphertext}")
# Decrypt
dec_response = call_nsigner(
target_qube,
{
"id": "4",
"method": "nostr_nostr_nip44_decrypt",
"params": [pubkey_hex, ciphertext, {"nostr_index": nostr_index}],
},
)
if "error" in dec_response:
raise RuntimeError(f"nostr_nip44_decrypt failed: {json.dumps(dec_response['error'])}")
decrypted = dec_response["result"]
print(f' decrypted: "{decrypted}"')
if plaintext == decrypted:
print(" ✓ Round-trip verified: plaintext matches decrypted")
else:
raise RuntimeError("Round-trip FAILED: plaintext does not match decrypted")
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": []}
print(" Mining with difficulty=4, threads=4, timeout_sec=30...")
response = call_nsigner(
target_qube,
{
"id": "5",
"method": "nostr_nostr_mine_event",
"params": [json.dumps(event), {
"difficulty": 4,
"threads": 4,
"timeout_sec": 30,
"nostr_index": nostr_index,
}],
},
)
if "error" in response:
raise RuntimeError(f"nostr_mine_event failed: {json.dumps(response['error'])}")
result = json.loads(response["result"])
print(f" achieved_difficulty: {result['achieved_difficulty']}")
print(f" target_reached: {result['target_reached']}")
print(f" elapsed_sec: {result['elapsed_sec']}")
print(f" attempts: {result['attempts']}")
mined_event = json.loads(result["event"])
print(f" event id: {mined_event['id']}")
print(f" nonce tag: {mined_event['tags'][0]}")
if result["target_reached"]:
print(" ✓ Target difficulty reached!")
else:
print(f" (Target not reached, best effort: {result['achieved_difficulty']} bits)")
# --- Main ---
def main():
target_qube = sys.argv[1] if len(sys.argv) > 1 else "nostr_signer"
nostr_index = int(sys.argv[2]) if len(sys.argv) > 2 else 0
print("=== n_signer Python Demo ===")
print(f"Target qube: {target_qube}")
print(f"Service: qubes.NsignerRpc")
print(f"nostr_index: {nostr_index}")
print("\nConnecting to n_signer via qrexec...")
try:
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_nostr_mine_event(target_qube, nostr_index)
print("\n=== Summary ===")
print("All demos completed successfully.")
except Exception as e:
print("\n=== Summary ===")
print(f"Demo failed: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -127,6 +127,26 @@ Methods are NIP-46 style verbs.
- `nip44_encrypt` - `nip44_encrypt`
- `nip44_decrypt` - `nip44_decrypt`
### 4.2b Algorithm-based verbs (new)
In addition to the role-based verbs above, the signer supports algorithm-based verbs where the caller specifies `algorithm` and `index` directly:
- `sign` — sign arbitrary bytes (params: `[message_hex, {algorithm, index, scheme?}]`)
- `verify` — verify a signature (params: `[message_hex, signature_hex, {algorithm, index, scheme?}]`)
- `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`
For secp256k1 `sign`/`verify`, the optional `scheme` parameter selects `"schnorr"` (default, BIP-340) or `"ecdsa"`.
Old verb aliases (`sign_data`, `ssh_sign`, `verify_signature`, `kem_encapsulate`, `kem_decapsulate`) map to the new verbs when used with the `algorithm` parameter. Without `algorithm`, they fall through to the role-based path.
See [README.md §4c](../README.md) for full details.
### 4.3 Selector options ### 4.3 Selector options
The last param may include selector options: The last param may include selector options:
@@ -484,7 +504,228 @@ Decrypt response:
--- ---
## 11. Compatibility notes ## 11. Post-Quantum and Multi-Algorithm Support
n_signer supports six cryptographic algorithms, all derived deterministically
from the same BIP-39 mnemonic via distinct derivation paths:
| Algorithm | Purpose | Curve string | Purpose string | Derivation path |
|---|---|---|---|---|
| `secp256k1` | Nostr (sign_event, NIP-04/44) | `secp256k1` | `nostr` | `m/44'/1237'/<n>'/0/0` (NIP-06) |
| `ed25519` | SSH signing, general signatures | `ed25519` | `ssh` | `m/44'/102001'/<n>'/0'/0'` (SLIP-0010) |
| `x25519` | Key agreement (age, ECDH) | `x25519` | `age` | `m/44'/102002'/<n>'/0'/0'` (SLIP-0010) |
| `ml-dsa-65` | Post-quantum signatures (FIPS 204) | `ml-dsa-65` | `pq-sig` | `m/44'/102003'/<n>'/0'/0'` → seed → PQClean keygen |
| `slh-dsa-128s` | Post-quantum hash-based signatures (FIPS 205) | `slh-dsa-128s` | `pq-sig` | `m/44'/102004'/<n>'/0'/0'` → seed → PQClean keygen |
| `ml-kem-768` | Post-quantum key encapsulation (FIPS 203) | `ml-kem-768` | `pq-kem` | `m/44'/102005'/<n>'/0'/0'` → seed → PQClean keygen |
The `102XXX` coin types are unregistered in SLIP-44 and reserved by n_signer
for PQ/SSH/age algorithm families. All non-secp256k1 paths use SLIP-0010
all-hardened derivation.
### 11.1 Algorithm key sizes
| Algorithm | Pub key | Priv key | Signature | Ciphertext | Shared secret |
|---|---|---|---|---|---|
| secp256k1 | 32 bytes | 32 bytes | 64 bytes | — | — |
| ed25519 | 32 bytes | 32 bytes | 64 bytes | — | — |
| x25519 | 32 bytes | 32 bytes | — | — | 32 bytes |
| ML-DSA-65 | 1952 bytes | 4032 bytes | 3309 bytes | — | — |
| SLH-DSA-128s | 32 bytes | 64 bytes | 7856 bytes | — | — |
| ML-KEM-768 | 1184 bytes | 2400 bytes | — | 1088 bytes | 32 bytes |
PQ public keys and signatures are much larger than classical ones. Clients
must allocate buffers accordingly (ML-DSA-65 pubkey hex = 3904 chars;
SLH-DSA-128s signature hex = 15712 chars; ML-KEM-768 pubkey hex = 2368 chars).
### 11.2 New verbs
| Verb | Purpose | Allowed (purpose, curve) | Description |
|---|---|---|---|
| `sign_data` | pq-sig, ssh | (pq-sig, ml-dsa-65), (pq-sig, slh-dsa-128s), (ssh, ed25519) | Sign arbitrary bytes (not a Nostr event) |
| `verify_signature` | pq-sig, ssh | same as `sign_data` | Verify a signature against the role's public key |
| `ssh_sign` | ssh | (ssh, ed25519) | Sign an SSH authentication challenge (ed25519) |
| `kem_encapsulate` | pq-kem | (pq-kem, ml-kem-768) | Encapsulate: generate ciphertext + shared secret from a peer's ML-KEM public key |
| `kem_decapsulate` | pq-kem | (pq-kem, ml-kem-768) | Decapsulate: recover shared secret from ciphertext using the role's ML-KEM private key |
The existing Nostr verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`)
remain restricted to `purpose=nostr + curve=secp256k1`.
### 11.3 Structured `get_public_key` response format
`get_public_key` is a universal verb — it works for all six algorithms.
**For secp256k1 (backward compatibility):** the result is a plain hex string
(the existing format). Existing Nostr clients are unaffected.
```json
{ "id": "1", "result": "<64-char hex pubkey>" }
```
**For secp256k1 with `format: "structured"` option:** new clients can request
the structured format for consistency:
Request:
```json
{
"id": "1",
"method": "get_public_key",
"params": [{ "role": "main", "format": "structured" }]
}
```
Response:
```json
{
"id": "1",
"result": "{\"algorithm\":\"secp256k1\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}"
}
```
**For all other algorithms (ed25519, x25519, ML-DSA-65, SLH-DSA-128s,
ML-KEM-768):** the result is always a structured JSON object serialized as a
string:
```json
{
"id": "1",
"result": {
"algorithm": "ml-dsa-65",
"public_key": "<hex-encoded public key>",
"key_id": "<first 16 hex chars of public key>"
}
}
```
The `key_id` is the first 16 hex characters of the public key — a short
display identifier similar to an SSH key fingerprint. The `result` field is a
JSON string (the object serialized), so clients must parse it twice: once for
the JSON-RPC envelope, once for the result object.
### 11.4 Example: `sign_data` (ML-DSA-65)
Request:
```json
{
"id": "10",
"method": "sign_data",
"params": ["68656c6c6f", { "role": "pq_sig" }]
}
```
Response:
```json
{
"id": "10",
"result": "{\"signature\":\"<hex>\",\"algorithm\":\"ml-dsa-65\"}"
}
```
The first param is the message bytes as hex. The signature is hex-encoded
(3309 bytes = 6618 hex chars for ML-DSA-65).
### 11.5 Example: `verify_signature` (ed25519)
Request:
```json
{
"id": "11",
"method": "verify_signature",
"params": ["<msg_hex>", "<sig_hex>", { "role": "ssh_main" }]
}
```
Response:
```json
{ "id": "11", "result": "{\"valid\":true}" }
```
The signature is verified against the role's derived public key.
### 11.6 Example: `ssh_sign` (ed25519)
Request:
```json
{
"id": "12",
"method": "ssh_sign",
"params": ["<session_id_hex>", { "role": "ssh_main" }]
}
```
Response:
```json
{
"id": "12",
"result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\"}"
}
```
The first param is the SSH session ID (or challenge) as hex. The signature is
a raw ed25519 signature (64 bytes = 128 hex chars).
### 11.7 Example: `kem_encapsulate` (ML-KEM-768)
Request:
```json
{
"id": "13",
"method": "kem_encapsulate",
"params": ["<peer_pubkey_hex>", { "role": "kem_main" }]
}
```
Response:
```json
{
"id": "13",
"result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}"
}
```
The first param is the peer's ML-KEM-768 public key as hex (1184 bytes = 2368
hex chars). The response contains the ciphertext (1088 bytes = 2176 hex chars)
and the shared secret (32 bytes = 64 hex chars). The encapsulating party keeps
the shared secret; the ciphertext is sent to the decapsulating party.
### 11.8 Example: `kem_decapsulate` (ML-KEM-768)
Request:
```json
{
"id": "14",
"method": "kem_decapsulate",
"params": ["<ciphertext_hex>", { "role": "kem_main" }]
}
```
Response:
```json
{
"id": "14",
"result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}"
}
```
The first param is the ciphertext from `kem_encapsulate` (1088 bytes = 2176
hex chars). The decapsulated shared secret will match the encapsulating
party's shared secret.
### 11.9 Example clients
See the `examples/` directory for working C clients demonstrating the new
verbs:
- [`examples/pq_sign_example.c`](../examples/pq_sign_example.c) — ML-DSA-65
`get_public_key` + `sign_data`
- [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) — ML-KEM-768
`get_public_key` + `kem_encapsulate` + `kem_decapsulate` (verifies shared
secrets match)
- [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) — ed25519
`get_public_key` + `ssh_sign`
---
## 12. Compatibility notes
- If you are writing an autonomous agent client, pin to explicit socket name and explicit role selector. - If you are writing an autonomous agent client, pin to explicit socket name and explicit role selector.
- Keep method support feature-detected (`method_not_found` fallback). - Keep method support feature-detected (`method_not_found` fallback).

View File

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

View File

@@ -215,12 +215,18 @@ The signer enforces a strict `(verb, purpose, curve)` matrix:
| Verb | Required purpose | Required curve | | Verb | Required purpose | Required curve |
|---|---|---| |---|---|---|
| `sign_event` | `nostr` | `secp256k1` | | `sign_event` | `nostr` | `secp256k1` |
| `get_public_key` | `nostr` | `secp256k1` | | `mine_event` | `nostr` | `secp256k1` |
| `nip04_encrypt` / `nip04_decrypt` | `nostr` | `secp256k1` | | `nip04_encrypt` / `nip04_decrypt` | `nostr` | `secp256k1` |
| `nip44_encrypt` / `nip44_decrypt` | `nostr` | `secp256k1` | | `nip44_encrypt` / `nip44_decrypt` | `nostr` | `secp256k1` |
| `get_public_key` | any | any (must match role's declared curve) |
| `sign_data` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
| `verify_signature` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
| `ssh_sign` | `ssh` | `ed25519` |
| `kem_encapsulate` | `pq-kem` | `ml-kem-768` |
| `kem_decapsulate` | `pq-kem` | `ml-kem-768` |
| Any other verb | rejected | rejected | | Any other verb | rejected | rejected |
A pre-approval to use a Bitcoin-purposed key for `sign_event` does **not** override the enforcement matrix. The approval grants access to the key; enforcement still gates the verb. **Fail-closed**: unknown verbs are rejected, never passed through. A pre-approval to use a Bitcoin-purposed key for `sign_event` does **not** override the enforcement matrix. The approval grants access to the key; enforcement still gates the verb. **Fail-closed**: unknown verbs and unlisted `(verb, purpose, curve)` combinations are rejected, never passed through.
This is the layer that prevents (for example) a `bitcoin/secp256k1` key from being used to sign a Nostr event even if some pre-approval entry mistakenly named it. The key's *purpose* is part of its identity; you cannot reuse it across domains. This is the layer that prevents (for example) a `bitcoin/secp256k1` key from being used to sign a Nostr event even if some pre-approval entry mistakenly named it. The key's *purpose* is part of its identity; you cannot reuse it across domains.
@@ -508,7 +514,86 @@ If any of these statements becomes false in code, that is a security bug worth f
--- ---
## 16. References ---
## 16. Post-Quantum Cryptography
`n_signer` supports three post-quantum algorithms alongside the classical secp256k1, ed25519, and x25519:
- **ML-DSA-65** (FIPS 204) — lattice-based post-quantum digital signatures
- **SLH-DSA-128s** (FIPS 205) — hash-based post-quantum signatures with minimal trust assumptions
- **ML-KEM-768** (FIPS 203) — lattice-based post-quantum key encapsulation mechanism
These are additional options, not replacements for secp256k1. Nostr continues to use secp256k1 exclusively. PQ algorithms are opt-in per role via `purpose="pq-sig"` or `purpose="pq-kem"`. See [`README.md`](../README.md) §4b for the full crypto palette.
### 16.1 PQ threat model
The primary PQ threat is **harvest-now-decrypt-later**: an adversary records encrypted traffic or key agreement exchanges today, stores them, and decrypts them once a sufficiently large quantum computer becomes available. ML-KEM-768 addresses this for key agreement — a session key encapsulated with ML-KEM-768 cannot be recovered by a future quantum adversary.
For signatures, the future risk is **quantum forgery**: a quantum computer could forge classical signatures (ECDSA, Ed25519) given the public key, undermining authentication retroactively. ML-DSA-65 and SLH-DSA-128s address this by providing signatures that resist quantum forgery. The urgency is lower than for key agreement (signatures are forged when needed, not retroactively decrypted), but forward-looking deployments may want PQ signature keys now.
`n_signer` does not claim to defend against all quantum threats. It provides the PQ primitives; the protocol layer (SSH, TLS, Nostr) must adopt them for the protection to be meaningful.
### 16.2 Deterministic PQ key derivation
PQ private keys are not scalars — they are complex mathematical structures (polynomial matrices for lattice schemes, hypertree seeds for hash-based schemes). You cannot use a 32-byte BIP-32 output directly as a PQ private key.
`n_signer` uses a **non-standard** approach to derive PQ keys deterministically from the mnemonic:
1. Derive a 32-byte seed from the mnemonic using BIP-32/SLIP-0010 HMAC-SHA512 at a PQ-specific derivation path (e.g. `m/44'/102003'/<n>'/0'/0'` for ML-DSA-65).
2. Feed that seed into a SHAKE-256 DRBG (NIST SP 800-90A style).
3. Replace PQClean's `randombytes()` callback with this DRBG so keygen is deterministic.
4. The PQ algorithm expands the DRBG output into the full key pair.
**Security argument:**
- The 32-byte seed from BIP-32 derivation carries full 256 bits of entropy (assuming the mnemonic has full entropy).
- SHAKE-256 is a NIST-approved XOF; using it as a DRBG seeded with 256 bits of entropy is sufficient for all three PQ algorithms.
- Each role uses a distinct derivation path (distinct coin types 102003/102004/102005), so compromising one role's PQ key does not compromise others.
**This is non-standard.** There is no NIST or IETF specification for deriving PQ keys from a BIP-39 mnemonic. The approach preserves `n_signer`'s core crash-equals-wipe model: PQ keys are re-derived from the mnemonic on every startup, same as secp256k1. The alternative (random PQ keys with no mnemonic recovery) would break the model.
**Risk:** If a weakness is found in using DRBG output as PQ keygen randomness, all PQ keys derived this way could be affected. Mitigation: per-role distinct derivation paths limit blast radius. The classical algorithms (secp256k1, ed25519, x25519) are unaffected — they do not use the DRBG.
Implementation: [`src/pq_drbg.c`](../src/pq_drbg.c), [`src/pq_crypto.c`](../src/pq_crypto.c).
### 16.3 PQ algorithm maturity
ML-DSA, SLH-DSA, and ML-KEM are FIPS-standardized (FIPS 203, 204, 205) and have undergone extensive NIST scrutiny. However, they are newer than classical algorithms and have less deployment history. They are provided as **additional options**, not replacements. The enforcement matrix (§5.2) ensures PQ keys cannot be used for Nostr operations and vice versa.
### 16.4 SLH-DSA-128s signing latency
SLH-DSA-128s signing on ESP32 can take **530 seconds**. This is a UX consideration, not a security issue. The hash-based signature scheme is intentionally compute-bound (that is its security foundation). On the Feather/CYD firmware, the approval prompt should show a "signing..." indicator during the operation.
The user should choose whether to use SLH-DSA-128s per role. For interactive use where latency matters, ML-DSA-65 is faster. SLH-DSA-128s is appropriate for low-frequency, high-assurance signing where minimal trust assumptions (hash-based, no number-theoretic hardness assumption) are desired.
### 16.5 Key sizes and memory
PQ private keys are large compared to classical keys:
| Algorithm | Private key | Public key | Signature / Ciphertext |
|---|---|---|---|
| secp256k1 | 32 bytes | 32 bytes | 64 bytes (sig) |
| ed25519 | 32 bytes | 32 bytes | 64 bytes (sig) |
| ML-DSA-65 | 4032 bytes | 1952 bytes | 3309 bytes (sig) |
| SLH-DSA-128s | 64 bytes | 32 bytes | 7856 bytes (sig) |
| ML-KEM-768 | 2400 bytes | 1184 bytes | 1088 bytes (ciphertext) |
With `ROLE_TABLE_MAX_ENTRIES` at 256, a full table of ML-DSA-65 keys would use ~1 MB of `mlock`'d memory (4032 × 256 ≈ 1.03 MB for private keys alone). This is acceptable on host. On ESP32 with 512 KB SRAM, this would not fit — on-demand derivation (deriving a PQ key only when a request targets that role) is the recommended pattern. The existing [`crypto_derive_one`](../src/key_store.c) path already supports this.
### 16.6 No hybrid signatures yet
Hybrid signatures (e.g., ed25519 + ML-DSA combined into one signature object) are **future work**. No standard exists for hybrid SSH signatures yet. `n_signer` provides the individual primitives (`sign_data` for ed25519, ML-DSA-65, and SLH-DSA-128s); a hybrid format can be assembled by the client once standards solidify.
### 16.7 PQ key persistence
**PQ keys are NOT persisted.** They are re-derived from the mnemonic on every startup, same as secp256k1. There is no PQ key file, no PQ key database, no PQ key cache on disk. Crash-equals-wipe (§10) applies unchanged: if the process dies, all PQ keys are gone and must be re-derived from the mnemonic on next startup.
This is a deliberate design choice. The deterministic derivation approach (§16.2) makes it possible to recover PQ keys from the mnemonic alone, so persistence would add risk (key material on disk) without adding capability.
---
## 17. References
- [`README.md`](../README.md) — authoritative behavior spec. - [`README.md`](../README.md) — authoritative behavior spec.
- [`plans/nsigner.md`](../plans/nsigner.md) — root design plan and decisions log. - [`plans/nsigner.md`](../plans/nsigner.md) — root design plan and decisions log.
@@ -517,4 +602,5 @@ If any of these statements becomes false in code, that is a security bug worth f
- [`documents/QUBES_OS.md`](QUBES_OS.md) — Qubes RPC integration. - [`documents/QUBES_OS.md`](QUBES_OS.md) — Qubes RPC integration.
- [`documents/FIPS_DEPLOYMENT.md`](FIPS_DEPLOYMENT.md) — FIPS-mode deployment notes. - [`documents/FIPS_DEPLOYMENT.md`](FIPS_DEPLOYMENT.md) — FIPS-mode deployment notes.
- [`plans/seed_phrase_uses.md`](../plans/seed_phrase_uses.md) — what one mnemonic can become. - [`plans/seed_phrase_uses.md`](../plans/seed_phrase_uses.md) — what one mnemonic can become.
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c), [`src/enforcement.c`](../src/enforcement.c) — the security-related code. - [`plans/post_quantum_crypto.md`](../plans/post_quantum_crypto.md) — post-quantum and multi-algorithm crypto expansion plan.
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c), [`src/enforcement.c`](../src/enforcement.c), [`src/pq_crypto.c`](../src/pq_crypto.c), [`src/pq_drbg.c`](../src/pq_drbg.c) — the security-related code.

View File

@@ -0,0 +1,535 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer CYD Web Serial Demo</title>
<style>
:root {
--bg: #0b0f14;
--panel: #121821;
--panel-2: #182231;
--text: #e6edf3;
--muted: #9fb0c3;
--accent: #58a6ff;
--good: #3fb950;
--bad: #f85149;
--border: #263448;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.35;
}
.wrap { max-width: 980px; margin: 20px auto; padding: 0 14px 24px; }
h1 { margin: 0 0 8px; font-size: 1.45rem; }
p.note { margin: 0 0 14px; color: var(--muted); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; margin-top: 12px; }
.card { background: linear-gradient(180deg, var(--panel), var(--panel-2)); border: 1px solid var(--border); border-radius: 12px; padding: 12px; }
.card h2 { font-size: 1rem; margin: 0 0 10px; }
label { font-size: 0.86rem; color: var(--muted); display: block; margin: 6px 0 4px; }
input, textarea, select, button { font: inherit; border-radius: 8px; border: 1px solid var(--border); }
input, textarea, select { width: 100%; background: #0c131d; color: var(--text); padding: 8px 10px; }
textarea { min-height: 64px; resize: vertical; }
input[type="number"] { max-width: 130px; }
button { background: #1f6feb; color: white; padding: 8px 12px; cursor: pointer; border: 0; }
button[disabled] { opacity: 0.55; cursor: not-allowed; }
.secondary { background: #334155; }
.status { padding: 6px 10px; border-radius: 999px; background: #2a3648; color: var(--muted); font-size: 0.85rem; border: 1px solid var(--border); }
.status.ok { color: var(--good); border-color: #2f5a3a; }
.status.err { color: var(--bad); border-color: #6a3131; }
pre { margin: 8px 0 0; background: #0a1018; border: 1px solid #1b2636; color: #d7e2ee; padding: 10px; border-radius: 8px; overflow: auto; max-height: 200px; font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.warn { color: #d29922; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="wrap">
<h1>n_signer CYD Web Serial Demo</h1>
<p class="note">Connect to the CYD (CH340 serial) via Web Serial, then exercise every algorithm and verb in the n_signer API. Chrome/Edge/Brave/Opera only.</p>
<div class="card">
<div class="row">
<button id="connectBtn">Connect Web Serial</button>
<button id="disconnectBtn" class="secondary" disabled>Disconnect</button>
<span id="connStatus" class="status">Disconnected</span>
</div>
<pre id="log" class="mono"></pre>
</div>
<div class="grid">
<!-- Get Public Key (algorithm-based) -->
<section class="card">
<h2>Get Public Key (algorithm)</h2>
<label for="gpkAlg">Algorithm</label>
<select id="gpkAlg">
<option>secp256k1</option><option>ed25519</option><option>x25519</option>
<option>ml-dsa-65</option><option>slh-dsa-128s</option><option>ml-kem-768</option>
</select>
<label for="gpkIdx">Index</label>
<input id="gpkIdx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="gpkBtn" disabled>get_public_key</button>
</div>
<pre id="gpkOut" class="mono"></pre>
</section>
<!-- Nostr Get Public Key -->
<section class="card">
<h2>nostr_get_public_key</h2>
<label for="ngpkIdx">nostr_index</label>
<input id="ngpkIdx" type="number" value="0" min="0" />
<label for="ngpkFmt">format</label>
<select id="ngpkFmt"><option>bare</option><option>structured</option></select>
<div class="row" style="margin-top:10px">
<button id="ngpkBtn" disabled>nostr_get_public_key</button>
</div>
<pre id="ngpkOut" class="mono"></pre>
</section>
<!-- Sign / Verify -->
<section class="card">
<h2>sign / verify</h2>
<label for="signAlg">Algorithm</label>
<select id="signAlg">
<option>secp256k1</option><option>ed25519</option><option>ml-dsa-65</option><option>slh-dsa-128s</option>
</select>
<label for="signIdx">Index</label>
<input id="signIdx" type="number" value="0" min="0" />
<label for="signScheme">scheme (secp256k1 only)</label>
<select id="signScheme"><option>schnorr</option><option>ecdsa</option></select>
<label for="signMsg">message (hex)</label>
<input id="signMsg" value="68656c6c6f" />
<div class="row" style="margin-top:10px">
<button id="signBtn" disabled>sign</button>
<button id="verifyBtn" disabled>verify</button>
</div>
<pre id="signOut" class="mono"></pre>
</section>
<!-- KEM encapsulate / decapsulate -->
<section class="card">
<h2>encapsulate / decapsulate (ml-kem-768)</h2>
<label for="kemPeer">peer pubkey hex (1184 bytes / 2368 hex) — leave empty to use self pubkey</label>
<textarea id="kemPeer" placeholder="auto: uses ml-kem-768 get_public_key"></textarea>
<label for="kemIdx">index (for decapsulate)</label>
<input id="kemIdx" type="number" value="0" min="0" />
<label for="kemCt">ciphertext hex (for decapsulate)</label>
<textarea id="kemCt" placeholder="filled by encapsulate"></textarea>
<div class="row" style="margin-top:10px">
<button id="encapBtn" disabled>encapsulate</button>
<button id="decapBtn" disabled>decapsulate</button>
</div>
<pre id="kemOut" class="mono"></pre>
</section>
<!-- derive_shared_secret (x25519) -->
<section class="card">
<h2>derive_shared_secret (x25519)</h2>
<label for="x25519Peer">peer pubkey hex (32 bytes / 64 hex)</label>
<input id="x25519Peer" placeholder="64 hex chars" />
<label for="x25519Idx">index</label>
<input id="x25519Idx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="x25519Btn" disabled>derive_shared_secret</button>
</div>
<pre id="x25519Out" class="mono"></pre>
</section>
<!-- derive (HMAC) -->
<section class="card">
<h2>derive (secp256k1 HMAC-SHA256)</h2>
<label for="deriveData">data (UTF-8 string)</label>
<input id="deriveData" value="hello-derive" />
<label for="deriveIdx">index (required)</label>
<input id="deriveIdx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="deriveBtn" disabled>derive</button>
</div>
<pre id="deriveOut" class="mono"></pre>
</section>
<!-- Nostr Sign Event -->
<section class="card">
<h2>nostr_sign_event</h2>
<label for="nseContent">content</label>
<textarea id="nseContent">hello from cyd webserial demo</textarea>
<label for="nseIdx">nostr_index</label>
<input id="nseIdx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="nseBtn" disabled>nostr_sign_event</button>
</div>
<pre id="nseOut" class="mono"></pre>
</section>
<!-- Nostr Mine Event -->
<section class="card">
<h2>nostr_mine_event</h2>
<p class="warn">Slow on ESP32 — uses single-threaded PoW. Keep difficulty low.</p>
<label for="nmeContent">content</label>
<textarea id="nmeContent">mined by cyd</textarea>
<label for="nmeIdx">nostr_index</label>
<input id="nmeIdx" type="number" value="0" min="0" />
<label for="nmeDiff">difficulty (leading zero bits)</label>
<input id="nmeDiff" type="number" value="4" min="1" max="16" />
<label for="nmeTimeout">timeout (sec)</label>
<input id="nmeTimeout" type="number" value="30" min="1" max="60" />
<div class="row" style="margin-top:10px">
<button id="nmeBtn" disabled>nostr_mine_event</button>
</div>
<pre id="nmeOut" class="mono"></pre>
</section>
<!-- NIP-04 -->
<section class="card">
<h2>nostr_nip04_encrypt / decrypt</h2>
<label for="nip04Peer">peer pubkey hex (32-byte x-only)</label>
<input id="nip04Peer" placeholder="64 hex chars" />
<label for="nip04Msg">plaintext</label>
<textarea id="nip04Msg">hello via nip04</textarea>
<label for="nip04Cipher">ciphertext (for decrypt)</label>
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
<label for="nip04Idx">nostr_index</label>
<input id="nip04Idx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="nip04EncBtn" disabled>encrypt</button>
<button id="nip04DecBtn" disabled>decrypt</button>
</div>
<pre id="nip04Out" class="mono"></pre>
</section>
<!-- NIP-44 -->
<section class="card">
<h2>nostr_nip44_encrypt / decrypt</h2>
<label for="nip44Peer">peer pubkey hex (32-byte x-only)</label>
<input id="nip44Peer" placeholder="64 hex chars" />
<label for="nip44Msg">plaintext</label>
<textarea id="nip44Msg">hello via nip44</textarea>
<label for="nip44Cipher">ciphertext (for decrypt)</label>
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
<label for="nip44Idx">nostr_index</label>
<input id="nip44Idx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="nip44EncBtn" disabled>encrypt</button>
<button id="nip44DecBtn" disabled>decrypt</button>
</div>
<pre id="nip44Out" class="mono"></pre>
</section>
<!-- OTP encrypt / decrypt -->
<section class="card">
<h2>encrypt / decrypt (otp)</h2>
<p class="note">OTP pad is derived from the mnemonic on the CYD. Offset advances monotonically.</p>
<label for="otpPlain">plaintext (base64)</label>
<textarea id="otpPlain">SGVsbG8sIE9UUCB3b3JsZCE=</textarea>
<label for="otpCipher">ciphertext (for decrypt, base64)</label>
<textarea id="otpCipher" placeholder="filled by encrypt"></textarea>
<label for="otpEnc">encoding</label>
<select id="otpEnc"><option>ascii</option><option>binary</option></select>
<div class="row" style="margin-top:10px">
<button id="otpEncBtn" disabled>encrypt</button>
<button id="otpDecBtn" disabled>decrypt</button>
</div>
<pre id="otpOut" class="mono"></pre>
</section>
</div>
</div>
<script type="module">
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
const logEl = document.getElementById("log");
const connStatusEl = document.getElementById("connStatus");
const connectBtn = document.getElementById("connectBtn");
const disconnectBtn = document.getElementById("disconnectBtn");
let port = null;
let reader = null;
let writer = null;
let readLoopRunning = false;
let rxBuffer = new Uint8Array(0);
let pendingResolve = null;
function log(...args) {
logEl.textContent += args.join(" ") + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, mode = "") {
connStatusEl.textContent = text;
connStatusEl.className = `status ${mode}`.trim();
}
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
function utf8(s) { return new TextEncoder().encode(s); }
function be32(n) {
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
}
async function sha256Hex(dataBytes) {
const h = await crypto.subtle.digest("SHA-256", dataBytes);
return hex(new Uint8Array(h));
}
function pretty(value) {
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
}
async function buildAuth(method, params) {
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params);
const bodyHash = await sha256Hex(utf8(paramsJson));
const tags = [
["nsigner_rpc", "1"],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "cyd-webserial-demo";
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await sha256Hex(utf8(ser));
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
}
/* ---- Web Serial transport ---- */
async function readLoop() {
readLoopRunning = true;
while (readLoopRunning && reader) {
try {
const { value, done } = await reader.read();
if (done) break;
if (value) {
const next = new Uint8Array(rxBuffer.length + value.length);
next.set(rxBuffer, 0);
next.set(value, rxBuffer.length);
rxBuffer = next;
tryDeliver();
}
} catch (e) {
log("read error:", e.message);
break;
}
}
readLoopRunning = false;
}
function tryDeliver() {
if (pendingResolve === null) return;
while (rxBuffer.length >= 4) {
const len = (rxBuffer[0] << 24) | (rxBuffer[1] << 16) | (rxBuffer[2] << 8) | rxBuffer[3];
if (len <= 0 || len > 16384) {
/* resync: drop one byte */
rxBuffer = rxBuffer.slice(1);
continue;
}
if (rxBuffer.length < 4 + len) return;
const payload = rxBuffer.slice(4, 4 + len);
rxBuffer = rxBuffer.slice(4 + len);
const text = new TextDecoder().decode(payload);
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = text; }
const r = pendingResolve;
pendingResolve = null;
r(parsed);
if (pendingResolve === null) return;
}
}
async function sendRpc(reqObj) {
if (!writer) throw new Error("not connected");
const body = utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(be32(body.length), 0);
frame.set(body, 4);
await writer.write(frame);
const resp = await new Promise((resolve, reject) => {
pendingResolve = resolve;
setTimeout(() => {
if (pendingResolve === resolve) {
pendingResolve = null;
reject(new Error("timeout (30s) — check the CYD screen for an approval prompt"));
}
}, 65000);
});
return resp;
}
async function callVerb(method, params, outEl) {
outEl.textContent = "→ " + method + " " + JSON.stringify(params);
try {
const auth = await buildAuth(method, params);
const req = { id: String(Math.floor(Math.random() * 1e9)), method, params, auth };
const resp = await sendRpc(req);
outEl.textContent += "\n← " + pretty(resp);
} catch (e) {
outEl.textContent += "\n✗ " + e.message;
}
}
/* ---- Connect / Disconnect ---- */
connectBtn.addEventListener("click", async () => {
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
reader = port.readable.getReader();
writer = port.writable.getWriter();
rxBuffer = new Uint8Array(0);
readLoop();
setStatus("Connected", "ok");
log("Connected to CYD via Web Serial @ 115200 baud");
document.querySelectorAll("button[id$='Btn']").forEach(b => {
if (b !== connectBtn && b !== disconnectBtn) b.disabled = false;
});
disconnectBtn.disabled = false;
connectBtn.disabled = true;
} catch (e) {
setStatus("Error", "err");
log("connect failed:", e.message);
}
});
disconnectBtn.addEventListener("click", async () => {
readLoopRunning = false;
try { if (reader) await reader.cancel(); } catch {}
try { if (writer) await writer.releaseLock(); } catch {}
try { if (port) await port.close(); } catch {}
reader = null; writer = null; port = null;
setStatus("Disconnected");
document.querySelectorAll("button[id$='Btn']").forEach(b => {
if (b !== connectBtn) b.disabled = true;
});
connectBtn.disabled = false;
disconnectBtn.disabled = true;
log("Disconnected");
});
/* ---- Verb wiring ---- */
const $ = id => document.getElementById(id);
$("gpkBtn").addEventListener("click", () => {
const alg = $("gpkAlg").value, idx = Number($("gpkIdx").value || 0);
callVerb("get_public_key", [{ algorithm: alg, index: idx }], $("gpkOut"));
});
$("ngpkBtn").addEventListener("click", () => {
const idx = Number($("ngpkIdx").value || 0), fmt = $("ngpkFmt").value;
const opts = { nostr_index: idx };
if (fmt === "structured") opts.format = "structured";
callVerb("nostr_get_public_key", [opts], $("ngpkOut"));
});
$("signBtn").addEventListener("click", () => {
const alg = $("signAlg").value, idx = Number($("signIdx").value || 0);
const scheme = $("signScheme").value, msg = $("signMsg").value;
const opts = { algorithm: alg, index: idx };
if (alg === "secp256k1") opts.scheme = scheme;
callVerb("sign", [msg, opts], $("signOut"));
});
$("verifyBtn").addEventListener("click", async () => {
const alg = $("signAlg").value, idx = Number($("signIdx").value || 0);
const scheme = $("signScheme").value, msg = $("signMsg").value;
const opts = { algorithm: alg, index: idx };
if (alg === "secp256k1") opts.scheme = scheme;
/* parse the last sign result to get the signature */
const outText = $("signOut").textContent;
const m = outText.match(/"signature"\s*:\s*"([0-9a-f]+)"/);
if (!m) { $("signOut").textContent += "\n✗ no signature found — run sign first"; return; }
callVerb("verify", [msg, m[1], opts], $("signOut"));
});
$("encapBtn").addEventListener("click", async () => {
let peer = $("kemPeer").value.trim();
if (!peer) {
/* fetch self ml-kem-768 pubkey first */
const opts = [{ algorithm: "ml-kem-768", index: Number($("kemIdx").value || 0) }];
const auth = await buildAuth("get_public_key", opts);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "get_public_key", params: opts, auth });
peer = resp.result && JSON.parse(resp.result).public_key;
if (!peer) { $("kemOut").textContent = "✗ could not fetch self pubkey"; return; }
$("kemPeer").value = peer;
}
callVerb("encapsulate", [peer, { algorithm: "ml-kem-768" }], $("kemOut"));
});
$("decapBtn").addEventListener("click", () => {
const ct = $("kemCt").value.trim();
const idx = Number($("kemIdx").value || 0);
if (!ct) { $("kemOut").textContent = "✗ paste a ciphertext first (from encapsulate)"; return; }
callVerb("decapsulate", [ct, { algorithm: "ml-kem-768", index: idx }], $("kemOut"));
});
$("x25519Btn").addEventListener("click", () => {
const peer = $("x25519Peer").value.trim();
const idx = Number($("x25519Idx").value || 0);
if (!peer) { $("x25519Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("derive_shared_secret", [peer, { algorithm: "x25519", index: idx }], $("x25519Out"));
});
$("deriveBtn").addEventListener("click", () => {
const data = $("deriveData").value;
const idx = Number($("deriveIdx").value || 0);
callVerb("derive", [data, { algorithm: "secp256k1", index: idx }], $("deriveOut"));
});
$("nseBtn").addEventListener("click", () => {
const content = $("nseContent").value;
const idx = Number($("nseIdx").value || 0);
const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content };
callVerb("nostr_sign_event", [event, { nostr_index: idx }], $("nseOut"));
});
$("nmeBtn").addEventListener("click", () => {
const content = $("nmeContent").value;
const idx = Number($("nmeIdx").value || 0);
const diff = Number($("nmeDiff").value || 4);
const timeout = Number($("nmeTimeout").value || 30);
const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content };
callVerb("nostr_mine_event", [event, { nostr_index: idx, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
});
const nip04Enc = () => {
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value, idx = Number($("nip04Idx").value || 0);
if (!peer) { $("nip04Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("nostr_nip04_encrypt", [peer, msg, { nostr_index: idx }], $("nip04Out"));
};
const nip04Dec = () => {
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0);
if (!peer || !ct) { $("nip04Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
callVerb("nostr_nip04_decrypt", [peer, ct, { nostr_index: idx }], $("nip04Out"));
};
$("nip04EncBtn").addEventListener("click", nip04Enc);
$("nip04DecBtn").addEventListener("click", nip04Dec);
const nip44Enc = () => {
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value, idx = Number($("nip44Idx").value || 0);
if (!peer) { $("nip44Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("nostr_nip44_encrypt", [peer, msg, { nostr_index: idx }], $("nip44Out"));
};
const nip44Dec = () => {
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0);
if (!peer || !ct) { $("nip44Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
callVerb("nostr_nip44_decrypt", [peer, ct, { nostr_index: idx }], $("nip44Out"));
};
$("nip44EncBtn").addEventListener("click", nip44Enc);
$("nip44DecBtn").addEventListener("click", nip44Dec);
$("otpEncBtn").addEventListener("click", () => {
const pt = $("otpPlain").value, enc = $("otpEnc").value;
callVerb("encrypt", [pt, { algorithm: "otp", encoding: enc }], $("otpOut"));
});
$("otpDecBtn").addEventListener("click", () => {
const ct = $("otpCipher").value, enc = $("otpEnc").value;
if (!ct) { $("otpOut").textContent = "✗ paste ciphertext first (from encrypt)"; return; }
callVerb("decrypt", [ct, { algorithm: "otp", encoding: enc }], $("otpOut"));
});
if (!("serial" in navigator)) {
log("Web Serial not supported in this browser. Use Chrome/Edge/Brave/Opera.");
connectBtn.disabled = true;
}
</script>
</body>
</html>

View File

@@ -140,11 +140,11 @@ def main() -> int:
req = { req = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "2", "id": "2",
"method": "sign_event", "method": "nostr_sign_event",
"params": params, "params": params,
} }
if not no_auth: 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") body = json.dumps(req, separators=(",", ":")).encode("utf-8")
frame = struct.pack(">I", len(body)) + body frame = struct.pack(">I", len(body)) + body

View File

@@ -460,7 +460,7 @@
}; };
const params = [unsignedEvent, getIndexOptions()]; 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); signOutEl.textContent = pretty(resp?.result ?? resp);
} catch (e) { } catch (e) {
signOutEl.textContent = String(e); signOutEl.textContent = String(e);
@@ -472,7 +472,7 @@
const peer = requirePeerHex(nip04PeerEl.value); const peer = requirePeerHex(nip04PeerEl.value);
const msg = String(nip04MsgEl.value || ""); const msg = String(nip04MsgEl.value || "");
const params = [peer, msg, getIndexOptions()]; 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); nip04OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") { if (resp && typeof resp.result === "string") {
nip04DecPeerEl.value = peer; nip04DecPeerEl.value = peer;
@@ -488,7 +488,7 @@
const peer = requirePeerHex(nip04DecPeerEl.value); const peer = requirePeerHex(nip04DecPeerEl.value);
const ciphertext = String(nip04CipherEl.value || ""); const ciphertext = String(nip04CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()]; 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"); nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
} catch (e) { } catch (e) {
nip04DecOutEl.textContent = String(e); nip04DecOutEl.textContent = String(e);
@@ -500,7 +500,7 @@
const peer = requirePeerHex(nip44PeerEl.value); const peer = requirePeerHex(nip44PeerEl.value);
const msg = String(nip44MsgEl.value || ""); const msg = String(nip44MsgEl.value || "");
const params = [peer, msg, getIndexOptions()]; 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); nip44OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") { if (resp && typeof resp.result === "string") {
nip44DecPeerEl.value = peer; nip44DecPeerEl.value = peer;
@@ -516,7 +516,7 @@
const peer = requirePeerHex(nip44DecPeerEl.value); const peer = requirePeerHex(nip44DecPeerEl.value);
const ciphertext = String(nip44CipherEl.value || ""); const ciphertext = String(nip44CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()]; 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"); nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) { } catch (e) {
nip44DecOutEl.textContent = String(e); nip44DecOutEl.textContent = String(e);

View File

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

View File

@@ -0,0 +1,110 @@
/*
* get_pubkey_qrexec.c — connect to a running n_signer in another Qubes qube
* via qrexec, using the high-level nostr_signer API from nostr_core_lib.
*
* This demonstrates the new nostr_core_lib client features:
* - nostr_signer_nsigner_qrexec() — qrexec transport
* - nostr_signer_nsigner_set_nostr_index() — index-based key selection
*
* Usage:
* ./get_pubkey_qrexec <target_qube> [nostr_index]
* ./get_pubkey_qrexec nostr_signer 0
* ./get_pubkey_qrexec nostr_signer 1
*
* No auth envelope needed — qrexec identity comes from QREXEC_REMOTE_DOMAIN
* on the server side.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "nostr_common.h"
#include "nostr_signer.h"
#include "nip019.h"
int main(int argc, char **argv) {
const char *target_qube;
const char *service_name = "qubes.NsignerRpc";
int nostr_index = 0;
nostr_signer_t *signer = NULL;
char pubkey_hex[65];
unsigned char pubkey_bytes[32];
char npub[128];
int rc;
if (argc < 2) {
fprintf(stderr, "Usage: %s <target_qube> [nostr_index]\n", argv[0]);
return 1;
}
target_qube = argv[1];
if (argc > 2) {
nostr_index = atoi(argv[2]);
}
if (nostr_init() != NOSTR_SUCCESS) {
fprintf(stderr, "failed to initialize crypto subsystem\n");
return 1;
}
printf("Connecting to n_signer in qube \"%s\" via qrexec (index %d)...\n",
target_qube, nostr_index);
/* Create a high-level signer backed by qrexec transport */
signer = nostr_signer_nsigner_qrexec(target_qube, service_name, NULL, 30000);
if (signer == NULL) {
fprintf(stderr, "failed to create qrexec signer (is qrexec-client-vm available?)\n");
nostr_cleanup();
return 1;
}
/* Select key by nostr_index (NIP-06 m/44'/1237'/N'/0/0) */
if (nostr_signer_nsigner_set_nostr_index(signer, nostr_index) != NOSTR_SUCCESS) {
fprintf(stderr, "failed to set nostr_index\n");
nostr_signer_free(signer);
nostr_cleanup();
return 1;
}
/* Request the public key */
rc = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc != NOSTR_SUCCESS) {
if (rc == NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED) {
fprintf(stderr, "DENIED: index %d is not in the signer's whitelist\n", nostr_index);
} else if (rc == NOSTR_ERROR_NSIGNER_POLICY_DENIED) {
fprintf(stderr, "DENIED: policy denied (caller not approved at signer terminal)\n");
} else {
fprintf(stderr, "get_public_key failed: error code %d\n", rc);
}
nostr_signer_free(signer);
nostr_cleanup();
return 1;
}
/* Convert hex pubkey to npub (bech32) */
{
int i;
for (i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(pubkey_hex + 2 * i, "%2x", &byte) != 1) {
fprintf(stderr, "failed to parse hex pubkey\n");
nostr_signer_free(signer);
nostr_cleanup();
return 1;
}
pubkey_bytes[i] = (unsigned char)byte;
}
}
if (nostr_key_to_bech32(pubkey_bytes, "npub", npub) != NOSTR_SUCCESS) {
fprintf(stderr, "failed to convert to npub\n");
nostr_signer_free(signer);
nostr_cleanup();
return 1;
}
printf("index %d: hex=%s npub=%s\n", nostr_index, pubkey_hex, npub);
nostr_signer_free(signer);
nostr_cleanup();
return 0;
}

View File

@@ -4,14 +4,14 @@
* bech32 npub for each. * bech32 npub for each.
* *
* This is a cross-qube test client for Qubes OS: the signer runs in the * 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. * a different qube connecting to the signer's FIPS address.
* *
* Usage: * Usage:
* ./get_pubkey_tcp <host> <port> * ./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: * Output: for each index, prints:
* index 0: hex=<64 hex chars> npub=npub1... * 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); cJSON_AddItemToArray(params, opts);
opts = NULL; 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, fprintf(stderr, "request failed for index %d: %s\n", nostr_index,
nsigner_client_last_error(client)); nsigner_client_last_error(client));
params = NULL; /* nsigner_client_call took ownership even on failure */ params = NULL; /* nsigner_client_call took ownership even on failure */
@@ -139,7 +139,7 @@ cleanup:
int main(int argc, char **argv) { int main(int argc, char **argv) {
const char *host = "127.0.0.1"; const char *host = "127.0.0.1";
int port = 8080; int port = 11111;
char hex0[65], npub0[128]; char hex0[65], npub0[128];
char hex1[65], npub1[128]; char hex1[65], npub1[128];
int failures = 0; int failures = 0;

View File

@@ -59,7 +59,7 @@ int main(int argc, char **argv) {
goto cleanup; 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)); fprintf(stderr, "request failed: %s\n", nsigner_client_last_error(client));
goto cleanup; goto cleanup;
} }

View File

@@ -167,7 +167,7 @@ def cmd_get_public_key(args):
def cmd_sign_event(args): def cmd_sign_event(args):
event = json.loads(args.event) 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(): def build_parser():

View File

@@ -5,14 +5,14 @@
* and bech32 npub for each. * and bech32 npub for each.
* *
* This is a cross-qube test client for Qubes OS: the signer runs in the * 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. * a different qube connecting to the signer's FIPS address.
* *
* Usage: * Usage:
* node n_signer_qube_example.js [host] [port] * 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: * Protocol:
* - 4-byte big-endian length prefix + JSON payload (TCP framing) * - 4-byte big-endian length prefix + JSON payload (TCP framing)
@@ -214,7 +214,7 @@ function hexToNpub(pubkeyHex) {
async function main() { async function main() {
const host = process.argv[2] || "127.0.0.1"; 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(`Connecting to n_signer at ${host}:${port}`);
console.log("Querying get_public_key for nostr_index 0 and 1...\n"); 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())

276
examples/pq_kem_example.c Normal file
View File

@@ -0,0 +1,276 @@
/*
* pq_kem_example.c — connect to a running n_signer over its abstract UNIX
* socket and demonstrate post-quantum key encapsulation with ML-KEM-768.
*
* The example:
* 1. Sends a get_public_key request for an ML-KEM-768 role ("kem_main").
* 2. Prints the structured public key (algorithm, public_key, key_id).
* 3. Sends a kem_encapsulate request with the public key, obtaining a
* ciphertext + shared secret.
* 4. Sends a kem_decapsulate request with the ciphertext, recovering the
* shared secret on the signer side.
* 5. Prints both shared secrets — they should match.
*
* Prerequisites:
* - n_signer must be running with a role configured for purpose=pq-kem,
* curve=ml-kem-768, named "kem_main" (or pass the role name as the 2nd arg).
* - A mnemonic must be loaded in the signer.
*
* Usage: ./pq_kem_example [socket_name] [role_name]
*
* Default socket_name: nsigner
* Default role_name: kem_main
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "nostr_common.h"
#include "nsigner_transport.h"
#include "nsigner_client.h"
#include "../cjson/cJSON.h"
static int get_structured_pubkey(nsigner_client_t *client, const char *role,
char **out_pub_hex) {
cJSON *params = NULL;
cJSON *opts = NULL;
cJSON *result = NULL;
cJSON *parsed = NULL;
int rc = -1;
*out_pub_hex = NULL;
params = cJSON_CreateArray();
if (params == NULL) return -1;
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "algorithm", "ml-kem-768");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "get_public_key", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return -1;
}
params = NULL;
if (cJSON_IsString(result)) {
parsed = cJSON_Parse(result->valuestring);
if (parsed != NULL) {
cJSON *pk_item = cJSON_GetObjectItemCaseSensitive(parsed, "public_key");
if (cJSON_IsString(pk_item)) {
*out_pub_hex = strdup(pk_item->valuestring);
rc = 0;
}
}
}
cJSON_Delete(parsed);
cJSON_Delete(result);
cJSON_Delete(params);
return rc;
}
/* kem_encapsulate: returns ciphertext_hex and shared_secret_hex (newly
* allocated, caller frees). */
static int kem_encapsulate(nsigner_client_t *client, const char *role,
const char *pub_hex,
char **out_ct_hex, char **out_ss_hex) {
cJSON *params = NULL;
cJSON *opts = NULL;
cJSON *result = NULL;
int rc = -1;
*out_ct_hex = NULL;
*out_ss_hex = NULL;
params = cJSON_CreateArray();
if (params == NULL) return -1;
cJSON_AddItemToArray(params, cJSON_CreateString(pub_hex));
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "algorithm", "ml-kem-768");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "encapsulate", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return -1;
}
params = NULL;
if (cJSON_IsString(result)) {
cJSON *parsed = cJSON_Parse(result->valuestring);
if (parsed != NULL) {
cJSON *ct_item = cJSON_GetObjectItemCaseSensitive(parsed, "ciphertext");
cJSON *ss_item = cJSON_GetObjectItemCaseSensitive(parsed, "shared_secret");
if (cJSON_IsString(ct_item) && cJSON_IsString(ss_item)) {
*out_ct_hex = strdup(ct_item->valuestring);
*out_ss_hex = strdup(ss_item->valuestring);
if (*out_ct_hex != NULL && *out_ss_hex != NULL) {
rc = 0;
}
}
cJSON_Delete(parsed);
}
}
cJSON_Delete(result);
cJSON_Delete(params);
return rc;
}
static char *kem_decapsulate(nsigner_client_t *client, const char *role,
const char *ct_hex) {
cJSON *params = NULL;
cJSON *opts = NULL;
cJSON *result = NULL;
char *ss_hex = NULL;
params = cJSON_CreateArray();
if (params == NULL) return NULL;
cJSON_AddItemToArray(params, cJSON_CreateString(ct_hex));
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return NULL;
}
cJSON_AddStringToObject(opts, "algorithm", "ml-kem-768");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "decapsulate", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return NULL;
}
params = NULL;
if (cJSON_IsString(result)) {
cJSON *parsed = cJSON_Parse(result->valuestring);
if (parsed != NULL) {
cJSON *ss_item = cJSON_GetObjectItemCaseSensitive(parsed, "shared_secret");
if (cJSON_IsString(ss_item)) {
ss_hex = strdup(ss_item->valuestring);
}
cJSON_Delete(parsed);
}
}
cJSON_Delete(result);
cJSON_Delete(params);
return ss_hex;
}
int main(int argc, char **argv) {
const char *socket_name = "nsigner";
const char *role = "kem_main";
nsigner_transport_t *transport = NULL;
nsigner_client_t *client = NULL;
char *pub_hex = NULL;
char *ct_hex = NULL;
char *encap_ss_hex = NULL;
char *decap_ss_hex = NULL;
int rc = 1;
if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') {
socket_name = argv[1];
}
if (argc > 2 && argv[2] != NULL && argv[2][0] != '\0') {
role = argv[2];
}
if (nostr_init() != NOSTR_SUCCESS) {
fprintf(stderr, "failed to initialize crypto subsystem\n");
return 1;
}
transport = nsigner_transport_open_unix(socket_name, 10000);
if (transport == NULL) {
fprintf(stderr, "connect failed: cannot open unix transport @%s\n", socket_name);
goto cleanup;
}
client = nsigner_client_new(transport);
if (client == NULL) {
fprintf(stderr, "connect failed: cannot create nsigner client\n");
transport->close(transport);
goto cleanup;
}
transport = NULL;
printf("=== PQ KEM Example (ML-KEM-768) ===\n");
printf("socket: %s\n", socket_name);
printf("role: %s\n", role);
printf("\n");
/* 1. Get the ML-KEM-768 public key. */
if (get_structured_pubkey(client, role, &pub_hex) != 0 || pub_hex == NULL) {
fprintf(stderr, "get_public_key failed: %s\n",
nsigner_client_last_error(client));
goto cleanup;
}
printf("Public Key:\n");
printf(" pub_len: %zu hex chars (%zu bytes)\n",
strlen(pub_hex), strlen(pub_hex) / 2);
printf(" pub_head: %.64s...\n", pub_hex);
printf("\n");
/* 2. Encapsulate with the public key. */
printf("Encapsulating with public key...\n");
if (kem_encapsulate(client, role, pub_hex, &ct_hex, &encap_ss_hex) != 0) {
fprintf(stderr, "kem_encapsulate failed: %s\n",
nsigner_client_last_error(client));
goto cleanup;
}
printf("Ciphertext:\n");
printf(" ct_len: %zu hex chars (%zu bytes)\n",
strlen(ct_hex), strlen(ct_hex) / 2);
printf(" ct_head: %.64s...\n", ct_hex);
printf("Encapsulated shared secret:\n");
printf(" ss: %s\n", encap_ss_hex);
printf("\n");
/* 3. Decapsulate with the ciphertext (uses the role's private key). */
printf("Decapsulating ciphertext on signer side...\n");
decap_ss_hex = kem_decapsulate(client, role, ct_hex);
if (decap_ss_hex == NULL) {
fprintf(stderr, "kem_decapsulate failed: %s\n",
nsigner_client_last_error(client));
goto cleanup;
}
printf("Decapsulated shared secret:\n");
printf(" ss: %s\n", decap_ss_hex);
printf("\n");
/* 4. Verify the shared secrets match. */
if (strcmp(encap_ss_hex, decap_ss_hex) == 0) {
printf("SUCCESS: shared secrets match!\n");
rc = 0;
} else {
printf("FAILURE: shared secrets do NOT match!\n");
}
cleanup:
free(pub_hex);
free(ct_hex);
free(encap_ss_hex);
free(decap_ss_hex);
nsigner_client_free(client);
nostr_cleanup();
return rc;
}

217
examples/pq_sign_example.c Normal file
View File

@@ -0,0 +1,217 @@
/*
* pq_sign_example.c — connect to a running n_signer over its abstract UNIX
* socket and demonstrate post-quantum signing with ML-DSA-65.
*
* The example:
* 1. Sends a get_public_key request for an ML-DSA-65 role ("pq_sig").
* 2. Prints the structured public key (algorithm, public_key, key_id).
* 3. Sends a sign_data request with a test message.
* 4. Prints the signature (hex) and algorithm.
*
* Prerequisites:
* - n_signer must be running with a role configured for purpose=pq-sig,
* curve=ml-dsa-65, named "pq_sig" (or pass the role name as the 2nd arg).
* - A mnemonic must be loaded in the signer.
*
* Usage: ./pq_sign_example [socket_name] [role_name]
*
* Default socket_name: nsigner
* Default role_name: pq_sig
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "nostr_common.h"
#include "nsigner_transport.h"
#include "nsigner_client.h"
#include "../cjson/cJSON.h"
static int get_structured_pubkey(nsigner_client_t *client, const char *role,
cJSON **out_obj) {
cJSON *params = NULL;
cJSON *opts = NULL;
cJSON *result = NULL;
cJSON *parsed = NULL;
int rc = -1;
*out_obj = NULL;
params = cJSON_CreateArray();
if (params == NULL) return -1;
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "algorithm", "ml-dsa-65");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "get_public_key", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return -1;
}
params = NULL;
/* result is a cJSON string containing the serialized structured object. */
if (cJSON_IsString(result)) {
parsed = cJSON_Parse(result->valuestring);
if (parsed != NULL && cJSON_IsObject(parsed)) {
*out_obj = parsed;
parsed = NULL;
rc = 0;
}
}
cJSON_Delete(parsed);
cJSON_Delete(result);
cJSON_Delete(params);
return rc;
}
static char *sign_data(nsigner_client_t *client, const char *role,
const char *msg_hex) {
cJSON *params = NULL;
cJSON *opts = NULL;
cJSON *result = NULL;
char *sig_hex = NULL;
params = cJSON_CreateArray();
if (params == NULL) return NULL;
cJSON_AddItemToArray(params, cJSON_CreateString(msg_hex));
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return NULL;
}
cJSON_AddStringToObject(opts, "algorithm", "ml-dsa-65");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "sign", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return NULL;
}
params = NULL;
/* result is a string containing {"signature":"<hex>","algorithm":"<alg>"} */
if (cJSON_IsString(result)) {
cJSON *parsed = cJSON_Parse(result->valuestring);
if (parsed != NULL) {
cJSON *sig_item = cJSON_GetObjectItemCaseSensitive(parsed, "signature");
if (cJSON_IsString(sig_item)) {
sig_hex = strdup(sig_item->valuestring);
}
cJSON_Delete(parsed);
}
}
cJSON_Delete(result);
cJSON_Delete(params);
return sig_hex;
}
int main(int argc, char **argv) {
const char *socket_name = "nsigner";
const char *role = "pq_sig";
/* "hello post-quantum world" in hex */
const char *msg_hex = "68656c6c6f20706f73742d7175616e74756d20776f726c64";
nsigner_transport_t *transport = NULL;
nsigner_client_t *client = NULL;
cJSON *pubkey_obj = NULL;
char *sig_hex = NULL;
int rc = 1;
if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') {
socket_name = argv[1];
}
if (argc > 2 && argv[2] != NULL && argv[2][0] != '\0') {
role = argv[2];
}
if (nostr_init() != NOSTR_SUCCESS) {
fprintf(stderr, "failed to initialize crypto subsystem\n");
return 1;
}
transport = nsigner_transport_open_unix(socket_name, 10000);
if (transport == NULL) {
fprintf(stderr, "connect failed: cannot open unix transport @%s\n", socket_name);
goto cleanup;
}
client = nsigner_client_new(transport);
if (client == NULL) {
fprintf(stderr, "connect failed: cannot create nsigner client\n");
transport->close(transport);
goto cleanup;
}
transport = NULL;
printf("=== PQ Sign Example (ML-DSA-65) ===\n");
printf("socket: %s\n", socket_name);
printf("role: %s\n", role);
printf("\n");
/* 1. Get the structured public key. */
if (get_structured_pubkey(client, role, &pubkey_obj) != 0 || pubkey_obj == NULL) {
fprintf(stderr, "get_public_key failed: %s\n",
nsigner_client_last_error(client));
goto cleanup;
}
{
cJSON *alg_item = cJSON_GetObjectItemCaseSensitive(pubkey_obj, "algorithm");
cJSON *pk_item = cJSON_GetObjectItemCaseSensitive(pubkey_obj, "public_key");
cJSON *kid_item = cJSON_GetObjectItemCaseSensitive(pubkey_obj, "key_id");
printf("Public Key:\n");
printf(" algorithm: %s\n",
(cJSON_IsString(alg_item)) ? alg_item->valuestring : "?");
printf(" key_id: %s\n",
(cJSON_IsString(kid_item)) ? kid_item->valuestring : "?");
if (cJSON_IsString(pk_item)) {
/* ML-DSA-65 public key is 3904 hex chars — print length + prefix. */
printf(" pub_len: %zu hex chars (%zu bytes)\n",
strlen(pk_item->valuestring), strlen(pk_item->valuestring) / 2);
printf(" pub_head: %.64s...\n", pk_item->valuestring);
} else {
printf(" public_key: (missing)\n");
}
}
printf("\n");
/* 2. Sign a test message. */
printf("Signing message (hex): %s\n", msg_hex);
sig_hex = sign_data(client, role, msg_hex);
if (sig_hex == NULL) {
fprintf(stderr, "sign_data failed: %s\n",
nsigner_client_last_error(client));
goto cleanup;
}
printf("Signature:\n");
printf(" sig_len: %zu hex chars (%zu bytes)\n",
strlen(sig_hex), strlen(sig_hex) / 2);
printf(" sig_head: %.64s...\n", sig_hex);
printf("\n");
printf("To verify externally, use ML-DSA-65 (FIPS 204) verify with the\n");
printf("public key above, the message, and this signature.\n");
rc = 0;
cleanup:
free(sig_hex);
cJSON_Delete(pubkey_obj);
nsigner_client_free(client);
nostr_cleanup();
return rc;
}

View File

@@ -73,7 +73,7 @@ int main(int argc, char **argv) {
cJSON_AddItemToArray(params, opts); cJSON_AddItemToArray(params, opts);
opts = NULL; /* owned by params now */ 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)); fprintf(stderr, "request failed: %s\n", nsigner_client_last_error(client));
goto cleanup; goto cleanup;
} }

214
examples/ssh_sign_example.c Normal file
View File

@@ -0,0 +1,214 @@
/*
* ssh_sign_example.c — connect to a running n_signer over its abstract UNIX
* socket and demonstrate SSH signing with ed25519.
*
* The example:
* 1. Sends a get_public_key request for an SSH/ed25519 role ("ssh_main").
* 2. Prints the structured public key (algorithm, public_key, key_id).
* 3. Sends an ssh_sign request with a test session ID.
* 4. Prints the signature (hex) and algorithm.
*
* Prerequisites:
* - n_signer must be running with a role configured for purpose=ssh,
* curve=ed25519, named "ssh_main" (or pass the role name as the 2nd arg).
* - A mnemonic must be loaded in the signer.
*
* Usage: ./ssh_sign_example [socket_name] [role_name]
*
* Default socket_name: nsigner
* Default role_name: ssh_main
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "nostr_common.h"
#include "nsigner_transport.h"
#include "nsigner_client.h"
#include "../cjson/cJSON.h"
static int get_structured_pubkey(nsigner_client_t *client, const char *role,
char **out_pub_hex, char **out_key_id) {
cJSON *params = NULL;
cJSON *opts = NULL;
cJSON *result = NULL;
cJSON *parsed = NULL;
int rc = -1;
*out_pub_hex = NULL;
*out_key_id = NULL;
params = cJSON_CreateArray();
if (params == NULL) return -1;
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return -1;
}
cJSON_AddStringToObject(opts, "algorithm", "ed25519");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "get_public_key", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return -1;
}
params = NULL;
if (cJSON_IsString(result)) {
parsed = cJSON_Parse(result->valuestring);
if (parsed != NULL) {
cJSON *pk_item = cJSON_GetObjectItemCaseSensitive(parsed, "public_key");
cJSON *kid_item = cJSON_GetObjectItemCaseSensitive(parsed, "key_id");
if (cJSON_IsString(pk_item)) {
*out_pub_hex = strdup(pk_item->valuestring);
}
if (cJSON_IsString(kid_item)) {
*out_key_id = strdup(kid_item->valuestring);
}
if (*out_pub_hex != NULL) {
rc = 0;
}
}
}
cJSON_Delete(parsed);
cJSON_Delete(result);
cJSON_Delete(params);
return rc;
}
static char *ssh_sign(nsigner_client_t *client, const char *role,
const char *msg_hex) {
cJSON *params = NULL;
cJSON *opts = NULL;
cJSON *result = NULL;
char *sig_hex = NULL;
params = cJSON_CreateArray();
if (params == NULL) return NULL;
cJSON_AddItemToArray(params, cJSON_CreateString(msg_hex));
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return NULL;
}
cJSON_AddStringToObject(opts, "algorithm", "ed25519");
cJSON_AddNumberToObject(opts, "index", 0);
cJSON_AddItemToArray(params, opts);
opts = NULL;
if (nsigner_client_call(client, "sign", params, &result) != NOSTR_SUCCESS) {
cJSON_Delete(params);
return NULL;
}
params = NULL;
if (cJSON_IsString(result)) {
cJSON *parsed = cJSON_Parse(result->valuestring);
if (parsed != NULL) {
cJSON *sig_item = cJSON_GetObjectItemCaseSensitive(parsed, "signature");
if (cJSON_IsString(sig_item)) {
sig_hex = strdup(sig_item->valuestring);
}
cJSON_Delete(parsed);
}
}
cJSON_Delete(result);
cJSON_Delete(params);
return sig_hex;
}
int main(int argc, char **argv) {
const char *socket_name = "nsigner";
const char *role = "ssh_main";
/* A fake SSH session ID (32 bytes = 64 hex chars) for demonstration. */
const char *session_id_hex =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
nsigner_transport_t *transport = NULL;
nsigner_client_t *client = NULL;
char *pub_hex = NULL;
char *key_id = NULL;
char *sig_hex = NULL;
int rc = 1;
if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') {
socket_name = argv[1];
}
if (argc > 2 && argv[2] != NULL && argv[2][0] != '\0') {
role = argv[2];
}
if (nostr_init() != NOSTR_SUCCESS) {
fprintf(stderr, "failed to initialize crypto subsystem\n");
return 1;
}
transport = nsigner_transport_open_unix(socket_name, 10000);
if (transport == NULL) {
fprintf(stderr, "connect failed: cannot open unix transport @%s\n", socket_name);
goto cleanup;
}
client = nsigner_client_new(transport);
if (client == NULL) {
fprintf(stderr, "connect failed: cannot create nsigner client\n");
transport->close(transport);
goto cleanup;
}
transport = NULL;
printf("=== SSH Sign Example (ed25519) ===\n");
printf("socket: %s\n", socket_name);
printf("role: %s\n", role);
printf("\n");
/* 1. Get the ed25519 public key. */
if (get_structured_pubkey(client, role, &pub_hex, &key_id) != 0 ||
pub_hex == NULL) {
fprintf(stderr, "get_public_key failed: %s\n",
nsigner_client_last_error(client));
goto cleanup;
}
printf("Public Key:\n");
printf(" algorithm: ed25519\n");
printf(" key_id: %s\n", (key_id != NULL) ? key_id : "?");
printf(" pub_len: %zu hex chars (%zu bytes)\n",
strlen(pub_hex), strlen(pub_hex) / 2);
printf(" pubkey: %s\n", pub_hex);
printf("\n");
/* 2. Sign a test SSH session ID. */
printf("Signing SSH session ID (hex): %s\n", session_id_hex);
sig_hex = ssh_sign(client, role, session_id_hex);
if (sig_hex == NULL) {
fprintf(stderr, "ssh_sign failed: %s\n",
nsigner_client_last_error(client));
goto cleanup;
}
printf("Signature:\n");
printf(" sig_len: %zu hex chars (%zu bytes)\n",
strlen(sig_hex), strlen(sig_hex) / 2);
printf(" sig: %s\n", sig_hex);
printf("\n");
printf("This ed25519 signature can be verified with the public key above\n");
printf("using standard ed25519 verify (e.g. libsodium, OpenSSL EVP_DigestVerify).\n");
rc = 0;
cleanup:
free(pub_hex);
free(key_id);
free(sig_hex);
nsigner_client_free(client);
nostr_cleanup();
return rc;
}

View File

@@ -53,6 +53,26 @@ WebUSB path:
- Run `get_public_key` - Run `get_public_key`
- Confirm pubkey matches CDC result - Confirm pubkey matches CDC result
## CYD (ESP32-2432S028) validation — Web Serial
The CYD has no native USB; its CH340 bridge exposes a serial port. The browser
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
page covering every algorithm and verb lives at
[`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html):
- Open [`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html) in Chrome/Edge
- Click **Connect Web Serial**, select the CH340 port (`1a86:7523`)
- Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
`encapsulate`/`decapsulate`, `derive_shared_secret`, `derive`,
`nostr_get_public_key`, `nostr_sign_event`, `nostr_mine_event`,
`nostr_nip04`/`nostr_nip44` encrypt+decrypt, and `encrypt`/`decrypt` (otp)
- Each request shows the raw JSON-RPC request and response
The CYD firmware (v0.0.2+) speaks the same algorithm-based API as the host
([`README.md`](../README.md) §4). The OTP pad is derived from the mnemonic
seed (no USB pad on this board); the offset advances monotonically and is
reported in every `encrypt`/`decrypt` response.
## Linux WebUSB host setup (one-time) ## Linux WebUSB host setup (one-time)
Chrome and Edge need permission to open the device on Linux. Install a udev rule for the firmware VID:PID and reload rules: Chrome and Edge need permission to open the device on Linux. Install a udev rule for the firmware VID:PID and reload rules:
@@ -99,3 +119,166 @@ Notes:
- Typical working range is ~4.7uF to 22uF; 10uF is recommended - Typical working range is ~4.7uF to 22uF; 10uF is recommended
- Keep leads short for best stability - Keep leads short for best stability
- Auto-reset behavior for flashing may still work, but if flashing ever becomes unreliable, enter bootloader manually - Auto-reset behavior for flashing may still work, but if flashing ever becomes unreliable, enter bootloader manually
## Post-quantum crypto support (Phase 7)
Both firmware targets (`feather_s3_tft` and `cyd_esp32_2432s028`) now include
the three NIST-standardized post-quantum algorithms alongside the existing
secp256k1 (Nostr) and new ed25519/x25519 classical algorithms:
| Algorithm | Standard | Purpose | Pub key | Priv key | Sig/Ct |
|---|---|---|---|---|---|
| secp256k1 | — | Nostr (existing) | 32 B | 32 B | 64 B |
| ed25519 | RFC 8032 | SSH signatures | 32 B | 32 B | 64 B |
| x25519 | RFC 7748 | Key agreement (age) | 32 B | 32 B | — |
| ML-DSA-65 | FIPS 204 | PQ signatures | 1952 B | 4032 B | 3309 B |
| SLH-DSA-128s | FIPS 205 | PQ hash-based sigs | 32 B | 64 B | 7856 B |
| ML-KEM-768 | FIPS 203 | PQ key encapsulation | 1184 B | 2400 B | 1088 B |
### mbedtls backend (vs OpenSSL on host)
The host build uses OpenSSL EVP for SHA-256, SHA-512, SHA3-256, SHA3-512,
SHAKE-128, and SHAKE-256. On ESP32, OpenSSL is not available. Instead, the
firmware uses a **crypto backend abstraction** ([`resources/pqclean/common/crypto_backend.h`](../resources/pqclean/common/crypto_backend.h))
with two implementations:
- [`resources/pqclean/common/crypto_backend_openssl.c`](../resources/pqclean/common/crypto_backend_openssl.c) — host build (OpenSSL EVP)
- [`resources/pqclean/common/crypto_backend_mbedtls.c`](../resources/pqclean/common/crypto_backend_mbedtls.c) — ESP32 firmware (mbedtls + vendored Keccak)
The mbedtls backend uses:
- `mbedtls_sha256()` for SHA-256 (ESP32 hardware accelerated where available)
- `mbedtls_sha512()` for SHA-512 (ESP32 hardware accelerated where available)
- A **self-contained Keccak-f[1600]** implementation (FIPS 202, public domain)
for SHA3-256, SHA3-512, SHAKE-128, and SHAKE-256. This is vendored directly
in `crypto_backend_mbedtls.c` because ESP-IDF v5.x mbedtls does not expose
SHAKE (and SHA3 is only available when `CONFIG_MBEDTLS_SHA3_C` is set) through
the `mbedtls_md` API. Carrying the Keccak core avoids any mbedtls config
dependency for the PQ algorithms.
### ed25519 / x25519 via PSA crypto
ESP-IDF v5.x mbedtls removed the low-level `mbedtls_ed25519_*` functions. The
firmware uses the **PSA Crypto API** for ed25519 sign/verify/key-derivation and
x25519 key derivation + ECDH. Enable PSA in `sdkconfig.defaults`:
```
CONFIG_MBEDTLS_PSA_CRYPTO_C=y
CONFIG_MBEDTLS_ECP_DP_CURVE25519_ENABLED=y
```
### No SHA3/SHAKE menuconfig requirement
Because SHA3/SHAKE are provided by the vendored Keccak core (not mbedtls), you
do **not** need to enable `CONFIG_MBEDTLS_SHA3_C` or any SHAKE config. The PQ
algorithms build and run with the default mbedtls configuration.
### PQClean component
The PQClean algorithm code is compiled as an ESP-IDF component at
`components/pqclean/`. The component's `CMakeLists.txt` references the shared
source files in [`resources/pqclean/`](../resources/pqclean/) via relative
paths, so there is a single source of truth for both host and firmware builds.
The component includes:
- ML-DSA-65: `sign.c`, `poly.c`, `ntt.c`
- SLH-DSA-128s: `sign.c`, `fors.c`, `wots.c`, `hash.c`, `thash.c`, `address.c`, `utils.c`
- ML-KEM-768: `kem.c`, `indcpa.c`, `poly.c`, `ntt.c`, `cbd.c`, `reduce.c`, `symmetric.c`, `verify.c`
- Common: `fips202.c`, `sha2.c`, `crypto_backend_mbedtls.c`
- Firmware DRBG: `pq_drbg_firmware.c`, `randombytes_mbedtls.c`
### Flash usage estimates
| Algorithm | Code size (approx) |
|---|---|
| ML-DSA-65 | ~150 KB |
| SLH-DSA-128s | ~80 KB |
| ML-KEM-768 | ~120 KB |
| Total PQ code | ~350 KB |
The ESP32-S3 (Feather S3 TFT) has 8 MB flash and the ESP32 (CYD) has 4 MB
flash. The PQ code fits comfortably in both, but partition sizes may need
adjustment if the total app image exceeds the default partition.
### RAM usage notes
PQ key buffers are large compared to classical ECC keys:
| Buffer | Size |
|---|---|
| ML-DSA-65 private key | 4032 bytes |
| ML-DSA-65 public key | 1952 bytes |
| ML-DSA-65 signature | 3309 bytes |
| SLH-DSA-128s signature | 7856 bytes |
| ML-KEM-768 private key | 2400 bytes |
| ML-KEM-768 public key | 1184 bytes |
| ML-KEM-768 ciphertext | 1088 bytes |
The ESP32 has ~320 KB available heap (after WiFi/BT are disabled). These
buffers **must not be stack-allocated** — the default task stack is 8 KB.
Use `malloc()` or static buffers. The firmware derives PQ keys **on demand**
(not all at startup) to keep peak RAM usage low.
### SLH-DSA-128s signing latency warning
SLH-DSA-128s (SPHINCS+-128s) is a hash-based signature scheme with a deep
hypertree structure (7 layers of WOTS+ + Merkle trees). On the ESP32-S3
(240 MHz dual-core), expect:
- **Key generation**: 530 seconds
- **Signing**: 530 seconds
- **Verification**: 0.52 seconds
This is inherent to the algorithm — it trades computation for minimal trust
assumptions (only SHA-256). The firmware logs a warning before SLH-DSA-128s
keygen/signing. Users should choose whether to use SLH-DSA-128s per-role
based on their latency tolerance. ML-DSA-65 is much faster (~100 ms for
signing on ESP32-S3) and is the recommended PQ signature algorithm for
interactive use.
### Derivation paths
All algorithms derive from the mnemonic using BIP-32/HMAC-SHA512 with
SLIP-0010 all-hardened derivation for ed25519/x25519/PQ:
| Algorithm | Path | Notes |
|---|---|---|
| secp256k1 (Nostr) | `m/44'/1237'/<n>'/0/0` | NIP-06, existing |
| ed25519 (SSH) | `m/44'/102001'/<n>'/0'/0'` | SLIP-0010 |
| x25519 (age) | `m/44'/102002'/<n>'/0'/0'` | SLIP-0010 |
| ML-DSA-65 | `m/44'/102003'/<n>'/0'/0'` | seed → PQClean keygen |
| SLH-DSA-128s | `m/44'/102004'/<n>'/0'/0'` | seed → PQClean keygen |
| ML-KEM-768 | `m/44'/102005'/<n>'/0'/0'` | seed → PQClean keygen |
The PQ derivation produces a 32-byte seed that feeds a deterministic
SHAKE-256 DRBG ([`pq_drbg_firmware.c`](feather_s3_tft/components/pqclean/pq_drbg_firmware.c)),
which replaces PQClean's `randombytes()` during keygen. This gives
deterministic, mnemonic-recoverable PQ keys — same mnemonic, same key pair.
### Firmware API
The firmware exposes PQ operations via [`pq_crypto_firmware.h`](feather_s3_tft/main/pq_crypto_firmware.h):
```c
/* Key generation (deterministic from mnemonic-derived seed) */
int fw_pq_ml_dsa_65_keygen(const uint8_t seed[32], uint8_t *pk, uint8_t *sk);
int fw_pq_slh_dsa_128s_keygen(const uint8_t seed[32], uint8_t *pk, uint8_t *sk);
int fw_pq_ml_kem_768_keygen(const uint8_t seed[32], uint8_t *pk, uint8_t *sk);
/* Signing / verification */
int fw_pq_ml_dsa_65_sign(uint8_t *sig, size_t *siglen, ...);
int fw_pq_slh_dsa_128s_sign(uint8_t *sig, size_t *siglen, ...);
/* KEM encaps / decaps */
int fw_pq_ml_kem_768_encaps(uint8_t *ct, uint8_t *ss, const uint8_t *pk);
int fw_pq_ml_kem_768_decaps(uint8_t *ss, const uint8_t *ct, const uint8_t *sk);
```
Key derivation from the mnemonic seed is via [`key_derivation.h`](feather_s3_tft/main/key_derivation.h):
```c
int derive_ed25519_key(const uint8_t seed[64], uint32_t index, ...);
int derive_x25519_key(const uint8_t seed[64], uint32_t index, ...);
int derive_ml_dsa_65_key(const uint8_t seed[64], uint32_t index, ...);
int derive_slh_dsa_128s_key(const uint8_t seed[64], uint32_t index, ...);
int derive_ml_kem_768_key(const uint8_t seed[64], uint32_t index, ...);
```

View File

@@ -0,0 +1,81 @@
# n_signer BLE Wearable Signer
**Status:** Concept — brainstorming. No plan yet.
A small, battery-powered wearable hardware signer that communicates with a host
over **Bluetooth Low Energy (BLE)**. The host sends JSON-RPC requests over a
BLE GATT characteristic; the signer shows an approval prompt on a tiny display;
the user taps a button to approve; the signed response goes back over BLE.
## Concept
```mermaid
flowchart LR
Host[Host: phone/laptop<br/>n_signer client] -->|BLE GATT| Signer[Wearable signer<br/>nRF52840 + OLED]
Signer -->|approve/deny button| User[User]
Signer -->|BLE GATT response| Host
```
The signer speaks the same algorithm-based API as the host and the CYD/Teensy
firmware ([`README.md`](../../README.md) §4). The auth envelope (kind 27235)
protects the BLE wire — even if BLE is sniffed, an attacker can't forge
requests without the caller's secp256k1 private key.
## Why BLE
- **Wearable form factor** — always with you (wristband, pendant, card)
- **No physical connection** — no USB cable, no host-side driver, no dongle
- **Universal host support** — phones, laptops, tablets all have BT
- **Low power** — nRF52840 draws ~5 mA active, ~1 µA sleep
## Hardware (preliminary)
| Component | Candidate | Notes |
|---|---|---|
| MCU | **nRF52840** (Nordic) | Cortex-M4 @ 64 MHz, 1 MB flash, 256 KB RAM, BT 5.0, hardware AES/ECC, USB device, NFC-A. ~$5-8. |
| Display | 0.96" or 1.3" SSD1306 OLED (I2C) or 1.02" e-paper | Small is fine — only shows "approve kind 1 from <caller>?" |
| Input | 2-3 tactile buttons (approve/deny/back) | No touch at this size |
| Power | 200 mAh coin cell or small LiPo | Weeks of battery life |
| Mnemonic entry | Buttons (scroll words), NFC from phone, or generate-on-device | The hard UX problem |
## Security considerations
- **BT stack attack surface:** BLE has a large stack (pairing, GATT, L2CAP, SMP).
A stack bug could allow code execution. Mitigations: use Nordic's audited
SoftDevice, disable unnecessary services, require LE Secure Connections pairing.
- **Radio range (~10 m):** an attacker in the same room could potentially
interact with the signer. The auth envelope + approval prompt protect against
this, but the radio is omnidirectional.
- **Pairing UX:** BT pairing can be frustrating. LE Secure Connections (Numeric
Comparison) is the most secure and user-friendly pairing method.
## Open questions
- **Mnemonic entry on a tiny screen:** scroll through 2048 BIP-39 words with
up/down buttons (like Coldcard)? Load via NFC from a phone? Generate on-device
and display for the user to write down?
- **PQ crypto on nRF52840:** 256 KB RAM is enough for ML-DSA-65 (~6 KB heap)
but SLH-DSA-128s is heavy. May need to limit the PQ algorithm set or stream
the keygen.
- **Display choice:** OLED (fast refresh, high power) vs e-paper (slow refresh,
zero power when static, persistent display).
- **Form factor:** wristband? pendant? card? What's the target use case —
daily signing, emergency key access, or a backup signer?
## Comparison to the IR air-gap signer
| | BLE wearable | IR air-gap |
|---|---|---|
| Air-gap | Medium (radio, ~10 m, omnidirectional) | High (light, line-of-sight, ~1 m) |
| Attack surface | Large (BT stack) | Small (no BT, dumb dongle) |
| Host compatibility | Universal (phones, laptops) | Requires USB dongle |
| Form factor | Wearable | Handheld (point at dongle) |
| Throughput | ~250 KB/s (BLE 5) | ~11 KB/s (raw IR) or ~400 KB/s (IrDA) |
| Novelty | Conventional | Novel (no hardware wallet uses IR) |
## Next steps
- Decide on the MCU (nRF52840 vs RP2040+BT-module)
- Decide on mnemonic entry method
- Decide on display (OLED vs e-paper)
- Write a port plan (similar to [`plans/teensy41_signer_port.md`](../../plans/teensy41_signer_port.md))

View File

@@ -0,0 +1,175 @@
# n_signer CYD Firmware (ESP32-2432S028)
The **Cheap Yellow Display** (ESP32-2432S028) is a $15 ESP32-WROOM-32 board with
a 2.8" 320×240 ILI9341 resistive-touch display, CH340 USB-UART bridge, and a
Micro SD card slot. This firmware turns it into a hardware n_signer that speaks
the same algorithm-based API as the host ([`README.md`](../../README.md) §4).
**Firmware version:** 0.0.2 (algorithm-based API)
## Hardware summary
| Concern | Value |
|---|---|
| MCU | ESP32-WROOM-32 (classic, dual-core Xtensa, 512 KB SRAM, no PSRAM) |
| USB-UART | CH340 (`1a86:7523`) → `/dev/ttyUSB0` |
| Flash | 4 MB |
| Display | 2.8" 320×240 ILI9341 (HSPI: DC=IO2, CS=IO15, SCK=IO14, MOSI=IO13, MISO=IO12, BL=IO21) |
| Touch | XPT2046 resistive (bit-banged SPI: CLK=IO25, MOSI=IO32, CS=IO33, MISO=IO39, IRQ=IO36) |
| SD card | Micro SD, VSPI (CS=IO5, SCK=IO18, MISO=IO19, MOSI=IO23) |
| RGB LED | R=IO4, G=IO16, B=IO17 (active LOW) |
| LDR | IO34 |
| Speaker | IO26 (DAC) |
| BOOT button | IO0 |
| GUI | LVGL 8.3 |
For the full pin map, connectors (P1/P3/CN1), and add-ons, see the upstream
hardware docs copied to [`docs/`](docs/) — especially
[`docs/PINS.md`](docs/PINS.md) and [`docs/SETUP.md`](docs/SETUP.md).
## SD card — size limits and OTP pad storage
The CYD's Micro SD slot is wired to VSPI (IO5/18/19/23). ESP-IDF drives it via
the SDSPI host + FATFS filesystem. The proven bring-up example is
`07_sd_card` in the `esp32_playground/cyb-esp32-2432s028/` workspace.
**Size limits:**
- **SDSC (≤ 2 GB):** supported.
- **SDHC (2 GB 32 GB):** supported — this is the recommended range. The
Makerfabs CYD ships with a 16 GB card, which works.
- **SDXC (> 32 GB):** **not supported** out of the box. SDXC cards ship
formatted as exFAT, and ESP-IDF's FATFS does not include exFAT. An SDXC card
reformatted to FAT32 will work up to 32 GB; beyond that, FAT32's 32 GB limit
applies. For OTP pad storage, 32 GB is vastly more than enough (see below).
**Recommendation:** use any **SDHC card from 432 GB** formatted **FAT32**.
### Using the SD card for the OTP pad
The current v0.0.2 firmware derives the OTP pad from the mnemonic seed via
HKDF-SHA256 into a 1024-byte in-RAM pad (no SD card required). This keeps the
wire contract identical to the host's `encrypt`/`decrypt` (algorithm:"otp")
verbs but limits the pad to 1024 bytes per session.
To hold a **large OTP pad** (the original n_signer host design binds a pad file
from `--otp-pad-dir`), the SD card is the right storage. The plan:
1. Format the SD card as FAT32.
2. Place a pad file (e.g. `nsigner.pad`) on it — any size up to the card's free
space. A 1 GB pad gives ~1 billion one-time-pad bytes before exhaustion.
3. The firmware mounts the SD card at boot via `esp_vfs_fat_sdmmc_mount()` on
the SDSPI host, opens the pad file, and reads pad bytes on demand into a
small ring buffer, advancing a persistent offset (stored in a small
`nsigner.offset` file on the SD so the offset survives power cycles).
4. The `encrypt`/`decrypt` verbs XOR against the SD-backed pad instead of the
HKDF-derived in-RAM pad.
This is a planned enhancement (see [`plans/cyd_algorithm_api_upgrade.md`](../../plans/cyd_algorithm_api_upgrade.md)
§13 — the current implementation uses the mnemonic-derived pad as the embedded
fallback). The SD card slot is confirmed working and the pin map is in
[`docs/PINS.md`](docs/PINS.md).
**Note on simultaneous display + touch + SD:** The CYD's display (HSPI), touch
(bit-banged), and SD (VSPI) use three different SPI buses. All three can run at
the same time — the touch is bit-banged precisely so it doesn't contend with
the other two hardware SPI buses (see [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md)).
## Building and flashing
Requires ESP-IDF v5.x (tested with v5.4.2). The classic ESP32 target uses the
`xtensa-esp-elf` unified toolchain.
```bash
source /home/user/esp/esp-idf/export.sh
cd firmware/cyd_esp32_2432s028
idf.py build
idf.py -p /dev/ttyUSB0 flash
```
If flashing fails with `Wrong boot mode detected (0x13)`, see the serial-reset
hardware note below.
## Validation — Web Serial
The CYD has no native USB; the CH340 bridge exposes a serial port. The browser
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
page covering every algorithm and verb lives at
[`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html):
1. Open [`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html) in Chrome/Edge.
2. Click **Connect Web Serial**, select the CH340 port (`1a86:7523`).
3. On the CYD touchscreen, enter or generate a mnemonic to reach the "ready" state.
4. Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
`encapsulate`/`decapsulate`, `derive_shared_secret`, `derive`,
`nostr_get_public_key`, `nostr_sign_event`, `nostr_mine_event`,
`nostr_nip04`/`nostr_nip44` encrypt+decrypt, and `encrypt`/`decrypt` (otp).
## API
The CYD firmware speaks the same algorithm-based API as the host n_signer
([`README.md`](../../README.md) §4). Supported verbs:
| Verb | Algorithms |
|---|---|
| `get_public_key` | secp256k1, ed25519, x25519, ml-dsa-65, slh-dsa-128s, ml-kem-768 |
| `sign` / `verify` | secp256k1 (schnorr/ecdsa), ed25519, ml-dsa-65, slh-dsa-128s |
| `encapsulate` / `decapsulate` | ml-kem-768 |
| `derive_shared_secret` | x25519 |
| `derive` | secp256k1 (HMAC-SHA256) |
| `encrypt` / `decrypt` | otp |
| `nostr_get_public_key` | secp256k1 (NIP-06) |
| `nostr_sign_event` | secp256k1 (NIP-06) |
| `nostr_mine_event` | secp256k1 (NIP-06, single-threaded PoW) |
| `nostr_nip04_encrypt` / `decrypt` | secp256k1 (NIP-06) |
| `nostr_nip44_encrypt` / `decrypt` | secp256k1 (NIP-06) |
All requests require an auth envelope (kind 27235). The `key_id` in every
structured result is the first 16 hex characters of the public key, matching
the host. Invalid `(verb, algorithm)` pairs return error `1010`.
### Embedded-specific notes
- **OTP pad:** derived from the mnemonic seed (HKDF-SHA256, 1024 bytes) in
v0.0.2. The offset advances monotonically and is reported in every
`encrypt`/`decrypt` response. SD-card-backed pad is a planned enhancement
(see above).
- **`nostr_mine_event`:** single-threaded, hard 30 s default timeout, shows a
"mining…" screen. Keep difficulty low (≤ 8) on ESP32.
- **SLH-DSA-128s:** keygen and signing take 530 s. The UI shows a "deriving
key…" / "signing…" indicator. ML-DSA-65 is much faster (~100 ms) and is the
recommended PQ signature algorithm for interactive use.
## Crypto backend
- **SHA-256 / SHA-512:** mbedtls (ESP32 hardware accelerated).
- **SHA3 / SHAKE-128 / SHAKE-256:** vendored Keccak-f[1600] (FIPS 202) in
[`resources/pqclean/common/crypto_backend_mbedtls.c`](../../resources/pqclean/common/crypto_backend_mbedtls.c).
No `CONFIG_MBEDTLS_SHA3_C` or SHAKE menuconfig dependency.
- **ed25519 / x25519:** PSA Crypto API (`psa_import_key`, `psa_sign_message`,
`psa_raw_key_agreement`, etc.) — ESP-IDF v5.x mbedtls removed the
`mbedtls_ed25519_*` functions. Requires `CONFIG_MBEDTLS_PSA_CRYPTO_C=y`
(set in [`sdkconfig.defaults`](sdkconfig.defaults)).
- **secp256k1:** the vendored secp256k1 component (schnorr + ECDSA).
- **PQ (ML-DSA-65, SLH-DSA-128s, ML-KEM-768):** PQClean via the
[`components/pqclean/`](components/pqclean/) component.
## Serial-reset hardware note (CH340 auto-reset)
Opening `/dev/ttyUSB0` can reset the ESP32 because the CH340's DTR/RTS lines
are wired into the ESP32 auto-reset circuit. Symptoms: the device returns to
the startup menu when a host app opens the serial port.
**Mitigation:** add a **10 µF capacitor between EN and GND** on the CYD board
(negative leg to GND). Typical working range is 4.722 µF. This also fixes the
`Wrong boot mode detected (0x13)` flashing error. See
[`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) and the
[`firmware/README.md`](../README.md) CYD section for details.
## Reference documentation
- [`docs/`](docs/) — upstream CYD hardware docs (PINS, SETUP, TROUBLESHOOTING, ADDONS, etc.)
- [`plans/cyd_signer_port.md`](../../plans/cyd_signer_port.md) — original port plan (hardware comparison, architecture, UI flow)
- [`plans/cyd_algorithm_api_upgrade.md`](../../plans/cyd_algorithm_api_upgrade.md) — v0.0.2 API upgrade plan
- [`firmware/README.md`](../README.md) — shared firmware README (PQ crypto, mbedtls backend, feather target)
- [`README.md`](../../README.md) §4 — the authoritative n_signer API reference

View File

@@ -0,0 +1,60 @@
# CMakeLists.txt — ESP-IDF component for PQClean post-quantum algorithms.
#
# Compiles the three PQ algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
# from the shared resources/pqclean/ source tree, using the mbedtls
# crypto backend (crypto_backend_mbedtls.c) for SHA-2/SHA3/SHAKE.
#
# The source files are referenced via relative paths back to the shared
# resources/pqclean/ directory so there is a single source of truth.
#
# mbedtls requirements:
# CONFIG_MBEDTLS_SHA3_C=y (for SHA3-256, SHA3-512)
# CONFIG_MBEDTLS_SHAKE_C=y (for SHAKE-128, SHAKE-256)
# Enable these in menuconfig under Component config -> mbedTLS ->
# Hash functions -> SHA-3 and SHAKE.
set(PQCLEAN_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../resources/pqclean")
idf_component_register(
SRCS
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65/sign.c"
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65/poly.c"
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65/ntt.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/sign.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/fors.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/wots.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/hash.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/thash.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/address.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/utils.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/kem.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/indcpa.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/poly.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/ntt.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/cbd.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/reduce.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/symmetric.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/verify.c"
"${PQCLEAN_ROOT}/common/fips202.c"
"${PQCLEAN_ROOT}/common/sha2.c"
"${PQCLEAN_ROOT}/common/crypto_backend_mbedtls.c"
"randombytes_mbedtls.c"
"pq_drbg_firmware.c"
INCLUDE_DIRS
"include"
"${PQCLEAN_ROOT}/common"
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768"
REQUIRES
mbedtls
)
# Suppress warnings from the PQClean code (it uses C99 patterns that
# trigger -Wextra warnings under ESP-IDF's default flags).
target_compile_options(${COMPONENT_LIB} PRIVATE
-Wno-unused-parameter
-Wno-sign-compare
-Wno-unused-variable
-Wno-unused-but-set-variable
)

View File

@@ -0,0 +1,5 @@
/* ml_dsa_65_api.h — firmware wrapper that includes the real PQClean header. */
#ifndef FIRMWARE_ML_DSA_65_API_WRAPPER_H
#define FIRMWARE_ML_DSA_65_API_WRAPPER_H
#include "../../../../resources/pqclean/crypto_sign/ml-dsa-65/api.h"
#endif

View File

@@ -0,0 +1,5 @@
/* ml_kem_768_api.h — firmware wrapper that includes the real PQClean header. */
#ifndef FIRMWARE_ML_KEM_768_API_WRAPPER_H
#define FIRMWARE_ML_KEM_768_API_WRAPPER_H
#include "../../../../resources/pqclean/crypto_kem/ml-kem-768/api.h"
#endif

View File

@@ -0,0 +1,38 @@
/* pqclean.h — Umbrella include for the ESP32 firmware PQClean component.
*
* Exposes the three post-quantum algorithms (ML-DSA-65, SLH-DSA-128s,
* ML-KEM-768) and the deterministic DRBG used for mnemonic-recoverable
* key generation.
*
* On ESP32 the underlying hash/SHAKE primitives are provided by the
* mbedtls backend (crypto_backend_mbedtls.c) instead of OpenSSL.
*/
#ifndef FIRMWARE_PQCLEAN_H
#define FIRMWARE_PQCLEAN_H
#include <stddef.h>
#include <stdint.h>
/* --- ML-DSA-65 (FIPS 204, lattice signatures) --- */
#include "ml_dsa_65_api.h"
/* --- SLH-DSA-128s (FIPS 205, hash-based signatures) --- */
#include "slh_dsa_128s_api.h"
/* --- ML-KEM-768 (FIPS 203, lattice KEM) --- */
#include "ml_kem_768_api.h"
/* --- Deterministic DRBG (replaces randombytes() for keygen) --- */
/* Initializes the DRBG with a 32-byte mnemonic-derived seed. Subsequent
* randombytes() calls will produce a deterministic byte stream. */
void pq_drbg_init(const unsigned char *seed, size_t seed_len);
/* Zeroizes the DRBG state (call after keygen to wipe sensitive material). */
void pq_drbg_zeroize(void);
/* randombytes() — called by the PQClean algorithm code.
* On firmware this is provided by randombytes_mbedtls.c (deterministic DRBG
* for keygen, or mbedtls_ctr_drbg for real randomness during encaps). */
int randombytes(unsigned char *buf, size_t len);
#endif /* FIRMWARE_PQCLEAN_H */

View File

@@ -0,0 +1,5 @@
/* slh_dsa_128s_api.h — firmware wrapper that includes the real PQClean header. */
#ifndef FIRMWARE_SLH_DSA_128S_API_WRAPPER_H
#define FIRMWARE_SLH_DSA_128S_API_WRAPPER_H
#include "../../../../resources/pqclean/crypto_sign/slh-dsa-128s/api.h"
#endif

View File

@@ -0,0 +1,108 @@
/* pq_drbg_firmware.c — Deterministic PRNG for PQ key generation on ESP32.
*
* Same algorithm as the host's src/pq_drbg.c but uses the crypto backend
* abstraction (which resolves to mbedtls on ESP32) for SHAKE-256 instead
* of OpenSSL EVP. This allows deterministic PQ key generation from a
* mnemonic-derived seed: same seed -> same randombytes output sequence.
*
* The PRNG: SHAKE-256(seed || counter) produces a stream of pseudo-random
* bytes. The counter is a 64-bit little-endian integer that increments
* each time we need more output.
*/
#include <string.h>
#include <stdlib.h>
#include "crypto_backend.h"
/* --- DRBG state --- */
static unsigned char g_seed[32];
static int g_seed_len = 0;
static uint64_t g_counter = 0;
static unsigned char g_buffer[168]; /* SHAKE-256 rate = 136, 168 for safety */
static size_t g_buffer_pos = sizeof(g_buffer);
static int g_initialized = 0;
/* --- internal: squeeze more bytes from SHAKE-256 --- */
static void drbg_refill(void) {
unsigned char seed_block[32 + 8]; /* seed + counter (8 bytes LE) */
memcpy(seed_block, g_seed, (size_t)g_seed_len);
seed_block[g_seed_len + 0] = (unsigned char)(g_counter & 0xFF);
seed_block[g_seed_len + 1] = (unsigned char)((g_counter >> 8) & 0xFF);
seed_block[g_seed_len + 2] = (unsigned char)((g_counter >> 16) & 0xFF);
seed_block[g_seed_len + 3] = (unsigned char)((g_counter >> 24) & 0xFF);
seed_block[g_seed_len + 4] = (unsigned char)((g_counter >> 32) & 0xFF);
seed_block[g_seed_len + 5] = (unsigned char)((g_counter >> 40) & 0xFF);
seed_block[g_seed_len + 6] = (unsigned char)((g_counter >> 48) & 0xFF);
seed_block[g_seed_len + 7] = (unsigned char)((g_counter >> 56) & 0xFF);
crypto_backend_shake256(seed_block, (size_t)g_seed_len + 8,
g_buffer, sizeof(g_buffer));
g_counter++;
g_buffer_pos = 0;
}
/* --- public API --- */
void pq_drbg_init(const unsigned char *seed, size_t seed_len) {
if (seed == NULL || seed_len == 0) {
return;
}
memset(g_seed, 0, sizeof(g_seed));
if (seed_len > sizeof(g_seed)) {
seed_len = sizeof(g_seed);
}
memcpy(g_seed, seed, seed_len);
g_seed_len = (int)sizeof(g_seed); /* always use 32-byte seed (zero-padded) */
g_counter = 0;
g_buffer_pos = sizeof(g_buffer);
g_initialized = 1;
}
void pq_drbg_zeroize(void) {
crypto_backend_cleanse(g_seed, sizeof(g_seed));
crypto_backend_cleanse(g_buffer, sizeof(g_buffer));
g_seed_len = 0;
g_counter = 0;
g_buffer_pos = sizeof(g_buffer);
g_initialized = 0;
}
/* Returns 1 if the DRBG has been initialized (keygen mode), 0 otherwise.
* Used by randombytes_mbedtls.c to decide between deterministic DRBG and
* hardware RNG. */
int pq_drbg_is_initialized(void) {
return g_initialized;
}
/* pq_drbg_randombytes is called by randombytes() below. */
int pq_drbg_randombytes(unsigned char *buf, size_t len) {
if (buf == NULL || !g_initialized) {
return -1;
}
while (len > 0) {
size_t avail;
size_t to_copy;
if (g_buffer_pos >= sizeof(g_buffer)) {
drbg_refill();
if (g_buffer_pos >= sizeof(g_buffer)) {
return -1; /* refill failed */
}
}
avail = sizeof(g_buffer) - g_buffer_pos;
to_copy = (len < avail) ? len : avail;
memcpy(buf, g_buffer + g_buffer_pos, to_copy);
g_buffer_pos += to_copy;
buf += to_copy;
len -= to_copy;
}
return 0;
}

View File

@@ -0,0 +1,40 @@
/* randombytes_mbedtls.c — randombytes() implementation for ESP32 firmware.
*
* PQClean's algorithm code calls randombytes() for:
* 1. Key generation (keygen) — must be deterministic from the mnemonic
* seed so keys are recoverable. The DRBG is initialized via
* pq_drbg_init() before keygen, so randombytes() draws from the
* deterministic stream.
* 2. Encapsulation (ML-KEM enc) — needs real cryptographic randomness.
* When the DRBG is NOT initialized, randombytes() falls back to
* esp_fill_random() which uses the ESP32 hardware RNG.
*
* This dual-mode behavior matches the host build (src/pq_drbg.c) where
* the DRBG is initialized for keygen and randombytes() returns -1 if
* called without initialization. On firmware we allow the fallback to
* hardware RNG for encaps, which is the correct behavior.
*/
#include <string.h>
#include "esp_random.h"
/* Defined in pq_drbg_firmware.c */
extern int pq_drbg_randombytes(unsigned char *buf, size_t len);
/* Check if the DRBG is initialized (declared in pq_drbg_firmware.c).
* We use a helper to avoid exposing the static directly. */
extern int pq_drbg_is_initialized(void);
int randombytes(unsigned char *buf, size_t len) {
if (buf == NULL) {
return -1;
}
/* If the deterministic DRBG is active (keygen mode), use it. */
if (pq_drbg_is_initialized()) {
return pq_drbg_randombytes(buf, len);
}
/* Otherwise, use the ESP32 hardware RNG for real randomness (encaps). */
esp_fill_random(buf, len);
return 0;
}

View File

@@ -0,0 +1,86 @@
# Add-Ons
Here is a list of additional hardware add-ons that can add functionality to your CYD
## SD Card Sniffer
If you want to use the pins of the SD card for a different purpose, the easiest way to do that is with an "SD card sniffer", which basically plugs into the SD card slot and breaks out the pins. It's particularly useful for SPI devices.
## Pin-out of the Sniffer board
| Sniffer Board Label | ESP32 Pin | SPI Use |
| ------------------- | --------- | --------- |
| DAT2 | - | - |
| CD | IO5 | CS |
| CMD | IO23 | DI / MOSI |
| GND | GND | - |
| VCC | 3.3V | - |
| CLK | IO18 | SCLK |
| DAT0 | IO19 | DO / MISO |
| DAT1 | - | - |
### Links
- [Micro SD Card Sniffer - Aliexpress\*](https://s.click.aliexpress.com/e/_Ddwcy9h)
## Nintendo Wii Nunchuck
A Nunchuck controller from a Nintendo Wii is a great input device for CYD projects as they are inexpensive and, since they use i2c for communication, they only require 2 GPIO pins to connect them up.
For these two pins you get:
- An analog stick
- 2 Buttons
- An accelerometer
### Hardware Required
#### Nunchuck controllers
Official Nintendo ones are generally better (maybe try second-hand options), but third-party ones also work fine.
- [Amazon.co.uk Search\*](https://amzn.to/3nQrXcE)
- [Amazon.com Search\*](https://amzn.to/3nRJTUd)
- [Aliexpress (Third Party)\*](https://s.click.aliexpress.com/e/_AaQbXh)
#### Nunchuck Adaptors
There are many different options available for these, even the cheap ones from Aliexpress work perfectly.
- [Aliexpress](https://s.click.aliexpress.com/e/_AEEtc3)
- [My Open source one from Oshpark](https://oshpark.com/shared_projects/RcIxSx2D)
- [Adafaruit](https://www.adafruit.com/product/4836)
### Wiring
The easiest way to wire this up is to use the wire that came with the CYD and the **CN1** JST connector (the one closest to the Micro SD card slot)
Connect the wire to your breakout board as follows:
| CYD CN1 | Adapter | Note |
| ------- | ----------- | ------------------ |
| GND | - (AKA GND) | Black wire for me |
| 3.3V | + (AKA 3V) | Red wire for me |
| IO22 | d (AKA SDA) | Blue wire for me |
| IO27 | c (AKA SCL) | Yellow wire for me |
Note: I have found pull-ups resistors are not required on SDA and SCL
### Example
Check out the [NunchuckTest](/Examples/InputTests/NunchuckTest) example for code how to use it.
## Speakers
A speaker can be attached to the display with a 1.25mm JST connector to the connector labeled "SPEAK" (or soldered)
Check out the [HelloRadio](/Examples/Basics/7-HelloRadio) example for the code on how to use it.
Most small 8 Ohm speakers should work. Maybe worth adding a 1.25mm JST connector to it to make it easy to add remove.
### Links
- [Speaker with 1.25mm JST connector (2pcs) - Aliexpress\*](https://s.click.aliexpress.com/e/_DBOJoh7) - Tested, works right out of the package.
- [2pin 1.25mm JST connectors - Aliexpress\*](https://s.click.aliexpress.com/e/_DlbPkWH) - Not purchased by me, but should work
\* = Affiliate Link - It doesn't cost you any extra but I receive a small portion of the sale.

View File

@@ -0,0 +1,14 @@
# Media and Mentions
This page can document any times the CYD project was mentioned somewhere!
## Videos
- [Brian Lough (hey, thats me!) - Cheap and Easy to Use ESP32 Screen!](https://www.youtube.com/watch?v=0AVyvwv0agk)
- [Talking Sasquach - Don't be Fooled!! This Cheap Yellow Display Can Do a LOT!!](https://youtu.be/PsqMCoCTgTg?feature=shared)
- [Teaching Tech - Cheap and easy Klipper touch interface with CYD Klipper](https://youtu.be/R3o0MGYW1ZU?feature=shared)
## Articles
- [Hackaday.com - “Cheap Yellow Display” Builds Community Through Hardware](https://hackaday.com/2023/10/28/cheap-yellow-display-builds-community-through-hardware/)
- [Hackster.io - Brian Lough Looks to Build a Community Around the Espressif ESP32-Powered "Cheap Yellow Display"](https://www.hackster.io/news/brian-lough-looks-to-build-a-community-around-the-espressif-esp32-powered-cheap-yellow-display-66d23972910d)

View File

@@ -0,0 +1,140 @@
# Pins
This page talks about the pins on the CYD.
## Connector types
The connectors are often called "1.25mm JST" but the correct name is "Molex PicoBlade".
Chinese clones are sometimes called "mx1.25".
|Connector|Type |Note |
|--- |--- |---- |
|[**P1**](#p1) |4P 1.25mm Molex PicoBlade|Serial |
|[**P3**](#p3) |4P 1.25mm Molex PicoBlade|GPIO |
|[**P4**](#p4) |2P 1.25mm Molex PicoBlade|Speaker |
|[**CN1**](#cn1)|4P 1.25mm Molex PicoBlade|GPIO (I2C) |
## What pins are available on the CYD?
There are 3 easily accessible GPIO pins
|Pin|Location|Note|
|---|---|----|
|IO35|**P3** Molex PicoBlade connector|Input only pin, no internal pull-ups available|
|IO22|**P3** and **CN1** Molex PicoBlade connector||
|IO27|**CN1** Molex PicoBlade connector||
If you need more than that, you need to start taking them from something else. An SD Card sniffer like mentioned in the [Add-ons](/ADDONS.md) is probably the next easiest.
After that you're probably de-soldering something!
## Broken Out Pins
There are three 4P 1.25mm Molex PicoBlade connectors on the board.
### P3
|Pin|Use|Note|
|---|---|----|
|GND|||
|IO35||Input only pin, no internal pull-ups available|
|IO22||Also on the **CN1** connector|
|IO21||Used for the TFT Backlight, so not really usable|
### CN1
This is a great candidate for I2C devices
|Pin|Use|Note|
|---|---|----|
|GND|||
|IO22||Also on **P3** connector|
|IO27|||
|3.3V|||
### P1
|Pin|Use|Note|
|---|---|----|
|VIN|||
|IO1(?)|TX|Maybe possible to use as a GPIO?|
|IO3(?)|RX|Maybe possible to use as a GPIO?|
|GND|||
## Buttons
The CYD has two buttons, reset and boot.
|Pin|Use|Note|
|---|---|----|
|IO0|BOOT|Can be used as an input in sketches|
## Speaker
The speaker connector is a 2P 1.25mm Molex PicoBlade connector that is connected to the amplifier, so not usable as GPIO at the speaker connector
|Pin|Use|Note|
|---|---|----|
|IO26|Connected to amp|`i2s_set_dac_mode(I2S_DAC_CHANNEL_LEFT_EN);`|
## RGB LED
If your project requires additional pins to what is available elsewhere, this might be a good candidate to sacrifice.
Note: LEDs are "active low", meaning HIGH == off, LOW == on
|Pin|Use|Note|
|---|---|----|
|IO4|Red LED||
|IO16|Green LED||
|IO17|Blue LED||
## SD Card
Uses the VSPI
Pin names are predefined in SPI.h
|Pin|Use|Note|
|---|---|----|
|IO5|SS||
|IO18|SCK||
|IO19|MISO||
|IO23|MOSI||
## Touch Screen
|Pin|Use|Note|
|---|---|----|
|IO25|XPT2046_CLK||
|IO32|XPT2046_MOSI||
|IO33|XPT2046_CS||
|IO36|XPT2046_IRQ||
|IO39|XPT2046_MISO||
## LDR (Light Sensor)
|Pin|Use|Note|
|---|---|----|
|IO34|||
## Display
Uses the HSPI
|Pin|Use|Note|
|---|---|----|
|IO2|TFT_RS|AKA: TFT_DC|
|IO12|TFT_SDO|AKA: TFT_MISO|
|IO13|TFT_SDI|AKA: TFT_MOSI|
|IO14|TFT_SCK||
|IO15|TFT_CS||
|IO21|TFT_BL|Also on P3 connector, for some reason|
## Test points
|Pad|Use|Note|
|---|---|----|
|S1|GND|near USB-SERIAL|
|S2|3.3v|for ESP32|
|S3|5v|near USB-SERIAL|
|S4|GND|for ESP32|
|S5|3.3v|for TFT|
|JP0 (pad nearest USB socket)|5v|TFT LDO|
|JP0|3.3v|TFT LDO|
|JP3 (pad nearest USB socket)|5v|ESP32 LDO|
|JP3|3.3v|ESP32 LDO|

View File

@@ -0,0 +1,60 @@
# Projects
Because the CYD is a common platform, it makes it really useful for sharing projects. This page will be a list of projects that are available on the CYD.
## Disclaimer!
Projects appearing on here is not necessarily a seal of approval from me, I will not be test each project that gets added, so please install these projects at your own risk!
## Projects
| Name | Description | Author | Additional Hardware? | Project Page | WebFlash |
| ---------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| Spotify DIY Thing | A device for displaying your currently playing Spotify track | [Brian Lough](https://github.com/witnessmenow) | | [Github](https://github.com/witnessmenow/Spotify-Diy-Thing) | [WebFlash](https://witnessmenow.github.io/Spotify-Diy-Thing/) |
| F1 Notifier | Displays and notifies you of the F1 session times(in your local timezone) | [Brian Lough](https://github.com/witnessmenow) | | [Github](https://github.com/witnessmenow/F1-Arduino-Notifications) | [WebFlash](https://witnessmenow.github.io/F1-Arduino-Notifications/) |
| Tetris with Nunchuck | A version of Tetris using a Nintendo wii Nunchuck | [Brian Lough](https://github.com/witnessmenow) | A nunchuck and an adaptor for connecting it | [Code](/Examples/Projects/TetrisWithNunchuck) | |
| Galagino | An emulator for some classic arcade games (Galaga, Donkey Kong, Pacman, Digdug and Frogger) | [Till Harbaum](https://github.com/harbaum) | A nunchuck and an adaptor for connecting it. Speaker if you want sound | [Github](https://github.com/harbaum/galagino) | |
| ESP32-fluid-simulation | A small fluid simulation with touch input | [Kenny Peng](https://github.com/colonelwatch) | | [Github](https://github.com/colonelwatch/ESP32-fluid-simulation) | |
| ESP32-TV | Play Video Files on the ESP32 | [atomic14](https://github.com/atomic14) | Speaker if you want sound and possibly an IR receiver | [Github](https://github.com/atomic14/esp32-tv) | |
| xtouch | "The xtouch screen is a revolutionary addition to your BambuLab Printer" | [xperiments-in](https://github.com/xperiments-in) (\#) | | [Github](https://github.com/xperiments-in/xtouch) | [Webflash](https://github.com/xperiments-in/xtouch#online-web-installer) |
| CYD-Klipper | An implementation of a wireless Klipper status display on an ESP32 + screen | [Sims](https://github.com/suchmememanyskill) | | [Github](https://github.com/suchmememanyskill/CYD-Klipper) | [Webflash](https://suchmememanyskill.github.io/CYD-Klipper/) |
| DRO (for lathe / mill) | A DRO (digital readout) for your lathe or mill | [Alanesq](https://github.com/alanesq) | It uses cheap digital caliper, requires a very basic interface | [Github](https://github.com/alanesq/DRO) | |
| ESP32Marauder-CYD | A suite of WiFi/Bluetooth offensive and defensive tools for the ESP32 | [Fr4nkFletcher](https://github.com/Fr4nkFletcher) | GPS if you want BT/Wifi wardriving options | [Github](https://github.com/Fr4nkFletcher/ESP32-Marauder-Cheap-Yellow-Display) | [Webflash](https://fr4nkfletcher.github.io/Adafruit_WebSerial_ESPTool/) |
| NerdMiner_v2 | A project that lets you try to solve a bitcoin block with a small piece of hardware. | [Fr4nkFletcher](https://github.com/Fr4nkFletcher) | | [Github](https://github.com/Fr4nkFletcher/NerdMiner_v2-Cheap-Yellow-Display) | [Webflash](https://fr4nkfletcher.github.io/NerdMiner_v2-Cheap-Yellow-Display/flash.html) |
| Tasmota | Tasmota (with UI) on the CYD | ? (\#) | | [Templates](https://templates.blakadder.com/sunton_ESP32-2432S028.html) | [Webflash](https://tasmota.github.io/install/) |
| BAM | A game engine featuring smooth scrolling tile map, sprites in layers with pixel precision on-screen collision detection, intuitive definition of game objects and logic, decent performance, ~30 frames per second on the device | [calint](https://github.com/calint) | | [Github](https://github.com/calint/bam) | |
|London Underground Arrivals| A highly configurable application that replicates the train arrivals boards found in [TFL](https://tfl.gov.uk/) stations. All variable data is encoded in a json file that may be updated at any time without the need to recompile the application e.g. the station to be displayed or the time to refresh data from TFL. The source code already supports 2 variants of CYD and, I hope, contains clear instructions how to handle any other variant.| [David Henry](https://github.com/mgaman) | | [Github](https://github.com/mgaman/TFL-tube-arrivals-board-ESP32-TFT-Arduino) |
|GitHub-Stats| This Arduino project fetches and displays GitHub repository statistics such as star count, open issues, forks and notifactions on a CYD or via serial communication. Ideal for developers to monitor project metrics in real time.| [ATOMNFT](https://github.com/ATOMNFT) | | [Github](https://github.com/ATOMNFT/ESP32-CYD-Projects/tree/main/GitHub-Stats) | |
| Midbar-Firebase-Edition | An advanced password vault that stores the encrypted data in the cloud while keeping the cryptographic keys on the edge! | [Northstrix](https://github.com/Northstrix) | PS/2 keyboard and an optional STM32F103C8T6 (if you want it to emulate the USB keyboard) | [SourceForge](https://sourceforge.net/projects/midbar-firebase-edition/) [Github](https://github.com/Northstrix/Midbar-Firebase-Edition)
| Electronic-Shelf-Label-Management-System | A simple device for displaying relevant product information. It gets the encrypted images via UDP. | [Northstrix](https://github.com/Northstrix)| | [SourceForge](https://sourceforge.net/projects/esl-management-system/) [Github](https://github.com/Northstrix/Electronic-Shelf-Label-Management-System)
| ESP32-Tetris-With-Nintendo-64-Controller | Tetris for ESP32 with Nintendo 64 controller support | [Northstrix](https://github.com/Northstrix) | Nintendo 64 Controller and Arduino Nano | [SourceForge](https://sourceforge.net/projects/esp32-tetris/) [Github](https://github.com/Northstrix/ESP32-Tetris-With-Nintendo-64-Controller)
| Midbar ESP32 CYD | A version of Midbar data vault tweaked specifically for the ESP32 Cheap Yellow Display. | [Northstrix](https://github.com/Northstrix) | PS/2 Keyboard | [SourceForge](https://sourceforge.net/projects/midbar-esp32-cyd/) [Github](https://github.com/Northstrix/Midbar-ESP32-CYD)
| ESP32-Cheap-Yellow-Display-Electronic-Shelf-Label-with-Google-Firebase | An ESP32 CYD-based Electronic Shelf Label that makes use of the Google Firebase and AES-256. | [Northstrix](https://github.com/Northstrix) | | [SourceForge](https://sourceforge.net/projects/esp32-cyd-esl-with-firebase/) [Github](https://github.com/Northstrix/ESP32-Cheap-Yellow-Display-Electronic-Shelf-Label-with-Google-Firebase) | [WebFlash](https://northstrix.github.io/ESP32-Cheap-Yellow-Display-Electronic-Shelf-Label-with-Google-Firebase/flash.html) </br>!!! Format Flash area designated for SPIFFS with [ESP32 Filesystem Uploader](https://github.com/me-no-dev/arduino-esp32fs-plugin/releases/) after using the WebFlash
| Addressable RGB LED Strip Controller (The Lantern Project) | DIY Addressable RGB LED Strip Controller that utilizes the ESP32, ESP8266, and the WS2812 LED Strip. | [Northstrix](https://github.com/Northstrix) | Nintendo Wii Nunchuk, WiiChuck Nunchuck Adapter (PCB Board), ESP8266, 580 Ohm resistor, WS2812 LED Strip | [SourceForge](https://sourceforge.net/projects/the-lantern-project/) [Github](https://github.com/Northstrix/Lantern)
| Midbar ESP32 CYD Firebase Edition | A version of Midbar data vault adapted for the ESP32 CYD and WebFlash. It keeps the cryptographic keys in the ESP32 RAM and stores the ciphertexts (encrypted data) in the Google Firebase. | [Northstrix](https://github.com/Northstrix) (Adapted for CYD2USB by [Rovel](https://github.com/Rovel))| PS2 Keyboard, PS2 Port *optional | [SourceForge](https://sourceforge.net/projects/midbar-esp32-cyd-firebase/) [Github (CYD)](https://github.com/Northstrix/Midbar-ESP32-CYD-Firebase-Edition) [Github (CYD2USB)](https://github.com/Northstrix/Midbar-ESP32-CYD2USB-Firebase-Edition) | [WebFlash (CYD)](https://northstrix.github.io/Midbar-ESP32-CYD-Firebase-Edition/flash) [WebFlash (CYD2USB)](https://northstrix.github.io/Midbar-ESP32-CYD2USB-Firebase-Edition/flash)
| cydOS (WIP) | cydOS is a GUI app that is able to manage various aspects of the CYD, like SD browsing and file mangement, on board flashing of .bin files for rapid firmware switching, on board device settings(WIP) | [orlandobianco](https://github.com/orlandobianco) | | [Github]((https://github.com/orlandobianco/cydOS)) | |
| ESP32 MFA Authenticator | Turn the CYD into a MFA Authenticator | [AllanOricil](https://github.com/AllanOricil) | | [Github](https://github.com/AllanOricil/esp32-mfa-authenticator) | [Webflash](https://allanoricil.github.io/esp32-mfa-authenticator/)
| cydWeatherStation | cyd Weather station | [gustheseventh](https://github.com/gustheseventh) (#) | | [Github](https://github.com/gustheseventh/cyd-Weather-Station) | |
| PhilRadio | CYD Wifi Radio project. Re-using an old radio as hardware. Exposing a webserver on local network to configure the radio stations. Persistent storage. | [mogrikid](https://github.com/mogrikid) | Required: A speaker. Recommended: Speaker, potentiometer, 10kohm resistor, female usb port, switch | [Github](https://github.com/mogrikid/PhilRadio)
| cydWeeWX | Simple CYD Weather Display for the open source [WeeWX](https://www.weewx.com/) weather station server. | [hcomet](https://hcomet.github.io/) | | [Github](https://github.com/hcomet/cydWeeWX)| [Webflash](https://hcomet.github.io/cydWeeWX/cydWeeWXFlash.html) |
| CYD Stream Deck | A customizable touch-based Bluetooth HID controller using CYD. | [gahingwoo](https://github.com/gahingwoo) | | [GitHub](https://github.com/gahingwoo/cyd-stream-deck) | [Webflash](https://gahingwoo.github.io/cyd-stream-deck/webflash/index.html) |
| CYD DHT22 Weather Clock | A weather and time display using CYD. | [gahingwoo](https://github.com/gahingwoo) | DHT22 sensor | [GitHub](https://github.com/gahingwoo/cyd-dht22-weather-clock) | |
| ESP CYD MCP | Model Context Protocol (MCP) server implementation for the ESP32 CYD | [OfryL](https://github.com/OfryL) | | [Github](https://github.com/OfryL/esp-cyd-mcp) | |
| Aura | Smart weather forecast device (OpenMeteo) | [Surrey-Homeware](https://github.com/Surrey-Homeware/) (\#) | [3D printed case](https://makerworld.com/en/models/1382304-aura-smart-weather-forecast-display#profileId-1430951) | [Github](https://github.com/Surrey-Homeware/Aura) | [Webflash](https://surrey-homeware.github.io/aura-installer/) |
| SmartEnergyMeter | An ESPHome display for Energy in the house (solar, battery, etc) | [anthony-spruyt](https://github.com/anthony-spruyt) (#) | | [Github](https://github.com/anthony-spruyt/ESPHOME-ESP32_CYD_V2-SmartEnergyMeter) | |
| Navi Phone | Relica of the Mobile Phones used int the anime Serial Experments Lain | [Aquafrostbyte](https://github.com/AquaFrostByte) (#) | A Sd card is required, Speaker and Wifi is optional | [Github](https://github.com/AquaFrostByte/Navi-Phone) | |
| OASMan | Open-source Air Suspension Management - Worlds first DIY Digital Air suspension controller for your car! | [gopro_2027](https://github.com/gopro2027/) | Ideally you would build the manifold and install it in your car, but if you just want to test the connection you can use an original esp32 dev board and flash the manifold code through platformio. We also have a [3d printable case](https://github.com/gopro2027/ArduinoAirSuspensionController/blob/main/3d%20Prints/other/3.2%20inch%20screen%20case/3.2%20inch%20CYD%20screen%20container%20v19%20-%20gopro_2027's%20design.stl) | [Github](https://github.com/gopro2027/ArduinoAirSuspensionController) | [Webflash](https://oasman.dev/oasman/flash/) |
| Sonos Remote Control | Use your Sonos Speakers as Internet Radio with Station Buttons | Florian Lenz | https://github.com/SpringTideSystems | [GitHub](https://github.com/SpringTideSystems/CYD_Sonos-RemoteControl) | |
(\#) = Project not added by original author
## Adding a project
If you have a project that you would like to add, please feel free to add it to the list!
New projects should be added to bottom of the list.
Some rules:
- Project must be open source
- Project must be functional - It's ok for it to not be finished, but it should do what it says!

View File

@@ -0,0 +1,114 @@
# ESP32-Cheap-Yellow-Display
There is an ESP32 with a built in 320 x 240 2.8" LCD display with a touch screen called the "ESP32-2432S028R", since this doesn't roll of the tongue, I propose it should be renamed the "Cheap Yellow Display" or CYD for short. This display is only about $15 delivered so I think it's really good value.
![image](https://github.com/witnessmenow/ESP32-Cheap-Yellow-Display/assets/1562562/76c3d481-2523-4b6f-881c-2e29f9368cd0)
## Features
The CYD has the following features:
- ESP32 (With Wifi and Bluetooth)
- 320 x 240 LCD Display (2.8")
- Touch Screen (Resistive)
- USB for powering and programming
- SD Card Slot, LED and some additional pins broken out
## Who is it good for?
I think it's useful for the following types of people:
- **People just getting started with working hardware** - as everything is already connected, there is no soldering or additional components required
- **People who are familiar with working with hardware, but are lazy** - (like me) Sometimes you just want to build a project without having to assemble any hardware
- **People who aren't really looking to learn anything, but just want to build some cool things** - More about this later.
## What is the purpose of this page?
So this is pretty nice hardware and a cheap price, but the software instructions/support around it is pretty poor. Just a single link to a zip file on a random website.
A couple of years ago I released the [ESP32 Trinity](https://github.com/witnessmenow/ESP32-Trinity), which is an open source ESP32 board for controlling Matrix panels. I think the main benefit people get out of the work I did on the Trinity is not the hardware, but the documentation, example code and ready to go projects.
I'm no longer creating hardware products, but I think it would be interesting if we could create the same kind of community around this display, where people can share examples and projects made for this display.
## How do I know if a display is a CYD?
![CYD decision tree](http://www.plantuml.com/plantuml/png/RP0nJyCm48Nt_8gZNIb3fge3LD2b2q92235UamDRE7PaNuhyxxda7DGgJBs-zxtSE-yJO-IXSzKD6-e8UeVMLyQs1DJrdA6br4JRims-4fW9LiS4bY6JS-47qBTWC052QvEayyCAvA-wS-8vi01F7mS8SVevOxJeUK9zu55QzzP_Nw-exxPmz8tHJzRRsJq4cdo3Pu98oIQsCd4O6WDIbyXF4LN-JNMsYG7UNXyXUAUTLHDfqVeMJWClUfSPrY_OOyPtO_ivUPcfnoMV3iyXJh4cj_MGJd8lEleQkvQKi9TYUT_DvbukXnraIfTQURMT39Nu8kcrXInIwQYO-gCyNwgm6al-ZneTNIRqjLokqS2UV3jqxXS0)
## Where to buy?
Buy from wherever works out cheapest for you:
- [Aliexpress\*](https://s.click.aliexpress.com/e/_DkSpIjB)
- [Aliexpress\*](https://s.click.aliexpress.com/e/_DkcmuCh)
- [Aliexpress](https://www.aliexpress.com/item/1005004502250619.html)
- [Makerfabs](https://www.makerfabs.com/sunton-esp32-2-8-inch-tft-with-touch.html) - Seems to come with a 16GB SD card. Makerfabs also stock my [ESP32 Trinity](https://github.com/witnessmenow/ESP32-Trinity) (NOTE there will be import due in the EU from makerfabs)
\* = Affiliate Link
## Getting Started With Your CYD
For details on how to get started with your CYD, please check out the [Setup and Configuration](/SETUP.md) page
## Code Examples
### The Basics
A collection of examples demonstrating how to use the different features of the CYD, this is a good place to get started. [Check them out here.](/Examples/Basics)
### Alternative Display Libraries
The basics examples are based on the TFT_eSPI display library, but the CYD also works with other display libraries too. Here is some example code if you prefer to use an alternative Arduino library. [Check them out here.](/Examples/AlternativeLibraries)
### ESPHome
Some examples for using the CYD in ESPHome. [Check them out here.](/Examples/ESPHome)
## Additional Info and Links
### Discord
Join the CYD discussion on [my Discord channel](https://discord.gg/nnezpvq)
### 3DPrinting
Some examples of 3D printed stands and cases. [Check them out here.](/3dModels)
### Pin Information
[This page](/PINS.md) contains information about what pins are used where, and what ones are free to use.
### Add-ons
[This page](/ADDONS.md) contains information about additional hardware add-ons that can add functionality to your CYD
### Troubleshooting
[This page](/TROUBLESHOOTING.md) contains information about how to troubleshoot your CYD device
### Hardware Mods
[This page](/Mods/README.md) contains information about some hardware mods that can be performed on the CYD to improve or change some of its functionality
### Media and Video Mentions
[This page](/MEDIA.md) lists any times the CYD project was mentioned somewhere!
## License Info
This project is licensed as MIT as per the [license file](/LICENSE)
The one exception to this is the [OriginalDocumentation](/OriginalDocumentation/) folder, that I do not have the right to license
## Other Languages
Some members of the community have ported some of this information to other languages!
Please note: I can't gaurantee the accuracy of the translation, how up to date they are or the content on them in general.
- [French / Française](https://github.com/usini/ESP32-Cheap-Yellow-Display-Documentation-FR)
- [German / Deutsch](https://github.com/paelzer/ESP32-Cheap-Yellow-Display-Documentation-DE)
If you would like to contribure a translation, please name the repo with the language name or code in the repo name and you can link it here.
## Help Support what I do!
[If you enjoy my work, please consider becoming a Github sponsor!](https://github.com/sponsors/witnessmenow/)

View File

@@ -0,0 +1,39 @@
# Setup and Configuration options
This page will cover the basics of setting up the CYD
## Hardware Setup
There really is nothing to setup here, just connect the CYD to a computer using a micro USB cable (it even comes with one)
## Software Setup
The driver needs to be setup for uploading to the CYD, including webflashing projects.
### Driver
The CYD uses the CH340 USB to UART chip. If you do not have a driver already installed for this chip you may need to install one. Check out [Sparkfun's guide for installation instruction](https://learn.sparkfun.com/tutorials/how-to-install-ch340-drivers/all)
## Coding Setup
Follow these instructions if you want to write new code for the CYD
### Board definition
You will need to have the ESP32 setup for your Arduino IDE, [instructions can be found here](https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html).
You can then select basically any ESP32 board in the boards menu. (I usually use "ESP32 Dev Module", but it doesn't really matter)
If you see errors uploading a sketch, try setting board upload speed to `115200`
### Library Configuration
The CYD can work with a selection of different libraries, but the main one this repo will focus on is [TFT_eSPI](https://github.com/Bodmer/TFT_eSPI) as it is a fairly popular library for working with these types of displays and there are lots of examples.
This can be installed from the library manager by searching for "TFT_eSPI".
> Note: After install of the library, copy the file [User_Setup.h](https://github.com/witnessmenow/ESP32-Cheap-Yellow-Display/blob/main/DisplayConfig/User_Setup.h) to the `libraries\TFT_eSPI` Arduino folder. This sets up the library for use with this display.
### Examples
I have provided examples for you to try out to get some ideas or inspiration. [Check them out here.](/Examples/)

View File

@@ -0,0 +1,45 @@
# First, Make sure it's a CYD!
If you are having any issues, this is the first thing I would check!
The examples and information contained on this repo are for the **ESP32-2432S028** display only. The model number is written on the back of the display in gold writting, beside the speaker connector.
# Display is not turning on
If you are having issues getting the display working, the first thing I would try is [webflashing an existing project](/PROJECTS.md#projects-1). These will be known working code, and if it works correctly, it points to a software issue, not a hardware one.
## If the webflash project displays something on the screen
- Make sure you have put the [User_Setup.h](DisplayConfig/User_Setup.h) file in the correct location [as described here](/SETUP.md#library-configuration)
- Pin 21 is the backlight pin, make sure you are not using it for something else in your sketch.
## The webflash project doesn't display on screen
- Make sure you are not connecting Pin 21 to anything. It is broken out on the connector labeled `P3`
- Try a different USB supply and or cable
- If nothing else worked, your CYD could be faulty. Contact the seller.
# Display, Touch and SD card are not working at the same time
The ESP32 offers two usable hardware SPI buses, but on the CYD each of display, touch and SD card use a different bus. To use all three devices at the same time, for one of them the SPI has to be "simulated" in software. Usually this is done for the touch device, since it doesn't require a high bandwidth. Therefor use a software SPI implementation like [XPT2046_Bitbang_Slim](https://github.com/TheNitek/XPT2046_Bitbang_Arduino_Library) and follow the [button example](https://github.com/witnessmenow/ESP32-Cheap-Yellow-Display/tree/main/Examples/Basics/8-Buttons)
# Display is flickering
- Try a different USB supply and or cable
- Go through the [Display is not turning on](#display-is-not-turning-on) steps
- If nothing else worked, your CYD could be faulty. Contact the seller.
# Cannot upload
- On Ubuntu and flavors disable or uninstall service `brltty` and make sure user is in group `dialout`
# Automatic flash with esptool failed: Wrong boot mode detected (0x13)
This is the well-known problem of flashing ESP32 through USB-UART converter, when DTR and RTS signals are used to switch the chip to the bootloader mode (with additional 2xNPN transistor digital protection logic). On some PC, OS, driver version it works, on another it doesn't:
```
A fatal error occurred: Failed to connect to ESP32: Wrong boot mode detected (0x13)! The chip needs to be in download mode. For troubleshooting steps visit: https://docs.espressif.com/projects/esptool/en/latest/troubleshooting.html
```
The solution is to replace a capacitor between EN (RST) and GND from 0.1uF, installed on CYD, to something in range 1uF and 10uF.
**NOTE:** In schematic, this is C4, but at least on Type-C version of CYD it is C5 actually.

View File

@@ -0,0 +1,43 @@
## What is a Cheap Yellow Display (CYD)?
A CYD is a ESP32-2432S028, an ESP32 development board with a 2.8" display with a resistive touch screen,
There are other boards with different sizes displays that look similar but **are not** a CYD. This isn't to try exclude anyone, but there so many different displays and types that it would be incredibly difficult and very confusing to support all of them.
You can verify you have the correct board by checking the number on the back of the display.
![image](https://github.com/witnessmenow/ESP32-Cheap-Yellow-Display/assets/1562562/d23bf84f-f34b-4814-b609-87c359d6334e)
## My CYD has two USB ports
The original CYD only has a micro USB port, but there is a device that is also labelled a _ESP32-2432S028_ that has two USB ports, one micro USB and one USB-C.
Having an additional USB port would be a minor problem if that was the only difference, but unfortunately the display also works differently, the colours are inverted on the display.
It can be fixed in a couple of ways:
- Use platformio - The examples on the Github have all been updated so they can be used with platformio, and you can simply select CYD or CYD2USB and it will just work
- Use the [CYD2USB specific User_setup.h](/DisplayConfig/CYD2USB/) that is on the repo, you can now use all the examples like normal
- Invert the display at the code level using the `tft.invertDisplay(1);` method
### The USB-C port doesn't work
The USB-C port has a flaw in it, it doesnt have the resistors on the CC lines. This means it will not work with USB-C to USB-C cables. If your computer only has USB-C ports, you can use it through a USB-C to USB-A adaptor.
### The Display doesn't look as good
There seems to be a gamma issue with the CYD2USB (I don't even know what gamma is)
Adding this to the code seems to help
```
tft.writecommand(ILI9341_GAMMASET); //Gamma curve selected
tft.writedata(2);
delay(120);
tft.writecommand(ILI9341_GAMMASET); //Gamma curve selected
tft.writedata(1);
```

View File

@@ -10,6 +10,7 @@ idf_component_register(
"bech32.c" "bech32.c"
"mnemonic.c" "mnemonic.c"
"key_derivation.c" "key_derivation.c"
"pq_crypto_firmware.c"
"secure_mem.c" "secure_mem.c"
"../../../resources/nostr_core_lib/nostr_core/nip004.c" "../../../resources/nostr_core_lib/nostr_core/nip004.c"
"../../../resources/nostr_core_lib/nostr_core/nip044.c" "../../../resources/nostr_core_lib/nostr_core/nip044.c"
@@ -31,6 +32,7 @@ idf_component_register(
esp_driver_uart esp_driver_uart
mbedtls mbedtls
secp256k1 secp256k1
pqclean
json) json)
target_compile_definitions(${COMPONENT_LIB} PUBLIC LV_CONF_INCLUDE_SIMPLE=1) target_compile_definitions(${COMPONENT_LIB} PUBLIC LV_CONF_INCLUDE_SIMPLE=1)

View File

@@ -1,17 +1,25 @@
#include "key_derivation.h" #include "key_derivation.h"
#include "pq_crypto_firmware.h"
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include "esp_random.h" #include "esp_random.h"
#include "esp_log.h"
#include "mbedtls/md.h" #include "mbedtls/md.h"
#include "mbedtls/ecp.h"
#include "mbedtls/pk.h"
#include "psa/crypto.h"
#include "secp256k1.h" #include "secp256k1.h"
#include "secp256k1_extrakeys.h" #include "secp256k1_extrakeys.h"
#include "secp256k1_schnorrsig.h" #include "secp256k1_schnorrsig.h"
static const char *KD_TAG = "key_derivation";
#define BIP32_HARDENED_FLAG 0x80000000u #define BIP32_HARDENED_FLAG 0x80000000u
typedef struct { typedef struct {
@@ -253,3 +261,351 @@ int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t s
secp256k1_context_destroy(ctx); secp256k1_context_destroy(ctx);
return 0; return 0;
} }
/* ====================================================================
* Phase 7: ed25519, x25519, and post-quantum key derivation
* ==================================================================== */
/* SLIP-0010 all-hardened derivation for ed25519/x25519.
*
* SLIP-0010 uses HMAC-SHA512 with a "ed25519 seed" or curve-specific key
* for the master key, and all derivation steps are hardened (the parent
* private key is prepended to the index data).
*
* For ed25519/x25519, the derived 512-bit HMAC output is split:
* - first 32 bytes = private key (the scalar)
* - last 32 bytes = chain code
*
* The private key IS the ed25519/x25519 secret — no tweak-add is needed
* (unlike secp256k1 BIP-32 where the child priv = parent_priv + HMAC).
*/
/* SLIP-0010 master key from seed: HMAC-SHA512(key="ed25519 seed", data=seed) */
static int slip10_master_from_seed(const uint8_t seed[64],
uint8_t priv[32], uint8_t chain[32]) {
static const uint8_t kEd25519Seed[] = "ed25519 seed";
uint8_t i64[64] = {0};
if (hmac_sha512(kEd25519Seed, sizeof(kEd25519Seed) - 1,
seed, 64, i64) != 0) {
return -1;
}
memcpy(priv, i64, 32);
memcpy(chain, i64 + 32, 32);
memset(i64, 0, sizeof(i64));
return 0;
}
/* SLIP-0010 hardened child derivation:
* HMAC-SHA512(key=chain, data=0x00 || priv || index_be32) */
static int slip10_ckd_priv(const uint8_t parent_priv[32],
const uint8_t parent_chain[32],
uint32_t index,
uint8_t child_priv[32],
uint8_t child_chain[32]) {
uint8_t data[37];
uint8_t i64[64] = {0};
/* Hardened derivation: 0x00 || priv || index (big-endian) */
data[0] = 0x00;
memcpy(data + 1, parent_priv, 32);
data[33] = (uint8_t)((index >> 24) & 0xFF);
data[34] = (uint8_t)((index >> 16) & 0xFF);
data[35] = (uint8_t)((index >> 8) & 0xFF);
data[36] = (uint8_t)(index & 0xFF);
if (hmac_sha512(parent_chain, 32, data, sizeof(data), i64) != 0) {
memset(data, 0, sizeof(data));
return -1;
}
memcpy(child_priv, i64, 32);
memcpy(child_chain, i64 + 32, 32);
memset(data, 0, sizeof(data));
memset(i64, 0, sizeof(i64));
return 0;
}
/* Derive a 32-byte seed via SLIP-0010 all-hardened path.
* path[] is an array of hardened indices (the caller sets the hardened flag).
* Returns the final 32-byte private material in `out_seed`. */
static int slip10_derive_seed(const uint8_t seed[64],
const uint32_t *path, size_t path_len,
uint8_t out_seed[32]) {
uint8_t priv[32], chain[32], next_priv[32], next_chain[32];
size_t i;
if (slip10_master_from_seed(seed, priv, chain) != 0) {
return -1;
}
for (i = 0; i < path_len; i++) {
if (slip10_ckd_priv(priv, chain, path[i],
next_priv, next_chain) != 0) {
memset(priv, 0, sizeof(priv));
memset(chain, 0, sizeof(chain));
return -1;
}
memcpy(priv, next_priv, 32);
memcpy(chain, next_chain, 32);
}
memcpy(out_seed, priv, 32);
memset(priv, 0, sizeof(priv));
memset(chain, 0, sizeof(chain));
memset(next_priv, 0, sizeof(next_priv));
memset(next_chain, 0, sizeof(next_chain));
return 0;
}
/* --- ed25519 --- */
int derive_ed25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]) {
/* m/44'/102001'/<index>'/0'/0' — all hardened (SLIP-0010) */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102001u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t derived_seed[32];
if (seed == NULL || privkey == NULL || pubkey == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, derived_seed) != 0) {
return -1;
}
/* The SLIP-0010 derived 32 bytes IS the ed25519 private key. */
memcpy(privkey, derived_seed, 32);
/* Derive the ed25519 public key via PSA crypto (IDF v5.x mbedtls has no
* mbedtls_ed25519_make_public). Import the private key, export the pub. */
psa_status_t status;
psa_key_id_t key_id = 0;
psa_key_attributes_t attrs = PSA_KEY_ATTRIBUTES_INIT;
size_t pub_len = 0;
psa_crypto_init();
psa_set_key_type(&attrs, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_TWISTED_EDWARDS));
psa_set_key_bits(&attrs, 255);
psa_set_key_usage_flags(&attrs, PSA_KEY_USAGE_EXPORT | PSA_KEY_USAGE_SIGN_MESSAGE);
psa_set_key_algorithm(&attrs, PSA_ALG_PURE_EDDSA);
status = psa_import_key(&attrs, privkey, 32, &key_id);
if (status != PSA_SUCCESS) {
ESP_LOGE(KD_TAG, "ed25519 psa_import failed: %d", (int)status);
memset(derived_seed, 0, sizeof(derived_seed));
memset(privkey, 0, 32);
return -1;
}
status = psa_export_public_key(key_id, pubkey, 32, &pub_len);
psa_destroy_key(key_id);
if (status != PSA_SUCCESS || pub_len != 32) {
ESP_LOGE(KD_TAG, "ed25519 psa_export_public failed: %d", (int)status);
memset(derived_seed, 0, sizeof(derived_seed));
memset(privkey, 0, 32);
return -1;
}
memset(derived_seed, 0, sizeof(derived_seed));
return 0;
}
/* ed25519 sign via PSA (PureEdDSA — signs the raw message, not pre-hashed). */
int ed25519_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
uint8_t sig64[64]) {
return ed25519_sign_msg(privkey, msg32, 32, sig64);
}
int ed25519_sign_msg(const uint8_t privkey[32], const uint8_t *msg, size_t msg_len,
uint8_t sig64[64]) {
psa_status_t status;
psa_key_id_t key_id = 0;
psa_key_attributes_t attrs = PSA_KEY_ATTRIBUTES_INIT;
size_t sig_len = 0;
psa_crypto_init();
psa_set_key_type(&attrs, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_TWISTED_EDWARDS));
psa_set_key_bits(&attrs, 255);
psa_set_key_usage_flags(&attrs, PSA_KEY_USAGE_SIGN_MESSAGE);
psa_set_key_algorithm(&attrs, PSA_ALG_PURE_EDDSA);
status = psa_import_key(&attrs, privkey, 32, &key_id);
if (status != PSA_SUCCESS) {
ESP_LOGE(KD_TAG, "ed25519 sign psa_import failed: %d", (int)status);
return -1;
}
status = psa_sign_message(key_id, PSA_ALG_PURE_EDDSA,
msg, msg_len, sig64, 64, &sig_len);
psa_destroy_key(key_id);
if (status != PSA_SUCCESS || sig_len != 64) {
ESP_LOGE(KD_TAG, "ed25519 psa_sign_message failed: %d", (int)status);
return -1;
}
return 0;
}
int ed25519_verify_msg(const uint8_t *sig, size_t sig_len,
const uint8_t *msg, size_t msg_len,
const uint8_t pubkey[32]) {
psa_status_t status;
psa_key_id_t key_id = 0;
psa_key_attributes_t attrs = PSA_KEY_ATTRIBUTES_INIT;
psa_crypto_init();
psa_set_key_type(&attrs, PSA_KEY_TYPE_ECC_PUBLIC_KEY(PSA_ECC_FAMILY_TWISTED_EDWARDS));
psa_set_key_bits(&attrs, 255);
psa_set_key_usage_flags(&attrs, PSA_KEY_USAGE_VERIFY_MESSAGE);
psa_set_key_algorithm(&attrs, PSA_ALG_PURE_EDDSA);
status = psa_import_key(&attrs, pubkey, 32, &key_id);
if (status != PSA_SUCCESS) {
ESP_LOGE(KD_TAG, "ed25519 verify psa_import failed: %d", (int)status);
return -1;
}
status = psa_verify_message(key_id, PSA_ALG_PURE_EDDSA,
msg, msg_len, sig, sig_len);
psa_destroy_key(key_id);
return (status == PSA_SUCCESS) ? 0 : -1;
}
/* --- x25519 --- */
int derive_x25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]) {
/* m/44'/102002'/<index>'/0'/0' — all hardened (SLIP-0010) */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102002u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t derived_seed[32];
psa_status_t status;
psa_key_id_t key_id = 0;
psa_key_attributes_t attrs = PSA_KEY_ATTRIBUTES_INIT;
size_t pub_len = 0;
if (seed == NULL || privkey == NULL || pubkey == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, derived_seed) != 0) {
return -1;
}
/* The SLIP-0010 derived 32 bytes IS the x25519 private key.
* PSA imports it and exports the public key (PSA handles clamping). */
memcpy(privkey, derived_seed, 32);
memset(derived_seed, 0, sizeof(derived_seed));
psa_crypto_init();
psa_set_key_type(&attrs, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_MONTGOMERY));
psa_set_key_bits(&attrs, 255);
psa_set_key_usage_flags(&attrs, PSA_KEY_USAGE_EXPORT | PSA_KEY_USAGE_DERIVE);
psa_set_key_algorithm(&attrs, PSA_ALG_ECDH);
status = psa_import_key(&attrs, privkey, 32, &key_id);
if (status != PSA_SUCCESS) {
ESP_LOGE(KD_TAG, "x25519 psa_import failed: %d", (int)status);
memset(privkey, 0, 32);
return -1;
}
status = psa_export_public_key(key_id, pubkey, 32, &pub_len);
psa_destroy_key(key_id);
if (status != PSA_SUCCESS || pub_len != 32) {
ESP_LOGE(KD_TAG, "x25519 psa_export_public failed: %d", (int)status);
memset(privkey, 0, 32);
return -1;
}
return 0;
}
/* --- ML-DSA-65 --- */
int derive_ml_dsa_65_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk) {
/* m/44'/102003'/<index>'/0'/0' — all hardened (SLIP-0010) -> 32-byte seed */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102003u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t pq_seed[32];
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, pq_seed) != 0) {
return -1;
}
int ret = fw_pq_ml_dsa_65_keygen(pq_seed, pk, sk);
memset(pq_seed, 0, sizeof(pq_seed));
return ret;
}
/* --- SLH-DSA-128s --- */
int derive_slh_dsa_128s_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk) {
/* m/44'/102004'/<index>'/0'/0' — all hardened (SLIP-0010) -> 32-byte seed */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102004u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t pq_seed[32];
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, pq_seed) != 0) {
return -1;
}
ESP_LOGW(KD_TAG, "SLH-DSA-128s keygen: this takes 5-30 seconds on ESP32");
int ret = fw_pq_slh_dsa_128s_keygen(pq_seed, pk, sk);
memset(pq_seed, 0, sizeof(pq_seed));
return ret;
}
/* --- ML-KEM-768 --- */
int derive_ml_kem_768_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk) {
/* m/44'/102005'/<index>'/0'/0' — all hardened (SLIP-0010) -> 32-byte seed */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102005u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t pq_seed[32];
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, pq_seed) != 0) {
return -1;
}
int ret = fw_pq_ml_kem_768_keygen(pq_seed, pk, sk);
memset(pq_seed, 0, sizeof(pq_seed));
return ret;
}

View File

@@ -1,7 +1,70 @@
#pragma once #pragma once
#include <stddef.h>
#include <stdint.h> #include <stdint.h>
/* --- secp256k1 (Nostr, existing) --- */
int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey[32]); int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey[32]);
int derive_nostr_key_index(const uint8_t seed[64], uint32_t nostr_index, uint8_t privkey[32], uint8_t pubkey[32]); int derive_nostr_key_index(const uint8_t seed[64], uint32_t nostr_index, uint8_t privkey[32], uint8_t pubkey[32]);
int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t sig64[64]); int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t sig64[64]);
/* --- ed25519 (SSH signatures) --- */
/* Derives an ed25519 keypair from the mnemonic seed using SLIP-0010
* all-hardened derivation: m/44'/102001'/<n>'/0'/0'
* privkey: 32-byte ed25519 private scalar
* pubkey: 32-byte ed25519 public key
* Returns 0 on success, -1 on error. */
int derive_ed25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]);
/* Signs a 32-byte message digest with ed25519 (PureEdDSA, raw message).
* sig: 64-byte ed25519 signature
* Returns 0 on success, -1 on error. */
int ed25519_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
uint8_t sig64[64]);
/* Signs a variable-length message with ed25519 (PureEdDSA). */
int ed25519_sign_msg(const uint8_t privkey[32], const uint8_t *msg, size_t msg_len,
uint8_t sig64[64]);
/* Verifies an ed25519 signature over a variable-length message. */
int ed25519_verify_msg(const uint8_t *sig, size_t sig_len,
const uint8_t *msg, size_t msg_len,
const uint8_t pubkey[32]);
/* --- x25519 (age encryption / key agreement) --- */
/* Derives an x25519 keypair from the mnemonic seed using SLIP-0010
* all-hardened derivation: m/44'/102002'/<n>'/0'/0'
* privkey: 32-byte x25519 private scalar
* pubkey: 32-byte x25519 public key
* Returns 0 on success, -1 on error. */
int derive_x25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]);
/* --- ML-DSA-65 (post-quantum signatures, FIPS 204) --- */
/* Derives an ML-DSA-65 keypair from the mnemonic seed.
* Path: m/44'/102003'/<n>'/0'/0' -> 32-byte seed -> PQClean keygen
* pk: FW_ML_DSA_65_PUBKEY_LEN (1952) bytes — caller must allocate
* sk: FW_ML_DSA_65_PRIVKEY_LEN (4032) bytes — caller must allocate
* Returns 0 on success, -1 on error. */
int derive_ml_dsa_65_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk);
/* --- SLH-DSA-128s (post-quantum hash-based signatures, FIPS 205) --- */
/* Derives an SLH-DSA-128s keypair from the mnemonic seed.
* Path: m/44'/102004'/<n>'/0'/0' -> 32-byte seed -> PQClean keygen
* pk: FW_SLH_DSA_128S_PUBKEY_LEN (32) bytes
* sk: FW_SLH_DSA_128S_PRIVKEY_LEN (64) bytes
* WARNING: Takes 5-30 seconds on ESP32.
* Returns 0 on success, -1 on error. */
int derive_slh_dsa_128s_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk);
/* --- ML-KEM-768 (post-quantum KEM, FIPS 203) --- */
/* Derives an ML-KEM-768 keypair from the mnemonic seed.
* Path: m/44'/102005'/<n>'/0'/0' -> 32-byte seed -> PQClean keygen
* pk: FW_ML_KEM_768_PUBKEY_LEN (1184) bytes — caller must allocate
* sk: FW_ML_KEM_768_PRIVKEY_LEN (2400) bytes — caller must allocate
* Returns 0 on success, -1 on error. */
int derive_ml_kem_768_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
/* pq_crypto_firmware.c — Post-quantum crypto wrappers for ESP32 firmware.
*
* Wraps the PQClean algorithm API (via the pqclean component) with
* firmware-friendly functions that handle the deterministic DRBG setup
* for keygen and provide clean sign/verify/encaps/decaps interfaces.
*
* The PQ key buffers are large (ML-DSA-65 priv = 4032 bytes, SLH-DSA-128s
* sig = 7856 bytes). Callers must allocate these on the heap or as static
* buffers — stack allocation on ESP32 (8KB task stack default) will overflow
* for the larger buffers.
*/
#include "pq_crypto_firmware.h"
#include "pqclean.h"
#include <stdlib.h>
#include <string.h>
/* --- Key generation (deterministic from seed) --- */
int fw_pq_ml_dsa_65_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk) {
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
/* Initialize the deterministic DRBG with the mnemonic-derived seed */
pq_drbg_init(seed, 32);
/* Run PQClean keygen — randombytes() draws from the DRBG */
int ret = crypto_sign_keypair(pk, sk);
/* Wipe the DRBG state — the seed material is sensitive */
pq_drbg_zeroize();
return ret;
}
int fw_pq_slh_dsa_128s_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk) {
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
pq_drbg_init(seed, 32);
/* WARNING: This call takes 5-30 seconds on ESP32 due to the
* hypertree construction (7 layers of WOTS+ + Merkle trees). */
int ret = slh_dsa_128s_crypto_sign_keypair(pk, sk);
pq_drbg_zeroize();
return ret;
}
int fw_pq_ml_kem_768_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk) {
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
pq_drbg_init(seed, 32);
int ret = crypto_kem_keypair(pk, sk);
pq_drbg_zeroize();
return ret;
}
/* --- Signing --- */
int fw_pq_ml_dsa_65_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk) {
if (sig == NULL || siglen == NULL || m == NULL || sk == NULL) {
return -1;
}
return crypto_sign(sig, siglen, m, mlen, sk);
}
int fw_pq_ml_dsa_65_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk) {
if (sig == NULL || m == NULL || pk == NULL) {
return -1;
}
/* crypto_sign_open expects (m_out, mlen_out, sm, smlen, pk) where sm
* is the signed message. For detached signatures we reconstruct: the
* PQClean API uses crypto_sign_open with sm = sig || m. */
/* For firmware use, we provide a simple verify by re-signing is not
* possible (non-deterministic). The PQClean crypto_sign_open expects
* the concatenated sig||msg format. Callers should use the PQClean
* API directly for verification, or we build the sm buffer here. */
uint8_t *sm = (uint8_t *)malloc(siglen + mlen);
if (sm == NULL) {
return -1;
}
memcpy(sm, sig, siglen);
memcpy(sm + siglen, m, mlen);
uint8_t *m_out = (uint8_t *)malloc(mlen);
if (m_out == NULL) {
free(sm);
return -1;
}
size_t mlen_out = 0;
int ret = crypto_sign_open(m_out, &mlen_out, sm, siglen + mlen, pk);
free(sm);
free(m_out);
return ret;
}
int fw_pq_slh_dsa_128s_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk) {
if (sig == NULL || siglen == NULL || m == NULL || sk == NULL) {
return -1;
}
/* WARNING: This call takes 5-30 seconds on ESP32. */
return slh_dsa_128s_crypto_sign(sig, siglen, m, mlen, sk);
}
int fw_pq_slh_dsa_128s_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk) {
if (sig == NULL || m == NULL || pk == NULL) {
return -1;
}
uint8_t *sm = (uint8_t *)malloc(siglen + mlen);
if (sm == NULL) {
return -1;
}
memcpy(sm, sig, siglen);
memcpy(sm + siglen, m, mlen);
uint8_t *m_out = (uint8_t *)malloc(mlen);
if (m_out == NULL) {
free(sm);
return -1;
}
size_t mlen_out = 0;
int ret = slh_dsa_128s_crypto_sign_open(m_out, &mlen_out, sm,
siglen + mlen, pk);
free(sm);
free(m_out);
return ret;
}
/* --- KEM --- */
int fw_pq_ml_kem_768_encaps(uint8_t *ct, uint8_t *ss, const uint8_t *pk) {
if (ct == NULL || ss == NULL || pk == NULL) {
return -1;
}
/* encaps uses real randomness (hardware RNG) — the DRBG is not
* initialized, so randombytes() falls back to esp_fill_random(). */
return crypto_kem_enc(ct, ss, pk);
}
int fw_pq_ml_kem_768_decaps(uint8_t *ss, const uint8_t *ct, const uint8_t *sk) {
if (ss == NULL || ct == NULL || sk == NULL) {
return -1;
}
return crypto_kem_dec(ss, ct, sk);
}

View File

@@ -0,0 +1,110 @@
/* pq_crypto_firmware.h — Post-quantum crypto wrappers for ESP32 firmware.
*
* Provides firmware-friendly wrappers around the PQClean algorithms:
* - ML-DSA-65 (FIPS 204 signatures)
* - SLH-DSA-128s (FIPS 205 hash-based signatures)
* - ML-KEM-768 (FIPS 203 key encapsulation)
*
* Key generation is deterministic from a 32-byte seed (derived from the
* mnemonic via BIP-32/HMAC-SHA512). The seed feeds the deterministic DRBG
* (pq_drbg_firmware.c) which replaces PQClean's randombytes() during keygen.
*
* ed25519 and x25519 are handled separately via mbedtls (see
* key_derivation.c) and are not part of this PQClean component.
*/
#ifndef FIRMWARE_PQ_CRYPTO_H
#define FIRMWARE_PQ_CRYPTO_H
#include <stddef.h>
#include <stdint.h>
/* --- Algorithm identifiers --- */
typedef enum {
FW_PQ_ALG_ML_DSA_65 = 0,
FW_PQ_ALG_SLH_DSA_128S,
FW_PQ_ALG_ML_KEM_768,
FW_PQ_ALG_UNKNOWN
} fw_pq_alg_t;
/* --- Key sizes (compile-time constants, matching PQClean api.h) --- */
#define FW_ML_DSA_65_PUBKEY_LEN 1952
#define FW_ML_DSA_65_PRIVKEY_LEN 4032
#define FW_ML_DSA_65_SIG_LEN 3309
#define FW_SLH_DSA_128S_PUBKEY_LEN 32
#define FW_SLH_DSA_128S_PRIVKEY_LEN 64
#define FW_SLH_DSA_128S_SIG_LEN 7856
#define FW_ML_KEM_768_PUBKEY_LEN 1184
#define FW_ML_KEM_768_PRIVKEY_LEN 2400
#define FW_ML_KEM_768_CIPHERTEXT_LEN 1088
#define FW_ML_KEM_768_SHARED_SECRET_LEN 32
/* --- Key generation (deterministic from seed) --- */
/* Generate an ML-DSA-65 keypair from a 32-byte seed.
* pk must be at least FW_ML_DSA_65_PUBKEY_LEN bytes.
* sk must be at least FW_ML_DSA_65_PRIVKEY_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_dsa_65_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk);
/* Generate an SLH-DSA-128s keypair from a 32-byte seed.
* pk must be at least FW_SLH_DSA_128S_PUBKEY_LEN bytes.
* sk must be at least FW_SLH_DSA_128S_PRIVKEY_LEN bytes.
* Returns 0 on success, -1 on error.
* WARNING: SLH-DSA-128s keygen takes 5-30 seconds on ESP32. */
int fw_pq_slh_dsa_128s_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk);
/* Generate an ML-KEM-768 keypair from a 32-byte seed.
* pk must be at least FW_ML_KEM_768_PUBKEY_LEN bytes.
* sk must be at least FW_ML_KEM_768_PRIVKEY_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_kem_768_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk);
/* --- Signing (ML-DSA-65, SLH-DSA-128s) --- */
/* Sign a message with ML-DSA-65.
* sig must be at least FW_ML_DSA_65_SIG_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_dsa_65_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk);
/* Verify an ML-DSA-65 signature.
* Returns 0 on valid, -1 on invalid. */
int fw_pq_ml_dsa_65_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk);
/* Sign a message with SLH-DSA-128s.
* sig must be at least FW_SLH_DSA_128S_SIG_LEN bytes.
* WARNING: SLH-DSA-128s signing takes 5-30 seconds on ESP32.
* Returns 0 on success, -1 on error. */
int fw_pq_slh_dsa_128s_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk);
/* Verify an SLH-DSA-128s signature.
* Returns 0 on valid, -1 on invalid. */
int fw_pq_slh_dsa_128s_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk);
/* --- KEM (ML-KEM-768) --- */
/* Encapsulate: generate ciphertext + shared secret from a public key.
* Uses real randomness (ESP32 hardware RNG) — not the deterministic DRBG.
* ct must be at least FW_ML_KEM_768_CIPHERTEXT_LEN bytes.
* ss must be at least FW_ML_KEM_768_SHARED_SECRET_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_kem_768_encaps(uint8_t *ct, uint8_t *ss, const uint8_t *pk);
/* Decapsulate: recover shared secret from secret key + ciphertext.
* ss must be at least FW_ML_KEM_768_SHARED_SECRET_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_kem_768_decaps(uint8_t *ss, const uint8_t *ct, const uint8_t *sk);
#endif /* FIRMWARE_PQ_CRYPTO_H */

View File

@@ -8,3 +8,8 @@ CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
CONFIG_FREERTOS_HZ=1000 CONFIG_FREERTOS_HZ=1000
CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384
CONFIG_COMPILER_OPTIMIZATION_SIZE=y CONFIG_COMPILER_OPTIMIZATION_SIZE=y
# PSA crypto for ed25519 sign/verify (IDF v5.x mbedtls has no mbedtls_ed25519_*).
CONFIG_MBEDTLS_PSA_CRYPTO_C=y
# Curve25519 / Ed25519 ECP domain parameter (already enabled, kept for clarity).
CONFIG_MBEDTLS_ECP_DP_CURVE25519_ENABLED=y

View File

@@ -0,0 +1,60 @@
# CMakeLists.txt — ESP-IDF component for PQClean post-quantum algorithms.
#
# Compiles the three PQ algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
# from the shared resources/pqclean/ source tree, using the mbedtls
# crypto backend (crypto_backend_mbedtls.c) for SHA-2/SHA3/SHAKE.
#
# The source files are referenced via relative paths back to the shared
# resources/pqclean/ directory so there is a single source of truth.
#
# mbedtls requirements:
# CONFIG_MBEDTLS_SHA3_C=y (for SHA3-256, SHA3-512)
# CONFIG_MBEDTLS_SHAKE_C=y (for SHAKE-128, SHAKE-256)
# Enable these in menuconfig under Component config -> mbedTLS ->
# Hash functions -> SHA-3 and SHAKE.
set(PQCLEAN_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../resources/pqclean")
idf_component_register(
SRCS
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65/sign.c"
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65/poly.c"
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65/ntt.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/sign.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/fors.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/wots.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/hash.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/thash.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/address.c"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s/utils.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/kem.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/indcpa.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/poly.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/ntt.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/cbd.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/reduce.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/symmetric.c"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768/verify.c"
"${PQCLEAN_ROOT}/common/fips202.c"
"${PQCLEAN_ROOT}/common/sha2.c"
"${PQCLEAN_ROOT}/common/crypto_backend_mbedtls.c"
"randombytes_mbedtls.c"
"pq_drbg_firmware.c"
INCLUDE_DIRS
"include"
"${PQCLEAN_ROOT}/common"
"${PQCLEAN_ROOT}/crypto_sign/ml-dsa-65"
"${PQCLEAN_ROOT}/crypto_sign/slh-dsa-128s"
"${PQCLEAN_ROOT}/crypto_kem/ml-kem-768"
REQUIRES
mbedtls
)
# Suppress warnings from the PQClean code (it uses C99 patterns that
# trigger -Wextra warnings under ESP-IDF's default flags).
target_compile_options(${COMPONENT_LIB} PRIVATE
-Wno-unused-parameter
-Wno-sign-compare
-Wno-unused-variable
-Wno-unused-but-set-variable
)

View File

@@ -0,0 +1,5 @@
/* ml_dsa_65_api.h — firmware wrapper that includes the real PQClean header. */
#ifndef FIRMWARE_ML_DSA_65_API_WRAPPER_H
#define FIRMWARE_ML_DSA_65_API_WRAPPER_H
#include "../../../../resources/pqclean/crypto_sign/ml-dsa-65/api.h"
#endif

View File

@@ -0,0 +1,5 @@
/* ml_kem_768_api.h — firmware wrapper that includes the real PQClean header. */
#ifndef FIRMWARE_ML_KEM_768_API_WRAPPER_H
#define FIRMWARE_ML_KEM_768_API_WRAPPER_H
#include "../../../../resources/pqclean/crypto_kem/ml-kem-768/api.h"
#endif

View File

@@ -0,0 +1,38 @@
/* pqclean.h — Umbrella include for the ESP32 firmware PQClean component.
*
* Exposes the three post-quantum algorithms (ML-DSA-65, SLH-DSA-128s,
* ML-KEM-768) and the deterministic DRBG used for mnemonic-recoverable
* key generation.
*
* On ESP32 the underlying hash/SHAKE primitives are provided by the
* mbedtls backend (crypto_backend_mbedtls.c) instead of OpenSSL.
*/
#ifndef FIRMWARE_PQCLEAN_H
#define FIRMWARE_PQCLEAN_H
#include <stddef.h>
#include <stdint.h>
/* --- ML-DSA-65 (FIPS 204, lattice signatures) --- */
#include "ml_dsa_65_api.h"
/* --- SLH-DSA-128s (FIPS 205, hash-based signatures) --- */
#include "slh_dsa_128s_api.h"
/* --- ML-KEM-768 (FIPS 203, lattice KEM) --- */
#include "ml_kem_768_api.h"
/* --- Deterministic DRBG (replaces randombytes() for keygen) --- */
/* Initializes the DRBG with a 32-byte mnemonic-derived seed. Subsequent
* randombytes() calls will produce a deterministic byte stream. */
void pq_drbg_init(const unsigned char *seed, size_t seed_len);
/* Zeroizes the DRBG state (call after keygen to wipe sensitive material). */
void pq_drbg_zeroize(void);
/* randombytes() — called by the PQClean algorithm code.
* On firmware this is provided by randombytes_mbedtls.c (deterministic DRBG
* for keygen, or mbedtls_ctr_drbg for real randomness during encaps). */
int randombytes(unsigned char *buf, size_t len);
#endif /* FIRMWARE_PQCLEAN_H */

View File

@@ -0,0 +1,5 @@
/* slh_dsa_128s_api.h — firmware wrapper that includes the real PQClean header. */
#ifndef FIRMWARE_SLH_DSA_128S_API_WRAPPER_H
#define FIRMWARE_SLH_DSA_128S_API_WRAPPER_H
#include "../../../../resources/pqclean/crypto_sign/slh-dsa-128s/api.h"
#endif

View File

@@ -0,0 +1,108 @@
/* pq_drbg_firmware.c — Deterministic PRNG for PQ key generation on ESP32.
*
* Same algorithm as the host's src/pq_drbg.c but uses the crypto backend
* abstraction (which resolves to mbedtls on ESP32) for SHAKE-256 instead
* of OpenSSL EVP. This allows deterministic PQ key generation from a
* mnemonic-derived seed: same seed -> same randombytes output sequence.
*
* The PRNG: SHAKE-256(seed || counter) produces a stream of pseudo-random
* bytes. The counter is a 64-bit little-endian integer that increments
* each time we need more output.
*/
#include <string.h>
#include <stdlib.h>
#include "crypto_backend.h"
/* --- DRBG state --- */
static unsigned char g_seed[32];
static int g_seed_len = 0;
static uint64_t g_counter = 0;
static unsigned char g_buffer[168]; /* SHAKE-256 rate = 136, 168 for safety */
static size_t g_buffer_pos = sizeof(g_buffer);
static int g_initialized = 0;
/* --- internal: squeeze more bytes from SHAKE-256 --- */
static void drbg_refill(void) {
unsigned char seed_block[32 + 8]; /* seed + counter (8 bytes LE) */
memcpy(seed_block, g_seed, (size_t)g_seed_len);
seed_block[g_seed_len + 0] = (unsigned char)(g_counter & 0xFF);
seed_block[g_seed_len + 1] = (unsigned char)((g_counter >> 8) & 0xFF);
seed_block[g_seed_len + 2] = (unsigned char)((g_counter >> 16) & 0xFF);
seed_block[g_seed_len + 3] = (unsigned char)((g_counter >> 24) & 0xFF);
seed_block[g_seed_len + 4] = (unsigned char)((g_counter >> 32) & 0xFF);
seed_block[g_seed_len + 5] = (unsigned char)((g_counter >> 40) & 0xFF);
seed_block[g_seed_len + 6] = (unsigned char)((g_counter >> 48) & 0xFF);
seed_block[g_seed_len + 7] = (unsigned char)((g_counter >> 56) & 0xFF);
crypto_backend_shake256(seed_block, (size_t)g_seed_len + 8,
g_buffer, sizeof(g_buffer));
g_counter++;
g_buffer_pos = 0;
}
/* --- public API --- */
void pq_drbg_init(const unsigned char *seed, size_t seed_len) {
if (seed == NULL || seed_len == 0) {
return;
}
memset(g_seed, 0, sizeof(g_seed));
if (seed_len > sizeof(g_seed)) {
seed_len = sizeof(g_seed);
}
memcpy(g_seed, seed, seed_len);
g_seed_len = (int)sizeof(g_seed); /* always use 32-byte seed (zero-padded) */
g_counter = 0;
g_buffer_pos = sizeof(g_buffer);
g_initialized = 1;
}
void pq_drbg_zeroize(void) {
crypto_backend_cleanse(g_seed, sizeof(g_seed));
crypto_backend_cleanse(g_buffer, sizeof(g_buffer));
g_seed_len = 0;
g_counter = 0;
g_buffer_pos = sizeof(g_buffer);
g_initialized = 0;
}
/* Returns 1 if the DRBG has been initialized (keygen mode), 0 otherwise.
* Used by randombytes_mbedtls.c to decide between deterministic DRBG and
* hardware RNG. */
int pq_drbg_is_initialized(void) {
return g_initialized;
}
/* pq_drbg_randombytes is called by randombytes() below. */
int pq_drbg_randombytes(unsigned char *buf, size_t len) {
if (buf == NULL || !g_initialized) {
return -1;
}
while (len > 0) {
size_t avail;
size_t to_copy;
if (g_buffer_pos >= sizeof(g_buffer)) {
drbg_refill();
if (g_buffer_pos >= sizeof(g_buffer)) {
return -1; /* refill failed */
}
}
avail = sizeof(g_buffer) - g_buffer_pos;
to_copy = (len < avail) ? len : avail;
memcpy(buf, g_buffer + g_buffer_pos, to_copy);
g_buffer_pos += to_copy;
buf += to_copy;
len -= to_copy;
}
return 0;
}

View File

@@ -0,0 +1,40 @@
/* randombytes_mbedtls.c — randombytes() implementation for ESP32 firmware.
*
* PQClean's algorithm code calls randombytes() for:
* 1. Key generation (keygen) — must be deterministic from the mnemonic
* seed so keys are recoverable. The DRBG is initialized via
* pq_drbg_init() before keygen, so randombytes() draws from the
* deterministic stream.
* 2. Encapsulation (ML-KEM enc) — needs real cryptographic randomness.
* When the DRBG is NOT initialized, randombytes() falls back to
* esp_fill_random() which uses the ESP32 hardware RNG.
*
* This dual-mode behavior matches the host build (src/pq_drbg.c) where
* the DRBG is initialized for keygen and randombytes() returns -1 if
* called without initialization. On firmware we allow the fallback to
* hardware RNG for encaps, which is the correct behavior.
*/
#include <string.h>
#include "esp_random.h"
/* Defined in pq_drbg_firmware.c */
extern int pq_drbg_randombytes(unsigned char *buf, size_t len);
/* Check if the DRBG is initialized (declared in pq_drbg_firmware.c).
* We use a helper to avoid exposing the static directly. */
extern int pq_drbg_is_initialized(void);
int randombytes(unsigned char *buf, size_t len) {
if (buf == NULL) {
return -1;
}
/* If the deterministic DRBG is active (keygen mode), use it. */
if (pq_drbg_is_initialized()) {
return pq_drbg_randombytes(buf, len);
}
/* Otherwise, use the ESP32 hardware RNG for real randomness (encaps). */
esp_fill_random(buf, len);
return 0;
}

View File

@@ -4,6 +4,7 @@ idf_component_register(
"display.c" "display.c"
"mnemonic.c" "mnemonic.c"
"key_derivation.c" "key_derivation.c"
"pq_crypto_firmware.c"
"bech32.c" "bech32.c"
"usb_transport.c" "usb_transport.c"
"buttons.c" "buttons.c"
@@ -23,6 +24,7 @@ idf_component_register(
REQUIRES REQUIRES
mbedtls mbedtls
secp256k1 secp256k1
pqclean
json json
espressif__esp_tinyusb espressif__esp_tinyusb
espressif__tinyusb espressif__tinyusb

View File

@@ -1,17 +1,25 @@
#include "key_derivation.h" #include "key_derivation.h"
#include "pq_crypto_firmware.h"
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include "esp_random.h" #include "esp_random.h"
#include "esp_log.h"
#include "mbedtls/md.h" #include "mbedtls/md.h"
#include "mbedtls/ed25519.h"
#include "mbedtls/ecp.h"
#include "mbedtls/pk.h"
#include "secp256k1.h" #include "secp256k1.h"
#include "secp256k1_extrakeys.h" #include "secp256k1_extrakeys.h"
#include "secp256k1_schnorrsig.h" #include "secp256k1_schnorrsig.h"
static const char *KD_TAG = "key_derivation";
#define BIP32_HARDENED_FLAG 0x80000000u #define BIP32_HARDENED_FLAG 0x80000000u
typedef struct { typedef struct {
@@ -253,3 +261,316 @@ int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t s
secp256k1_context_destroy(ctx); secp256k1_context_destroy(ctx);
return 0; return 0;
} }
/* ====================================================================
* Phase 7: ed25519, x25519, and post-quantum key derivation
* ==================================================================== */
/* SLIP-0010 all-hardened derivation for ed25519/x25519.
*
* SLIP-0010 uses HMAC-SHA512 with a "ed25519 seed" or curve-specific key
* for the master key, and all derivation steps are hardened (the parent
* private key is prepended to the index data).
*
* For ed25519/x25519, the derived 512-bit HMAC output is split:
* - first 32 bytes = private key (the scalar)
* - last 32 bytes = chain code
*
* The private key IS the ed25519/x25519 secret — no tweak-add is needed
* (unlike secp256k1 BIP-32 where the child priv = parent_priv + HMAC).
*/
/* SLIP-0010 master key from seed: HMAC-SHA512(key="ed25519 seed", data=seed) */
static int slip10_master_from_seed(const uint8_t seed[64],
uint8_t priv[32], uint8_t chain[32]) {
static const uint8_t kEd25519Seed[] = "ed25519 seed";
uint8_t i64[64] = {0};
if (hmac_sha512(kEd25519Seed, sizeof(kEd25519Seed) - 1,
seed, 64, i64) != 0) {
return -1;
}
memcpy(priv, i64, 32);
memcpy(chain, i64 + 32, 32);
memset(i64, 0, sizeof(i64));
return 0;
}
/* SLIP-0010 hardened child derivation:
* HMAC-SHA512(key=chain, data=0x00 || priv || index_be32) */
static int slip10_ckd_priv(const uint8_t parent_priv[32],
const uint8_t parent_chain[32],
uint32_t index,
uint8_t child_priv[32],
uint8_t child_chain[32]) {
uint8_t data[37];
uint8_t i64[64] = {0};
/* Hardened derivation: 0x00 || priv || index (big-endian) */
data[0] = 0x00;
memcpy(data + 1, parent_priv, 32);
data[33] = (uint8_t)((index >> 24) & 0xFF);
data[34] = (uint8_t)((index >> 16) & 0xFF);
data[35] = (uint8_t)((index >> 8) & 0xFF);
data[36] = (uint8_t)(index & 0xFF);
if (hmac_sha512(parent_chain, 32, data, sizeof(data), i64) != 0) {
memset(data, 0, sizeof(data));
return -1;
}
memcpy(child_priv, i64, 32);
memcpy(child_chain, i64 + 32, 32);
memset(data, 0, sizeof(data));
memset(i64, 0, sizeof(i64));
return 0;
}
/* Derive a 32-byte seed via SLIP-0010 all-hardened path.
* path[] is an array of hardened indices (the caller sets the hardened flag).
* Returns the final 32-byte private material in `out_seed`. */
static int slip10_derive_seed(const uint8_t seed[64],
const uint32_t *path, size_t path_len,
uint8_t out_seed[32]) {
uint8_t priv[32], chain[32], next_priv[32], next_chain[32];
size_t i;
if (slip10_master_from_seed(seed, priv, chain) != 0) {
return -1;
}
for (i = 0; i < path_len; i++) {
if (slip10_ckd_priv(priv, chain, path[i],
next_priv, next_chain) != 0) {
memset(priv, 0, sizeof(priv));
memset(chain, 0, sizeof(chain));
return -1;
}
memcpy(priv, next_priv, 32);
memcpy(chain, next_chain, 32);
}
memcpy(out_seed, priv, 32);
memset(priv, 0, sizeof(priv));
memset(chain, 0, sizeof(chain));
memset(next_priv, 0, sizeof(next_priv));
memset(next_chain, 0, sizeof(next_chain));
return 0;
}
/* --- ed25519 --- */
int derive_ed25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]) {
/* m/44'/102001'/<index>'/0'/0' — all hardened (SLIP-0010) */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102001u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t derived_seed[32];
if (seed == NULL || privkey == NULL || pubkey == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, derived_seed) != 0) {
return -1;
}
/* The SLIP-0010 derived 32 bytes IS the ed25519 private key.
* Use mbedtls to derive the public key. */
memcpy(privkey, derived_seed, 32);
/* mbedtls_ed25519_make_public: derive pub from priv */
/* Note: mbedtls ed25519 API may vary by version. The ESP-IDF mbedtls
* component provides mbedtls_ed25519_make_public (or via the PK API).
* We use the low-level function if available. */
int ret = mbedtls_ed25519_make_public((unsigned char *)pubkey, 32,
(const unsigned char *)privkey, 32);
if (ret != 0) {
ESP_LOGE(KD_TAG, "ed25519 make_public failed: %d", ret);
memset(derived_seed, 0, sizeof(derived_seed));
memset(privkey, 0, 32);
return -1;
}
memset(derived_seed, 0, sizeof(derived_seed));
return 0;
}
int ed25519_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
uint8_t sig64[64]) {
/* mbedtls_ed25519_sign: sign a message (not pre-hashed) */
int ret = mbedtls_ed25519_sign((unsigned char *)sig64, 64,
(const unsigned char *)msg32, 32,
(const unsigned char *)privkey, 32,
NULL, NULL);
if (ret != 0) {
ESP_LOGE(KD_TAG, "ed25519 sign failed: %d", ret);
return -1;
}
return 0;
}
/* --- x25519 --- */
int derive_x25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]) {
/* m/44'/102002'/<index>'/0'/0' — all hardened (SLIP-0010) */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102002u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t derived_seed[32];
mbedtls_ecp_group grp;
mbedtls_mpi d;
mbedtls_ecp_point Q;
int ret;
if (seed == NULL || privkey == NULL || pubkey == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, derived_seed) != 0) {
return -1;
}
/* The SLIP-0010 derived 32 bytes IS the x25519 private key.
* Clamp it per RFC 7748 and derive the public key via mbedtls ECDH. */
memcpy(privkey, derived_seed, 32);
memset(derived_seed, 0, sizeof(derived_seed));
/* x25519 clamping: priv[0] &= 248, priv[31] &= 127, priv[31] |= 64 */
privkey[0] &= 248;
privkey[31] &= 127;
privkey[31] |= 64;
mbedtls_ecp_group_init(&grp);
mbedtls_mpi_init(&d);
mbedtls_ecp_point_init(&Q);
ret = mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_CURVE25519);
if (ret != 0) {
ESP_LOGE(KD_TAG, "x25519 group load failed: %d", ret);
goto cleanup;
}
ret = mbedtls_mpi_read_binary_le(d, privkey, 32);
if (ret != 0) {
ESP_LOGE(KD_TAG, "x25519 mpi read failed: %d", ret);
goto cleanup;
}
ret = mbedtls_ecp_mul(&grp, &Q, d, &grp.G, NULL, NULL);
if (ret != 0) {
ESP_LOGE(KD_TAG, "x25519 ecp_mul failed: %d", ret);
goto cleanup;
}
/* Serialize the public key as raw 32 bytes (little-endian) */
{
size_t olen = 0;
ret = mbedtls_ecp_point_write_binary(&grp, &Q,
MBEDTLS_ECP_PF_COMPRESSED,
&olen, pubkey, 32);
if (ret != 0 || olen != 32) {
ESP_LOGE(KD_TAG, "x25519 pub serialize failed: %d", ret);
ret = -1;
}
}
cleanup:
mbedtls_ecp_group_free(&grp);
mbedtls_mpi_free(&d);
mbedtls_ecp_point_free(&Q);
return (ret == 0) ? 0 : -1;
}
/* --- ML-DSA-65 --- */
int derive_ml_dsa_65_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk) {
/* m/44'/102003'/<index>'/0'/0' — all hardened (SLIP-0010) -> 32-byte seed */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102003u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t pq_seed[32];
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, pq_seed) != 0) {
return -1;
}
int ret = fw_pq_ml_dsa_65_keygen(pq_seed, pk, sk);
memset(pq_seed, 0, sizeof(pq_seed));
return ret;
}
/* --- SLH-DSA-128s --- */
int derive_slh_dsa_128s_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk) {
/* m/44'/102004'/<index>'/0'/0' — all hardened (SLIP-0010) -> 32-byte seed */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102004u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t pq_seed[32];
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, pq_seed) != 0) {
return -1;
}
ESP_LOGW(KD_TAG, "SLH-DSA-128s keygen: this takes 5-30 seconds on ESP32");
int ret = fw_pq_slh_dsa_128s_keygen(pq_seed, pk, sk);
memset(pq_seed, 0, sizeof(pq_seed));
return ret;
}
/* --- ML-KEM-768 --- */
int derive_ml_kem_768_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk) {
/* m/44'/102005'/<index>'/0'/0' — all hardened (SLIP-0010) -> 32-byte seed */
const uint32_t path[5] = {
44u | BIP32_HARDENED_FLAG,
102005u | BIP32_HARDENED_FLAG,
index | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
uint8_t pq_seed[32];
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
if (slip10_derive_seed(seed, path, 5, pq_seed) != 0) {
return -1;
}
int ret = fw_pq_ml_kem_768_keygen(pq_seed, pk, sk);
memset(pq_seed, 0, sizeof(pq_seed));
return ret;
}

View File

@@ -1,7 +1,61 @@
#pragma once #pragma once
#include <stddef.h>
#include <stdint.h> #include <stdint.h>
/* --- secp256k1 (Nostr, existing) --- */
int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey[32]); int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey[32]);
int derive_nostr_key_index(const uint8_t seed[64], uint32_t nostr_index, uint8_t privkey[32], uint8_t pubkey[32]); int derive_nostr_key_index(const uint8_t seed[64], uint32_t nostr_index, uint8_t privkey[32], uint8_t pubkey[32]);
int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t sig64[64]); int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t sig64[64]);
/* --- ed25519 (SSH signatures) --- */
/* Derives an ed25519 keypair from the mnemonic seed using SLIP-0010
* all-hardened derivation: m/44'/102001'/<n>'/0'/0'
* privkey: 32-byte ed25519 private scalar
* pubkey: 32-byte ed25519 public key
* Returns 0 on success, -1 on error. */
int derive_ed25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]);
/* Signs a 32-byte message digest with ed25519.
* sig: 64-byte ed25519 signature
* Returns 0 on success, -1 on error. */
int ed25519_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
uint8_t sig64[64]);
/* --- x25519 (age encryption / key agreement) --- */
/* Derives an x25519 keypair from the mnemonic seed using SLIP-0010
* all-hardened derivation: m/44'/102002'/<n>'/0'/0'
* privkey: 32-byte x25519 private scalar
* pubkey: 32-byte x25519 public key
* Returns 0 on success, -1 on error. */
int derive_x25519_key(const uint8_t seed[64], uint32_t index,
uint8_t privkey[32], uint8_t pubkey[32]);
/* --- ML-DSA-65 (post-quantum signatures, FIPS 204) --- */
/* Derives an ML-DSA-65 keypair from the mnemonic seed.
* Path: m/44'/102003'/<n>'/0'/0' -> 32-byte seed -> PQClean keygen
* pk: FW_ML_DSA_65_PUBKEY_LEN (1952) bytes — caller must allocate
* sk: FW_ML_DSA_65_PRIVKEY_LEN (4032) bytes — caller must allocate
* Returns 0 on success, -1 on error. */
int derive_ml_dsa_65_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk);
/* --- SLH-DSA-128s (post-quantum hash-based signatures, FIPS 205) --- */
/* Derives an SLH-DSA-128s keypair from the mnemonic seed.
* Path: m/44'/102004'/<n>'/0'/0' -> 32-byte seed -> PQClean keygen
* pk: FW_SLH_DSA_128S_PUBKEY_LEN (32) bytes
* sk: FW_SLH_DSA_128S_PRIVKEY_LEN (64) bytes
* WARNING: Takes 5-30 seconds on ESP32.
* Returns 0 on success, -1 on error. */
int derive_slh_dsa_128s_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk);
/* --- ML-KEM-768 (post-quantum KEM, FIPS 203) --- */
/* Derives an ML-KEM-768 keypair from the mnemonic seed.
* Path: m/44'/102005'/<n>'/0'/0' -> 32-byte seed -> PQClean keygen
* pk: FW_ML_KEM_768_PUBKEY_LEN (1184) bytes — caller must allocate
* sk: FW_ML_KEM_768_PRIVKEY_LEN (2400) bytes — caller must allocate
* Returns 0 on success, -1 on error. */
int derive_ml_kem_768_key(const uint8_t seed[64], uint32_t index,
uint8_t *pk, uint8_t *sk);

View File

@@ -0,0 +1,171 @@
/* pq_crypto_firmware.c — Post-quantum crypto wrappers for ESP32 firmware.
*
* Wraps the PQClean algorithm API (via the pqclean component) with
* firmware-friendly functions that handle the deterministic DRBG setup
* for keygen and provide clean sign/verify/encaps/decaps interfaces.
*
* The PQ key buffers are large (ML-DSA-65 priv = 4032 bytes, SLH-DSA-128s
* sig = 7856 bytes). Callers must allocate these on the heap or as static
* buffers — stack allocation on ESP32 (8KB task stack default) will overflow
* for the larger buffers.
*/
#include "pq_crypto_firmware.h"
#include "pqclean.h"
#include <stdlib.h>
#include <string.h>
/* --- Key generation (deterministic from seed) --- */
int fw_pq_ml_dsa_65_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk) {
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
/* Initialize the deterministic DRBG with the mnemonic-derived seed */
pq_drbg_init(seed, 32);
/* Run PQClean keygen — randombytes() draws from the DRBG */
int ret = crypto_sign_keypair(pk, sk);
/* Wipe the DRBG state — the seed material is sensitive */
pq_drbg_zeroize();
return ret;
}
int fw_pq_slh_dsa_128s_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk) {
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
pq_drbg_init(seed, 32);
/* WARNING: This call takes 5-30 seconds on ESP32 due to the
* hypertree construction (7 layers of WOTS+ + Merkle trees). */
int ret = slh_dsa_128s_crypto_sign_keypair(pk, sk);
pq_drbg_zeroize();
return ret;
}
int fw_pq_ml_kem_768_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk) {
if (seed == NULL || pk == NULL || sk == NULL) {
return -1;
}
pq_drbg_init(seed, 32);
int ret = crypto_kem_keypair(pk, sk);
pq_drbg_zeroize();
return ret;
}
/* --- Signing --- */
int fw_pq_ml_dsa_65_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk) {
if (sig == NULL || siglen == NULL || m == NULL || sk == NULL) {
return -1;
}
return crypto_sign(sig, siglen, m, mlen, sk);
}
int fw_pq_ml_dsa_65_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk) {
if (sig == NULL || m == NULL || pk == NULL) {
return -1;
}
/* crypto_sign_open expects (m_out, mlen_out, sm, smlen, pk) where sm
* is the signed message. For detached signatures we reconstruct: the
* PQClean API uses crypto_sign_open with sm = sig || m. */
/* For firmware use, we provide a simple verify by re-signing is not
* possible (non-deterministic). The PQClean crypto_sign_open expects
* the concatenated sig||msg format. Callers should use the PQClean
* API directly for verification, or we build the sm buffer here. */
uint8_t *sm = (uint8_t *)malloc(siglen + mlen);
if (sm == NULL) {
return -1;
}
memcpy(sm, sig, siglen);
memcpy(sm + siglen, m, mlen);
uint8_t *m_out = (uint8_t *)malloc(mlen);
if (m_out == NULL) {
free(sm);
return -1;
}
size_t mlen_out = 0;
int ret = crypto_sign_open(m_out, &mlen_out, sm, siglen + mlen, pk);
free(sm);
free(m_out);
return ret;
}
int fw_pq_slh_dsa_128s_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk) {
if (sig == NULL || siglen == NULL || m == NULL || sk == NULL) {
return -1;
}
/* WARNING: This call takes 5-30 seconds on ESP32. */
return slh_dsa_128s_crypto_sign(sig, siglen, m, mlen, sk);
}
int fw_pq_slh_dsa_128s_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk) {
if (sig == NULL || m == NULL || pk == NULL) {
return -1;
}
uint8_t *sm = (uint8_t *)malloc(siglen + mlen);
if (sm == NULL) {
return -1;
}
memcpy(sm, sig, siglen);
memcpy(sm + siglen, m, mlen);
uint8_t *m_out = (uint8_t *)malloc(mlen);
if (m_out == NULL) {
free(sm);
return -1;
}
size_t mlen_out = 0;
int ret = slh_dsa_128s_crypto_sign_open(m_out, &mlen_out, sm,
siglen + mlen, pk);
free(sm);
free(m_out);
return ret;
}
/* --- KEM --- */
int fw_pq_ml_kem_768_encaps(uint8_t *ct, uint8_t *ss, const uint8_t *pk) {
if (ct == NULL || ss == NULL || pk == NULL) {
return -1;
}
/* encaps uses real randomness (hardware RNG) — the DRBG is not
* initialized, so randombytes() falls back to esp_fill_random(). */
return crypto_kem_enc(ct, ss, pk);
}
int fw_pq_ml_kem_768_decaps(uint8_t *ss, const uint8_t *ct, const uint8_t *sk) {
if (ss == NULL || ct == NULL || sk == NULL) {
return -1;
}
return crypto_kem_dec(ss, ct, sk);
}

View File

@@ -0,0 +1,110 @@
/* pq_crypto_firmware.h — Post-quantum crypto wrappers for ESP32 firmware.
*
* Provides firmware-friendly wrappers around the PQClean algorithms:
* - ML-DSA-65 (FIPS 204 signatures)
* - SLH-DSA-128s (FIPS 205 hash-based signatures)
* - ML-KEM-768 (FIPS 203 key encapsulation)
*
* Key generation is deterministic from a 32-byte seed (derived from the
* mnemonic via BIP-32/HMAC-SHA512). The seed feeds the deterministic DRBG
* (pq_drbg_firmware.c) which replaces PQClean's randombytes() during keygen.
*
* ed25519 and x25519 are handled separately via mbedtls (see
* key_derivation.c) and are not part of this PQClean component.
*/
#ifndef FIRMWARE_PQ_CRYPTO_H
#define FIRMWARE_PQ_CRYPTO_H
#include <stddef.h>
#include <stdint.h>
/* --- Algorithm identifiers --- */
typedef enum {
FW_PQ_ALG_ML_DSA_65 = 0,
FW_PQ_ALG_SLH_DSA_128S,
FW_PQ_ALG_ML_KEM_768,
FW_PQ_ALG_UNKNOWN
} fw_pq_alg_t;
/* --- Key sizes (compile-time constants, matching PQClean api.h) --- */
#define FW_ML_DSA_65_PUBKEY_LEN 1952
#define FW_ML_DSA_65_PRIVKEY_LEN 4032
#define FW_ML_DSA_65_SIG_LEN 3309
#define FW_SLH_DSA_128S_PUBKEY_LEN 32
#define FW_SLH_DSA_128S_PRIVKEY_LEN 64
#define FW_SLH_DSA_128S_SIG_LEN 7856
#define FW_ML_KEM_768_PUBKEY_LEN 1184
#define FW_ML_KEM_768_PRIVKEY_LEN 2400
#define FW_ML_KEM_768_CIPHERTEXT_LEN 1088
#define FW_ML_KEM_768_SHARED_SECRET_LEN 32
/* --- Key generation (deterministic from seed) --- */
/* Generate an ML-DSA-65 keypair from a 32-byte seed.
* pk must be at least FW_ML_DSA_65_PUBKEY_LEN bytes.
* sk must be at least FW_ML_DSA_65_PRIVKEY_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_dsa_65_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk);
/* Generate an SLH-DSA-128s keypair from a 32-byte seed.
* pk must be at least FW_SLH_DSA_128S_PUBKEY_LEN bytes.
* sk must be at least FW_SLH_DSA_128S_PRIVKEY_LEN bytes.
* Returns 0 on success, -1 on error.
* WARNING: SLH-DSA-128s keygen takes 5-30 seconds on ESP32. */
int fw_pq_slh_dsa_128s_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk);
/* Generate an ML-KEM-768 keypair from a 32-byte seed.
* pk must be at least FW_ML_KEM_768_PUBKEY_LEN bytes.
* sk must be at least FW_ML_KEM_768_PRIVKEY_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_kem_768_keygen(const uint8_t seed[32],
uint8_t *pk, uint8_t *sk);
/* --- Signing (ML-DSA-65, SLH-DSA-128s) --- */
/* Sign a message with ML-DSA-65.
* sig must be at least FW_ML_DSA_65_SIG_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_dsa_65_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk);
/* Verify an ML-DSA-65 signature.
* Returns 0 on valid, -1 on invalid. */
int fw_pq_ml_dsa_65_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk);
/* Sign a message with SLH-DSA-128s.
* sig must be at least FW_SLH_DSA_128S_SIG_LEN bytes.
* WARNING: SLH-DSA-128s signing takes 5-30 seconds on ESP32.
* Returns 0 on success, -1 on error. */
int fw_pq_slh_dsa_128s_sign(uint8_t *sig, size_t *siglen,
const uint8_t *m, size_t mlen,
const uint8_t *sk);
/* Verify an SLH-DSA-128s signature.
* Returns 0 on valid, -1 on invalid. */
int fw_pq_slh_dsa_128s_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *pk);
/* --- KEM (ML-KEM-768) --- */
/* Encapsulate: generate ciphertext + shared secret from a public key.
* Uses real randomness (ESP32 hardware RNG) — not the deterministic DRBG.
* ct must be at least FW_ML_KEM_768_CIPHERTEXT_LEN bytes.
* ss must be at least FW_ML_KEM_768_SHARED_SECRET_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_kem_768_encaps(uint8_t *ct, uint8_t *ss, const uint8_t *pk);
/* Decapsulate: recover shared secret from secret key + ciphertext.
* ss must be at least FW_ML_KEM_768_SHARED_SECRET_LEN bytes.
* Returns 0 on success, -1 on error. */
int fw_pq_ml_kem_768_decaps(uint8_t *ss, const uint8_t *ct, const uint8_t *sk);
#endif /* FIRMWARE_PQ_CRYPTO_H */

View File

@@ -0,0 +1,192 @@
# n_signer FPGA Signing Core
**Status:** Concept — brainstorming. No plan yet.
An FPGA-based secp256k1 signing core that provides the **maximum physical
security** possible for Nostr signing: constant-time crypto (no instruction
timing leakage), key material in FPGA fabric (no bus access to the key), and no
firmware (no malware injection surface). The FPGA is a **signing oracle** — a
small, auditable hardware module that does one thing (secp256k1 schnorr/ECDSA
signing) with side-channel resistance that software on an MCU cannot match.
## The secure element gap
Commercial secure elements (NXP JCOP, Infineon, Microchip ATECC) support NIST
curves (P-256, P-384) and RSA, but **not secp256k1** — the smart card industry
standardized on NIST curves, and secp256k1 was treated as a "Bitcoin curve"
that didn't get hardware support. This is why every Nostr/Bitcoin hardware
wallet (Coldcard, Ledger, Trezor, Keystone) uses a **general-purpose MCU**
running software secp256k1, not a secure element.
An FPGA fills this gap: it gives us **hardware-level secp256k1** without relying
on a secure element vendor to support the curve. We write the secp256k1 core
ourselves in Verilog, with full control over the timing, the key storage, and
the side-channel resistance.
## Why an FPGA
| Property | MCU (software) | FPGA (hardware) |
|---|---|---|
| Timing leakage | Branch prediction, cache, instruction timing | **None** — fixed datapath, every op takes the same cycles |
| Key storage | RAM (accessible via bus/debug) | **FPGA fabric / BRAM** (no external bus access) |
| Firmware attacks | OS, USB stack, BT stack = injection surface | **No firmware** — bitstream is the entire program |
| Debug access | JTAG/SWD can read RAM | **No debug path to key** if not routed |
| Auditability | Large codebase (thousands of lines of C) | **Small Verilog core** (~2000 lines, auditable) |
| PQ crypto | Yes (software) | No (too complex for FPGA) |
## Architecture: hybrid FPGA + MCU
The practical design is a **two-chip hybrid**: the FPGA is the signing oracle,
the MCU handles the protocol/UI/transport. The MCU sends a message hash + key
index to the FPGA over SPI; the FPGA signs with the key in fabric; the FPGA
returns the 64-byte signature. The private key never leaves the FPGA.
```mermaid
flowchart TD
subgraph Host_Side
Host[Host: laptop/phone<br/>n_signer client]
end
subgraph Signer_Device
MCU[MCU: RP2040 or nRF52840<br/>protocol + UI + transport<br/>PQ crypto in software]
FPGA[FPGA: iCE40-UP5K<br/>secp256k1 signing core<br/>ed25519 signing core<br/>SHA-256/512 cores<br/>key in BRAM]
Display[OLED / e-paper display]
Buttons[approve / deny buttons]
end
Host -->|USB / IR / NFC / BLE| MCU
MCU -->|SPI: msg_hash + key_index| FPGA
FPGA -->|SPI: 64-byte signature| MCU
MCU --> Display
Buttons --> MCU
MCU -->|response| Host
```
### Division of labor
| Function | Chip | Notes |
|---|---|---|
| Transport (USB/IR/NFC/BLE) | MCU | Ported from CYD/Teensy firmware |
| JSON-RPC dispatch | MCU | Ported from `handle_request()` |
| Auth envelope verify | MCU | secp256k1 schnorr verify (software) |
| Approval UI (display + buttons) | MCU | Ported from CYD UI |
| Mnemonic entry | MCU | BIP-39 wordlist + entry UI |
| BIP-32 / SLIP-0010 key derivation | **FPGA** | SHA-512 HMAC core + derivation FSM |
| secp256k1 schnorr sign | **FPGA** | Constant-time scalar multiply + schnorr |
| secp256k1 ECDSA sign | **FPGA** | Same scalar multiply + RFC 6979 nonce |
| ed25519 sign | **FPGA** | Curve25519 arithmetic core |
| x25519 ECDH | **FPGA** | Same curve as ed25519 |
| SHA-256 | **FPGA** | Hardware core (~1000 LUTs) |
| SHA-512 | **FPGA** | Hardware core (~2000 LUTs) — needed for BIP-32 |
| HMAC-SHA-256 | **FPGA** | SHA-256 core + FSM — for the `derive` verb |
| NIP-04 / NIP-44 encryption | MCU | AES + ChaCha20 in software |
| ML-DSA-65 / SLH-DSA-128s / ML-KEM-768 | MCU | PQClean in software (too complex for FPGA) |
| nostr_mine_event (PoW) | MCU | SHA-256 hash loop in software (or offload to FPGA) |
### Key storage in the FPGA
The mnemonic seed (64 bytes) is loaded into the FPGA's BRAM at boot (sent by
the MCU after the user enters the mnemonic). The FPGA derives secp256k1/ed25519/
x25519 keys on demand using its SHA-512 + modular arithmetic cores. The derived
private keys live in FPGA registers/BRAM and are **never readable from the SPI
interface** — the SPI interface only accepts "sign this hash with key index N"
commands and returns signatures. There is no "read key" command.
This is the key security property: **the private key is physically unreachable
from any external interface.** On an MCU, the key is in RAM and can be read via
JTAG/SWD or a firmware exploit. On the FPGA, the key is in fabric and there is
no path to it.
## FPGA board options
| Board | FPGA | LUTs | Toolchain | Price | Notes |
|---|---|---|---|---|---|
| **iCE40-UP5K** (e.g. iCEBreaker, Fomu) | iCE40UP5K | 5,300 | **Yosys + nextpnr** (open-source) | ~$15-20 | Best DIY choice. Open-source toolchain, DSP blocks, 128 KB BRAM, SPI flash. |
| **Gowin Tang Nano 9K** | GW1NR-9 | 8,640 | Yosys + nextpnr (open-source) | ~$8 | Cheapest. Newer open-source support. |
| **Lattice ECP5** (OrangeCrab, ULX3S) | LFE5U-12F / 25F / 45F | 12K-45K | Yosys + nextpnr (open-source) | ~$30-50 | More room. Supports **bitstream encryption** (important for production). |
| **Xilinx Artix-7** (Arty A7) | XC7A35T | 33,280 | Vivado (proprietary) | ~$100 | Professional. Overkill for a signer. |
**Recommendation: Lattice iCE40-UP5K for prototyping, ECP5 for production.**
The iCE40 has the most mature open-source toolchain (Yosys + nextpnr) and is
cheap. The ECP5 adds bitstream encryption (prevents bitstream cloning) for a
production device.
## The secp256k1 core (the hard part)
The secp256k1 signing core is the main development effort. It needs:
1. **256-bit modular arithmetic** over the secp256k1 prime field (p = 2²⁵⁶ - 2³² - 977):
- Modular add, subtract, multiply (Montgomery multiplication for performance)
- Modular inversion (Fermat's little theorem: a^(p-2) mod p, or extended Euclidean)
2. **Point operations** on the secp256k1 curve (y² = x³ + 7):
- Point addition (Jacobian coordinates)
- Point doubling
- Scalar multiplication (constant-time double-and-add, no conditional branches)
3. **Schnorr sign** (BIP-340):
- Deterministic nonce: k = HMAC-SHA256(d, x) where d is the key, x is the message hash
- R = k·G (point multiplication)
- e = tagged hash(R.x || P || m) (SHA-256)
- s = (k + e·x) mod n (scalar multiply + modular add)
- Signature = (R.x, s)
4. **ECDSA sign** (for the `scheme:"ecdsa"` option):
- RFC 6979 deterministic nonce: k = HMAC-SHA256(x, m) with rejection sampling
- R = k·G
- r = R.x mod n
- s = k⁻¹ · (m + r·x) mod n
- Signature = (r, s)
**Estimated size:** ~2000-5000 LUTs for the modular arithmetic + point
multiplication + schnorr/ECDSA FSM. Fits comfortably in an iCE40-UP5K (5,300
LUTs) alongside the SHA-256/512 cores and the SPI interface.
**Estimated sign time:** ~1-5 ms at 12 MHz (iCE40-UP5K typical clock). The
bottleneck is the 256-bit modular multiplication (~0.5-2 ms per multiply, ~256
multiplies per scalar multiplication). This is **much faster than software**
(the CYD's software schnorr sign takes ~10-50 ms).
## Open questions
- **Pure FPGA vs hybrid?** A pure-FPGA signer (no MCU) would implement the
entire dispatch + transport + UI in Verilog. This is extremely secure but
very hard to build (JSON parsing in Verilog is painful). The hybrid (FPGA
signing oracle + MCU protocol) is practical and still gives the key-isolation
benefit. **Lean toward hybrid.**
- **Which MCU?** RP2040 (cheap, no WiFi) or nRF52840 (low power, NFC, BLE)?
This determines the transport options (USB, IR, NFC, BLE).
- **Bitstream security:** iCE40 doesn't support bitstream encryption. If an
attacker reads the SPI flash, they get the bitstream (but not the key — the
key is loaded at runtime by the MCU, not stored in the bitstream). For
production, use ECP5 with bitstream encryption.
- **Key loading:** the MCU sends the mnemonic seed to the FPGA at boot over
SPI. Is this SPI transfer vulnerable to sniffing? It happens once, at boot,
inside the device. If the device is physically sealed, the SPI bus is not
accessible. For higher security, the FPGA could derive the key from the
mnemonic internally (the MCU sends the mnemonic string, the FPGA does
PBKDF2-HMAC-SHA512 + BIP-32 derivation in hardware).
- **ed25519 core:** worth implementing, or secp256k1-only? ed25519 is the same
field size (256-bit) but a different curve (Curve25519 vs secp256k1). The
modular arithmetic is similar but the curve operations differ. Adding
ed25519 roughly doubles the core size.
- **Open-source secp256k1 FPGA cores:** are there existing Verilog secp256k1
cores we can reuse or adapt? (There are Bitcoin mining cores, but those do
double-SHA256, not ECDSA. Academic ECDSA-on-FPGA papers exist but the code
is rarely open-sourced. We'd likely write the core from scratch.)
## Comparison to the other concepts
| | FPGA signer (hybrid) | MCU-only (CYD/Teensy) | Secure element |
|---|---|---|---|
| secp256k1 side-channel resistance | **Best** (constant-time, key in fabric) | Medium (software, timing leakage) | N/A (no secp256k1 support) |
| Key isolation | **Best** (no bus access to key) | Low (RAM, JTAG/SWD accessible) | Best (tamper-resistant) |
| Firmware attack surface | **Minimal** (no firmware on FPGA) | Large (OS, USB, BT stacks) | Minimal (fixed function) |
| PQ crypto | On MCU (software) | On MCU (software) | N/A |
| Development effort | **High** (Verilog secp256k1 core) | Low (port existing C code) | High (NDA + Java Card) |
| Cost | ~$15 FPGA + ~$4 MCU = ~$20 | ~$4-27 (MCU only) | ~$3-5 (chip only) |
| Auditability | **High** (small Verilog core, open toolchain) | Medium (large C codebase) | Low (proprietary, NDA) |
## Next steps
- Decide: hybrid (FPGA + MCU) vs pure FPGA
- Decide: iCE40-UP5K (prototype) vs ECP5 (production)
- Decide: secp256k1-only vs secp256k1 + ed25519
- Survey existing open-source secp256k1 / ECDSA FPGA cores
- Write a Verilog secp256k1 modular arithmetic core (the foundation)
- Write a plan (similar to [`plans/teensy41_signer_port.md`](../../plans/teensy41_signer_port.md))

View File

@@ -0,0 +1,149 @@
# n_signer IR Air-Gap Signer
**Status:** Concept — brainstorming. No plan yet.
A hardware signer that communicates with the host via **infrared light**
line-of-sight, short-range, physically directional. The signer never touches
the host electrically: no wire, no radio, no shared ground. The only channel
is modulated light through air. A small **USB receiver dongle** on the host
decodes the IR signal and presents it as a CDC-ACM serial port.
This is the strongest air-gap model in the n_signer family: the signer is
electrically isolated from the host, and the receiver dongle is a dumb
IR-to-serial bridge with no crypto, no keys, and ~200 lines of auditable
firmware.
## Concept
```mermaid
flowchart LR
Host[Host: laptop<br/>n_signer client] -->|USB CDC| Dongle[USB IR receiver dongle<br/>RP2040 + IR receiver]
Dongle -->|IR light<br/>line-of-sight| Signer[IR signer<br/>RP2040 + OLED + buttons]
Signer -->|approve/deny button| User[User]
Signer -->|IR light response| Dongle
Dongle -->|USB CDC| Host
```
The signer speaks the same algorithm-based API as the host and the CYD/Teensy
firmware ([`README.md`](../../README.md) §4). The auth envelope (kind 27235)
protects the IR wire. The receiver dongle is a transparent byte pipe — it has
no knowledge of the protocol, no keys, and no state beyond the IR-to-USB
bridge.
## Why IR
- **True air-gap** — the signer is electrically isolated from the host. No wire,
no radio, no shared ground. Host-side malware cannot reach the signer's
firmware through the communication channel.
- **Line-of-sight required** — you point the signer at the receiver. An attacker
would need to be in the same room, in the line of sight, with their own IR
transmitter. Much smaller attack surface than BT (which broadcasts
omnidirectionally to ~10 m).
- **Dumb dongle** — the USB receiver is a simple IR-to-serial bridge. ~200
lines of firmware, no crypto, no keys, fully auditable in an afternoon. If
compromised, it can only MITM the IR stream (which is already protected by the
auth envelope).
- **No BT stack** — much smaller firmware attack surface on the signer. No
pairing, no GATT, no L2CAP, no SMP.
- **Novel** — no hardware wallet uses IR for host communication. It's a
creative solution to the air-gap problem that avoids both the wire (USB) and
the radio (BT/NFC) attack surfaces.
## Hardware (preliminary)
### Signer
| Component | Candidate | Notes |
|---|---|---|
| MCU | **RP2040** (Raspberry Pi Pico) | $4, Cortex-M0+ @ 133 MHz, 264 KB SRAM, no WiFi/BT (perfect for air-gap). Enough RAM for secp256k1 + ed25519. ML-DSA-65 fits (~6 KB heap). |
| | or **nRF52840** | If you want NFC for mnemonic loading + lower power. |
| IR transceiver | **38 kHz IR LED + TSOP38238** (raw async, 115200 baud, ~$1) | Simplest. ~11 KB/s. Fine for Nostr events (~500 bytes). Slow for PQ sigs (3-8 KB → 0.3-0.7 s). |
| | or **TFBS4711 IrDA module** (~$2, up to 4 Mbps) | Faster (~400 KB/s) but harder to source + more complex protocol. |
| Display | 0.96" SSD1306 OLED (I2C, ~$2) or 1.54" e-paper | Small is fine — shows "approve kind 1 from <caller>?" |
| Input | 2-3 tactile buttons (approve/deny/back) | |
| Power | Coin cell or small LiPo | RP2040 + OLED + IR = very low power |
### USB receiver dongle
| Component | Candidate | Notes |
|---|---|---|
| MCU | **RP2040** (Pico) or **ATmega32U4** (Arduino Micro) | $4-8. Native USB device. |
| IR receiver | Matching TSOP38238 or IrDA module | Must match the signer's IR modulation. |
| USB | Native USB CDC-ACM | Presents as `/dev/ttyACM0` to the host. |
| Firmware | ~200 lines | Read IR → write USB CDC; read USB CDC → transmit IR. A dumb pipe. No crypto, no keys, no state. |
## Throughput
| IR mode | Baud | Throughput | sign_event (500 B req + 600 B resp) | ML-DSA-65 sign (3.3 KB sig) |
|---|---|---|---|---|
| Raw 38 kHz async | 115200 | ~11 KB/s | ~100 ms | ~300 ms |
| Raw 38 kHz async | 230400 | ~23 KB/s | ~50 ms | ~150 ms |
| IrDA | 4 Mbps | ~400 KB/s | ~3 ms | ~8 ms |
**Recommendation:** start with raw 38 kHz IR at 115200 baud (simplest, cheapest,
works with any IR LED + TSOP receiver). Upgrade to 230400 or IrDA if PQ
signature throughput is a bottleneck.
## Protocol
The IR link is **half-duplex** — the signer and receiver take turns
transmitting. The protocol is simple:
1. Host sends JSON-RPC request → USB CDC → dongle transmits IR.
2. Signer receives IR, parses the request, shows approval prompt.
3. User approves/denies.
4. Signer transmits IR response → dongle → USB CDC → host.
The 4-byte big-endian length-prefix framing (same as the CYD/feather) works
over IR as-is. The auth envelope protects against MITM on the IR stream.
## Security model
- **Electrical isolation:** the signer has no electrical connection to the host.
The IR link is a one-way-at-a-time optical channel.
- **Line-of-sight:** an attacker must be in the same room, in the line of sight,
with their own IR transmitter. The auth envelope + approval prompt protect
against a MITM even if the attacker intercepts the IR stream.
- **Dumb dongle:** the USB receiver has no crypto, no keys, no protocol
knowledge. It's a byte pipe. If compromised, it can only MITM the IR stream
(already protected by the auth envelope). The dongle's firmware is small
enough to audit in an afternoon.
- **No radio:** no BT, no WiFi, no NFC (unless you add NFC for mnemonic loading).
The signer emits no RF — only modulated IR light when actively transmitting.
## Open questions
- **IR modulation:** raw 38 kHz async (simplest) vs IrDA (faster, more complex)?
- **Mnemonic entry:** buttons (scroll BIP-39 words) vs NFC from phone vs
generate-on-device? On a 0.96" OLED, scrolling 2048 words is tedious but
secure.
- **PQ crypto on RP2040:** 264 KB SRAM is enough for ML-DSA-65 but SLH-DSA-128s
is tight. May need to limit the PQ algorithm set.
- **Dongle design:** separate RP2040 Pico, or integrate the IR receiver into a
custom PCB with a USB-A plug for a compact dongle?
- **Range:** raw IR with an IR LED + TSOP38238 reaches ~1-2 m line-of-sight.
Enough for "point at the dongle on your desk" but not across a room.
- **Bidirectional IR:** the signer needs both an IR LED (transmit) and a TSOP
receiver (receive). Two modules, or an IrDA transceiver module that does both?
## Comparison to the BLE wearable signer
| | IR air-gap | BLE wearable |
|---|---|---|
| Air-gap | **High (light, line-of-sight, ~1 m)** | Medium (radio, ~10 m, omnidirectional) |
| Attack surface | **Small (no BT, dumb dongle)** | Large (BT stack) |
| Host compatibility | Requires USB dongle | Universal (phones, laptops) |
| Form factor | Handheld (point at dongle) | Wearable |
| Throughput | ~11 KB/s (raw IR) or ~400 KB/s (IrDA) | ~250 KB/s (BLE 5) |
| Novelty | **Novel (no hardware wallet uses IR)** | Conventional |
| Cost | ~$10 signer + ~$8 dongle | ~$10-15 (nRF52840 + OLED) |
## Next steps
- Decide on IR modulation (raw 38 kHz vs IrDA)
- Decide on MCU (RP2040 vs nRF52840)
- Decide on mnemonic entry method
- Decide on display (OLED vs e-paper)
- Prototype the IR link: two RP2040 Picos + IR LEDs + TSOP38238, bidirectional
byte pipe at 115200 baud
- Write a port plan (similar to [`plans/teensy41_signer_port.md`](../../plans/teensy41_signer_port.md))

View File

@@ -0,0 +1,192 @@
# n_signer NFC Card / Ring Signer
**Status:** Concept — open-ended brainstorming. No plan yet.
A contactless signer in a **card or ring form factor** that is powered and
communicates via **NFC (13.56 MHz)**. You place it on a reader (USB NFC reader
or a phone); the reader's RF field powers the device and exchanges data. No
battery, no wire, no radio beyond the 4 cm NFC zone.
This directory also explores **passive RFID/NFC tag ideas** that don't do
signing on-device — they store keys or seed material that a host reads and uses.
---
## Concept A: NFC-powered active signer (card with display + button)
```mermaid
flowchart LR
Host[Host: laptop/phone<br/>n_signer client] -->|USB or built-in NFC| Reader[NFC reader<br/>ACR122U or phone]
Reader -->|13.56 MHz RF field<br/>powers + communicates| Card[Signer card<br/>nRF52840 + e-paper + button]
Card -->|NFC response| Reader
Reader -->|USB| Host
```
The card has a tiny e-paper display + one button. The reader powers the card;
the card shows the approval prompt on its own display; the user presses the
button to approve; the card signs and sends the response over NFC. The card
does not trust the reader for display — it shows what it's signing.
### Hardware (preliminary)
| Component | Candidate | Notes |
|---|---|---|
| MCU | **nRF52840** (WLCSP) | Cortex-M4, NFC-A tag mode built in, secp256k1/ed25519 in software. Needs a thin-film battery (not fully passive). |
| | or **NXP JCOP 4** (Java Card) | Fully passive, hardware secp256k1, tamper-resistant. Requires NDA + Java Card applet. Not DIY. |
| Display | 1.1" e-paper segment display | Shows "approve kind 1? caller: <hex>". Zero power when static. |
| Input | One capacitive touch button | Press to approve, timeout = deny. |
| Power | Thin-film battery (like payment cards with displays) + NFC harvesting | |
| Antenna | Etched into flex PCB around card perimeter | Standard smart card manufacturing. |
### Security
- **Attack radius ~4 cm** — an attacker must touch your card with their reader.
- **No emissions when not on a reader** — the card is invisible to remote attackers.
- **Self-contained approval** — the card's display shows what it's signing. The reader can't lie.
- **Physical possession = authorization** — same model as a payment card.
### Build difficulty
- **DIY prototype:** nRF52840 dev board + wire-wound NFC antenna + ACR122U reader + small OLED. Prove NFC-powered signing works.
- **Production:** custom flex PCB + etched antenna + thin battery + e-paper segment. Standard smart card manufacturing, but not DIY.
---
## Concept B: NFC ring (tap-to-sign, no display)
A ring with an NFC tag + MCU inside. No display, no button. You tap it on a
reader; the reader displays the approval prompt; you tap again to confirm
(two-tap protocol) or the ring signs immediately (single-tap, trusts the reader).
### Hardware
| Component | Candidate | Notes |
|---|---|---|
| MCU | Secure element (JCOP / Infineon) or nRF52840 (WLCSP) | Must be tiny (2×3 mm package). |
| Power | **Fully passive** (harvested from reader) if using a secure element. nRF52840 needs a battery. |
| Antenna | Coil wound into the ring body | Custom manufacturing. |
| Display | **None** | No room. |
| Input | **None** | Pure tap-to-sign. |
### Security
- **Two-tap protocol:** tap to receive the request, reader displays it, tap again to sign. Forces deliberate action but still trusts the reader's display.
- **Single-tap:** anyone who taps your ring with a reader can sign. Only safe if the ring is always in your physical possession and the reader is trusted.
- **No display = reader-trusted approval.** Weaker than Concept A but much more portable.
### Build difficulty
- **Very hard** — custom antenna winding, tiny chip placement, ring-form-factor PCB. Not DIY without specialized equipment.
---
## Concept C: Passive NFC tag that stores keys (no on-device signing)
This is a fundamentally different idea: the tag **does not sign anything**. It
stores key material (a mnemonic seed, a private key, or a derived key) that a
host reads over NFC and uses to sign. The tag is a **portable key storage
device**, not a signer.
### How it would work
1. The user taps the tag on a phone or USB NFC reader.
2. The host reads the stored key material over NFC (ISO 14443 / NDEF).
3. The host uses the key material to sign (the host runs the n_signer crypto).
4. The tag is just storage — it has no MCU, no crypto, no battery.
### What the tag stores (options)
| Storage model | What's on the tag | Security | Notes |
|---|---|---|---|
| **Encrypted seed** | The mnemonic seed, encrypted with a passphrase (BIP-39 password). The host decrypts after the user types the passphrase. | Medium — if the tag is stolen, the attacker needs the passphrase. | Like an encrypted paper backup, but in NFC form. |
| **Raw private key** | The secp256k1 private key (32 bytes), stored in the tag's EEPROM. | **Low** — anyone who reads the tag has the key. Only safe if the tag is PIN-protected (needs a secure element, not a dumb tag). | Like storing a private key on a USB stick. |
| **NDEF URI** | A URI like `nostr:npub1...` (just the public key). The host uses it to identify which key to use (the actual private key is elsewhere). | High (it's just a pubkey) | Not a signer — just an identity token. |
| **Shamir shard** | One share of a Shamir's Secret Sharing split of the seed. The tag holds 1 of N shares; you need M tags to reconstruct. | **High** — a single tag is useless. | Like a metal seed backup but in NFC form. Multiple tags = multiple shares. |
| **HD wallet derivation path** | Just the derivation path + a reference to a master seed stored elsewhere. The tag tells the host *which* key to derive. | Medium | The tag is a pointer, not the key itself. |
### Hardware
| Component | Candidate | Notes |
|---|---|---|
| **NTAG215** (NXP) | 504 bytes user memory, no crypto, ~$0.10 | The cheapest option. Stores an encrypted seed or NDEF URI. No MCU. |
| **NTAG424 DNA** (NXP) | 4 KB, AES-128, tamper detection, ~$0.50 | Has crypto — can do authenticated read (the host must present a key to read the data). Better security. |
| **MIFARE DESFire EV3** | 32 KB, AES, secure applets, ~$1 | Smart card chip. Can store encrypted key material with PIN/mutual auth. |
| **Java Card (JCOP)** | Full smart card, runs applets, ~$3-5 | Could run a "key storage" applet that only releases the seed after a PIN is verified on the host. |
### Security analysis
The **passive tag as key storage** is the weakest signer model (the host does
the signing, so a compromised host can steal the key), but it has interesting
niche uses:
- **Encrypted seed backup:** an NTAG215 storing an encrypted seed is a
convenient portable backup — tap your phone to read it, type the passphrase
to decrypt. More convenient than a metal plate, less secure than a hardware
signer.
- **Shamir shard carrier:** each tag holds one SSS share. You need M of N tags
to reconstruct the seed. Distribute the tags to different locations/people.
A single stolen tag is useless. This is a **key-recovery** tool, not a signer.
- **Identity token:** an NDEF URI tag with your npub. Tap to share your Nostr
identity with a phone. Not a signer — just a business card for Nostr.
- **PIN-protected key release:** a DESFire or JCOP tag that only releases the
seed after the host verifies a PIN. The host never sees the key until the PIN
is correct. Better than a raw tag, but the host still gets the key after the
PIN — so a compromised host can still steal it.
### The fundamental limitation
A passive tag that stores keys **cannot protect the key from a compromised
host**. Once the host reads the key, the host has it. This is the same problem
as storing a private key in a file — the OS can steal it. The only way to
protect the key from the host is to **never release the raw key** — which means
the tag must do the signing itself (Concept A or B), or the tag must participate
in a protocol where the host sends a hash to sign and the tag returns a
signature (which requires an MCU + crypto = not a passive tag).
**The one exception:** a **secure element** (JCOP / Infineon) can do
"sign inside, never release the key." The host sends the message hash; the
secure element signs it internally and returns the signature. The private key
never leaves the chip. This is how smart card signing works (e.g. FIDO2 keys,
PIV cards). But this is Concept A (active signer), not a passive tag.
---
## Open questions (all concepts)
- **Is the goal a signer (does crypto on-device) or a key carrier (stores keys for a host to use)?**
- Signer → Concept A (card with display) or B (ring). The key never leaves the device.
- Key carrier → Concept C (passive tag). The host gets the key. Simpler but less secure.
- **Form factor:** card (credit card size, room for display) vs ring (tiny, no display)?
- **Power:** passive (secure element, no battery) vs semi-passive (nRF52840 + thin battery)?
- **Approval model:** on-device display (secure, needs a screen) vs reader display (trusts the reader, no screen needed) vs two-tap (medium security, no screen)?
- **Host reader:** USB NFC reader (ACR122U, ~$15) vs phone NFC (universal, no dongle)?
- **MCU/toolchain:** nRF52840 + C (DIY-friendly) vs JCOP + Java Card (production, NDA)?
- **Could a passive tag + a host-side n_signer be a useful "portable encrypted seed backup" even if it's not a signer?** (Yes — for key recovery / Shamir shard distribution.)
---
## Comparison across all three concepts (NFC, BLE, IR)
| | NFC card (A) | NFC ring (B) | NFC tag (C) | BLE wearable | IR air-gap |
|---|---|---|---|---|---|
| Does signing on-device? | **Yes** | **Yes** | No (host signs) | Yes | Yes |
| Key leaves device? | **No** | **No** | Yes (host reads it) | No | No |
| Power | Reader + thin battery | Reader (passive) or battery | **Reader (passive)** | Battery | Battery |
| Display | Tiny e-paper | None | None | Tiny OLED | Tiny OLED/e-paper |
| Approval | On-card display + button | Reader display (two-tap) | N/A (host decides) | On-device display + buttons | On-device display + buttons |
| Attack radius | ~4 cm | ~4 cm | ~4 cm | ~10 m | ~1 m (line-of-sight) |
| Host needs | NFC reader or phone NFC | NFC reader or phone NFC | NFC reader or phone NFC | BT (universal) | USB IR dongle |
| Form factor | Card | Ring | Tag/card/sticker | Wristband/pendant | Handheld |
| Build difficulty | Hard (flex PCB) | Very hard (custom ring) | **Easy (off-the-shelf tag)** | Moderate | Moderate |
| Best for | Daily signing | Quick tap-to-sign | Key backup / recovery | Wearable daily use | High-security air-gap |
---
## Next steps
- Decide: signer (A/B) vs key carrier (C) vs both
- Decide: card vs ring vs tag
- Decide: DIY prototype (nRF52840 + ACR122U) vs production (secure element)
- Explore the Shamir-shard-on-NFC-tags idea as a key-recovery tool
- Explore the encrypted-seed-on-NTAG215 idea as a portable backup
- Write a plan for the chosen direction

View File

@@ -0,0 +1,60 @@
# n_signer Teensy 4.1 Firmware
**Status:** Planned — not yet implemented. See
[`plans/teensy41_signer_port.md`](../../plans/teensy41_signer_port.md).
The **Teensy 4.1** (NXP i.MX RT1062, Cortex-M7 @ 600 MHz) is the high-capacity
OTP pad target. Its built-in SD slot supports **1 TB SDXC cards (exFAT)** via
PJRC's SdFat library, making it the ideal hardware signer for large one-time-pad
storage.
## Why the Teensy 4.1
| Concern | Teensy 4.1 | CYD (ESP32) | Feather S3 TFT (ESP32-S3) |
|---|---|---|---|
| MCU | 600 MHz Cortex-M7 | 240 MHz Xtensa LX6 | 240 MHz Xtensa LX7 |
| SRAM | 1 MB + 16 MB PSRAM | 512 KB (no PSRAM) | 512 KB + quad PSRAM |
| SD slot | **4-bit SDMMC, exFAT, up to 2 TB** | 1-bit SDSPI, FAT32, up to 32 GB | — |
| USB | Hi-Speed (480 Mbps) device + host | CH340 UART only | Full-Speed USB |
| WiFi | **None** | Yes (unused) | Yes (unused) |
| Ethernet | 10/100 PHY (optional) | — | — |
| PQ crypto speed | ~1-2 s SLH-DSA-128s | 5-30 s SLH-DSA-128s | 5-30 s SLH-DSA-128s |
## SD card size support
| Card type | Size | Works? |
|---|---|---|
| SDSC | ≤ 2 GB | Yes (FAT16/32) |
| SDHC | 2 32 GB | Yes (FAT32) |
| **SDXC** | **32 GB 2 TB** | **Yes (exFAT via SdFat)** |
| SDUC | 2 128 TB | No |
**1 TB SDXC cards work** — SdFat has native exFAT support, and the Teensy's
4-bit SDMMC bus runs at ~20-40 MB/s. No reformatting needed.
## Display + touch
**4.0" ST7796S 480×320 SPI TFT with XPT2046 resistive touch** (Hosyond or
equivalent, ~$12-15). Specs: 4-wire SPI, RGB 65K, 3.3V~5V (works at the Teensy's
3.3V logic), XPT2046 resistive touch, includes touch pen + on-module SD slot.
Resistive touch is the right choice for a hardware signer (deliberate physical
activation, stylus-compatible, simple driver). The XPT2046 driver ports from
the CYD's [`touch.c`](../cyd_esp32_2432s028/main/touch.c) with 480×320
resolution constants. The ST7796S display driver is new code (different init
sequence than the CYD's ILI9341).
Wiring: TFT SPI on pins 11/12/13, CS=10, DC=9, RESET=8, BL=22; touch shares
the SPI bus with T_CS=7, T_IRQ=6. See
[`plans/teensy41_signer_port.md`](../../plans/teensy41_signer_port.md) for the
full pin table.
## Build (planned)
```bash
# Arduino CLI / Teensyduino
arduino-cli compile --fqbn teensy:avr:teensy41 firmware/teensy41
arduino-cli upload -p /dev/ttyACM0 --fqbn teensy:avr:teensy41 firmware/teensy41
```
See the port plan: [`plans/teensy41_signer_port.md`](../../plans/teensy41_signer_port.md).

View File

@@ -181,10 +181,12 @@ build_release_binary() {
# Prevent stale artifacts from previous builds being uploaded. # Prevent stale artifacts from previous builds being uploaded.
rm -f build/nsigner_static_x86_64 build/nsigner_static_arm64 rm -f build/nsigner_static_x86_64 build/nsigner_static_arm64
./build_static.sh > /dev/null 2>&1 || return 1 print_status "Building x86_64 static binary (this may take a few minutes with PQ algorithms)..."
./build_static.sh 2>&1 | tail -5 || return 1
verify_binary_version "build/nsigner_static_x86_64" "$NEW_VERSION" || return 1 verify_binary_version "build/nsigner_static_x86_64" "$NEW_VERSION" || return 1
./build_static.sh --arch arm64 > /dev/null 2>&1 || print_warning "ARM64 build failed (continuing)" print_status "Building ARM64 static binary..."
./build_static.sh --arch arm64 2>&1 | tail -5 || print_warning "ARM64 build failed (continuing)"
if [[ -x "build/nsigner_static_arm64" ]]; then if [[ -x "build/nsigner_static_arm64" ]]; then
verify_binary_version "build/nsigner_static_arm64" "$NEW_VERSION" || return 1 verify_binary_version "build/nsigner_static_arm64" "$NEW_VERSION" || return 1
fi fi
@@ -221,11 +223,25 @@ create_gitea_release() {
response=$(curl -s -X POST "$api_url/releases" \ response=$(curl -s -X POST "$api_url/releases" \
-H "Authorization: token $token" \ -H "Authorization: token $token" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}") -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 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 else
print_error "Failed to create Gitea release: $response"
return 1 return 1
fi fi
} }

View File

@@ -84,12 +84,21 @@ resolve_nsigner_version() {
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}") headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
fi fi
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases" \ # Use the git tags API and sort by version number, instead of the releases
| jq -r '.[0].tag_name // empty' || true)" # 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 if [[ -z "${latest_tag}" ]]; then
err "Could not resolve latest n_signer release tag from API." 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 exit 1
fi fi
@@ -176,7 +185,7 @@ write_signer_start_script() {
set -euo pipefail set -euo pipefail
export PATH="$HOME/.local/bin:$PATH" 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. # 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). # 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."

View File

@@ -0,0 +1,387 @@
# Proposal: Algorithm-Based API + New Policy Model
## 1. Problem
The current API keys off **roles** that bundle three concepts together:
- **Algorithm** (curve: secp256k1, ed25519, ml-dsa-65, etc.)
- **Purpose** (nostr, ssh, age, pq-sig, pq-kem)
- **Derivation index** (nostr_index or role_path)
The caller must know the role name and the role's configuration determines the algorithm. This is unintuitive — a caller that wants to "sign something with ed25519" shouldn't need to know that the operator named the role "ssh_main" and configured it with purpose=ssh.
The purpose field is really a **policy/enforcement constraint**, not something the caller should care about. The caller knows what operation they want (sign, verify, encapsulate) and what algorithm they want to use. The signer's job is to check whether that combination is allowed and whether the caller is approved.
## 2. Proposed API shape
### 2.1 Core principle: verb + algorithm, not verb + role
The caller specifies:
- **What** they want to do (the verb)
- **Which algorithm** they want to use
- **Which key** (by derivation index)
The signer determines:
- Whether the verb+algorithm combination is valid (enforcement)
- Whether the caller is approved (policy)
- Derives the key on demand from the mnemonic
### 2.2 Verbs
Simplify to operation-based verbs. The algorithm is a parameter, not implicit in the verb name.
| Verb | Description | Algorithm parameter | Key parameter |
|---|---|---|---|
| `get_public_key` | Get public key for a derived key | `algorithm` | `index` |
| `sign` | Sign arbitrary bytes | `algorithm` | `index` |
| `verify` | Verify a signature | `algorithm` | `index` (or `public_key`) |
| `encapsulate` | KEM encapsulation | `algorithm` | `public_key` (peer's) |
| `decapsulate` | KEM decapsulation | `algorithm` | `index` |
| `derive_shared_secret` | ECDH key agreement (x25519) | `algorithm` | `index` + `peer_public_key` |
| `sign_event` | Sign a Nostr event (secp256k1 only) | — (always secp256k1) | `index` |
| `nip44_encrypt` | NIP-44 encrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
| `nip44_decrypt` | NIP-44 decrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
| `nip04_encrypt` | NIP-04 encrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
| `nip04_decrypt` | NIP-04 decrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
| `mine_event` | Mine + sign Nostr event (secp256k1 only) | — | `index` |
The Nostr-specific verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`) are inherently secp256k1 — that's a protocol requirement of Nostr, not a policy choice. They don't take an `algorithm` parameter. The generic verbs (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`) take an `algorithm` parameter.
### 2.3 Request format
**Generic sign (any signature algorithm):**
```json
{
"id": "1",
"method": "sign",
"params": [
"68656c6c6f",
{
"algorithm": "ml-dsa-65",
"index": 0
}
]
}
```
- First param: message bytes as hex
- Second param: options with `algorithm` and `index`
**Response:**
```json
{
"id": "1",
"result": {
"signature": "<hex>",
"algorithm": "ml-dsa-65",
"key_id": "a1b2c3d4e5f6a1b2"
}
}
```
**Get public key (any algorithm):**
```json
{
"id": "2",
"method": "get_public_key",
"params": [
{
"algorithm": "ed25519",
"index": 0
}
]
}
```
**Response:**
```json
{
"id": "2",
"result": {
"algorithm": "ed25519",
"public_key": "<hex>",
"key_id": "a1b2c3d4e5f6a1b2"
}
}
```
**KEM encapsulate:**
```json
{
"id": "3",
"method": "encapsulate",
"params": [
"<peer_pubkey_hex>",
{
"algorithm": "ml-kem-768"
}
]
}
```
**KEM decapsulate:**
```json
{
"id": "4",
"method": "decapsulate",
"params": [
"<ciphertext_hex>",
{
"algorithm": "ml-kem-768",
"index": 0
}
]
}
```
**ECDH shared secret (x25519):**
```json
{
"id": "5",
"method": "derive_shared_secret",
"params": [
"<peer_pubkey_hex>",
{
"algorithm": "x25519",
"index": 0
}
]
}
```
**Nostr sign_event (unchanged, always secp256k1):**
```json
{
"id": "6",
"method": "sign_event",
"params": [
"<event_json>",
{
"index": 0
}
]
}
```
### 2.4 Algorithm names
| String | Algorithm | Key type |
|---|---|---|
| `secp256k1` | secp256k1 (Schnorr) | Signature |
| `ed25519` | ed25519 | Signature |
| `ml-dsa-65` | ML-DSA-65 (FIPS 204) | Signature |
| `slh-dsa-128s` | SLH-DSA-128s (FIPS 205) | Signature |
| `x25519` | X25519 (ECDH) | Key agreement |
| `ml-kem-768` | ML-KEM-768 (FIPS 203) | KEM |
### 2.5 Key identification
Keys are identified by **algorithm + index** instead of role names. The index is the BIP-44 account field in the derivation path:
| Algorithm | Derivation path for index N |
|---|---|
| secp256k1 | `m/44'/1237'/N'/0/0` (NIP-06, unchanged) |
| ed25519 | `m/44'/102001'/N'/0'/0'` |
| x25519 | `m/44'/102002'/N'/0'/0'` |
| ml-dsa-65 | `m/44'/102003'/N'/0'/0'` |
| slh-dsa-128s | `m/44'/102004'/N'/0'/0'` |
| ml-kem-768 | `m/44'/102005'/N'/0'/0'` |
The `key_id` (first 16 hex chars of the public key) provides a short human-readable identifier for display at the approval prompt.
### 2.6 Backward compatibility
The existing role-based API continues to work for Nostr clients:
- `sign_event` with `{role: "main"}` or `{nostr_index: 0}` — unchanged
- `nip44_encrypt`, `nip04_decrypt`, etc. — unchanged
- `get_public_key` with `{role: "main"}` — returns plain hex (secp256k1 backward compat)
The new algorithm-based API is additive — new verbs (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`) use the algorithm parameter. Old verbs keep their role-based selectors.
## 3. Proposed policy model
### 3.1 Current model (role-based)
From [`plans/deny_by_default_approvals.md`](plans/deny_by_default_approvals.md):
- Roles are pre-configured at startup with purpose+curve+index
- Policy entries approve callers for specific roles
- `--preapprove caller=uid:1000,role=main,verb=sign_event`
- Prompt `[a]` approves the caller for the role
### 3.2 New model (algorithm-based)
Policy entries approve callers for **algorithm + index ranges + verbs**:
**Policy entry structure:**
```
caller: <caller_id>
algorithms: [ed25519, ml-dsa-65] # which algorithms this caller can use
index_range: 0-4 # which derivation indices (or * for any)
verbs: [sign, verify, get_public_key] # which verbs
prompt: first_per_boot # prompt behavior
```
**`--preapprove` CLI flag:**
```bash
nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign
nsigner --preapprove caller=uid:1000,algorithm=ml-dsa-65,index=0,verb=sign,verify
nsigner --preapprove caller=pubkey:abc...,algorithm=ml-kem-768,index=0,verb=decapsulate
```
**Prompt display:**
When a caller requests `sign` with `algorithm=ml-dsa-65, index=0`, the prompt shows:
```
Caller: uid:1000
Action: sign with ML-DSA-65
Key: index 0 (key_id: a1b2c3d4e5f6a1b2)
[a] approve [d] deny
```
The operator approves the caller for that algorithm+index+verb combination. Subsequent requests with the same combination are auto-approved (if `prompt: first_per_boot`).
### 3.3 Nostr verbs policy
Nostr verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`) are always secp256k1. They can be policy-keyed as either:
- **Algorithm-based**: `algorithm=secp256k1, verb=sign_event, index=0` (new style)
- **Role-based**: `role=main, verb=sign_event` (old style, backward compat)
Both resolve to the same key. The signer accepts both selector forms.
### 3.4 Wildcards
| Pattern | Meaning |
|---|---|
| `algorithm=*` | Any algorithm |
| `index=*` | Any index |
| `index=0-4` | Indices 0 through 4 inclusive |
| `verb=*` | Any verb valid for the algorithm |
Wildcards make policy more compact but less precise. The default is no wildcards (explicit per-algorithm, per-index, per-verb).
### 3.5 Enforcement matrix
The signer checks verb+algorithm validity (regardless of policy):
| Verb | Valid algorithms | Notes |
|---|---|---|
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Generic signing |
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Generic verification |
| `encapsulate` | ml-kem-768 | KEM encapsulation |
| `decapsulate` | ml-kem-768 | KEM decapsulation |
| `derive_shared_secret` | x25519 | ECDH key agreement |
| `get_public_key` | all | Any algorithm |
| `sign_event` | secp256k1 (implicit) | Nostr protocol requirement |
| `nip44_encrypt` | secp256k1 (implicit) | Nostr protocol requirement |
| `nip44_decrypt` | secp256k1 (implicit) | Nostr protocol requirement |
| `nip04_encrypt` | secp256k1 (implicit) | Nostr protocol requirement |
| `nip04_decrypt` | secp256k1 (implicit) | Nostr protocol requirement |
| `mine_event` | secp256k1 (implicit) | Nostr protocol requirement |
If a caller requests `sign` with `algorithm=x25519`, the signer rejects with `algorithm_not_supported_for_verb` — x25519 is a key agreement algorithm, not a signature algorithm.
### 3.6 No purpose field
The `purpose` field is **removed entirely**. There is no `purpose` in the API, no purpose in enforcement, no purpose in policy. The role_purpose_t enum and all purpose-related code is removed from the codebase.
Enforcement is purely verb+algorithm based — the signer checks whether the requested verb is valid for the requested algorithm. No "purpose mismatch" errors, no purpose in policy entries, no purpose in role configuration.
Roles (if kept for backward compat with existing Nostr clients) are keyed on algorithm+index only, not purpose+curve.
## 4. Migration path
### Phase A: Add algorithm-based verbs (additive, no breaking changes)
1. Add new verbs: `sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`
2. These accept `{algorithm, index}` in the options
3. Old verbs (`sign_data`, `ssh_sign`, `kem_encapsulate`, `kem_decapsulate`) become aliases that map to the new verbs
4. Old role-based selectors continue to work on all verbs
5. Policy model extended to support algorithm-based entries alongside role-based entries
### Phase B: Deprecate old verb names (optional, future)
1. `sign_data``sign`
2. `ssh_sign``sign` (with `algorithm=ed25519`)
3. `kem_encapsulate``encapsulate`
4. `kem_decapsulate``decapsulate`
5. `verify_signature``verify`
6. Old names still work but are documented as deprecated
### Phase C: Remove purpose from enforcement (optional, future)
1. Purpose becomes pure metadata
2. Enforcement only checks verb+algorithm validity
3. Roles become optional (algorithm+index is the primary key selector)
## 5. Example: full session with new API
### Operator starts n_signer:
```bash
nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0,verb=sign,verify,get_public_key \
--preapprove caller=uid:1000,algorithm=ml-dsa-65,index=0,verb=sign,verify,get_public_key \
--preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsulate,get_public_key
```
### Client gets an ed25519 public key:
```json
{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}
{"id":"1","result":{"algorithm":"ed25519","public_key":"...","key_id":"a1b2..."}}
```
### Client signs a message with ed25519:
```json
{"id":"2","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}
{"id":"2","result":{"signature":"...","algorithm":"ed25519","key_id":"a1b2..."}}
```
### Client signs a message with ML-DSA-65:
```json
{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ml-dsa-65","index":0}]}
{"id":"3","result":{"signature":"...","algorithm":"ml-dsa-65","key_id":"c3d4..."}}
```
### Client encapsulates with ML-KEM-768 (using peer's public key):
```json
{"id":"4","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]}
{"id":"4","result":{"ciphertext":"...","shared_secret":"...","algorithm":"ml-kem-768"}}
```
### Client decapsulates (signer side, using its ML-KEM private key):
```json
{"id":"5","method":"decapsulate","params":["<ciphertext_hex>",{"algorithm":"ml-kem-768","index":0}]}
{"id":"5","result":{"shared_secret":"...","algorithm":"ml-kem-768"}}
```
### Client signs a Nostr event (backward compatible):
```json
{"id":"6","method":"sign_event","params":["<event_json>",{"index":0}]}
{"id":"6","result":"<signed_event_json>"}
```
## 6. Comparison: old vs new
| Aspect | Old (role-based) | New (algorithm-based) |
|---|---|---|
| Caller specifies | Role name | Algorithm + index |
| Algorithm determined by | Role's configured curve | Explicit `algorithm` parameter |
| Purpose | Enforcement constraint | Optional metadata |
| Policy unit | Role | Algorithm + index + verb |
| Nostr verbs | Role-based selector | Index-based (algorithm implicit) |
| Generic verbs | `sign_data` (role-based) | `sign` (algorithm-based) |
| Key derivation | Role's index/path | Algorithm's derivation path + index |
## 7. Decisions
1. **secp256k1 `sign` — offer both Schnorr and ECDSA.** The `sign` verb accepts an optional `scheme` parameter for secp256k1: `"scheme": "schnorr"` (default, BIP-340) or `"scheme": "ecdsa"`. Other algorithms have one signature scheme each and ignore this parameter.
2. **Index is per-algorithm.** Each algorithm has its own derivation path prefix (coin type), so "index 0" for ed25519 and "index 0" for ml-dsa-65 are different keys. This is the only sensible model — different algorithms need different key material.
3. **Key selector is `index` only.** No `role_path` support for the new verbs. The `index` parameter maps to the account field in the standard BIP-44 derivation path. This is simpler, covers all normal use cases, and is easy to policy-gate. Arbitrary derivation paths can be a future advanced feature if needed.
4. **`verify` uses the signer's own derived public key.** The `verify` verb takes `algorithm` + `index` (same as `sign`) and verifies a signature against the signer's own derived public key. This is useful for round-trip testing (client signs, then verifies). The signer does NOT verify arbitrary external signatures with a `public_key` parameter — that's a client-side operation that doesn't need the signer.
5. **Remove the `purpose` field entirely.** No `purpose` in the API, no purpose in enforcement, no purpose in policy. Enforcement is purely verb+algorithm based:
- `sign` works with any signature algorithm (secp256k1, ed25519, ml-dsa-65, slh-dsa-128s)
- `encapsulate`/`decapsulate` work with ml-kem-768
- `derive_shared_secret` works with x25519
- `sign_event`/`nip44_*`/`nip04_*`/`mine_event` always use secp256k1 (Nostr protocol requirement, not a purpose check)
- `get_public_key` works with any algorithm
The `role_purpose_t` enum and all purpose-related code is removed. Roles (if kept for backward compat) are keyed on algorithm+index only.

View File

@@ -0,0 +1,306 @@
# Plan: Bring `firmware/cyd_esp32_2432s028` Up to Date with the Algorithm-Based API
## Context
The main project ([`src/dispatcher.c`](../src/dispatcher.c)) has fully migrated to the
algorithm-based API documented in [`README.md`](../README.md) §4. The migration is marked
COMPLETED in [`plans/legacy_verb_aliases.md`](../plans/legacy_verb_aliases.md): legacy verb
names are gone from the wire protocol, implementation, tests, clients, and docs.
The CYD firmware at [`firmware/cyd_esp32_2432s028/main/main.c`](../firmware/cyd_esp32_2432s028/main/main.c)
was **not** updated and still speaks the **legacy verb API**:
| Firmware verb (current) | Main-project verb (target) |
|--------------------------|----------------------------|
| `get_public_key` (+ `nostr_index`) | split → `get_public_key` (+ `algorithm`) **and** `nostr_get_public_key` (+ `nostr_index`) |
| `sign_event` | `nostr_sign_event` |
| `nip04_encrypt` / `nip04_decrypt` | `nostr_nip04_encrypt` / `nostr_nip04_decrypt` |
| `nip44_encrypt` / `nip44_decrypt` | `nostr_nip44_encrypt` / `nostr_nip44_decrypt` |
| _(missing)_ | `sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`, `derive`, `encrypt`/`decrypt` (otp), `nostr_mine_event` |
### Good news: the crypto primitives already exist
The firmware already has every underlying primitive needed — only the **dispatch layer**
in [`main.c`](../firmware/cyd_esp32_2432s028/main/main.c) is stale:
- [`key_derivation.h`](../firmware/cyd_esp32_2432s028/main/key_derivation.h): `derive_nostr_key_index`, `derive_ed25519_key`, `derive_x25519_key`, `derive_ml_dsa_65_key`, `derive_slh_dsa_128s_key`, `derive_ml_kem_768_key`, `schnorr_sign32`, `ed25519_sign32`
- [`pq_crypto_firmware.h`](../firmware/cyd_esp32_2432s028/main/pq_crypto_firmware.h): `fw_pq_ml_dsa_65_sign/verify`, `fw_pq_slh_dsa_128s_sign/verify`, `fw_pq_ml_kem_768_encaps/decaps`
- [`nostr_core_lib`](../resources/nostr_core_lib) nip004/nip044 already linked for the Nostr verbs
### `key_id` convention
The main project defines `key_id` as the **first 16 hex characters of the public key**
(see [`src/dispatcher.c`](../src/dispatcher.c) ~line 1788). The firmware must match this
so clients can correlate keys across targets.
## Scope
**Target:** [`firmware/cyd_esp32_2432s028`](../firmware/cyd_esp32_2432s028) only.
The feather_s3_tft firmware is explicitly out of scope for this pass (it has the same gap
but will be handled separately).
## Architecture: dispatch flow after upgrade
```mermaid
flowchart TD
A[recv frame] --> B[parse JSON-RPC]
B --> C{auth envelope}
C -->|fail| Z[auth error]
C -->|ok| D{method}
D -->|nostr_*| E[Nostr verb branch<br/>secp256k1 NIP-06<br/>nostr_index selector]
D -->|alg verb| F[Algorithm verb branch<br/>algorithm + index selector]
E --> G[derive_request_key<br/>nostr_index]
F --> H{algorithm}
H -->|secp256k1| H1[derive_nostr_key_index]
H -->|ed25519| H2[derive_ed25519_key]
H -->|x25519| H3[derive_x25519_key]
H -->|ml-dsa-65| H4[derive_ml_dsa_65_key]
H -->|slh-dsa-128s| H5[derive_slh_dsa_128s_key]
H -->|ml-kem-768| H6[derive_ml_kem_768_key]
H -->|otp| H7[bound pad]
G --> I[enforcement matrix check]
H1 --> I
H2 --> I
H3 --> I
H4 --> I
H5 --> I
H6 --> I
H7 --> I
I -->|reject 1010| Z
I -->|ok| J[approval prompt]
J --> K[execute verb]
K --> L[structured result JSON<br/>algorithm + key_id + field]
```
## Implementation Steps
### 1. Add algorithm-name parsing helpers (`main.c`)
Add a small enum + parser mirroring the main project's algorithm set:
```c
typedef enum {
FW_ALG_SECP256K1 = 0,
FW_ALG_ED25519,
FW_ALG_X25519,
FW_ALG_ML_DSA_65,
FW_ALG_SLH_DSA_128S,
FW_ALG_ML_KEM_768,
FW_ALG_OTP,
FW_ALG_UNKNOWN
} fw_alg_t;
```
- `parse_algorithm_from_options(cJSON *options, fw_alg_t *out_alg, uint32_t *out_index)`
— reads `algorithm` (string) and `index` (number, default 0) from the trailing options
object in `params`.
- `fw_alg_name(fw_alg_t)` → canonical string (`"secp256k1"`, `"ed25519"`, …) for response
JSON.
- Keep the existing `parse_nostr_index_from_params()` for the `nostr_*` verbs.
### 2. Add a unified algorithm-key derivation + `key_id` helper
Add `derive_alg_key(fw_alg_t alg, uint32_t index, ...)` that dispatches to the right
`derive_*_key` function and produces:
- the raw private key bytes (when applicable),
- the public key hex,
- the `key_id` (first 16 hex chars of the public key).
PQ algorithms have large key buffers (ml-dsa-65 sk = 4032 B, ml-kem-768 sk = 2400 B).
Allocate these as **static** buffers (not on the stack) and `secure_memzero` after use,
matching the existing `s_privkey`/`s_pubkey` pattern. SLH-DSA-128s keygen is slow
(530 s) — log a warning and show a "deriving key…" UI screen before calling it.
### 3. Add a structured-result builder
Add `build_alg_result_json(const char *alg_name, const char *key_id_16hex, const char *field_name, const char *field_value)` → returns a JSON string like
`{"algorithm":"ed25519","key_id":"<16hex>","public_key":"<hex>"}`. This mirrors
[`build_alg_result_json`](../src/dispatcher.c:861) in the main dispatcher.
### 4. Add the enforcement matrix
Add `enforce_alg_verb(fw_alg_t alg, const char *verb)` returning 0 / `1010`, matching
[`README.md`](../README.md) §4.3 enforcement matrix:
| Verb | Valid algorithms |
|------|------------------|
| `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
| `encapsulate` / `decapsulate` | ml-kem-768 |
| `derive_shared_secret` | x25519 |
| `derive` | secp256k1 |
| `encrypt` / `decrypt` | otp |
| `get_public_key` | all key-deriving algorithms |
Reject any unlisted pair with
`{"error":{"code":1010,"message":"algorithm_not_supported_for_verb"}}`.
### 5. Rename the Nostr verbs (in-place, no compat shim)
In the dispatch `if/else if` chain in [`main.c`](../firmware/cyd_esp32_2432s028/main/main.c)
~lines 8361230:
- `get_public_key` (nostr_index branch) → `nostr_get_public_key`
- `sign_event``nostr_sign_event`
- `nip04_encrypt``nostr_nip04_encrypt`
- `nip04_decrypt``nostr_nip04_decrypt`
- `nip44_encrypt``nostr_nip44_encrypt`
- `nip44_decrypt``nostr_nip44_decrypt`
The `nostr_get_public_key` verb should also honor the `format` option
(`"structured"``{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16hex>"}`,
default → bare 64-hex pubkey string), matching the main project.
### 6. Add the algorithm-based `get_public_key` verb
`get_public_key` with `algorithm` + `index` → derive the key, return structured JSON:
`{"algorithm":"<alg>","public_key":"<hex>","key_id":"<16hex>"}`. For PQ algorithms the
`public_key` is the full PQClean pubkey hex (1952 B for ml-dsa-65, 1184 B for ml-kem-768,
32 B for slh-dsa-128s) — ensure `s_response_buf` (currently 2048 B) is large enough, or
emit via `cJSON_PrintUnformatted` into a larger static buffer.
### 7. Add `sign` and `verify` (algorithm-based)
- `sign`: params `[<message_hex>, {algorithm, index, scheme?}]`. `scheme` is
secp256k1-only (`"schnorr"` default / `"ecdsa"`). For ed25519 use `ed25519_sign32`
(note: ed25519 signs the raw 32-byte message, not a pre-hash — match main project
behavior). For ml-dsa-65 / slh-dsa-128s use `fw_pq_*_sign`. Response:
`{"algorithm":"<alg>","key_id":"<16hex>","signature":"<hex>"}`.
- `verify`: params `[<message_hex>, <signature_hex>, {algorithm, index, scheme?}]`.
Derive the signer's own pubkey and verify against it. Response:
`{"valid":true,"algorithm":"<alg>"}`.
### 8. Add `encapsulate` / `decapsulate` (ml-kem-768)
- `encapsulate`: params `[<peer_pubkey_hex>, {algorithm:"ml-kem-768"}]`
`fw_pq_ml_kem_768_encaps`
`{"ciphertext":"<hex>","shared_secret":"<hex>","algorithm":"ml-kem-768"}`.
- `decapsulate`: params `[<ciphertext_hex>, {algorithm:"ml-kem-768", index}]` → derive
kem keypair, `fw_pq_ml_kem_768_decaps`
`{"shared_secret":"<hex>","algorithm":"ml-kem-768"}`.
### 9. Add `derive_shared_secret` (x25519)
params `[<peer_pubkey_hex>, {algorithm:"x25519", index}]` → derive x25519 keypair,
compute X25519 ECDH via mbedtls →
`{"shared_secret":"<hex>","algorithm":"x25519"}`.
### 10. Add `derive` (secp256k1 HMAC-SHA256)
params `[<data>, {algorithm:"secp256k1", index}]` (`index` **required**) → derive
secp256k1 privkey, compute `HMAC-SHA256(privkey, data)` via mbedtls →
`{"algorithm":"secp256k1","key_id":"<16hex>","digest":"<64hex>"}`. See
[`plans/derive_hmac.md`](../plans/derive_hmac.md) for the spec.
### 11. Add `nostr_mine_event` (PoW)
params `[<event_json>, {nostr_index, difficulty, timeout_sec, threads?}]`. Reuse
`build_signed_event_json` but iterate nonce in the event's `tags` until the leading-zero
bits of the event id meet `difficulty`. ESP32 is slow — cap `threads` at 1 and enforce a
firm `timeout_sec` (default 30). Show a "mining…" UI screen. If timeout, return error
`1008 mining_failed`. This mirrors [`src/miner.c`](../src/miner.c).
### 12. Add `encrypt` / `decrypt` (otp)
The main project binds a pad from `--otp-pad-dir` + `--otp-pad` (a USB file). The CYD has
no filesystem pad source. **Decision: derive the OTP pad from the mnemonic seed** via a
SHAKE-256 / HKDF expansion keyed on `algorithm:"otp"` so the pad is deterministic per
mnemonic and advances monotonically across requests (offset stored in a static variable,
reported in every response). This keeps the wire contract identical (`encrypt`/`decrypt`
with `algorithm:"otp"`, `encoding:"ascii"|""binary""`) while fitting the embedded
constraint. Document this divergence in [`firmware/README.md`](../firmware/README.md).
### 13. Bump `FIRMWARE_VERSION` and update `firmware/README.md`
- `FIRMWARE_VERSION` "0.0.1" → "0.0.2" (algorithm-based API).
- Document the new verb table, the OTP pad-derivation divergence, and the SLH-DSA-128s
latency warning.
### 14. Update CYD-targeting examples / clients
Audit and update any example or client that speaks to the CYD over UART/Web-Serial and
uses legacy verb names:
- [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) and
[`examples/feather_sign_event.py`](../examples/feather_sign_event.py) (feather-targeting
but the wire protocol is shared — note in README they need `nostr_` prefixes for CYD
after this change; leave feather examples alone since feather is out of scope, but add a
CYD-specific example pair if none exists).
- [`client/`](../client/) demos already use the new verbs (per
[`plans/legacy_verb_aliases.md`](../plans/legacy_verb_aliases.md)) — verify no CYD-specific
legacy calls remain.
### 15. Add a CYD Web Serial test page covering all algorithms
The existing [`examples/feather_webusb_demo.html`](../examples/feather_webusb_demo.html) is
**WebUSB-only** (feather's native USB) and uses the **legacy verbs**. The CYD's CH340
bridge (`1a86:7523`) is not a WebUSB device — it exposes a serial port, so the browser
transport is **Web Serial** (`navigator.serial`), Chromium-only.
Create [`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html) — a
single-file, dependency-light test page that:
**Transport:**
- `navigator.serial.requestPort()``port.open({ baudRate: 115200 })` (matches
[`uart_transport.c`](../firmware/cyd_esp32_2432s028/main/uart_transport.c) UART_BAUD_RATE).
- Same 4-byte big-endian length-prefix frame format as the feather demo
([`be32()`](../examples/feather_webusb_demo.html:256), read loop reassembling frames).
- Read via a `ReadableStream` reader + length-prefix reassembly (Web Serial is stream-based,
not packet-based like WebUSB `transferIn`).
- Same auth-envelope construction (kind 27235, `nsigner_method` / `nsigner_body_hash` tags,
schnorr sign with a demo caller key) — reuse the
[`buildAuth()`](../examples/feather_webusb_demo.html:279) logic verbatim.
**UI sections (one card per verb family, all algorithms):**
1. **Connect** — Connect Web Serial button + status.
2. **Get Public Key** — algorithm dropdown (`secp256k1`, `ed25519`, `x25519`, `ml-dsa-65`,
`slh-dsa-128s`, `ml-kem-768`) + index → `get_public_key`. Also a `nostr_get_public_key`
card with `nostr_index` + `format` (bare / structured) toggle.
3. **Sign / Verify** — algorithm dropdown (sig algs only) + index + `scheme` (schnorr/ecdsa,
secp256k1-only) + message hex → `sign`; then `verify` with the returned signature.
4. **KEM (ml-kem-768)**`encapsulate` with a peer pubkey (or self-pubkey from
`get_public_key`) → ciphertext + shared secret; `decapsulate` with that ciphertext →
shared secret (confirm match).
5. **X25519**`derive_shared_secret` with peer pubkey.
6. **Derive (HMAC)**`derive` with data string + index → 64-hex digest.
7. **Nostr Sign Event**`nostr_sign_event` (kind 1) with `nostr_index`.
8. **Nostr Mine Event**`nostr_mine_event` with difficulty + timeout (low default, e.g.
difficulty 4) — warn it's slow on ESP32.
9. **NIP-04 / NIP-44**`nostr_nip04_encrypt`/`decrypt`, `nostr_nip44_encrypt`/`decrypt`
with `nostr_index`.
10. **OTP**`encrypt` / `decrypt` with `algorithm:"otp"`, `encoding` toggle
(ascii/binary), base64 plaintext.
Each card shows the raw JSON-RPC request and response in a `<pre>` so the wire format is
visible. Reuse the feather demo's CSS (dark theme, cards, `.mono` log) for consistency.
**Link it from [`firmware/README.md`](../firmware/README.md)** in the CYD section (the
"Quick validation" / Web Serial path), since the current README only points at the
feather WebUSB demo.
### 16. Verification
- `idf.py build` in [`firmware/cyd_esp32_2432s028`](../firmware/cyd_esp32_2432s028) compiles
clean.
- Flash to the connected CYD board (CH340 on `/dev/ttyUSB0`) and smoke-test each verb:
- Primary path: open [`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html)
in Chrome/Edge, connect via Web Serial, exercise every card.
- Secondary path: a small Python script over `/dev/ttyUSB0` for headless confirmation.
- Cover: `get_public_key` (each algorithm), `sign`/`verify` (each sig alg),
`encapsulate`/`decapsulate`, `derive_shared_secret`, `derive`,
`nostr_get_public_key`, `nostr_sign_event`, `nostr_nip04_encrypt`/`decrypt`,
`nostr_nip44_encrypt`/`decrypt`, `nostr_mine_event` (low difficulty),
`encrypt`/`decrypt` (otp).
- Confirm `key_id` matches the first 16 hex of the returned pubkey for every alg.
- Confirm an invalid `(verb, algorithm)` pair returns code 1010.
- `grep -rn "sign_event\|nip04_encrypt\|nip04_decrypt\|nip44_encrypt\|nip44_decrypt"
firmware/cyd_esp32_2432s028/` returns no matches (legacy names gone).
## Open questions / decisions baked in
- **OTP pad source:** derived from mnemonic (no USB pad on CYD). Documented divergence.
- **`nostr_mine_event`:** implemented, single-threaded, hard 30 s default timeout, with a
"mining…" UI screen. Not stubbed — the main project has it and the user asked to bring
the firmware up to date.
- **No legacy-verb compat shim:** matches the main project's policy
([`plans/legacy_verb_aliases.md`](../plans/legacy_verb_aliases.md) — "No backward-
compatibility shim is needed").
- **feather_s3_tft not touched** in this pass.

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

@@ -0,0 +1,143 @@
# Plan: Interactive multi-transport selection at startup
Status: design / ready for review.
Related:
- [`README.md`](../README.md) §7 Transport, §8.3 Qubes OS qube, §9 Usage
- [`src/main.c`](../src/main.c) — argument parsing, main loop, TUI
- [`src/server.c`](../src/server.c) — `server_ctx_t`, `server_start`, `server_handle_one`
- [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) — bridge design
---
## 1. Motivation
The current CLI has many transport flags (`--listen`, `--socket-name`, `--bridge-source-trusted`, `--auth`, `--allow-all`) that the user must know in advance. For interactive use, this is unfriendly — the user just wants to answer "how should other programs reach this signer?"
Additionally, the user may want **multiple transports active simultaneously** (e.g. unix socket for local clients + TCP for FIPS mesh clients + qrexec bridge for other qubes). Today only one `--listen` mode is supported at a time.
---
## 2. Design
### 2.1 Interactive transport menu at startup
After mnemonic entry and before the running phase, if no `--listen` flag was given (i.e. interactive mode), present a multi-select menu:
```text
Transport — how should other programs reach this signer?
Select one or more (space to toggle, enter to confirm):
[x] 1. Local Unix socket (same machine/qube)
[ ] 2. Qubes qrexec bridge (other qubes via qrexec, no network)
[ ] 3. TCP listener (FIPS mesh or local network)
[ ] 4. Qrexec one-shot (legacy, one request per invocation)
[a] select all
(at least one must be selected)
```
Each selected transport gets configured with sensible defaults:
| Choice | What it starts | 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:[::]: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).
**Choice 3 (TCP) can coexist with 1+2** — it's a separate listener on a different fd.
**Choice 4 (qrexec one-shot) is mutually exclusive** with all others — it uses stdin/stdout and exits after one request. If selected alone, it runs the one-shot path. If selected with others, it's ignored with a warning (or: the user is told it can't combine).
### 2.2 Multi-listener architecture
Currently `main()` creates one `server_ctx_t` and polls `server.listen_fd` + `STDIN_FILENO`. To support multiple listeners:
- Create an array of `server_ctx_t` (up to 3: unix, tcp, and optionally a second unix for bridge — though 1+2 collapse into one).
- Each `server_ctx_t` gets its own `server_start()`.
- The poll loop expands to poll all listener fds + STDIN_FILENO.
- When a listener fd has activity, call `server_handle_one()` on that context.
- The TUI status display shows all active transports.
```text
Connections
listen: unix @nsigner (bridge-source-trusted)
listen: tcp [::]:11111
client: nsigner --socket-name nsigner client '<json>'
```
### 2.3 Coexistence with CLI flags
- If `--listen` is given on the command line, **skip the interactive menu** (automation/scripting path unchanged).
- If no `--listen` is given and stdin is a TTY, **show the interactive menu**.
- If no `--listen` is given and stdin is NOT a TTY, **default to unix socket** (current behavior, for `--mnemonic-stdin` / `--mnemonic-fd` supervised launches).
This preserves backward compatibility: all existing flags and scripts work unchanged. The menu is purely an interactive convenience.
### 2.4 TUI implementation
The menu uses the existing TUI rendering helpers (`tui_render_content_screen`, `tui_print`). It appears after mnemonic acceptance and before the status display. Navigation:
- `1`-`4` or space: toggle a choice
- `a`: select all (except 4, which is mutually exclusive)
- `a`: select all (except 4, which is mutually exclusive)
- `enter`: confirm and proceed to running phase (at least one must be selected)
After confirmation, the selected listeners are started and the status display renders.
### 2.5 Security considerations
- The menu is purely a UX layer — it doesn't change the security model. Each transport still goes through the same policy/approval pipeline.
- `--bridge-source-trusted` is only set if the user explicitly selects the qrexec bridge option. It's never silently enabled.
- TCP listener still requires auth envelopes (existing behavior).
- The menu doesn't offer `--allow-all`; that remains a CLI flag for users who want it.
---
## 3. Implementation checklist
### 3.1 Multi-listener support in `main.c`
- [ ] Replace single `server_ctx_t server` with an array (e.g. `server_ctx_t servers[3]`).
- [ ] Add a `server_count` variable.
- [ ] Expand the poll loop to poll all `servers[i].listen_fd` + STDIN_FILENO.
- [ ] On activity, call `server_handle_one(&servers[i], ...)` for the matching fd.
- [ ] `server_stop()` all on shutdown.
### 3.2 Interactive menu
- [ ] Add `prompt_transport_selection()` function in `main.c` (after mnemonic load, before server start).
- [ ] Returns a bitmask of selected transports.
- [ ] Only called when `listen_mode` was not set by CLI and stdin is a TTY.
- [ ] Uses `tui_render_content_screen` + `read_line_stdin` for input.
### 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:[::]:11111`.
- [ ] Qrexec one-shot: existing `NSIGNER_LISTEN_QREXEC` path (stdin, one request, exit).
### 3.4 TUI status display
- [ ] Update `render_status()` to show multiple active listeners.
- [ ] Show `(bridge-source-trusted)` annotation on unix listeners that have it.
### 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:[::]: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).
---
## 4. Open questions
1. **Should the menu remember the last selection?** n_signer has no config files (by design). We could store the preference in an env var or a `/tmp` file, but that violates the zero-filesystem-footprint principle. **Decision: no persistence — the menu appears fresh each time.**
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 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).

430
plans/mine_event_pow.md Normal file
View File

@@ -0,0 +1,430 @@
# Plan: Add `mine_event` Verb (NIP-13 Proof-of-Work) to n_signer
## Executive Summary
**Recommendation: Do NOT import event_miner as a subrepo or copy its code.**
n_signer already has [`nip013.h`](resources/nostr_core_lib/nostr_core/nip013.h:1) and [`nip013.c`](resources/nostr_core_lib/nostr_core/nip013.c:1) in its `resources/nostr_core_lib/`. event_miner is a standalone CLI tool (~716 lines) whose useful logic is ~50 lines. The rest is CLI parsing, stdin/file I/O, signal handlers, and `exit()` calls that are inappropriate for a long-running server.
Instead, we add a new `mine_event` verb to n_signer's existing dispatcher that:
1. Mines PoW for a time budget (e.g., "mine for 30 seconds with 4 threads")
2. Returns the **best result found** within that time — regardless of whether the target difficulty was reached
3. Signs the event with the role's derived private key
4. Returns the mined + signed event JSON along with metadata about the achieved difficulty
## Why Not Subrepo or Copy?
| Factor | Subrepo | Copy | New verb (recommended) |
|--------|---------|------|----------------------|
| Key management | event_miner takes raw nsec CLI arg | same | Uses n_signer's role table + key_store (secure, mlock'd) |
| Architecture | CLI tool, calls exit() | same | Fits JSON-RPC dispatcher/enforcement/policy model |
| Threading | Global vars, signal handlers, exit() | same | Clean detached thread per request, no globals |
| Best-effort model | No — stops at target or max_attempts | same | Yes — returns best result within time budget |
| Code reuse | ~50 lines useful | same | Rewrite ~150 lines for server-safe best-effort model |
| Maintenance | Extra submodule to track | Drift risk | Single codebase, single nostr_core_lib |
## Design Decisions
- **Verb name**: `mine_event`
- **Params**: `[event_json, {difficulty: N, threads: N, timeout_sec: N, ...role_selector}]`
- **Dual termination model**: Both `timeout_sec` and `difficulty` are independent options. Either, both, or at least one must be specified:
- **Both set**: Mine until target reached OR timeout — return best result (with `target_reached` flag)
- **Only `timeout_sec`**: Mine for the full duration, return best result found
- **Only `difficulty`**: Mine until target reached, with a safety max timeout (e.g., 10 minutes) to prevent infinite mining
- **Neither**: Error — must specify at least one termination condition
- **Best-effort return**: The response always includes the best event found, plus metadata (`achieved_difficulty`, `target_difficulty`, `target_reached`, `elapsed_sec`, `attempts`)
- **Threading**: Spawn a detached pthread for mining. Server stays responsive. Mining thread writes result to client socket when done.
- **Key source**: Uses the role's derived private key from `key_store` (same as `sign_event`)
## Why Our Own Mining Loop (Not `nostr_add_proof_of_work()`)
The existing [`nostr_add_proof_of_work()`](resources/nostr_core_lib/nostr_core/nip013.c:121) has two problems for the best-effort model:
1. **Discards work on failure**: It returns `NOSTR_ERROR_CRYPTO_FAILED` if `max_attempts` is exceeded without reaching the target. The best nonce found is lost.
2. **No time-based termination**: It uses `max_attempts` (a count), not a time deadline. For a "mine for 30 seconds" model, we need time-based termination.
Instead, `miner.c` implements its own mining loop that:
- Iterates nonces across multiple threads
- Uses [`nostr_create_and_sign_event()`](resources/nostr_core_lib/nostr_core/nip001.h:1) from nip001 to sign each attempt
- Uses [`nostr_calculate_pow_difficulty()`](resources/nostr_core_lib/nostr_core/nip013.h:42) from nip013 to check difficulty
- Tracks the best event (highest difficulty) across all threads
- Stops when target reached OR timeout
- Returns the best event with metadata
This reuses the library's crypto/JSON primitives without modifying the shared library.
## Request/Response Format
### Request
```json
{
"id": "req-1",
"method": "mine_event",
"params": [
"{\"kind\":1,\"content\":\"Hello Nostr!\",\"tags\":[],\"created_at\":1723666800}",
{
"difficulty": 20,
"threads": 4,
"timeout_sec": 30,
"role": "main"
}
]
}
```
- `difficulty` (optional): Target leading zero bits. If reached, mining stops early. If not specified, mining runs until timeout.
- `threads` (optional, default: 1): Number of mining threads.
- `timeout_sec` (optional): How long to mine in seconds. If not specified, mining runs until target difficulty is reached (with a safety max of 10 minutes).
- **At least one of `difficulty` or `timeout_sec` must be specified.** If neither is provided, returns error `1007` (`mining_failed` / `no_termination_condition`).
- **If only `difficulty` is specified**: A safety max timeout of 600 seconds (10 min) is applied to prevent infinite mining.
- **If only `timeout_sec` is specified**: Mining runs the full duration and returns the best result found.
- **If both are specified**: Mining stops when either condition is met (target reached OR timeout elapsed).
### Success Response (best-effort, always returned if mining ran)
```json
{
"id": "req-1",
"result": {
"event": "{\"kind\":1,\"content\":\"Hello Nostr!\",\"pubkey\":\"...\",\"id\":\"00000...\",\"sig\":\"...\",\"tags\":[[\"nonce\",\"12345\",\"20\"]],\"created_at\":1755197090}",
"achieved_difficulty": 18,
"target_difficulty": 20,
"target_reached": false,
"elapsed_sec": 30,
"attempts": 4523456
}
}
```
The `result` is now a JSON **object** (not a string like other verbs) containing the signed event plus mining metadata. The `event` field within it is the signed event JSON string. This is a departure from the other verbs that return a plain string result, but the metadata is essential for the client to know whether the target was met.
### Error Responses
- `1007` - `mining_failed` — internal error (invalid event, bad params, crypto failure)
- `1008` - `mining_busy` — another mining operation is already running (optional: reject or queue)
- Existing error codes (1001-1006, -326xx) apply as usual for policy/selector/enforcement errors
Note: Timeout is NOT an error — it's the normal termination condition. The best result is always returned.
## Architecture Diagram
```mermaid
flowchart TD
Client -->|JSON-RPC mine_event| ServerHandleOne
ServerHandleOne -->|policy_check and enforce_verb_role| PolicyEnforcement
PolicyEnforcement -->|ALLOWED| SpawnDetachedThread
ServerHandleOne -->|returns immediately| ServerLoop
SpawnDetachedThread --> MinerCoordinator
MinerCoordinator -->|spawn N worker threads| Worker1
MinerCoordinator -->|spawn N worker threads| Worker2
MinerCoordinator -->|spawn N worker threads| WorkerN
Worker1 -->|nostr_create_and_sign_event| NIP001Lib
Worker2 -->|nostr_create_and_sign_event| NIP001Lib
WorkerN -->|nostr_create_and_sign_event| NIP001Lib
NIP001Lib -->|signed event with nonce| CalcDifficulty
CalcDifficulty -->|nostr_calculate_pow_difficulty| NIP013Lib
NIP013Lib -->|difficulty bits| TrackBest
TrackBest -->|mutex protected| BestEvent
MinerCoordinator -->|timeout or target reached| JoinThreads
JoinThreads -->|best event + metadata| BuildResponse
BuildResponse -->|transport_send_framed| Client
```
## Implementation Steps
### Step 1: Add nip013 to nostr_core_lib build
The Makefile's `lib` target runs:
```
cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,19,44
```
Add `13` to the `--nips` flag so nip013.c is compiled into the static library.
**Files to modify:**
- [`Makefile`](Makefile:54) — change `--nips=1,4,6,19,44` to `--nips=1,4,6,13,19,44`
### Step 2: Add VERB_MINE_EVENT to enforcement.c
Add `mine_event` to the known nostr verbs so it passes enforcement.
**Files to modify:**
- [`src/enforcement.c`](src/enforcement.c:200) — add `#define VERB_MINE_EVENT "mine_event"` and add it to [`is_nostr_verb()`](src/enforcement.c:453)
- [`src/dispatcher.c`](src/dispatcher.c:200) — add the same `#define VERB_MINE_EVENT "mine_event"` (the headerless decls pattern means defines are repeated per-file)
### Step 3: Create src/miner.c — Multithreaded Best-Effort Mining Coordinator
This is the core new file. It implements a server-safe, best-effort mining loop.
**Key design:**
- No global variables (all state in context structs)
- No `exit()` calls (return result codes)
- No signal handlers (server handles signals)
- Time-based termination (not attempt-count-based)
- Tracks best event across all threads
- Returns best event + metadata regardless of whether target was reached
**Key structures:**
```c
typedef struct {
cJSON *best_event; /* best event found so far (mutex-protected) */
int best_difficulty; /* difficulty of best_event */
uint64_t total_attempts; /* total attempts across all threads */
int target_difficulty; /* 0 = no target, mine full timeout */
int target_reached; /* 1 if target was reached */
pthread_mutex_t mutex; /* protects best_event, best_difficulty, total_attempts */
volatile int stop; /* set by coordinator when target reached or timeout */
} mine_shared_state_t;
typedef struct {
mine_shared_state_t *shared;
cJSON *event_template; /* this thread's copy of the event */
unsigned char private_key[32];
int thread_id;
uint64_t nonce_start; /* starting nonce for this thread (thread_id * stride) */
uint64_t nonce_stride; /* increment per iteration to avoid overlap */
time_t deadline; /* absolute time to stop */
uint64_t attempts; /* this thread's attempt count */
} miner_worker_ctx_t;
typedef struct {
cJSON *best_event; /* caller frees */
int achieved_difficulty;
int target_difficulty;
int target_reached;
int elapsed_sec;
uint64_t total_attempts;
} mine_result_t;
```
**Main function:**
```c
/* Returns 0 on success (result populated with best event found), -1 on error */
int miner_run(cJSON *event, const unsigned char *private_key,
int target_difficulty, int thread_count, int timeout_sec,
mine_result_t *result);
```
**Mining loop (per thread):**
```c
while (!shared->stop && time(NULL) < deadline) {
/* Build event with current nonce */
cJSON *working_tags = cJSON_Duplicate(original_tags, 1);
update_nonce_tag(working_tags, nonce, target_difficulty);
cJSON *signed_event = nostr_create_and_sign_event(kind, content, working_tags,
private_key, timestamp);
cJSON_Delete(working_tags);
/* Check difficulty */
const char *id = cJSON_GetStringValue(cJSON_GetObjectItem(signed_event, "id"));
int difficulty = nostr_calculate_pow_difficulty(id);
/* Track best under mutex */
pthread_mutex_lock(&shared->mutex);
shared->total_attempts++;
if (difficulty > shared->best_difficulty) {
if (shared->best_event) cJSON_Delete(shared->best_event);
shared->best_event = cJSON_Duplicate(signed_event, 1);
shared->best_difficulty = difficulty;
}
if (target_difficulty > 0 && difficulty >= target_difficulty) {
shared->target_reached = 1;
shared->stop = 1;
}
pthread_mutex_unlock(&shared->mutex);
cJSON_Delete(signed_event);
nonce += nonce_stride;
attempts++;
}
```
**Files to create:**
- `src/miner.c` — mining coordinator (~250 lines)
### Step 4: Add crypto_mine_event() to key_store.c
Add a function that:
1. Parses the event JSON
2. Gets the role's private key from key_store
3. Calls `miner_run()` from miner.c
4. Builds the response JSON object (event string + metadata)
5. Zeroizes the private key copy
6. Returns the response JSON string (caller frees)
**Signature:**
```c
/* Returns newly-allocated JSON string with result object, or NULL on error.
* Caller frees. */
char *crypto_mine_event(const key_store_t *store, int role_index,
const char *event_json, int difficulty,
int threads, int timeout_sec);
```
**Response building:**
```c
cJSON *result_obj = cJSON_CreateObject();
cJSON *event_item = cJSON_CreateString(signed_event_json);
cJSON_AddItemToObject(result_obj, "event", event_item);
cJSON_AddNumberToObject(result_obj, "achieved_difficulty", result.achieved_difficulty);
cJSON_AddNumberToObject(result_obj, "target_difficulty", result.target_difficulty);
cJSON_AddBoolToObject(result_obj, "target_reached", result.target_reached);
cJSON_AddNumberToObject(result_obj, "elapsed_sec", result.elapsed_sec);
cJSON_AddNumberToObject(result_obj, "attempts", (double)result.total_attempts);
char *out = cJSON_PrintUnformatted(result_obj);
cJSON_Delete(result_obj);
return out;
```
**Files to modify:**
- [`src/key_store.c`](src/key_store.c:456) — add `#include <nostr_core/nip013.h>`, add `crypto_mine_event()` function, add forward declaration in the headerless decls section
### Step 5: Update Makefile
Add `miner.c` to SOURCES. pthread is already linked (`-lpthread` in LDFLAGS).
**Files to modify:**
- [`Makefile`](Makefile:13) — add `$(SRC_DIR)/miner.c \` to SOURCES list
### Step 6: Add dispatcher handler for mine_event
In [`dispatcher_handle_request()`](src/dispatcher.c:545), add a branch for `VERB_MINE_EVENT` that:
1. Extracts `event_json` from params[0]
2. Extracts `difficulty`, `threads`, `timeout_sec` from the options object (params[last])
3. Validates: at least one of `difficulty` or `timeout_sec` must be specified (else error `1007`); `threads` defaults to 1 (max 32); if only `difficulty` is set, safety timeout of 600 sec is applied
4. Calls `crypto_mine_event()`
5. Returns the result
**Files to modify:**
- [`src/dispatcher.c`](src/dispatcher.c:675) — add `else if (strcmp(method, VERB_MINE_EVENT) == 0)` branch after the `VERB_SIGN_EVENT` branch
### Step 7: Modify server.c for async mining
The current [`server_handle_one()`](src/server.c:1453) is synchronous. For `mine_event`, we need to:
1. Detect `mine_event` method after policy check passes
2. Spawn a detached thread that:
- Calls `dispatcher_handle_request()` (which calls `crypto_mine_event()`)
- Sends the response via `transport_send_framed()` on the client_fd
- Closes the client_fd
3. Return immediately from `server_handle_one()` without closing the fd (the thread owns it now)
**Thread function:**
```c
typedef struct {
int client_fd;
dispatcher_ctx_t *dispatcher;
char *request;
} mine_thread_arg_t;
static void *mine_event_thread(void *arg) {
mine_thread_arg_t *a = (mine_thread_arg_t *)arg;
char *response = dispatcher_handle_request(a->dispatcher, a->request);
if (response != NULL) {
(void)transport_send_framed(a->client_fd, response);
free(response);
}
free(a->request);
close(a->client_fd);
free(a);
return NULL;
}
```
**In server_handle_one**, after `pchk == POLICY_ALLOW` and before calling `dispatcher_handle_request()`:
```c
if (pchk == POLICY_ALLOW && strcmp(method, "mine_event") == 0) {
pthread_t tid;
mine_thread_arg_t *arg = malloc(sizeof(*arg));
if (arg == NULL) {
/* fall through to error path */
} else {
arg->client_fd = client_fd;
arg->dispatcher = ctx->dispatcher;
arg->request = request; /* transfer ownership */
request = NULL; /* prevent free in cleanup */
if (pthread_create(&tid, NULL, mine_event_thread, arg) == 0) {
pthread_detach(tid);
/* Skip synchronous response path — thread owns fd and request */
/* Log activity and return without closing fd */
verdict = "ALLOWED";
source_label = "async-mine";
/* ... log activity ... */
return 1;
}
/* pthread_create failed — fall through to error path */
free(arg->request);
free(arg);
}
}
```
**Files to modify:**
- [`src/server.c`](src/server.c:1755) — add mine_event async path before the synchronous `dispatcher_handle_request()` call
### Step 8: Add client demos
Add `mine_event` examples to the existing demo files.
**Files to modify:**
- [`client/demo_javascript.js`](client/demo_javascript.js:1) — add mine_event example
- [`client/demo_python.py`](client/demo_python.py:1) — add mine_event example
- [`client/demo_c99.c`](client/demo_c99.c:1) — add mine_event example
### Step 9: Add tests
Create a test that:
1. Starts nsigner with a test mnemonic
2. Sends a `mine_event` request with low difficulty (e.g., 2) and short timeout (e.g., 5 sec)
3. Verifies the response contains a signed event with a nonce tag
4. Verifies the `achieved_difficulty` >= 2 and `target_reached` is true
5. Sends a `mine_event` request with high difficulty (e.g., 30) and short timeout (e.g., 3 sec)
6. Verifies the response contains a signed event, `target_reached` is false, and `achieved_difficulty` < 30
**Files to create:**
- `tests/test_mine_event.c`
**Files to modify:**
- [`Makefile`](Makefile:37) add `TEST_MINE_EVENT_TARGET` and `test-mine-event` target
### Step 10: Update documentation
**Files to modify:**
- [`README.md`](README.md:1) add `mine_event` to the verbs table with params description
- [`client/README.md`](client/README.md:1) add mine_event usage example
## Security Considerations
1. **Private key never leaves key_store**: The mining thread receives a copy of the 32-byte private key, which is zeroized after use (following the pattern in [`crypto_sign_event()`](src/key_store.c:456))
2. **Policy enforcement applies**: `mine_event` goes through the same `policy_check()` and `enforce_verb_role()` as all other verbs
3. **Thread safety**:
- Each mining thread works on its own cJSON copies
- The shared state (best_event, best_difficulty, total_attempts) is protected by a mutex
- secp256k1 context is thread-safe for signing
4. **Resource limits**:
- Max threads capped at 32 to prevent resource exhaustion
- `timeout_sec` is required (must be > 0) to prevent infinite mining
- Optional: limit to 1 concurrent mining operation (reject with `mining_busy` if already running)
5. **Client fd ownership**: The detached thread owns the client_fd and is responsible for closing it. The main server loop must not close it.
## Concurrency Considerations
- Each mining thread uses a unique nonce stride (thread_id) to avoid duplicate work
- cJSON operations are NOT thread-safe across different cJSON objects, but each thread works on its own copies
- The result mutex protects only the shared best-event tracking
- `nostr_create_and_sign_event()` creates a new secp256k1 context per call (or uses a thread-local one) — need to verify this is thread-safe. If not, each thread may need its own context.
## File Summary
| File | Action | Description |
|------|--------|-------------|
| `Makefile` | Modify | Add nip013 to --nips, add miner.c to SOURCES, add test target |
| `src/enforcement.c` | Modify | Add VERB_MINE_EVENT to is_nostr_verb() |
| `src/dispatcher.c` | Modify | Add VERB_MINE_EVENT define + handler branch |
| `src/key_store.c` | Modify | Add crypto_mine_event() function + nip013 include |
| `src/miner.c` | Create | Multithreaded best-effort mining coordinator |
| `src/server.c` | Modify | Add async mine_event path with detached thread |
| `tests/test_mine_event.c` | Create | Integration test for mine_event |
| `client/demo_javascript.js` | Modify | Add mine_event example |
| `client/demo_python.py` | Modify | Add mine_event example |
| `client/demo_c99.c` | Modify | Add mine_event example |
| `README.md` | Modify | Document mine_event verb |
| `client/README.md` | Modify | Add mine_event usage |

View File

@@ -0,0 +1,123 @@
# Plan: nostr_core_lib client updates for new n_signer features
Status: design / ready for review.
Related:
- [`../nostr_core_lib/nostr_core/nsigner_transport.h`](../../nostr_core_lib/nostr_core/nsigner_transport.h) — transport vtable
- [`../nostr_core_lib/nostr_core/nsigner_client.h`](../../nostr_core_lib/nostr_core/nsigner_client.h) — low-level client
- [`../nostr_core_lib/nostr_core/nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h) — high-level signer
- [`../nostr_core_lib/nostr_core/nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c) — `signer_remote_params_with_selector`
- [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) — qrexec bridge design
- [`plans/interactive_transport_selection.md`](interactive_transport_selection.md) — index whitelist
---
## 1. Gaps identified
The `nostr_core_lib` client stack was built before the recent n_signer features. Three gaps need filling:
### 1.1 No `nostr_index` selector in the high-level API
`signer_remote_params_with_selector()` ([`nostr_signer.c:287`](../../nostr_core_lib/nostr_core/nostr_signer.c:287)) only emits `{"role":"..."}`. It does not support `{"nostr_index":N}`, which is n_signer's primary key-selection mechanism for Nostr identities (NIP-06 `m/44'/1237'/N'/0/0`).
The `nostr_signer_nsigner_*` constructors take a `const char* role` parameter. There's no way to say "use nostr_index 3" through the high-level API. Callers who want a specific Nostr identity by index must drop down to the low-level `nsigner_client_call` and build params manually — which is exactly what [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) do.
### 1.2 No qrexec transport
`nsigner_transport_open_*` supports unix, tcp, serial, fds — but not qrexec. On Qubes OS, the primary inter-qube path is `qrexec-client-vm <target> qubes.NsignerRpc`, which pipes framed I/O through stdin/stdout of a subprocess. There's no transport that wraps this.
### 1.3 No error code mapping for `index_not_allowed` (2002)
n_signer now returns `{"error":{"code":2002,"message":"index_not_allowed"}}` when the index whitelist denies a request. The client should surface this distinctly from `policy_denied` (2001) so callers can tell "this index isn't whitelisted" vs "this caller isn't approved".
---
## 2. Proposed changes
### 2.1 Add `nostr_index` selector to the high-level API
**Option A (minimal):** Add a new constructor variant that takes `nostr_index` instead of `role`:
```c
nostr_signer_t* nostr_signer_nsigner_unix_index(const char* socket_name, int nostr_index, int timeout_ms);
nostr_signer_t* nostr_signer_nsigner_tcp_index(const char* host, int port, int nostr_index, int timeout_ms);
/* etc. */
```
**Option B (cleaner):** Replace the `role` string parameter with a selector struct:
```c
typedef struct {
const char* role; /* NULL = use default */
int nostr_index; /* -1 = not set */
const char* role_path; /* NULL = not set */
} nsigner_selector_t;
nostr_signer_t* nostr_signer_nsigner_unix(const char* socket_name, const nsigner_selector_t* selector, int timeout_ms);
```
**Option C (additive, chosen):** Keep existing constructors, add a setter for nostr_index:
```c
int nostr_signer_nsigner_set_nostr_index(nostr_signer_t* signer, int nostr_index);
```
This sets the index on the remote backend, and `signer_remote_params_with_selector` emits `{"nostr_index":N}` instead of `{"role":"..."}` when set. Existing callers using `role` are unaffected.
**Decision: Option C** — additive, no API break, smallest change.
### 2.2 Add qrexec transport
New transport constructor:
```c
nsigner_transport_t* nsigner_transport_open_qrexec(const char* target_qube, const char* service_name, int timeout_ms);
```
Implementation: `fork()` + `execvp("qrexec-client-vm", [target_qube, service_name])` with `stdin`/`stdout` pipes. The `send_framed`/`recv_framed` vtable methods write/read through the pipes. `reconnect` re-spawns the subprocess (qrexec handles one request per invocation, so reconnect is mandatory before each call — same as the existing per-request reconnect pattern).
The transport is stateless per call: each `reconnect` spawns a fresh `qrexec-client-vm` process, sends one framed request, reads one framed response, and the process exits. This matches the qrexec execution model.
### 2.3 Add `nostr_signer_nsigner_qrexec` high-level constructor
```c
nostr_signer_t* nostr_signer_nsigner_qrexec(const char* target_qube, const char* service_name, const char* role, int timeout_ms);
```
Wraps `nsigner_transport_open_qrexec` + `nostr_signer_nsigner_from_transport`. No auth envelope needed — qrexec identity comes from `QREXEC_REMOTE_DOMAIN` on the server side.
### 2.4 Surface `index_not_allowed` error distinctly
Add to `nostr_common.h`:
```c
#define NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED -2002
```
In `nsigner_client_call` (or `nostr_signer` remote backend), when the response error code is 2002, map it to `NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED` so callers can distinguish it from generic policy denial.
---
## 3. Implementation checklist
In `nostr_core_lib`:
- [ ] `nsigner_transport.h` / `nsigner_transport.c`: add `nsigner_transport_open_qrexec(target_qube, service_name, timeout_ms)` — fork+exec `qrexec-client-vm`, pipe-based framed I/O, reconnect re-spawns.
- [ ] `nostr_signer.h` / `nostr_signer.c`: add `nostr_signer_nsigner_qrexec(target_qube, service_name, role, timeout_ms)`.
- [ ] `nostr_signer.c`: add `nostr_signer_nsigner_set_nostr_index(signer, nostr_index)`; update `signer_remote_params_with_selector` to emit `{"nostr_index":N}` when index is set.
- [ ] `nostr_common.h`: add `NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED` error code.
- [ ] `nsigner_client.c`: map error code 2002 to the new constant.
- [ ] `NSIGNER_INTEGRATION.md`: document the qrexec transport, nostr_index selector, and index_not_allowed error.
In `n_signer` (this repo):
- [ ] Update [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) to optionally use the high-level `nostr_signer` API with `nostr_index` selector (demonstrates the new client API).
- [ ] Add a C example for the qrexec path using the new `nostr_signer_nsigner_qrexec` constructor.
---
## 4. Open questions
1. **Should the qrexec transport live in `nostr_core_lib` or in a Qubes-specific addon?** The transport is only useful on Qubes OS, but `nostr_core_lib` is meant to be cross-platform. **Decision: put it in `nostr_core_lib` behind `NOSTR_ENABLE_NSIGNER_CLIENT` — it just won't work on non-Qubes hosts (execvp fails). No new feature flag needed.**
2. **Should `nostr_signer_nsigner_set_nostr_index` override `role` or coexist?** n_signer rejects ambiguous selectors (both `role` and `nostr_index` set = `ambiguous_role_selector`). **Decision: setting nostr_index clears role, and vice versa. The setter is exclusive.**

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.

View File

@@ -0,0 +1,567 @@
# Plan: Post-Quantum Cryptography + Standard ECC Expansion for n_signer
## 1. Goal
Expand n_signer's crypto palette from the current single algorithm (secp256k1 for Nostr) to a complete offering:
**Standard ECC (classical):**
- `ed25519` — SSH signing keys (works with current OpenSSH), general-purpose signatures
- `x25519` — key agreement (age encryption, WireGuard-style identities, SSH KEX classical leg)
**Post-quantum (NIST FIPS standardized):**
- `ML-DSA-65` (FIPS 204, lattice-based signatures) — post-quantum digital signatures
- `SLH-DSA-128s` (FIPS 205, hash-based signatures) — post-quantum signatures with minimal trust assumptions
- `ML-KEM-768` (FIPS 203, lattice-based KEM) — post-quantum key encapsulation for encryption
All six algorithms are always compiled in on all targets (host x86_64 static binary and ESP32 firmware). Size is acceptable on both; signing speed for SLH-DSA-128s on ESP32 may be slow (5-30s) but the user accepts this and will choose whether to use it per-role.
## 2. Background and context
### 2.1 Current state
n_signer models three curves in [`role_curve_t`](src/role_table.c:89) (`secp256k1`, `ed25519`, `x25519`) and five purposes in [`role_purpose_t`](src/role_table.c:79) (`nostr`, `bitcoin`, `ssh`, `age`, `fips`). However, only `PURPOSE_NOSTR + CURVE_SECP256K1` is actually implemented:
- [`crypto_derive_all`](src/key_store.c:484) and [`crypto_derive_one`](src/key_store.c:548) skip any role that isn't `PURPOSE_NOSTR + CURVE_SECP256K1 + SELECTOR_NOSTR_INDEX`.
- [`enforce_verb_role`](src/enforcement.c:468) only allows nostr verbs on `PURPOSE_NOSTR + CURVE_SECP256K1`; everything else returns `ENFORCE_ERR_UNKNOWN_VERB` or a mismatch.
- [`derived_key_t`](src/key_store.c:303) uses fixed 32-byte arrays for private/public keys — insufficient for PQ keys (ML-DSA-65 priv is 4032 bytes, pub 1952 bytes).
### 2.2 PQ key derivation from mnemonic
PQ private keys are not scalars — they are complex mathematical structures (polynomial matrices for lattice schemes, hypertree seeds for hash-based schemes). You cannot use a 32-byte BIP-32 output directly as a PQ private key.
However, all three PQ algorithms internally use a seed to generate the full key pair via keygen. The approach:
1. Derive a 32-byte seed from the mnemonic using BIP-32/HMAC (same as secp256k1 today, using a PQ-specific derivation path)
2. Feed that seed into a deterministic PRNG (SHA-256-DRBG or SHAKE-256)
3. Replace PQClean's `randombytes()` callback with this PRNG so keygen is deterministic
4. The algorithm expands the seed into the full key pair
This gives **deterministic, mnemonic-recoverable PQ keys** — same mnemonic, same role, same key pair every time. The crash-equals-wipe model from [`README.md`](README.md) is preserved because we re-derive from the mnemonic on every startup. No persistence needed.
This is the same technique used by PQM4 (the MCU post-quantum benchmark project) for deterministic test vectors.
### 2.3 SSH post-quantum landscape
Per OpenSSH's PQ page (openssh.com/pq.html):
- **KEX (key agreement)**: PQ supported since OpenSSH 9.0, default since 10.0 (`mlkem768x25519-sha256`). This is session encryption, handled by the SSH protocol — n_signer is not involved.
- **Signature/authentication keys**: "OpenSSH will add support for post-quantum signature algorithms in the future." Not yet implemented. SSH signing keys are still classical (ed25519, RSA, ECDSA).
Implication: ed25519 is still required for SSH signing keys today. ML-DSA-65 is forward-looking — we build the primitive before the ecosystem supports it. When OpenSSH adds PQ signing keys, n_signer will already have ML-DSA-65 ready.
### 2.4 Library choice: PQClean
**PQClean** (public domain / CC0) is the reference implementation source for all three algorithms. Reasons:
- **ESP32-compatible**: Self-contained C, no build system dependency, proven on Cortex-M4 via PQM4. No CMake, no provider loading, no OpenSSL 3.x requirement.
- **Minimal footprint**: Vendor only the three algorithm folders. No infrastructure code from liboqs.
- **Public domain**: No license complexity for a security-critical project.
- **`randombytes()` hook**: Each algorithm calls a `randombytes()` function that we can replace with our mnemonic-seeded PRNG for deterministic keygen.
liboqs was considered but rejected: designed for servers, CMake-heavy, pulls in infrastructure code not needed on MCU, no MCU deployments. oqs-provider was rejected: requires OpenSSL 3.x provider loading, not viable on ESP32.
For ed25519 and x25519: use existing OpenSSL (already linked) or libsecp256k1's sibling curve25519 code. OpenSSL's EVP_PKEY API already supports both. On ESP32, use the ESP-IDF mbedtls component which has ed25519/x25519 support.
## 3. Design
### 3.1 Algorithm registry
Introduce a new `src/pq_crypto.c` / `pq_crypto.h` (headerless-decls style matching the project convention) that provides a unified interface over all six algorithms. The core abstraction:
```c
/* Algorithm identifiers */
typedef enum {
CRYPTO_ALG_SECP256K1 = 0, /* existing, Nostr */
CRYPTO_ALG_ED25519, /* new, SSH signatures */
CRYPTO_ALG_X25519, /* new, key agreement */
CRYPTO_ALG_ML_DSA_65, /* new, PQ signatures */
CRYPTO_ALG_SLH_DSA_128S, /* new, PQ signatures */
CRYPTO_ALG_ML_KEM_768, /* new, PQ KEM */
CRYPTO_ALG_UNKNOWN
} crypto_alg_t;
/* Key sizes for each algorithm (compile-time constants) */
typedef struct {
size_t priv_key_len;
size_t pub_key_len;
size_t sig_len; /* 0 for KEM */
size_t ciphertext_len; /* 0 for signatures */
size_t shared_secret_len; /* 0 for signatures */
} crypto_alg_sizes_t;
const crypto_alg_sizes_t *crypto_alg_get_sizes(crypto_alg_t alg);
/* Map role_curve_t + role_purpose_t to crypto_alg_t */
crypto_alg_t crypto_alg_from_role(role_curve_t curve, role_purpose_t purpose);
```
### 3.2 Variable-length key storage
Replace the fixed 32-byte [`derived_key_t`](src/key_store.c:303) with a variable-length design:
```c
typedef struct {
secure_buf_t private_key; /* mlock'd, variable size per algorithm */
secure_buf_t public_key; /* mlock'd, variable size per algorithm */
char pubkey_hex[8192]; /* hex-encoded public key (PQ pubkeys are large) */
char key_id[128]; /* human-readable key identifier (bech32/hex/algorithm-specific) */
crypto_alg_t alg; /* which algorithm this key was derived for */
int valid;
} derived_key_t;
```
The `secure_buf_t` already supports variable sizes via [`secure_buf_alloc`](src/secure_mem.c). The change is allocating different sizes per role based on the algorithm.
### 3.3 Derivation paths
Each algorithm gets a distinct BIP-32 derivation path from the mnemonic. All paths follow the standard 5-level BIP-44 structure for consistency: `m/purpose'/coin_type'/account'/change/index`. This ensures no two algorithms derive from the same path (avoiding key reuse across algorithms) and maintains compatibility with standard HD wallet tooling.
| Algorithm | Purpose | Derivation path | Notes |
|---|---|---|---|
| secp256k1 | nostr | `m/44'/1237'/<n>'/0/0` (existing) | NIP-06, unchanged |
| secp256k1 | bitcoin | `m/44'/0'/<account>'/0/0` etc. | BIP-44, future |
| ed25519 | ssh | `m/44'/102001'/<n>'/0'/0'` | SLIP-0010 ed25519 (all hardened) |
| x25519 | age | `m/44'/102002'/<n>'/0'/0'` | SLIP-0010 x25519 (all hardened) |
| ML-DSA-65 | pq-sig | `m/44'/102003'/<n>'/0'/0'` → seed → PQClean keygen | PQ-SIG coin type |
| SLH-DSA-128s | pq-sig | `m/44'/102004'/<n>'/0'/0'` → seed → PQClean keygen | PQ-HASH-SIG coin type |
| ML-KEM-768 | pq-kem | `m/44'/102005'/<n>'/0'/0'` → seed → PQClean keygen | PQ-KEM coin type |
**Path structure notes:**
- **Purpose (level 0):** Always `44'` (BIP-44). This is the registered BIP-44 purpose for HD wallets. Using a different purpose number (e.g., 204 for PQ-SIG) at level 0 would be non-standard; instead we use distinct **coin_type** values at level 1 to separate algorithm families.
- **Coin type (level 1):** Distinct coin type per algorithm family: `1237` (Nostr, existing NIP-06), `102001` (SSH/ed25519), `102002` (age/x25519), `102003` (PQ-SIG/ML-DSA), `102004` (PQ-HASH-SIG/SLH-DSA), `102005` (PQ-KEM/ML-KEM). The 102XXX range was chosen because it is unregistered in [SLIP-44](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) and unlikely to be claimed by real cryptocurrencies, avoiding future path collisions.
- **Account (level 2):** The `<n>` index — this is the `nostr_index` or equivalent role index. Hardened (`'`).
- **Change (level 3):** `0` for secp256k1 (standard BIP-44 external chain). `0'` (hardened) for ed25519/x25519/PQ because SLIP-0010 requires all-hardened derivation.
- **Index (level 4):** `0` for secp256k1 (standard BIP-44 first address). `0'` (hardened) for ed25519/x25519/PQ, same SLIP-0010 reason.
**Why all-hardened for ed25519/x25519/PQ:** SLIP-0010 (the standard for ed25519/x25519 HD derivation) requires that every derivation step be hardened — non-hardened derivation is not possible with ed25519 because the public key cannot be used to derive child public keys (the math doesn't work the same as secp256k1). For PQ, we follow the same convention since we're using the path output as a seed, and hardened derivation provides stronger isolation between derived keys.
The path produces a 32-byte seed (512-bit extended key, of which 256 bits are the private key material). For ECC algorithms, this seed IS the private key (or is processed via SLIP-0010's derivation). For PQ algorithms, this seed feeds the deterministic PRNG (SHA-256-DRBG) that replaces PQClean's `randombytes()` during keygen.
### 3.4 New purpose values
Extend [`role_purpose_t`](src/role_table.c:79):
```c
typedef enum {
PURPOSE_NOSTR = 0,
PURPOSE_BITCOIN,
PURPOSE_SSH,
PURPOSE_AGE,
PURPOSE_FIPS,
PURPOSE_PQ_SIG, /* new — post-quantum signatures (ML-DSA, SLH-DSA) */
PURPOSE_PQ_KEM, /* new — post-quantum key encapsulation (ML-KEM) */
PURPOSE_UNKNOWN
} role_purpose_t;
```
### 3.5 New curve values
Extend [`role_curve_t`](src/role_table.c:89):
```c
typedef enum {
CURVE_SECP256K1 = 0,
CURVE_ED25519,
CURVE_X25519,
CURVE_ML_DSA_65, /* new */
CURVE_SLH_DSA_128S, /* new */
CURVE_ML_KEM_768, /* new */
CURVE_UNKNOWN
} role_curve_t;
```
String mappings in [`role_curve_from_str`](src/role_table.c:597) / [`role_curve_to_str`](src/role_table.c:631):
- `"ml-dsa-65"``CURVE_ML_DSA_65`
- `"slh-dsa-128s"``CURVE_SLH_DSA_128S`
- `"ml-kem-768"``CURVE_ML_KEM_768`
### 3.6 New verbs
Add verbs for the new crypto operations:
| Verb | Purpose | Curve | Description |
|---|---|---|---|
| `get_public_key` | all | all | Already exists; extend to return algorithm-appropriate pubkey format |
| `sign_data` | pq-sig, ssh | ml-dsa-65, slh-dsa-128s, ed25519 | Sign arbitrary bytes (not a Nostr event) |
| `verify_signature` | pq-sig, ssh | ml-dsa-65, slh-dsa-128s, ed25519 | Verify a signature (optional — signer can verify, or client verifies) |
| `kem_encapsulate` | pq-kem | ml-kem-768 | Generate ciphertext + shared secret from peer's ML-KEM public key |
| `kem_decapsulate` | pq-kem | ml-kem-768 | Decapsulate ciphertext to recover shared secret |
| `ssh_sign` | ssh | ed25519 | Sign SSH authentication challenge (ed25519-specific format) |
The existing nostr verbs (`sign_event`, `nip44_encrypt`, etc.) remain unchanged and still require `PURPOSE_NOSTR + CURVE_SECP256K1`.
### 3.7 Enforcement rules
Extend [`enforce_verb_role`](src/enforcement.c:468) with new verb→purpose→curve rules:
```c
/* Nostr verbs: unchanged — PURPOSE_NOSTR + CURVE_SECP256K1 only */
/* sign_data: allowed on PQ-SIG or SSH purposes */
/* PURPOSE_PQ_SIG + CURVE_ML_DSA_65 → OK */
/* PURPOSE_PQ_SIG + CURVE_SLH_DSA_128S → OK */
/* PURPOSE_SSH + CURVE_ED25519 → OK */
/* kem_encapsulate / kem_decapsulate: */
/* PURPOSE_PQ_KEM + CURVE_ML_KEM_768 → OK */
/* ssh_sign: */
/* PURPOSE_SSH + CURVE_ED25519 → OK */
```
Fail-closed for any unlisted combination, preserving the existing security model from [`plans/deny_by_default_approvals.md`](plans/deny_by_default_approvals.md).
### 3.8 Public key output format
The `get_public_key` verb currently returns a 64-hex-char secp256k1 pubkey. For PQ keys, the public keys are much larger. The response format changes to include algorithm metadata:
```json
{
"id": "...",
"result": {
"algorithm": "ml-dsa-65",
"public_key": "<hex-encoded, up to ~4KB>",
"key_id": "<short identifier for display>"
}
}
```
For backward compatibility, secp256k1/nostr roles continue to return the plain hex string (the existing format) unless the client requests the structured format via an options flag.
### 3.9 PQClean integration
Vendor PQClean into `resources/pqclean/` with only the three algorithm folders:
```
resources/pqclean/
├── crypto_sign/
│ ├── ml-dsa-65/
│ │ ├── api.h
│ │ ├── sign.c
│ │ ├── params.h
│ │ └── ... (algorithm-specific files)
│ └── slh-dsa-128s/
│ ├── api.h
│ ├── sign.c
│ ├── params.h
│ └── ...
├── crypto_kem/
│ └── ml-kem-768/
│ ├── api.h
│ ├── kem.c
│ ├── params.h
│ └── ...
├── common/ /* shared utilities (fips202, sha2, randombytes hook) */
│ ├── fips202.c
│ ├── sha2.c
│ └── randombytes.c /* we replace this with our PRNG */
└── pqclean.h /* umbrella include */
```
Each PQClean algorithm exposes a simple API:
```c
int crypto_sign_keypair(unsigned char *pk, unsigned char *sk); /* uses randombytes() */
int crypto_sign(unsigned char *sig, size_t *siglen,
const unsigned char *m, size_t mlen,
const unsigned char *sk);
int crypto_sign_open(unsigned char *m, size_t *mlen,
const unsigned char *sm, size_t smlen,
const unsigned char *pk);
int crypto_kem_keypair(unsigned char *pk, unsigned char *sk);
int crypto_kem_enc(unsigned char *ct, unsigned char *ss, const unsigned char *pk);
int crypto_kem_dec(unsigned char *ss, const unsigned char *ct, const unsigned char *sk);
```
### 3.10 Deterministic PRNG for PQ keygen
New file `src/pq_drbg.c`:
```c
/* SHA-256-DRBG seeded from mnemonic-derived seed.
* Replaces PQClean's randombytes() for deterministic keygen. */
void pq_drbg_init(const unsigned char *seed, size_t seed_len);
int pq_drbg_randombytes(unsigned char *buf, size_t len);
void pq_drbg_zeroize(void);
```
PQClean's `randombytes.c` is replaced with a shim that calls `pq_drbg_randombytes()`. During keygen, the DRBG is seeded from the mnemonic-derived path seed. After keygen, the DRBG is zeroized.
For signing operations, PQClean's sign functions do not call `randombytes()` (ML-DSA and SLH-DSA are deterministic signatures), so the DRBG is only needed during keygen.
### 3.11 ed25519 / x25519 implementation
**Host (x86_64 static binary):** Use OpenSSL's EVP_PKEY API (already linked via `-lssl -lcrypto` in [`Makefile`](Makefile:3)). Functions:
- `EVP_PKEY_keygen_init` / `EVP_PKEY_keygen` for key generation
- `EVP_DigestSign` / `EVP_DigestVerify` for ed25519 signing
- `EVP_PKEY_derive` for x25519 key agreement
**ESP32 firmware:** Use ESP-IDF's mbedtls component (`mbedtls_ed25519_*`, `mbedtls_ecdh_*` with X25519 curve). Already available in the ESP-IDF dependency tree.
The abstraction in `src/pq_crypto.c` wraps both backends behind the same interface so the dispatcher and key_store are backend-agnostic.
### 3.12 Build system changes
#### Makefile (host)
Add PQClean source files to [`SOURCES`](Makefile:13):
```makefile
PQCLEAN_DIR := resources/pqclean
PQCLEAN_SOURCES := \
$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65/sign.c \
$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65/... \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/sign.c \
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/... \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/kem.c \
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/... \
$(PQCLEAN_DIR)/common/fips202.c \
$(PQCLEAN_DIR)/common/sha2.c \
src/pq_crypto.c \
src/pq_drbg.c
SOURCES += $(PQCLEAN_SOURCES)
CFLAGS += -I$(PQCLEAN_DIR) -I$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65 ...
```
#### Dockerfile.alpine-musl
No new system packages needed — PQClean is vendored C source compiled by the existing gcc/musl toolchain. The build already has `-lssl -lcrypto` for ed25519/x25519.
#### ESP32 firmware
Add PQClean sources to the firmware CMakeLists.txt. ESP32-S3 has hardware SHA-2 acceleration which benefits SLH-DSA-128s. The PQClean SHA-2 implementation can be replaced with ESP-IDF's hardware-accelerated `mbedtls_sha256` for a significant speedup.
### 3.13 Architecture diagram
```mermaid
graph TD
M[BIP-39 Mnemonic] --> DERIVE[crypto_derive_all]
DERIVE --> |m/44'/1237'/n'| SECP[secp256k1 keypair]
DERIVE --> |m/44'/1238'/n'| ED25519[ed25519 keypair]
DERIVE --> |m/44'/1239'/n'| X25519[x25519 keypair]
DERIVE --> |m/44'/204'/n' seed| DRBG[SHA-256-DRBG]
DRBG --> |randombytes hook| MLDSA[ML-DSA-65 keypair]
DERIVE --> |m/44'/205'/n' seed| DRBG2[SHA-256-DRBG]
DRBG2 --> |randombytes hook| SLHDSA[SLH-DSA-128s keypair]
DERIVE --> |m/44'/206'/n' seed| DRBG3[SHA-256-DRBG]
DRBG3 --> |randombytes hook| MLKEM[ML-KEM-768 keypair]
SECP --> KS[Key Store - variable length]
ED25519 --> KS
X25519 --> KS
MLDSA --> KS
SLHDSA --> KS
MLKEM --> KS
KS --> DISP[Dispatcher]
DISP --> |sign_event| SECP
DISP --> |sign_data| ED25519
DISP --> |sign_data| MLDSA
DISP --> |sign_data| SLHDSA
DISP --> |kem_encaps/decaps| MLKEM
DISP --> |ssh_sign| ED25519
```
## 4. Implementation phases
### Phase 1 — Foundation: variable-length key store + algorithm registry
**Goal:** Refactor the key store to support variable-length keys and introduce the algorithm abstraction. No new crypto yet — secp256k1 continues to work through the new abstraction.
**Files:**
- `src/pq_crypto.c` / `pq_crypto.h` (new) — algorithm registry, size tables, `crypto_alg_from_role()`
- `src/key_store.c` (modify) — replace fixed 32-byte `derived_key_t` with variable-length `secure_buf_t` for both priv and pub; route secp256k1 through the new abstraction
- `src/role_table.c` (modify) — add new purpose/curve enum values and string mappings
- `tests/test_key_store.c` (new or extend) — verify secp256k1 still works after refactor
**Exit criteria:**
- `make test` passes (all existing tests)
- secp256k1 key derivation and signing works through the new abstraction
- `derived_key_t` uses `secure_buf_t` for both private and public keys
- New enum values exist but are not yet wired to crypto
### Phase 2 — ed25519 + x25519 (standard ECC)
**Goal:** Implement ed25519 signing and x25519 key agreement using OpenSSL on host.
**Files:**
- `src/pq_crypto.c` (extend) — add ed25519 keygen, sign, verify; x25519 keygen, derive
- `src/key_store.c` (extend) — derive ed25519/x25519 keys from mnemonic via SLIP-0010 paths
- `src/enforcement.c` (extend) — add `sign_data` and `ssh_sign` verbs for `PURPOSE_SSH + CURVE_ED25519`
- `src/dispatcher.c` (extend) — handle new verbs
- `tests/test_pq_crypto.c` (new) — ed25519 sign/verify roundtrip, x25519 ECDH roundtrip
**Exit criteria:**
- ed25519 key derived from mnemonic, signs data, signature verifies
- x25519 key derived from mnemonic, ECDH shared secret matches between two derived keys
- `sign_data` verb works for ed25519
- Enforcement rejects ed25519 on nostr verbs and vice versa
### Phase 3 — PQClean vendoring + ML-DSA-65
**Goal:** Vendor PQClean, implement deterministic keygen via DRBG, get ML-DSA-65 working.
**Files:**
- `resources/pqclean/` (new) — vendored PQClean ML-DSA-65 + common files
- `src/pq_drbg.c` / `pq_drbg.h` (new) — SHA-256-DRBG seeded from mnemonic
- `resources/pqclean/common/randombytes.c` (replace) — shim calling `pq_drbg_randombytes()`
- `src/pq_crypto.c` (extend) — ML-DSA-65 keygen, sign, verify
- `src/key_store.c` (extend) — derive ML-DSA-65 keys via DRBG-seeded keygen
- `src/enforcement.c` (extend) — `sign_data` for `PURPOSE_PQ_SIG + CURVE_ML_DSA_65`
- `src/dispatcher.c` (extend) — handle ML-DSA-65 signing
- `Makefile` (modify) — add PQClean sources
- `Dockerfile.alpine-musl` (verify) — static build still works with PQClean
- `tests/test_pq_crypto.c` (extend) — ML-DSA-65 keygen determinism, sign/verify roundtrip, known-answer test
**Exit criteria:**
- ML-DSA-65 keypair derived deterministically from mnemonic (same mnemonic + role = same keypair)
- Signature verifies with PQClean's verify function
- Static binary builds with PQClean linked
- Known-answer test passes (test vector from NIST or PQClean)
### Phase 4 — SLH-DSA-128s
**Goal:** Add hash-based PQ signatures.
**Files:**
- `resources/pqclean/crypto_sign/slh-dsa-128s/` (new) — vendored PQClean SLH-DSA-128s
- `src/pq_crypto.c` (extend) — SLH-DSA-128s keygen, sign, verify
- `src/key_store.c` (extend) — derive SLH-DSA-128s keys
- `src/enforcement.c` (extend) — `sign_data` for `PURPOSE_PQ_SIG + CURVE_SLH_DSA_128S`
- `src/dispatcher.c` (extend) — handle SLH-DSA-128s signing
- `Makefile` (modify) — add SLH-DSA-128s sources
- `tests/test_pq_crypto.c` (extend) — SLH-DSA-128s keygen determinism, sign/verify roundtrip
**Exit criteria:**
- SLH-DSA-128s keypair derived deterministically from mnemonic
- Signature verifies
- Known-answer test passes
- Document the signing latency on ESP32 (measure and record, no mitigation required)
### Phase 5 — ML-KEM-768
**Goal:** Add PQ key encapsulation.
**Files:**
- `resources/pqclean/crypto_kem/ml-kem-768/` (new) — vendored PQClean ML-KEM-768
- `src/pq_crypto.c` (extend) — ML-KEM-768 keygen, encaps, decaps
- `src/key_store.c` (extend) — derive ML-KEM-768 keys
- `src/enforcement.c` (extend) — `kem_encapsulate` / `kem_decapsulate` for `PURPOSE_PQ_KEM + CURVE_ML_KEM_768`
- `src/dispatcher.c` (extend) — handle KEM verbs
- `Makefile` (modify) — add ML-KEM-768 sources
- `tests/test_pq_crypto.c` (extend) — ML-KEM-768 keygen determinism, encaps/decaps roundtrip, shared secret matches
**Exit criteria:**
- ML-KEM-768 keypair derived deterministically from mnemonic
- Encapsulate with public key produces ciphertext + shared secret
- Decapsulate with private key + ciphertext recovers same shared secret
- Known-answer test passes
### Phase 6 — Public key output format + client API
**Goal:** Update the `get_public_key` response to handle large PQ public keys and algorithm metadata.
**Files:**
- `src/dispatcher.c` (modify) — structured `get_public_key` response for non-secp256k1 algorithms
- `client/` (modify) — client library handles new response format
- `documents/CLIENT_IMPLEMENTATION.md` (modify) — document new verbs and response formats
- `examples/` (new) — example clients for PQ signing and KEM
**Exit criteria:**
- `get_public_key` returns algorithm-appropriate format for all 6 algorithms
- secp256k1 backward compatibility preserved (plain hex string by default)
- Client examples demonstrate PQ signing and KEM usage
### Phase 7 — ESP32 firmware port
**Goal:** Get PQ algorithms working on ESP32-S3 firmware.
**Files:**
- `firmware/feather_s3_tft/main/` (modify) — add PQClean sources to firmware build, replace SHA-2 with hardware-accelerated mbedtls
- `firmware/feather_s3_tft/CMakeLists.txt` (modify) — add PQClean source files
- `firmware/cyd_esp32_2432s028/CMakeLists.txt` (modify) — same
**Exit criteria:**
- ESP32-S3 firmware builds with all 6 algorithms
- ML-DSA-65 signing works on device (measure latency)
- SLH-DSA-128s signing works on device (measure and document latency)
- ML-KEM-768 encaps/decaps works on device
- ed25519/x25519 work on device via mbedtls
- Flash usage measured and documented (confirm it fits)
### Phase 8 — Documentation
**Files:**
- `README.md` (modify) — add PQ algorithms to the crypto palette section, document new verbs
- `documents/SECURITY.md` (modify) — PQ threat model, deterministic derivation explanation, SLH-DSA latency note
- `plans/seed_phrase_uses.md` (modify) — add PQ purpose domains to the catalog
- `documents/CLIENT_IMPLEMENTATION.md` (modify) — full verb reference for new operations
## 5. Threat model considerations
### 5.1 Deterministic PQ key derivation
The mnemonic-seeded DRBG approach is non-standard. There is no NIST or IETF specification for deriving PQ keys from a BIP-39 mnemonic. The security argument:
- The 32-byte seed from BIP-32 derivation has full 256 bits of entropy (assuming the mnemonic has full entropy).
- SHA-256-DRBG is a NIST-approved DRBG (SP 800-90A). Seeding it with 256 bits of entropy is sufficient for all three PQ algorithms.
- The alternative (random PQ keys, no mnemonic recovery) breaks n_signer's core model. The deterministic approach is the right tradeoff for this project.
**Risk:** If a weakness is found in using DRBG output as PQ keygen randomness, all PQ keys derived this way could be affected. Mitigation: the DRBG is seeded per-role with distinct derivation paths, so compromising one role's PQ key does not compromise others.
### 5.2 PQ algorithm maturity
ML-DSA, SLH-DSA, and ML-KEM are FIPS-standardized (FIPS 203, 204, 205). They have undergone extensive NIST scrutiny. However, they are newer than classical algorithms. The plan does not replace secp256k1 for Nostr — PQ algorithms are additional options, not replacements.
### 5.3 SLH-DSA-128s signing latency on ESP32
SLH-DSA-128s signing on ESP32 could take 5-30 seconds. This is not a security issue but a UX consideration. The approval prompt flow on the feather/CYD should show a "signing..." indicator during the operation. The user has accepted this and will choose whether to use SLH-DSA-128s per-role.
### 5.4 Key size and memory
PQ private keys are large (ML-DSA-65: 4032 bytes, ML-KEM-768: 2400 bytes). With `ROLE_TABLE_MAX_ENTRIES` at 256, a full table of PQ keys would use ~1MB of mlock'd memory. This is acceptable on host. On ESP32 with 512KB SRAM, the role table should be smaller or PQ roles should be derived on-demand rather than all at startup. The existing on-demand derivation in [`crypto_derive_one`](src/key_store.c:548) already supports this pattern.
## 6. What is explicitly out of scope
1. **Hybrid signatures** (e.g., ed25519 + ML-DSA combined signature). No standard exists yet for SSH. Will be a follow-up when OpenSSH defines the format.
2. **PQ SSH signing key format**. OpenSSH does not support PQ signing keys yet. We build the primitive; the SSH wire format is future work.
3. **Bitcoin purpose implementation**. `PURPOSE_BITCOIN` remains modeled but not implemented. Separate plan.
4. **FIPS purpose with PQ algorithms**. The FIPS mesh transport purpose is orthogonal to the crypto algorithm. Separate concern.
5. **PQ key persistence**. Breaks the crash-equals-wipe model. Explicitly rejected.
6. **NIP-44/NIP-04 with PQ keys**. Nostr encryption uses secp256k1 ECDH. PQ KEM is a separate encryption path, not a Nostr NIP.
7. **liboqs or oqs-provider**. Rejected in favor of PQClean for ESP32 compatibility.
## 7. Open questions
1. **PQ public key encoding for `get_public_key`**: Should PQ public keys be returned as raw hex, base64, or a structured format? Plan defaults to hex for consistency with secp256k1, but PQ pubkeys are large (ML-DSA-65 pub is 1952 bytes = 3904 hex chars). Base64 would be more compact. Decision can be made at implementation time.
2. **Derivation path purpose codes**: The plan proposes `44'/204'`, `44'/205'`, `44'/206'` for PQ-SIG, PQ-HASH-SIG, PQ-KEM. These are unregistered BIP-44 purpose codes. If there is a future standard for PQ derivation paths, these may need to change. Acceptable for now since the derivation is internal to n_signer.
3. **ESP32 role table size for PQ**: Should the firmware limit the number of PQ roles to control memory usage, or rely on on-demand derivation? Plan defaults to on-demand derivation (already supported) with no hard limit change.
4. **Verify-on-signer for `sign_data`**: Should the signer verify its own PQ signatures before returning them to the client? Adds latency but catches implementation bugs. Plan defaults to no (client verifies), matching the existing `sign_event` behavior.
## 8. Files touched summary
| File | Action | Phase |
|---|---|---|
| `src/pq_crypto.c` / `pq_crypto.h` | New | 1-5 |
| `src/pq_drbg.c` / `pq_drbg.h` | New | 3 |
| `src/key_store.c` | Modify (variable-length keys, new derivations) | 1-5 |
| `src/role_table.c` | Modify (new enum values, string mappings) | 1 |
| `src/enforcement.c` | Modify (new verb→purpose→curve rules) | 2-5 |
| `src/dispatcher.c` | Modify (new verb handlers, structured pubkey response) | 2-6 |
| `src/main.c` | Modify (headerless decls for new types) | 1-5 |
| `resources/pqclean/` | New (vendored PQClean) | 3-5 |
| `Makefile` | Modify (PQClean sources, includes) | 3-5 |
| `Dockerfile.alpine-musl` | Verify (static build with PQClean) | 3 |
| `tests/test_pq_crypto.c` | New | 2-5 |
| `firmware/feather_s3_tft/` | Modify (PQClean in firmware build) | 7 |
| `firmware/cyd_esp32_2432s028/` | Modify (same) | 7 |
| `README.md` | Modify (crypto palette, new verbs) | 8 |
| `documents/SECURITY.md` | Modify (PQ threat model) | 8 |
| `documents/CLIENT_IMPLEMENTATION.md` | Modify (new verb docs) | 6, 8 |
| `plans/seed_phrase_uses.md` | Modify (PQ purpose domains) | 8 |
| `examples/` | New (PQ signing + KEM examples) | 6 |

View File

@@ -92,15 +92,57 @@ This prevents "same seed means same trust domain" mistakes by separating identit
- Per-service deterministic auth keys (HMAC or asymmetric challenge keys depending on service model). - Per-service deterministic auth keys (HMAC or asymmetric challenge keys depending on service model).
- Strongly policy-scoped to avoid accidental cross-service linkage. - Strongly policy-scoped to avoid accidental cross-service linkage.
### 3.7 Post-quantum signature identities (`purpose="pq-sig"`)
- Candidate curves: `ml-dsa-65`, `slh-dsa-128s`
- Derivation paths:
- ML-DSA-65: `m/44'/102003'/<n>'/0'/0'` → seed → SHAKE-256 DRBG → PQClean keygen
- SLH-DSA-128s: `m/44'/102004'/<n>'/0'/0'` → seed → SHAKE-256 DRBG → PQClean keygen
- Uses:
- PQ-aware protocol signatures
- future SSH PQ signing keys (when OpenSSH adds support)
- general-purpose PQ signatures for forward-looking applications
- Note: OpenSSH does not yet support PQ signing keys. These are forward-looking — the primitives are ready for when the ecosystem adopts them. See [`plans/post_quantum_crypto.md`](post_quantum_crypto.md) §2.3 for the SSH PQ landscape.
- Note: SLH-DSA-128s signing on ESP32 can take 530 seconds. Choose per-role whether the latency tradeoff is acceptable.
- Verbs: `sign_data`, `verify_signature` (see [`README.md`](../README.md) §5).
### 3.8 Post-quantum key encapsulation (`purpose="pq-kem"`)
- Candidate curves: `ml-kem-768`
- Derivation path: `m/44'/102005'/<n>'/0'/0'` → seed → SHAKE-256 DRBG → PQClean keygen
- Uses:
- PQ key agreement for encryption (encapsulate a shared secret against a peer's ML-KEM-768 public key)
- hybrid PQ+classical key exchange (client combines ML-KEM-768 with x25519)
- future PQ TLS session key establishment
- Note: ML-KEM-768 addresses the harvest-now-decrypt-later threat for key agreement. See [`documents/SECURITY.md`](../documents/SECURITY.md) §16.1 for the PQ threat model.
- Verbs: `kem_encapsulate`, `kem_decapsulate` (see [`README.md`](../README.md) §5).
## 4. Curve and derivation notes ## 4. Curve and derivation notes
`n_signer` currently models curve metadata from day one: `n_signer` models these curves:
- `secp256k1` - `secp256k1` — Nostr, Bitcoin
- `ed25519` - `ed25519` — SSH signatures
- `x25519` - `x25519` — key agreement (age)
- `ml-dsa-65` — PQ signatures (FIPS 204)
- `slh-dsa-128s` — PQ hash-based signatures (FIPS 205)
- `ml-kem-768` — PQ key encapsulation (FIPS 203)
Not every purpose should be valid on every curve. Enforcement should be explicit in policy/runtime validation. Derivation paths use BIP-44 structure with distinct coin types per algorithm family:
| Algorithm | Coin type | Path | Derivation style |
|---|---|---|---|
| secp256k1 (nostr) | 1237 | `m/44'/1237'/<n>'/0/0` | BIP-32 (NIP-06) |
| secp256k1 (bitcoin) | 0 | `m/44'/0'/<account>'/0/0` etc. | BIP-44 |
| ed25519 (ssh) | 102001 | `m/44'/102001'/<n>'/0'/0'` | SLIP-0010 (all hardened) |
| x25519 (age) | 102002 | `m/44'/102002'/<n>'/0'/0'` | SLIP-0010 (all hardened) |
| ML-DSA-65 (pq-sig) | 102003 | `m/44'/102003'/<n>'/0'/0'` | SLIP-0010 → seed → DRBG → PQClean keygen |
| SLH-DSA-128s (pq-sig) | 102004 | `m/44'/102004'/<n>'/0'/0'` | SLIP-0010 → seed → DRBG → PQClean keygen |
| ML-KEM-768 (pq-kem) | 102005 | `m/44'/102005'/<n>'/0'/0'` | SLIP-0010 → seed → DRBG → PQClean keygen |
The 102XXX coin type range is unregistered in SLIP-44 and chosen to avoid collisions with real cryptocurrencies. All non-secp256k1 paths are fully hardened per SLIP-0010. PQ keys use a seed→DRBG→PQClean keygen approach because PQ private keys are not scalars — see [`documents/SECURITY.md`](../documents/SECURITY.md) §16.2 for the security argument.
Not every purpose should be valid on every curve. Enforcement is explicit in the `(verb, purpose, curve)` matrix — see [`README.md`](../README.md) §6.
## 5. Operational caveats ## 5. Operational caveats

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,248 @@
# Plan: Port n_signer to Teensy 4.1 with SDXC 1 TB exFAT OTP Pad
## Goal
Port the n_signer hardware signer to the **Teensy 4.1** (NXP i.MX RT1062,
Cortex-M7 @ 600 MHz), using its built-in SD slot to hold a **1 TB SDXC exFAT
one-time-pad file**. The Teensy 4.1 is the high-capacity-OTP target; the CYD
(classic ESP32) remains the cheap/low-power option with the mnemonic-derived
stream pad.
The on-the-wire protocol is identical to the host and the CYD/feather firmware
([`README.md`](../README.md) §4 — the algorithm-based API). The auth envelope,
verb set, enforcement matrix, `key_id` convention, and structured-result JSON
are all unchanged.
## Why the Teensy 4.1
| Concern | Teensy 4.1 | CYD (ESP32) |
|---|---|---|
| MCU | 600 MHz Cortex-M7 | 240 MHz Xtensa LX6 |
| SRAM | 1 MB + 16 MB PSRAM (on-board) | 512 KB, no PSRAM |
| SD slot | **4-bit SDMMC, exFAT via SdFat, up to 2 TB** | 1-bit SDSPI, FAT32, up to 32 GB |
| SD speed | ~20-40 MB/s | ~2 MB/s |
| USB | Hi-Speed (480 Mbps) device + host | CH340 UART only |
| WiFi | **None** | Yes (unused) |
| PQ crypto | ~1-2 s SLH-DSA-128s | 5-30 s SLH-DSA-128s |
| Display | Add SPI ILI9341 (same panel as CYD) | Built-in 2.8" ILI9341 + touch |
The decisive factor is the **SD slot**: PJRC's [SdFat](https://github.com/greiman/SdFat)
library has native exFAT support, so a 1 TB SDXC card (which ships formatted
exFAT) mounts and reads/writes directly — no reformatting, no exFAT driver
work. The 4-bit SDMMC bus is fast enough (~20-40 MB/s) for pad reads with
arbitrary seeks.
## Hardware
### Board
- **Teensy 4.1** (PJRC) — $27. Has 8 MB flash, 16 MB external QSPI, 16 MB PSRAM
(soldered), 1 MB internal SRAM, native USB Hi-Speed, built-in SD slot, 10/100
Ethernet PHY, no WiFi/BT.
### Display + touch (add-on)
- **4.0" ST7796S 480×320 with XPT2046 resistive touch** (Hosyond or equivalent,
~$12-15). Specs: 4-wire SPI, RGB 65K, 3.3V~5V (works at Teensy's 3.3V logic),
XPT2046 resistive touch, includes touch pen + SD card slot on the module.
SPI wiring to the Teensy 4.1:
- TFT: MOSI=pin 11, SCK=pin 13, MISO=pin 12, CS=pin 10, DC=pin 9,
RESET=pin 8, BL=pin 22 (PWM via analogWrite)
- Touch (XPT2046, shared SPI bus): T_CS=pin 7, T_IRQ=pin 6,
T_CLK=pin 13, T_MOSI=pin 11, T_MISO=pin 12
- Power: VCC=3.3V, GND=GND
- The ST7796S controller needs a different init sequence than the CYD's
ILI9341, and the resolution is 480×320 (not 320×240). The XPT2046 touch
driver ports from [`firmware/cyd_esp32_2432s028/main/touch.c`](../firmware/cyd_esp32_2432s028/main/touch.c)
with new resolution constants.
- The module's on-board SD card slot is a bonus (backup pad / offset file),
but the 1 TB pad uses the Teensy's built-in SD slot (4-bit SDMMC, faster).
### SD card
- **1 TB microSDXC** (exFAT, ~$60-80) in the Teensy's built-in slot.
- The pad file (`/pad.bin`) + offset file (`/pad.offset`) live on this card.
### USB transport
- The Teensy's native USB port (device mode) exposes a **CDC-ACM serial** +
optional **WebUSB vendor** interface (TinyUSB composite), same framing as the
feather/CYD (4-byte big-endian length prefix + JSON-RPC payload).
- The host sees `/dev/ttyACM0` (Linux) or `COMx` (Windows).
## Target directory layout
```
firmware/teensy41/
├── README.md (created)
├── teensy41_signer.ino (Arduino entry, or main.cpp for PlatformIO)
├── src/
│ ├── main.cpp (app loop: UI → transport → dispatch)
│ ├── dispatch.cpp (verb dispatch — ported from cyd main.c handle_request)
│ ├── dispatch.h
│ ├── key_derivation.cpp (BIP-39 → seed → secp256k1/ed25519/x25519/PQ keys)
│ ├── key_derivation.h
│ ├── pq_crypto.cpp (PQClean wrappers: ml-dsa-65, slh-dsa-128s, ml-kem-768)
│ ├── pq_crypto.h
│ ├── otp_pad_sd.cpp (SDXC exFAT pad: mount, read, offset persistence)
│ ├── otp_pad_sd.h
│ ├── transport.cpp (USB CDC + length-prefix framing)
│ ├── transport.h
│ ├── display.cpp (ILI9341 driver — ported from cyd ili9341.c)
│ ├── display.h
│ ├── touch.cpp (XPT2046 driver — ported from cyd touch.c)
│ ├── touch.h
│ ├── ui.cpp (LVGL or hand-rolled UI — ported from cyd ui.c)
│ ├── ui.h
│ ├── secure_mem.cpp (zeroize helpers)
│ ├── secure_mem.h
│ ├── bech32.cpp (npub encoding)
│ ├── bech32.h
│ ├── mnemonic.cpp (BIP-39 wordlist + validation)
│ ├── mnemonic.h
│ └── mnemonic_wordlist.h
├── lib/
│ ├── secp256k1/ (libsecp256k1, built for ARM Cortex-M7)
│ ├── pqclean/ (PQClean ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
│ ├── nostr_core_lib/ (symlink/copy of resources/nostr_core_lib)
│ └── SdFat/ (PJRC SdFat with exFAT — via Arduino Library Manager)
└── platformio.ini (or Arduino project config)
```
## Architecture
```mermaid
flowchart TD
USB[Host USB CDC] --> Frame[transport.cpp<br/>length-prefix framing]
Frame --> Auth[auth envelope verify<br/>secp256k1 schnorr]
Auth -->|ok| Disp[dispatch.cpp<br/>handle_request]
Auth -->|fail| Err[auth error]
Disp -->|nostr_*| NIP[Nostr verbs<br/>secp256k1 NIP-06]
Disp -->|alg verb| Alg[Algorithm verbs<br/>algorithm + index]
Alg -->|otp| OTP[otp_pad_sd.cpp<br/>SDXC exFAT pad]
Alg -->|secp256k1/ed25519/x25519/PQ| KD[key_derivation.cpp]
OTP --> SD[(1 TB SDXC<br/>exFAT<br/>/pad.bin + /pad.offset)]
KD --> UI[ui.cpp<br/>approval prompt<br/>ILI9341 + XPT2046]
UI -->|approve| Exec[execute verb]
Exec --> Resp[structured JSON result]
Resp --> Frame
```
## Implementation phases
### Phase 1: Board bring-up (display + touch + SD)
1. **Toolchain:** Arduino CLI + Teensyduino (simplest), or PlatformIO with the
`teensy` platform. Verify `Blink` + `HelloSerial` compile and flash.
2. **ILI9341 display:** port [`firmware/cyd_esp32_2432s028/main/ili9341.c`](../firmware/cyd_esp32_2432s028/main/ili9341.c)
to Teensy GPIO + SPI (use `SPI.beginTransaction` for 40 MHz HSPI). Exit
criterion: fill screen + draw text.
3. **XPT2046 touch:** port [`firmware/cyd_esp32_2432s028/main/touch.c`](../firmware/cyd_esp32_2432s028/main/touch.c).
Exit criterion: read touch coordinates, map to 320×240.
4. **SD card + exFAT:** install SdFat via Arduino Library Manager. Mount a 1 TB
SDXC card (exFAT), write + read a test file. Exit criterion:
`sd.begin(SdioConfig(FIFO_SDIO))` succeeds on a 1 TB card, `file.open`
+ `file.write` + `file.read` round-trips.
### Phase 2: Crypto stack port
1. **secp256k1:** build `libsecp256k1` for ARM Cortex-M7 (no ASM, pure C with
`USE_NUM_NONE` / `USE_FIELD_INV_BUILTIN`). Verify schnorr sign/verify +
ECDSA sign/verify against known test vectors.
2. **ed25519 / x25519:** use a portable ed25519 (e.g. the ref10 impl or
`crypto_mbedtls` if mbedtls is available for Teensy — alternatively
[`micro-ecc`](https://github.com/kmackay/micro-ecc) + a portable ed25519).
Verify against the host's test vectors.
3. **PQClean:** compile `resources/pqclean/` (ML-DSA-65, SLH-DSA-128s,
ML-KEM-768) for Cortex-M7. The `crypto_backend` abstraction needs a Teensy
backend (`crypto_backend_sdfat.c` or reuse the vendored Keccak from
[`resources/pqclean/common/crypto_backend_mbedtls.c`](../resources/pqclean/common/crypto_backend_mbedtls.c)
— the Keccak core is portable C). SHA-256/512 via SdFat's built-in or a
portable impl. Verify keygen + sign + verify against the host.
4. **nostr_core_lib:** compile [`resources/nostr_core_lib/`](../resources/nostr_core_lib/)
(nip004, nip044, nostr_common, utils, crypto) for Cortex-M7. This is
portable C and should compile as-is.
### Phase 3: Key derivation + mnemonic
1. Port [`firmware/cyd_esp32_2432s028/main/key_derivation.c`](../firmware/cyd_esp32_2432s028/main/key_derivation.c)
(BIP-32/SLIP-0010 derivation for all 6 algorithms). Replace mbedtls/PSA
calls with the portable crypto from Phase 2.
2. Port [`firmware/cyd_esp32_2432s028/main/mnemonic.c`](../firmware/cyd_esp32_2432s028/main/mnemonic.c)
(BIP-39 wordlist + validation + mnemonic_to_seed via PBKDF2-HMAC-SHA512).
3. Port [`firmware/cyd_esp32_2432s028/main/bech32.c`](../firmware/cyd_esp32_2432s028/main/bech32.c)
(npub encoding).
### Phase 4: OTP pad from SDXC (the key feature)
1. **`otp_pad_sd.cpp`:**
- `otp_pad_init()`: mount the SD card via `sd.begin(SdioConfig(FIFO_SDIO))`,
open `/pad.bin` for reading, open `/pad.offset` for the persistent offset.
Read the offset file on boot; if absent, start at 0.
- `otp_pad_read(buf, len)`: seek to the current offset in `/pad.bin`, read
`len` bytes, advance the offset. If the offset + len exceeds the file
size, return an error (pad exhausted).
- `otp_pad_persist_offset()`: write the current offset to `/pad.offset`
after each `encrypt`/`decrypt` call (or batch: persist every N calls to
reduce SD wear).
- `otp_pad_zeroize()`: close files, zeroize in-RAM state.
2. **Wire into the `encrypt`/`decrypt` verbs:** replace the CYD's
HKDF-derived in-RAM pad with `otp_pad_read()`. The wire contract is
unchanged (`encrypt`/`decrypt` with `algorithm:"otp"`, base64 payload,
`pad_offset` in the response).
3. **UI:** show "reading pad from SD…" during the read (the SD read is fast
but the user should see activity). Show the pad offset + remaining bytes on
the idle screen.
### Phase 5: Transport + dispatch + UI
1. **Transport (`transport.cpp`):** TinyUSB CDC-ACM + 4-byte length-prefix
framing (same as [`firmware/cyd_esp32_2432s028/main/uart_transport.c`](../firmware/cyd_esp32_2432s028/main/uart_transport.c)).
Optionally add a WebUSB vendor interface for browser transport (the Teensy's
Hi-Speed USB makes this fast).
2. **Dispatch (`dispatch.cpp`):** port `handle_request()` from
[`firmware/cyd_esp32_2432s028/main/main.c`](../firmware/cyd_esp32_2432s028/main/main.c)
— the entire verb dispatch (all nostr_* + algorithm-based verbs, enforcement
matrix, structured results, auth envelope verify). This is the bulk of the
logic and ports nearly verbatim (only the crypto backend calls change).
3. **UI (`ui.cpp`):** port [`firmware/cyd_esp32_2432s028/main/ui.c`](../firmware/cyd_esp32_2432s028/main/ui.c)
(LVGL 8.3 or hand-rolled). The UI screens are: startup menu → generate
mnemonic → confirm mnemonic → enter mnemonic → idle (show npub + pad offset)
→ approval prompt. The Teensy's 600 MHz M7 makes LVGL snappy.
### Phase 6: Integration + testing
1. **End-to-end smoke test:** load a mnemonic, exercise every verb over USB
CDC with a Python script or the Web Serial test page
([`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html)
works for any CDC device).
2. **OTP pad test:** place a known pad file on the 1 TB SDXC card, run
`encrypt` + `decrypt` round-trips, verify the XOR is correct and the offset
advances + persists across power cycles.
3. **Cross-board parity:** same mnemonic on the Teensy 4.1 and the CYD → same
npub, same secp256k1/ed25519/x25519/PQ public keys, same signatures.
4. **Performance:** measure SLH-DSA-128s sign time (expect ~1-2 s vs 5-30 s on
ESP32), ML-DSA-65 sign time (expect ~50 ms), SD pad read throughput.
## Open questions / decisions
- **UI framework:** LVGL 8.3 (heavier, proven on CYD) vs hand-rolled (lighter,
faster to port, no external dep). The Teensy has enough RAM for LVGL. **Lean
toward LVGL** for consistency with the CYD.
- **ed25519/x25519 impl:** mbedtls is available for Teensy via the
`mbedtls` Arduino library, but PSA crypto is not. Options: (a) mbedtls
ed25519 (if the Arduino mbedtls has it), (b) a portable ed25519 like
[`orlp/ed25519`](https://github.com/orlp/ed25519) + micro-ecc for x25519,
(c) libsodium for Teensy. **Investigate (a) first, fall back to (b).**
- **Toolchain:** Arduino CLI + Teensyduino (simplest, SdFat + USB stack
included) vs PlatformIO (better dependency management, CI-friendly). **Lean
toward Arduino CLI** for the initial port, migrate to PlatformIO later.
- **Offset persistence frequency:** writing `/pad.offset` to SD after every
`encrypt`/`decrypt` is safe but wears the SD. **Batch: persist every 16
calls, and also on idle timeout.** If power is lost, at most 16 pad bytes are
reused (acceptable for a signing device, not for a high-volume OTP channel).
## Verification
- `arduino-cli compile` (or `pio run`) builds clean for `teensy:avr:teensy41`.
- Flash to the Teensy 4.1, load a mnemonic, exercise every verb over USB CDC.
- 1 TB SDXC card mounts, `/pad.bin` reads at >20 MB/s, offset persists across
power cycles.
- Same mnemonic → same keys as the CYD and the host n_signer.
- SLH-DSA-128s signs in <3 s (vs 5-30 s on ESP32).

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

@@ -82,6 +82,8 @@ typedef enum {
PURPOSE_SSH, PURPOSE_SSH,
PURPOSE_AGE, PURPOSE_AGE,
PURPOSE_FIPS, PURPOSE_FIPS,
PURPOSE_PQ_SIG, /* post-quantum signatures (ML-DSA, SLH-DSA) */
PURPOSE_PQ_KEM, /* post-quantum key encapsulation (ML-KEM) */
PURPOSE_UNKNOWN PURPOSE_UNKNOWN
} role_purpose_t; } role_purpose_t;
@@ -90,6 +92,9 @@ typedef enum {
CURVE_SECP256K1 = 0, CURVE_SECP256K1 = 0,
CURVE_ED25519, CURVE_ED25519,
CURVE_X25519, CURVE_X25519,
CURVE_ML_DSA_65,
CURVE_SLH_DSA_128S,
CURVE_ML_KEM_768,
CURVE_UNKNOWN CURVE_UNKNOWN
} role_curve_t; } role_curve_t;
@@ -195,21 +200,37 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */ #define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */ #define ENFORCE_ERR_CURVE -2 /* curve mismatch */
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */ #define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
#define ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
/* Known verbs */ /* Algorithm-based verb defines (algorithm is a parameter, not implicit).
#define VERB_SIGN_EVENT "sign_event" * Callers must supply `algorithm` explicitly — no implicit defaults. */
#define VERB_GET_PUBLIC_KEY "get_public_key" #define VERB_SIGN "sign"
#define VERB_NIP44_ENCRYPT "nip44_encrypt" #define VERB_VERIFY "verify"
#define VERB_NIP44_DECRYPT "nip44_decrypt" #define VERB_ENCAPSULATE "encapsulate"
#define VERB_NIP04_ENCRYPT "nip04_encrypt" #define VERB_DECAPSULATE "decapsulate"
#define VERB_NIP04_DECRYPT "nip04_decrypt" #define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define VERB_GET_PUBLIC_KEY "get_public_key"
/* 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"
/* 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`. * Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code. * Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
* *
* The enforcement rules are: * 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 * purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed). * - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/ */
@@ -230,6 +251,8 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 160 #define POLICY_CALLER_MAX_LEN 160
#define POLICY_MAX_ALGS 16
#define POLICY_MAX_ALGS 16
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -254,6 +277,12 @@ typedef struct {
int role_count; int role_count;
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX]; char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
int purpose_count; int purpose_count;
/* Algorithm-based (new) */
char algorithms[16][32]; /* algorithm names; POLICY_MAX_ALGS */
int alg_count;
int index_min; /* -1 = any */
int index_max; /* -1 = any */
/* Common */
prompt_mode_t prompt; prompt_mode_t prompt;
policy_source_t source; policy_source_t source;
} policy_entry_t; } policy_entry_t;
@@ -287,6 +316,20 @@ int policy_check(const policy_table_t *table, const char *caller_id,
const char *verb, const char *role_name, const char *purpose, const char *verb, const char *role_name, const char *purpose,
policy_source_t *out_source); policy_source_t *out_source);
/* Check whether caller_id is allowed to invoke `verb` with the given
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
int policy_check_algorithm(const policy_table_t *table, const char *caller_id,
const char *verb, const char *algorithm, int index,
policy_source_t *out_source);
/* Check whether caller_id is allowed to invoke `verb` with the given
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
int policy_check_algorithm(const policy_table_t *table, const char *caller_id,
const char *verb, const char *algorithm, int index,
policy_source_t *out_source);
/* Parse prompt mode from string */ /* Parse prompt mode from string */
prompt_mode_t prompt_mode_from_str(const char *s); prompt_mode_t prompt_mode_from_str(const char *s);
@@ -294,15 +337,147 @@ prompt_mode_t prompt_mode_from_str(const char *s);
const char *prompt_mode_to_str(prompt_mode_t m); const char *prompt_mode_to_str(prompt_mode_t m);
/* from pq_crypto.h */
/* Algorithm identifiers */
typedef enum {
CRYPTO_ALG_SECP256K1 = 0, /* existing, Nostr */
CRYPTO_ALG_ED25519, /* new, SSH signatures */
CRYPTO_ALG_X25519, /* new, key agreement */
CRYPTO_ALG_ML_DSA_65, /* new, PQ signatures */
CRYPTO_ALG_SLH_DSA_128S, /* new, PQ signatures */
CRYPTO_ALG_ML_KEM_768, /* new, PQ KEM */
CRYPTO_ALG_UNKNOWN
} crypto_alg_t;
/* Key sizes for each algorithm (compile-time constants) */
typedef struct {
size_t priv_key_len;
size_t pub_key_len;
size_t sig_len; /* 0 for KEM */
size_t ciphertext_len; /* 0 for signatures */
size_t shared_secret_len; /* 0 for signatures */
} crypto_alg_sizes_t;
/* Get size info for an algorithm. Returns NULL for CRYPTO_ALG_UNKNOWN. */
const crypto_alg_sizes_t *crypto_alg_get_sizes(crypto_alg_t alg);
/* Map role_curve_t + role_purpose_t to crypto_alg_t.
* Returns CRYPTO_ALG_UNKNOWN for unsupported combinations. */
crypto_alg_t crypto_alg_from_role(role_curve_t curve, role_purpose_t purpose);
/* Convert crypto_alg_t to string. Returns NULL for unknown. */
const char *crypto_alg_to_str(crypto_alg_t alg);
/* Parse string to crypto_alg_t. Returns CRYPTO_ALG_UNKNOWN for unrecognized. */
crypto_alg_t crypto_alg_from_str(const char *s);
/* ed25519: derive keypair from a 32-byte seed.
* priv_out and pub_out must be at least 32 bytes each.
* Returns 0 on success, -1 on error. */
int crypto_ed25519_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* ed25519: sign a message. priv is 32-byte private key.
* sig_out must be at least 64 bytes. Returns 0 on success, -1 on error. */
int crypto_ed25519_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* ed25519: verify a signature. pub is 32-byte public key.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_ed25519_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* x25519: derive keypair from a 32-byte seed.
* priv_out and pub_out must be at least 32 bytes each.
* Returns 0 on success, -1 on error. */
int crypto_x25519_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* x25519: derive shared secret from our private key and peer's public key.
* shared_out must be at least 32 bytes. Returns 0 on success, -1 on error. */
int crypto_x25519_ecdh(const unsigned char *our_priv, size_t priv_len,
const unsigned char *peer_pub, size_t pub_len,
unsigned char *shared_out, size_t *shared_out_len);
/* Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010).
* seed_out must be at least 32 bytes. Returns 0 on success, -1 on error. */
int crypto_derive_seed_from_mnemonic(const char *mnemonic, const char *path,
unsigned char *seed_out, size_t seed_out_len);
/* ML-DSA-65: generate keypair from a 32-byte seed (deterministic).
* priv_out must be at least 4032 bytes, pub_out at least 1952 bytes.
* Returns 0 on success, -1 on error. */
int crypto_ml_dsa_65_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* ML-DSA-65: sign a message. priv is 4032-byte private key.
* sig_out must be at least 3309 bytes. Returns 0 on success, -1 on error. */
int crypto_ml_dsa_65_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* ML-DSA-65: verify a signature. pub is 1952-byte public key.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_ml_dsa_65_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* SLH-DSA-128s: generate keypair from a 32-byte seed (deterministic).
* priv_out must be at least 64 bytes, pub_out at least 32 bytes.
* Returns 0 on success, -1 on error. */
int crypto_slh_dsa_128s_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* SLH-DSA-128s: sign a message. priv is 64-byte private key.
* sig_out must be at least 7856 bytes. Returns 0 on success, -1 on error. */
int crypto_slh_dsa_128s_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* SLH-DSA-128s: verify a signature. pub is 32-byte public key.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_slh_dsa_128s_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* ML-KEM-768: generate keypair from a 32-byte seed (deterministic).
* priv_out must be at least 2400 bytes, pub_out at least 1184 bytes.
* Returns 0 on success, -1 on error. */
int crypto_ml_kem_768_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* ML-KEM-768: encapsulate. pub is 1184-byte public key.
* ct_out must be at least 1088 bytes, ss_out at least 32 bytes.
* Returns 0 on success, -1 on error. */
int crypto_ml_kem_768_encaps(const unsigned char *pub, size_t pub_len,
unsigned char *ct_out, unsigned char *ss_out);
/* ML-KEM-768: decapsulate. priv is 2400-byte secret key, ct is 1088-byte ciphertext.
* ss_out must be at least 32 bytes. Returns 0 on success, -1 on error. */
int crypto_ml_kem_768_decaps(const unsigned char *priv, size_t priv_len,
const unsigned char *ct, size_t ct_len,
unsigned char *ss_out);
/* Deterministic PRNG for PQ keygen (replaces PQClean randombytes()). */
void pq_drbg_init(const unsigned char *seed, size_t seed_len);
int pq_drbg_randombytes(unsigned char *buf, size_t len);
void pq_drbg_zeroize(void);
/* from crypto.h */ /* from crypto.h */
/* Per-role derived key material (stored in secure memory) */ /* Per-role derived key material (stored in secure memory) */
typedef struct { typedef struct {
secure_buf_t private_key; /* 32 bytes, mlock'd */ secure_buf_t private_key; /* mlock'd, variable size per algorithm */
unsigned char public_key[32]; secure_buf_t public_key; /* mlock'd, variable size per algorithm */
char pubkey_hex[65]; /* 64 hex chars + null */ char pubkey_hex[8192]; /* hex-encoded public key (PQ pubkeys are large) */
char npub[128]; /* bech32 npub */ char npub[128]; /* bech32 npub (secp256k1 only, empty for others) */
crypto_alg_t alg; /* which algorithm this key was derived for */
int valid; int valid;
} derived_key_t; } derived_key_t;
@@ -333,6 +508,82 @@ char *crypto_sign_event(const key_store_t *store, int role_index, const char *ev
void crypto_wipe(key_store_t *store); void crypto_wipe(key_store_t *store);
/* from alg_api.h */
/* Check whether a verb is valid for an algorithm (algorithm-based enforcement).
* Returns ENFORCE_OK, ENFORCE_ERR_ALGORITHM, or ENFORCE_ERR_UNKNOWN_VERB.
* Does NOT check purpose — purpose is irrelevant for the new verbs. */
int enforce_verb_algorithm(const char *verb, crypto_alg_t alg);
/* secp256k1 Schnorr (BIP-340) sign arbitrary bytes.
* priv is 32-byte scalar, pub is 32-byte x-only pubkey, sig_out is 64 bytes.
* Hashes the message with SHA-256 before signing (like Nostr event signing).
* Returns 0 on success, -1 on error. */
int crypto_secp256k1_schnorr_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* secp256k1 Schnorr (BIP-340) verify.
* pub is 32-byte x-only pubkey, sig is 64 bytes.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_secp256k1_schnorr_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* secp256k1 ECDSA sign arbitrary bytes.
* priv is 32-byte scalar, sig_out must be at least 64 bytes (compact DER r||s).
* Hashes the message with SHA-256 before signing.
* Returns 0 on success, -1 on error. */
int crypto_secp256k1_ecdsa_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* secp256k1 ECDSA verify.
* pub is 32-byte x-only pubkey (converted internally to compressed form).
* sig is 64-byte compact (r||s). Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_secp256k1_ecdsa_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* ---- Algorithm key cache ----
* On-demand key derivation by algorithm+index, separate from the role-based
* key_store. Holds up to ALG_KEY_CACHE_MAX derived keys in secure memory.
* When full, the oldest entry is evicted (FIFO). */
#define ALG_KEY_CACHE_MAX 32
typedef struct {
crypto_alg_t alg;
int index;
secure_buf_t private_key;
secure_buf_t public_key;
char pubkey_hex[8192];
char key_id[17];
int valid;
} alg_key_entry_t;
typedef struct {
alg_key_entry_t entries[ALG_KEY_CACHE_MAX];
int count;
} algorithm_key_cache_t;
/* Initialize an empty cache. */
void alg_key_cache_init(algorithm_key_cache_t *cache);
/* Zeroize and free all entries. Idempotent. */
void alg_key_cache_wipe(algorithm_key_cache_t *cache);
/* Look up a cached entry by (alg, index). Returns NULL if not present. */
const alg_key_entry_t *alg_key_cache_get(algorithm_key_cache_t *cache, crypto_alg_t alg, int index);
/* Derive a key on-demand by (alg, index) and store it in the cache.
* Uses the standard derivation path for the algorithm.
* Returns 0 on success, -1 on error. */
int alg_key_cache_derive(algorithm_key_cache_t *cache, const mnemonic_state_t *mnemonic, crypto_alg_t alg, int index);
/* from dispatcher.h */ /* from dispatcher.h */
@@ -341,10 +592,11 @@ typedef struct {
role_table_t *role_table; role_table_t *role_table;
mnemonic_state_t *mnemonic; mnemonic_state_t *mnemonic;
key_store_t *key_store; key_store_t *key_store;
algorithm_key_cache_t *alg_key_cache; /* algorithm-based on-demand keys */
} dispatcher_ctx_t; } dispatcher_ctx_t;
/* Initialize dispatcher context */ /* Initialize dispatcher context */
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store); void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store, algorithm_key_cache_t *alg_key_cache);
/* /*
* Process a JSON-RPC request string and produce a JSON-RPC response string. * Process a JSON-RPC request string and produce a JSON-RPC response string.
@@ -377,7 +629,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108 #define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536 #define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */ /* Caller identity */
typedef struct { typedef struct {
@@ -450,33 +702,124 @@ int socket_name_random(char *out, size_t out_len);
#include <string.h> #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) { static int is_nostr_verb(const char *verb) {
if (verb == NULL) { if (verb == NULL) {
return 0; return 0;
} }
return (strcmp(verb, VERB_NOSTR_SIGN_EVENT) == 0) ||
return (strcmp(verb, VERB_SIGN_EVENT) == 0) || (strcmp(verb, VERB_NOSTR_MINE_EVENT) == 0) ||
(strcmp(verb, VERB_GET_PUBLIC_KEY) == 0) || (strcmp(verb, VERB_NOSTR_NIP44_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NIP44_ENCRYPT) == 0) || (strcmp(verb, VERB_NOSTR_NIP44_DECRYPT) == 0) ||
(strcmp(verb, VERB_NIP44_DECRYPT) == 0) || (strcmp(verb, VERB_NOSTR_NIP04_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NIP04_ENCRYPT) == 0) || (strcmp(verb, VERB_NOSTR_NIP04_DECRYPT) == 0);
(strcmp(verb, VERB_NIP04_DECRYPT) == 0);
} }
int enforce_verb_role(const char *verb, const role_entry_t *role) { int enforce_verb_role(const char *verb, const role_entry_t *role) {
if (!is_nostr_verb(verb)) { if (verb == NULL || role == NULL) {
return ENFORCE_ERR_UNKNOWN_VERB; return ENFORCE_ERR_UNKNOWN_VERB;
} }
if (role->purpose != PURPOSE_NOSTR) { /* nostr_get_public_key: returns the role's secp256k1 (NIP-06) public key.
return ENFORCE_ERR_PURPOSE; * 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;
} }
if (role->curve != CURVE_SECP256K1) { /* Nostr verbs: PURPOSE_NOSTR + CURVE_SECP256K1 only. */
return ENFORCE_ERR_CURVE; if (is_nostr_verb(verb)) {
if (role->purpose != PURPOSE_NOSTR) {
return ENFORCE_ERR_PURPOSE;
}
if (role->curve != CURVE_SECP256K1) {
return ENFORCE_ERR_CURVE;
}
return ENFORCE_OK;
} }
return ENFORCE_OK; /* 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 ----
*
* 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 algorithm-based verbs (sign, verify,
* encapsulate, decapsulate, derive_shared_secret, get_public_key).
*
* 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) {
return ENFORCE_ERR_UNKNOWN_VERB;
}
/* get_public_key: valid for all known algorithms. */
if (strcmp(verb, VERB_GET_PUBLIC_KEY) == 0) {
if (alg == CRYPTO_ALG_UNKNOWN) {
return ENFORCE_ERR_ALGORITHM;
}
return ENFORCE_OK;
}
/* sign / verify: signature algorithms only. */
if (strcmp(verb, VERB_SIGN) == 0 || strcmp(verb, VERB_VERIFY) == 0) {
if (alg == CRYPTO_ALG_SECP256K1 ||
alg == CRYPTO_ALG_ED25519 ||
alg == CRYPTO_ALG_ML_DSA_65 ||
alg == CRYPTO_ALG_SLH_DSA_128S) {
return ENFORCE_OK;
}
return ENFORCE_ERR_ALGORITHM;
}
/* encapsulate / decapsulate: ML-KEM-768 only. */
if (strcmp(verb, VERB_ENCAPSULATE) == 0 || strcmp(verb, VERB_DECAPSULATE) == 0) {
if (alg == CRYPTO_ALG_ML_KEM_768) {
return ENFORCE_OK;
}
return ENFORCE_ERR_ALGORITHM;
}
/* derive_shared_secret: x25519 only. */
if (strcmp(verb, VERB_DERIVE_SHARED) == 0) {
if (alg == CRYPTO_ALG_X25519) {
return ENFORCE_OK;
}
return ENFORCE_ERR_ALGORITHM;
}
/* 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;
}
return ENFORCE_ERR_UNKNOWN_VERB;
} }
const char *enforce_strerror(int err) { const char *enforce_strerror(int err) {
@@ -489,6 +832,8 @@ const char *enforce_strerror(int err) {
return "curve_mismatch"; return "curve_mismatch";
case ENFORCE_ERR_UNKNOWN_VERB: case ENFORCE_ERR_UNKNOWN_VERB:
return "unknown_verb"; return "unknown_verb";
case ENFORCE_ERR_ALGORITHM:
return "algorithm_not_supported_for_verb";
default: default:
return "unknown_error"; return "unknown_error";
} }

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 */

File diff suppressed because it is too large Load Diff

1430
src/main.c

File diff suppressed because it is too large Load Diff

368
src/miner.c Normal file
View File

@@ -0,0 +1,368 @@
/*
* miner.c - Multithreaded best-effort NIP-13 Proof-of-Work mining coordinator
*
* This module implements a server-safe mining loop that:
* - Runs N worker threads, each trying nonces with a unique stride
* - Tracks the best event (highest difficulty) across all threads
* - Stops when target difficulty is reached OR timeout expires
* - Always returns the best result found (best-effort, not failure on timeout)
*
* Unlike the standalone event_miner tool, this code:
* - Has no global variables (all state in context structs)
* - Never calls exit() (returns result codes)
* - Has no signal handlers (the server handles signals)
* - Uses time-based termination as the primary budget
*/
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include <pthread.h>
#include <cJSON.h>
#include <nostr_core/nip001.h>
#include <nostr_core/nip013.h>
#include <nostr_core/utils.h>
/* ---- Constants ---- */
#define MINER_MAX_THREADS 32
#define MINER_SAFETY_TIMEOUT 600 /* 10 minutes safety max when only difficulty is set */
#define MINER_NONCE_STRIDE 0 /* 0 = use thread_count as stride */
/* ---- Result structure ---- */
typedef struct {
cJSON *best_event; /* best event found (caller frees) */
int achieved_difficulty;
int target_difficulty;
int target_reached;
int elapsed_sec;
uint64_t total_attempts;
} mine_result_t;
/* ---- Shared state across worker threads ---- */
typedef struct {
cJSON *best_event; /* best event found so far (mutex-protected) */
int best_difficulty; /* difficulty of best_event */
uint64_t total_attempts; /* total attempts across all threads */
int target_difficulty;/* 0 = no target, mine full timeout */
int target_reached; /* 1 if target was reached */
pthread_mutex_t mutex; /* protects best_event, best_difficulty, total_attempts */
volatile int stop; /* set when target reached or timeout */
} mine_shared_state_t;
/* ---- Per-worker context ---- */
typedef struct {
mine_shared_state_t *shared;
int kind;
char content[65536]; /* copy of event content */
cJSON *tags_template; /* this thread's copy of original tags */
unsigned char private_key[32];
int thread_id;
uint64_t nonce_start; /* starting nonce for this thread */
uint64_t nonce_stride; /* increment to avoid overlap with other threads */
time_t deadline; /* absolute time to stop */
uint64_t attempts; /* this thread's attempt count */
} miner_worker_ctx_t;
/* ---- Helper: update nonce tag in a tags array ---- */
static int miner_update_nonce_tag(cJSON *tags, uint64_t nonce, int target_difficulty) {
cJSON *tag;
int index = 0;
if (!tags) return -1;
/* Remove existing nonce tag if present */
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON *tag_type = cJSON_GetArrayItem(tag, 0);
if (tag_type && cJSON_IsString(tag_type) &&
strcmp(cJSON_GetStringValue(tag_type), "nonce") == 0) {
cJSON_DetachItemFromArray(tags, index);
cJSON_Delete(tag);
break;
}
}
index++;
}
/* Add new nonce tag: ["nonce", "<nonce>", "<target>"] */
{
cJSON *nonce_tag = cJSON_CreateArray();
char nonce_str[32];
char difficulty_str[16];
if (!nonce_tag) return -1;
snprintf(nonce_str, sizeof(nonce_str), "%llu", (unsigned long long)nonce);
snprintf(difficulty_str, sizeof(difficulty_str), "%d", target_difficulty);
cJSON_AddItemToArray(nonce_tag, cJSON_CreateString("nonce"));
cJSON_AddItemToArray(nonce_tag, cJSON_CreateString(nonce_str));
cJSON_AddItemToArray(nonce_tag, cJSON_CreateString(difficulty_str));
cJSON_AddItemToArray(tags, nonce_tag);
}
return 0;
}
/* ---- Worker thread function ---- */
static void *miner_worker(void *arg) {
miner_worker_ctx_t *ctx = (miner_worker_ctx_t *)arg;
uint64_t nonce = ctx->nonce_start;
time_t current_ts = time(NULL);
while (!ctx->shared->stop && time(NULL) < ctx->deadline) {
cJSON *working_tags;
cJSON *signed_event;
const char *event_id;
int difficulty;
/* Build tags with current nonce */
working_tags = cJSON_Duplicate(ctx->tags_template, 1);
if (!working_tags) break;
if (miner_update_nonce_tag(working_tags, nonce, ctx->shared->target_difficulty) != 0) {
cJSON_Delete(working_tags);
break;
}
/* Update timestamp periodically (every 1000 attempts) to keep it fresh */
if (ctx->attempts % 1000 == 0) {
current_ts = time(NULL);
}
/* Create and sign the event with this nonce */
signed_event = nostr_create_and_sign_event(ctx->kind, ctx->content,
working_tags, ctx->private_key,
current_ts);
cJSON_Delete(working_tags);
if (!signed_event) {
nonce += ctx->nonce_stride;
ctx->attempts++;
continue;
}
/* Check difficulty of the resulting event id */
{
cJSON *id_item = cJSON_GetObjectItem(signed_event, "id");
if (!id_item || !cJSON_IsString(id_item)) {
cJSON_Delete(signed_event);
nonce += ctx->nonce_stride;
ctx->attempts++;
continue;
}
event_id = cJSON_GetStringValue(id_item);
}
difficulty = nostr_calculate_pow_difficulty(event_id);
/* Track best under mutex */
pthread_mutex_lock(&ctx->shared->mutex);
ctx->shared->total_attempts++;
if (difficulty > ctx->shared->best_difficulty) {
if (ctx->shared->best_event) {
cJSON_Delete(ctx->shared->best_event);
}
ctx->shared->best_event = cJSON_Duplicate(signed_event, 1);
ctx->shared->best_difficulty = difficulty;
}
if (ctx->shared->target_difficulty > 0 && difficulty >= ctx->shared->target_difficulty) {
ctx->shared->target_reached = 1;
ctx->shared->stop = 1;
}
pthread_mutex_unlock(&ctx->shared->mutex);
cJSON_Delete(signed_event);
nonce += ctx->nonce_stride;
ctx->attempts++;
}
return NULL;
}
/* ---- Main mining coordinator ---- */
/*
* Run multithreaded best-effort mining on `event`.
*
* Parameters:
* event - cJSON event to mine (must have kind, content, tags, created_at)
* private_key - 32-byte private key for signing
* target_difficulty - 0 = no target (mine full timeout); >0 = stop when reached
* thread_count - number of worker threads (1..MINER_MAX_THREADS)
* timeout_sec - time budget in seconds (0 = use MINER_SAFETY_TIMEOUT)
* result - output struct (caller frees result->best_event)
*
* Returns 0 on success (result populated), -1 on error.
*/
int miner_run(cJSON *event, const unsigned char *private_key,
int target_difficulty, int thread_count, int timeout_sec,
mine_result_t *result)
{
mine_shared_state_t shared;
miner_worker_ctx_t *workers = NULL;
pthread_t *threads = NULL;
cJSON *kind_item, *content_item, *tags_item, *created_at_item;
int kind;
const char *content;
time_t start_time;
int effective_timeout;
int i;
int ret = 0;
if (!event || !private_key || !result) {
return -1;
}
memset(result, 0, sizeof(mine_result_t));
/* Validate event has required fields */
kind_item = cJSON_GetObjectItem(event, "kind");
content_item = cJSON_GetObjectItem(event, "content");
tags_item = cJSON_GetObjectItem(event, "tags");
created_at_item = cJSON_GetObjectItem(event, "created_at");
if (!kind_item || !content_item || !tags_item) {
return -1;
}
/* If created_at is missing, add it with current time */
if (!created_at_item) {
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
}
kind = (int)cJSON_GetNumberValue(kind_item);
content = cJSON_GetStringValue(content_item);
if (!content) {
content = "";
}
/* Clamp thread count */
if (thread_count < 1) thread_count = 1;
if (thread_count > MINER_MAX_THREADS) thread_count = MINER_MAX_THREADS;
/* Determine effective timeout */
if (timeout_sec <= 0) {
effective_timeout = MINER_SAFETY_TIMEOUT;
} else {
effective_timeout = timeout_sec;
}
/* Initialize shared state */
memset(&shared, 0, sizeof(shared));
shared.best_event = NULL;
shared.best_difficulty = -1;
shared.total_attempts = 0;
shared.target_difficulty = target_difficulty;
shared.target_reached = 0;
shared.stop = 0;
if (pthread_mutex_init(&shared.mutex, NULL) != 0) {
return -1;
}
/* Allocate worker contexts and thread array */
workers = (miner_worker_ctx_t *)calloc(thread_count, sizeof(miner_worker_ctx_t));
threads = (pthread_t *)calloc(thread_count, sizeof(pthread_t));
if (!workers || !threads) {
free(workers);
free(threads);
pthread_mutex_destroy(&shared.mutex);
return -1;
}
start_time = time(NULL);
/* Set up each worker */
for (i = 0; i < thread_count; i++) {
workers[i].shared = &shared;
workers[i].kind = kind;
strncpy(workers[i].content, content, sizeof(workers[i].content) - 1);
workers[i].content[sizeof(workers[i].content) - 1] = '\0';
workers[i].tags_template = cJSON_Duplicate(tags_item, 1);
memcpy(workers[i].private_key, private_key, 32);
workers[i].thread_id = i;
workers[i].nonce_start = (uint64_t)i;
workers[i].nonce_stride = (uint64_t)thread_count;
workers[i].deadline = start_time + effective_timeout;
workers[i].attempts = 0;
if (!workers[i].tags_template) {
/* Allocation failed for this worker's tags — clean up what we have */
int j;
for (j = 0; j < i; j++) {
cJSON_Delete(workers[j].tags_template);
}
free(workers);
free(threads);
pthread_mutex_destroy(&shared.mutex);
return -1;
}
}
/* Start threads */
for (i = 0; i < thread_count; i++) {
if (pthread_create(&threads[i], NULL, miner_worker, &workers[i]) != 0) {
/* Failed to create thread i — signal stop and join what we have */
shared.stop = 1;
{
int j;
for (j = 0; j < i; j++) {
pthread_join(threads[j], NULL);
}
}
ret = -1;
goto cleanup;
}
}
/* Wait for all threads to complete (they stop on target/timeout) */
for (i = 0; i < thread_count; i++) {
pthread_join(threads[i], NULL);
}
cleanup:
/* Sum up total attempts from all workers */
{
uint64_t total = 0;
for (i = 0; i < thread_count; i++) {
total += workers[i].attempts;
}
/* Use the shared counter (more accurate, includes mutex-protected increments) */
result->total_attempts = shared.total_attempts > 0 ? shared.total_attempts : total;
}
/* Populate result */
result->best_event = shared.best_event; /* transfer ownership */
result->achieved_difficulty = shared.best_difficulty;
result->target_difficulty = target_difficulty;
result->target_reached = shared.target_reached;
result->elapsed_sec = (int)(time(NULL) - start_time);
/* Clean up worker contexts */
for (i = 0; i < thread_count; i++) {
if (workers[i].tags_template) {
cJSON_Delete(workers[i].tags_template);
}
/* Zeroize private key copy */
memset(workers[i].private_key, 0, 32);
}
free(workers);
free(threads);
pthread_mutex_destroy(&shared.mutex);
return ret;
}

View File

@@ -82,6 +82,8 @@ typedef enum {
PURPOSE_SSH, PURPOSE_SSH,
PURPOSE_AGE, PURPOSE_AGE,
PURPOSE_FIPS, PURPOSE_FIPS,
PURPOSE_PQ_SIG, /* post-quantum signatures (ML-DSA, SLH-DSA) */
PURPOSE_PQ_KEM, /* post-quantum key encapsulation (ML-KEM) */
PURPOSE_UNKNOWN PURPOSE_UNKNOWN
} role_purpose_t; } role_purpose_t;
@@ -90,6 +92,9 @@ typedef enum {
CURVE_SECP256K1 = 0, CURVE_SECP256K1 = 0,
CURVE_ED25519, CURVE_ED25519,
CURVE_X25519, CURVE_X25519,
CURVE_ML_DSA_65,
CURVE_SLH_DSA_128S,
CURVE_ML_KEM_768,
CURVE_UNKNOWN CURVE_UNKNOWN
} role_curve_t; } role_curve_t;
@@ -195,21 +200,42 @@ const char *selector_strerror(int err);
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */ #define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */ #define ENFORCE_ERR_CURVE -2 /* curve mismatch */
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */ #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) */
#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 ENFORCE_ERR_ALGORITHM -4 /* algorithm not valid for verb */
/* 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"
#define VERB_DERIVE "derive"
/* Known verbs */ /* Known verbs */
#define VERB_SIGN_EVENT "sign_event" #define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_GET_PUBLIC_KEY "get_public_key" #define VERB_NOSTR_GET_PUBLIC_KEY "nostr_get_public_key"
#define VERB_NIP44_ENCRYPT "nip44_encrypt" #define VERB_NOSTR_SIGN_EVENT "nostr_sign_event"
#define VERB_NIP44_DECRYPT "nip44_decrypt" #define VERB_NOSTR_NIP44_ENCRYPT "nostr_nip44_encrypt"
#define VERB_NIP04_ENCRYPT "nip04_encrypt" #define VERB_NOSTR_NIP44_DECRYPT "nostr_nip44_decrypt"
#define VERB_NIP04_DECRYPT "nip04_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`. * Check whether `verb` is allowed to execute against `role`.
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code. * Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
* *
* The enforcement rules are: * 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 * purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed). * - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
*/ */
@@ -230,6 +256,8 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 160 #define POLICY_CALLER_MAX_LEN 160
#define POLICY_MAX_ALGS 16
#define POLICY_MAX_ALGS 16
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -254,6 +282,12 @@ typedef struct {
int role_count; int role_count;
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX]; char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
int purpose_count; int purpose_count;
/* Algorithm-based (new) */
char algorithms[16][32]; /* algorithm names; POLICY_MAX_ALGS */
int alg_count;
int index_min; /* -1 = any */
int index_max; /* -1 = any */
/* Common */
prompt_mode_t prompt; prompt_mode_t prompt;
policy_source_t source; policy_source_t source;
} policy_entry_t; } policy_entry_t;
@@ -287,6 +321,20 @@ int policy_check(const policy_table_t *table, const char *caller_id,
const char *verb, const char *role_name, const char *purpose, const char *verb, const char *role_name, const char *purpose,
policy_source_t *out_source); policy_source_t *out_source);
/* Check whether caller_id is allowed to invoke `verb` with the given
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
int policy_check_algorithm(const policy_table_t *table, const char *caller_id,
const char *verb, const char *algorithm, int index,
policy_source_t *out_source);
/* Check whether caller_id is allowed to invoke `verb` with the given
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
int policy_check_algorithm(const policy_table_t *table, const char *caller_id,
const char *verb, const char *algorithm, int index,
policy_source_t *out_source);
/* Parse prompt mode from string */ /* Parse prompt mode from string */
prompt_mode_t prompt_mode_from_str(const char *s); prompt_mode_t prompt_mode_from_str(const char *s);
@@ -294,15 +342,147 @@ prompt_mode_t prompt_mode_from_str(const char *s);
const char *prompt_mode_to_str(prompt_mode_t m); const char *prompt_mode_to_str(prompt_mode_t m);
/* from pq_crypto.h */
/* Algorithm identifiers */
typedef enum {
CRYPTO_ALG_SECP256K1 = 0, /* existing, Nostr */
CRYPTO_ALG_ED25519, /* new, SSH signatures */
CRYPTO_ALG_X25519, /* new, key agreement */
CRYPTO_ALG_ML_DSA_65, /* new, PQ signatures */
CRYPTO_ALG_SLH_DSA_128S, /* new, PQ signatures */
CRYPTO_ALG_ML_KEM_768, /* new, PQ KEM */
CRYPTO_ALG_UNKNOWN
} crypto_alg_t;
/* Key sizes for each algorithm (compile-time constants) */
typedef struct {
size_t priv_key_len;
size_t pub_key_len;
size_t sig_len; /* 0 for KEM */
size_t ciphertext_len; /* 0 for signatures */
size_t shared_secret_len; /* 0 for signatures */
} crypto_alg_sizes_t;
/* Get size info for an algorithm. Returns NULL for CRYPTO_ALG_UNKNOWN. */
const crypto_alg_sizes_t *crypto_alg_get_sizes(crypto_alg_t alg);
/* Map role_curve_t + role_purpose_t to crypto_alg_t.
* Returns CRYPTO_ALG_UNKNOWN for unsupported combinations. */
crypto_alg_t crypto_alg_from_role(role_curve_t curve, role_purpose_t purpose);
/* Convert crypto_alg_t to string. Returns NULL for unknown. */
const char *crypto_alg_to_str(crypto_alg_t alg);
/* Parse string to crypto_alg_t. Returns CRYPTO_ALG_UNKNOWN for unrecognized. */
crypto_alg_t crypto_alg_from_str(const char *s);
/* ed25519: derive keypair from a 32-byte seed.
* priv_out and pub_out must be at least 32 bytes each.
* Returns 0 on success, -1 on error. */
int crypto_ed25519_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* ed25519: sign a message. priv is 32-byte private key.
* sig_out must be at least 64 bytes. Returns 0 on success, -1 on error. */
int crypto_ed25519_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* ed25519: verify a signature. pub is 32-byte public key.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_ed25519_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* x25519: derive keypair from a 32-byte seed.
* priv_out and pub_out must be at least 32 bytes each.
* Returns 0 on success, -1 on error. */
int crypto_x25519_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* x25519: derive shared secret from our private key and peer's public key.
* shared_out must be at least 32 bytes. Returns 0 on success, -1 on error. */
int crypto_x25519_ecdh(const unsigned char *our_priv, size_t priv_len,
const unsigned char *peer_pub, size_t pub_len,
unsigned char *shared_out, size_t *shared_out_len);
/* Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010).
* seed_out must be at least 32 bytes. Returns 0 on success, -1 on error. */
int crypto_derive_seed_from_mnemonic(const char *mnemonic, const char *path,
unsigned char *seed_out, size_t seed_out_len);
/* ML-DSA-65: generate keypair from a 32-byte seed (deterministic).
* priv_out must be at least 4032 bytes, pub_out at least 1952 bytes.
* Returns 0 on success, -1 on error. */
int crypto_ml_dsa_65_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* ML-DSA-65: sign a message. priv is 4032-byte private key.
* sig_out must be at least 3309 bytes. Returns 0 on success, -1 on error. */
int crypto_ml_dsa_65_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* ML-DSA-65: verify a signature. pub is 1952-byte public key.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_ml_dsa_65_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* SLH-DSA-128s: generate keypair from a 32-byte seed (deterministic).
* priv_out must be at least 64 bytes, pub_out at least 32 bytes.
* Returns 0 on success, -1 on error. */
int crypto_slh_dsa_128s_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* SLH-DSA-128s: sign a message. priv is 64-byte private key.
* sig_out must be at least 7856 bytes. Returns 0 on success, -1 on error. */
int crypto_slh_dsa_128s_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* SLH-DSA-128s: verify a signature. pub is 32-byte public key.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_slh_dsa_128s_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* ML-KEM-768: generate keypair from a 32-byte seed (deterministic).
* priv_out must be at least 2400 bytes, pub_out at least 1184 bytes.
* Returns 0 on success, -1 on error. */
int crypto_ml_kem_768_keygen_from_seed(const unsigned char *seed, size_t seed_len,
unsigned char *priv_out, unsigned char *pub_out);
/* ML-KEM-768: encapsulate. pub is 1184-byte public key.
* ct_out must be at least 1088 bytes, ss_out at least 32 bytes.
* Returns 0 on success, -1 on error. */
int crypto_ml_kem_768_encaps(const unsigned char *pub, size_t pub_len,
unsigned char *ct_out, unsigned char *ss_out);
/* ML-KEM-768: decapsulate. priv is 2400-byte secret key, ct is 1088-byte ciphertext.
* ss_out must be at least 32 bytes. Returns 0 on success, -1 on error. */
int crypto_ml_kem_768_decaps(const unsigned char *priv, size_t priv_len,
const unsigned char *ct, size_t ct_len,
unsigned char *ss_out);
/* Deterministic PRNG for PQ keygen (replaces PQClean randombytes()). */
void pq_drbg_init(const unsigned char *seed, size_t seed_len);
int pq_drbg_randombytes(unsigned char *buf, size_t len);
void pq_drbg_zeroize(void);
/* from crypto.h */ /* from crypto.h */
/* Per-role derived key material (stored in secure memory) */ /* Per-role derived key material (stored in secure memory) */
typedef struct { typedef struct {
secure_buf_t private_key; /* 32 bytes, mlock'd */ secure_buf_t private_key; /* mlock'd, variable size per algorithm */
unsigned char public_key[32]; secure_buf_t public_key; /* mlock'd, variable size per algorithm */
char pubkey_hex[65]; /* 64 hex chars + null */ char pubkey_hex[8192]; /* hex-encoded public key (PQ pubkeys are large) */
char npub[128]; /* bech32 npub */ char npub[128]; /* bech32 npub (secp256k1 only, empty for others) */
crypto_alg_t alg; /* which algorithm this key was derived for */
int valid; int valid;
} derived_key_t; } derived_key_t;
@@ -333,6 +513,82 @@ char *crypto_sign_event(const key_store_t *store, int role_index, const char *ev
void crypto_wipe(key_store_t *store); void crypto_wipe(key_store_t *store);
/* from alg_api.h */
/* Check whether a verb is valid for an algorithm (algorithm-based enforcement).
* Returns ENFORCE_OK, ENFORCE_ERR_ALGORITHM, or ENFORCE_ERR_UNKNOWN_VERB.
* Does NOT check purpose — purpose is irrelevant for the new verbs. */
int enforce_verb_algorithm(const char *verb, crypto_alg_t alg);
/* secp256k1 Schnorr (BIP-340) sign arbitrary bytes.
* priv is 32-byte scalar, pub is 32-byte x-only pubkey, sig_out is 64 bytes.
* Hashes the message with SHA-256 before signing (like Nostr event signing).
* Returns 0 on success, -1 on error. */
int crypto_secp256k1_schnorr_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* secp256k1 Schnorr (BIP-340) verify.
* pub is 32-byte x-only pubkey, sig is 64 bytes.
* Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_secp256k1_schnorr_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* secp256k1 ECDSA sign arbitrary bytes.
* priv is 32-byte scalar, sig_out must be at least 64 bytes (compact DER r||s).
* Hashes the message with SHA-256 before signing.
* Returns 0 on success, -1 on error. */
int crypto_secp256k1_ecdsa_sign(const unsigned char *priv, size_t priv_len,
const unsigned char *msg, size_t msg_len,
unsigned char *sig_out, size_t *sig_out_len);
/* secp256k1 ECDSA verify.
* pub is 32-byte x-only pubkey (converted internally to compressed form).
* sig is 64-byte compact (r||s). Returns 0 on valid, 1 on invalid, -1 on error. */
int crypto_secp256k1_ecdsa_verify(const unsigned char *pub, size_t pub_len,
const unsigned char *msg, size_t msg_len,
const unsigned char *sig, size_t sig_len);
/* ---- Algorithm key cache ----
* On-demand key derivation by algorithm+index, separate from the role-based
* key_store. Holds up to ALG_KEY_CACHE_MAX derived keys in secure memory.
* When full, the oldest entry is evicted (FIFO). */
#define ALG_KEY_CACHE_MAX 32
typedef struct {
crypto_alg_t alg;
int index;
secure_buf_t private_key;
secure_buf_t public_key;
char pubkey_hex[8192];
char key_id[17];
int valid;
} alg_key_entry_t;
typedef struct {
alg_key_entry_t entries[ALG_KEY_CACHE_MAX];
int count;
} algorithm_key_cache_t;
/* Initialize an empty cache. */
void alg_key_cache_init(algorithm_key_cache_t *cache);
/* Zeroize and free all entries. Idempotent. */
void alg_key_cache_wipe(algorithm_key_cache_t *cache);
/* Look up a cached entry by (alg, index). Returns NULL if not present. */
const alg_key_entry_t *alg_key_cache_get(algorithm_key_cache_t *cache, crypto_alg_t alg, int index);
/* Derive a key on-demand by (alg, index) and store it in the cache.
* Uses the standard derivation path for the algorithm.
* Returns 0 on success, -1 on error. */
int alg_key_cache_derive(algorithm_key_cache_t *cache, const mnemonic_state_t *mnemonic, crypto_alg_t alg, int index);
/* from dispatcher.h */ /* from dispatcher.h */
@@ -341,10 +597,11 @@ typedef struct {
role_table_t *role_table; role_table_t *role_table;
mnemonic_state_t *mnemonic; mnemonic_state_t *mnemonic;
key_store_t *key_store; key_store_t *key_store;
algorithm_key_cache_t *alg_key_cache; /* algorithm-based on-demand keys */
} dispatcher_ctx_t; } dispatcher_ctx_t;
/* Initialize dispatcher context */ /* Initialize dispatcher context */
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store); void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store, algorithm_key_cache_t *alg_key_cache);
/* /*
* Process a JSON-RPC request string and produce a JSON-RPC response string. * Process a JSON-RPC request string and produce a JSON-RPC response string.
@@ -377,7 +634,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108 #define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536 #define SERVER_MAX_MSG_SIZE 16777216
/* Caller identity */ /* Caller identity */
typedef struct { 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 */

Some files were not shown because too many files have changed in this diff Show More