Compare commits

...
3 Commits
7 changed files with 259 additions and 79 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"
+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.
+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 23
#define NSIGNER_VERSION "v0.1.23"
/* 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;