# Proposal: Algorithm-Based API + New Policy Model ## 1. Problem The current API keys off **roles** that bundle three concepts together: - **Algorithm** (curve: secp256k1, ed25519, ml-dsa-65, etc.) - **Purpose** (nostr, ssh, age, pq-sig, pq-kem) - **Derivation index** (nostr_index or role_path) The caller must know the role name and the role's configuration determines the algorithm. This is unintuitive — a caller that wants to "sign something with ed25519" shouldn't need to know that the operator named the role "ssh_main" and configured it with purpose=ssh. The purpose field is really a **policy/enforcement constraint**, not something the caller should care about. The caller knows what operation they want (sign, verify, encapsulate) and what algorithm they want to use. The signer's job is to check whether that combination is allowed and whether the caller is approved. ## 2. Proposed API shape ### 2.1 Core principle: verb + algorithm, not verb + role The caller specifies: - **What** they want to do (the verb) - **Which algorithm** they want to use - **Which key** (by derivation index) The signer determines: - Whether the verb+algorithm combination is valid (enforcement) - Whether the caller is approved (policy) - Derives the key on demand from the mnemonic ### 2.2 Verbs Simplify to operation-based verbs. The algorithm is a parameter, not implicit in the verb name. | Verb | Description | Algorithm parameter | Key parameter | |---|---|---|---| | `get_public_key` | Get public key for a derived key | `algorithm` | `index` | | `sign` | Sign arbitrary bytes | `algorithm` | `index` | | `verify` | Verify a signature | `algorithm` | `index` (or `public_key`) | | `encapsulate` | KEM encapsulation | `algorithm` | `public_key` (peer's) | | `decapsulate` | KEM decapsulation | `algorithm` | `index` | | `derive_shared_secret` | ECDH key agreement (x25519) | `algorithm` | `index` + `peer_public_key` | | `sign_event` | Sign a Nostr event (secp256k1 only) | — (always secp256k1) | `index` | | `nip44_encrypt` | NIP-44 encrypt (secp256k1 only) | — | `index` + `peer_pubkey` | | `nip44_decrypt` | NIP-44 decrypt (secp256k1 only) | — | `index` + `peer_pubkey` | | `nip04_encrypt` | NIP-04 encrypt (secp256k1 only) | — | `index` + `peer_pubkey` | | `nip04_decrypt` | NIP-04 decrypt (secp256k1 only) | — | `index` + `peer_pubkey` | | `mine_event` | Mine + sign Nostr event (secp256k1 only) | — | `index` | The Nostr-specific verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`) are inherently secp256k1 — that's a protocol requirement of Nostr, not a policy choice. They don't take an `algorithm` parameter. The generic verbs (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`) take an `algorithm` parameter. ### 2.3 Request format **Generic sign (any signature algorithm):** ```json { "id": "1", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ml-dsa-65", "index": 0 } ] } ``` - First param: message bytes as hex - Second param: options with `algorithm` and `index` **Response:** ```json { "id": "1", "result": { "signature": "", "algorithm": "ml-dsa-65", "key_id": "a1b2c3d4e5f6a1b2" } } ``` **Get public key (any algorithm):** ```json { "id": "2", "method": "get_public_key", "params": [ { "algorithm": "ed25519", "index": 0 } ] } ``` **Response:** ```json { "id": "2", "result": { "algorithm": "ed25519", "public_key": "", "key_id": "a1b2c3d4e5f6a1b2" } } ``` **KEM encapsulate:** ```json { "id": "3", "method": "encapsulate", "params": [ "", { "algorithm": "ml-kem-768" } ] } ``` **KEM decapsulate:** ```json { "id": "4", "method": "decapsulate", "params": [ "", { "algorithm": "ml-kem-768", "index": 0 } ] } ``` **ECDH shared secret (x25519):** ```json { "id": "5", "method": "derive_shared_secret", "params": [ "", { "algorithm": "x25519", "index": 0 } ] } ``` **Nostr sign_event (unchanged, always secp256k1):** ```json { "id": "6", "method": "sign_event", "params": [ "", { "index": 0 } ] } ``` ### 2.4 Algorithm names | String | Algorithm | Key type | |---|---|---| | `secp256k1` | secp256k1 (Schnorr) | Signature | | `ed25519` | ed25519 | Signature | | `ml-dsa-65` | ML-DSA-65 (FIPS 204) | Signature | | `slh-dsa-128s` | SLH-DSA-128s (FIPS 205) | Signature | | `x25519` | X25519 (ECDH) | Key agreement | | `ml-kem-768` | ML-KEM-768 (FIPS 203) | KEM | ### 2.5 Key identification Keys are identified by **algorithm + index** instead of role names. The index is the BIP-44 account field in the derivation path: | Algorithm | Derivation path for index N | |---|---| | secp256k1 | `m/44'/1237'/N'/0/0` (NIP-06, unchanged) | | ed25519 | `m/44'/102001'/N'/0'/0'` | | x25519 | `m/44'/102002'/N'/0'/0'` | | ml-dsa-65 | `m/44'/102003'/N'/0'/0'` | | slh-dsa-128s | `m/44'/102004'/N'/0'/0'` | | ml-kem-768 | `m/44'/102005'/N'/0'/0'` | The `key_id` (first 16 hex chars of the public key) provides a short human-readable identifier for display at the approval prompt. ### 2.6 Backward compatibility The existing role-based API continues to work for Nostr clients: - `sign_event` with `{role: "main"}` or `{nostr_index: 0}` — unchanged - `nip44_encrypt`, `nip04_decrypt`, etc. — unchanged - `get_public_key` with `{role: "main"}` — returns plain hex (secp256k1 backward compat) The new algorithm-based API is additive — new verbs (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`) use the algorithm parameter. Old verbs keep their role-based selectors. ## 3. Proposed policy model ### 3.1 Current model (role-based) From [`plans/deny_by_default_approvals.md`](plans/deny_by_default_approvals.md): - Roles are pre-configured at startup with purpose+curve+index - Policy entries approve callers for specific roles - `--preapprove caller=uid:1000,role=main,verb=sign_event` - Prompt `[a]` approves the caller for the role ### 3.2 New model (algorithm-based) Policy entries approve callers for **algorithm + index ranges + verbs**: **Policy entry structure:** ``` caller: algorithms: [ed25519, ml-dsa-65] # which algorithms this caller can use index_range: 0-4 # which derivation indices (or * for any) verbs: [sign, verify, get_public_key] # which verbs prompt: first_per_boot # prompt behavior ``` **`--preapprove` CLI flag:** ```bash nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign nsigner --preapprove caller=uid:1000,algorithm=ml-dsa-65,index=0,verb=sign,verify nsigner --preapprove caller=pubkey:abc...,algorithm=ml-kem-768,index=0,verb=decapsulate ``` **Prompt display:** When a caller requests `sign` with `algorithm=ml-dsa-65, index=0`, the prompt shows: ``` Caller: uid:1000 Action: sign with ML-DSA-65 Key: index 0 (key_id: a1b2c3d4e5f6a1b2) [a] approve [d] deny ``` The operator approves the caller for that algorithm+index+verb combination. Subsequent requests with the same combination are auto-approved (if `prompt: first_per_boot`). ### 3.3 Nostr verbs policy Nostr verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`) are always secp256k1. They can be policy-keyed as either: - **Algorithm-based**: `algorithm=secp256k1, verb=sign_event, index=0` (new style) - **Role-based**: `role=main, verb=sign_event` (old style, backward compat) Both resolve to the same key. The signer accepts both selector forms. ### 3.4 Wildcards | Pattern | Meaning | |---|---| | `algorithm=*` | Any algorithm | | `index=*` | Any index | | `index=0-4` | Indices 0 through 4 inclusive | | `verb=*` | Any verb valid for the algorithm | Wildcards make policy more compact but less precise. The default is no wildcards (explicit per-algorithm, per-index, per-verb). ### 3.5 Enforcement matrix The signer checks verb+algorithm validity (regardless of policy): | Verb | Valid algorithms | Notes | |---|---|---| | `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Generic signing | | `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Generic verification | | `encapsulate` | ml-kem-768 | KEM encapsulation | | `decapsulate` | ml-kem-768 | KEM decapsulation | | `derive_shared_secret` | x25519 | ECDH key agreement | | `get_public_key` | all | Any algorithm | | `sign_event` | secp256k1 (implicit) | Nostr protocol requirement | | `nip44_encrypt` | secp256k1 (implicit) | Nostr protocol requirement | | `nip44_decrypt` | secp256k1 (implicit) | Nostr protocol requirement | | `nip04_encrypt` | secp256k1 (implicit) | Nostr protocol requirement | | `nip04_decrypt` | secp256k1 (implicit) | Nostr protocol requirement | | `mine_event` | secp256k1 (implicit) | Nostr protocol requirement | If a caller requests `sign` with `algorithm=x25519`, the signer rejects with `algorithm_not_supported_for_verb` — x25519 is a key agreement algorithm, not a signature algorithm. ### 3.6 No purpose field The `purpose` field is **removed entirely**. There is no `purpose` in the API, no purpose in enforcement, no purpose in policy. The role_purpose_t enum and all purpose-related code is removed from the codebase. Enforcement is purely verb+algorithm based — the signer checks whether the requested verb is valid for the requested algorithm. No "purpose mismatch" errors, no purpose in policy entries, no purpose in role configuration. Roles (if kept for backward compat with existing Nostr clients) are keyed on algorithm+index only, not purpose+curve. ## 4. Migration path ### Phase A: Add algorithm-based verbs (additive, no breaking changes) 1. Add new verbs: `sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret` 2. These accept `{algorithm, index}` in the options 3. Old verbs (`sign_data`, `ssh_sign`, `kem_encapsulate`, `kem_decapsulate`) become aliases that map to the new verbs 4. Old role-based selectors continue to work on all verbs 5. Policy model extended to support algorithm-based entries alongside role-based entries ### Phase B: Deprecate old verb names (optional, future) 1. `sign_data` → `sign` 2. `ssh_sign` → `sign` (with `algorithm=ed25519`) 3. `kem_encapsulate` → `encapsulate` 4. `kem_decapsulate` → `decapsulate` 5. `verify_signature` → `verify` 6. Old names still work but are documented as deprecated ### Phase C: Remove purpose from enforcement (optional, future) 1. Purpose becomes pure metadata 2. Enforcement only checks verb+algorithm validity 3. Roles become optional (algorithm+index is the primary key selector) ## 5. Example: full session with new API ### Operator starts n_signer: ```bash nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0,verb=sign,verify,get_public_key \ --preapprove caller=uid:1000,algorithm=ml-dsa-65,index=0,verb=sign,verify,get_public_key \ --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsulate,get_public_key ``` ### Client gets an ed25519 public key: ```json {"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]} → {"id":"1","result":{"algorithm":"ed25519","public_key":"...","key_id":"a1b2..."}} ``` ### Client signs a message with ed25519: ```json {"id":"2","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]} → {"id":"2","result":{"signature":"...","algorithm":"ed25519","key_id":"a1b2..."}} ``` ### Client signs a message with ML-DSA-65: ```json {"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ml-dsa-65","index":0}]} → {"id":"3","result":{"signature":"...","algorithm":"ml-dsa-65","key_id":"c3d4..."}} ``` ### Client encapsulates with ML-KEM-768 (using peer's public key): ```json {"id":"4","method":"encapsulate","params":["",{"algorithm":"ml-kem-768"}]} → {"id":"4","result":{"ciphertext":"...","shared_secret":"...","algorithm":"ml-kem-768"}} ``` ### Client decapsulates (signer side, using its ML-KEM private key): ```json {"id":"5","method":"decapsulate","params":["",{"algorithm":"ml-kem-768","index":0}]} → {"id":"5","result":{"shared_secret":"...","algorithm":"ml-kem-768"}} ``` ### Client signs a Nostr event (backward compatible): ```json {"id":"6","method":"sign_event","params":["",{"index":0}]} → {"id":"6","result":""} ``` ## 6. Comparison: old vs new | Aspect | Old (role-based) | New (algorithm-based) | |---|---|---| | Caller specifies | Role name | Algorithm + index | | Algorithm determined by | Role's configured curve | Explicit `algorithm` parameter | | Purpose | Enforcement constraint | Optional metadata | | Policy unit | Role | Algorithm + index + verb | | Nostr verbs | Role-based selector | Index-based (algorithm implicit) | | Generic verbs | `sign_data` (role-based) | `sign` (algorithm-based) | | Key derivation | Role's index/path | Algorithm's derivation path + index | ## 7. Decisions 1. **secp256k1 `sign` — offer both Schnorr and ECDSA.** The `sign` verb accepts an optional `scheme` parameter for secp256k1: `"scheme": "schnorr"` (default, BIP-340) or `"scheme": "ecdsa"`. Other algorithms have one signature scheme each and ignore this parameter. 2. **Index is per-algorithm.** Each algorithm has its own derivation path prefix (coin type), so "index 0" for ed25519 and "index 0" for ml-dsa-65 are different keys. This is the only sensible model — different algorithms need different key material. 3. **Key selector is `index` only.** No `role_path` support for the new verbs. The `index` parameter maps to the account field in the standard BIP-44 derivation path. This is simpler, covers all normal use cases, and is easy to policy-gate. Arbitrary derivation paths can be a future advanced feature if needed. 4. **`verify` uses the signer's own derived public key.** The `verify` verb takes `algorithm` + `index` (same as `sign`) and verifies a signature against the signer's own derived public key. This is useful for round-trip testing (client signs, then verifies). The signer does NOT verify arbitrary external signatures with a `public_key` parameter — that's a client-side operation that doesn't need the signer. 5. **Remove the `purpose` field entirely.** No `purpose` in the API, no purpose in enforcement, no purpose in policy. Enforcement is purely verb+algorithm based: - `sign` works with any signature algorithm (secp256k1, ed25519, ml-dsa-65, slh-dsa-128s) - `encapsulate`/`decapsulate` work with ml-kem-768 - `derive_shared_secret` works with x25519 - `sign_event`/`nip44_*`/`nip04_*`/`mine_event` always use secp256k1 (Nostr protocol requirement, not a purpose check) - `get_public_key` works with any algorithm The `role_purpose_t` enum and all purpose-related code is removed. Roles (if kept for backward compat) are keyed on algorithm+index only.