diff --git a/.dockerignore b/.dockerignore index bfe9de1..7423b59 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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/ diff --git a/Makefile b/Makefile index c154101..ea4fdbf 100644 --- a/Makefile +++ b/Makefile @@ -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 "; \ + 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) diff --git a/build_static.sh b/build_static.sh index 91b0082..586d07f 100755 --- a/build_static.sh +++ b/build_static.sh @@ -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 ]" + echo "Usage: $0 [--arch ] [--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 ]" + echo "Usage: $0 [--arch ] [--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 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 diff --git a/plans/role_as_password_default.md b/plans/role_as_password_default.md new file mode 100644 index 0000000..5c36461 --- /dev/null +++ b/plans/role_as_password_default.md @@ -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. diff --git a/src/main.c b/src/main.c index 98ecadf..c5235de 100644 --- a/src/main.c +++ b/src/main.c @@ -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 22 -#define NSIGNER_VERSION "v0.1.22" +#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");