# 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.