Compare commits

...
5 Commits
25 changed files with 2750 additions and 151 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/
+26
View File
@@ -93,12 +93,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)
+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"
+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.
+18 -44
View File
@@ -813,8 +813,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 25
#define NSIGNER_VERSION "v0.1.25"
/* NSIGNER_HEADERLESS_DECLS_END */
@@ -2206,24 +2206,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 +2342,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. */
@@ -3813,7 +3783,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 +4315,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;
+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);