docs(spec): reframe identity model around operator-IdP + sidecar bunker

Updates §7.2, §7.3, §12 to reflect the actual architecture from
aiolabs/lnbits#9 (reframed since the earlier commit) and #18 (the
concrete phase 2 bunker integration using nsecbunkerd).

Three shifts:

- LocalSigner demoted to transitional/migration helper. RemoteBunker
  Signer is the steady state for every bound user. New accounts MUST
  NOT default to LocalSigner. Earlier framing treated them as
  equivalent choices — they're not.

- Binding artifact is a per-device NIP-46 connection token with
  scoped permissions, not just a (mxid → user_id) mapping row. Calls
  out the security property: compromise of one client device
  (tracker, ATM, webapp) leaks only that token's scope, not the
  user's full identity. Revocation is one RPC at the bunker.

- §12 redrawn around the operator-IdP-with-sidecar-bunker pattern.
  Names nsecbunkerd as the canonical bunker for the aiolabs ref
  impl, points at #9 + #18 for the LNbits side. Pattern is reusable
  beyond LNbits — any operator providing identity-as-a-service can
  run this shape.

NIP-26 explicitly out (Nostr ecosystem has deprecated; NIP-46 covers
the use case). §11 open questions trimmed accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-25 20:19:56 +02:00
commit d089a4b021

View file

