diff --git a/docs/COMPARISON-lnbits-nostr_bunker.md b/docs/COMPARISON-lnbits-nostr_bunker.md new file mode 100644 index 0000000..5aa6d77 --- /dev/null +++ b/docs/COMPARISON-lnbits-nostr_bunker.md @@ -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.