nsecbunkerd/docs/COMPARISON-lnbits-nostr_bunker.md
Padreug 8326a16ea9 docs(#25): add lnbits/nostr_bunker comparison (prior art)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:54:18 +02:00

6.3 KiB

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.

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.