Files
n_signer/plans/algorithm_based_api.md

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 algorithm and index

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_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:

  • 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)

  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_datasign
  2. ssh_signsign (with algorithm=ed25519)
  3. kem_encapsulateencapsulate
  4. kem_decapsulatedecapsulate
  5. verify_signatureverify
  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:

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

  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.