Compare commits

...
7 Commits
Author SHA1 Message Date
Laan Tungir e577de4e2d v0.1.27 - Entropy audit: remediate 3 findings (F-006 RP2040 xorshift fallback, F-007 PQ DRBG docs, F-008 SLH-DSA SK.prf domain separation) 2026-08-13 08:30:55 -04:00
Laan Tungir 44a87432b7 v0.1.26 - Security audit remediation: mlock fatal on failure, hybrid auth replay protection, HTTP Content-Length safe parsing, OTP sscanf validation, compiler hardening flags 2026-08-13 08:05:58 -04:00
Laan Tungir 6aa42d4387 v0.1.25 - Fix submodule dirty state in feather_s3_tft secp256k1; clean release v0.1.25 2026-08-11 10:06:19 -04:00
Laan Tungir 0b1c3ea382 v0.1.24 - Migrate Teensy 4.1 signer to role+path authorization model (deprecate nostr_index) 2026-08-08 13:55:02 -04:00
Laan Tungir fd895db0c9 v0.1.23 - Make role-as-password the default authorization model; remove interactive approval prompts from wizard; add Makefile guard to direct agents to build_static.sh; optimize build_static.sh with content-hash skip and fix .dockerignore to exclude 1.3GB of unnecessary files from Docker context 2026-08-08 08:09:56 -04:00
Laan Tungir e4087441a5 v0.1.22 - Fix connections display EINTR handling with tui_get_key; add qrexec usage example to client help; simplify deploy_local.sh by removing confirmation prompt 2026-08-06 17:41:10 -04:00
Laan Tungir 5a16c4e1bf v0.1.21 - Refactor n_signer_client to use nostr_core_lib high-level wrappers; sync vendored lib with selector rewrite + 10 new algorithm-verb wrappers + from_client/last_error APIs 2026-08-06 11:41:08 -04:00
34 changed files with 3252 additions and 258 deletions
+23
View File
@@ -4,3 +4,26 @@ build/
/*.a
!resources/
!resources/**
# Exclude nested .git and bare repos inside resources/nostr_core_lib.
# These are not needed for the build and add ~1.3 GB to the Docker context.
resources/nostr_core_lib/.git/
resources/nostr_core_lib/rewrite_mirror/
resources/nostr_core_lib/verify_remote_size/
resources/nostr_core_lib/verify_remote_size_now/
resources/nostr_core_lib/backups/
resources/nostr_core_lib/examples/
resources/nostr_core_lib/tests/
resources/nostr_core_lib/plans/
resources/nostr_core_lib/pool.log
resources/nostr_core_lib/Trash/
resources/nostr_core_lib/node_modules/
resources/nostr_core_lib/nips/
resources/nostr_core_lib/nak/
resources/nostr_core_lib/nostr-tools/
resources/nostr_core_lib/libsodium/
resources/nostr_core_lib/monocypher-4.0.2/
resources/nostr_core_lib/tiny-AES-c/
resources/nostr_core_lib/blossom/
resources/nostr_core_lib/ndk/
resources/nostr_core_lib/cline_history/
+39 -2
View File
@@ -1,6 +1,17 @@
CC := gcc
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
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections \
-fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE \
-fstack-clash-protection \
-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 -pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack \
resources/nostr_core_lib/libnostr_core_x64.a \
-lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
SRC_DIR := src
BUILD_DIR := build
@@ -93,12 +104,38 @@ N_SIGNER_CLIENT_TARGET := $(BUILD_DIR)/nsigner_client
.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api test-path-whitelist test-n-signer-client examples clients test-client clean
# Guard for non-static build targets.
# The canonical build is `make static` (runs build_static.sh).
# To use dev/test targets, set NSIGNER_ALLOW_DEV_BUILD=1 in your environment.
# This prevents AI agents from accidentally using the wrong build path.
define BUILD_GUARD
@if [ -z "$$NSIGNER_ALLOW_DEV_BUILD" ]; then \
echo "=========================================================="; \
echo "ERROR: This target is blocked for non-interactive agents."; \
echo " For testing and deployment, use:"; \
echo ""; \
echo " ./build_static.sh"; \
echo " or"; \
echo " make static"; \
echo ""; \
echo " The static build produces the canonical binary that"; \
echo " matches production deployments."; \
echo ""; \
echo " To override (human developers only):"; \
echo " NSIGNER_ALLOW_DEV_BUILD=1 make <target>"; \
echo "=========================================================="; \
exit 1; \
fi
endef
all: dev clients
lib:
$(BUILD_GUARD)
cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,13,19,44
dev: lib $(TARGET_DEV)
$(BUILD_GUARD)
$(TARGET_DEV): $(SOURCES)
@mkdir -p $(BUILD_DIR)
+254
View File
@@ -0,0 +1,254 @@
# n_signer Security Audit — Consolidated Remediation Report
**Date:** 2026-08-13
**Scope:** Full static security audit of [`src/`](../src/), [`client/`](../client/), [`libotppad/`](../libotppad/), build configuration, and entropy/key-derivation paths
**Result:** 8 findings identified, all remediated and verified
---
## Executive Summary
A comprehensive security audit of the `n_signer` codebase identified **8 security findings** across memory safety, network parsing, authentication, build hardening, and entropy/key-derivation. All findings have been remediated, code-reviewed, and verified against the existing test suite.
| Severity | Count | Status |
|----------|-------|--------|
| High | 2 | ✅ All Remediated |
| Medium | 5 | ✅ All Remediated |
| Low | 1 | ✅ All Remediated |
| **Total** | **8** | **All Fixed** |
---
## Findings and Remediations
### F-001: mlock Failure Silently Degraded to Pageable Memory
| | |
|---|---|
| **Severity** | Medium |
| **File** | [`src/secure_mem.c`](../src/secure_mem.c):762798 |
| **Status** | ✅ Remediated |
**Problem.** [`secure_buf_alloc()`](../src/secure_mem.c:762) called `mlock()` to pin secret material (mnemonic, private keys) in RAM. On failure (e.g., `RLIMIT_MEMLOCK` exhausted, missing `CAP_IPC_LOCK`), it printed a warning and **returned success with the buffer unlocked**. No caller checked `buf->locked`, so the process continued with secrets in pageable memory — silently undermining the "crash = total wipe" and "no filesystem footprint" guarantees. An attacker with disk access after the fact could recover key material from swap.
**Fix.** mlock failure is now **fatal by default**. The function prints a diagnostic with `strerror(errno)` and returns `-1`, causing startup to abort. A new opt-in escape hatch, `secure_buf_allow_unlocked()`, is wired to the `--allow-unlocked-memory` CLI flag in [`src/main.c`](../src/main.c):3653 for development/container environments where `mlock` is unavailable.
**Files changed:**
- [`src/secure_mem.c`](../src/secure_mem.c) — fatal-by-default logic, `secure_buf_allow_unlocked()`, added `<errno.h>`
- [`src/main.c`](../src/main.c) — `--allow-unlocked-memory` argument parsing + declaration
---
### F-002: HTTP Content-Length Parsed with `atol()` — No Error Detection
| | |
|---|---|
| **Severity** | Medium |
| **File** | [`src/http_listener.c`](../src/http_listener.c):75163 |
| **Status** | ✅ Remediated |
**Problem.** The HTTP request parser read `Content-Length` using `atol()`, which has **no error detection**: it returns `0` for non-numeric input (indistinguishable from a real `0`), and silently truncates values exceeding `LONG_MAX`. The value was stored in a signed `long` and compared against a `size_t` limit, creating signed/unsigned confusion. A `Content-Length` near `LONG_MAX` could trigger a giant allocation attempt (DoS via OOM or NULL-deref crash).
**Fix.** Replaced `atol()` with `strtoull()` and full validation:
- Rejects empty/non-numeric values (`endptr == p`)
- Rejects trailing garbage (only whitespace/CR allowed after digits)
- Rejects values exceeding `SIZE_MAX`
- Changed `content_length` from `long` to `size_t`, eliminating signed/unsigned confusion
- Added a `has_content_length` flag to distinguish "missing header" from "zero length"
**Files changed:**
- [`src/http_listener.c`](../src/http_listener.c) — safe parsing, type fix, drain loop type fix
---
### F-003: Auth Envelope Nonce Cache Replay After Wrap
| | |
|---|---|
| **Severity** | **High** |
| **Files** | [`src/auth_envelope.h`](../src/auth_envelope.h), [`src/auth_envelope.c`](../src/auth_envelope.c) |
| **Status** | ✅ Remediated |
**Problem.** Replay protection used a **bounded FIFO cache of 1024 event IDs** ([`AUTH_NONCE_CACHE_SIZE`](../src/auth_envelope.h):10). When full, the oldest entry was evicted (circular overwrite). An attacker who captured 1024 valid auth envelopes could replay any of them after the cache wrapped — the evicted nonce would no longer be detected as a duplicate. Combined with the 30-second timestamp skew window, this allowed impersonation of any previously-seen caller.
**Fix.** Replaced the bounded FIFO cache with a **hybrid per-pubkey replay tracker**:
1. **Monotonic timestamp per pubkey** — tracks the highest `created_at` seen for each of up to 64 pubkeys. Any envelope with `created_at < max_seen` is rejected as a replay. This has **no wrap-around weakness**.
2. **Event ID set for the current second** — because `created_at` has 1-second granularity, a per-(pubkey, second) set of up to 32 event IDs allows multiple legitimate concurrent requests within the same second while still rejecting exact duplicates.
3. When `created_at > max_seen`, the event ID set is cleared and the timestamp advances.
The initial monotonic-only version was caught by the existing test suite ([`tests/test_auth_envelope.c`](../tests/test_auth_envelope.c)) which builds multiple same-second requests — the hybrid design passes all 13 tests.
**Files changed:**
- [`src/auth_envelope.h`](../src/auth_envelope.h) — new `auth_pubkey_entry_t` structure with `max_created_at` + `event_ids[]`
- [`src/auth_envelope.c`](../src/auth_envelope.c) — new `auth_nonce_cache_check_and_update()` implementing the hybrid check; event ID extracted from the signed envelope's `id` field
---
### F-004: OTP Binary Header Checksum Parsed with `sscanf` — Return Value Ignored
| | |
|---|---|
| **Severity** | Low |
| **File** | [`src/otp_pad.c`](../src/otp_pad.c):408416 |
| **Status** | ✅ Remediated |
**Problem.** When building a binary `.otp` output header, the hex pad checksum was converted to bytes using `sscanf("%02x")` in a loop, but the **return value was never checked**. If the checksum string were ever malformed, `sscanf` would leave the destination variable uninitialized, producing garbage in the output header.
**Fix.** Added a return-value check: if `sscanf` does not return exactly 1, the function zeroizes the scratch buffer and returns an error. (An earlier version of this fix incorrectly called `free(blob)` before `blob` was declared — this was caught in code review and corrected.)
**Files changed:**
- [`src/otp_pad.c`](../src/otp_pad.c) — `sscanf` return value checked with proper error cleanup
---
### F-005: Missing Compiler Hardening Flags
| | |
|---|---|
| **Severity** | Medium |
| **File** | [`Makefile`](../Makefile):110 |
| **Status** | ✅ Remediated |
**Problem.** The build used only `-Wall -Wextra -Os` with no exploit-mitigation flags. The resulting binary had no stack canaries, no `_FORTIFY_SOURCE` bounds checking, no PIE (fixed load address — trivial ROP), writable GOT (no RELRO), and a potentially executable stack. For a program that parses untrusted network input while holding signing keys, these gaps significantly raise the impact of any memory-corruption bug.
**Fix.** Added the standard hardening flag set to `CFLAGS` and `LDFLAGS`:
- `-fstack-protector-strong` — stack canaries
- `-D_FORTIFY_SOURCE=2` — compile-time + runtime bounds checking for libc functions
- `-fPIE` / `-pie` — position-independent executable (ASLR for code)
- `-Wl,-z,relro -Wl,-z,now` — full RELRO (read-only GOT after startup)
- `-Wl,-z,noexecstack` — non-executable stack (NX)
- `-fstack-clash-protection` — stack-clash probing
**Verification.** The rebuilt binary is confirmed as `ELF 64-bit LSB pie executable`. Notably, the new `-fstack-protector-strong` flag **immediately caught a pre-existing latent buffer overflow** in [`tests/test_selector.c`](../tests/test_selector.c) (stack smashing detected at runtime) — a bug that was previously silent. This validates the value of the hardening flags.
**Files changed:**
- [`Makefile`](../Makefile) — hardening flags in `CFLAGS` and `LDFLAGS`
---
### F-006: RP2040 Fallback RNG Uses Cryptographically Weak xorshift32
| | |
|---|---|
| **Severity** | **High** |
| **File** | [`firmware/kb2040_hidden_signer/src/platform/rp2040.c`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c):3178 |
| **Status** | ✅ Remediated |
**Problem.** [`nostr_platform_random()`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c:31) is the sole entropy source for the KB2040 hidden signer, used to generate private keys, mnemonic entropy, secp256k1 context randomization, NIP-04 IVs, and NIP-44 nonces. When the Pico SDK's hardware RNG (`get_rand_32()`) is unavailable, it fell through to a **deterministic xorshift32 PRNG** seeded from a hardcoded constant XOR'd with `micros()`, `millis()`, a stack address, and ADC temperature sensor readings. xorshift32 is not cryptographically secure — its 32-bit state is trivially brute-forceable. An attacker who observes boot timing could reconstruct all keys.
**Fix.** Removed the xorshift32 fallback entirely. If `get_rand_32()` is unavailable, the function now returns `-1` and refuses to generate keys. The Pico SDK's ring-oscillator-based RNG is available on all official RP2040 boards. The ADC and Arduino timing code was also removed since it was only used to seed the xorshift.
**Files changed:**
- [`firmware/kb2040_hidden_signer/src/platform/rp2040.c`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c) — removed xorshift32 fallback, fail closed on missing `get_rand_32()`
---
### F-007: PQ DRBG is Not a NIST SP 800-90A Compliant Construction
| | |
|---|---|
| **Severity** | Medium |
| **File** | [`src/pq_drbg.c`](../src/pq_drbg.c):1134 |
| **Status** | ✅ Remediated |
**Problem.** The PQ deterministic DRBG uses a custom `SHAKE-256(seed || counter)` construction rather than a NIST SP 800-90A DRBG. While cryptographically sound for single-shot keygen, it had issues: (1) the buffer-size comment confused SHAKE-256's rate (136 bytes) with SHAKE-128's rate (168 bytes); (2) the non-standard nature was documented but the rationale for why it's acceptable could be clearer.
**Fix.** Fixed the rate comment to correctly explain that 168 is the requested output length, not the SHAKE-256 rate (136 bytes). Strengthened the file header documentation with a detailed security argument listing 5 reasons why this non-standard construction is acceptable for this use case. Domain separation across algorithm types is not needed because the DRBG is initialized once per keygen and zeroized after — different algorithm types use different seeds.
**Files changed:**
- [`src/pq_drbg.c`](../src/pq_drbg.c) — fixed comment, strengthened documentation
---
### F-008: SLH-DSA-128s SK.prf Used as Both PRF Key and DRBG Seed
| | |
|---|---|
| **Severity** | Medium |
| **File** | [`src/pq_crypto.c`](../src/pq_crypto.c):14571471 |
| **Status** | ✅ Remediated |
**Problem.** In the SLH-DSA-128s signing path, `SK.prf` (bytes 16..31 of the secret key) was used for two distinct purposes: (1) as the PRF key for `PRF_msg()` per FIPS 205, and (2) as the raw seed for the deterministic DRBG that produces `opt_rand`. This dual use was non-standard — if the DRBG output were ever compromised, `SK.prf` would also be compromised, breaking the `PRF_msg` security guarantee.
**Fix.** Replaced the direct `pq_drbg_init(sk_prf, ...)` call with a domain-separated derivation:
```c
drbg_seed = HMAC-SHA256(SK.prf, "slh-dsa-drbg-seed")
pq_drbg_init(drbg_seed, 32)
```
This ensures that even if the DRBG output is somehow compromised, `SK.prf` remains secret and `PRF_msg` remains secure. The HMAC key is `SK.prf` (16 bytes), the message is the ASCII string `"slh-dsa-drbg-seed"`, and the output is a 32-byte DRBG seed that is zeroized after initialization.
**Files changed:**
- [`src/pq_crypto.c`](../src/pq_crypto.c):14571471 — domain-separated DRBG seed from SK.prf via HMAC-SHA256
---
## Post-Remediation Defects Caught in Review
During code review of the initial fixes, 5 defects were identified and corrected before final verification:
| # | Defect | File | Resolution |
|---|--------|------|-----------|
| 1 | `errno` used without `#include <errno.h>` (compile error) | [`src/secure_mem.c`](../src/secure_mem.c) | Added include |
| 2 | `free(blob)` referenced before `blob` was declared (compile error) | [`src/otp_pad.c`](../src/otp_pad.c) | Removed erroneous `free()`; only `secure_memzero` needed on that path |
| 3 | Duplicated pubkey validation block (dead code) | [`src/auth_envelope.c`](../src/auth_envelope.c) | Removed duplicate |
| 4 | `secure_buf_allow_unlocked()` not declared in main.c's headerless block (compile error) | [`src/main.c`](../src/main.c) | Added declaration |
| 5 | Monotonic-only timestamp rejected same-second requests (test failure) | [`src/auth_envelope.c`](../src/auth_envelope.c) | Upgraded to hybrid timestamp + event-ID design |
---
## Verification
### Build
- Compiles cleanly with all hardening flags enabled
- Output binary confirmed as PIE: `ELF 64-bit LSB pie executable, x86-64`
### Test Suite
| Test | Result |
|------|--------|
| `test_auth_envelope` | ✅ 13/13 passed (validates F-003 hybrid design) |
| `test_mnemonic` | ✅ All passed |
| `test_role_table` | ✅ All passed |
| `test_enforcement` | ✅ 10/10 passed |
| `test_policy` | ✅ 43/43 passed |
| `test_socket_name` | ✅ All passed |
| `test_mnemonic_input` | ✅ All passed |
| `test_path_whitelist` | ✅ 40/41 (1 pre-existing failure, unrelated) |
| `test_selector` | ⚠️ Stack smashing detected — **hardening caught a pre-existing latent bug** (unrelated to remediations) |
| `test_ml_dsa_65` | ✅ 26/26 passed |
| `test_ml_kem_768` | ✅ 29/29 passed |
| `test_pq_crypto` | ✅ All passed |
---
## Remaining Recommendations (Non-Blocking)
These items were noted during the audit but are not security findings:
1. **Fix the latent `test_selector` buffer overflow** now exposed by `-fstack-protector-strong`. This is a pre-existing bug in the test code, not in production code.
2. **Apply the same hardening flags to the musl-static build** in [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) / [`build_static.sh`](../build_static.sh) (verify musl-gcc supports `-fstack-clash-protection`, GCC 8+).
3. **Pin vendored dependency versions** (cJSON, nostr_core_lib, PQClean, secp256k1) to specific commits and track known CVEs.
4. **Add fuzz testing** for the HTTP parser and transport frame parser.
5. **Document the `--allow-unlocked-memory` flag** in the README security section.
---
## Files Changed Summary
| File | Finding(s) |
|------|-----------|
| [`src/secure_mem.c`](../src/secure_mem.c) | F-001 |
| [`src/main.c`](../src/main.c) | F-001 (flag wiring) |
| [`src/http_listener.c`](../src/http_listener.c) | F-002 |
| [`src/auth_envelope.h`](../src/auth_envelope.h) | F-003 |
| [`src/auth_envelope.c`](../src/auth_envelope.c) | F-003 |
| [`src/otp_pad.c`](../src/otp_pad.c) | F-004 |
| [`Makefile`](../Makefile) | F-005 |
| [`firmware/kb2040_hidden_signer/src/platform/rp2040.c`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c) | F-006 |
| [`firmware/kb2040_hidden_signer/src/nostr_core/nip006.c`](../firmware/kb2040_hidden_signer/src/nostr_core/nip006.c) | F-006 (propagation) |
| [`src/pq_drbg.c`](../src/pq_drbg.c) | F-007 |
| [`src/pq_crypto.c`](../src/pq_crypto.c) | F-008 |
+75 -7
View File
@@ -1,21 +1,26 @@
#!/bin/bash
# Build fully static MUSL binary for nsigner using Alpine Docker
#
# Speed optimization: if nothing changed since the last successful build,
# skip the Docker build entirely and reuse the existing binaries.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
HASH_FILE="$BUILD_DIR/.nsigner_build_hash"
TARGET_ARCH=""
FORCE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--arch)
if [[ -z "${2:-}" ]]; then
echo "ERROR: --arch requires a value"
echo "Usage: $0 [--arch <x86_64|arm64|armv7>]"
echo "Usage: $0 [--arch <x86_64|arm64|armv7>] [--force]"
exit 1
fi
case "$2" in
@@ -30,9 +35,13 @@ while [[ $# -gt 0 ]]; do
esac
shift 2
;;
--force)
FORCE=1
shift
;;
*)
echo "ERROR: Unknown argument '$1'"
echo "Usage: $0 [--arch <x86_64|arm64|armv7>]"
echo "Usage: $0 [--arch <x86_64|arm64|armv7>] [--force]"
exit 1
;;
esac
@@ -78,7 +87,7 @@ case "$ARCH" in
CLIENT_NAME="nsigner_client_static_arm64"
;;
armv7)
PLATFORM="linux/arm/v7"
PLATFORM="linux/v7"
OUTPUT_NAME="nsigner_static_armv7"
CLIENT_NAME="nsigner_client_static_armv7"
;;
@@ -109,6 +118,62 @@ echo "Output: $BUILD_DIR/$OUTPUT_NAME"
echo "Client: $BUILD_DIR/$CLIENT_NAME"
echo ""
# ---- Change detection ----
# Compute a hash of all files that feed into the Docker build.
# If the hash matches the last successful build and the output binaries
# exist, skip the Docker build entirely.
compute_source_hash() {
{
# Dockerfile itself
cat "$DOCKERFILE"
# .dockerignore
cat "$SCRIPT_DIR/.dockerignore" 2>/dev/null || true
# All source files
find "$SCRIPT_DIR/src" "$SCRIPT_DIR/client" "$SCRIPT_DIR/libotppad" \
"$SCRIPT_DIR/resources/tui_continuous" "$SCRIPT_DIR/resources/pqclean" \
-type f -not -path '*/.git/*' 2>/dev/null | sort | xargs cat 2>/dev/null
# nostr_core_lib source (exclude .git, backups, bare repos, examples, tests)
find "$SCRIPT_DIR/resources/nostr_core_lib" \
-type f \
-not -path '*/.git/*' \
-not -path '*/rewrite_mirror/*' \
-not -path '*/verify_remote_size*' \
-not -path '*/backups/*' \
-not -path '*/examples/*' \
-not -path '*/tests/*' \
-not -path '*/Trash/*' \
-not -path '*/node_modules/*' \
-not -path '*/nips/*' \
-not -path '*/nak/*' \
-not -path '*/nostr-tools/*' \
-not -path '*/libsodium/*' \
-not -path '*/monocypher*' \
-not -path '*/tiny-AES-c/*' \
-not -path '*/blossom/*' \
-not -path '*/ndk/*' \
-not -path '*/cline_history/*' \
2>/dev/null | sort | xargs cat 2>/dev/null
} | sha256sum | awk '{print $1}'
}
CURRENT_HASH="$(compute_source_hash)"
OUTPUT_PATH="$BUILD_DIR/$OUTPUT_NAME"
CLIENT_PATH="$BUILD_DIR/$CLIENT_NAME"
if [[ "$FORCE" -eq 0 ]] && \
[[ -f "$OUTPUT_PATH" ]] && \
[[ -f "$CLIENT_PATH" ]] && \
[[ -f "$HASH_FILE" ]] && \
[[ "$(cat "$HASH_FILE" 2>/dev/null)" == "$CURRENT_HASH" ]]; then
echo "No changes detected since last successful build."
echo "Skipping Docker build. Existing binaries:"
echo " $OUTPUT_PATH"
echo " $CLIENT_PATH"
echo ""
echo "Use --force to rebuild anyway."
exit 0
fi
if [ "$ARCH" != "$HOST_ARCH" ]; then
echo "[0/3] Preparing buildx + QEMU for cross-architecture build"
if ! docker buildx inspect >/dev/null 2>&1; then
@@ -126,10 +191,10 @@ if [ "$ARCH" != "$HOST_ARCH" ]; then
fi
echo "[1/3] Building builder stage from project root context"
# Remove previous builder image to avoid dangling <none> images piling up
# across repeated builds (each rebuild untagges the old image, leaving ~422MB
# of garbage per build otherwise).
docker rmi "$IMAGE_TAG" >/dev/null 2>&1 || true
# Note: we no longer docker rmi before building. The buildx cache handles
# layer reuse, and the prune at the end prevents dangling images. Removing
# the image here forced a full --load re-export (~370MB) every time even
# when all layers were cache hits.
docker buildx build \
--platform "$PLATFORM" \
--target builder \
@@ -173,6 +238,9 @@ echo "Build complete:"
echo " $BUILD_DIR/$OUTPUT_NAME"
echo " $BUILD_DIR/$CLIENT_NAME"
# Record the source hash so the next run can skip if nothing changed.
echo "$CURRENT_HASH" > "$HASH_FILE"
# Prune stale build cache older than 24h to prevent unbounded cache growth
# from repeated buildx builds. Recent layers are kept for fast rebuilds.
docker builder prune -af --filter "until=24h" >/dev/null 2>&1 || true
+5 -2
View File
@@ -110,8 +110,11 @@ static void print_usage(FILE *fp, const char *prog) {
" %s --algorithm ed25519 --index 0 sign 68656c6c6f\n"
"\n"
" # Get signer metadata\n"
" %s get-info\n",
prog, prog, prog, prog, prog, prog, prog, prog, prog, prog, prog);
" %s get-info\n"
"\n"
" # Qubes qrexec: get the first pubkey from a signer in the nostr_signer qube\n"
" %s --qrexec nostr_signer:qubes.NsignerRpc --role nostr_range --path \"m/44'/1237'/0'/0/0\" get-public-key\n",
prog, prog, prog, prog, prog, prog, prog, prog, prog, prog, prog, prog);
}
/* Read one line from stdin (newline stripped). Returns malloc'd string or NULL on EOF/error. */
+2 -26
View File
@@ -4,9 +4,8 @@
# and install them to /usr/local/bin/
#
# Usage:
# ./deploy_local.sh # build + install (prompts for sudo)
# ./deploy_local.sh # build + install (uses sudo if needed)
# ./deploy_local.sh --no-build # install existing build/ binaries only
# ./deploy_local.sh --force # skip confirmation prompt
#
set -euo pipefail
@@ -41,7 +40,6 @@ case "$ARCH" in
esac
DO_BUILD=true
FORCE=false
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -49,10 +47,6 @@ while [[ $# -gt 0 ]]; do
DO_BUILD=false
shift
;;
--force|-f)
FORCE=true
shift
;;
-h|--help)
echo "deploy_local.sh — Build and install nsigner + nsigner_client to $INSTALL_PREFIX"
echo ""
@@ -60,13 +54,12 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "OPTIONS:"
echo " --no-build Skip build step; install existing binaries from build/"
echo " --force, -f Skip confirmation prompt"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "ERROR: Unknown argument '$1'"
echo "Usage: $0 [--no-build] [--force]"
echo "Usage: $0 [--no-build]"
exit 1
;;
esac
@@ -119,23 +112,6 @@ echo " OK: $CLIENT_BIN ($(du -h "$CLIENT_BIN" | cut -f1))"
SIGNER_VERSION="$("$SIGNER_BIN" --version 2>&1 || echo "unknown")"
echo " Signer version: $SIGNER_VERSION"
# --- Confirm ------------------------------------------------------------------
if ! $FORCE; then
echo ""
echo "About to install:"
echo " $SIGNER_BIN -> $INSTALL_PREFIX/nsigner"
echo " $CLIENT_BIN -> $INSTALL_PREFIX/nsigner_client"
echo ""
read -r -p "Proceed? [y/N] " response
case "$response" in
[yY][eE][sS]|[yY]) ;;
*)
echo "Aborted."
exit 0
;;
esac
fi
# --- Install ------------------------------------------------------------------
echo ""
echo "[3/3] Installing to $INSTALL_PREFIX"
@@ -13,21 +13,8 @@
# include "pico/rand.h"
# define NOSTR_HAVE_PICO_RAND 1
# endif
# if __has_include("hardware/adc.h")
# include "hardware/adc.h"
# define NOSTR_HAVE_PICO_ADC 1
# endif
#endif
static uint32_t xorshift32(uint32_t *state) {
uint32_t x = *state ? *state : 0xA5A5A5A5u;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*state = x;
return x;
}
int nostr_platform_random(unsigned char *buf, size_t len) {
if (!buf) {
return -1;
@@ -47,33 +34,14 @@ int nostr_platform_random(unsigned char *buf, size_t len) {
}
return 0;
#else
uint32_t seed = 0x13579BDFu;
#if defined(ARDUINO)
seed ^= (uint32_t)micros();
seed ^= ((uint32_t)millis() << 16);
seed ^= (uint32_t)(uintptr_t)&seed;
#endif
#if defined(NOSTR_HAVE_PICO_ADC)
adc_init();
adc_set_temp_sensor_enabled(true);
adc_select_input(4);
for (int k = 0; k < 16; ++k) {
seed ^= ((uint32_t)adc_read() << ((k & 3) * 8));
}
#endif
while (i < len) {
uint32_t r = xorshift32(&seed);
#if defined(ARDUINO)
r ^= (uint32_t)micros();
#endif
size_t take = (len - i >= 4) ? 4 : (len - i);
memcpy(buf + i, &r, take);
i += take;
}
return 0;
/* No hardware RNG available — fail closed.
* The Pico SDK's get_rand_32() (ring-oscillator-based TRNG) is available
* on all official RP2040 boards. Without it, we cannot provide secure
* randomness for key generation. The previous xorshift32 fallback was
* removed because it was cryptographically weak (32-bit state, predictable
* from boot timing). See audit/F-006-rp2040-xorshift-fallback-rng.md. */
(void)i;
(void)len;
return -1;
#endif
}
+42
View File
@@ -478,6 +478,48 @@ void setup() {
dispatch_init();
Serial.println("Dispatch initialized.");
// ---- Role table (Phase 6 of teensy41_role_path_migration.md) ----
// The role table (g_roles, defined in dispatch.cpp as DMAMEM) must be
// populated before any nostr_* verb can be dispatched. In interactive mode
// the user picks presets via the LVGL wizard; in DEBUG_AUTO_GENERATE mode
// a single "main" role is auto-created so headless tests work.
role_table_init(&g_roles);
#if DEBUG_AUTO_GENERATE
{
Serial.println("DEBUG_AUTO_GENERATE=1: auto-creating 'main' role.");
role_entry_t entry;
memset(&entry, 0, sizeof(entry));
strncpy(entry.name, "main", sizeof(entry.name) - 1);
strncpy(entry.role_path, "m/44'/1237'/0'/0/0", sizeof(entry.role_path) - 1);
entry.purpose = ROLE_PURPOSE_NOSTR;
entry.curve = ROLE_CURVE_SECP256K1;
entry.path_range_lo = -1;
entry.path_range_hi = -1;
entry.path_default_index = -1;
entry.requires_approval = 0; /* role-as-password */
if (role_table_add(&g_roles, &entry) != 0) {
Serial.println("WARNING: failed to auto-create 'main' role");
} else {
Serial.print("Auto-created role 'main' (");
Serial.print(entry.role_path);
Serial.println(")");
}
}
#else
{
Serial.println("Starting role wizard...");
if (ui_role_wizard(&g_roles) != 0) {
Serial.println("Role wizard cancelled or failed — aborting boot.");
// Show an error screen and halt.
build_busy_screen("No roles defined.\nReboot to try again.");
while (1) { /* halt */ }
}
Serial.print("Role wizard complete: ");
Serial.print(g_roles.count);
Serial.println(" role(s) defined.");
}
#endif
// Initialize the USB CDC transport.
transport_init();
Serial.println("Transport initialized.");
+305 -28
View File
@@ -41,6 +41,8 @@
#include "otp_pad_sd.h"
#include "key_derivation.h"
#include "ed25519.h"
#include "role_table.h"
#include "selector.h"
#include "secp256k1/include/secp256k1.h"
#include "secp256k1/include/secp256k1_extrakeys.h"
@@ -70,6 +72,11 @@ char g_npub[128];
char g_pubkey_hex[65];
int g_signer_ready = 0;
/* ---- Role table (populated by signer.ino after the role wizard) ----
* In DMAMEM (RAM2) to keep RAM1 free for ITCM code. 16 entries × ~240 bytes
* = ~3.8 KB, negligible against the 110 KB free heap. */
DMAMEM role_table_t g_roles;
/* ---- Persistent crash diagnostics (defined in signer.ino, DMAMEM) ---- */
extern "C" volatile uint32_t g_last_op;
extern "C" volatile uint32_t g_last_op_seq;
@@ -146,6 +153,13 @@ typedef enum {
#define ERR_APPROVAL_TIMEOUT -32001
#define ERR_ALG_NOT_SUPPORTED 1010
#define ERR_MINING_FAILED 1008
/* Role + path authorization errors (match the host's error codes). */
#define ERR_UNKNOWN_ROLE 1002
#define ERR_PATH_NOT_ALLOWED 2003
#define ERR_NOSTR_INDEX_DEPRECATED 2006
#define ERR_ROLE_REQUIRED 2007
#define ERR_PATH_REQUIRED 2008
#define ERR_NO_DEFAULT_ROLE 2009
/* Auth envelope error messages (indexed by AUTH_ERR_* code). */
static const char *auth_err_message(int code) {
@@ -920,15 +934,237 @@ __attribute__((section(".flashmem"))) static int derive_request_key(uint32_t nos
return 0;
}
/* Parse [peer_hex, message, {options}] from params. */
/* ====================================================================
* Role + path selector helpers (Phase 4 of teensy41_role_path_migration.md)
* ==================================================================== */
/* Compile-time flag: when 1, nostr_index is silently accepted (mapped to the
* default role's path) so the old test_signer.py can run during migration.
* When 0 (the default), nostr_index is rejected with error 2006, matching
* the host. Flip to 1 only for the transition period. */
#ifndef ALLOW_DEPRECATED_NOSTR_INDEX
#define ALLOW_DEPRECATED_NOSTR_INDEX 0
#endif
/* Parse the selector fields (role, role_path, nostr_index) from the trailing
* options object of a params array. Returns 0 on success (fields left at
* their defaults if absent). Returns -1 if the params shape is invalid. */
__attribute__((section(".flashmem"))) static int parse_selector_from_params(cJSON *params,
selector_request_t *out) {
int n;
cJSON *last, *item;
if (out == NULL) {
return -1;
}
selector_request_init(out);
if (params == NULL || !cJSON_IsArray(params)) {
return 0; /* no options → empty selector (will use default role) */
}
n = cJSON_GetArraySize(params);
if (n <= 0) {
return 0;
}
last = cJSON_GetArrayItem(params, n - 1);
if (last == NULL || !cJSON_IsObject(last)) {
return 0; /* no options object → empty selector */
}
item = cJSON_GetObjectItemCaseSensitive(last, "role");
if (cJSON_IsString(item) && item->valuestring != NULL) {
out->has_role = 1;
strncpy(out->role_name, item->valuestring, sizeof(out->role_name) - 1);
out->role_name[sizeof(out->role_name) - 1] = '\0';
}
item = cJSON_GetObjectItemCaseSensitive(last, "role_path");
if (cJSON_IsString(item) && item->valuestring != NULL) {
out->has_role_path = 1;
strncpy(out->role_path, item->valuestring, sizeof(out->role_path) - 1);
out->role_path[sizeof(out->role_path) - 1] = '\0';
}
item = cJSON_GetObjectItemCaseSensitive(last, "nostr_index");
if (cJSON_IsNumber(item)) {
int idx = item->valueint;
if (idx >= 0) {
out->has_nostr_index = 1;
out->nostr_index = (uint32_t)idx;
} else {
return -1;
}
}
return 0;
}
/* Map a selector_resolve() error code to a wire-protocol error code + message.
* Writes the error response into s_response_buf. */
__attribute__((section(".flashmem"))) static void selector_err_to_response(const char *id_token, int sel_err) {
switch (sel_err) {
case SELECTOR_ERR_NOSTR_INDEX_DEPRECATED:
set_error_code(id_token, ERR_NOSTR_INDEX_DEPRECATED,
"nostr_index is deprecated - use role + role_path "
"(e.g. role=main, role_path=m/44'1237'0'/0/0)");
break;
case SELECTOR_ERR_NOT_FOUND:
set_error_code(id_token, ERR_UNKNOWN_ROLE, "unknown_role");
break;
case SELECTOR_ERR_PATH_MISMATCH:
set_error_code(id_token, ERR_PATH_NOT_ALLOWED, "path_not_allowed");
break;
case SELECTOR_ERR_ROLE_REQUIRED:
set_error_code(id_token, ERR_ROLE_REQUIRED, "role_required");
break;
case SELECTOR_ERR_PATH_REQUIRED:
set_error_code(id_token, ERR_PATH_REQUIRED, "path_required");
break;
case SELECTOR_ERR_NO_DEFAULT:
set_error_code(id_token, ERR_NO_DEFAULT_ROLE, "no_default_role");
break;
default:
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
break;
}
}
/* Resolve a nostr_* request's selector and derive the secp256k1 keypair from
* the resolved path. Replaces the old parse_nostr_index_from_params +
* derive_request_key pattern.
*
* On success: fills privkey_out[32] and pubkey_hex_out[65], returns 0.
* On failure: writes the error response into s_response_buf and returns -1.
* The caller should `return;` immediately on a -1 return.
*
* `out_role` (if non-NULL) receives the resolved role entry pointer so the
* caller can check role->requires_approval. */
__attribute__((section(".flashmem"))) static int resolve_nostr_request_key(cJSON *params,
const char *id_token,
uint8_t privkey_out[32],
char pubkey_hex_out[65],
role_entry_t **out_role) {
selector_request_t req;
role_entry_t *role = NULL;
int sel_rc;
const char *path_to_derive = NULL;
char concrete_path[ROLE_PATH_MAX];
uint8_t pubkey[32];
if (privkey_out == NULL || pubkey_hex_out == NULL || id_token == NULL) {
return -1;
}
if (out_role != NULL) {
*out_role = NULL;
}
memset(privkey_out, 0, 32);
memset(pubkey_hex_out, 0, 65);
memset(concrete_path, 0, sizeof(concrete_path));
if (g_seed_len == 0) {
set_error_code(id_token, ERR_INTERNAL, "signer not ready (no mnemonic)");
return -1;
}
if (parse_selector_from_params(params, &req) != 0) {
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
return -1;
}
#if ALLOW_DEPRECATED_NOSTR_INDEX
/* Migration shim: if nostr_index is present, map it to the default role
* with the NIP-06 path m/44'/1237'/0'/0/<index>. This lets the old
* test_signer.py run during the transition. */
if (req.has_nostr_index && !req.has_role && !req.has_role_path) {
snprintf(concrete_path, sizeof(concrete_path),
"m/44'/1237'/0'/0/%u", req.nostr_index);
path_to_derive = concrete_path;
/* Skip selector_resolve — derive directly from the constructed path. */
if (derive_secp256k1_from_path(g_seed, g_seed_len, path_to_derive,
privkey_out, pubkey) != 0) {
set_error_code(id_token, ERR_INTERNAL, "key derivation failed");
return -1;
}
bytes_to_hex(pubkey, 32, pubkey_hex_out, 65);
secure_memzero(pubkey, sizeof(pubkey));
/* No role → default to requires_approval=0 (role-as-password). */
if (out_role != NULL) {
role = role_table_get_default(&g_roles);
*out_role = role; /* may be NULL if no roles configured */
}
return 0;
}
#endif
sel_rc = selector_resolve(&req, &g_roles, &role);
if (sel_rc != SELECTOR_OK) {
selector_err_to_response(id_token, sel_rc);
return -1;
}
/* Determine the concrete path to derive from. */
if (req.has_role_path) {
/* Client supplied a concrete path that selector_resolve verified
* matches the role's template + range. Use it directly. */
path_to_derive = req.role_path;
} else if (strstr(role->role_path, "%d") == NULL) {
/* Fixed path (no %d) — use the role's path. */
path_to_derive = role->role_path;
} else {
/* Template path with no client-supplied path — use the default index. */
int idx = role->path_default_index;
if (idx < 0) {
set_error_code(id_token, ERR_PATH_REQUIRED, "path_required");
return -1;
}
{
const char *pct = strstr(role->role_path, "%d");
size_t prefix_len = (size_t)(pct - role->role_path);
const char *tail = pct + 2; /* skip "%d" */
snprintf(concrete_path, sizeof(concrete_path), "%.*s%d%s",
(int)prefix_len, role->role_path, idx, tail);
}
path_to_derive = concrete_path;
}
if (derive_secp256k1_from_path(g_seed, g_seed_len, path_to_derive,
privkey_out, pubkey) != 0) {
set_error_code(id_token, ERR_INTERNAL, "key derivation failed");
secure_memzero(pubkey, sizeof(pubkey));
return -1;
}
bytes_to_hex(pubkey, 32, pubkey_hex_out, 65);
secure_memzero(pubkey, sizeof(pubkey));
if (out_role != NULL) {
*out_role = role;
}
return 0;
}
/* Check whether a nostr_* verb requires interactive approval, given the
* resolved role. Returns 1 if the prompt should be shown, 0 if role-as-
* password authorizes immediately. When no role is resolved (NULL), defaults
* to requiring approval (fail-safe). */
__attribute__((section(".flashmem"))) static int nostr_role_requires_approval(const role_entry_t *role) {
if (role == NULL) {
return 1; /* fail-safe: prompt if no role */
}
return role->requires_approval ? 1 : 0;
}
/* Parse [peer_hex, message, {options}] from params. The options object is
* parsed into the selector_request_t (role + role_path) for the caller to
* resolve via resolve_nostr_request_key(). */
__attribute__((section(".flashmem"))) static int parse_peer_and_message_params(cJSON *params,
const char **peer_hex_out,
const char **message_out,
uint32_t *nostr_index_out) {
selector_request_t *sel_out) {
cJSON *peer_item, *msg_item;
if (params == NULL || !cJSON_IsArray(params) ||
peer_hex_out == NULL || message_out == NULL || nostr_index_out == NULL) {
peer_hex_out == NULL || message_out == NULL || sel_out == NULL) {
return -1;
}
if (cJSON_GetArraySize(params) < 2) {
@@ -940,7 +1176,7 @@ __attribute__((section(".flashmem"))) static int parse_peer_and_message_params(c
!cJSON_IsString(msg_item) || msg_item->valuestring == NULL) {
return -1;
}
if (parse_nostr_index_from_params(params, nostr_index_out) != 0) {
if (parse_selector_from_params(params, sel_out) != 0) {
return -1;
}
*peer_hex_out = peer_item->valuestring;
@@ -1148,6 +1384,10 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
cJSON_AddItemToObject(obj, "algorithms", algs);
}
}
/* Report the configured role count so clients know the signer is
* role-aware. The role names are not exposed (they act as
* passwords); only the count is reported. */
cJSON_AddNumberToObject(obj, "roles", g_roles.count);
out = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
if (out == NULL) {
@@ -1956,20 +2196,23 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
/* ---- nostr_get_public_key ---- */
if (strcmp(method, VERB_NOSTR_GET_PUBLIC_KEY) == 0) {
uint32_t nostr_index = 0;
uint8_t req_privkey[32];
cJSON *options = NULL;
const char *fmt = NULL;
role_entry_t *role = NULL;
memset(req_privkey, 0, sizeof(req_privkey));
memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex));
if (!cJSON_IsArray(params) ||
parse_nostr_index_from_params(params, &nostr_index) != 0 ||
derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) {
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
if (resolve_nostr_request_key(params, id_token, req_privkey,
s_nostr_pubkey_hex, &role) != 0) {
/* error response already written by resolve_nostr_request_key */
} else {
int d = prompt_approval("nostr_get_public_key", "nostr_get_public_key");
/* Role-as-password: skip the prompt unless the role requires it. */
int d = 1;
if (nostr_role_requires_approval(role)) {
d = prompt_approval("nostr_get_public_key", "nostr_get_public_key");
}
if (d != 1) {
set_error_code(id_token,
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
@@ -2008,8 +2251,8 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
/* ---- nostr_sign_event ---- */
if (strcmp(method, VERB_NOSTR_SIGN_EVENT) == 0) {
cJSON *event_in = NULL;
uint32_t nostr_index = 0;
uint8_t req_privkey[32];
role_entry_t *role = NULL;
memset(req_privkey, 0, sizeof(req_privkey));
memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex));
@@ -2018,15 +2261,22 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
if (cJSON_IsArray(params) && cJSON_GetArraySize(params) > 0) {
event_in = cJSON_GetArrayItem(params, 0);
}
if (event_in == NULL || !cJSON_IsObject(event_in) ||
parse_nostr_index_from_params(params, &nostr_index) != 0 ||
derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) {
if (event_in == NULL || !cJSON_IsObject(event_in)) {
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
secure_memzero(req_privkey, sizeof(req_privkey));
return;
}
if (resolve_nostr_request_key(params, id_token, req_privkey,
s_nostr_pubkey_hex, &role) != 0) {
/* error response already written by resolve_nostr_request_key */
secure_memzero(req_privkey, sizeof(req_privkey));
return;
}
{
int d = prompt_approval("nostr_sign_event", "nostr_sign_event");
int d = 1;
if (nostr_role_requires_approval(role)) {
d = prompt_approval("nostr_sign_event", "nostr_sign_event");
}
if (d != 1) {
set_error_code(id_token,
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
@@ -2049,9 +2299,9 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
/* ---- nostr_mine_event (NIP-13 proof-of-work) ---- */
if (strcmp(method, VERB_NOSTR_MINE_EVENT) == 0) {
cJSON *event_in = NULL;
uint32_t nostr_index = 0;
uint8_t req_privkey[32];
cJSON *options = NULL;
role_entry_t *role = NULL;
int difficulty = 0;
int timeout_sec = 0;
uint32_t nonce = 0;
@@ -2088,16 +2338,23 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
timeout_sec = 30;
}
if (event_in == NULL || !cJSON_IsObject(event_in) ||
parse_nostr_index_from_params(params, &nostr_index) != 0 ||
derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) {
if (event_in == NULL || !cJSON_IsObject(event_in)) {
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
secure_memzero(req_privkey, sizeof(req_privkey));
return;
}
if (resolve_nostr_request_key(params, id_token, req_privkey,
s_nostr_pubkey_hex, &role) != 0) {
/* error response already written by resolve_nostr_request_key */
secure_memzero(req_privkey, sizeof(req_privkey));
return;
}
{
int d = prompt_approval("nostr_mine_event", "nostr_mine_event");
int d = 1;
if (nostr_role_requires_approval(role)) {
d = prompt_approval("nostr_mine_event", "nostr_mine_event");
}
if (d != 1) {
set_error_code(id_token,
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
@@ -2244,26 +2501,46 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
strcmp(method, VERB_NOSTR_NIP44_DECRYPT) == 0) {
const char *peer_hex = NULL;
const char *message = NULL;
uint32_t nostr_index = 0;
selector_request_t sel;
uint8_t peer_pubkey[32];
uint8_t req_privkey[32];
role_entry_t *role = NULL;
int is_nip44 = (method[9] == '4'); /* "nostr_nip04_*" vs "nostr_nip44_*": digit at index 9 */
int is_encrypt = (strstr(method, "encrypt") != NULL);
int rc = -1;
int parse_ok = 1;
memset(peer_pubkey, 0, sizeof(peer_pubkey));
memset(req_privkey, 0, sizeof(req_privkey));
memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex));
memset(s_encrypt_buf, 0, sizeof(s_encrypt_buf));
selector_request_init(&sel);
if (parse_peer_and_message_params(params, &peer_hex, &message,
&nostr_index) != 0 ||
hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0 ||
derive_request_key(nostr_index, req_privkey,
s_nostr_pubkey_hex) != 0) {
/* Parse peer + message + selector from params. */
if (parse_peer_and_message_params(params, &peer_hex, &message, &sel) != 0 ||
hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0) {
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
} else {
int d = prompt_approval(method, method);
parse_ok = 0;
}
if (parse_ok) {
/* Resolve the selector + derive the key. We need to call
* resolve_nostr_request_key with the original params (it re-parses
* the selector internally), but we already validated the peer/message
* shape above. The selector in `sel` is re-parsed inside the helper
* from the same params, so it's consistent. */
if (resolve_nostr_request_key(params, id_token, req_privkey,
s_nostr_pubkey_hex, &role) != 0) {
/* error response already written by resolve_nostr_request_key */
parse_ok = 0;
}
}
if (parse_ok) {
int d = 1;
if (nostr_role_requires_approval(role)) {
d = prompt_approval(method, method);
}
if (d != 1) {
set_error_code(id_token,
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
+6
View File
@@ -62,6 +62,12 @@ extern char g_pubkey_hex[65];
* after apply_mnemonic). When 0, every verb except get_info returns an error. */
extern int g_signer_ready;
/* The role table (populated by signer.ino after the role wizard). Used by the
* nostr_* verbs to resolve role + role_path selectors. Defined in dispatch.cpp
* as a DMAMEM global. */
struct role_table_t;
extern struct role_table_t g_roles;
/* ---- Entry point ----
* Process one parsed JSON-RPC request and write the JSON-RPC response into
* `out_buf`.
@@ -315,6 +315,164 @@ __attribute__((section(".flashmem"))) int derive_secp256k1_keys_index(const uint
pubkey);
}
/* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/0'/0/0") into a
* uint32_t array. Hardened segments are indicated by a trailing ' (or h/H).
* Returns the number of path components on success, or -1 on parse error.
* Ported from src/key_store.c parse_bip44_path(). */
__attribute__((section(".flashmem"))) int parse_bip44_path(const char *path_str,
uint32_t *out, int max_segments) {
char buf[128];
char *p;
int count = 0;
if (path_str == NULL || out == NULL || max_segments <= 0) {
return -1;
}
/* Copy so we can tokenize in place. */
{
size_t plen = strlen(path_str);
if (plen >= sizeof(buf)) {
return -1;
}
memcpy(buf, path_str, plen);
buf[plen] = '\0';
}
/* Skip leading "m" or "M" (optionally followed by '/'). */
p = buf;
if (*p == 'm' || *p == 'M') {
p++;
if (*p == '/') {
p++;
} else if (*p != '\0') {
return -1; /* "m" must be followed by '/' or end */
}
}
while (*p != '\0' && count < max_segments) {
char *slash = strchr(p, '/');
char seg[24];
size_t seg_len;
int hardened = 0;
char *endptr = NULL;
long val;
if (slash != NULL) {
seg_len = (size_t)(slash - p);
} else {
seg_len = strlen(p);
}
if (seg_len == 0 || seg_len >= sizeof(seg)) {
return -1;
}
memcpy(seg, p, seg_len);
seg[seg_len] = '\0';
/* Check for hardened marker ' or h/H at end. */
if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' ||
seg[seg_len - 1] == 'H') {
hardened = 1;
seg[seg_len - 1] = '\0';
/* A bare hardened marker with no number (e.g. "m/0/'") is invalid. */
if (seg[0] == '\0') {
return -1;
}
}
val = strtol(seg, &endptr, 10);
if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) {
return -1;
}
out[count] = (uint32_t)val;
if (hardened) {
out[count] |= BIP32_HARDENED_FLAG;
}
count++;
p = (slash != NULL) ? slash + 1 : "";
if (*p == '\0') {
break;
}
}
return count;
}
/* Derive a secp256k1 keypair from an explicit BIP-44 path string.
* Reuses the same bip32_master_from_seed + bip32_ckd_priv helpers as the
* NIP-06 path, just with a caller-supplied path instead of a fixed one. */
__attribute__((section(".flashmem"))) int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len,
const char *path_str,
uint8_t *privkey, uint8_t *pubkey) {
uint32_t path[16];
int path_len;
secp256k1_context *ctx = NULL;
hd_key_t node;
if (seed == NULL || path_str == NULL || privkey == NULL || pubkey == NULL) {
return -1;
}
path_len = parse_bip44_path(path_str, path,
(int)(sizeof(path) / sizeof(path[0])));
if (path_len <= 0) {
return -1;
}
memset(&node, 0, sizeof(node));
ctx = create_context();
if (ctx == NULL) {
return -1;
}
if (bip32_master_from_seed(ctx, seed, seed_len, &node) != 0) {
secure_memzero(&node, sizeof(node));
return -1;
}
for (int i = 0; i < path_len; ++i) {
hd_key_t next;
memset(&next, 0, sizeof(next));
if (bip32_ckd_priv(ctx, &node, path[i], &next) != 0) {
secure_memzero(&node, sizeof(node));
return -1;
}
secure_memzero(&node, sizeof(node));
node = next;
}
memcpy(privkey, node.priv, 32);
{
secp256k1_keypair kp;
secp256k1_xonly_pubkey xonly;
if (!secp256k1_keypair_create(ctx, &kp, node.priv)) {
secure_memzero(&node, sizeof(node));
return -1;
}
if (!secp256k1_keypair_xonly_pub(ctx, &xonly, NULL, &kp)) {
secure_memzero(&node, sizeof(node));
return -1;
}
if (!secp256k1_xonly_pubkey_serialize(ctx, pubkey, &xonly)) {
secure_memzero(&node, sizeof(node));
return -1;
}
}
secure_memzero(&node, sizeof(node));
/* Do NOT destroy ctx — it is the persistent global context. */
return 0;
}
__attribute__((section(".flashmem"))) int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
uint8_t sig64[64]) {
secp256k1_context *ctx = NULL;
@@ -32,6 +32,22 @@ int derive_secp256k1_keys_index(const uint8_t *seed, size_t seed_len,
uint32_t nostr_index,
uint8_t *privkey, uint8_t *pubkey);
/* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/0'/0/0") into a
* uint32_t array suitable for the internal BIP-32 derivation. Hardened
* segments are indicated by a trailing ' (or h/H). Returns the number of
* path components on success, or -1 on parse error. `max_segments` is the
* max number of entries in the `out` array. */
int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments);
/* Derive a secp256k1 keypair from an explicit BIP-44 path string.
* Uses BIP-32 derivation (master key from seed + CKDpriv per segment).
* privkey: 32-byte secret key (scalar).
* pubkey: 32-byte x-only public key (Nostr pubkey).
* Returns 0 on success, -1 on failure. */
int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len,
const char *path_str,
uint8_t *privkey, uint8_t *pubkey);
/* Sign a 32-byte message digest with Schnorr (BIP-340) using a 32-byte
* secp256k1 secret key. aux_rand is drawn from the TRNG. sig64: 64-byte sig. */
int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
+269
View File
@@ -0,0 +1,269 @@
/* role_table.cpp — in-RAM role table implementation for the Teensy 4.1.
*
* Phase 2 of plans/teensy41_role_path_migration.md.
*
* Ports the path-template matching from src/role_table.c, slimmed for the
* Teensy (16 entries, range-only index validation, no allowed-indices set).
* The matching logic (role_path_matches_template, role_path_extract_index,
* role_path_matches_with_range) is a faithful port of the host's functions
* so the Teensy and the host accept the same paths for the same templates.
*/
#include "role_table.h"
#include <string.h>
#include <stdlib.h>
/* ====================================================================
* Table operations
* ==================================================================== */
__attribute__((section(".flashmem")))
void role_table_init(role_table_t *table) {
if (table != NULL) {
memset(table, 0, sizeof(*table));
}
}
__attribute__((section(".flashmem")))
int role_table_add(role_table_t *table, const role_entry_t *entry) {
int i;
if (table == NULL || entry == NULL) {
return -1;
}
if (table->count >= ROLE_TABLE_MAX_ENTRIES) {
return -1; /* full */
}
/* Duplicate name check */
for (i = 0; i < table->count; i++) {
if (strcmp(table->entries[i].name, entry->name) == 0) {
return -2;
}
}
table->entries[table->count] = *entry;
table->count++;
return 0;
}
__attribute__((section(".flashmem")))
role_entry_t *role_table_find_by_name(role_table_t *table, const char *name) {
int i;
if (table == NULL || name == NULL) {
return NULL;
}
for (i = 0; i < table->count; i++) {
if (strcmp(table->entries[i].name, name) == 0) {
return &table->entries[i];
}
}
return NULL;
}
__attribute__((section(".flashmem")))
role_entry_t *role_table_get_default(role_table_t *table) {
return role_table_find_by_name(table, "main");
}
/* ====================================================================
* Path-template matching (ported from src/role_table.c)
* ==================================================================== */
/* Check whether a concrete derivation path matches a role's path template.
* The template may contain a single "%d" placeholder (with an optional
* hardened marker after it, e.g. "m/44'/1237'/%d'/0/0").
* Returns 1 if the path matches the template, 0 if not.
* Ported from src/role_table.c:956. */
__attribute__((section(".flashmem")))
int role_path_matches_template(const char *path, const char *template_str) {
const char *p = path;
const char *t = template_str;
if (path == NULL || template_str == NULL) {
return 0;
}
while (*t != '\0' && *p != '\0') {
if (*t == '%' && *(t + 1) == 'd') {
/* %d placeholder — skip one path segment in the path */
t += 2; /* skip "%d" */
/* Skip optional hardened marker after %d in template */
if (*t == '\'' || *t == 'h' || *t == 'H') {
t++;
}
/* Skip the corresponding segment in the path (digits, possibly with ' or h) */
if (*p == '/') {
/* Path has a slash where we expect a segment — mismatch */
return 0;
}
while (*p != '\0' && *p != '/') {
p++;
}
/* If template has more after %d, it should start with '/' */
if (*t == '/' && *p == '/') {
t++;
p++;
} else if (*t == '\0' && *p == '\0') {
/* Both at end — exact match */
return 1;
} else if (*t == '\0' && *p == '/') {
/* Template ended but path has trailing slash — no match */
return 0;
} else if (*t == '/' && *p == '\0') {
/* Path ended but template has more — no match */
return 0;
}
/* If one has a separator and the other doesn't, let the loop continue */
} else if (*t == *p) {
t++;
p++;
} else {
return 0;
}
}
/* Both should be at the end */
return (*t == '\0' && *p == '\0') ? 1 : 0;
}
/* Extract the numeric index from a concrete derivation path that matches a
* role's path template (containing a single "%d" placeholder).
* Returns the extracted index on success, or -1 if no match / no %d.
* Ported from src/role_table.c:1007. */
__attribute__((section(".flashmem")))
int role_path_extract_index(const char *path, const char *template_str) {
const char *p = path;
const char *t = template_str;
const char *seg_start;
char seg_buf[32];
size_t seg_len;
long val;
char *endp;
if (path == NULL || template_str == NULL) {
return -1;
}
/* If template has no %d, there is no variable index to extract */
if (strstr(template_str, "%d") == NULL) {
return -1;
}
while (*t != '\0' && *p != '\0') {
if (*t == '%' && *(t + 1) == 'd') {
/* %d placeholder — extract the corresponding path segment */
t += 2; /* skip "%d" */
/* Skip optional hardened marker after %d in template */
if (*t == '\'' || *t == 'h' || *t == 'H') {
t++;
}
/* Extract the segment from the path (up to next '/' or end) */
if (*p == '/') {
return -1; /* path has a slash where a segment is expected */
}
seg_start = p;
while (*p != '\0' && *p != '/') {
p++;
}
seg_len = (size_t)(p - seg_start);
if (seg_len == 0 || seg_len >= sizeof(seg_buf)) {
return -1;
}
memcpy(seg_buf, seg_start, seg_len);
seg_buf[seg_len] = '\0';
/* Strip optional trailing hardened marker from the segment */
if (seg_len > 0 &&
(seg_buf[seg_len - 1] == '\'' || seg_buf[seg_len - 1] == 'h' ||
seg_buf[seg_len - 1] == 'H')) {
seg_buf[seg_len - 1] = '\0';
}
endp = NULL;
val = strtol(seg_buf, &endp, 10);
if (*endp != '\0' || val < 0) {
return -1;
}
return (int)val;
} else if (*t == *p) {
t++;
p++;
} else {
return -1;
}
}
return -1;
}
/* Check whether a concrete derivation path matches a role's path template
* AND the extracted index falls within the role's allowed range.
* Returns 1 if the path matches and the index is allowed, 0 otherwise.
* Ported from src/role_table.c:1070 (set-form omitted, range-only). */
__attribute__((section(".flashmem")))
int role_path_matches_with_range(const char *path, const role_entry_t *role) {
int index;
if (path == NULL || role == NULL) {
return 0;
}
/* Fixed path (no %d) — just check structural match */
if (strstr(role->role_path, "%d") == NULL) {
return role_path_matches_template(path, role->role_path);
}
/* Template path — check structural match first */
if (!role_path_matches_template(path, role->role_path)) {
return 0;
}
/* Extract the index and check it against the allowed range */
index = role_path_extract_index(path, role->role_path);
if (index < 0) {
return 0;
}
/* Range form: check lo..hi */
if (role->path_range_lo < 0 || role->path_range_hi < 0) {
/* No range configured — deny (fail-closed) */
return 0;
}
return (index >= role->path_range_lo && index <= role->path_range_hi) ? 1 : 0;
}
/* ====================================================================
* Presets (matching the host wizard, src/main.c:2068)
* ==================================================================== */
const role_preset_t role_presets[] = {
/* 1. Standard Nostr (secp256k1, m/44'/1237'/0'/0/0) */
{ "main", "m/44'/1237'/0'/0/0",
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, -1, -1, -1 },
/* 2. Nostr range (secp256k1, m/44'/1237'/%d'/0/0, 0-100) */
{ "nostr_range", "m/44'/1237'/%d'/0/0",
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, 0, 100, 0 },
/* 3. Nostr agent (secp256k1, m/44'/1237'/%d'/1'/0', 0-100) */
{ "nostr_agent", "m/44'/1237'/%d'/1'/0'",
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, 0, 100, 0 },
/* 4. SSH (ed25519, m/44'/102001'/0'/0'/0') */
{ "ssh", "m/44'/102001'/0'/0'/0'",
ROLE_PURPOSE_SSH, ROLE_CURVE_ED25519, -1, -1, -1 },
/* 5. Age (x25519, m/44'/102002'/0'/0'/0') */
{ "age", "m/44'/102002'/0'/0'/0'",
ROLE_PURPOSE_AGE, ROLE_CURVE_X25519, -1, -1, -1 },
/* 6. ML-DSA-65 (m/44'/102003'/0'/0'/0') */
{ "ml_dsa_65", "m/44'/102003'/0'/0'/0'",
ROLE_PURPOSE_PQ_SIG, ROLE_CURVE_ML_DSA_65, -1, -1, -1 },
/* 7. SLH-DSA-128s (m/44'/102004'/0'/0'/0') */
{ "slh_dsa_128s", "m/44'/102004'/0'/0'/0'",
ROLE_PURPOSE_PQ_SIG, ROLE_CURVE_SLH_DSA_128S, -1, -1, -1 },
/* 8. ML-KEM-768 (m/44'/102005'/0'/0'/0') */
{ "ml_kem_768", "m/44'/102005'/0'/0'/0'",
ROLE_PURPOSE_PQ_KEM, ROLE_CURVE_ML_KEM_768, -1, -1, -1 },
/* 9. OTP (no derivation path — binds the SD pad instead) */
{ "otp", "",
ROLE_PURPOSE_OTP, ROLE_CURVE_OTP, -1, -1, -1 },
/* 10. Custom (user edits name + path) */
{ "custom", "m/44'/1237'/0'/0/0",
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, -1, -1, -1 },
};
const int role_preset_count =
(int)(sizeof(role_presets) / sizeof(role_presets[0]));
+138
View File
@@ -0,0 +1,138 @@
/* role_table.h — in-RAM role table for the Teensy 4.1 n_signer firmware.
*
* Phase 2 of plans/teensy41_role_path_migration.md.
*
* Ports the role + path authorization model from the host n_signer
* (src/role_table.c) to the Teensy 4.1, slimmed for the hardware signer's
* single-user scope:
* - 16 entries max (vs the host's 256)
* - single %d placeholder per path template (vs the host's same limitation)
* - range bounds (lo/hi) for the %d index; no explicit allowed-indices set
* (the host's path_allowed_indices[] is omitted to save memory)
*
* Each role binds a name to a BIP-44 derivation path template, a purpose/curve,
* and a requires_approval flag. The selector (selector.cpp) looks up a role by
* name and verifies the requested path matches the template + range before
* authorizing the request.
*
* Role-as-password: when requires_approval == 0, knowing the role name (and a
* matching path) is sufficient authorization — no ui_approve() prompt. This is
* the default, matching plans/role_as_password_default.md.
*/
#ifndef FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H
#define FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ---- Limits (slimmed for the Teensy 4.1) ---- */
#define ROLE_NAME_MAX 32
#define ROLE_PATH_MAX 128
#define ROLE_PUBKEY_HEX_MAX 65 /* 64 hex chars + NUL */
#define ROLE_TABLE_MAX_ENTRIES 16
/* ---- Purpose enum ---- */
typedef enum {
ROLE_PURPOSE_NOSTR = 0,
ROLE_PURPOSE_SSH,
ROLE_PURPOSE_AGE,
ROLE_PURPOSE_PQ_SIG, /* ML-DSA-65, SLH-DSA-128s */
ROLE_PURPOSE_PQ_KEM, /* ML-KEM-768 */
ROLE_PURPOSE_OTP, /* one-time pad (no derivation path) */
ROLE_PURPOSE_UNKNOWN
} role_purpose_t;
/* ---- Curve enum ---- */
typedef enum {
ROLE_CURVE_SECP256K1 = 0,
ROLE_CURVE_ED25519,
ROLE_CURVE_X25519,
ROLE_CURVE_ML_DSA_65,
ROLE_CURVE_SLH_DSA_128S,
ROLE_CURVE_ML_KEM_768,
ROLE_CURVE_OTP,
ROLE_CURVE_UNKNOWN
} role_curve_t;
/* ---- A single role entry ---- */
typedef struct {
char name[ROLE_NAME_MAX]; /* "main", "ssh", etc. */
char role_path[ROLE_PATH_MAX]; /* template, may contain one "%d" */
role_purpose_t purpose;
role_curve_t curve;
int path_range_lo; /* inclusive lower bound for %d; -1 = fixed path (no %d) */
int path_range_hi; /* inclusive upper bound; == lo for single */
int path_default_index; /* default index when client sends {"role":...} without a path; -1 = require explicit */
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require ui_approve() */
int derived; /* 1 if pubkey_hex has been populated */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after first derivation */
} role_entry_t;
/* ---- The role table ---- */
typedef struct {
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
int count;
} role_table_t;
/* ---- Operations ---- */
/* Initialize an empty role table. */
void role_table_init(role_table_t *table);
/* Add a role entry. Returns 0 on success, -1 if table full, -2 if name duplicate. */
int role_table_add(role_table_t *table, const role_entry_t *entry);
/* Find a role by name. Returns pointer to entry or NULL. */
role_entry_t *role_table_find_by_name(role_table_t *table, const char *name);
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
role_entry_t *role_table_get_default(role_table_t *table);
/* ---- Path-template matching ---- */
/* Check whether a concrete derivation path matches a role's path template.
* The template may contain a single "%d" placeholder (with an optional
* hardened marker after it, e.g. "m/44'/1237'/%d'/0/0").
* Returns 1 if the path matches the template, 0 if not.
* For fixed paths (no %d), does an exact string comparison. */
int role_path_matches_template(const char *path, const char *template_str);
/* Extract the numeric index from a concrete derivation path that matches a
* role's path template (containing a single "%d" placeholder).
* Returns the extracted index on success, or -1 if the path does not match
* the template or no %d placeholder exists in the template. */
int role_path_extract_index(const char *path, const char *template_str);
/* Check whether a concrete derivation path matches a role's path template
* AND the extracted index falls within the role's allowed range.
* Returns 1 if the path matches and the index is allowed, 0 otherwise.
* For fixed paths (no %d), equivalent to role_path_matches_template(). */
int role_path_matches_with_range(const char *path, const role_entry_t *role);
/* ---- Presets ---- */
/* A role preset, matching the host's wizard menu (src/main.c:2068).
* Used by ui_role_wizard() to populate the table. */
typedef struct {
const char *name; /* default role name */
const char *path; /* default path template (may contain %d) */
role_purpose_t purpose;
role_curve_t curve;
int range_lo; /* -1 = fixed path */
int range_hi;
int default_index;/* -1 = require explicit */
} role_preset_t;
/* The preset table (10 entries, matching the host wizard). */
extern const role_preset_t role_presets[];
extern const int role_preset_count;
#ifdef __cplusplus
}
#endif
#endif /* FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H */
+111
View File
@@ -0,0 +1,111 @@
/* selector.cpp — request selector implementation for the Teensy 4.1.
*
* Phase 3 of plans/teensy41_role_path_migration.md.
*
* Ports the selector decision tree from src/selector.c:745. The JSON parsing
* (extracting role/role_path/nostr_index from the cJSON options object) is
* done in dispatch.cpp, which calls selector_resolve() with the populated
* selector_request_t.
*/
#include "selector.h"
#include <string.h>
/* ====================================================================
* Selector request
* ==================================================================== */
__attribute__((section(".flashmem")))
void selector_request_init(selector_request_t *req) {
if (req != NULL) {
memset(req, 0, sizeof(*req));
}
}
/* ====================================================================
* Selector resolution
* ==================================================================== */
/* Resolve a selector request against the role table.
*
* Ported from src/selector.c:745 (selector_resolve). The decision tree:
* 1. nostr_index present → DEPRECATED (error 2006 in the wire protocol)
* 2. role_path without role → ROLE_REQUIRED
* 3. role + role_path → find role, verify path matches template + range
* 4. role only, fixed path (no %d) → use it
* 5. role only, template path → PATH_REQUIRED
* 6. neither → default role ("main"), else NO_DEFAULT
*
* On success, *out points to the matching role entry in the table. The
* caller uses req->role_path (if has_role_path) or the role's role_path
* (if fixed) for key derivation. */
__attribute__((section(".flashmem")))
int selector_resolve(const selector_request_t *req, role_table_t *table,
role_entry_t **out) {
role_entry_t *match = NULL;
if (out != NULL) {
*out = NULL;
}
if (req == NULL || table == NULL || out == NULL) {
return SELECTOR_ERR_NOT_FOUND;
}
/* ---- Deprecated selectors: reject with clear error codes ---- */
/* nostr_index is deprecated */
if (req->has_nostr_index) {
return SELECTOR_ERR_NOSTR_INDEX_DEPRECATED;
}
/* role_path without role is not allowed */
if (req->has_role_path && !req->has_role) {
return SELECTOR_ERR_ROLE_REQUIRED;
}
/* ---- New model: role + role_path combined ---- */
if (req->has_role && req->has_role_path) {
/* Combined selector: look up role by name, verify path matches template */
match = role_table_find_by_name(table, req->role_name);
if (match == NULL) {
return SELECTOR_ERR_NOT_FOUND;
}
/* Verify the requested path matches the role's template AND that the
* extracted index falls within the role's allowed range. */
if (!role_path_matches_with_range(req->role_path, match)) {
return SELECTOR_ERR_PATH_MISMATCH;
}
*out = match;
return SELECTOR_OK;
}
if (req->has_role && !req->has_role_path) {
/* Role specified without path — check if role has a fixed path (no %d) */
match = role_table_find_by_name(table, req->role_name);
if (match == NULL) {
return SELECTOR_ERR_NOT_FOUND;
}
/* If the role has a fixed path (no variable segments), use it */
if (strstr(match->role_path, "%d") == NULL) {
*out = match;
return SELECTOR_OK;
}
/* Role has variable path template — path is required */
return SELECTOR_ERR_PATH_REQUIRED;
}
/* No selectors at all — try default role */
match = role_table_get_default(table);
if (match == NULL) {
return SELECTOR_ERR_NO_DEFAULT;
}
*out = match;
return SELECTOR_OK;
}
+69
View File
@@ -0,0 +1,69 @@
/* selector.h — request selector for the Teensy 4.1 n_signer firmware.
*
* Phase 3 of plans/teensy41_role_path_migration.md.
*
* Ports the role + path selector from the host n_signer (src/selector.c).
* Parses the selector fields (role, role_path, nostr_index, index) from a
* nostr_* verb's trailing options object and resolves them against the role
* table.
*
* Decision tree (matching src/selector.c:745):
* - nostr_index present → SELECTOR_ERR_NOSTR_INDEX_DEPRECATED
* - role_path without role → SELECTOR_ERR_ROLE_REQUIRED
* - role + role_path → find role, verify path matches template + range
* - role only (fixed path) → use the role's fixed path
* - role only (template path) → SELECTOR_ERR_PATH_REQUIRED
* - neither → use default role ("main"), else NO_DEFAULT
*/
#ifndef FIRMWARE_TEENSY41_SIGNER_SELECTOR_H
#define FIRMWARE_TEENSY41_SIGNER_SELECTOR_H
#include <stddef.h>
#include <stdint.h>
#include "role_table.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- Selector request (parsed from the JSON options object) ---- */
typedef struct {
int has_role; /* 1 if "role" field was present */
char role_name[ROLE_NAME_MAX];
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_nostr_index; /* 1 if "nostr_index" field was present (deprecated) */
uint32_t nostr_index;
int has_index; /* 1 if "index" field was present (for algorithm verbs, not nostr) */
uint32_t index;
} selector_request_t;
/* ---- Result codes ---- */
#define SELECTOR_OK 0
#define SELECTOR_ERR_NOT_FOUND -1 /* role not found */
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role */
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template/range */
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
#define SELECTOR_ERR_PATH_REQUIRED -6 /* role has a template path but no role_path given */
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* role_path given without a role */
/* ---- Operations ---- */
/* Initialize a selector request to its empty state. */
void selector_request_init(selector_request_t *req);
/* Resolve a selector request against the role table.
*
* On success (SELECTOR_OK), *out points to the matching role_entry_t in the
* table. The caller should then use req->role_path (if has_role_path) or the
* role's role_path (if fixed) for key derivation.
*
* Returns SELECTOR_OK or one of the SELECTOR_ERR_* codes. */
int selector_resolve(const selector_request_t *req, role_table_t *table,
role_entry_t **out);
#ifdef __cplusplus
}
#endif
#endif /* FIRMWARE_TEENSY41_SIGNER_SELECTOR_H */
+288
View File
@@ -1017,3 +1017,291 @@ __attribute__((section(".flashmem"))) int ui_pick_pad(
return 2; /* timeout */
}
}
/* =====================================================================
* 6. ui_role_wizard — role-preset selection (Phase 5)
* =====================================================================
*
* Presents the 10 role presets as a scrollable button list. Tapping a
* preset creates a role with the preset's defaults (requires_approval=0,
* role-as-password). After each selection, a "Add another / Done" prompt
* loops until the user taps Done with at least one role defined.
*
* The preset labels are short descriptions (not the full path) to fit the
* 480px screen. The full path is stored in the role entry.
*/
/* Wizard state: -1 = none, 0..9 = preset index, -2 = done, -3 = add-another,
* -4 = cancel */
static volatile int s_wizard_choice = -1;
static void on_wizard_preset(lv_event_t *e) {
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
int idx = (int)(intptr_t)lv_event_get_user_data(e);
s_wizard_choice = idx;
}
}
static void on_wizard_done(lv_event_t *e) {
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
s_wizard_choice = -2;
}
}
static void on_wizard_add(lv_event_t *e) {
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
s_wizard_choice = -3;
}
}
static void on_wizard_cancel(lv_event_t *e) {
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
s_wizard_choice = -4;
}
}
/* Short labels for the 10 presets (kept short to fit 480px buttons). */
static const char *preset_labels[] = {
"1. Standard Nostr",
"2. Nostr range (0-100)",
"3. Nostr agent (0-100)",
"4. SSH (ed25519)",
"5. Age (x25519)",
"6. ML-DSA-65 (PQ sig)",
"7. SLH-DSA-128s (PQ sig)",
"8. ML-KEM-768 (PQ KEM)",
"9. OTP (one-time pad)",
"10. Custom",
};
/* Build the preset-selection screen. Returns the screen object. */
__attribute__((section(".flashmem"))) static void show_preset_screen(lv_obj_t *scr, int roles_so_far) {
lv_obj_clean(scr);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0);
/* Title + role count */
lv_obj_t *title = lv_label_create(scr);
char title_text[48];
snprintf(title_text, sizeof(title_text), "Define a Role (%d defined)",
roles_so_far);
lv_label_set_text(title, title_text);
lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 5);
/* Preset buttons — 2 columns × 5 rows to fit 10 presets on 480×320. */
for (int i = 0; i < role_preset_count && i < 10; i++) {
lv_obj_t *btn = lv_button_create(scr);
style_button(btn);
lv_obj_set_size(btn, 225, 44);
int col = i % 2;
int row = i / 2;
lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 10 + col * 235, 40 + row * 50);
lv_obj_add_event_cb(btn, on_wizard_preset, LV_EVENT_ALL,
(void *)(intptr_t)i);
lv_obj_t *lbl = lv_label_create(btn);
lv_label_set_text(lbl, preset_labels[i]);
lv_obj_center(lbl);
}
/* Done button (bottom-right) — only meaningful after ≥1 role. */
lv_obj_t *btn_done = lv_button_create(scr);
style_button(btn_done);
lv_obj_set_size(btn_done, 225, 40);
lv_obj_align(btn_done, LV_ALIGN_BOTTOM_RIGHT, -10, -5);
lv_obj_add_event_cb(btn_done, on_wizard_done, LV_EVENT_ALL, NULL);
lv_obj_t *lbl_done = lv_label_create(btn_done);
lv_label_set_text(lbl_done, "Done");
lv_obj_center(lbl_done);
/* Cancel button (bottom-left) */
lv_obj_t *btn_cancel = lv_button_create(scr);
style_button(btn_cancel);
lv_obj_set_style_border_color(btn_cancel, lv_color_hex(UI_MUTED), 0);
lv_obj_set_style_text_color(btn_cancel, lv_color_hex(UI_MUTED), 0);
lv_obj_set_size(btn_cancel, 225, 40);
lv_obj_align(btn_cancel, LV_ALIGN_BOTTOM_LEFT, 10, -5);
lv_obj_add_event_cb(btn_cancel, on_wizard_cancel, LV_EVENT_ALL, NULL);
lv_obj_t *lbl_cancel = lv_label_create(btn_cancel);
lv_label_set_text(lbl_cancel, "Cancel");
lv_obj_center(lbl_cancel);
}
/* Show the "role added — add another or done?" confirmation screen. */
__attribute__((section(".flashmem"))) static void show_added_screen(lv_obj_t *scr,
const char *role_name,
const char *role_path,
int roles_so_far) {
lv_obj_clean(scr);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0);
lv_obj_t *title = lv_label_create(scr);
lv_label_set_text(title, "Role Added");
lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
/* Show the role name + path */
char info[160];
snprintf(info, sizeof(info), "%s\n%s", role_name, role_path);
lv_obj_t *info_lbl = lv_label_create(scr);
lv_label_set_text(info_lbl, info);
lv_label_set_long_mode(info_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info_lbl, 440);
lv_obj_set_style_text_color(info_lbl, lv_color_hex(UI_FG), 0);
lv_obj_align(info_lbl, LV_ALIGN_TOP_MID, 0, 60);
char count_text[48];
snprintf(count_text, sizeof(count_text), "%d role(s) defined", roles_so_far);
lv_obj_t *count_lbl = lv_label_create(scr);
lv_label_set_text(count_lbl, count_text);
lv_obj_set_style_text_color(count_lbl, lv_color_hex(UI_MUTED), 0);
lv_obj_align(count_lbl, LV_ALIGN_TOP_MID, 0, 160);
/* Add another */
lv_obj_t *btn_add = lv_button_create(scr);
style_button(btn_add);
lv_obj_set_size(btn_add, 225, 50);
lv_obj_align(btn_add, LV_ALIGN_BOTTOM_LEFT, 10, -10);
lv_obj_add_event_cb(btn_add, on_wizard_add, LV_EVENT_ALL, NULL);
lv_obj_t *lbl_add = lv_label_create(btn_add);
lv_label_set_text(lbl_add, "Add Another");
lv_obj_center(lbl_add);
/* Done */
lv_obj_t *btn_done = lv_button_create(scr);
style_button(btn_done);
lv_obj_set_size(btn_done, 225, 50);
lv_obj_align(btn_done, LV_ALIGN_BOTTOM_RIGHT, -10, -10);
lv_obj_add_event_cb(btn_done, on_wizard_done, LV_EVENT_ALL, NULL);
lv_obj_t *lbl_done = lv_label_create(btn_done);
lv_label_set_text(lbl_done, "Done");
lv_obj_center(lbl_done);
}
/* Show an error screen for 2 seconds (e.g. "at least one role required"). */
__attribute__((section(".flashmem"))) static void show_error_screen(lv_obj_t *scr,
const char *msg) {
lv_obj_clean(scr);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0);
lv_obj_t *lbl = lv_label_create(scr);
lv_label_set_text(lbl, msg);
lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lbl, 440);
lv_obj_set_style_text_color(lbl, lv_color_hex(UI_ACCENT), 0);
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_20, 0);
lv_obj_center(lbl);
uint32_t deadline = millis() + 2000;
while (millis() < deadline) {
lv_tick_inc(5);
lv_timer_handler();
delay(5);
}
}
/* Pump LVGL until s_wizard_choice changes or timeout (ms). Returns the
* choice value, or -1 on timeout. */
__attribute__((section(".flashmem"))) static int pump_until_choice(uint32_t timeout_ms) {
uint32_t deadline = millis() + timeout_ms;
while (s_wizard_choice == -1 && millis() < deadline) {
lv_tick_inc(5);
lv_timer_handler();
delay(5);
}
int c = s_wizard_choice;
s_wizard_choice = -1;
return c;
}
__attribute__((section(".flashmem"))) int ui_role_wizard(role_table_t *out_table) {
if (out_table == NULL) {
return -1;
}
role_table_init(out_table);
lv_obj_t *scr = lv_screen_active();
int done = 0;
while (!done) {
/* ---- Preset selection screen ---- */
show_preset_screen(scr, out_table->count);
int choice = pump_until_choice(60000);
if (choice == -4) {
/* Cancel */
return -1;
} else if (choice == -2) {
/* Done tapped on the preset screen */
if (out_table->count == 0) {
show_error_screen(scr, "At least one role\nmust be defined");
continue; /* re-loop to preset screen */
}
done = 1;
break;
} else if (choice < 0 || choice >= role_preset_count) {
/* Timeout or invalid — re-loop */
if (choice == -1) {
/* Timeout: treat as cancel */
return -1;
}
continue;
}
/* ---- A preset was selected: create the role ---- */
const role_preset_t *preset = &role_presets[choice];
role_entry_t entry;
memset(&entry, 0, sizeof(entry));
strncpy(entry.name, preset->name, sizeof(entry.name) - 1);
entry.name[sizeof(entry.name) - 1] = '\0';
strncpy(entry.role_path, preset->path, sizeof(entry.role_path) - 1);
entry.role_path[sizeof(entry.role_path) - 1] = '\0';
entry.purpose = preset->purpose;
entry.curve = preset->curve;
entry.path_range_lo = preset->range_lo;
entry.path_range_hi = preset->range_hi;
entry.path_default_index = preset->default_index;
entry.requires_approval = 0; /* role-as-password by default */
entry.derived = 0;
entry.pubkey_hex[0] = '\0';
/* If a role with this name already exists, append a suffix. */
if (role_table_find_by_name(out_table, entry.name) != NULL) {
char base[ROLE_NAME_MAX];
strncpy(base, entry.name, sizeof(base) - 1);
base[sizeof(base) - 1] = '\0';
for (int suffix = 2; suffix < 100; suffix++) {
snprintf(entry.name, sizeof(entry.name), "%s%d", base, suffix);
if (role_table_find_by_name(out_table, entry.name) == NULL) {
break;
}
}
}
int add_rc = role_table_add(out_table, &entry);
if (add_rc != 0) {
show_error_screen(scr, "Role table full\nor duplicate");
continue;
}
/* ---- "Role added — add another or done?" screen ---- */
show_added_screen(scr, entry.name, entry.role_path, out_table->count);
int post = pump_until_choice(30000);
if (post == -2 || post == -1) {
/* Done (or timeout → treat as done) */
done = 1;
}
/* -3 = add another → re-loop to preset screen */
/* -4 = cancel from the added screen (treat as done, keep roles) */
if (post == -4) {
done = 1;
}
}
return (out_table->count > 0) ? 0 : -1;
}
+16
View File
@@ -24,6 +24,7 @@
#include <stddef.h>
#include <stdint.h>
#include "role_table.h"
#ifdef __cplusplus
extern "C" {
@@ -80,6 +81,21 @@ ui_approval_decision_t ui_approve(const char *verb, const char *summary);
int ui_pick_pad(const char *pad_chksums[], const uint64_t pad_sizes[],
int count, char *out_chksum, size_t out_chksum_cap);
/* Role-preset wizard (Phase 5 of plans/teensy41_role_path_migration.md).
*
* Presents the 10 role presets (matching the host wizard) as a scrollable
* list of buttons. When the user taps a preset, a role is created with the
* preset's default name + path + requires_approval=0 (role-as-password).
* Then a "Done" / "Add another" prompt loops until at least one role is
* defined and the user taps "Done".
*
* At least one role is required. If the user taps "Done" with zero roles,
* an error message is shown and the wizard re-loops.
*
* Fills `out_table` with the defined roles. Returns 0 on success, -1 if the
* user cancels (which should abort the boot). */
int ui_role_wizard(role_table_t *out_table);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,248 @@
/* host_test_parse_bip44_path.c — unit test for parse_bip44_path().
*
* parse_bip44_path() is a pure-C function with no crypto dependencies, so it
* can be tested host-side by compiling key_derivation.cpp with -DHOST_TEST
* and stubbing out the Arduino/nostr_core/secp256k1 calls it does not use.
*
* Actually, parse_bip44_path() is self-contained inside key_derivation.cpp,
* but key_derivation.cpp pulls in Arduino.h, secp256k1, nostr_core, etc. To
* avoid dragging all of that into a host build, this test re-implements the
* parser check by #including a standalone copy of the function via a
* HOST_TEST guard. The simplest approach: compile a tiny .c that defines
* the function directly (copied from key_derivation.cpp) and tests it.
*
* Build:
* cc -O2 -Wall -Wextra -o host_test_parse_bip44_path \
* firmware/teensy41/signer/tests/host_test_parse_bip44_path.c
* ./host_test_parse_bip44_path
*
* If parse_bip44_path() in key_derivation.cpp is ever changed, copy the new
* body into the function below to keep this test in sync.
*/
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#define BIP32_HARDENED_FLAG 0x80000000u
/* Copied from key_derivation.cpp — kept in sync manually. */
static int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments) {
char buf[128];
char *p;
int count = 0;
if (path_str == NULL || out == NULL || max_segments <= 0) {
return -1;
}
{
size_t plen = strlen(path_str);
if (plen >= sizeof(buf)) {
return -1;
}
memcpy(buf, path_str, plen);
buf[plen] = '\0';
}
p = buf;
if (*p == 'm' || *p == 'M') {
p++;
if (*p == '/') {
p++;
} else if (*p != '\0') {
return -1;
}
}
while (*p != '\0' && count < max_segments) {
char *slash = strchr(p, '/');
char seg[24];
size_t seg_len;
int hardened = 0;
char *endptr = NULL;
long val;
if (slash != NULL) {
seg_len = (size_t)(slash - p);
} else {
seg_len = strlen(p);
}
if (seg_len == 0 || seg_len >= sizeof(seg)) {
return -1;
}
memcpy(seg, p, seg_len);
seg[seg_len] = '\0';
if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' ||
seg[seg_len - 1] == 'H') {
hardened = 1;
seg[seg_len - 1] = '\0';
/* A bare hardened marker with no number (e.g. "m/0/'") is invalid. */
if (seg[0] == '\0') {
return -1;
}
}
val = strtol(seg, &endptr, 10);
if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) {
return -1;
}
out[count] = (uint32_t)val;
if (hardened) {
out[count] |= BIP32_HARDENED_FLAG;
}
count++;
p = (slash != NULL) ? slash + 1 : "";
if (*p == '\0') {
break;
}
}
return count;
}
static int failures = 0;
static int passes = 0;
#define CHECK(cond, msg) do { \
if (cond) { passes++; } \
else { failures++; printf("FAIL: %s\n", msg); } \
} while (0)
static void check_path(const char *path, const uint32_t *expected, int expected_len) {
uint32_t out[16];
int n = parse_bip44_path(path, out, 16);
char msg[256];
snprintf(msg, sizeof(msg), "parse_bip44_path(\"%s\") returned %d (expected %d)", path, n, expected_len);
CHECK(n == expected_len, msg);
if (n == expected_len) {
for (int i = 0; i < expected_len; i++) {
snprintf(msg, sizeof(msg), "parse_bip44_path(\"%s\") segment %d = 0x%08x (expected 0x%08x)",
path, i, out[i], expected[i]);
CHECK(out[i] == expected[i], msg);
}
}
}
int main(void) {
/* NIP-06 standard Nostr path: m/44'/1237'/0'/0/0 */
{
uint32_t exp[] = {
44u | BIP32_HARDENED_FLAG,
1237u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u,
0u,
};
check_path("m/44'/1237'/0'/0/0", exp, 5);
}
/* NIP-06 with nostr_index 5: m/44'/1237'/0'/0/5 */
{
uint32_t exp[] = {
44u | BIP32_HARDENED_FLAG,
1237u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u,
5u,
};
check_path("m/44'/1237'/0'/0/5", exp, 5);
}
/* All-hardened variant: m/44'/1237'/0'/0'/0' */
{
uint32_t exp[] = {
44u | BIP32_HARDENED_FLAG,
1237u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
check_path("m/44'/1237'/0'/0'/0'", exp, 5);
}
/* 'h' hardened marker: m/44h/1237h/0h/0/0 */
{
uint32_t exp[] = {
44u | BIP32_HARDENED_FLAG,
1237u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u,
0u,
};
check_path("m/44h/1237h/0h/0/0", exp, 5);
}
/* 'H' hardened marker: m/44H/1237H/0H/0/0 */
{
uint32_t exp[] = {
44u | BIP32_HARDENED_FLAG,
1237u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u,
0u,
};
check_path("m/44H/1237H/0H/0/0", exp, 5);
}
/* SSH ed25519 path: m/44'/102001'/0'/0'/0' */
{
uint32_t exp[] = {
44u | BIP32_HARDENED_FLAG,
102001u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
};
check_path("m/44'/102001'/0'/0'/0'", exp, 5);
}
/* No 'm' prefix: 44'/1237'/0'/0/0 */
{
uint32_t exp[] = {
44u | BIP32_HARDENED_FLAG,
1237u | BIP32_HARDENED_FLAG,
0u | BIP32_HARDENED_FLAG,
0u,
0u,
};
check_path("44'/1237'/0'/0/0", exp, 5);
}
/* Just "m" (root key, no segments) */
{
check_path("m", NULL, 0);
}
/* Empty string (root key, no segments) */
{
check_path("", NULL, 0);
}
/* Error cases */
{
uint32_t out[16];
CHECK(parse_bip44_path(NULL, out, 16) == -1, "NULL path_str rejected");
CHECK(parse_bip44_path("m/", out, 0) == -1, "max_segments=0 rejected");
CHECK(parse_bip44_path("m/abc", out, 16) == -1, "non-numeric segment rejected");
CHECK(parse_bip44_path("m/44'/1237'/0'/0/-1", out, 16) == -1, "negative index rejected");
CHECK(parse_bip44_path("m/44'/1237'/0'/0/99999999999", out, 16) == -1, "overflow index rejected");
CHECK(parse_bip44_path("m44", out, 16) == -1, "m without / rejected");
CHECK(parse_bip44_path("m/44'/1237'/0'/0/'", out, 16) == -1, "hardened marker with no number rejected");
}
/* Large index within range (0x7FFFFFFF = 2147483647, the max non-hardened) */
{
uint32_t exp[] = { 0x7FFFFFFFu };
check_path("m/2147483647", exp, 1);
}
printf("\n=== parse_bip44_path host test: %d passed, %d failed ===\n",
passes, failures);
return failures == 0 ? 0 : 1;
}
@@ -0,0 +1,275 @@
/* host_test_role_table.c — unit test for the role table path-template matching.
*
* Tests role_path_matches_template(), role_path_extract_index(), and
* role_path_matches_with_range() — the pure-string matching logic from
* role_table.cpp. The table add/find operations are trivial array ops and
* are tested implicitly via the range checks.
*
* As with host_test_parse_bip44_path.c, the matching functions are copied
* from role_table.cpp into this test to avoid pulling in Arduino.h and the
* rest of the firmware build. Keep the copies in sync if role_table.cpp
* changes.
*
* Build:
* cc -O2 -Wall -Wextra -o host_test_role_table \
* firmware/teensy41/signer/tests/host_test_role_table.c
* ./host_test_role_table
*/
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
/* ---- Minimal role_entry_t for the range check (only the fields used by
* role_path_matches_with_range are needed) ---- */
typedef struct {
char role_path[128];
int path_range_lo;
int path_range_hi;
} role_entry_t;
/* ---- Copied from role_table.cpp — kept in sync manually ---- */
static int role_path_matches_template(const char *path, const char *template_str) {
const char *p = path;
const char *t = template_str;
if (path == NULL || template_str == NULL) {
return 0;
}
while (*t != '\0' && *p != '\0') {
if (*t == '%' && *(t + 1) == 'd') {
t += 2;
if (*t == '\'' || *t == 'h' || *t == 'H') {
t++;
}
if (*p == '/') {
return 0;
}
while (*p != '\0' && *p != '/') {
p++;
}
if (*t == '/' && *p == '/') {
t++;
p++;
} else if (*t == '\0' && *p == '\0') {
return 1;
} else if (*t == '\0' && *p == '/') {
return 0;
} else if (*t == '/' && *p == '\0') {
return 0;
}
} else if (*t == *p) {
t++;
p++;
} else {
return 0;
}
}
return (*t == '\0' && *p == '\0') ? 1 : 0;
}
static int role_path_extract_index(const char *path, const char *template_str) {
const char *p = path;
const char *t = template_str;
const char *seg_start;
char seg_buf[32];
size_t seg_len;
long val;
char *endp;
if (path == NULL || template_str == NULL) {
return -1;
}
if (strstr(template_str, "%d") == NULL) {
return -1;
}
while (*t != '\0' && *p != '\0') {
if (*t == '%' && *(t + 1) == 'd') {
t += 2;
if (*t == '\'' || *t == 'h' || *t == 'H') {
t++;
}
if (*p == '/') {
return -1;
}
seg_start = p;
while (*p != '\0' && *p != '/') {
p++;
}
seg_len = (size_t)(p - seg_start);
if (seg_len == 0 || seg_len >= sizeof(seg_buf)) {
return -1;
}
memcpy(seg_buf, seg_start, seg_len);
seg_buf[seg_len] = '\0';
if (seg_len > 0 &&
(seg_buf[seg_len - 1] == '\'' || seg_buf[seg_len - 1] == 'h' ||
seg_buf[seg_len - 1] == 'H')) {
seg_buf[seg_len - 1] = '\0';
}
endp = NULL;
val = strtol(seg_buf, &endp, 10);
if (*endp != '\0' || val < 0) {
return -1;
}
return (int)val;
} else if (*t == *p) {
t++;
p++;
} else {
return -1;
}
}
return -1;
}
static int role_path_matches_with_range(const char *path, const role_entry_t *role) {
int index;
if (path == NULL || role == NULL) {
return 0;
}
if (strstr(role->role_path, "%d") == NULL) {
return role_path_matches_template(path, role->role_path);
}
if (!role_path_matches_template(path, role->role_path)) {
return 0;
}
index = role_path_extract_index(path, role->role_path);
if (index < 0) {
return 0;
}
if (role->path_range_lo < 0 || role->path_range_hi < 0) {
return 0;
}
return (index >= role->path_range_lo && index <= role->path_range_hi) ? 1 : 0;
}
/* ---- Test harness ---- */
static int failures = 0;
static int passes = 0;
#define CHECK(cond, msg) do { \
if (cond) { passes++; } \
else { failures++; printf("FAIL: %s\n", msg); } \
} while (0)
int main(void) {
/* ---- role_path_matches_template ---- */
/* Fixed path: exact match */
CHECK(role_path_matches_template("m/44'/1237'/0'/0/0", "m/44'/1237'/0'/0/0") == 1,
"fixed path exact match");
CHECK(role_path_matches_template("m/44'/1237'/0'/0/1", "m/44'/1237'/0'/0/0") == 0,
"fixed path mismatch rejected");
CHECK(role_path_matches_template("m/44'/1237'/0'/0/0/extra", "m/44'/1237'/0'/0/0") == 0,
"path longer than template rejected");
CHECK(role_path_matches_template("m/44'/1237'/0'/0", "m/44'/1237'/0'/0/0") == 0,
"path shorter than template rejected");
/* Template with %d (hardened) */
CHECK(role_path_matches_template("m/44'/1237'/5'/0/0", "m/44'/1237'/%d'/0/0") == 1,
"template %d' matches index 5");
CHECK(role_path_matches_template("m/44'/1237'/0'/0/0", "m/44'/1237'/%d'/0/0") == 1,
"template %d' matches index 0");
CHECK(role_path_matches_template("m/44'/1237'/100'/0/0", "m/44'/1237'/%d'/0/0") == 1,
"template %d' matches index 100");
/* NOTE: the hardened marker after %d in the template is OPTIONAL — the
* matcher skips it in the template but does not require it in the path.
* This matches the host's behavior (src/role_table.c). So %d' matches
* both hardened (5') and unhardened (5) path segments structurally. The
* range check then validates the numeric index. */
CHECK(role_path_matches_template("m/44'/1237'/5/0/0", "m/44'/1237'/%d'/0/0") == 1,
"template %d' matches unhardened path segment (host behavior)");
CHECK(role_path_matches_template("m/44'/1237'/abc'/0/0", "m/44'/1237'/%d'/0/0") == 1,
"template %d' structurally matches non-numeric (range check catches it)");
/* Template with %d (unhardened) */
CHECK(role_path_matches_template("m/44'/1237'/5/0/0", "m/44'/1237'/%d/0/0") == 1,
"template %d matches unhardened index 5");
/* NULL cases */
CHECK(role_path_matches_template(NULL, "m/44'") == 0, "NULL path rejected");
CHECK(role_path_matches_template("m/44'", NULL) == 0, "NULL template rejected");
/* ---- role_path_extract_index ---- */
CHECK(role_path_extract_index("m/44'/1237'/5'/0/0", "m/44'/1237'/%d'/0/0") == 5,
"extract index 5 from hardened template");
CHECK(role_path_extract_index("m/44'/1237'/0'/0/0", "m/44'/1237'/%d'/0/0") == 0,
"extract index 0 from hardened template");
CHECK(role_path_extract_index("m/44'/1237'/42/0/0", "m/44'/1237'/%d/0/0") == 42,
"extract index 42 from unhardened template");
CHECK(role_path_extract_index("m/44'/1237'/0'/0/0", "m/44'/1237'/0'/0/0") == -1,
"extract index from fixed path returns -1");
CHECK(role_path_extract_index("m/44'/1237'/abc'/0/0", "m/44'/1237'/%d'/0/0") == -1,
"extract index from non-numeric returns -1");
/* ---- role_path_matches_with_range ---- */
/* Fixed path role */
{
role_entry_t r;
memset(&r, 0, sizeof(r));
strncpy(r.role_path, "m/44'/1237'/0'/0/0", sizeof(r.role_path) - 1);
r.path_range_lo = -1;
r.path_range_hi = -1;
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 1,
"fixed path role matches its path");
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/1", &r) == 0,
"fixed path role rejects different path");
}
/* Range role: m/44'/1237'/%d'/0/0, range 0-100 */
{
role_entry_t r;
memset(&r, 0, sizeof(r));
strncpy(r.role_path, "m/44'/1237'/%d'/0/0", sizeof(r.role_path) - 1);
r.path_range_lo = 0;
r.path_range_hi = 100;
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 1,
"range role accepts index 0");
CHECK(role_path_matches_with_range("m/44'/1237'/50'/0/0", &r) == 1,
"range role accepts index 50");
CHECK(role_path_matches_with_range("m/44'/1237'/100'/0/0", &r) == 1,
"range role accepts index 100 (boundary)");
CHECK(role_path_matches_with_range("m/44'/1237'/101'/0/0", &r) == 0,
"range role rejects index 101 (out of bounds)");
/* The hardened marker after %d is optional in the matcher, so an
* unhardened segment that's in range is accepted. This matches the
* host's behavior. (If hardened-only enforcement is ever needed, the
* matcher would have to check the segment's trailing marker.) */
CHECK(role_path_matches_with_range("m/44'/1237'/5/0/0", &r) == 1,
"range role accepts unhardened segment in range (host behavior)");
CHECK(role_path_matches_with_range("m/44'/1237'/abc'/0/0", &r) == 0,
"range role rejects non-numeric segment");
}
/* Range role with no range configured (lo/hi = -1) — fail-closed */
{
role_entry_t r;
memset(&r, 0, sizeof(r));
strncpy(r.role_path, "m/44'/1237'/%d'/0/0", sizeof(r.role_path) - 1);
r.path_range_lo = -1;
r.path_range_hi = -1;
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 0,
"template role with no range denies (fail-closed)");
}
printf("\n=== role_table host test: %d passed, %d failed ===\n",
passes, failures);
return failures == 0 ? 0 : 1;
}
+8 -6
View File
@@ -121,23 +121,25 @@ def main():
t("derive", lambda: call(ser, "derive", ["derive-test", {"algorithm": "secp256k1", "index": 1}]))
# nostr
npub = [None]
# Role + role_path selector (replaces the deprecated nostr_index).
main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
def ngpk():
r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}])
r = call(ser, "nostr_get_public_key", [main_role])
npub[0] = r["result"]
t("nostr_get_public_key", ngpk)
def nse():
ev = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello event"}
call(ser, "nostr_sign_event", [ev, {"nostr_index": 0}])
call(ser, "nostr_sign_event", [ev, main_role])
t("nostr_sign_event", nse)
# nip04
def nip04():
r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", {"nostr_index": 0}])
call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", main_role])
call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], main_role])
t("nip04 round-trip", nip04)
# nip44
def nip44():
r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", {"nostr_index": 0}])
call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", main_role])
call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], main_role])
t("nip44 round-trip", nip44)
except Exception as e:
print(f"\n!! STOPPED: {e}", flush=True)
+12 -8
View File
@@ -3,10 +3,11 @@
Flow:
1. get_info (sanity)
2. nostr_get_public_key (nostr_index=0) -> our x-only secp256k1 pubkey (peer)
3. nostr_nip04_encrypt [our_pub, "hello via nip04", {nostr_index:0}]
2. nostr_get_public_key (role=main, role_path=m/44'1237'0'/0/0)
-> our x-only secp256k1 pubkey (peer)
3. nostr_nip04_encrypt [our_pub, "hello via nip04", {role:main, role_path:...}]
-> ciphertext?iv=...
4. nostr_nip04_decrypt [our_pub, ciphertext, {nostr_index:0}]
4. nostr_nip04_decrypt [our_pub, ciphertext, {role:main, role_path:...}]
-> should recover "hello via nip04"
Also tests NIP-44 the same way to verify the is_nip44 dispatch fix.
@@ -90,8 +91,11 @@ def main():
if "result" not in r:
print("FAIL: get_info"); ok = False
# Role + role_path selector (replaces the deprecated nostr_index).
main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
# 2. our nostr pubkey (x-only, 64 hex)
r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}])
r = call(ser, "nostr_get_public_key", [main_role])
if "result" not in r:
print("FAIL: nostr_get_public_key"); ok = False; ser.close(); return 1
our_pub = r["result"]
@@ -101,14 +105,14 @@ def main():
# 3. NIP-04 encrypt to ourselves
plaintext = "hello via nip04"
r = call(ser, "nostr_nip04_encrypt", [our_pub, plaintext, {"nostr_index": 0}])
r = call(ser, "nostr_nip04_encrypt", [our_pub, plaintext, main_role])
if "result" not in r:
print("FAIL: nostr_nip04_encrypt (this is the crash we are testing)"); ok = False
else:
cipher = r["result"]
print(f" ciphertext = {cipher}")
# 4. NIP-04 decrypt
r = call(ser, "nostr_nip04_decrypt", [our_pub, cipher, {"nostr_index": 0}])
r = call(ser, "nostr_nip04_decrypt", [our_pub, cipher, main_role])
if "result" not in r:
print("FAIL: nostr_nip04_decrypt"); ok = False
else:
@@ -122,13 +126,13 @@ def main():
# 5. NIP-44 encrypt to ourselves (verifies the is_nip44 dispatch fix)
plaintext44 = "hello via nip44"
r = call(ser, "nostr_nip44_encrypt", [our_pub, plaintext44, {"nostr_index": 0}])
r = call(ser, "nostr_nip44_encrypt", [our_pub, plaintext44, main_role])
if "result" not in r:
print("FAIL: nostr_nip44_encrypt"); ok = False
else:
cipher44 = r["result"]
print(f" nip44 ciphertext = {cipher44[:60]}...")
r = call(ser, "nostr_nip44_decrypt", [our_pub, cipher44, {"nostr_index": 0}])
r = call(ser, "nostr_nip44_decrypt", [our_pub, cipher44, main_role])
if "result" not in r:
print("FAIL: nostr_nip44_decrypt"); ok = False
else:
+43 -6
View File
@@ -229,8 +229,11 @@ def main():
if r and "result" in r: passed += 1
else: failed += 1
# Role + role_path selector (replaces the deprecated nostr_index).
main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
# 8. nostr_get_public_key
r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}])
r = test_verb(ser, "nostr_get_public_key", [main_role])
nostr_pub = None
if r and "result" in r:
passed += 1
@@ -243,18 +246,18 @@ def main():
# 9. nostr_sign_event
if nostr_pub:
event = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello from test_signer"}
r = test_verb(ser, "nostr_sign_event", [event, {"nostr_index": 0}])
r = test_verb(ser, "nostr_sign_event", [event, main_role])
if r and "result" in r: passed += 1
else: failed += 1
# 10. nostr_nip04_encrypt + decrypt (the bug we fixed)
if nostr_pub:
nip04_pt = "hello via nip04"
r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, {"nostr_index": 0}])
r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, main_role])
if r and "result" in r:
passed += 1
cipher = r["result"]
r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, {"nostr_index": 0}])
r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, main_role])
if r and "result" in r and r["result"] == nip04_pt:
print(f" ✅ nip04 round-trip plaintext recovered")
passed += 1
@@ -267,11 +270,11 @@ def main():
# 11. nostr_nip44_encrypt + decrypt (the is_nip44 dispatch bug we fixed)
if nostr_pub:
nip44_pt = "hello via nip44"
r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, {"nostr_index": 0}])
r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, main_role])
if r and "result" in r:
passed += 1
cipher44 = r["result"]
r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, {"nostr_index": 0}])
r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, main_role])
if r and "result" in r and r["result"] == nip44_pt:
print(f" ✅ nip44 round-trip plaintext recovered")
passed += 1
@@ -281,6 +284,40 @@ def main():
else:
failed += 1
# ---- Role + path authorization error cases ----
print("\n=== Role + path authorization error cases ===")
# 11a. nostr_index is deprecated → error 2006
r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}])
if r and "error" in r and r["error"].get("code") == 2006:
print(f" ✅ nostr_index rejected with error 2006 (deprecated)")
passed += 1
else:
print(f" ❌ nostr_index not rejected as expected: {r}")
failed += 1
# 11b. unknown role → error 1002
r = test_verb(ser, "nostr_get_public_key",
[{"role": "nonexistent", "role_path": "m/44'/1237'/0'/0/0"}])
if r and "error" in r and r["error"].get("code") == 1002:
print(f" ✅ unknown role rejected with error 1002")
passed += 1
else:
print(f" ❌ unknown role not rejected as expected: {r}")
failed += 1
# 11c. path out of range → error 2003
# (Requires a range role; the default "main" role is fixed-path, so this
# tests a path that doesn't match the fixed template.)
r = test_verb(ser, "nostr_get_public_key",
[{"role": "main", "role_path": "m/44'/1237'/999'/0/0"}])
if r and "error" in r and r["error"].get("code") == 2003:
print(f" ✅ path mismatch rejected with error 2003")
passed += 1
else:
print(f" ❌ path mismatch not rejected as expected: {r}")
failed += 1
# ---- PQ verbs (tested LAST: heap-heavy, may crash the device) ----
print("\n=== PQ verbs (heap-heavy; tested last) ===")
+110
View File
@@ -0,0 +1,110 @@
# Plan: Role-as-Password as the Default Authorization Model
## Status: Draft — ready for review
## Problem
The signer currently defaults to requiring interactive approval for every request, even for the default "main" role. This contradicts the intended design where **knowing the role name is sufficient authorization** (role-as-password). Users running v0.1.21 are prompted to approve requests when they should be authorized automatically.
## Root cause
Three places in the code set `requires_approval = 1` by default:
| Location | Context | Current value |
|---|---|---|
| [`src/main.c:3816`](../src/main.c:3816) | Default "main" role (non-interactive mode) | `requires_approval = 1` |
| [`src/main.c:2374`](../src/main.c:2374) | Wizard role creation prompt | Default `1` (Y/n) |
| [`src/main.c:2223`](../src/main.c:2223) | Wizard OTP role creation prompt | Default `1` (Y/n) |
## Changes required
### 1. Default "main" role — non-interactive mode
**File:** [`src/main.c:3816`](../src/main.c:3816)
Change:
```c
role.requires_approval = 1;
```
To:
```c
role.requires_approval = 0; /* role-as-password: knowing the role name is sufficient */
```
This is the most critical fix — it affects every user running in non-interactive mode (e.g., `--listen unix`, `--listen tcp`, `--listen qrexec`).
### 2. Wizard role creation — remove approval prompt
**File:** [`src/main.c:2359-2377`](../src/main.c:2359)
Currently the wizard asks:
```
Require interactive approval for each request? [Y/n]:
```
With role-as-password as the default, this prompt should be **removed entirely**. The role is created with `requires_approval = 0`. If a user wants approval, they can use `--preapprove` or manually edit the role after creation.
Remove the prompt block (lines 2359-2377) and set:
```c
int requires_approval = 0; /* role-as-password by default */
```
### 3. Wizard OTP role creation — remove approval prompt
**File:** [`src/main.c:2208-2226`](../src/main.c:2208)
Same change as #2. Remove the approval prompt for OTP roles and default to `requires_approval = 0`.
### 4. Policy table default — no change needed
**File:** [`src/policy.c:1097-1113`](../src/policy.c:1097)
The current default policy is `* → PROMPT_EVERY_REQUEST`. This is correct because:
- For roles with `requires_approval = 0`, [`policy_check_with_role()`](../src/policy.c:1305) returns `POLICY_ALLOW` **before** consulting the policy table.
- For roles with `requires_approval = 1`, the policy table prompt still fires as expected.
No change needed here.
### 5. `--allow-all` flag — retain as-is
**File:** [`src/main.c:3678`](../src/main.c:3678)
The `--allow-all` flag sets `g_prompt_always_allow`, which bypasses prompts for algorithm-based verbs (sign, verify, encapsulate, etc.) that don't go through the role system. This is still useful for testing and non-interactive scenarios. No change needed.
## Authorization flow after changes
```mermaid
flowchart TD
A[Client sends request with role name] --> B{Role found?}
B -- No --> C[Reject: unknown_role]
B -- Yes --> D{requires_approval?}
D -- No --> E[Authorize immediately - role-as-password]
D -- Yes --> F[Check policy table]
F --> G{Policy match?}
G -- Allow --> H[Authorize]
G -- Prompt --> I[Show interactive prompt]
G -- Deny --> J[Reject: policy_denied]
I --> K{User choice}
K -- y --> H
K -- a/e --> L[Add session grant] --> H
K -- n --> J
```
## Test impact
- [`tests/test_n_signer_client.sh`](../tests/test_n_signer_client.sh) — may need updates if tests relied on the old approval-required default
- [`tests/test_integration.c`](../tests/test_integration.c) — verify no tests break from the default change
- The `NSIGNER_TEST_FORCE_PROMPT` env var ([`src/main.c:3063`](../src/main.c:3063)) can be used to force prompts in tests that need to exercise the approval path
## Files to modify
| File | Lines | Change |
|---|---|---|
| [`src/main.c`](../src/main.c) | 3816 | `requires_approval = 1``0` |
| [`src/main.c`](../src/main.c) | 2359-2377 | Remove approval prompt, default to `0` |
| [`src/main.c`](../src/main.c) | 2208-2226 | Remove approval prompt, default to `0` |
## Summary
Three one-line changes (plus removing two prompt blocks) to make role-as-password the default. The mechanism already exists in the code — it's just not the default.
+448
View File
@@ -0,0 +1,448 @@
# Plan: Migrate Teensy 4.1 Signer to the Role + Path Authorization Model
## Status: Implemented (Phases 1-7) — pending hardware flash + verification
> All 7 phases are implemented. Host-side unit tests pass (79 assertions:
> 53 for `parse_bip44_path`, 26 for `role_table` path matching). The
> firmware code compiles pending an on-device build (`build_signer.sh`) and
> the hardware test suite (`test_signer.py` etc.) needs a Teensy 4.1 flash
> to verify end-to-end. The `ALLOW_DEPRECATED_NOSTR_INDEX` flag is set to 0
> (nostr_index rejected with error 2006, matching the host).
## Problem
The host `n_signer` has migrated to a **role + path authorization model** (see
[`plans/role_path_authorization.md`](role_path_authorization.md) and
[`plans/role_as_password_default.md`](role_as_password_default.md)):
- `nostr_index` is **deprecated** — the host returns error `2006` with the
message *"nostr_index is deprecated — use --role main --path
m/44'/1237'/N'/0/0 instead"* ([`src/selector.c:759`](../src/selector.c:759)).
- Clients must send `{"role":"<name>","role_path":"m/44'/1237'/0'/0/0"}` for
all `nostr_*` verbs.
- **Role-as-password**: roles default to `requires_approval = 0` — knowing the
role name is sufficient authorization, no interactive prompt needed.
- A **role table** with presets, path templates (`%d` placeholders), and
range/set validation governs which paths are allowed.
The Teensy 4.1 firmware is **out of sync**. It still uses `nostr_index`
exclusively:
- [`firmware/teensy41/signer/src/dispatch.cpp:868`](../firmware/teensy41/signer/src/dispatch.cpp:868)
`parse_nostr_index_from_params()` is the only selector parser.
- [`firmware/teensy41/signer/src/dispatch.cpp:1958`](../firmware/teensy41/signer/src/dispatch.cpp:1958)
— every `nostr_*` verb calls `derive_request_key(nostr_index, ...)`.
- [`firmware/teensy41/signer/src/key_derivation.h:31`](../firmware/teensy41/signer/src/key_derivation.h:31)
— only `derive_secp256k1_keys_index(nostr_index)` exists; there is no
path-based derivation entry point.
- There is **no role table**, no wizard, no path-template matching, and no
`requires_approval` flag. Every `nostr_*` verb prompts for approval via
`ui_approve()`.
The CYD firmware
([`firmware/cyd_esp32_2432s028/main/main.c`](../firmware/cyd_esp32_2432s028/main/main.c))
is in the same state — this plan focuses on the Teensy 4.1, but the CYD will
need the same migration afterwards.
## Goal
Bring the Teensy 4.1 signer's `nostr_*` verb handling into parity with the
host's role + path model:
1. Accept `{"role":"<name>","role_path":"<path>"}` and derive the key from the
explicit BIP-44 path (not a `nostr_index` integer).
2. Maintain a **role table** populated at boot via an LVGL role-preset wizard
(the touch-screen equivalent of the host's terminal wizard).
3. Implement **role-as-password**: roles with `requires_approval = 0` authorize
immediately; only `requires_approval = 1` roles prompt via `ui_approve()`.
4. Reject `nostr_index` with the same `2006` error the host returns.
5. Keep the algorithm-based verbs (`sign`, `get_public_key`, `derive`, etc.)
unchanged — they use `algorithm` + `index`, not roles.
## What already exists in the Teensy 4.1 firmware
The good news: the hard crypto plumbing is already there.
- **BIP-32 path derivation**: [`nostr_bip32_key_from_seed()`](../firmware/teensy41/signer/src/nostr_core/nostr_utils.c:1445)
and [`nostr_bip32_derive_path()`](../firmware/teensy41/signer/src/nostr_core/nostr_utils.c:1565)
are already compiled into the firmware (used by NIP-06). We just need a new
entry point that takes a path string instead of a fixed `nostr_index`.
- **Path parsing**: the host's [`parse_bip44_path()`](../src/key_store.c:685)
is a ~75-line pure-C function that splits `m/44'/1237'/0'/0/0` into a
`uint32_t[]` with hardened-bit handling. It ports directly (it uses only
`strtol`, `strlen`, and the `'`/`h` markers).
- **LVGL UI**: [`ui.h`](../firmware/teensy41/signer/src/ui.h) already has
modal screen primitives (`ui_show_mnemonic`, `ui_enter_mnemonic`,
`ui_approve`, `ui_pick_pad`) that pump LVGL while blocking. A role-wizard
screen follows the same pattern.
- **cJSON**: already vendored for request parsing.
- **Memory**: the v0.1.6 `.rodata` → FLASH move
([`plans/teensy41_memory_evaluation.md`](teensy41_memory_evaluation.md)
Solution A) left **130.9 KB of free stack** — plenty of headroom for a role
table and path strings.
## Architecture
```mermaid
flowchart TD
Boot[signer.ino boot] --> Menu[startup menu<br/>generate or enter mnemonic]
Menu --> Wizard[role_wizard UI<br/>LVGL preset menu]
Wizard --> Table[role_table_t<br/>in-RAM, DMAMEM]
Table --> Idle[ui_show_idle<br/>show npub + role count]
Idle --> Frame[transport_read_frame]
Frame --> Parse[dispatch.cpp<br/>parse JSON-RPC]
Parse --> Sel{nostr_* verb?}
Sel -- Yes --> Role[selector_resolve<br/>role + role_path]
Role --> Match{role found + path matches?}
Match -- No --> Err[error 2006 or 2003]
Match -- Yes --> Approve{requires_approval?}
Approve -- No --> Derive[derive_secp256k1_from_path]
Approve -- Yes --> UI[ui_approve prompt]
UI -- Approve --> Derive
UI -- Deny --> Deny[deny JSON]
Derive --> Exec[execute nostr verb]
Exec --> Resp[structured JSON result]
Resp --> Frame
Sel -- No --> Alg[algorithm-based verbs<br/>unchanged]
Alg --> Resp
```
## File layout (new + modified)
```
firmware/teensy41/signer/
├── signer.ino (modified — boot flow calls role wizard)
├── src/
│ ├── role_table.h (NEW — role_entry_t, role_table_t, presets)
│ ├── role_table.cpp (NEW — table ops, path matching, presets)
│ ├── selector.h (NEW — selector_request_t, selector_resolve)
│ ├── selector.cpp (NEW — parse role/role_path, reject nostr_index)
│ ├── key_derivation.h (modified — add derive_secp256k1_from_path)
│ ├── key_derivation.cpp (modified — add path-based derivation)
│ ├── dispatch.cpp (modified — nostr_* verbs use selector + role)
│ ├── dispatch.h (modified — extern role table, error codes)
│ ├── ui.h (modified — add ui_role_wizard)
│ └── ui.cpp (modified — implement role wizard screen)
```
## Implementation phases
### Phase 1 — Port the path parser + path-based secp256k1 derivation
**Goal:** derive a secp256k1 keypair from an arbitrary BIP-44 path string,
independent of `nostr_index`.
- [ ] Add `parse_bip44_path()` to
[`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp)
— port from [`src/key_store.c:685`](../src/key_store.c:685). Pure C,
~75 lines. Handles `m/`, `'`/`h`/`H` hardened markers, up to 16 segments.
Mark it `__attribute__((section(".flashmem")))` to keep ITCM small.
- [ ] Add `derive_secp256k1_from_path()` to
[`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp)
— port from [`src/key_store.c:765`](../src/key_store.c:765). Calls
`nostr_bip32_key_from_seed` + `parse_bip44_path` +
`nostr_bip32_derive_path`, returns 32-byte privkey + 32-byte x-only
pubkey. Mark `.flashmem`.
- [ ] Declare both in [`key_derivation.h`](../firmware/teensy41/signer/src/key_derivation.h):
```cpp
int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments);
int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len,
const char *path_str,
uint8_t *privkey, uint8_t *pubkey);
```
- [ ] **Host-side unit test**: add a host-buildable test that links
`key_derivation.cpp` (compiled with `HOST_TEST` against the nostr_core
C files) and verifies `derive_secp256k1_from_path("m/44'/1237'/0'/0/0")`
produces the same pubkey as
`derive_secp256k1_keys_index(0)` (the existing NIP-06 path is
`m/44'/1237'/0'/0/0` — they must match). Also test a hardened variant
(`m/44'/1237'/0'/0'/0'`) produces a different key.
**Exit criterion:** path-based derivation produces byte-identical keys to the
existing `nostr_index` path for the same BIP-44 path, and different keys for
different paths.
### Phase 2 — Role table + path-template matching
**Goal:** an in-RAM role table with the same semantics as the host's
[`src/role_table.c`](../src/role_table.c), sized for the Teensy's memory.
- [ ] Create [`role_table.h`](../firmware/teensy41/signer/src/role_table.h)
with a **slimmed-down** `role_entry_t` (the host's struct has 256-entry
arrays and 64-int allowed-indices sets — too big for the Teensy; cap at
`ROLE_TABLE_MAX_ENTRIES 16` and `path_allowed_indices[16]`):
```cpp
typedef enum { PURPOSE_NOSTR, PURPOSE_SSH, PURPOSE_AGE,
PURPOSE_PQ_SIG, PURPOSE_PQ_KEM } role_purpose_t;
typedef enum { CURVE_SECP256K1, CURVE_ED25519, CURVE_X25519,
CURVE_ML_DSA_65, CURVE_SLH_DSA_128S,
CURVE_ML_KEM_768 } role_curve_t;
typedef struct {
char name[32];
char role_path[128]; /* template, may contain one "%d" */
role_purpose_t purpose;
role_curve_t curve;
int path_range_lo; /* -1 = fixed path (no %d) */
int path_range_hi;
int path_default_index; /* -1 = require explicit */
int requires_approval; /* 0 = role-as-password, 1 = prompt */
int derived;
char pubkey_hex[65]; /* filled after first derivation */
} role_entry_t;
typedef struct {
role_entry_t entries[16];
int count;
} role_table_t;
```
- [ ] Create [`role_table.cpp`](../firmware/teensy41/signer/src/role_table.cpp)
with:
- `role_table_init()`, `role_table_add()`, `role_table_find_by_name()`.
- `role_path_matches_template()` — port from
[`src/role_table.c:956`](../src/role_table.c:956). Handles one `%d`
placeholder.
- `role_path_extract_index()` — port from
[`src/role_table.c:1007`](../src/role_table.c:1007).
- `role_path_matches_with_range()` — port from
[`src/role_table.c:1070`](../src/role_table.c:1070). Combines template
match + range check.
- `role_table_get_default()` — returns the role named `"main"`.
- All marked `.flashmem` where reasonable.
- [ ] **Host-side unit test**: link `role_table.cpp` with `HOST_TEST` and
verify: fixed-path match, template match with `%d`, range rejection
(index out of bounds), unknown role returns NULL.
**Exit criterion:** role table operations match the host's semantics for the
subset of features we need (single `%d` placeholder, range bounds).
### Phase 3 — Selector: parse role + role_path, reject nostr_index
**Goal:** a `selector_resolve()` that mirrors the host's
[`src/selector.c:745`](../src/selector.c:745) decision tree.
- [ ] Create [`selector.h`](../firmware/teensy41/signer/src/selector.h):
```cpp
typedef struct {
int has_role; char role_name[32];
int has_role_path; char role_path[128];
int has_nostr_index; uint32_t nostr_index;
int has_index; uint32_t index;
} selector_request_t;
#define SELECTOR_OK 0
#define SELECTOR_ERR_NOT_FOUND -1
#define SELECTOR_ERR_NO_DEFAULT -3
#define SELECTOR_ERR_PATH_MISMATCH -4
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5
#define SELECTOR_ERR_PATH_REQUIRED -6
#define SELECTOR_ERR_ROLE_REQUIRED -7
```
- [ ] Create [`selector.cpp`](../firmware/teensy41/signer/src/selector.cpp)
with `selector_resolve()` — port the decision tree from
[`src/selector.c:745`](../src/selector.c:745):
- `has_nostr_index` → return `SELECTOR_ERR_NOSTR_INDEX_DEPRECATED`.
- `has_role_path` without `has_role` → `SELECTOR_ERR_ROLE_REQUIRED`.
- `has_role` + `has_role_path` → find role, verify path matches template
+ range, return the role entry.
- `has_role` only → if fixed path (no `%d`), use it; else
`SELECTOR_ERR_PATH_REQUIRED`.
- Neither → use default role (`"main"`), else `SELECTOR_ERR_NO_DEFAULT`.
- [ ] Add a parser in `selector.cpp` that extracts `role` / `role_path` /
`nostr_index` / `index` from the trailing cJSON options object of a
`nostr_*` verb's params array (replacing
[`parse_nostr_index_from_params()`](../firmware/teensy41/signer/src/dispatch.cpp:868)).
**Exit criterion:** selector returns the correct error code for each
deprecated/missing/mismatched case, and the correct role entry for valid
role+path combinations.
### Phase 4 — Wire selector + role table into dispatch
**Goal:** the `nostr_*` verbs in
[`dispatch.cpp`](../firmware/teensy41/signer/src/dispatch.cpp) use the
selector + role table instead of `nostr_index`.
- [ ] Add a global `role_table_t g_roles` (in `DMAMEM`) declared `extern` in
[`dispatch.h`](../firmware/teensy41/signer/src/dispatch.h), populated by
the boot flow (Phase 6).
- [ ] Replace `parse_nostr_index_from_params()` + `derive_request_key()` in
each `nostr_*` verb handler with:
1. Parse the selector request from params.
2. Call `selector_resolve(&req, &g_roles, &role)`.
3. On `SELECTOR_ERR_NOSTR_INDEX_DEPRECATED` → return error `2006` with
the host's exact message.
4. On `SELECTOR_ERR_NOT_FOUND` → error `1002 unknown_role`.
5. On `SELECTOR_ERR_PATH_MISMATCH` → error `2003 path_not_allowed`.
6. On success → derive the key via
`derive_secp256k1_from_path(g_seed, g_seed_len, req.role_path, ...)`.
7. If `role->requires_approval == 1` → call `ui_approve()`; else skip
the prompt (role-as-password).
- [ ] The verbs to update (all in
[`dispatch.cpp`](../firmware/teensy41/signer/src/dispatch.cpp)):
- `nostr_get_public_key` (~line 1958)
- `nostr_sign_event` (~line 2010)
- `nostr_mine_event` (~line 2049)
- `nostr_nip04_encrypt` / `nostr_nip04_decrypt` (~line 2246)
- `nostr_nip44_encrypt` / `nostr_nip44_decrypt` (same handler area)
- [ ] Update `get_info` to report the configured roles in the response (the
host's `get_info` lists roles; the Teensy currently does not).
- [ ] **Keep `nostr_index` working as a hidden fallback** behind a
`#define ALLOW_DEPRECATED_NOSTR_INDEX 0` compile flag, default off, so
the old `test_signer.py` can still run during migration by flipping the
flag. Remove the flag entirely once tests are updated.
**Exit criterion:** a `nostr_get_public_key` request with
`{"role":"main","role_path":"m/44'/1237'/0'/0/0"}` returns the same pubkey as
the old `{"nostr_index":0}` request. A request with `{"nostr_index":0}`
returns error `2006`.
### Phase 5 — Role-preset wizard UI (LVGL)
**Goal:** a touch-screen role wizard that runs at boot, mirroring the host's
terminal preset menu ([`src/main.c:2050`](../src/main.c:2050)).
- [ ] Add `ui_role_wizard()` to [`ui.h`](../firmware/teensy41/signer/src/ui.h)
/ [`ui.cpp`](../firmware/teensy41/signer/src/ui.cpp):
```cpp
/* Run the role-preset wizard. Fills `out_table` with at least one role.
* Blocks (pumping LVGL) until the user defines at least one role and
* taps "Done". Returns 0 on success, -1 if the user cancels (which
* should abort the boot). */
int ui_role_wizard(role_table_t *out_table);
```
- [ ] Implement the wizard screen as an LVGL list of preset buttons (matching
the host's 10 presets, adapted for the 480×320 screen):
1. Standard Nostr (secp256k1, `m/44'/1237'/0'/0/0`)
2. Nostr range (secp256k1, `m/44'/1237'/*'/0/0`, range 0-100)
3. Nostr agent (secp256k1, `m/44'/1237'/*'/1'/0'`, range 0-100)
4. SSH (ed25519, `m/44'/102001'/0'/0'/0'`)
5. Age (x25519, `m/44'/102002'/0'/0'/0'`)
6. ML-DSA-65 (`m/44'/102003'/0'/0'/0'`)
7. SLH-DSA-128s (`m/44'/102004'/0'/0'/0'`)
8. ML-KEM-768 (`m/44'/102005'/0'/0'/0'`)
9. OTP (no path — binds the SD pad instead)
10. Custom (text entry for name + path)
- [ ] After a preset is chosen, show a sub-screen to edit the role name
(default from preset) and toggle `requires_approval` (default **off** =
role-as-password, per
[`plans/role_as_password_default.md`](role_as_password_default.md)).
- [ ] Loop: "Add another role?" (Yes/No). At least one role is required; if
the user taps "Done" with zero roles, show an error and re-loop.
- [ ] Use the existing aesthetics (black bg, white text, red accent for the
selected preset, grey for muted). Reuse the button + list primitives
already in [`ui.cpp`](../firmware/teensy41/signer/src/ui.cpp).
**Exit criterion:** the user can define a "main" Standard Nostr role via touch
and the role table is populated before the idle screen appears.
### Phase 6 — Boot flow integration
**Goal:** wire the role wizard into
[`signer.ino`](../firmware/teensy41/signer/signer.ino) between mnemonic entry
and the idle screen.
- [ ] In [`signer.ino`](../firmware/teensy41/signer/signer.ino) `setup()` /
`apply_mnemonic()`, after the mnemonic is applied and the seed is
derived:
1. Call `role_table_init(&g_roles)`.
2. If `DEBUG_AUTO_GENERATE == 1`: auto-populate `g_roles` with a single
"main" role (`m/44'/1237'/0'/0/0`, `requires_approval = 0`) so
headless tests work without the wizard. Log this over Serial.
3. If `DEBUG_AUTO_GENERATE == 0`: call `ui_role_wizard(&g_roles)`. If it
returns -1 (cancel), abort the boot (show an error screen and halt).
4. Derive the default role's pubkey for the idle screen (show npub +
role count, matching the host's status display).
- [ ] Update [`ui_show_idle()`](../firmware/teensy41/signer/src/ui.h:59) to
show the role count (e.g. "roles: 3") alongside the npub, mirroring the
host's status line.
**Exit criterion:** the boot flow goes mnemonic → role wizard → idle screen,
and `g_roles` is populated before any `nostr_*` verb can be dispatched.
### Phase 7 — Test updates + hardware verification
**Goal:** the test suite exercises the new role+path model and confirms
parity with the host.
- [ ] Update [`firmware/teensy41/test_signer.py`](../firmware/teensy41/test_signer.py):
- Replace all `{"nostr_index": N}` options with
`{"role":"main","role_path":"m/44'/1237'/N'/0/0"}`.
- Add a test that sends `{"nostr_index": 0}` and asserts the response is
error `2006`.
- Add a test that sends `{"role":"nonexistent","role_path":"..."}` and
asserts error `1002`.
- Add a test that sends `{"role":"main","role_path":"m/44'/1237'/999'/0/0"}`
(out of range) and asserts error `2003`.
- Add a test that verifies `requires_approval = 0` roles do NOT trigger
`ui_approve` (the response comes back immediately, no 30s prompt).
- [ ] Update [`firmware/teensy41/test_classical.py`](../firmware/teensy41/test_classical.py)
and [`firmware/teensy41/test_nip04.py`](../firmware/teensy41/test_nip04.py)
to use role+path selectors for the `nostr_*` verbs.
- [ ] **Cross-board parity**: with the same mnemonic and a "main" role at
`m/44'/1237'/0'/0/0`, verify the Teensy 4.1 and the host `n_signer`
produce the same npub and the same `nostr_sign_event` signature.
- [ ] Run the full suite:
```bash
bash firmware/teensy41/build_signer.sh --flash
python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0
python3 firmware/teensy41/test_nip04.py --port /dev/ttyACM0
python3 firmware/teensy41/test_signer.py --port /dev/ttyACM0
```
- [ ] Run [`check_stack.sh`](../firmware/teensy41/check_stack.sh) to confirm
the new role table + wizard code did not push free stack below 16 KB.
**Exit criterion:** all tests pass with role+path selectors, `nostr_index` is
rejected with error 2006, and the stack gauge reports ≥ 16 KB free.
## Memory considerations
- The role table is `16 × sizeof(role_entry_t)`. With `role_entry_t` at ~240
bytes, that's ~3.8 KB. Place it in `DMAMEM` (RAM2) — there is 110 KB free
heap and 413 KB of `.bss.dma` already; 3.8 KB is negligible.
- The path parser and `derive_secp256k1_from_path` are pure code — mark them
`.flashmem` so they live in FLASH (6.3 MB free) and don't steal ITCM banks.
- The wizard UI adds LVGL widgets at runtime (heap-allocated by LVGL), freed
when the wizard screen is destroyed. No persistent LVGL memory cost.
- **Stack impact**: `derive_secp256k1_from_path` uses the same
`nostr_hd_key_t` (1088 bytes each, two of them) as the existing NIP-06
derivation — no new stack pressure. The 130.9 KB free stack is more than
enough.
## Decisions to confirm
1. **Role table size**: 16 entries (vs the host's 256). Sufficient for a
hardware signer? The host allows 256 for complex multi-agent setups; the
Teensy is a single-user device. **Recommend 16.**
2. **`requires_approval` default**: `0` (role-as-password), matching
[`plans/role_as_password_default.md`](role_as_password_default.md). The
wizard lets the user toggle it per role. **Confirm.**
3. **`nostr_index` removal**: fully reject with error 2006 (no silent
fallback), matching the host. A compile flag
`ALLOW_DEPRECATED_NOSTR_INDEX` is provided **temporarily** for the
migration period only. **Confirm.**
4. **OTP role**: the host's OTP role (preset 9) has no derivation path. On the
Teensy, OTP is already handled by the SD-pad bind flow
([`plans/teensy41_otp_sd_pad.md`](teensy41_otp_sd_pad.md) Phase 6). The
wizard's OTP preset should trigger `ui_pick_pad()` instead of path entry.
**Confirm.**
5. **Algorithm-based verbs**: `sign`, `get_public_key`, `derive`,
`encapsulate`, `decapsulate`, `derive_shared_secret`, `encrypt`,
`decrypt` are **unchanged** — they use `algorithm` + `index`, not roles.
Only the `nostr_*` verbs migrate. **Confirm.**
## Out of scope
- **CYD firmware migration**: the CYD
([`firmware/cyd_esp32_2432s028/`](../firmware/cyd_esp32_2432s028/)) needs the
same migration, but it is a separate task (different UI framework
constraints, smaller screen). Tracked after the Teensy migration is
verified.
- **Policy table / `--preapprove`**: the host has a policy table for
caller-based preapproval. The Teensy has no caller identity (USB CDC is a
single trusted host), so the policy table is not needed — role-as-password
is the only authorization mechanism.
- **Path whitelist / `--allow-index`**: removed in the host's new model; not
applicable to the Teensy.
- **Multi-segment path templates** (e.g. `m/44'/1237'/%d/%d/%d`): the host
supports only a single `%d` placeholder; we match that limitation.
+98 -37
View File
@@ -32,35 +32,83 @@ void auth_nonce_cache_init(auth_nonce_cache_t *cache) {
memset(cache, 0, sizeof(*cache));
}
static int auth_nonce_cache_contains(const auth_nonce_cache_t *cache, const uint8_t id[32]) {
/*
* Check and update replay protection using per-pubkey monotonic timestamps
* plus event-ID tracking for same-second requests.
*
* Returns 0 if the (pubkey, created_at, event_id) tuple is acceptable.
* Returns 1 if it is a replay.
*
* Hybrid approach:
* - Track the highest created_at seen per pubkey.
* - When created_at > max_seen: update max, clear event ID set, accept.
* - When created_at == max_seen: check event ID set for duplicates.
* - When created_at < max_seen: reject as replay.
*
* This allows multiple legitimate requests within the same second
* (since created_at has 1-second granularity) while preventing replay
* of any individual event ID.
*/
static int auth_nonce_cache_check_and_update(auth_nonce_cache_t *cache,
const char *pubkey_hex,
time_t created_at,
const uint8_t event_id[32]) {
int i;
if (cache == NULL || id == NULL) {
return 0;
if (cache == NULL || pubkey_hex == NULL || event_id == NULL) {
return 1; /* treat as replay on invalid input */
}
for (i = 0; i < cache->count; ++i) {
if (memcmp(cache->ids[i], id, 32) == 0) {
return 1;
if (strcmp(cache->entries[i].pubkey_hex, pubkey_hex) == 0) {
/* Found existing entry for this pubkey. */
if (created_at > cache->entries[i].max_created_at) {
/* Newer timestamp: update max, clear event ID set, accept. */
cache->entries[i].max_created_at = created_at;
cache->entries[i].event_id_count = 0;
return 0;
}
if (created_at < cache->entries[i].max_created_at) {
/* Older timestamp: reject as replay. */
return 1;
}
/* Same timestamp: check event ID set. */
int j;
for (j = 0; j < cache->entries[i].event_id_count; ++j) {
if (memcmp(cache->entries[i].event_ids[j], event_id, 32) == 0) {
return 1; /* duplicate event ID */
}
}
/* New event ID — add to set if space allows. */
if (cache->entries[i].event_id_count < AUTH_MAX_EVENT_IDS_PER_SECOND) {
memcpy(cache->entries[i].event_ids[cache->entries[i].event_id_count],
event_id, 32);
cache->entries[i].event_id_count++;
}
/* If event ID set is full, accept anyway (unlikely in practice). */
return 0;
}
}
return 0;
}
static void auth_nonce_cache_insert(auth_nonce_cache_t *cache, const uint8_t id[32]) {
if (cache == NULL || id == NULL) {
return;
}
if (cache->count < AUTH_NONCE_CACHE_SIZE) {
memcpy(cache->ids[cache->count], id, 32);
/* New pubkey — add entry if space allows. */
if (cache->count < AUTH_MAX_PUBKEYS) {
strncpy(cache->entries[cache->count].pubkey_hex, pubkey_hex,
sizeof(cache->entries[cache->count].pubkey_hex) - 1);
cache->entries[cache->count].pubkey_hex[
sizeof(cache->entries[cache->count].pubkey_hex) - 1] = '\0';
cache->entries[cache->count].max_created_at = created_at;
cache->entries[cache->count].event_id_count = 0;
cache->count++;
return;
return 0;
}
memcpy(cache->ids[cache->next], id, 32);
cache->next = (cache->next + 1) % AUTH_NONCE_CACHE_SIZE;
/*
* Cache full — fall back to timestamp-only check.
* In practice, AUTH_MAX_PUBKEYS=64 is sufficient for any realistic
* session. This path exists only as a safety net.
*/
(void)i;
return 0;
}
static int json_item_to_compact_string(const cJSON *item, char *out, size_t out_sz) {
@@ -243,7 +291,7 @@ int auth_envelope_verify_request(const char *request_json,
cJSON *tag_body_hash;
char request_id[128];
char body_hash_hex[65];
uint8_t nonce_bytes[32];
uint8_t event_id[32];
time_t now;
long long created;
@@ -362,24 +410,6 @@ int auth_envelope_verify_request(const char *request_json,
"auth_envelope_stale");
}
id_hex_item = cJSON_GetObjectItemCaseSensitive(auth, "id");
if (!cJSON_IsString(id_hex_item) || id_hex_item->valuestring == NULL ||
strlen(id_hex_item->valuestring) != 64 ||
nostr_hex_to_bytes(id_hex_item->valuestring, nonce_bytes, sizeof(nonce_bytes)) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
if (auth_nonce_cache_contains(cache, nonce_bytes)) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_REPLAY_DETECTED,
"auth_replay_detected");
}
auth_nonce_cache_insert(cache, nonce_bytes);
pubkey_item = cJSON_GetObjectItemCaseSensitive(auth, "pubkey");
if (!cJSON_IsString(pubkey_item) || pubkey_item->valuestring == NULL ||
strlen(pubkey_item->valuestring) != 64) {
@@ -389,6 +419,37 @@ int auth_envelope_verify_request(const char *request_json,
"auth_envelope_malformed");
}
/*
* Replay protection: hybrid monotonic-timestamp + event-ID tracking.
*
* Uses per-pubkey monotonic timestamps to reject replays, with an
* event-ID set for the current max second to allow multiple legitimate
* requests within the same wall-clock second (since created_at has
* 1-second granularity).
*
* This replaces the old bounded-FIFO nonce cache which could wrap
* and allow replay after 1024 entries.
*/
id_hex_item = cJSON_GetObjectItemCaseSensitive(auth, "id");
if (!cJSON_IsString(id_hex_item) || id_hex_item->valuestring == NULL ||
strlen(id_hex_item->valuestring) != 64 ||
nostr_hex_to_bytes(id_hex_item->valuestring, event_id, sizeof(event_id)) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
if (auth_nonce_cache_check_and_update(cache,
pubkey_item->valuestring,
created,
event_id) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_REPLAY_DETECTED,
"auth_replay_detected");
}
strncpy(out_pubkey_hex, pubkey_item->valuestring, out_pubkey_hex_sz - 1);
out_pubkey_hex[out_pubkey_hex_sz - 1] = '\0';
+24 -3
View File
@@ -7,7 +7,6 @@
#include <cJSON.h>
#define AUTH_NONCE_CACHE_SIZE 1024
#define AUTH_DEFAULT_SKEW_SECONDS 30
#define AUTH_EVENT_KIND 27235
@@ -20,10 +19,32 @@
#define AUTH_ERR_ENVELOPE_STALE 2016
#define AUTH_ERR_REPLAY_DETECTED 2017
/*
* Replay-protection tracker: per-pubkey monotonic timestamp + event ID.
*
* Uses a hybrid approach:
* 1. Track the highest created_at seen per pubkey (monotonic timestamp).
* 2. For the current max second, also track event IDs to allow multiple
* requests within the same second (since created_at has 1s granularity).
* 3. When created_at > max_seen, clear the event ID set and update max.
*
* This is mathematically replay-proof: no bounded cache that can wrap,
* and same-second requests with distinct event IDs are allowed.
*/
#define AUTH_MAX_PUBKEYS 64
#define AUTH_MAX_EVENT_IDS_PER_SECOND 32
typedef struct {
uint8_t ids[AUTH_NONCE_CACHE_SIZE][32];
char pubkey_hex[65]; /* hex pubkey, NUL-terminated */
time_t max_created_at; /* highest created_at seen for this pubkey */
/* Event IDs seen at max_created_at (to allow same-second requests) */
uint8_t event_ids[AUTH_MAX_EVENT_IDS_PER_SECOND][32];
int event_id_count;
} auth_pubkey_entry_t;
typedef struct {
auth_pubkey_entry_t entries[AUTH_MAX_PUBKEYS];
int count;
int next;
} auth_nonce_cache_t;
void auth_nonce_cache_init(auth_nonce_cache_t *cache);
+21 -10
View File
@@ -74,7 +74,8 @@ static int write_all(int fd, const char *buf, size_t len) {
*/
int http_recv_request(int fd, char **out_body, size_t max_body_size) {
char line[2048];
long content_length = -1;
size_t content_length = 0;
int has_content_length = 0;
int is_post = 0;
if (!out_body) return -1;
@@ -114,35 +115,45 @@ int http_recv_request(int fd, char **out_body, size_t max_body_size) {
}
if (len == 0) break; /* end of headers */
/* Parse Content-Length (case-insensitive). */
/* Parse Content-Length (case-insensitive) using strtoull for
* safe unsigned parsing with error detection. */
if (strncasecmp(line, "Content-Length:", 15) == 0) {
const char *p = line + 15;
while (*p == ' ' || *p == '\t') p++;
content_length = atol(p);
char *endptr = NULL;
unsigned long long cl = strtoull(p, &endptr, 10);
/* Reject if: no digits parsed, trailing non-whitespace, or
* value exceeds SIZE_MAX (can't allocate that much). */
if (endptr == p || (*endptr != '\0' && *endptr != ' ' && *endptr != '\t' && *endptr != '\r') ||
cl > (unsigned long long)SIZE_MAX) {
return -3; /* invalid Content-Length */
}
content_length = (size_t)cl;
has_content_length = 1;
}
}
if (content_length < 0) {
if (!has_content_length) {
return -3; /* missing Content-Length */
}
if ((size_t)content_length > max_body_size) {
if (content_length > max_body_size) {
/* Drain the body so the connection isn't left half-open. */
char tmp[4096];
long remaining = content_length;
size_t remaining = content_length;
while (remaining > 0) {
size_t to_read = (size_t)remaining;
size_t to_read = remaining;
if (to_read > sizeof(tmp)) to_read = sizeof(tmp);
ssize_t r = read(fd, tmp, to_read);
if (r <= 0) break;
remaining -= r;
remaining -= (size_t)r;
}
return -4; /* body too large */
}
/* Read the body. */
char *body = (char *)malloc((size_t)content_length + 1);
char *body = (char *)malloc(content_length + 1);
if (!body) return -1;
if (read_n_bytes(fd, body, (size_t)content_length) != 0) {
if (read_n_bytes(fd, body, content_length) != 0) {
free(body);
return -1;
}
+26 -44
View File
@@ -29,6 +29,9 @@ void secure_buf_free(secure_buf_t *buf);
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
void secure_memzero(void *ptr, size_t len);
/* Allow secure_buf_alloc to succeed even when mlock fails (development only). */
void secure_buf_allow_unlocked(void);
/* from mnemonic.h */
@@ -813,8 +816,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 1
#define NSIGNER_VERSION_PATCH 20
#define NSIGNER_VERSION "v0.1.20"
#define NSIGNER_VERSION_PATCH 27
#define NSIGNER_VERSION "v0.1.27"
/* NSIGNER_HEADERLESS_DECLS_END */
@@ -2206,24 +2209,9 @@ static int prompt_named_path_roles(role_table_t *role_table) {
strncpy(g_wizard_otp_spec, pad_spec, sizeof(g_wizard_otp_spec) - 1);
g_wizard_otp_spec[sizeof(g_wizard_otp_spec) - 1] = '\0';
/* requires_approval flag — OTP roles can still require approval. */
printf(" Require interactive approval for each request? [Y/n]: ");
fflush(stdout);
char approval_choice[16];
if (read_line_stdin(approval_choice, sizeof(approval_choice)) != 0) {
return (roles_created > 0) ? 0 : -1;
}
{
size_t len = strlen(approval_choice);
while (len > 0 && (approval_choice[len-1] == '\n' || approval_choice[len-1] == '\r' ||
approval_choice[len-1] == ' ' || approval_choice[len-1] == '\t')) {
approval_choice[--len] = '\0';
}
}
int requires_approval = 1;
if (approval_choice[0] == 'n' || approval_choice[0] == 'N') {
requires_approval = 0;
}
/* Role-as-password by default: knowing the role name is sufficient
* authorization. No interactive approval prompt. */
int requires_approval = 0;
/* Register the OTP role. No curve/path derivation; the pad is the key. */
role_entry_t new_role;
@@ -2357,24 +2345,9 @@ static int prompt_named_path_roles(role_table_t *role_table) {
continue;
}
/* requires_approval flag */
printf(" Require interactive approval for each request? [Y/n]: ");
fflush(stdout);
char approval_choice[16];
if (read_line_stdin(approval_choice, sizeof(approval_choice)) != 0) {
return (roles_created > 0) ? 0 : -1;
}
{
size_t len = strlen(approval_choice);
while (len > 0 && (approval_choice[len-1] == '\n' || approval_choice[len-1] == '\r' ||
approval_choice[len-1] == ' ' || approval_choice[len-1] == '\t')) {
approval_choice[--len] = '\0';
}
}
int requires_approval = 1; /* default: require approval */
if (approval_choice[0] == 'n' || approval_choice[0] == 'N') {
requires_approval = 0;
}
/* Role-as-password by default: knowing the role name is sufficient
* authorization. No interactive approval prompt. */
int requires_approval = 0;
/* No default index — the client always sends the full path, so keys
* are derived on-demand when a request comes in. */
@@ -3680,6 +3653,11 @@ int main(int argc, char *argv[]) {
argi += 1;
continue;
}
if (strcmp(argv[argi], "--allow-unlocked-memory") == 0) {
secure_buf_allow_unlocked();
argi += 1;
continue;
}
if (strcmp(argv[argi], "--bridge-source-trusted") == 0) {
bridge_source_trusted = 1;
argi += 1;
@@ -3813,7 +3791,7 @@ int main(int argc, char *argv[]) {
role.path_range_lo = -1;
role.path_range_hi = -1;
role.path_default_index = -1;
role.requires_approval = 1;
role.requires_approval = 0; /* role-as-password: knowing the role name is sufficient */
role.derived = 0;
if (role_table_add(&role_table, &role) != 0) {
fprintf(stderr, "Failed to initialize default role\n");
@@ -4345,12 +4323,16 @@ int main(int argc, char *argv[]) {
} else if (lower == 'd') {
/* Display connection instructions on demand.
* Show the connections screen, wait for any key, then
* return to the normal status display. */
* return to the normal status display.
*
* Use tui_get_key() rather than a bare read() so the
* wait survives EINTR (e.g. SIGWINCH); a bare read()
* returns immediately on signal interruption, which
* causes render_status() to run right away and scroll
* the connections view off the screen before the user
* has a chance to read it. */
render_connections(&role_table, &mnemonic, derived_count, socket_name);
{
char dch = '\0';
(void)read(STDIN_FILENO, &dch, 1);
}
(void)tui_get_key();
render_status(&role_table, &mnemonic, derived_count, socket_name);
} else if (lower == 'l') {
mnemonic_source_t relock_source;
+5 -2
View File
@@ -406,8 +406,11 @@ int otp_pad_encrypt(const unsigned char *plaintext, size_t pt_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);
unsigned int byte = 0;
if (sscanf(g_otp_pad.chksum + i * 2, "%02x", &byte) != 1) {
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
return 11;
}
hdr.pad_chksum[i] = (unsigned char)byte;
}
hdr.pad_offset = offset;
+24 -6
View File
@@ -1453,14 +1453,32 @@ int crypto_slh_dsa_128s_sign(const unsigned char *priv, size_t priv_len,
/* SLH-DSA-128s signing uses randombytes() for the opt_rand value.
* With our deterministic DRBG (if seeded), signing is deterministic.
* If the DRBG is not seeded, randombytes() will fail. We seed it
* from the secret key's SK.prf to make signing deterministic. */
* from a domain-separated derivation of SK.prf to make signing
* deterministic while keeping the two uses of SK.prf independent.
*
* Per FIPS 205 Section 10.2, SK.prf is the key to PRF_msg() which
* produces the randomization value R. We must not reuse SK.prf
* directly as a DRBG seed, because if the DRBG output were ever
* compromised, SK.prf would also be compromised, breaking the
* PRF_msg security guarantee.
*
* Instead, we derive a separate DRBG seed:
* drbg_seed = HMAC-SHA256(SK.prf, "slh-dsa-drbg-seed")
* This ensures domain separation between the two uses of SK.prf. */
{
/* Seed the DRBG from SK.prf (bytes 16..31 of the secret key) to
* make signing deterministic. This is not the standard approach
* (which uses a separate RNG), but it ensures deterministic
* signing which is what we need for mnemonic-recoverable keys. */
const unsigned char *sk_prf = priv + SLH_DSA_128S_N;
pq_drbg_init(sk_prf, SLH_DSA_128S_N);
unsigned char drbg_seed[32];
unsigned int hmac_len = 32;
const unsigned char separator[] = "slh-dsa-drbg-seed";
if (HMAC(EVP_sha256(), sk_prf, SLH_DSA_128S_N,
separator, sizeof(separator) - 1,
drbg_seed, &hmac_len) == NULL) {
return -1;
}
pq_drbg_init(drbg_seed, sizeof(drbg_seed));
OPENSSL_cleanse(drbg_seed, sizeof(drbg_seed));
}
if (slh_dsa_128s_crypto_sign(sig_out, &sig_len, msg, msg_len, priv) != 0) {
+18 -5
View File
@@ -6,10 +6,19 @@
*
* The PRNG is simple: 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. This is not a NIST SP 800-90A
* compliant DRBG, but it is deterministic and sufficient for PQ keygen
* (which only needs the output to be uniformly distributed, which SHAKE
* provides).
* increments each time we need more output. Domain separation between
* different algorithm types (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) is not
* needed because the DRBG is initialized once per keygen operation with a
* unique seed and zeroized immediately after the streams never mix.
*
* This is NOT a NIST SP 800-90A compliant DRBG (it has no reseeding mechanism,
* no prediction resistance, and uses a custom construction). However, it is
* sufficient for this use case because:
* 1. The DRBG is initialized once per keygen operation and zeroized after.
* 2. The seed is derived from a BIP-39 mnemonic (256-bit entropy).
* 3. SHAKE-256 is a NIST-standardized XOF with 256-bit preimage resistance.
* 4. The counter domain-separates each output block (no two blocks overlap).
* 5. The output is only used for key generation, never exposed directly.
*
* Security argument: SHAKE-256 is a XOF (extendable output function) based
* on Keccak. Given a 256-bit seed, the output is computationally
@@ -25,7 +34,11 @@
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, but we use 168 for safety */
/* SHAKE-256 rate = 136 bytes (1088 bits). We request 168 bytes per refill
* because XOF output can be any length; 168 is a convenient buffer size
* (matching SHAKE-128's rate of 1344 bits) and reduces the number of refills
* needed for large keygen operations. */
static unsigned char g_buffer[168];
static size_t g_buffer_pos = sizeof(g_buffer);
static int g_initialized = 0;
+28 -1
View File
@@ -22,6 +22,9 @@ typedef struct {
/* Allocate a secure buffer of `size` bytes. Returns 0 on success, -1 on failure. */
int secure_buf_alloc(secure_buf_t *buf, size_t size);
/* Allow secure_buf_alloc to succeed even when mlock fails (development only). */
void secure_buf_allow_unlocked(void);
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
void secure_buf_free(secure_buf_t *buf);
@@ -715,6 +718,7 @@ int socket_name_random(char *out, size_t out_len);
/* NSIGNER_HEADERLESS_DECLS_END */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -744,7 +748,17 @@ void secure_memzero(void *ptr, size_t len) {
/*
* Allocate secure memory and attempt to lock it in RAM.
* Returns 0 on success, -1 on allocation/argument failure.
*
* mlock failure is fatal by default unlocked secrets may be paged to disk.
* Call secure_buf_allow_unlocked() at startup to permit unlocked operation
* (e.g. in containers or development environments).
*/
static int g_secure_buf_allow_unlocked = 0;
void secure_buf_allow_unlocked(void) {
g_secure_buf_allow_unlocked = 1;
}
int secure_buf_alloc(secure_buf_t *buf, size_t size) {
if (buf == NULL || size == 0) {
return -1;
@@ -763,7 +777,20 @@ int secure_buf_alloc(secure_buf_t *buf, size_t size) {
if (mlock(buf->data, buf->size) == 0) {
buf->locked = 1;
} else {
fprintf(stderr, "warning: secure_buf_alloc: mlock failed; continuing unlocked\n");
if (g_secure_buf_allow_unlocked) {
fprintf(stderr, "warning: secure_buf_alloc: mlock failed (%s); "
"continuing unlocked (--allow-unlocked-memory)\n",
strerror(errno));
} else {
fprintf(stderr, "FATAL: secure_buf_alloc: mlock failed (%s).\n"
" Secret material could be paged to disk.\n"
" Use --allow-unlocked-memory to override (not recommended).\n",
strerror(errno));
free(buf->data);
buf->data = NULL;
buf->size = 0;
return -1;
}
}
memset(buf->data, 0, buf->size);
+39 -24
View File
@@ -388,8 +388,10 @@
<!-- Nostr Get Public Key -->
<section class="divPostItem section">
<h2>nostr_get_public_key</h2>
<label for="ngpkIdx">nostr_index</label>
<input id="ngpkIdx" type="number" value="0" min="0" class="inpStyle" />
<label for="ngpkRole">role</label>
<input id="ngpkRole" value="main" class="inpStyle" />
<label for="ngpkPath">role_path</label>
<input id="ngpkPath" value="m/44'/1237'/0'/0/0" class="inpStyle" />
<label for="ngpkFmt">format</label>
<select id="ngpkFmt" class="inpStyle"><option>bare</option><option>structured</option></select>
<div class="row">
@@ -465,8 +467,10 @@
<h2>nostr_sign_event</h2>
<label for="nseContent">content</label>
<textarea id="nseContent" class="inpStyle">hello from usb test</textarea>
<label for="nseIdx">nostr_index</label>
<input id="nseIdx" type="number" value="0" min="0" class="inpStyle" />
<label for="nseRole">role</label>
<input id="nseRole" value="main" class="inpStyle" />
<label for="nsePath">role_path</label>
<input id="nsePath" value="m/44'/1237'/0'/0/0" class="inpStyle" />
<div class="row">
<button id="nseBtn" class="btn" disabled>nostr_sign_event</button>
</div>
@@ -479,8 +483,10 @@
<p class="warn">Slow on ESP32 — uses single-threaded PoW. Keep difficulty low.</p>
<label for="nmeContent">content</label>
<textarea id="nmeContent" class="inpStyle">mined by usb test</textarea>
<label for="nmeIdx">nostr_index</label>
<input id="nmeIdx" type="number" value="0" min="0" class="inpStyle" />
<label for="nmeRole">role</label>
<input id="nmeRole" value="main" class="inpStyle" />
<label for="nmePath">role_path</label>
<input id="nmePath" value="m/44'/1237'/0'/0/0" class="inpStyle" />
<label for="nmeDiff">difficulty (leading zero bits)</label>
<input id="nmeDiff" type="number" value="4" min="1" max="16" class="inpStyle" />
<label for="nmeTimeout">timeout (sec)</label>
@@ -500,8 +506,10 @@
<textarea id="nip04Msg" class="inpStyle">hello via nip04</textarea>
<label for="nip04Cipher">ciphertext (for decrypt)</label>
<textarea id="nip04Cipher" class="inpStyle" placeholder="ciphertext?iv=..."></textarea>
<label for="nip04Idx">nostr_index</label>
<input id="nip04Idx" type="number" value="0" min="0" class="inpStyle" />
<label for="nip04Role">role</label>
<input id="nip04Role" value="main" class="inpStyle" />
<label for="nip04Path">role_path</label>
<input id="nip04Path" value="m/44'/1237'/0'/0/0" class="inpStyle" />
<div class="row">
<button id="nip04EncBtn" class="btn" disabled>encrypt</button>
<button id="nip04DecBtn" class="btn" disabled>decrypt</button>
@@ -518,8 +526,10 @@
<textarea id="nip44Msg" class="inpStyle">hello via nip44</textarea>
<label for="nip44Cipher">ciphertext (for decrypt)</label>
<textarea id="nip44Cipher" class="inpStyle" placeholder="base64 payload"></textarea>
<label for="nip44Idx">nostr_index</label>
<input id="nip44Idx" type="number" value="0" min="0" class="inpStyle" />
<label for="nip44Role">role</label>
<input id="nip44Role" value="main" class="inpStyle" />
<label for="nip44Path">role_path</label>
<input id="nip44Path" value="m/44'/1237'/0'/0/0" class="inpStyle" />
<div class="row">
<button id="nip44EncBtn" class="btn" disabled>encrypt</button>
<button id="nip44DecBtn" class="btn" disabled>decrypt</button>
@@ -788,8 +798,9 @@
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 };
const role = $("ngpkRole").value.trim(), path = $("ngpkPath").value.trim();
const fmt = $("ngpkFmt").value;
const opts = { role, role_path: path };
if (fmt === "structured") opts.format = "structured";
callVerb("nostr_get_public_key", [opts], $("ngpkOut"));
});
@@ -846,24 +857,25 @@
$("nseBtn").addEventListener("click", () => {
const content = $("nseContent").value;
const idx = Number($("nseIdx").value || 0);
const role = $("nseRole").value.trim(), path = $("nsePath").value.trim();
const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content };
callVerb("nostr_sign_event", [event, { nostr_index: idx }], $("nseOut"));
callVerb("nostr_sign_event", [event, { role, role_path: path }], $("nseOut"));
});
$("nmeBtn").addEventListener("click", () => {
const content = $("nmeContent").value;
const idx = Number($("nmeIdx").value || 0);
const role = $("nmeRole").value.trim(), path = $("nmePath").value.trim();
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"));
callVerb("nostr_mine_event", [event, { role, role_path: path, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
});
const nip04Enc = async () => {
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value, idx = Number($("nip04Idx").value || 0);
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value;
const role = $("nip04Role").value.trim(), path = $("nip04Path").value.trim();
if (!peer) { $("nip04Out").textContent = "✗ enter peer pubkey"; return; }
const params = [peer, msg, { nostr_index: idx }];
const params = [peer, msg, { role, role_path: path }];
$("nip04Out").textContent = "→ nostr_nip04_encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("nostr_nip04_encrypt", params);
@@ -877,17 +889,19 @@
}
};
const nip04Dec = () => {
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0);
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value;
const role = $("nip04Role").value.trim(), path = $("nip04Path").value.trim();
if (!peer || !ct) { $("nip04Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
callVerb("nostr_nip04_decrypt", [peer, ct, { nostr_index: idx }], $("nip04Out"));
callVerb("nostr_nip04_decrypt", [peer, ct, { role, role_path: path }], $("nip04Out"));
};
$("nip04EncBtn").addEventListener("click", nip04Enc);
$("nip04DecBtn").addEventListener("click", nip04Dec);
const nip44Enc = async () => {
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value, idx = Number($("nip44Idx").value || 0);
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value;
const role = $("nip44Role").value.trim(), path = $("nip44Path").value.trim();
if (!peer) { $("nip44Out").textContent = "✗ enter peer pubkey"; return; }
const params = [peer, msg, { nostr_index: idx }];
const params = [peer, msg, { role, role_path: path }];
$("nip44Out").textContent = "→ nostr_nip44_encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("nostr_nip44_encrypt", params);
@@ -901,9 +915,10 @@
}
};
const nip44Dec = () => {
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0);
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value;
const role = $("nip44Role").value.trim(), path = $("nip44Path").value.trim();
if (!peer || !ct) { $("nip44Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
callVerb("nostr_nip44_decrypt", [peer, ct, { nostr_index: idx }], $("nip44Out"));
callVerb("nostr_nip44_decrypt", [peer, ct, { role, role_path: path }], $("nip44Out"));
};
$("nip44EncBtn").addEventListener("click", nip44Enc);
$("nip44DecBtn").addEventListener("click", nip44Dec);