15 KiB
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):
{
"id": "1",
"method": "sign",
"params": [
"68656c6c6f",
{
"algorithm": "ml-dsa-65",
"index": 0
}
]
}
- First param: message bytes as hex
- Second param: options with
algorithmandindex
Response:
{
"id": "1",
"result": {
"signature": "<hex>",
"algorithm": "ml-dsa-65",
"key_id": "a1b2c3d4e5f6a1b2"
}
}
Get public key (any algorithm):
{
"id": "2",
"method": "get_public_key",
"params": [
{
"algorithm": "ed25519",
"index": 0
}
]
}
Response:
{
"id": "2",
"result": {
"algorithm": "ed25519",
"public_key": "<hex>",
"key_id": "a1b2c3d4e5f6a1b2"
}
}
KEM encapsulate:
{
"id": "3",
"method": "encapsulate",
"params": [
"<peer_pubkey_hex>",
{
"algorithm": "ml-kem-768"
}
]
}
KEM decapsulate:
{
"id": "4",
"method": "decapsulate",
"params": [
"<ciphertext_hex>",
{
"algorithm": "ml-kem-768",
"index": 0
}
]
}
ECDH shared secret (x25519):
{
"id": "5",
"method": "derive_shared_secret",
"params": [
"<peer_pubkey_hex>",
{
"algorithm": "x25519",
"index": 0
}
]
}
Nostr sign_event (unchanged, always secp256k1):
{
"id": "6",
"method": "sign_event",
"params": [
"<event_json>",
{
"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_eventwith{role: "main"}or{nostr_index: 0}— unchangednip44_encrypt,nip04_decrypt, etc. — unchangedget_public_keywith{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:
- 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: <caller_id>
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:
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)
- Add new verbs:
sign,verify,encapsulate,decapsulate,derive_shared_secret - These accept
{algorithm, index}in the options - Old verbs (
sign_data,ssh_sign,kem_encapsulate,kem_decapsulate) become aliases that map to the new verbs - Old role-based selectors continue to work on all verbs
- Policy model extended to support algorithm-based entries alongside role-based entries
Phase B: Deprecate old verb names (optional, future)
sign_data→signssh_sign→sign(withalgorithm=ed25519)kem_encapsulate→encapsulatekem_decapsulate→decapsulateverify_signature→verify- Old names still work but are documented as deprecated
Phase C: Remove purpose from enforcement (optional, future)
- Purpose becomes pure metadata
- Enforcement only checks verb+algorithm validity
- Roles become optional (algorithm+index is the primary key selector)
5. Example: full session with new API
Operator starts n_signer:
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:
{"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:
{"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:
{"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):
{"id":"4","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]}
→ {"id":"4","result":{"ciphertext":"...","shared_secret":"...","algorithm":"ml-kem-768"}}
Client decapsulates (signer side, using its ML-KEM private key):
{"id":"5","method":"decapsulate","params":["<ciphertext_hex>",{"algorithm":"ml-kem-768","index":0}]}
→ {"id":"5","result":{"shared_secret":"...","algorithm":"ml-kem-768"}}
Client signs a Nostr event (backward compatible):
{"id":"6","method":"sign_event","params":["<event_json>",{"index":0}]}
→ {"id":"6","result":"<signed_event_json>"}
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
-
secp256k1
sign— offer both Schnorr and ECDSA. Thesignverb accepts an optionalschemeparameter for secp256k1:"scheme": "schnorr"(default, BIP-340) or"scheme": "ecdsa". Other algorithms have one signature scheme each and ignore this parameter. -
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.
-
Key selector is
indexonly. Norole_pathsupport for the new verbs. Theindexparameter 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. -
verifyuses the signer's own derived public key. Theverifyverb takesalgorithm+index(same assign) 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 apublic_keyparameter — that's a client-side operation that doesn't need the signer. -
Remove the
purposefield entirely. Nopurposein the API, no purpose in enforcement, no purpose in policy. Enforcement is purely verb+algorithm based:signworks with any signature algorithm (secp256k1, ed25519, ml-dsa-65, slh-dsa-128s)encapsulate/decapsulatework with ml-kem-768derive_shared_secretworks with x25519sign_event/nip44_*/nip04_*/mine_eventalways use secp256k1 (Nostr protocol requirement, not a purpose check)get_public_keyworks with any algorithm
The
role_purpose_tenum and all purpose-related code is removed. Roles (if kept for backward compat) are keyed on algorithm+index only.