108 lines
6.3 KiB
Markdown
108 lines
6.3 KiB
Markdown
# 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.
|