docs(#25): add lnbits/nostr_bunker comparison (prior art)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-19 14:54:18 +02:00
commit 8326a16ea9

View file

@ -0,0 +1,108 @@
# nsecbunkerd vs. `lnbits/nostr_bunker`
A comparison of this daemon (the aiolabs fork of `kind-0/nsecbunkerd`) against the
upstream LNbits extension [`lnbits/nostr_bunker`](https://github.com/lnbits/nostr_bunker).
> Source verified 2026-06-19 against `lnbits/nostr_bunker@main` (`services.py`,
> `models.py`, `crud.py`). The two projects share a name and a NIP (NIP-46 remote
> signing) but are architecturally **inverted**: this daemon *uses* LNbits as a
> downstream wallet provider; the upstream extension *is* an LNbits extension that
> turns a wallet account into the bunker.
## The one thing that matters: where the nsec lives
| | nsecbunkerd (this fork) | `lnbits/nostr_bunker` |
|---|---|---|
| **Signing key location** | On the **daemon** host, separate process from LNbits | On the **LNbits** host, inside the extension DB |
| **At-rest protection** | Passphrase-encrypted (LND-style unlock) for manually-added keys | **Plaintext** in `nostr_bunker.bunkers_data.nsec` — no encryption |
| **Integration direction** | LNbits is a *downstream dependency* (wallet factory) | LNbits is the *host* (wallet account = signer identity) |
`crud.py:create_bunkers_data()` writes the nsec straight through
`db.insert("nostr_bunker.bunkers_data", ...)` with no encryption step; `models.py`
`BunkersData.nsec` is "the normalized private key stored directly." This is the exact
posture the aiolabs roadmap (`aiolabs/lnbits#18`, "no nsec at rest on LNbits") exists
to eliminate: the LNbits host runs extension code, payment plumbing, and a public API,
so disk/root compromise there must NOT equal Nostr-identity compromise. The
standalone-daemon model keeps signing off that host; the upstream extension puts the
key right back on it, unencrypted.
## Full side-by-side
| Dimension | nsecbunkerd (this fork) | `lnbits/nostr_bunker` |
|---|---|---|
| **Form factor** | Standalone Node daemon (own process/container) | LNbits extension, runs inside the LNbits process |
| **Stack** | TypeScript + NDK 3.0.3 + nostr-tools 2.20 + Prisma/SQLite | Python + Vue/Quasar UMD frontend |
| **Relay transport** | Daemon opens its own relay connections (NDK); per-key kind:24133 subs pinned to explicit relays (#21) | Piggybacks the `nostrclient` extension's shared relay layer (`nostr_client.relay_manager.publish_message()`) |
| **Tenancy** | Multi-key, multi-domain, multi-user from one daemon | One bunker per wallet account; multiplexes clients via multiple `bunker://` URLs |
| **Admin / control plane** | Whitelisted admin npubs over E2E-encrypted Nostr events; separate bunker key holds no user key material; optional remote `app.nsecbunker.com` UI | LNbits admin UI; wallet owner is implicitly the operator |
| **Account provisioning** | OAuth-like flow: remote `create_account` → NIP-05 file write → NIP-89 (`kind:31990`) announce → mints LNbits wallet via `usermanager` API + nostdress `lud16` | None — the LNbits account already exists; the wallet *is* the identity |
## NIP-46 surface
Both implement NIP-46 over kind:24133 and accept **both** NIP-04 and NIP-44 v2
(upstream `services.py` tries `nip44_decrypt` first, falls back to `nip04_decrypt`).
| Method | nsecbunkerd | `lnbits/nostr_bunker` |
|---|---|---|
| `connect` | ✓ | ✓ (returns secret/ack after permission check) |
| `get_public_key` | ✓ | ✓ |
| `sign_event` | ✓ (ACL-gated, wire-name vocab #14) | ✓ (`_assert_method_allowed` + auto/confirm flow) |
| `nip04_encrypt` / `decrypt` | ✓ | ✓ |
| `nip44_encrypt` / `decrypt` | ✓ | ✓ |
| `ping` | ✓ | ✓ (`pong`) |
| `switch_relays` | — | ✓ (returns relay list as JSON) |
## Policy / permission model
This is where the designs genuinely diverge, and where upstream has something worth
borrowing.
**nsecbunkerd** — relational ACL across several tables:
- `KeyUser` — a (keyName, userPubkey) grant
- `SigningCondition` — per-method/kind/content allow rules
- `Policy` / `PolicyRule` — reusable rule sets with per-rule `maxUsageCount` + expiry
- `Token` — redeemable connection grant bound to a policy, with `redeemedAt` / `revokedAt`
- Live-policy auth re-evaluated at request time (#11)
**`lnbits/nostr_bunker`** — policy is **the `bunker://` URL itself**. Each `UrlData`
row carries its own:
- `relays`, `secret`, `client_pubkey`
- `permissions` (e.g. `sign_event:{kind}`), `can_read`, `can_write`
- `auto_sign` (default `False`) vs `confirm_sign` (default `True`)
- `expires_at`
- `post_rate_limit_per_day` — daily cap on kind:1, enforced by counting
`get_signing_requests_since()` over 24h (`_assert_post_rate_limit`)
Pending approvals live in `SigningRequest` (status: pending/approved/signed/rejected/error),
mirroring this fork's `Request` + manual-approval flow.
**Takeaway:** upstream's "one bunker, many scoped URLs, each URL is a self-contained
grant" is arguably cleaner than this fork's `Token`+`Policy`+`SigningCondition` triad
for the common case of "issue a narrowly-scoped grant to one client." If the ACL surface
here is ever simplified, that URL-as-grant model is the reference design — note in
particular the built-in `post_rate_limit_per_day`, which this fork has no direct
equivalent for.
## Where each fits the aiolabs stack
- **nsecbunkerd is the signer; LNbits is a client of it.** This is the `#18` endgame:
LNbits routes signing through a `RemoteBunkerSigner` over NIP-46 (the
protocol-over-loopback boundary chosen deliberately over a Unix socket), and every
nsec — operator *and* server identity — is retired from the LNbits host.
- **`lnbits/nostr_bunker` is the convenience inversion we're explicitly avoiding.**
Useful prior art for per-URL policy ergonomics, but adopting it as the *signer
location* would reintroduce plaintext nsec-at-rest on the payments host — the precise
thing `#18` is designed to kill.
## Gaps to track on our side
1. **OAuth-created keys are stored recoverable, not encrypted.**
`create_account.ts` writes `currentConfig.keys[keyName] = { key: key.privateKey }`,
unlike the passphrase-encrypted path the SECURITY-MODEL doc describes for
manually-added keys. The doc promises non-exfiltratable keys; the OAuth path doesn't
meet that bar. (We're still strictly better than upstream, which stores *all* nsecs
plaintext — but the doc/behavior gap is real.)
2. **No per-grant rate limiting.** Upstream's `post_rate_limit_per_day` is a clean
primitive we lack. Worth considering as a `PolicyRule` field.