@ -453,57 +453,107 @@ Implementations MUST design their signing layer as an abstraction so
per-user signing plugs in without refactor. Recommended interface
(adopted from the LNbits reference implementation, see §12):
| Signer | Holds | Where it signs |
|---|---|---|
| `BotSigner` | One server-side keypair shared across all events | Server-side, in-process. The v1 fallback. |
| `LocalSigner` | A user's encrypted-at-rest keypair on the bot's host | Server-side, in-process, but per-user |
| `RemoteBunkerSigner` | A NIP-46 remote-signer connection (per-user) | RPC over relay (kind 24133) to the user's bunker |
| `ClientSideOnlySigner` | Nothing — sentinel meaning "user signs in their own client" | Not the bot; events for this user come in via subscription from elsewhere |
| Signer | Holds | Where it signs | Lifecycle role |
|---|---|---|---|
| `BotSigner` | One server-side keypair shared across all events | Server-side, in-process | The v1 fallback and the unbound-user fallback |
| `RemoteBunkerSigner` | A NIP-46 connection token + bunker URL + scoped perms (per-user) | RPC over relay (kind 24133) to a sidecar bunker | **Steady state** — what every bound user ends up on |
| `ClientSideOnlySigner` | Nothing — sentinel meaning "user signs in their own client" | Not the bot; events for this user come in via subscription from elsewhere | Sovereignty escape (NIP-41) — user took ownership |
| `LocalSigner` | A user's encrypted-at-rest keypair on the bot's host | Server-side, in-process | **Transitional** — migration helper only; never the long-term home for a key |
**The steady-state architecture is the operator-IdP-with-sidecar-bunker
pattern.** The bot (or any other client of the identity provider) holds
zero nsec material; a separate bunker process on the same host (e.g.
`nak bunker`) holds every user's target key and signs on RPC. The bot
authenticates to the bunker with a per-user NIP-46 connection token
issued at binding time (§7.3), scoped to only the kinds the bot needs
to sign. Compromise of the bot leaks the scoped tokens; revoking them
at the bunker side is one RPC and doesn't disturb the target key or
any other client device the user has authorized.
`LocalSigner` is included in the abstraction for two narrow reasons:
(a) it's how identity providers migrate existing plaintext-nsec rows
into the bunker safely, and (b) it lets adopters who don't (yet) run a
bunker fall back to encrypt-at-rest in-process signing. Once a bunker
is available, the migration drains every `LocalSigner` row into it and
flips them to `RemoteBunkerSigner`. New accounts MUST NOT default to
`LocalSigner`.
For each captured message the bot:
1. Looks up the originating chat handle (e.g. MXID) in its binding
table (§7.3).
2. If bound and the signer is server-callable (`LocalSigner` /
`RemoteBunkerSigner`), signs as the user. Drop the `author` tag
(the pubkey is the attribution).
3. If unbound — or bound to `ClientSideOnlySigner` for a verb the
user must sign themselves — falls back to `BotSigner` with the
`author` tag (the v1 §7.1 behavior).
table (§7.3) — yielding a connection token + bunker URL + pubkey
(or `None` if unbound).
2. If bound and the signer is server-callable (`RemoteBunkerSigner`
or, transitionally, `LocalSigner`), signs as the user. Drop the
`author` tag — the pubkey is the attribution.
3. If unbound — or bound to `ClientSideOnlySigner` for a verb the user
must sign themselves — falls back to `BotSigner` with the `author`
tag (the v1 §7.1 behavior).
This degrades gracefully: a community can run v1 indefinitely with
just `BotSigner`; users opt into sovereignty individually as they
just `BotSigner`; users opt into per-user signing individually as they
bind their identity.
**NIP-46 is the recommended remote signer protocol.** It's what the
Nostr ecosystem is converging on for client-without-nsec flows, and
it works without browser extensions on iOS — important for any
Matrix client that runs on mobile.
**NIP-46 is the only remote signer protocol this spec recognizes.**
It's what the Nostr ecosystem has converged on for client-without-nsec
flows, works without browser extensions on iOS, and natively supports
the per-device scoped tokens that make multi-client identity safe.
NIP-26 delegation tokens are explicitly NOT used (the Nostr ecosystem
has deprecated NIP-26 and NIP-46 covers the same ground better).
### 7.3 External-identity binding (v2)
For per-user signing to mean anything, the bot needs a verified
mapping from `chat handle → external identity → signer`. The binding
mapping from `chat handle → identity provider → signer`. The binding
mechanism is implementation-defined but MUST be:
- **Opt-in** per user. No silent association.
- **Verifiable** — the binding proves the user controls both identities
(e.g. magic-link round-trip, signature challenge, NIP-39 external
identity proof).
- **Revocable** — user can unbind. Bot drops back to fallback.
(e.g. magic-link round-trip via the IdP's authenticated session,
signature challenge, NIP-39 external identity proof).
- **Revocable** — user can unbind. Bot drops back to `BotSigner`
fallback. Crucially: revocation MUST also revoke whatever per-device
signing capability the binding established (see below).
#### Binding artifact (the important part)
A binding is not just a row in a mapping table — it's a **per-device
NIP-46 connection token** scoped to exactly the kinds the bot needs to
sign on behalf of the user. The bot stores:
- The user's pubkey (for display, addressing, ECDH)
- The bunker URL (transport: which relay channel the bunker listens on)
- The connection token (the credential to authenticate to the bunker)
- The granted permission scope (e.g. `sign_event:31922,31923,31925`
for a community-organizer bot — no kind-1 notes, no DMs, no profile
edits)
Per-device-scoped tokens are the security property that makes
multi-client identity safe. If the bot is compromised, the attacker
can publish NIP-52 events as the user — annoying, but they can't
publish kind-1 notes, change the user's profile, send DMs, or do
anything else the user's other devices can. Revocation of the bot's
token at the bunker side is one RPC and doesn't disturb the target
key or any other authorized device.
#### Identity-provider options
The spec does not mandate a specific identity provider. Any system
that can answer "for chat handle X, what signer should I use?" works:
that can answer "for chat handle X, issue me a scoped connection
token to sign on their behalf" works:
- A **Lightning/Nostr account system** (LNbits, Alby Hub, etc.) — the
reference implementation, since these already hold user pubkeys
and have an auth surface that can mediate the binding flow.
reference implementation. These already hold user pubkeys and have
an authenticated session surface that can mediate the binding flow.
Per the operator-IdP pattern (§12), the IdP itself doesn't hold the
nsec — a sidecar bunker does — and the IdP brokers token issuance
to that bunker.
- A **standalone web app** with its own auth and a `bind chat handle`
flow.
- An **on-Nostr profile claim** — user publishes a kind 0 / 30311 /
similar with a chat-handle attestation; bot reads relay and
flow that issues NIP-46 connection tokens.
- An **on-Nostr profile claim** — user publishes a kind-0 / kind-10002
/ NIP-39 attestation linking the chat handle to a pubkey, with a
pre-issued bunker URL in their profile. Bot reads relay and
cryptographically verifies the claim.
- A **NIP-39 external-identity proof** in the user's profile.
Adopters without any of the above can stay on v1 (`BotSigner` only)
— per-user signing is enhancement, not requirement.
@ -752,13 +802,14 @@ Not yet decided in this draft:
- **Migration / weekly review.** Bullet-journal-style migration of
stale open items into a new period. Needs a verb (`!migrate`?)
and a state transition spec.
- **External signer story.** NIP-46 bunker integration is sketched
in §7.2 / §7.3; the LNbits reference identity provider is being
hardened in [aiolabs/lnbits#9](https://git.atitlan.io/aiolabs/lnbits/issues/9).
Pending: ergonomics of the chat-side binding flow (DM the bot? web
callback? both?), and how to handle `ClientSideOnlySigner` users
whose events can't be bot-published at all (the bot subscribes and
mirrors instead).
- **External signer story.** Architecture is concrete in §7.2 / §7.3
/ §12 (operator-IdP with sidecar `nsecbunkerd`, per-device scoped
connection tokens). Phase 1 of [aiolabs/lnbits#9](https://git.atitlan.io/aiolabs/lnbits/issues/9)
has shipped the signer abstraction; phase 2 ([#18](https://git.atitlan.io/aiolabs/lnbits/issues/18))
is the actual bunker integration. Pending design: ergonomics of the
chat-side binding flow (DM the bot? web callback? both?), and how
to handle `ClientSideOnlySigner` users whose events can't be
bot-published at all (the bot subscribes and mirrors instead).
Contributions welcome on any of these.
@ -791,20 +842,73 @@ scoping in §5. A ZeroClaw-based implementation would carry the
`["client", "maubot-tracker", "..."]`; renderers ignore the difference
since they filter by community `a`-tag.
### Reference identity provider
### Reference identity provider — operator-IdP pattern
The aiolabs reference implementation uses [LNbits](https://lnbits.com/)
(specifically the [aiolabs fork](https://git.atitlan.io/aiolabs/lnbits))
as its identity provider — each user already has an LNbits account with
a Nostr `pubkey` field; the in-progress [issue #9 hardening](https://git.atitlan.io/aiolabs/lnbits/issues/9)
introduces a `NostrSigner` abstraction (`LocalSigner` / `RemoteBunkerSigner`
/ `ClientSideOnlySigner`) that matches the per-user-signing model in §7.2
exactly. The bot stores `(chat_handle → lnbits_user_id)` and resolves the
signer per-user at publish time.
The aiolabs reference implementation runs the **operator-IdP-with-
sidecar-bunker** pattern that NIP-46 was designed for. Three processes
on the same host:
Other communities can substitute their own identity provider (any system
that maps chat handles to Nostr signers per §7.3) — LNbits is one such
provider, not the only one.
```
┌─────────────────────────────┐
│ nsecbunkerd (sidecar) │ Holds every user's target key
│ │ + the operator master.
│ - admin: M_lnbits │ Per-device scoped connection
│ - targets: X_alice, … │ tokens.
│ - per-client tokens │ Speaks NIP-46 over kind-24133;
│ - scoped perms │ admin RPC over kind-24134.
└────────────┬────────────────┘
│ kind-24133 / 24134 over internal relay
┌─────────────────────────────┐
│ LNbits (identity broker) │ Holds zero nsec material.
│ │ Stores per-user (pubkey,
│ - account → pubkey │ signer_type, signer_config)
│ - account → bunker URL │ where signer_config is the
│ - admin RPC client │ bunker URL + connection token
│ (scoped agent key) │ + scoped perms.
└────────────┬────────────────┘
│ HTTPS (token issuance, account lookup)
┌─────────────────────────────┐
│ Bot / tracker plugin / │ Holds per-user connection
│ other clients │ tokens scoped to its needs.
│ │ Speaks NIP-46 directly to
│ - NIP-46 client per user │ the bunker. Compromise →
│ for sign_event RPCs │ revoke just that token.
└─────────────────────────────┘
```
This is the architecture from [aiolabs/lnbits#9](https://git.atitlan.io/aiolabs/lnbits/issues/9)
(the IdP framing and signer abstraction) and
[aiolabs/lnbits#18](https://git.atitlan.io/aiolabs/lnbits/issues/18)
(the concrete bunker integration using
[`nsecbunkerd`](https://github.com/kind-0/nsecbunkerd)). PR #17 has
shipped phase 1 (the abstraction + transitional `LocalSigner` + the
classify migration); phase 2 (#18) is the actual bunker integration.
#### Why a sidecar bunker, not "encrypt at rest in the IdP"
The IdP attack surface (web requests, dependency tree, plugin system,
admin endpoints, the database) is large. Putting nsec material on the
other side of an RPC boundary — held by a much smaller process whose
only job is signing — bounds compromise to "we leak signed events
during the window the bunker is unavailable for revocation" rather
than "the entire user identity pool is exfiltrated". The bunker also
gets its own audit log, independent of the IdP's.
The pattern is reusable beyond LNbits — any operator who wants to
provide identity-as-a-service to their users can run this same shape
with any NIP-46-compliant bunker (`nsecbunkerd`, Pablo's reference
implementation, or any future alternative). The Nostriga 2024 NIP-46
panel describes the same architecture.
#### Adopters without an IdP
Communities without an existing identity provider can run v1
(`BotSigner` only) indefinitely. Per-user signing is enhancement,
not requirement — the community organizer use case works fine with
the bot signing as itself and human attribution carried in the
`author` tag.
---