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) grantSigningCondition— per-method/kind/content allow rulesPolicy/PolicyRule— reusable rule sets with per-rulemaxUsageCount+ expiryToken— redeemable connection grant bound to a policy, withredeemedAt/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_pubkeypermissions(e.g.sign_event:{kind}),can_read,can_writeauto_sign(defaultFalse) vsconfirm_sign(defaultTrue)expires_atpost_rate_limit_per_day— daily cap on kind:1, enforced by countingget_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
#18endgame: LNbits routes signing through aRemoteBunkerSignerover 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_bunkeris 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#18is designed to kill.
Gaps to track on our side
-
OAuth-created keys are stored recoverable, not encrypted.
create_account.tswritescurrentConfig.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.) -
No per-grant rate limiting. Upstream's
post_rate_limit_per_dayis a clean primitive we lack. Worth considering as aPolicyRulefield.