Files
n_signer/plans/role_path_authorization.md
T

11 KiB

Plan: Role + Path Authorization Model

Status: Finalized — ready for implementation

Hardened vs unhardened derivation paths

BIP-32 derivation paths use ' (or h) to mark hardened segments. The presence or absence of ' changes the math and produces completely different keys.

  • Hardened (m/44'/1237'/0'/0'/0'): requires the parent private key; compromising one child key does NOT compromise siblings. Best for agent isolation.
  • Unhardened (m/44'/1237'/0/0/0): can derive public keys from the parent public key alone; but compromising one child private key + the extended public key compromises all siblings.

Recommendation for multi-agent setups

Use all-hardened paths like m/44'/1237'/0-99'/0'/0' for 100 agents. This gives full isolation — if agent #5 is compromised, agents #0-4 and #6-99 are safe. Since n_signer always holds the private key, there's no benefit from unhardened derivation's "watching-only" capability.

NIP-06 compatibility

NIP-06 defines m/44'/1237'/<account>'/0/0 — the account segment is hardened, the last two are unhardened. If you need NIP-06 compatibility (keys work with standard Nostr tools), use m/44'/1237'/0-99'/0/0. If you don't care about NIP-06, harden everything.

Current code support

The signer already supports arbitrary hardened paths. The path parser in src/key_store.c (parse_derivation_path) handles both ' and h/H as hardened markers and sets the 0x80000000 bit accordingly. The secp256k1 derivation uses standard BIP-32 (nostr_bip32_derive_path). Non-secp256k1 curves (ed25519, x25519, PQ algorithms) already use all-hardened SLIP-0010 paths. So you can use m/44'/1237'/0-99'/0'/0' (all hardened) right now — no code changes needed for the derivation itself.

Role preset menu implications

The role preset menu in the wizard should offer both NIP-06-compatible and all-hardened options:

  • "Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0"
  • "Standard Nostr hardened: secp256k1, m/44'/1237'/0'/0'/0'"
  • "Nostr agent range (NIP-06): secp256k1, m/44'/1237'/0-99'/0/0"
  • "Nostr agent range (hardened): secp256k1, m/44'/1237'/0-99'/0'/0'"

Problem

The current selector model has three independent selectors (nostr_index, role, role_path) that are mutually exclusive and confusing:

  • nostr_index bypasses the role system entirely — the server doesn't know which role's encryption scheme applies.
  • role_path bypasses the role system for authorization.
  • index is ambiguous when a role template has multiple variable segments (e.g. m/44'/1237'/0-10'/0-10/0-10 — which "index"?).
  • The --allow-index path whitelist is a separate authorization mechanism that duplicates what roles already do.

New model

Core principle

Every request specifies both a role and a full path. The role authorizes the request (acts as a password) and determines the encryption scheme. The path selects the specific key to derive. No exceptions, no backward compatibility for the old selectors.

Client-side selectors (n_signer_client)

Flag Meaning
--role <name> --path <full-bip44-path> The only selector. Both required for nostr_* verbs. Sends {"role":"<name>","role_path":"<path>"}.
--nostr-index <N> Removed.
--index <N> Removed.
--role-path <path> Removed (replaced by --path).

Example commands:

# Standard Nostr key (role "main", path m/44'/1237'/0'/0/0)
n_signer_client --role main --path "m/44'/1237'/0'/0/0" get-public-key

# A specific key from role1
n_signer_client --role role1 --path "m/44'/1237'/1'/1/0" get-public-key

# Sign an event
echo '{"kind":1,"content":"hello","tags":[],"created_at":1700000000}' \
  | n_signer_client --role main --path "m/44'/1237'/0'/0/0" sign-event | nak publish

Server-side authorization (nsigner)

Role-as-password with per-role approval flag

Each role defined in the wizard has a property: requires_approval (boolean, default true).

  • requires_approval = false: Knowing the role name is sufficient authorization. If the client provides a valid role name and a path that matches the role's registered template, the request is authorized immediately — no interactive prompt. This is "role-as-password."
  • requires_approval = true: The role name identifies the request, but the human attendant must approve each request interactively (existing approval flow). Use this for roles given to agents where you want to see and approve everything they do.

Path verification

When the client sends {"role":"<name>","role_path":"<path>"}:

  1. Look up the role by name. If not found → 1002 unknown_role.
  2. Check if the requested path matches the role's registered template (substitute the variable segments and compare). If it doesn't match → 2003 path_not_allowed.
  3. If requires_approval = false → authorize immediately.
  4. If requires_approval = true → prompt the human attendant (existing approval flow).

What's removed

  • --allow-index flag and path whitelist — removed entirely. All access goes through roles now.
  • --index selector option — removed. Use --path with the full path.
  • --nostr-index selector — removed. Use --role main --path "m/44'/1237'/N'/0/0".
  • --role-path as a standalone selector (without --role) — removed. Use --role + --path together.
  • No backward compatibility for {"nostr_index":N} or {"role":"<name>","index":N} — these are rejected with an error message explaining the new model.

--allow-all behavior

--allow-all still works for same-uid callers without a role — but only for the algorithm-based verbs (sign, verify, derive, etc.) that use --algorithm + --index (the algorithm index, not the nostr index). For nostr_* verbs, --role + --path are always required.

Wizard changes

Mandatory role creation

Upon startup, the user is required to create at least one role. If no role is created, the signer exits with an error message: "At least one role must be defined."

Role preset menu

Instead of starting from a blank template, the wizard presents a menu of common presets:

Define a role:
  1. Standard Nostr role (secp256k1, m/44'/1237'/0'/0/0)
  2. Standard Nostr role with range (secp256k1, m/44'/1237'/0-100'/0/0)
  3. SSH role (ed25519, m/44'/102001'/0'/0/0)
  4. Age/x25519 role (x25519, m/44'/102002'/0'/0/0)
  5. ML-DSA-65 role (post-quantum signatures, m/44'/102003'/0'/0/0)
  6. SLH-DSA-128s role (post-quantum signatures, m/44'/102004'/0'/0/0)
  7. ML-KEM-768 role (post-quantum KEM, m/44'/102005'/0'/0/0)
  8. Custom path
  Select [1]:

After selecting a preset, the user can:

  • Edit the role name (default: main for option 1, ssh for option 3, etc.)
  • Edit the path template (pre-filled from the preset)
  • Set requires_approval (default: true)

Then the wizard loops: "Define another role? [y/N]"

Default role

The first role created is the default role. If the user selects preset 1 and keeps the name main, that becomes the default. The default role is used when a client sends a request without specifying a role — but since the new model requires both --role and --path, the "default role" concept only applies to the --allow-all algorithm-verb path.

Verb-level granularity

Not implemented. All verbs within a role have the same authorization level. Future expansion.

What changes in the code

client/n_signer_client.c

  • Add --path <path> flag (replaces --role-path).
  • Remove --nostr-index flag.
  • Remove --index flag (for nostr verbs; keep it for algorithm verbs where it's the algorithm derivation index).
  • Remove --role-path flag.
  • For nostr_* verbs: require both --role and --path. Error if either is missing.
  • Update --help text and examples.
  • Update client/n_signer_client_README.md.

src/role_table.c / src/role_table.h

  • Add requires_approval field to the role entry struct.
  • Add role preset menu to the wizard.
  • Make role creation mandatory (at least one role).
  • Add function to check a path against a role's template (path matching).

src/selector.c

  • When both role and role_path are present: look up the role, verify the path matches the template, set the role index for key derivation.
  • When nostr_index is present: reject with error (removed).
  • When index is present without --algorithm: reject with error (removed for nostr verbs).
  • When only role_path is present (no role): reject with error.
  • When only role is present (no path): reject with error (unless the role has a fixed single path with no variable segments — in that case, use the role's default path).

src/policy.c

  • Add "role-as-password" authorization: if the role is known, the path matches, and requires_approval = false, allow without prompting.
  • If requires_approval = true, use the existing approval flow.
  • Remove --allow-index handling and the path whitelist.

src/main.c

  • Remove --allow-index flag parsing.
  • Remove --nostr-index references in help text.
  • Update the wizard to use the preset menu and prompt for requires_approval.
  • Make role creation mandatory.

src/dispatcher.c

  • Update selector resolution to use the new role+path model.
  • Remove the old --allow-index path whitelist checks.
  • Reject nostr_index and index (for nostr verbs) with clear error messages.

Tests

  • Update tests/test_n_signer_client.sh to use --role + --path instead of --nostr-index / --index.
  • Update tests/test_integration.c to use the new selector model.
  • Remove or repurpose tests/test_path_whitelist.c (path whitelist is gone).
  • Add tests for the requires_approval flag (both true and false).
  • Add tests for the role preset menu.

Documentation

  • Update README.md §4.6 (role-based selectors) to describe the new model.
  • Update client/n_signer_client_README.md.
  • Update client/n_signer_client_PLAN.md.
  • Update documents/CLIENT_IMPLEMENTATION.md.

Mermaid: new authorization flow

flowchart TD
  A[Client request with role + path] --> B{Role known?}
  B -- No --> E[Reject: unknown_role]
  B -- Yes --> C{Path matches role template?}
  C -- No --> F[Reject: path_not_allowed]
  C -- Yes --> D{requires_approval?}
  D -- No --> G[Authorize — no prompt]
  D -- Yes --> H[Prompt human attendant]
  H -- allow --> G
  H -- deny --> I[Reject: unauthorized]

Mermaid: wizard role creation

flowchart TD
  A[Startup] --> B[Show role preset menu]
  B --> C[User selects preset]
  C --> D[Pre-fill path template]
  D --> E[User edits role name]
  E --> F[User edits path template]
  F --> G[User sets requires_approval]
  G --> H[Register role]
  H --> I{Define another role?}
  I -- Yes --> B
  I -- No --> J{At least one role defined?}
  J -- No --> K[Error: at least one role required]
  J -- Yes --> L[Continue to transport selection]

Out of scope

  • Verb-level granularity (can-see-pubkey-but-cant-sign) — future expansion.
  • Role revocation / rotation — not needed yet.
  • Role names as actual cryptographic tokens (currently just plain text names) — future hardening.
  • Backward compatibility for nostr_index / index — intentionally removed.