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_indexbypasses the role system entirely — the server doesn't know which role's encryption scheme applies.role_pathbypasses the role system for authorization.indexis ambiguous when a role template has multiple variable segments (e.g.m/44'/1237'/0-10'/0-10/0-10— which "index"?).- The
--allow-indexpath 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>"}:
- Look up the role by name. If not found →
1002 unknown_role. - 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. - If
requires_approval = false→ authorize immediately. - If
requires_approval = true→ prompt the human attendant (existing approval flow).
What's removed
--allow-indexflag and path whitelist — removed entirely. All access goes through roles now.--indexselector option — removed. Use--pathwith the full path.--nostr-indexselector — removed. Use--role main --path "m/44'/1237'/N'/0/0".--role-pathas a standalone selector (without--role) — removed. Use--role+--pathtogether.- 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:
mainfor option 1,sshfor 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-indexflag. - Remove
--indexflag (for nostr verbs; keep it for algorithm verbs where it's the algorithm derivation index). - Remove
--role-pathflag. - For
nostr_*verbs: require both--roleand--path. Error if either is missing. - Update
--helptext and examples. - Update
client/n_signer_client_README.md.
src/role_table.c / src/role_table.h
- Add
requires_approvalfield 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
roleandrole_pathare present: look up the role, verify the path matches the template, set the role index for key derivation. - When
nostr_indexis present: reject with error (removed). - When
indexis present without--algorithm: reject with error (removed for nostr verbs). - When only
role_pathis present (no role): reject with error. - When only
roleis 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-indexhandling and the path whitelist.
src/main.c
- Remove
--allow-indexflag parsing. - Remove
--nostr-indexreferences 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-indexpath whitelist checks. - Reject
nostr_indexandindex(for nostr verbs) with clear error messages.
Tests
- Update
tests/test_n_signer_client.shto use--role+--pathinstead of--nostr-index/--index. - Update
tests/test_integration.cto use the new selector model. - Remove or repurpose
tests/test_path_whitelist.c(path whitelist is gone). - Add tests for the
requires_approvalflag (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.