Compare commits

...
5 Commits
8 changed files with 730 additions and 60 deletions
+1 -1
View File
@@ -500,7 +500,7 @@ The wizard presents a **preset menu** of 10 options covering the common role typ
```
Wizard preset menu:
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
2. Standard Nostr hardened range: secp256k1, m/44'/1237'/*'/0'/0'
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
+8
View File
@@ -126,6 +126,10 @@ if [ "$ARCH" != "$HOST_ARCH" ]; then
fi
echo "[1/3] Building builder stage from project root context"
# Remove previous builder image to avoid dangling <none> images piling up
# across repeated builds (each rebuild untagges the old image, leaving ~422MB
# of garbage per build otherwise).
docker rmi "$IMAGE_TAG" >/dev/null 2>&1 || true
docker buildx build \
--platform "$PLATFORM" \
--target builder \
@@ -168,3 +172,7 @@ echo ""
echo "Build complete:"
echo " $BUILD_DIR/$OUTPUT_NAME"
echo " $BUILD_DIR/$CLIENT_NAME"
# Prune stale build cache older than 24h to prevent unbounded cache growth
# from repeated buildx builds. Recent layers are kept for fast rebuilds.
docker builder prune -af --filter "until=24h" >/dev/null 2>&1 || true
+2 -2
View File
@@ -487,7 +487,7 @@ int main(int argc, char **argv) {
} else if (socket_name) {
transport = nsigner_transport_open_unix(socket_name, timeout_ms);
if (!transport) {
fprintf(stderr, "error: cannot open unix transport @%s\n", socket_name);
fprintf(stderr, "error: cannot open unix transport %s\n", socket_name);
goto cleanup;
}
} else {
@@ -508,7 +508,7 @@ int main(int argc, char **argv) {
socket_name = names[0];
transport = nsigner_transport_open_unix(socket_name, timeout_ms);
if (!transport) {
fprintf(stderr, "error: cannot open unix transport @%s\n", socket_name);
fprintf(stderr, "error: cannot open unix transport %s\n", socket_name);
goto cleanup;
}
}
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
#
# deploy_local.sh — Build static nsigner + nsigner_client binaries
# and install them to /usr/local/bin/
#
# Usage:
# ./deploy_local.sh # build + install (prompts for sudo)
# ./deploy_local.sh --no-build # install existing build/ binaries only
# ./deploy_local.sh --force # skip confirmation prompt
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
INSTALL_PREFIX="/usr/local/bin"
HOST_UNAME="$(uname -m)"
case "$HOST_UNAME" in
x86_64) ARCH="x86_64" ;;
aarch64|arm64) ARCH="arm64" ;;
armv7l|armv7) ARCH="armv7" ;;
*)
echo "ERROR: Unsupported host architecture '$HOST_UNAME'"
exit 1
;;
esac
case "$ARCH" in
x86_64)
SIGNER_BIN="$BUILD_DIR/nsigner_static_x86_64"
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_x86_64"
;;
arm64)
SIGNER_BIN="$BUILD_DIR/nsigner_static_arm64"
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_arm64"
;;
armv7)
SIGNER_BIN="$BUILD_DIR/nsigner_static_armv7"
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_armv7"
;;
esac
DO_BUILD=true
FORCE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--no-build)
DO_BUILD=false
shift
;;
--force|-f)
FORCE=true
shift
;;
-h|--help)
echo "deploy_local.sh — Build and install nsigner + nsigner_client to $INSTALL_PREFIX"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "OPTIONS:"
echo " --no-build Skip build step; install existing binaries from build/"
echo " --force, -f Skip confirmation prompt"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "ERROR: Unknown argument '$1'"
echo "Usage: $0 [--no-build] [--force]"
exit 1
;;
esac
done
echo "=========================================="
echo "nsigner local deploy"
echo "=========================================="
echo "Architecture: $ARCH"
echo "Install dir: $INSTALL_PREFIX"
echo "Signer binary: $SIGNER_BIN"
echo "Client binary: $CLIENT_BIN"
echo ""
# --- Build step ---------------------------------------------------------------
if $DO_BUILD; then
echo "[1/3] Building static binaries via build_static.sh"
echo ""
bash "$SCRIPT_DIR/build_static.sh" --arch "$ARCH"
echo ""
else
echo "[1/3] Skipping build (--no-build)"
fi
# --- Verify binaries exist ----------------------------------------------------
echo "[2/3] Verifying binaries"
if [[ ! -f "$SIGNER_BIN" ]]; then
echo "ERROR: Signer binary not found: $SIGNER_BIN"
echo " Run without --no-build, or run build_static.sh first."
exit 1
fi
if [[ ! -x "$SIGNER_BIN" ]]; then
echo "ERROR: Signer binary is not executable: $SIGNER_BIN"
exit 1
fi
if [[ ! -f "$CLIENT_BIN" ]]; then
echo "ERROR: Client binary not found: $CLIENT_BIN"
echo " Run without --no-build, or run build_static.sh first."
exit 1
fi
if [[ ! -x "$CLIENT_BIN" ]]; then
echo "ERROR: Client binary is not executable: $CLIENT_BIN"
exit 1
fi
echo " OK: $SIGNER_BIN ($(du -h "$SIGNER_BIN" | cut -f1))"
echo " OK: $CLIENT_BIN ($(du -h "$CLIENT_BIN" | cut -f1))"
# Quick smoke test
SIGNER_VERSION="$("$SIGNER_BIN" --version 2>&1 || echo "unknown")"
echo " Signer version: $SIGNER_VERSION"
# --- Confirm ------------------------------------------------------------------
if ! $FORCE; then
echo ""
echo "About to install:"
echo " $SIGNER_BIN -> $INSTALL_PREFIX/nsigner"
echo " $CLIENT_BIN -> $INSTALL_PREFIX/nsigner_client"
echo ""
read -r -p "Proceed? [y/N] " response
case "$response" in
[yY][eE][sS]|[yY]) ;;
*)
echo "Aborted."
exit 0
;;
esac
fi
# --- Install ------------------------------------------------------------------
echo ""
echo "[3/3] Installing to $INSTALL_PREFIX"
SUDO=""
if [[ $EUID -ne 0 ]]; then
if ! command -v sudo >/dev/null 2>&1; then
echo "ERROR: Need root privileges to write to $INSTALL_PREFIX but sudo is not available"
exit 1
fi
SUDO="sudo"
fi
$SUDO install -m 0755 "$SIGNER_BIN" "$INSTALL_PREFIX/nsigner"
$SUDO install -m 0755 "$CLIENT_BIN" "$INSTALL_PREFIX/nsigner_client"
echo ""
echo "=========================================="
echo "Deploy complete!"
echo "=========================================="
echo " $INSTALL_PREFIX/nsigner"
echo " $INSTALL_PREFIX/nsigner_client"
echo ""
echo "Verify:"
echo " nsigner --version"
echo " nsigner_client --help"
+2 -2
View File
@@ -53,7 +53,7 @@ Default is E; you can also paste full mnemonic here.
```
Define a role:
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
2. Standard Nostr hardened range: secp256k1, m/44'/1237'/*'/0'/0'
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
@@ -70,7 +70,7 @@ Define a role:
| Choice | Default name | Default path | Curve | Purpose |
|--------|-------------|-------------|-------|---------|
| 1 | `main` | `m/44'/1237'/0'/0/0` | secp256k1 | nostr |
| 2 | `nostr_hardened` | `m/44'/1237'/*'/0'/0'` | secp256k1 | nostr |
| 2 | `nostr_range` | `m/44'/1237'/*'/0/0` | secp256k1 | nostr |
| 3 | `nostr_agent` | `m/44'/1237'/*'/1'/0'` | secp256k1 | nostr |
| 4 | `ssh` | `m/44'/102001'/0'/0'/0'` | ed25519 | ssh |
| 5 | `age` | `m/44'/102002'/0'/0'/0'` | x25519 | age |
+242 -48
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 15
#define NSIGNER_VERSION "v0.1.15"
#define NSIGNER_VERSION_PATCH 20
#define NSIGNER_VERSION "v0.1.20"
/* NSIGNER_HEADERLESS_DECLS_END */
@@ -989,11 +989,17 @@ static int read_line_stdin(char *buf, size_t buf_sz) {
*
* Only works when stdin is a TTY. Falls back to read_line_stdin if not a TTY
* (in which case prefill is ignored).
*
* `prompt` is the label text printed before the editable field (e.g.
* " Role name [main]: "). It is reprinted on every redraw so that the
* prompt does not disappear when the user starts editing.
*/
static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
static int read_line_editable(char *buf, size_t buf_sz, const char *prefill,
const char *prompt) {
struct termios old_term, new_term;
size_t len = 0; /* current text length */
size_t pos = 0; /* cursor position (0..len) */
size_t prompt_len = 0;/* length of prompt string (for cursor math) */
int fd = STDIN_FILENO;
int was_raw = 0;
@@ -1001,6 +1007,10 @@ static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
return -1;
}
if (prompt != NULL) {
prompt_len = strlen(prompt);
}
/* If not a TTY, fall back to plain fgets */
if (!isatty(fd)) {
return read_line_stdin(buf, buf_sz);
@@ -1028,11 +1038,14 @@ static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
}
}
/* Draw the initial pre-filled text */
/* Draw the initial prompt + pre-filled text */
if (prompt != NULL) {
fputs(prompt, stdout);
}
if (len > 0) {
fputs(buf, stdout);
fflush(stdout);
}
fflush(stdout);
for (;;) {
char ch;
@@ -1060,6 +1073,7 @@ static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
buf[len] = '\0';
/* Redraw: move to start of field, clear line, redraw, reposition */
fputs("\r\033[K", stdout); /* CR + clear to end of line */
if (prompt != NULL) fputs(prompt, stdout);
fputs(buf, stdout);
if (pos < len) {
/* Move cursor left to pos */
@@ -1115,6 +1129,7 @@ static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
len--;
buf[len] = '\0';
fputs("\r\033[K", stdout);
if (prompt != NULL) fputs(prompt, stdout);
fputs(buf, stdout);
if (pos < len) {
printf("\033[%zuD", len - pos);
@@ -1141,6 +1156,7 @@ static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
/* Ctrl-U — clear entire line */
if (pos > 0) {
fputs("\r\033[K", stdout);
if (prompt != NULL) fputs(prompt, stdout);
len = 0;
pos = 0;
buf[0] = '\0';
@@ -1159,6 +1175,7 @@ static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
buf[len] = '\0';
/* Redraw from cursor position */
fputs("\r\033[K", stdout);
if (prompt != NULL) fputs(prompt, stdout);
fputs(buf, stdout);
pos++;
if (pos < len) {
@@ -1332,7 +1349,8 @@ static void print_usage(const char *program_name) {
tui_print("nsigner - single-binary signer program");
tui_print("Usage:");
tui_print(" %s [--socket-name|--name|-n <name>] [--listen|-l <unix|stdio|qrexec|tcp:HOST:PORT>]", program_name);
tui_print(" [--preapprove|-p <SPEC>]... [--auth|-a <off|optional|required>]");
tui_print(" [--preapprove|-p <SPEC>]... [--register-role <SPEC>]...");
tui_print(" [--auth|-a <off|optional|required>]");
tui_print(" [--mnemonic-stdin|--mnemonic-fd <N>] [--allow-all|-A]");
tui_print(" [--bridge-source-trusted]");
tui_print(" Run signer server (unix mode has TUI)");
@@ -1348,6 +1366,11 @@ static void print_usage(const char *program_name) {
tui_print(" --preapprove, -p SPEC");
tui_print(" Pre-approve a caller for a role (repeatable)");
tui_print(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>");
tui_print(" --register-role SPEC");
tui_print(" Register a named path-role non-interactively (repeatable)");
tui_print(" SPEC: <name>:<curve>:<path-template>");
tui_print(" e.g. nostr_range:secp256k1:m/44'/1237'/*'/0/0");
tui_print(" Suppresses the default 'main' role in non-TTY mode");
tui_print(" --auth, -a MODE Auth envelope policy per listener: off|optional|required");
tui_print(" --mnemonic-stdin Read mnemonic from stdin (one line) at startup");
tui_print(" --mnemonic-fd N Read mnemonic from inherited fd N (one line) at startup");
@@ -1417,9 +1440,9 @@ static int list_sockets_main(void) {
}
while (fgets(line, sizeof(line), fp) != NULL) {
char name_with_at[SERVER_SOCKET_NAME_MAX + 1];
if (extract_nsigner_socket_from_proc_line(line, name_with_at, sizeof(name_with_at), NULL, 0) == 0) {
printf("%s\n", name_with_at);
char name_no_at[SERVER_SOCKET_NAME_MAX + 1];
if (extract_nsigner_socket_from_proc_line(line, NULL, 0, name_no_at, sizeof(name_no_at)) == 0) {
printf("%s\n", name_no_at);
found = 1;
}
}
@@ -1504,7 +1527,7 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
fd = connect_abstract_socket(socket_name);
if (fd < 0) {
fprintf(stderr, "Failed to connect to @%s: %s\n", socket_name, strerror(errno));
fprintf(stderr, "Failed to connect to %s: %s\n", socket_name, strerror(errno));
return 1;
}
@@ -1597,7 +1620,7 @@ static int bridge_main(int argc, char *argv[], const char *socket_name, int sock
/* Connect to the persistent signer's abstract unix socket */
fd = connect_abstract_socket(target_socket);
if (fd < 0) {
fprintf(stderr, "bridge: cannot connect to @%s: %s\n", target_socket, strerror(errno));
fprintf(stderr, "bridge: cannot connect to %s: %s\n", target_socket, strerror(errno));
return 1;
}
@@ -2014,7 +2037,7 @@ static int prompt_named_path_roles(role_table_t *role_table) {
tui_render_content_screen(NULL, "Define a role — bind a role name to a derivation path template");
printf("Define a role:\n");
printf(" 1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0\n");
printf(" 2. Standard Nostr hardened range: secp256k1, m/44'/1237'/*'/0'/0'\n");
printf(" 2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0\n");
printf(" 3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'\n");
printf(" 4. SSH role: ed25519, m/44'/102001'/0'/0'/0'\n");
printf(" 5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'\n");
@@ -2050,8 +2073,8 @@ static int prompt_named_path_roles(role_table_t *role_table) {
switch (choice) {
case 2:
default_name = "nostr_hardened";
default_path = "m/44'/1237'/*'/0'/0'";
default_name = "nostr_range";
default_path = "m/44'/1237'/*'/0/0";
break;
case 3:
default_name = "nostr_agent";
@@ -2107,10 +2130,12 @@ static int prompt_named_path_roles(role_table_t *role_table) {
/* Role name */
char role_name[ROLE_NAME_MAX];
printf(" Role name [%s]: ", default_name);
fflush(stdout);
if (read_line_editable(role_name, sizeof(role_name), default_name) != 0) {
return (roles_created > 0) ? 0 : -1;
{
char prompt_buf[128];
snprintf(prompt_buf, sizeof(prompt_buf), " Role name [%s]: ", default_name);
if (read_line_editable(role_name, sizeof(role_name), default_name, prompt_buf) != 0) {
return (roles_created > 0) ? 0 : -1;
}
}
{
size_t len = strlen(role_name);
@@ -2288,10 +2313,12 @@ static int prompt_named_path_roles(role_table_t *role_table) {
/* Path template — only prompt for custom path; presets use the default */
char path_token[ROLE_PATH_MAX];
if (choice == 10) {
printf(" Path template [%s]:\n ", default_path);
fflush(stdout);
if (read_line_editable(path_token, sizeof(path_token), default_path) != 0) {
return (roles_created > 0) ? 0 : -1;
{
char prompt_buf[256];
snprintf(prompt_buf, sizeof(prompt_buf), " Path template [%s]: ", default_path);
if (read_line_editable(path_token, sizeof(path_token), default_path, prompt_buf) != 0) {
return (roles_created > 0) ? 0 : -1;
}
}
{
size_t len = strlen(path_token);
@@ -2646,6 +2673,145 @@ static role_purpose_t purpose_from_path(const char *path) {
return PURPOSE_NOSTR; /* default */
}
/*
* Parse a --register-role spec of the form:
* <name>:<curve>:<path-template>
* and register it into the role table. The curve field may be empty
* (e.g. "name::path") in which case the curve is auto-detected from the
* path prefix via purpose_from_path + the default curve for that purpose.
*
* The path-template uses the same syntax as the TUI wizard:
* - Wildcard: * or *' (any non-negative integer)
* - Range: 0-99 or 0-99' (inclusive range)
* - Set: 1+3+5 or 1+3-5+10
* - Fixed: a literal path with no variable segment
*
* Returns 0 on success, -1 on parse error.
*/
static int register_role_from_spec(role_table_t *role_table, const char *spec) {
char buf[512];
char *name, *curve_str, *path_token;
role_curve_t curve;
role_purpose_t purpose;
char template[ROLE_PATH_MAX];
int range_lo, range_hi;
int allowed_indices[64];
int allowed_count = 0;
role_entry_t new_role;
if (role_table == NULL || spec == NULL) {
return -1;
}
strncpy(buf, spec, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
/* Split on ':' — name:curve:path-template */
name = buf;
curve_str = strchr(buf, ':');
if (curve_str == NULL) {
fprintf(stderr, "--register-role: missing ':' separator in '%s'\n", spec);
return -1;
}
*curve_str = '\0';
curve_str++;
path_token = strchr(curve_str, ':');
if (path_token == NULL) {
fprintf(stderr, "--register-role: missing path template in '%s' (expected name:curve:path)\n", spec);
return -1;
}
*path_token = '\0';
path_token++;
if (name[0] == '\0') {
fprintf(stderr, "--register-role: empty role name in '%s'\n", spec);
return -1;
}
if (path_token[0] == '\0') {
fprintf(stderr, "--register-role: empty path template in '%s'\n", spec);
return -1;
}
/* Resolve curve: empty string → auto-detect from path */
if (curve_str[0] == '\0') {
purpose = purpose_from_path(path_token);
/* Default curve per purpose */
switch (purpose) {
case PURPOSE_SSH: curve = CURVE_ED25519; break;
case PURPOSE_AGE: curve = CURVE_X25519; break;
case PURPOSE_PQ_SIG:
/* 102003 → ml-dsa-65, 102004 → slh-dsa-128s */
if (strncmp(path_token, "m/44'/102004'", 13) == 0) {
curve = CURVE_SLH_DSA_128S;
} else {
curve = CURVE_ML_DSA_65;
}
break;
case PURPOSE_PQ_KEM: curve = CURVE_ML_KEM_768; break;
default: curve = CURVE_SECP256K1; break;
}
} else {
curve = role_curve_from_str(curve_str);
if (curve == CURVE_UNKNOWN) {
fprintf(stderr, "--register-role: unknown curve '%s' in '%s'\n", curve_str, spec);
return -1;
}
purpose = purpose_from_path(path_token);
}
/* Parse the path template (wildcard/range/set → %d) */
if (parse_path_template_for_role(path_token, template, sizeof(template),
&range_lo, &range_hi,
allowed_indices, 64, &allowed_count) != 0) {
fprintf(stderr, "--register-role: invalid path template '%s'\n", path_token);
return -1;
}
/* Validate purpose+curve combination */
if (crypto_alg_from_role(curve, purpose) == CRYPTO_ALG_UNKNOWN) {
fprintf(stderr, "--register-role: curve '%s' is not valid for path '%s'\n",
role_curve_to_str(curve), path_token);
return -1;
}
/* Register the role (no approval required in non-interactive mode) */
memset(&new_role, 0, sizeof(new_role));
strncpy(new_role.name, name, sizeof(new_role.name) - 1);
new_role.name[sizeof(new_role.name) - 1] = '\0';
strncpy(new_role.purpose_str, role_purpose_to_str(purpose), sizeof(new_role.purpose_str) - 1);
new_role.purpose_str[sizeof(new_role.purpose_str) - 1] = '\0';
strncpy(new_role.curve_str, role_curve_to_str(curve), sizeof(new_role.curve_str) - 1);
new_role.curve_str[sizeof(new_role.curve_str) - 1] = '\0';
new_role.purpose = purpose;
new_role.curve = curve;
new_role.selector_type = SELECTOR_ROLE_PATH;
strncpy(new_role.role_path, template, sizeof(new_role.role_path) - 1);
new_role.role_path[sizeof(new_role.role_path) - 1] = '\0';
new_role.nostr_index = -1;
new_role.path_range_lo = range_lo;
new_role.path_range_hi = range_hi;
new_role.path_default_index = -1;
new_role.requires_approval = 0; /* non-interactive: no TUI approval */
if (allowed_count > 0) {
int copy_n = allowed_count;
if (copy_n > (int)(sizeof(new_role.path_allowed_indices) / sizeof(new_role.path_allowed_indices[0]))) {
copy_n = (int)(sizeof(new_role.path_allowed_indices) / sizeof(new_role.path_allowed_indices[0]));
}
memcpy(new_role.path_allowed_indices, allowed_indices, (size_t)copy_n * sizeof(int));
new_role.path_allowed_count = copy_n;
} else {
new_role.path_allowed_count = 0;
}
new_role.derived = 0;
if (role_table_add(role_table, &new_role) != 0) {
fprintf(stderr, "--register-role: failed to register role '%s' (table full or duplicate name)\n", name);
return -1;
}
return 0;
}
static int prompt_load_mnemonic_tui(mnemonic_state_t *mnemonic) {
char phrase[MNEMONIC_MAX_LEN];
@@ -3384,6 +3550,8 @@ int main(int argc, char *argv[]) {
int argi = 1;
const char *preapprove_specs[POLICY_MAX_ENTRIES];
int preapprove_count = 0;
const char *register_role_specs[ROLE_TABLE_MAX_ENTRIES];
int register_role_count = 0;
int auth_mode = NSIGNER_AUTH_OFF;
int auth_skew_seconds = AUTH_DEFAULT_SKEW_SECONDS;
int allow_all = 0;
@@ -3462,6 +3630,19 @@ int main(int argc, char *argv[]) {
argi += 2;
continue;
}
if (strcmp(argv[argi], "--register-role") == 0) {
if (argi + 1 >= argc) {
fprintf(stderr, "Missing value for %s\n", argv[argi]);
return 1;
}
if (register_role_count >= ROLE_TABLE_MAX_ENTRIES) {
fprintf(stderr, "Too many --register-role entries (max %d)\n", ROLE_TABLE_MAX_ENTRIES);
return 1;
}
register_role_specs[register_role_count++] = argv[argi + 1];
argi += 2;
continue;
}
if (strcmp(argv[argi], "--mnemonic-stdin") == 0) {
if (mnemonic_source.kind != MNEMONIC_SOURCE_TUI) {
fprintf(stderr, "nsigner: --mnemonic-stdin and --mnemonic-fd are mutually exclusive\n");
@@ -3605,27 +3786,40 @@ int main(int argc, char *argv[]) {
return 1;
}
} else {
/* Non-interactive mode: create a default "main" role */
role_entry_t role;
memset(&role, 0, sizeof(role));
strncpy(role.name, "main", sizeof(role.name) - 1);
strncpy(role.purpose_str, "nostr", sizeof(role.purpose_str) - 1);
strncpy(role.curve_str, "secp256k1", sizeof(role.curve_str) - 1);
role.purpose = role_purpose_from_str(role.purpose_str);
role.curve = role_curve_from_str(role.curve_str);
role.selector_type = SELECTOR_ROLE_PATH;
strncpy(role.role_path, "m/44'/1237'/0'/0/0", sizeof(role.role_path) - 1);
role.role_path[sizeof(role.role_path) - 1] = '\0';
role.nostr_index = -1;
role.path_range_lo = -1;
role.path_range_hi = -1;
role.path_default_index = -1;
role.requires_approval = 1;
role.derived = 0;
if (role_table_add(&role_table, &role) != 0) {
fprintf(stderr, "Failed to initialize default role\n");
mnemonic_unload(&mnemonic);
return 1;
/* Non-interactive mode. If --register-role specs were provided,
* register those roles (suppressing the default "main" role).
* Otherwise, create a default "main" role with the standard
* NIP-06 path. */
if (register_role_count > 0) {
for (int i = 0; i < register_role_count; ++i) {
if (register_role_from_spec(&role_table, register_role_specs[i]) != 0) {
fprintf(stderr, "Failed to register role from --register-role spec\n");
mnemonic_unload(&mnemonic);
return 1;
}
}
} else {
role_entry_t role;
memset(&role, 0, sizeof(role));
strncpy(role.name, "main", sizeof(role.name) - 1);
strncpy(role.purpose_str, "nostr", sizeof(role.purpose_str) - 1);
strncpy(role.curve_str, "secp256k1", sizeof(role.curve_str) - 1);
role.purpose = role_purpose_from_str(role.purpose_str);
role.curve = role_curve_from_str(role.curve_str);
role.selector_type = SELECTOR_ROLE_PATH;
strncpy(role.role_path, "m/44'/1237'/0'/0/0", sizeof(role.role_path) - 1);
role.role_path[sizeof(role.role_path) - 1] = '\0';
role.nostr_index = -1;
role.path_range_lo = -1;
role.path_range_hi = -1;
role.path_default_index = -1;
role.requires_approval = 1;
role.derived = 0;
if (role_table_add(&role_table, &role) != 0) {
fprintf(stderr, "Failed to initialize default role\n");
mnemonic_unload(&mnemonic);
return 1;
}
}
}
@@ -3812,7 +4006,7 @@ int main(int argc, char *argv[]) {
}
if (server_start(&server) != 0) {
if (listen_mode == NSIGNER_LISTEN_UNIX) {
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
fprintf(stderr, "Failed to start server on %s: %s\n", socket_name, server_last_error(&server));
} else if (listen_mode == NSIGNER_LISTEN_TCP ||
listen_mode == NSIGNER_LISTEN_HTTP) {
fprintf(stderr, "Failed to start server on %s: %s\n", listen_target, server_last_error(&server));
@@ -3916,7 +4110,7 @@ int main(int argc, char *argv[]) {
char unix_conn[128];
char unix_example[256];
char unix_extra[256] = "";
snprintf(unix_conn, sizeof(unix_conn), "@%s", socket_name);
snprintf(unix_conn, sizeof(unix_conn), "%s", socket_name);
snprintf(unix_example, sizeof(unix_example),
"nsigner --socket-name %s client '<json>'", socket_name);
if (transport_mask & TRANSPORT_QREXEC_BRIDGE) {
@@ -3927,7 +4121,7 @@ int main(int argc, char *argv[]) {
printf("qrexec service: %s\n", NSIGNER_QREXEC_SERVICE_NAME);
}
connection_info_add_transport("Unix socket", unix_conn, unix_example, unix_extra);
printf("System is ready and waiting for connections on @%s.\n", socket_name);
printf("System is ready and waiting for connections on %s.\n", socket_name);
}
if (transport_mask & TRANSPORT_TCP) {
const char *tcp_addr = servers[tcp_server_idx].socket_name;
@@ -3967,7 +4161,7 @@ int main(int argc, char *argv[]) {
char unix_conn[128];
char unix_example[256];
char unix_extra[256] = "";
snprintf(unix_conn, sizeof(unix_conn), "@%s", socket_name);
snprintf(unix_conn, sizeof(unix_conn), "%s", socket_name);
snprintf(unix_example, sizeof(unix_example),
"nsigner --socket-name %s client '<json>'", socket_name);
if (bridge_source_trusted) {
@@ -3978,7 +4172,7 @@ int main(int argc, char *argv[]) {
printf("qrexec service: %s\n", NSIGNER_QREXEC_SERVICE_NAME);
}
connection_info_add_transport("Unix socket", unix_conn, unix_example, unix_extra);
printf("System is ready and waiting for connections on @%s.\n", socket_name);
printf("System is ready and waiting for connections on %s.\n", socket_name);
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
const char *actual_addr = server.socket_name;
char fips_conn[256] = "";
+14 -7
View File
@@ -2000,7 +2000,7 @@ int server_start(server_ctx_t *ctx) {
}
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(@%s) failed: %s (and failed to generate retry socket name)",
"bind(%s) failed: %s (and failed to generate retry socket name)",
ctx->socket_name,
strerror(errno));
close(fd);
@@ -2010,13 +2010,13 @@ int server_start(server_ctx_t *ctx) {
if (errno == EADDRINUSE && ctx->socket_name_explicit) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(@%s) failed: %s (explicit --socket-name is already in use)",
"bind(%s) failed: %s (explicit --socket-name is already in use)",
ctx->socket_name,
strerror(errno));
} else {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(@%s) failed: %s",
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
}
@@ -2443,12 +2443,19 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
if (selector_req.has_role_path) {
/* Case (a): client supplied a concrete path that was already
* validated by selector_resolve. Use it directly. */
* validated by selector_resolve. Use it directly.
*
* Always re-derive for template roles: the client may request
* a different concrete path (e.g. a different account index)
* than the one previously derived and cached on the role.
* The derivation block below swaps in the concrete path,
* clears derived/pubkey, re-derives, and restores the
* template — so forcing pending_derivation here is safe and
* correct. Without this, a second request with a different
* path would return the stale cached pubkey from the first. */
snprintf(concrete_path, sizeof(concrete_path),
"%s", selector_req.role_path);
if (!role->derived) {
pending_derivation = 1;
}
pending_derivation = 1;
} else {
/* Case (b): role only — resolve index from --index or default */
int chosen_index;
+297
View File
@@ -206,8 +206,55 @@ stop_server() {
fi
}
# Second server with --register-role (template roles). Uses a separate socket.
REGROLE_SOCKET_NAME=""
REGROLE_SERVER_PID=""
start_server_regrole() {
REGROLE_SOCKET_NAME="nsigner_test_regrole_$$"
echo "Starting nsigner server with --register-role (socket: @$REGROLE_SOCKET_NAME)..."
export NSIGNER_TEST_NONINTERACTIVE_PROMPT=allow
echo "$MNEMONIC" | "$SERVER" \
--socket-name "$REGROLE_SOCKET_NAME" \
--allow-all \
--listen unix \
--mnemonic-stdin \
--register-role "nostr_range:secp256k1:m/44'/1237'/*'/0/0" \
--register-role "ssh_range:ed25519:m/44'/102001'/*'/0'/0'" \
--register-role "ml_dsa_range:ml-dsa-65:m/44'/102003'/*'/0'/0'" \
--register-role "slh_dsa_range:slh-dsa-128s:m/44'/102004'/*'/0'/0'" \
>/dev/null 2>&1 &
REGROLE_SERVER_PID=$!
local max_attempts=50
local attempt=0
while [ $attempt -lt $max_attempts ]; do
if grep -q "$REGROLE_SOCKET_NAME" /proc/net/unix 2>/dev/null; then
echo "Regrole server ready (PID $REGROLE_SERVER_PID, socket @$REGROLE_SOCKET_NAME)"
return 0
fi
sleep 0.1
attempt=$((attempt + 1))
done
echo "ERROR: regrole server did not become ready"
kill "$REGROLE_SERVER_PID" 2>/dev/null
REGROLE_SERVER_PID=""
return 1
}
stop_server_regrole() {
if [ -n "$REGROLE_SERVER_PID" ]; then
echo "Stopping regrole server (PID $REGROLE_SERVER_PID)..."
kill "$REGROLE_SERVER_PID" 2>/dev/null
wait "$REGROLE_SERVER_PID" 2>/dev/null || true
REGROLE_SERVER_PID=""
fi
}
cleanup() {
stop_server
stop_server_regrole
}
# ---------------------------------------------------------------------------
@@ -589,6 +636,243 @@ test_ml_kem_roundtrip() {
fi
}
# ---------------------------------------------------------------------------
# Template-role tests (require the regrole server with --register-role)
# ---------------------------------------------------------------------------
test_template_role_distinct_pubkeys() {
echo ""
echo "=== Template-role: distinct pubkeys per account index ==="
# This test catches the caching bug where a template role returned the
# same pubkey for all concrete paths after the first derivation.
local name="nostr_range index 0 returns 64 hex"
local pk0
pk0=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
if printf '%s\n' "$pk0" | grep -qE '^[0-9a-f]{64}$'; then
pass "$name"
else
fail "$name" "got: $pk0"
return
fi
local pk1 pk5
pk1=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/1'/0/0" get-public-key 2>/dev/null)
pk5=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/5'/0/0" get-public-key 2>/dev/null)
if [ "$pk0" != "$pk1" ]; then
pass "nostr_range index 0 != index 1 (distinct keys)"
else
fail "nostr_range index 0 != index 1 (distinct keys)" "both: $pk0"
fi
if [ "$pk0" != "$pk5" ] && [ "$pk1" != "$pk5" ]; then
pass "nostr_range index 5 distinct from 0 and 1"
else
fail "nostr_range index 5 distinct from 0 and 1" "pk0=$pk0 pk1=$pk1 pk5=$pk5"
fi
# Re-request index 0 to confirm it's stable (not affected by caching)
local pk0_again
pk0_again=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null)
if [ "$pk0" = "$pk0_again" ]; then
pass "nostr_range index 0 stable on re-request"
else
fail "nostr_range index 0 stable on re-request" "first=$pk0 second=$pk0_again"
fi
}
test_template_role_path_rejection() {
echo ""
echo "=== Template-role: path validation rejection ==="
# Hardened tail should be rejected (role template has unhardened 0/0)
local name="reject hardened tail (0'/0') with path_not_allowed"
local output rc
set +e
output=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0'/0'" get-public-key 2>&1)
rc=$?
set -e
if [ $rc -ne 0 ] && printf '%s\n' "$output" | grep -q 'path_not_allowed'; then
pass "$name"
else
fail "$name" "rc=$rc output=$output"
fi
# Wrong change segment should be rejected
name="reject wrong change segment (1) with path_not_allowed"
set +e
output=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/1/0" get-public-key 2>&1)
rc=$?
set -e
if [ $rc -ne 0 ] && printf '%s\n' "$output" | grep -q 'path_not_allowed'; then
pass "$name"
else
fail "$name" "rc=$rc output=$output"
fi
# Unknown role should be rejected
name="reject unknown role with unknown_role"
set +e
output=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role bogus --path "m/44'/1237'/0'/0/0" get-public-key 2>&1)
rc=$?
set -e
if [ $rc -ne 0 ] && printf '%s\n' "$output" | grep -q 'unknown_role'; then
pass "$name"
else
fail "$name" "rc=$rc output=$output"
fi
}
test_template_role_sign_event() {
echo ""
echo "=== Template-role: sign-event with distinct indices ==="
local pk0 signed_pubkey
pk0=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null)
local name="sign-event via nostr_range index 0"
local output
output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" sign-event 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
signed_pubkey=$(json_get "pubkey" "$output")
if [ "$signed_pubkey" = "$pk0" ]; then
pass "$name pubkey matches get-public-key"
else
fail "$name pubkey matches get-public-key" "expected $pk0, got $signed_pubkey"
fi
# Sign with index 1 and confirm different pubkey
local pk1 signed_pubkey1
pk1=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/1'/0/0" get-public-key 2>/dev/null)
output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/1'/0/0" sign-event 2>/dev/null) || {
fail "sign-event via nostr_range index 1" "exit code $?"
return
}
signed_pubkey1=$(json_get "pubkey" "$output")
if [ "$signed_pubkey1" = "$pk1" ] && [ "$signed_pubkey1" != "$signed_pubkey" ]; then
pass "sign-event nostr_range index 1 has correct and distinct pubkey"
else
fail "sign-event nostr_range index 1 has correct and distinct pubkey" "pk1=$pk1 signed=$signed_pubkey1 prev=$signed_pubkey"
fi
}
test_ml_dsa_65_roundtrip() {
echo ""
echo "=== ML-DSA-65 sign/verify round-trip ==="
local name="get-public-key --algorithm ml-dsa-65 --index 0"
local output
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 get-public-key 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
if printf '%s\n' "$output" | grep -q '"algorithm":"ml-dsa-65"' && \
printf '%s\n' "$output" | grep -q '"public_key"'; then
pass "$name"
else
fail "$name" "got: $output"
return
fi
name="sign --algorithm ml-dsa-65 --index 0 68656c6c6f"
local sig_raw sig
sig_raw=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 sign "68656c6c6f" 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
# Sign output is JSON: {"algorithm":"...","key_id":"...","signature":"<hex>"}
sig=$(echo "$sig_raw" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('signature',''))" 2>/dev/null)
if [ -z "$sig" ]; then
# Fallback: maybe raw hex
sig="$sig_raw"
fi
# ML-DSA-65 signatures are 3309 bytes = 6618 hex chars
if printf '%s\n' "$sig" | grep -qE '^[0-9a-f]{6618}$'; then
pass "$name (sig is 6618 hex chars)"
else
fail "$name (sig is 6618 hex chars)" "got length ${#sig}"
return
fi
name="verify --algorithm ml-dsa-65 --index 0 68656c6c6f (valid)"
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 verify "68656c6c6f" "$sig" 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
if printf '%s\n' "$output" | grep -qi 'valid\|true\|verified'; then
pass "$name"
else
fail "$name" "got: $output"
fi
name="verify --algorithm ml-dsa-65 --index 0 68656c6c6f (invalid sig)"
local wrong_sig
wrong_sig=$(printf '%06618s' 0 | tr ' ' '0')
set +e
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 verify "68656c6c6f" "$wrong_sig" 2>/dev/null)
local rc=$?
set -e
if [ $rc -ne 0 ] || printf '%s\n' "$output" | grep -qi 'invalid\|false\|not'; then
pass "$name"
else
fail "$name" "rc=$rc output=$output"
fi
}
test_slh_dsa_128s_roundtrip() {
echo ""
echo "=== SLH-DSA-128s sign/verify round-trip ==="
local name="get-public-key --algorithm slh-dsa-128s --index 0"
local output
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm slh-dsa-128s --index 0 get-public-key 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
if printf '%s\n' "$output" | grep -q '"algorithm":"slh-dsa-128s"' && \
printf '%s\n' "$output" | grep -q '"public_key"'; then
pass "$name"
else
fail "$name" "got: $output"
return
fi
name="sign --algorithm slh-dsa-128s --index 0 68656c6c6f"
local sig_raw sig
sig_raw=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm slh-dsa-128s --index 0 sign "68656c6c6f" 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
# Sign output is JSON: {"algorithm":"...","key_id":"...","signature":"<hex>"}
sig=$(echo "$sig_raw" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('signature',''))" 2>/dev/null)
if [ -z "$sig" ]; then
sig="$sig_raw"
fi
# SLH-DSA-128s signatures are 7856 bytes = 15712 hex chars
if printf '%s\n' "$sig" | grep -qE '^[0-9a-f]{15712}$'; then
pass "$name (sig is 15712 hex chars)"
else
fail "$name (sig is 15712 hex chars)" "got length ${#sig}"
return
fi
name="verify --algorithm slh-dsa-128s --index 0 68656c6c6f (valid)"
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm slh-dsa-128s --index 0 verify "68656c6c6f" "$sig" 2>/dev/null) || {
fail "$name" "exit code $?"
return
}
if printf '%s\n' "$output" | grep -qi 'valid\|true\|verified'; then
pass "$name"
else
fail "$name" "got: $output"
fi
}
test_otp_encrypt_decrypt() {
echo ""
echo "=== OTP encrypt/decrypt ==="
@@ -790,6 +1074,12 @@ start_server || {
exit 1
}
# Start a second server with --register-role (template roles)
start_server_regrole || {
echo "FATAL: could not start nsigner regrole server"
exit 1
}
# Run tests
test_get_info
test_get_public_key_nostr
@@ -799,11 +1089,18 @@ test_nip04_roundtrip
test_nip44_roundtrip
test_algorithm_verbs
test_ml_kem_roundtrip
test_ml_dsa_65_roundtrip
test_slh_dsa_128s_roundtrip
test_otp_encrypt_decrypt
test_call_verb
test_error_cases
test_auto_discovery
# Template-role tests (require regrole server)
test_template_role_distinct_pubkeys
test_template_role_path_rejection
test_template_role_sign_event
# Summary
echo ""
echo "============================================"