Compare commits

..

No commits in common. "docs-migration-runbook" and "master" have entirely different histories.

39 changed files with 2786 additions and 6465 deletions

View file

@ -1,32 +1,20 @@
# Patched from upstream kind-0/nsecbunkerd Dockerfile to use pnpm — the
# upstream version uses `npm install` but package.json declares
# `@nostr-dev-kit/ndk` as `workspace:*`, which only pnpm understands.
# A clean clone of upstream fails to build with `EUNSUPPORTEDPROTOCOL`
# under npm. Switching to pnpm matches the lockfile that ships in-repo.
# Also drops `--frozen-lockfile` because the upstream pnpm-lock.yaml is
# out of date vs. package.json (ERR_PNPM_OUTDATED_LOCKFILE) — bug to
# file upstream once we've verified the rest of the stack works.
FROM node:20.11-bullseye AS build
WORKDIR /app
RUN npm install -g pnpm@9
# Copy lockfile + manifest first so the install layer caches across
# source changes.
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --no-frozen-lockfile
# Copy package files and install dependencies
COPY package*.json ./
RUN npm install
# Copy application files
COPY . .
# Generate prisma client and build the application
RUN npx prisma generate
RUN pnpm run build
RUN npm run build
# Runtime stage
FROM node:20.11-alpine AS runtime
FROM node:20.11-alpine as runtime
WORKDIR /app
@ -34,25 +22,13 @@ RUN apk update && \
apk add --no-cache openssl && \
rm -rf /var/cache/apk/*
RUN npm install -g pnpm@9
# Copy built files from the build stage
COPY --from=build /app .
# Install all dependencies (including devDeps). The prisma CLI lives in
# devDependencies but scripts/start.js invokes `prisma migrate deploy`
# at boot, so it must be available at runtime. Dropping --prod adds the
# CLI tooling to the runtime image — a modest size cost for the
# correctness of the migration step.
RUN pnpm install --no-frozen-lockfile
# Install only runtime dependencies
RUN npm install --only=production
EXPOSE 3000
# Run via scripts/start.js so `prisma migrate deploy` applies pending
# migrations before the daemon spawns. The upstream Dockerfile invokes
# ./dist/index.js directly, which silently bypasses the migration step
# and leaves the SQLite db empty on first boot — every command that
# touches Policy/KeyUser/Token/etc. then throws "table does not exist."
# Caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
ENTRYPOINT [ "node", "./scripts/start.js" ]
ENTRYPOINT [ "node", "./dist/index.js" ]
CMD ["start"]

View file

@ -1,143 +0,0 @@
# Boot-time autounlock
`nsecbunkerd` stores each managed key encrypted at rest in
`nsecbunker.db`. By default, every key is **locked** after the daemon
starts — clients must drive an `unlock_key` admin RPC against the
bunker before signing / encrypting / decrypting works for that key.
Autounlock is an opt-in feature that, when enabled, reads a
passphrase from a configured source at boot and unlocks every
non-soft-deleted key in the `Key` table automatically. This trades
operational simplicity for a documented security weakening; read
this whole document before enabling.
## Configuration
Two mutually-exclusive environment variables:
| Var | Meaning |
|---|---|
| `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE` | Literal passphrase string. Useful for dev / `docker compose .env` flows. |
| `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE` | Path to a file containing the passphrase (newline-trimmed at read). Idiomatic for sops / systemd-LoadCredential / k8s-secret / external secrets-manager flows where the passphrase comes from a separate credential store. |
**If both are set, the daemon fails loud at boot** with an explicit
error. Ambiguous config is never allowed to silently pick one.
**If neither is set, autounlock is off** — behavior is identical to
pre-#16: keys remain locked until an admin `unlock_key` RPC fires per
key per restart.
## What happens at boot when autounlock is on
After the daemon's existing key-loading passes complete (unencrypted
keys from in-process config, plain-key entries in `nsecbunker.json`),
the autounlock pass runs:
1. Read the passphrase from the configured source. Failure to read
(missing file, no permission) is fatal at boot.
2. Enumerate the encrypted-at-rest entries in `nsecbunker.json`'s
`keys` map — entries carrying the `{iv, data}` shape from
`create_new_key`. Plain-key entries (`{key: ...}` shape from
`create_account`) are already loaded by the existing
`startKeys()` passes and are skipped here for log clarity.
3. For each candidate, call `unlockKey(keyName, passphrase)`.
`unlockKey` is idempotent post-#16: if the key was already
unlocked by a prior pass, it's a no-op.
4. Log per-key INFO on success, WARN on `unlockKey → false`
(typically: wrong passphrase, possibly the key was created under a
historical passphrase that differs from the current one), ERROR on
throw (typically: corrupted blob).
5. Log one summary line:
`🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>`.
The loop is sequential — log clarity > parallelism, the unlock op
itself is cheap (one ChaCha20 decrypt per key). For 100 keys it's
milliseconds. If a fleet ever needs the thousands, parallelize then.
The NIP-46 client channel doesn't accept RPCs that route to a key
until that key's `Backend.start()` resolves — which happens inside
`unlockKey`. So there's no race window where a freshly-restarted
bunker would say "key locked" to a client while the loop is in
flight on that key.
## The security trade-off
Enabling autounlock means **whoever can read the passphrase source
can recover any key from the bunker disk.** Specifically:
- The encrypt-at-rest property of `nsecbunker.db` is *preserved*
against `cat /var/lib/nsecbunker/*.db` alone — the database holds
ciphertext + IV per key, not plaintext.
- The encrypt-at-rest property is *lost* if the attacker also has
access to the passphrase source. Anyone with read access to the
passphrase env var, the passphrase file, or the process memory at
the moment of autounlock can decrypt every key.
This is the same trade today's deployments already make when they
hold the passphrase in `lnbits`'s env to drive `unlock_key` RPCs
post-restart. Autounlock makes the trade *explicit at the bunker
level* and *visible per-deployment*, but it doesn't introduce a new
trust requirement that didn't already exist for any deployment using
external automation to drive unlocks.
### Recommendations by deployment shape
- **Dev / regtest / single-host:** literal `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE`
in `docker compose .env` is fine. The threat model on a dev box
doesn't justify the file-source ceremony.
- **Single-tenant production:** passphrase file on a separate
volume / mount with stricter access. Mount via
`systemd-LoadCredential` so the file is only readable by the
bunker process and is materialized from a sops-decrypted source
at boot. Avoid baking the passphrase into the container image or
process env list (which leaks into `ps aux`, container labels, etc.).
- **Multi-tenant / high-security:** leave autounlock off. Orchestrate
unlock per-restart from an external process that prompts for the
passphrase out-of-band (hardware token, HSM-derived secret, human
approval). This preserves the property that bunker startup alone
doesn't restore crypto capability — a deliberate human action is
required.
## What's *not* in scope
These are deliberately out of scope for the autounlock feature.
Separate issues to file if needed:
- **Per-key passphrase support.** The current `Key` table doesn't
carry per-key passphrase metadata; every `create_new_key(name, passphrase)`
in our usage today uses the same passphrase
(`LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE`). The autounlock
passphrase covers every encrypted key by virtue of this
single-passphrase invariant. If a deployment ever needs per-key
passphrases, that's a separate feature (per-key passphrase-selector
column + per-key passphrase map).
- **Passphrase rotation.** Re-encrypting every key under a new
passphrase belongs in a dedicated admin RPC (`rotate_keystore`),
not in autounlock.
- **HSM / hardware-derived passphrase delivery.** Orthogonal to
where the passphrase comes from at unlock time — autounlock just
reads a string. An HSM integration would land between the
hardware and the file the bunker reads from.
## Observability hooks
The autounlock pass emits:
- `🔓 autounlock: unlocked <keyName>` (INFO, one per success)
- `⚠️ autounlock: unlockKey returned false for <keyName> ...` (WARN, one per soft failure)
- `❌ autounlock: <keyName> failed: <err.message>` (ERROR, one per throw)
- `🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>` (summary, once)
When the optional Prometheus exporter lands, counters
`nsecbunkerd_keys_unlocked_total` and `nsecbunkerd_keys_locked_total`
will be reported from the autounlock summary state. The current
implementation doesn't export metrics — the log line is the
canonical signal.
## See also
- `src/daemon/run.ts:Daemon.maybeAutounlock` — implementation
- `src/daemon/run.ts:Daemon.unlockKey` — the idempotent per-key call
- `src/daemon/admin/commands/unlock_key.ts` — the admin-RPC wrapper for manual unlock
- aiolabs/nsecbunkerd#16 — issue with full design rationale + acceptance criteria
- aiolabs/nsecbunkerd#15 — NDK 3.0.3 bump (the structural fix this builds on)

View file

@ -1,108 +0,0 @@
# 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.

View file

@ -1,297 +0,0 @@
# ACL prior-art survey — NIP-46 bunker implementations
Source-verified survey of how other open-source NIP-46 remote signers model
authorization and grant lifecycle, run to inform the #25 ACL redesign (enforce
token + grant lifecycle live at sign time instead of via a materialized cache).
> **Verification status.** Every claim below was read against actual source on
> 2026-06-19 (clones at the commits noted per project). An initial automated
> survey overstated several implementations (notably "Signet enforces all
> lifecycle live" — false); the corrections are called out inline. Treat the
> file:line citations as the authority, not the prose summaries.
## TL;DR for the redesign
- **Amber** is the one *positive* live-lifecycle template: store the absolute
deadline on the grant row, recompute the verdict against `now()` on every
request, treat the periodic sweep as cleanup only. It also time-boxes
*denials*, not just grants.
- **Signet** (a fork of our own codebase) re-shipped our #24 bug — proof that
materializing a policy photocopy without a live join cannot enforce
grant-level TTL/usage. Its schema is still the best reference for the
token/policy decomposition (minus the one `applyToken` materialization line).
- **FROSTR** has the cleanest *revocation decomposition* (3 independent layers)
and a good auditable-credential table — but enforces **no** live expiry
anywhere.
- **promenade** confirms the **revoke = re-key** anti-pattern to avoid, and
debunks "FROST can't decrypt DMs" (it's a design choice, not a math limit).
- **NDK** (which we embed) is a deliberately *blank* permit seam: we own 100% of
policy — and `get_public_key` bypasses the seam entirely (see #26).
Decision unchanged: **Option D, leaning D1.** Amber = live-evaluation reference;
Signet = schema reference; FROSTR = revocation-decomposition reference; NDK =
confirmed blank seam.
## Strategic decision: keep our fork, treat Signet as a parts donor (2026-06-19)
Signet is a fork/re-architecture of the same kind-0/nsecbunkerd lineage we
maintain, and is feature-richer on the standalone-operator surface (trust dial,
suspension, NIP-49 at-rest, two-tier tokens, kill-switch, React dashboard,
Android companion). We considered adopting it wholesale. **Decision: no — keep
our fork as what we ship; lift Signet's patterns as needed.**
Why:
- **Replacing doesn't solve #25.** Signet re-ships our exact #24 (materialized
photocopy, no live grant-level join). We'd still have to do the live-join work
— after paying a migration cost.
- **We'd lose the integration that makes it ours.** LNbits wallet provisioning
(`usermanager` + nostdress), the OAuth-like `create_account` flow, and being
the signer target for the #18 `RemoteBunkerSigner` endgame. Porting those into
Signet just means maintaining a fork of a more opinionated upstream.
- **Lineage/bus-factor.** Our `master` tracks the canonical kind-0 upstream;
Signet is a solo-maintainer rewrite with choices we may not want (removed JWT
auth, Android surface). For a security-load-bearing component that's more risk,
not less.
Why it's low-stakes either way: LNbits ↔ bunker is **NIP-46 over the wire** (the
deliberate protocol-over-IPC choice), so the signer is substitutable by design.
If our fork ever becomes a maintenance burden we can drop in any conformant
NIP-46 signer (Signet, Amber-as-bunker, HSM-backed) with config-only changes —
**not a one-way door.**
Escape hatch (option 3, parked): run Signet unmodified behind the protocol. Only
attractive if the LNbits provisioning/OAuth flows move out of the bunker into
LNbits proper (plausible under #18), which would shrink the integration gap
that's the main reason to stay. Revisit if #25 implementation reveals our
daemon's NDK/relay/ACL plumbing is materially rougher than Signet's.
---
## A — daemon/server implementations with a real policy model
### Signet — `Letdown2491/signet` (TS daemon + React UI + Kotlin companion)
MIT, very active (v1.11.0, 2026-06). An extensive re-architecture of the same
kind-0/nsecbunkerd codebase we maintain.
- **Re-ships our #24.** `applyToken` (`nip46-backend.ts:807`) checks
`Token.expiresAt` once at redeem (`:895`), then materializes `policy.rules`
into lifecycle-free `SigningCondition` rows (`:845-862`); the sign-time path
(`acl.ts:checkRequestPermission`) never reads `Token` again.
`maxUsageCount`/`currentUsageCount` are touched only in the policy CRUD route —
never enforced. Same materialization-drift bug as ours.
- **What it adds over us:** a coarse-cache layer for **subject-level** state on
`KeyUser``revokedAt`, `suspendedAt`/`suspendUntil`, `trustLevel` — read live
per request and invalidated on change (`invalidateAclCache`). Genuinely fixes
live *revoke* (our sibling spirekeeper#22). Puts revoke on **`KeyUser`, not
`Token`** — corroborating our revoke=subject / expiry=grant split.
- **Trust dial** over a kind-risk classifier: `trustLevel ∈ {paranoid,
reasonable, full}`, `SAFE_KINDS` auto / `SENSITIVE_KINDS` (0/3/4/5/wallet/
auth/NIP-04) forced manual (`acl.ts:129-161`).
- **Two-tier tokens:** one-time `ConnectionToken` (mandatory `expiresAt`,
validates connect but never auto-approves) vs policy-backed `Token` (atomic
claim `updateMany where redeemedAt:null`, `nip46-backend.ts:813`).
- **Key-at-rest:** NIP-49 ncryptsec + AES-256-GCM envelope (PBKDF2-SHA256 @600k).
- **Takeaway:** adopt its `KeyUser` subject-state + `Request` indexing; reject
its `applyToken` materialization; the `ConnectionToken`-vs-`Token` split *is*
D1 in schema form.
### Amber — `greenart7c3/Amber` (Android, Kotlin/Room) ⭐ live-lifecycle reference
MIT, very active (last commit 2026-06-19). Android signer (NIP-55 intents **and**
NIP-46 over relays). Listed in tier A despite being mobile because its permission
model is the strongest of any surveyed.
- **Grant schema** (`ApplicationPermissionsEntity.kt:18-41`): unique composite
index over `(pkKey, type, kind, relay)` — per-(app × method × kind × relay).
Columns include `acceptable: Boolean`, `rememberType: Int`, `acceptUntil:
Long`, `rejectUntil: Long`.
- **Expiry enforced LIVE** (the key finding): `IntentUtils.isRemembered()`
(`IntentUtils.kt:1087-1101`) is the per-request verdict and recomputes
`acceptUntil > TimeUtils.now()` / `rejectUntil > now()` fresh every call;
expired → returns `null` → falls through to a user prompt. Called on both the
NIP-46 relay path (`EventNotificationConsumer.kt:440-441`) and the NIP-55
intent path (`SignerProviderQuery.kt:183` etc.).
- **The sweep is non-load-bearing.** `updateExpiredPermissions(time)`
(`ApplicationDao.kt:51`, exempts `rememberType <> 4`=ALWAYS) runs every 24h via
WorkManager — pure cleanup; correctness doesn't depend on it firing because the
decision is recomputed against `now()` on read.
- **Time-boxed denials too:** `rejectUntil` means "reject for 5 min" decays back
to a prompt rather than a permanent no — a nicer primitive than a single
allow/deny flag.
- **Wildcard-as-distinct-tier:** lookup ladder is exact-kind → all-kinds
(`kind IS NULL`, `getPermissionAllKinds`, `ApplicationDao.kt:87-91`); relay
wildcard matches `'*' OR '' OR NULL` in one query (`getWildcardRelayPermission`,
`:101-106`). Wildcard rows are explicitly queried, never an accidental
missing-WHERE match.
- **Read-through LRU caches rows, not verdicts** (`CachingApplicationDao`) — keeps
the live `now()` re-check on every cache hit; invalidation is write-driven and
coarse per-app.
- **Sign policies** (`ChooseSignPolicy.kt:32-45`, stored as `signPolicy: Int`):
`0` basic / `1` manual-per-new-app / `2` fully-auto (short-circuits to allow
before any row lookup, `IntentUtils.kt:1090`).
- **Key-at-rest** (`SecureCryptoHelper.kt`): Android Keystore AES-256-GCM, 96-bit
IV / 128-bit tag, StrongBox-backed when available with TEE fallback and a
MediaTek denylist; optional app-level biometric gate.
- **NIP-46 coverage** (`SignerType.kt`, `BunkerRequestUtils.kt:232-248`): connect,
sign_event, nip04/nip44 (+v3) encrypt/decrypt, get_public_key,
decrypt_zap_event, ping, switch_relays, sign_psbt, logout; both `bunker://` and
`nostrconnect://`.
- **Steal for us:** absolute-deadline-on-row + recompute-vs-now per request;
time-boxed denials; wildcard as a distinct explicitly-queried tier; cache rows
not answers.
### FROSTR — `FROSTR-ORG/igloo-server` + `bifrost` (TS, FROST k-of-n)
MIT. igloo-server v1.2.0 (2026-05-28); bifrost v2.0.2 (2026-01-24). Threshold
Schnorr over Nostr; igloo-server exposes the NIP-46 endpoint, bifrost is the node
SDK.
- **Three independent authorization layers** (the prize):
1. **App NIP-46 policy**`Nip46Policy { methods?, kinds? }` (`db/nip46.ts:8-11`),
sessions keyed `(user_id, client_pubkey)` (`:92`), checked live per request
(`service.ts:508-509, 766-795`). No TTL/expiry. Session revoke is **explicit**
(`status='revoked'`, `:792-826`); per-method/kind revoke is **implicit** (flip
boolean false, audited at `:722-790`).
2. **Peer-transport policy** — per-peer directional `allowSend`/`allowReceive`
(`util/peer-policy.ts:3-9`, `docs/PEER_POLICIES.md`), enforced in bifrost
`_filter`/`get_recv_pubkeys` (`client.ts:226-245`). **Correction:** it's
*default-allow + explicit per-peer deny + last-layer-wins*, not "deny-override".
3. **Operator API auth** — keys stored SHA-256 hash+prefix with `revoked_at`
(checked first, timing-safe) + `last_used_at/ip` (`migrations/..._api_keys.sql`,
`database.ts:815-1047`); Argon2id password hashing (`config/crypto.ts:26-31`).
- **No layer enforces live expiry.** `nip46_requests.expires_at` exists but is
never populated; the only time-based enforcement is the in-memory derived-key
vault (TTL + bounded reads + zeroize, `auth.ts:359-459`).
- **Key-at-rest:** DB mode AES-256-GCM in SQLite, PBKDF2-HMAC-SHA256 **@600k**
(corrected from "~200k", `config/crypto.ts:7-11`); headless mode = plaintext env
(`GROUP_CRED`/`SHARE_CRED`).
- **Distributed veto** is real at the participation level (a co-signer withholding
its partial below threshold blocks the sig) but the default signer auto-signs
(`middleware: {}`, `client.ts:55`) — realizing a veto needs a custom
`middleware.sign` not shipped by default.
- **Share rotation** (recover → re-split, same group npub, old shares can't
combine) exists as a **bifrost SDK primitive** (`generate_dealer_package`), **not**
as an igloo-server endpoint; recovery reconstructs the full nsec in memory and
`/api/recover` even returns it over HTTP (`routes/recovery.ts:147-157`).
- **Steal for us:** the 3-layer revocation decomposition; audit-event-on-grant-
change; `revoked_at`-checked-first + last-used credential table.
### promenade — fiatjaf (Go, FROST coordinator + signer split)
Off GitHub; cloned from fiatjaf's nostr-git (`relay.ngit.dev/npub180c…/promenade.git`),
HEAD `70ff8439` 2026-06-18. NIP-46 method logic lives in the pinned dep
`fiatjaf.com/nostr` (`nip46.DynamicSigner`).
- **Architecture:** khatru coordinator-relay doubles as the NIP-46 endpoint, runs
the FROST ceremony, holds a transport/handler key but **no shard**
(`account_registration.go:44` carries only `frost.PublicKeyShard`); separate
signer daemons each hold one shard; m-of-n with m≤20 (`:79`). Signing-ceremony
kinds 2643026434; account registration is kind **16430** (replaceable).
- **No encrypted DMs — by choice, not by math.** `DynamicSigner` recognizes
`nip44_encrypt`/`nip44_decrypt`/`switch_relays` (`dynamic-signer.go`), but
promenade hardwires `AuthorizeEncryption → false` (`coordinator/nip46.go:167`)
and `GroupContext.Encrypt/Decrypt → "not implemented"` (`sign.go:288-302`).
README: *"destroyer of encryption."* **Correction:** threshold ECDH is NOT
impossible for FROST — `frost/ecdh.go` implements `CreateECDHShare` /
`AggregateECDHShards`; it's simply not plumbed in.
- **ACL:** `AuthorizeSigning` per sign_event (`coordinator/nip46.go:86`); named
profiles `["profile", name, secret, restrictions]` where restrictions is a
`nostr.Filter` but only `Kinds` + `Until` are enforced (`:139-159`). The secret
is a reusable bearer capability.
- **Lifecycle:** per-profile `Until` is the only time-bound; **no revoke API**
dropping one capability means re-publishing the whole kind:16430 account signed
by the **master nsec**. The **revoke = re-key anti-pattern** to avoid.
- **Key-at-rest:** nsec sharded client-side (never whole), but shards stored
**plaintext** in each signer's BoltDB (`acceptor.go:209`); coordinator/signer
identity keys from plaintext env.
- **Relevance:** confirms (1) keep grant-revoke independent of key rotation, and
(2) for the #18 "bunker for everything" endgame, threshold-protecting the server
identity wouldn't *mathematically* preclude DM decryption — but keeping ECDH on a
separate non-threshold key is the cheaper path.
---
## B — library/SDK signer seams
### NDK — `nostr-dev-kit/ndk` (we embed this) @ `4b86acd` (2026-04-05)
nip46 under `core/src/signers/nip46/`.
- Backend `NDKNip46Backend` (`backend/index.ts:58`), client `NDKNip46Signer`
(`index.ts:60`).
- Permit seam: `Nip46PermitCallback = (params: {id, pubkey, method, params?}) =>
Promise<boolean>` (`backend/index.ts:29-43`), invoked via overridable
`pubkeyAllowed()` (`:229-231`) from each strategy.
- **`get_public_key` bypasses the seam** — `backend/get-public-key.ts:3-11`
returns the pubkey with no `pubkeyAllowed` call. (rust-nostr's `approve()` wraps
every method including this one.) See #26.
- Signature verified before dispatch (`index.ts:181`); strategies swappable
(`setStrategy`, `:156-158`).
- `applyToken(pubkey, token)` default-throws (`:166-168`), invoked by the connect
handler when a token is present (`connect.ts:21-24`) — token policy is the
embedder's job.
- **No** built-in scoping/kinds/rate-limit/expiry/persistence — all policy lives
behind the one callback. We own 100% of the policy engine.
### rust-nostr / nostr-sdk @ `e47b572` (v0.45.0-alpha.1)
- `NostrConnectRemoteSigner` (`signer.rs:39`) + `NostrConnect` client.
- Trait `NostrConnectSignerActions::approve(&self, public_key, req) -> bool`
(`signer.rs:342-345`), synchronous bool, wraps the **entire** request match in
`serve()` (`:201-202`) — gates every method **including** `get_public_key`.
- FFI (uniffi/wasm) exposes **only the client** `NostrConnect`, not the backend —
no non-Rust embedding of the signer side.
### nak — `fiatjaf/nak` bunker subcommand @ `483bf94`
- Allow-list of client pubkeys (`BunkerConfig.Clients`), `--persist`s 0600 JSON.
- Once authorized, **signs everything** — no method/kind scoping, no expiry, no
rate limiting. Notably its underlying lib computes a `harmless` (connect/
get_public_key/ping) vs dangerous (sign/encrypt/decrypt) hint that nak
**discards**. A bare always-sign baseline.
---
## C — clients / extensions (less relevant; novel UX only)
- **keys.band** — Svelte Chrome extension (NIP-07): the one browser signer with
*time-bounded* authorization grants (allow-for-N-minutes/session). Relevant to a
TTL-grant UX.
- **nos2x / nos2x-fox** (fiatjaf) — origin of the per-origin "remember / allow
this site" NIP-07 model; key stored ~plaintext in extension storage.
- **Gossip** (Rust desktop) — not a bunker, but best-in-class key-at-rest:
passphrase-encrypted on disk, startup unlock, memory zeroed before free. Clean
`LocalSigner` envelope reference.
- **Primal**, **nowser** (Flutter) — clients that also serve NIP-46/NIP-55; use the
standard `optional_requested_perms` per-method/per-kind grammar.
---
## D — not bunkers / dead
- **`Letdown2491/nip46-relay`** — a NIP-46 *transport relay* (forwards opaque
blobs), no signing/authz. Appears next to Signet; easy to mistake for a signer.
- **Keychat** — Signal-over-Nostr chat app; signs only its own events.
- **python-nostr** — abandoned 2022, no NIP-46. (No Python library offers a
signer-side permission abstraction; a Python bunker means hand-rolling the
kind-24133 loop or driving rust-nostr via FFI — and the FFI exposes only the
client.)
---
## Patterns worth stealing — consolidated
1. **Live evaluation (Amber):** absolute deadline on the grant row; verdict is a
pure function recomputed vs `now()` per request; sweep is cleanup-only. This is
Option D, proven in production.
2. **Time-box denials too (Amber `rejectUntil`):** a deny decays to a prompt.
3. **Wildcard as a distinct, explicitly-queried tier (Amber):** never a fuzzy
missing-WHERE match in the auto-decide path.
4. **Cache rows, never verdicts (Amber `CachingApplicationDao`, Signet coarse
cache):** keep the `now()` re-check on every hit; invalidate on write.
5. **Subject vs grant separation (Signet):** revoke/suspend/trust on `KeyUser`
(cheap, cache+invalidate); expiry/usage on `Token`/`Policy` (must join live).
6. **Usage = COUNT(Request) in window (lnbits/nostr_bunker), not a mutable
counter:** drop `currentUsageCount`; needs `Request.keyUserId` + index.
7. **Revocation decomposition (FROSTR):** app-grant revoke ≠ transport quarantine ≠
key rotation. Never collapse grant-revoke into re-key (promenade anti-pattern).
8. **Auditable, revocable credentials (FROSTR):** `revoked_at` checked first +
last-used tracking; audit-event-on-grant-change decoupled from enforcement.
9. **Single predicate `grantIsLive(now)`** used at both redeem and sign time
(the discipline that prevents the original drift).
10. **NDK seam reality:** we own all policy; design around `get_public_key`
bypassing `pubkeyAllowed`.

View file

@ -1,71 +0,0 @@
# nsecbunkerd migration & DB-maintenance runbook
Operational notes for applying schema migrations and ACL/pairing maintenance on
deployed, **LNbits-connected** nsecbunkerd instances.
## ⚠️ Never full-wipe `nsecbunker.db` on an LNbits-connected instance
The nsecbunkerd ↔ LNbits pairing is **split across both systems**:
- **Bunker** (`nsecbunker.db`): per-account `KeyUser` binding (keyed by LNbits's
stable client pubkey) + redeemed `Token` + a shared `Policy`.
- **LNbits** (`accounts.signer_config`, `RemoteBunkerSigner`):
`{token, client_nsec, policy_id}`.
`RemoteBunkerSigner.sign_event()` signs **directly with the stored `client_nsec`**
— it does NOT re-connect/re-redeem, and there is **no auto-repair** on restart or
sign-failure. `provision()` runs only at new-account creation and mints a NEW
npub (which changes the user's nostr identity).
**Consequence of a full `nsecbunker.db` wipe:** the `KeyUser` bindings are
deleted → every LNbits account's stored config dangles → all signing fails, and
the only standard "repair" (`provision()`) changes identities. **Do not do it.**
### Correct way to strip the #24 materialized photocopies
Post-#27, token grants are evaluated live via the ACL step-4 `Token → Policy →
PolicyRule` join, so the old materialized `SigningCondition` rows are redundant
— and, written with `expiresAt = NULL`, they would keep granting past a token's
expiry (silently re-opening #24 for already-paired clients). Strip them with a
**targeted delete** that preserves the pairing:
```sql
-- Verify first.
SELECT COUNT(*) FROM Token WHERE redeemedAt IS NOT NULL; -- bindings to preserve
SELECT COUNT(*) FROM SigningCondition; -- photocopies to strip
-- Keeps KeyUser + Token + Policy intact. Live-token clients keep working
-- untouched; only the stale photocopies are removed.
DELETE FROM SigningCondition;
```
Run against each instance's `nsecbunker.db`. If an instance was already
full-wiped, recover by restoring the pre-wipe `nsecbunker.db` backup, then run
the targeted delete.
> Manual-override grants (`add_signing_condition`, web-approval) also live in
> `SigningCondition`. On an LNbits-only bunker there typically are none, so a
> blanket `DELETE FROM SigningCondition` is safe. If an instance uses manual
> overrides, delete only the policy-derived rows you intend to strip.
## Keys are never in the DB
Key material lives in `nsecbunker.json` (`keys`), never in `nsecbunker.db`. A DB
wipe loses ACL/pairing state, never keys. LNbits holds the bunker **admin nsec**
(`LNBITS_NSEC_BUNKER_ADMIN_NSEC`) and is the sole admin client.
## Schema migrations
On the **nix deploy**, migrations are applied by the deploy's `prisma migrate
deploy`, not by the daemon — `start.js`'s `npm run prisma:migrate` step fails in
the read-only nix store (no `npm` on PATH) and is a redundant no-op there (#31).
On **docker**, by contrast, `start.js` IS the migration path (it's the image
`ENTRYPOINT`), so the step is not dead everywhere. After adding a migration, make
sure the path that applies for your target actually runs it.
Prisma on NixOS: the flake pins **nixos-25.05**, whose `prisma-engines` is
**6.7.0** (ships `libquery_engine.node`) — both the devShell and the deploy's
`package.nix` use it, so `prisma` (and the `npm run test:integration` suite) work
in `nix develop` out of the box. Heads-up: on **nixos-unstable / a system
`<nixpkgs>` channel**, the bare `prisma-engines` attr is 7.x with no
`libquery_engine.node`; don't run the repo's prisma against an unstable channel.

27
flake.lock generated
View file

@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767313136,
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,57 +0,0 @@
{
description = "nsecbunkerd Nostr remote signing daemon (NIP-46)";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (system:
let pkgs = pkgsFor system; in
rec {
default = nsecbunkerd;
nsecbunkerd = pkgs.callPackage ./package.nix {
# nixos-unstable splits prisma-engines into versioned attrs and
# aliases the bare `prisma-engines` to 7.x (no libquery_engine
# for our 6.x client). nixos-25.05 has only the bare attr at
# 6.7.0. Pick whichever exists so the package builds on both.
prisma-engines_6 = pkgs.prisma-engines_6 or pkgs.prisma-engines;
};
}
);
devShells = forAllSystems (system:
let pkgs = pkgsFor system; in
{
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_20
pnpm_8
prisma
prisma-engines
python3
gcc
pkg-config
openssl
sqlite
];
shellHook = ''
# Point prisma at the nix-provided engines so it doesn't try to
# download them from binaries.prisma.sh on every install.
export PRISMA_QUERY_ENGINE_BINARY=${pkgs.prisma-engines}/bin/query-engine
export PRISMA_QUERY_ENGINE_LIBRARY=${pkgs.prisma-engines}/lib/libquery_engine.node
export PRISMA_SCHEMA_ENGINE_BINARY=${pkgs.prisma-engines}/bin/schema-engine
export PRISMA_FMT_BINARY=${pkgs.prisma-engines}/bin/prisma-fmt
export PRISMA_INTROSPECTION_ENGINE_BINARY=${pkgs.prisma-engines}/bin/introspection-engine
export PRISMA_CLIENT_ENGINE_TYPE=binary
'';
};
}
);
};
}

View file

@ -21,7 +21,6 @@
"scripts": {
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client",
"build:client": "tsup src/client.ts -d dist/client",
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/*.test.ts",
"prisma:generate": "npx prisma generate",
"prisma:migrate": "npx prisma migrate deploy",
"prisma:create": "npx prisma db push --preview-feature",
@ -40,8 +39,8 @@
"@fastify/view": "^8.2.0",
"@inquirer/password": "^1.1.2",
"@inquirer/prompts": "^1.2.3",
"@nostr-dev-kit/ndk": "3.0.3",
"@prisma/client": "^6.19.0",
"@nostr-dev-kit/ndk": "workspace:*",
"@prisma/client": "^5.4.1",
"@scure/base": "^1.1.1",
"@types/yargs": "^17.0.24",
"axios": "^1.6.2",
@ -58,7 +57,7 @@
"isomorphic-ws": "^5.0.0",
"lnbits": "^1.1.5",
"lnbits-ts": "^0.0.2",
"nostr-tools": "~2.20.0",
"nostr-tools": "^1.17.0",
"websocket-polyfill": "^0.0.3",
"ws": "^8.13.0",
"yargs": "^17.7.2"
@ -66,7 +65,7 @@
"devDependencies": {
"@types/debug": "^4.1.8",
"@types/node": "^18.16.18",
"prisma": "^6.19.0",
"prisma": "^5.4.1",
"ts-node": "^10.9.1",
"tsup": "^7.2.0",
"typescript": "^5.1.3"

View file

@ -1,146 +0,0 @@
{
lib,
stdenv,
pnpm_9,
nodejs_20,
makeWrapper,
# Pin to prisma-engines_6 (6.19.3) — package.json's `@prisma/client` +
# `prisma` are at ^6.19.0. The unversioned `prisma-engines` attr is now
# 7.x in nixpkgs which doesn't ship libquery_engine.node, so we'd fail
# at postinstall.
prisma-engines_6,
openssl,
sqlite,
python311,
pkg-config,
node-gyp,
}:
let
prisma-engines = prisma-engines_6;
# The NDK 2.8.1 → 3.0.3 bump (commit 041f431) regenerated pnpm-lock.yaml
# at lockfile v9 and pinned NDK as `"3.0.3"`. Lockfile + manifest agree
# post-bump, so the historical patch-back-to-caret-form is no longer
# required. Leave the no-op shim in place as a structural anchor; if a
# future bump regenerates the lockfile under a non-caret manifest spec
# again, this is the seam where the realignment goes.
patchNdk = "";
prismaEnv = {
PRISMA_SCHEMA_ENGINE_BINARY = lib.getExe' prisma-engines "schema-engine";
PRISMA_QUERY_ENGINE_BINARY = lib.getExe' prisma-engines "query-engine";
PRISMA_QUERY_ENGINE_LIBRARY = "${prisma-engines}/lib/libquery_engine.node";
# Prisma 6 collapsed introspection-engine into schema-engine — the
# binary no longer ships in prisma-engines_6. The env var is still
# honored if present (drops gracefully), but pointing it at a path
# that doesn't exist would fail at startup.
PRISMA_FMT_BINARY = lib.getExe' prisma-engines "prisma-fmt";
PRISMA_CLIENT_ENGINE_TYPE = "binary";
};
in
stdenv.mkDerivation (finalAttrs: {
pname = "nsecbunkerd";
version = "0.10.5";
src = ./.;
pnpmDeps = pnpm_9.fetchDeps {
inherit (finalAttrs) pname version src;
fetcherVersion = 2;
prePnpmInstall = patchNdk;
hash = "sha256-DkFzzsQTuptRR8+rWfr9RGC+5XjSQrZlsZtspWfBW8w=";
};
postPatch = patchNdk;
nativeBuildInputs = [
pnpm_9.configHook
pnpm_9
nodejs_20
makeWrapper
node-gyp
python311
pkg-config
];
buildInputs = [
openssl
sqlite
];
env = prismaEnv;
buildPhase = ''
runHook preBuild
export npm_config_nodedir=${nodejs_20}
pnpm config set nodedir ${nodejs_20}
# configHook ran with --ignore-scripts; re-run install to trigger
# native-module postinstall (bcrypt). --offline keeps it inside the
# store seeded by configHook.
pnpm install --force --offline --frozen-lockfile --reporter=append-only
pnpm prisma generate
pnpm build
# Do NOT `pnpm prune --prod` here — the prisma CLI lives in
# devDependencies and `scripts/start.js` invokes it at boot via
# `npx prisma migrate deploy`. Without the CLI, the migration step
# silently fails (npx falls back to downloading prisma fresh, which
# OOMs on most containers) and the SQLite db stays empty. See
# `aiolabs/nsecbunkerd#7` diagnosis 2026-05-27.
find node_modules -xtype l -delete
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/{bin,share/nsecbunkerd}
# scripts/ MUST be copied — it contains the start.js launcher that
# runs `prisma migrate deploy` before spawning the daemon. The
# upstream packaging (and the upstream Dockerfile) bypassed this by
# invoking dist/index.js directly, leaving migrations unapplied.
cp -r dist node_modules prisma scripts templates package.json \
$out/share/nsecbunkerd/
# Wrapper invokes scripts/start.js, which runs `prisma migrate deploy`
# then spawns dist/index.js. start.js resolves sibling paths from
# __dirname, so the caller (systemd unit, docker compose, etc.) can
# set its own WorkingDirectory for the writable state dir without
# interfering with how the launcher finds its own package files.
# NSEC_BUNKER_CONFIG_DIR can override the config directory location;
# by default it's `./config` relative to cwd.
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunkerd \
--add-flags $out/share/nsecbunkerd/scripts/start.js \
--set NODE_ENV production \
--prefix PATH : ${lib.makeBinPath [ openssl nodejs_20 ]} \
${
lib.concatStringsSep " \\\n " (
lib.mapAttrsToList (n: v: "--set ${n} ${lib.escapeShellArg v}") prismaEnv
)
}
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunker-client \
--chdir $out/share/nsecbunkerd \
--add-flags $out/share/nsecbunkerd/dist/client/client.js \
--set NODE_ENV production
runHook postInstall
'';
passthru = {
inherit prisma-engines;
};
meta = {
description = "Nostr remote signing daemon (NIP-46)";
homepage = "https://github.com/kind-0/nsecbunkerd";
license = lib.licenses.mit;
mainProgram = "nsecbunkerd";
platforms = lib.platforms.linux;
};
})

6938
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Token" ADD COLUMN "revokedAt" DATETIME;

View file

@ -1,37 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Request" (
"id" TEXT NOT NULL PRIMARY KEY,
"keyName" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"requestId" TEXT NOT NULL,
"remotePubkey" TEXT NOT NULL,
"method" TEXT NOT NULL,
"params" TEXT,
"allowed" BOOLEAN,
"keyUserId" INTEGER,
CONSTRAINT "Request_keyUserId_fkey" FOREIGN KEY ("keyUserId") REFERENCES "KeyUser" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Request" ("allowed", "createdAt", "id", "keyName", "method", "params", "remotePubkey", "requestId") SELECT "allowed", "createdAt", "id", "keyName", "method", "params", "remotePubkey", "requestId" FROM "Request";
DROP TABLE "Request";
ALTER TABLE "new_Request" RENAME TO "Request";
CREATE INDEX "Request_keyUserId_method_idx" ON "Request"("keyUserId", "method");
CREATE TABLE "new_SigningCondition" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"method" TEXT,
"kind" TEXT,
"content" TEXT,
"keyUserKeyName" TEXT,
"allowed" BOOLEAN,
"keyUserId" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME,
"revokedAt" DATETIME,
CONSTRAINT "SigningCondition_keyUserId_fkey" FOREIGN KEY ("keyUserId") REFERENCES "KeyUser" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_SigningCondition" ("allowed", "content", "id", "keyUserId", "keyUserKeyName", "kind", "method") SELECT "allowed", "content", "id", "keyUserId", "keyUserKeyName", "kind", "method" FROM "SigningCondition";
DROP TABLE "SigningCondition";
ALTER TABLE "new_SigningCondition" RENAME TO "SigningCondition";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -17,14 +17,6 @@ model Request {
method String
params String?
allowed Boolean?
// Bind each request to the KeyUser it was evaluated against so usage
// caps can be derived live by COUNTing allowed Requests, instead of
// maintaining a mutable PolicyRule.currentUsageCount that drifts.
// See aiolabs/nsecbunkerd#25 (Option D, derive-don't-count).
keyUserId Int?
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
@@index([keyUserId, method])
}
model KeyUser {
@ -39,7 +31,6 @@ model KeyUser {
logs Log[]
signingConditions SigningCondition[]
Token Token[]
requests Request[]
@@unique([keyName, userPubkey], name: "unique_key_user")
}
@ -65,13 +56,6 @@ model User {
pubkey String
}
// The SigningCondition layer is the MANUAL-OVERRIDE source of truth
// (web-approval / add_signing_condition / create_account bootstrap) — it is
// no longer materialized from token policies (see aiolabs/nsecbunkerd#25:
// applyToken stopped photocopying; token grants are evaluated live off
// Token -> Policy -> PolicyRule). Under D1 the override layer carries its
// own lifecycle so it runs through the same grantIsLive(now) predicate as
// token grants.
model SigningCondition {
id Int @id @default(autoincrement())
method String?
@ -80,9 +64,6 @@ model SigningCondition {
keyUserKeyName String?
allowed Boolean?
keyUserId Int?
createdAt DateTime @default(now())
expiresAt DateTime?
revokedAt DateTime?
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
}
@ -129,7 +110,6 @@ model Token {
deletedAt DateTime?
expiresAt DateTime?
redeemedAt DateTime?
revokedAt DateTime?
keyUserId Int?
policyId Int?
policy Policy? @relation(fields: [policyId], references: [id])

View file

@ -1,32 +1,20 @@
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
// Resolve sibling paths from this script's location so the launcher
// works whether cwd is /app (docker), the nix store, or a writable
// state dir set by systemd's WorkingDirectory. The prisma CLI and
// dist/index.js live alongside this file in `<pkg>/share/nsecbunkerd/`
// (nix) or `/app/` (docker). The migration-side env knobs:
// NSEC_BUNKER_CONFIG_DIR — directory holding nsecbunker.{json,db};
// defaults to ./config relative to cwd.
// DATABASE_URL — prisma's source of truth for the sqlite
// path; honor whatever the caller set.
const pkgRoot = path.resolve(__dirname, '..');
const configDir = process.env.NSEC_BUNKER_CONFIG_DIR || path.resolve(process.cwd(), 'config');
try {
console.log(`Running migrations`);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
// check if config folder exists
if (!fs.existsSync('./config')) {
execSync(`mkdir config`);
}
execSync('npm run prisma:migrate', { cwd: pkgRoot, stdio: 'inherit' });
execSync('npm run prisma:migrate');
} catch (error) {
console.log(error);
// Handle any potential migration errors here
}
const args = process.argv.slice(2);
const childProcess = spawn('node', [path.join(pkgRoot, 'dist/index.js'), ...args], {
const childProcess = spawn('node', ['./dist/index.js', ...args], {
stdio: 'inherit',
});

View file

@ -100,7 +100,7 @@ function loadPrivateKey(): string | undefined {
} else {
// check if we have a @ so we try to get the npub from nip05
if (remotePubkey.includes('@')) {
const u = await NDKUser.fromNip05(remotePubkey, ndk);
const u = await NDKUser.fromNip05(remotePubkey);
if (!u) {
console.log(`Invalid nip05 ${remotePubkey}`);
process.exit(1);

View file

@ -1,48 +0,0 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
* Add a single PolicyRule to an existing Policy. Under the live-policy
* auth model (#11), this propagates to every KeyUser bound to the
* policy at the next sign-time check.
*
* Param shape (JSON-stringified):
* { policyId: number,
* rule: { method: string,
* kind?: number | "all" | null,
* maxUsageCount?: number } }
*
* `kind` is stored as a string for parity with create_new_policy.ts's
* `rule.kind.toString()` storage and the override-layer convention. The
* `'all'` literal is honored at sign-time as a wildcard across kinds.
*/
export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { policyId, rule } = payload;
if (typeof policyId !== "number" || !rule || !rule.method) {
throw new Error("Invalid params");
}
const policy = await prisma.policy.findUnique({ where: { id: policyId } });
if (!policy) throw new Error("Policy not found");
await prisma.policyRule.create({
data: {
policyId,
method: rule.method,
kind: rule.kind !== undefined && rule.kind !== null ? rule.kind.toString() : null,
maxUsageCount: rule.maxUsageCount,
currentUsageCount: 0,
}
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,47 +0,0 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
* Add a SigningCondition override row for a specific KeyUser. Used to
* grant a user a (method, kind) combination beyond the policy, or to
* deny one explicitly even when the policy allows.
*
* Param shape (JSON-stringified):
* { keyUserId: number,
* condition: { method: string,
* kind?: number | "all",
* allowed: boolean } }
*
* The override layer is consulted before the live policy join in
* checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here
* denies regardless of the policy.
*/
export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { keyUserId, condition } = payload;
if (typeof keyUserId !== "number" || !condition || !condition.method || typeof condition.allowed !== "boolean") {
throw new Error("Invalid params");
}
const keyUser = await prisma.keyUser.findUnique({ where: { id: keyUserId } });
if (!keyUser) throw new Error("KeyUser not found");
await prisma.signingCondition.create({
data: {
keyUserId,
method: condition.method,
kind: condition.kind !== undefined && condition.kind !== null ? condition.kind.toString() : null,
allowed: condition.allowed,
}
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,4 +1,4 @@
import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
import AdminInterface from "..";
import { nip19 } from 'nostr-tools';
import { setupSkeletonProfile } from "../../lib/profile";
@ -131,15 +131,12 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
username = payload[0];
domain = payload[1];
email = payload[2];
if (!username || !domain) {
throw new Error('Invalid authorization payload: missing username/domain');
}
return createAccountReal(admin, req, username, domain, email);
}
}
/**
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen pragma: allowlist secret
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen
*/
export async function createAccountReal(
admin: AdminInterface,
@ -198,7 +195,7 @@ export async function createAccountReal(
}
const keyName = nip05;
const nsec = key.nsec;
const nsec = nip19.nsecEncode(key.privateKey!);
currentConfig.keys[keyName] = { key: key.privateKey };
saveCurrentConfig(admin.configFile, currentConfig);
@ -212,18 +209,11 @@ export async function createAccountReal(
// access it without having to go through an approval flow
await grantPermissions(req, keyName);
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — it resolves
// to `undefined` and sendResponse defaults to NDKKind.NostrConnect
// (24133), sending the response on the wrong channel. Mirror the
// request's kind so the response goes back on the same channel the
// client subscribed for. Filed as part of aiolabs/nsecbunkerd#7
// diagnosis 2026-05-27.
const originalKind = req.event.kind!;
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, originalKind);
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin);
} catch (e: any) {
console.trace('error', e);
const originalKind = req.event.kind!;
return admin.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, e.message);
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin,
e.message);
}
}

View file

@ -1,7 +1,7 @@
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import { saveEncrypted } from "../../../commands/add.js";
import { nip19 } from 'nostr-tools';
import { setupSkeletonProfile } from "../../lib/profile.js";
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
@ -13,9 +13,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
let key;
if (_nsec) {
// NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
// (see core/src/signers/private-key/index.ts `@ai-guardrail`).
key = new NDKPrivateKeySigner(_nsec);
key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
} else {
key = NDKPrivateKeySigner.generate();
@ -25,7 +23,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
}
const user = await key.user();
const nsec = key.nsec;
const nsec = nip19.nsecEncode(key.privateKey!);
await saveEncrypted(
admin.configFile,
@ -40,5 +38,5 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
npub: user.npub,
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -1,6 +1,5 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) {
@ -30,5 +29,5 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
}
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -1,6 +1,5 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) {
@ -8,19 +7,15 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
if (!clientName || !policyId) throw new Error("Invalid params");
const policyIdInt = parseInt(policyId);
const policy = await prisma.policy.findUnique({ where: { id: policyIdInt }, include: { rules: true } });
const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } });
if (!policy) throw new Error("Policy not found");
console.log({clientName, policy, durationInHours});
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
// policyId must be Int per the Prisma schema (Token.policyId references
// Policy.id which is autoincrement Int). Upstream passes the raw string
// from the wire — caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
const data: any = {
keyName, clientName, policyId: policyIdInt,
keyName, clientName, policyId,
createdBy: req.pubkey,
token
};
@ -31,5 +26,5 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
if (!tokenRecord) throw new Error("Token not created");
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -1,7 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
export default async function ping(admin: AdminInterface, req: NDKRpcRequest) {
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND);
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", 24134);
}

View file

@ -1,34 +0,0 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
* Delete a single PolicyRule by id. Under the live-policy auth model
* (#11), removal takes effect at the next sign-time check for every
* KeyUser bound to that policy.
*
* Param shape (JSON-stringified):
* { ruleId: number }
*
* Multi-instance bunker-sharing caveat (per comment 1473 §2):
* downstream reconcilers (e.g. lnbits' lnbits-default policy
* convergence) should treat this as an admin-only op concurrent
* removes across instance versions can race. Adds are safe, removes
* are not.
*/
export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { ruleId } = payload;
if (typeof ruleId !== "number") throw new Error("Invalid params");
await prisma.policyRule.delete({ where: { id: ruleId } });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,27 +0,0 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
* Delete a SigningCondition override row by id. Used to walk back a
* per-user grant or deny without affecting the policy.
*
* Param shape (JSON-stringified):
* { conditionId: number }
*/
export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { conditionId } = payload;
if (typeof conditionId !== "number") throw new Error("Invalid params");
await prisma.signingCondition.delete({ where: { id: conditionId } });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,6 +1,5 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRequest) {
@ -26,5 +25,5 @@ export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRe
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -1,37 +0,0 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
* Revoke a single Token without affecting other tokens for the same
* KeyUser. Sets Token.revokedAt to now; the live-policy auth check
* (#11) filters tokens with `revokedAt IS NULL` so a revoked token's
* policy no longer contributes to any sign-time grant for the
* associated KeyUser.
*
* Param shape (JSON-stringified):
* { tokenId: number }
*
* The KeyUser remains intact and any other (non-revoked) tokens
* bound to it continue to grant via their own policies. Use
* revoke_user for the binary "this user is gone" case.
*/
export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { tokenId } = payload;
if (typeof tokenId !== "number") throw new Error("Invalid params");
await prisma.token.update({
where: { id: tokenId },
data: { revokedAt: new Date() },
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,6 +1,5 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
export default async function revokeUser(admin: AdminInterface, req: NDKRpcRequest) {
@ -21,5 +20,5 @@ export default async function revokeUser(admin: AdminInterface, req: NDKRpcReque
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -1,6 +1,5 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) {
const [ keyName, passphrase ] = req.params as [ string, string ];
@ -17,5 +16,5 @@ export default async function unlockKey(admin: AdminInterface, req: NDKRpcReques
result = JSON.stringify({ success: false, error: e.message });
}
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -1,44 +0,0 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
* Update mutable fields on a Policy. Currently `name` and `expiresAt`.
* Other Policy columns (`description`, `deletedAt`, etc.) are not
* exposed by this RPC they aren't in the ratified shape, and we want
* a single point of intent.
*
* Param shape (JSON-stringified):
* { policyId: number,
* patch: { name?: string,
* expiresAt?: string | null } }
*
* `expiresAt: null` explicitly clears the field; `expiresAt` absent
* from the patch leaves it alone.
*/
export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { policyId, patch } = payload;
if (typeof policyId !== "number" || !patch || typeof patch !== "object") {
throw new Error("Invalid params");
}
const data: { name?: string; expiresAt?: Date | null } = {};
if (patch.name !== undefined) data.name = patch.name;
if (patch.expiresAt !== undefined) {
data.expiresAt = patch.expiresAt === null ? null : new Date(patch.expiresAt);
}
if (Object.keys(data).length === 0) throw new Error("Empty patch");
await prisma.policy.update({ where: { id: policyId }, data });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,5 @@
import "websocket-polyfill";
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk';
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk';
import { NDKNostrRpc } from '@nostr-dev-kit/ndk';
import createDebug from 'debug';
import { Key, KeyUser } from '../run';
@ -13,19 +13,11 @@ import createNewToken from './commands/create_new_token';
import unlockKey from './commands/unlock_key';
import renameKeyUser from './commands/rename_key_user.js';
import revokeUser from './commands/revoke_user';
import addPolicyRule from './commands/add_policy_rule';
import removePolicyRule from './commands/remove_policy_rule';
import updatePolicy from './commands/update_policy';
import addSigningCondition from './commands/add_signing_condition';
import removeSigningCondition from './commands/remove_signing_condition';
import revokeToken from './commands/revoke_token';
import { NIP46_ADMIN_RESPONSE_KIND } from './kinds.js';
import fs from 'fs';
import { validateRequestFromAdmin } from './validations/request-from-admin';
import { dmUser } from '../../utils/dm-user';
import { IConfig, getCurrentConfig } from "../../config";
import path from 'path';
import { attachIndefiniteReconnect } from '../lib/relay-reconnect.js';
const debug = createDebug("nsecbunker:admin");
@ -63,15 +55,6 @@ class AdminInterface {
explicitRelayUrls: opts.adminRelays,
signer: new NDKPrivateKeySigner(opts.key),
});
// Override NDK's "give up after detecting flapping" behavior so the
// bunker's admin NDK keeps trying to reconnect indefinitely. The
// watchdog (when enabled) still fires after 60s of zero connected
// relays; this helper handles shorter disconnects (e.g. an lnbits
// restart that pulls the nostrrelay extension's WS for a few
// seconds) without involving the supervisor. See aiolabs/nsecbunkerd#20.
attachIndefiniteReconnect(this.ndk, 'admin');
this.ndk.signer?.user().then((user: NDKUser) => {
let connectionString = `bunker://${user.npub}`;
@ -128,74 +111,18 @@ class AdminInterface {
return;
}
const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1';
// Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7.
// NDKNostrRpc.sendResponse calls event.publish() and discards the
// returned Set<NDKRelay>, so a silent outbox-drop is invisible without
// hooking the underlying per-relay events. Gated by env flag so
// production deployments stay quiet.
const attachRelayLogging = (relay: any) => {
relay.on('published', (event: NDKEvent) => {
console.log(`📤 PUBLISHED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)}`);
});
relay.on('publish:failed', (event: NDKEvent, err: any) => {
console.log(`❌ PUBLISH_FAILED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)} err=${err?.message ?? err}`);
});
};
this.ndk.pool.on('relay:connect', (relay: any) => {
console.log('✅ nsecBunker Admin Interface ready');
if (debugTransport) attachRelayLogging(relay);
});
this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready'));
this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
this.ndk.connect(2500).then(() => {
// connect for whitelisted admins
this.rpc.subscribe({
"kinds": [NDKKind.NostrConnect, NIP46_ADMIN_RESPONSE_KIND],
"kinds": [NDKKind.NostrConnect, 24134 as number],
"#p": [this.signerUser!.pubkey]
});
// Attach per-relay logging to relays that connected before our
// 'relay:connect' listener was registered above (NDK can connect
// synchronously inside .connect() under some paths).
if (debugTransport) {
this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay));
this.rpc.on('request', (req) => this.handleRequest(req));
// Wrap sendResponse to log id + kind + elapsed time so we
// can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED.
const originalSendResponse = this.rpc.sendResponse.bind(this.rpc);
this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => {
const start = Date.now();
try {
await originalSendResponse(id, remotePubkey, result, kind, error);
console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`);
} catch (e: any) {
console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`);
throw e;
}
};
}
this.rpc.on('request', (req) => {
if (debugTransport) {
console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`);
}
this.handleRequest(req);
});
// Connection watchdog: exit if pool reports no connected relays
// for >60s so the process supervisor (systemd / docker restart
// policy / k8s) can recover. Replaces the original self-echo
// pingOrDie — see relayConnectionWatchdog comment + #4 + #7.
// Operators with external liveness checking can disable via
// NSEC_BUNKER_DISABLE_WATCHDOG=1.
if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') {
relayConnectionWatchdog(this.ndk);
} else {
console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1');
}
pingOrDie(this.ndk);
}).catch((err) => {
console.log('❌ admin connection failed');
console.log(err);
@ -219,12 +146,6 @@ class AdminInterface {
case 'create_new_policy': await createNewPolicy(this, req); break;
case 'get_policies': await this.reqListPolicies(req); break;
case 'create_new_token': await createNewToken(this, req); break;
case 'add_policy_rule': await addPolicyRule(this, req); break;
case 'remove_policy_rule': await removePolicyRule(this, req); break;
case 'update_policy': await updatePolicy(this, req); break;
case 'add_signing_condition': await addSigningCondition(this, req); break;
case 'remove_signing_condition': await removeSigningCondition(this, req); break;
case 'revoke_token': await revokeToken(this, req); break;
default:
const originalKind = req.event.kind!;
console.log(`Unknown method ${req.method}`);
@ -237,15 +158,7 @@ class AdminInterface {
}
} catch (err: any) {
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it
// makes sendResponse fall through to its default of 24133, which
// sends the error on a different channel than the request came in
// on. Mirror req.event.kind so the response goes back where the
// client is listening. Filed as part of aiolabs/nsecbunkerd#7
// diagnosis 2026-05-27.
const originalKind = req.event.kind!;
console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`);
return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message);
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message);
}
}
@ -283,7 +196,7 @@ class AdminInterface {
const key = keys.find((k) => k.name === keyName);
if (!key || !key.npub) {
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), NIP46_ADMIN_RESPONSE_KIND);
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), 24134);
}
const npub = key.npub;
@ -305,7 +218,7 @@ class AdminInterface {
};
}));
return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}
/**
@ -337,7 +250,7 @@ class AdminInterface {
};
}));
return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}
/**
@ -349,7 +262,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeys());
const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
}
/**
@ -361,7 +274,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeyUsers(req));
const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
}
/**
@ -427,7 +340,7 @@ class AdminInterface {
remoteUser.pubkey,
'acl',
[params],
NIP46_ADMIN_RESPONSE_KIND,
24134,
(res: NDKRpcResponse) => {
this.requestPermissionResponse(
remotePubkey,
@ -482,47 +395,44 @@ class AdminInterface {
}
}
/**
* Pool-status connection watchdog. Exits the daemon if every relay in
* the pool stays disconnected for longer than PARTITION_THRESHOLD_MS.
*
* Replaces the original `pingOrDie` self-echo watchdog, which published
* a kind-24133 event to its own pubkey every 20s and exited if it
* didn't see the echo within 50s. That works on public relays but
* silently breaks on single-private-relay setups: NDK 2.8.1's outbox
* model doesn't reliably route self-publishes back through the
* matching subscription, so the watchdog fires false positives and
* exits the daemon every 50s while RPCs over the same channel still
* work fine. See aiolabs/nsecbunkerd#4 + #7.
*
* The pool-status approach uses NDK's own connection-lifecycle
* tracking `pool.connectedRelays()` reports relays in
* NDKRelayStatus.CONNECTED which is reliable across all relay
* configurations because it doesn't depend on round-trip
* publish/subscribe. No event is published; no relay traffic.
*
* Detects partition within POLL_INTERVAL + PARTITION_THRESHOLD ms.
* Transient disconnects shorter than PARTITION_THRESHOLD don't trip
* the watchdog useful for relays that flap or briefly drop on
* network blips.
*/
async function relayConnectionWatchdog(ndk: NDK) {
const POLL_INTERVAL_MS = 10_000;
const PARTITION_THRESHOLD_MS = 60_000;
let lastConnectedAt = Date.now();
async function pingOrDie(ndk: NDK) {
let deathTimer: NodeJS.Timeout | null = null;
function resetDeath() {
if (deathTimer) clearTimeout(deathTimer);
deathTimer = setTimeout(() => {
console.log(`❌ No ping event received in 30 seconds. Exiting.`);
process.exit(1);
}, 50000);
}
const self = await ndk.signer!.user();
const sub = ndk.subscribe({
authors: [self.pubkey],
kinds: [NDKKind.NostrConnect],
"#p": [self.pubkey]
});
sub.on("event", (event: NDKEvent) => {
console.log(`🔔 Received ping event:`, event.created_at);
resetDeath();
});
sub.start();
resetDeath();
setInterval(() => {
const connectedCount = ndk.pool.connectedRelays().length;
if (connectedCount > 0) {
lastConnectedAt = Date.now();
return;
}
const elapsed = Date.now() - lastConnectedAt;
if (elapsed > PARTITION_THRESHOLD_MS) {
console.log(`❌ No connected relays for ${Math.floor(elapsed / 1000)}s. Exiting.`);
const event = new NDKEvent(ndk, {
kind: NDKKind.NostrConnect,
tags: [ ["p", self.pubkey] ],
content: "ping"
} as NostrEvent);
event.publish().then(() => {
console.log(`🔔 Sent ping event:`, event.created_at);
}).catch((e: any) => {
console.log(`❌ Failed to send ping event:`, e.message);
process.exit(1);
}
}, POLL_INTERVAL_MS);
});
}, 20000);
}
export default AdminInterface;

View file

@ -1,14 +0,0 @@
import type { NDKKind } from '@nostr-dev-kit/ndk';
/**
* NIP-46 admin-RPC response channel kind-24134. Distinct from the
* standard NIP-46 client channel kind-24133 (`NDKKind.NostrConnect`)
* which carries `sign_event` / `nip04_*` / `nip44_*` / etc.
*
* nsecbunkerd's admin surface uses a dedicated kind so signer clients
* and admin clients don't subscribe to each other's events.
*
* NDK 3.x's `NDKKind` enum does not include 24134; the cast happens
* once here so callers can pass a typed value to `rpc.sendResponse`.
*/
export const NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind;

View file

@ -59,8 +59,9 @@ async function createRecord(
) {
let params: string | undefined;
if (typeof param === 'object' && param !== null && 'rawEvent' in param) {
params = JSON.stringify(param.rawEvent());
if (param?.rawEvent) {
const e = param as NDKEvent;
params = JSON.stringify(e.rawEvent());
} else if (param) {
params = param.toString();
}

View file

@ -1,7 +1,6 @@
import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '@nostr-dev-kit/ndk';
import prisma from '../../db.js';
import type {FastifyInstance} from "fastify";
import { grantIsLive } from '../lib/acl/index.js';
export class Backend extends NDKNip46Backend {
public baseUrl?: string;
@ -19,67 +18,8 @@ export class Backend extends NDKNip46Backend {
this.baseUrl = baseUrl;
this.fastify = fastify;
}
/**
* Override NDKNip46Backend.start() to await the kind-24133
* subscription's EOSE before resolving. The base implementation
* calls `this.ndk.subscribe(...)` and returns immediately the
* NDKSubscription queues a REQ on the relay connection but the
* relay's acknowledgement (EOSE) hasn't arrived yet. Any caller
* that publishes a NIP-46 event in the immediate window after
* `start()` returns races against the relay registering this
* subscription.
*
* aiolabs/lnbits#33's eager-bind chain publishes a NIP-46
* `connect` event in the same HTTP round-trip as `create_new_key`,
* which loses this race deterministically the bunker never
* sees the connect event because its subscription wasn't yet
* registered with the relay when the event was broadcast.
*
* Awaiting EOSE closes the race: by the time `start()` resolves,
* the relay has confirmed it has the bunker's subscription on
* file and will route matching kind-24133 events to it.
*
* See aiolabs/nsecbunkerd#9 for the full diagnosis.
*/
async start(): Promise<void> {
this.localUser = await this.signer.user();
await new Promise<void>((resolve) => {
// Pin this subscription to the daemon's explicit relays via
// `relayUrls`. Without that, NDK 3.x's outbox routing tries to
// resolve the relay set from `this.localUser.pubkey`'s NIP-65
// relay list (kind:10002). Newly-provisioned bunker keys have
// no published kind:10002 yet, so NDK's subscription manager
// queues the REQ waiting for a relay list that will never
// arrive — the kind:24133 subscription never lands on the
// wire, and inbound NIP-46 events (sign_event, get_public_key,
// nip44_*) targeted at this key get dropped by the relay
// with "Filter didn't match" because the bunker isn't actually
// subscribed for them.
//
// `relayUrls` was added in NDK 2.13.0 as the supported way to
// bypass outbox routing per subscription (see
// NDKSubscriptionOptions.relayUrls in @nostr-dev-kit/ndk).
// The relay set built from these URLs matches what the rest
// of the bunker uses (admin RPC channel + per-key Backend
// channels alike), so events flow through the same connection
// the admin interface already established.
//
// See aiolabs/nsecbunkerd#21.
const sub = this.ndk.subscribe(
{
kinds: [24133],
"#p": [this.localUser!.pubkey],
},
{
closeOnEose: false,
relayUrls: this.ndk.explicitRelayUrls,
}
);
sub.on("event", (e: any) => this.handleIncomingEvent(e));
sub.on("eose", () => resolve());
});
// this.setStrategy('publish_event', new PublishEventHandlingStrategy());
}
private async validateToken(token: string) {
@ -92,10 +32,7 @@ export class Backend extends NDKNip46Backend {
if (!tokenRecord) throw new Error("Token not found");
if (tokenRecord.redeemedAt) throw new Error("Token already redeemed");
if (!tokenRecord.policy) throw new Error("Policy not found");
// Revoke + expiry via the single grantIsLive predicate — the exact
// check the sign-time ACL uses, so redeem-time and sign-time cannot
// drift (the root of #24). See aiolabs/nsecbunkerd#25.
if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked");
if (tokenRecord.expiresAt && tokenRecord.expiresAt < new Date()) throw new Error("Token expired");
return tokenRecord;
}
@ -104,20 +41,39 @@ export class Backend extends NDKNip46Backend {
const tokenRecord = await this.validateToken(token);
const keyName = tokenRecord.keyName;
// Record ONLY the binding (KeyUser <- Token). Under #25 the token's
// policy is evaluated live at sign time (checkIfPubkeyAllowed step 4)
// off Token -> Policy -> PolicyRule, NOT photocopied into
// SigningCondition rows here. That photocopy was the root of #24: the
// copy carried no expiry/revoke and short-circuited the live check, so
// an expired or revoked token kept signing forever. With no copy, the
// token's lifecycle is re-checked on every request and there is nothing
// to keep in sync.
// Upsert the KeyUser with the given remotePubkey
const upsertedUser = await prisma.keyUser.upsert({
where: { unique_key_user: { keyName, userPubkey } },
update: { },
create: { keyName, userPubkey, description: tokenRecord.clientName },
});
await prisma.signingCondition.create({
data: {
keyUserId: upsertedUser.id,
method: 'connect',
allowed: true,
}
});
// Go through the rules of this policy and apply them to the user
for (const rule of tokenRecord!.policy!.rules) {
const signingConditionQuery: any = { method: rule.method };
if (rule && rule.kind) {
signingConditionQuery.kind = rule.kind.toString();
}
await prisma.signingCondition.create({
data: {
keyUserId: upsertedUser.id,
method: rule.method,
allowed: true,
...signingConditionQuery,
}
});
}
await prisma.token.update({
where: { id: tokenRecord.id },
data: {

View file

@ -0,0 +1,14 @@
import { NDKNip46Backend } from "@nostr-dev-kit/ndk";
import { IEventHandlingStrategy } from '@nostr-dev-kit/ndk';
export default class PublishEventHandlingStrategy implements IEventHandlingStrategy {
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]): Promise<string|undefined> {
const event = await backend.signEvent(remotePubkey, params);
if (!event) return undefined;
console.log('Publishing event', event);
await event.publish();
return JSON.stringify(await event.toNostrEvent());
}
}

View file

@ -1,45 +1,13 @@
import { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
import prisma from '../../../db.js';
import { liveWhere } from './lifecycle.js';
// Re-export the single lifecycle predicate so callers (e.g.
// Backend.validateToken) import it from the ACL module. The implementation
// lives in ./lifecycle.ts so it can be unit-tested without a database.
export { grantIsLive } from './lifecycle.js';
/**
* Layered authorization check. Order matters (denials beat grants):
*
* 1. fetch KeyUser; if missing undefined (no binding exists)
* 2. KeyUser.revokedAt set false (subject-level ban beats everything)
* 3. manual-override layer (LIVE SigningConditions only):
* - live matching per-(method,kind) deny false
* - live matching per-(method,kind) grant true
* 4. live token grant: a redeemed Token bound to this KeyUser that is
* neither revoked nor expired pairs the user (`connect`) outright and,
* via its policy, governs signing. Token expiry/revoke are evaluated
* HERE, every request not photocopied at redeem (#24).
* 5. else undefined (caller's requestPermission flow may prompt an admin)
*
* Unlike the pre-#25 algorithm, token grants are no longer materialized into
* SigningCondition rows at redeem (Backend.applyToken stopped photocopying),
* so step 4 is the live source of truth for token lifecycle. The override
* layer (step 3) is manual-only and now carries its own lifecycle, so an
* expired/revoked override stops granting too.
*
* Supersedes the #11 algorithm; closes the materialization-drift family
* behind #24. See aiolabs/nsecbunkerd#25.
*/
export async function checkIfPubkeyAllowed(
keyName: string,
remotePubkey: string,
method: IMethod,
payload?: string | NostrEvent,
payload?: string | NostrEvent
): Promise<boolean | undefined> {
// One clock reading for the whole decision.
const now = new Date();
// Step 1: find KeyUser.
// find KeyUser
const keyUser = await prisma.keyUser.findUnique({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
});
@ -48,113 +16,59 @@ export async function checkIfPubkeyAllowed(
return undefined;
}
// Step 2: subject-level revoke (sticky ban, beats everything).
if (keyUser.revokedAt) {
return false;
}
const live = liveWhere(now);
// Step 3: live matching per-(method, kind) override — deny beats grant.
// (Subject-level "reject all from this user" is KeyUser.revokedAt, applied
// at step 2 via the revoke_user admin command. There is no method='*'
// SigningCondition sentinel — nothing writes one.)
// find SigningCondition
const signingConditionQuery = requestToSigningConditionQuery(method, payload);
const liveDeny = await prisma.signingCondition.findFirst({
where: { keyUserId: keyUser.id, ...signingConditionQuery, allowed: false, ...live },
const explicitReject = await prisma.signingCondition.findFirst({
where: {
keyUserId: keyUser.id,
method: '*',
allowed: false,
}
});
if (liveDeny) {
if (explicitReject) {
console.log(`explicit reject`, explicitReject);
return false;
}
const liveGrant = await prisma.signingCondition.findFirst({
where: { keyUserId: keyUser.id, ...signingConditionQuery, allowed: true, ...live },
});
if (liveGrant) {
return true;
}
// Step 4: live token grant.
//
// A redeemed token that is live (not revoked, not past expiry) grants
// `connect` (the pairing) outright, and grants other methods when its
// policy has a matching PolicyRule. The live filter is what closes #24:
// an expired or revoked token simply stops matching here, every request,
// with no photocopy to outlive it.
if (method === 'connect') {
const liveToken = await prisma.token.findFirst({
where: { keyUserId: keyUser.id, ...live },
});
if (liveToken) {
return true;
}
} else {
// PolicyRule.kind matching:
// - exact match against the stringified payload kind (matches the
// create_new_policy.ts storage format `rule.kind.toString()`)
// - 'all' literal matches any kind
// - NULL kind is a defensive wildcard — no current writer emits a
// null-kind rule, but treat it as a wildcard rather than failing
// closed silently if one ever appears (raw SQL, future code).
const payloadKindString =
method === 'sign_event' && typeof payload === 'object' && payload?.kind !== undefined
? payload.kind.toString()
: undefined;
const kindMatchers: Array<{ kind: string | null }> = [{ kind: null }, { kind: 'all' }];
if (payloadKindString !== undefined) {
kindMatchers.push({ kind: payloadKindString });
}
const policyAllowance = await prisma.token.findFirst({
const signingCondition = await prisma.signingCondition.findFirst({
where: {
keyUserId: keyUser.id,
...live,
policy: {
rules: {
some: {
method,
OR: kindMatchers,
},
},
},
},
...signingConditionQuery,
}
});
if (policyAllowance) {
return true;
}
}
// Step 5: no live override and no live token grant matched. Caller's
// requestPermission flow may still prompt the admin out-of-band.
// if no SigningCondition found, return undefined
if (!signingCondition) {
return undefined;
}
/**
* Sign-time auth method names follow the NIP-46 wire convention as
* NDK 3.x's `NDKNip46Backend` passes them through to `pubkeyAllowed`
* verbatim (it stopped normalizing `nip04_encrypt`/`nip04_decrypt`
* to `encrypt`/`decrypt` somewhere between 2.8.1 and current
* upstream).
*
* lnbits's `_ensure_policy` writes `PolicyRule.method` using the same
* wire-name vocabulary (`nip04_encrypt`, `nip04_decrypt`,
* `nip44_encrypt`, `nip44_decrypt`, `sign_event`, `get_public_key`,
* `connect`, `ping`). With the wire-name vocabulary on both sides,
* the post-#11 live-policy join (step 4 of `checkIfPubkeyAllowed`)
* naturally matches lnbits's stored rules no `encrypt → nip04_encrypt`
* adapter layer needed.
*
* Source the type from NDK itself so it can't drift across future
* NDK bumps; if NDK adds a new method (e.g. `nip60_*`) we pick it up
* for free.
*/
export type IMethod = NIP46Method;
const allowed = signingCondition.allowed;
// Check if the key user has been revoked
if (allowed) {
const revoked = await prisma.keyUser.findFirst({
where: {
id: keyUser.id,
revokedAt: { not: null },
}
});
if (revoked) {
return false;
}
}
if (allowed === true || allowed === false) {
console.log(`found signing condition`, signingCondition);
return allowed;
}
return undefined;
}
export type IMethod = "connect" | "sign_event" | "encrypt" | "decrypt" | "ping";
export type IAllowScope = {
kind?: number | 'all';
@ -164,14 +78,10 @@ export function requestToSigningConditionQuery(method: IMethod, payload?: string
const signingConditionQuery: any = { method };
switch (method) {
case 'sign_event': {
const kindString = (typeof payload === 'object' && payload?.kind !== undefined)
? payload.kind.toString()
: undefined;
signingConditionQuery.kind = { in: [kindString, 'all'] };
case 'sign_event':
signingConditionQuery.kind = { in: [ payload?.kind?.toString(), 'all' ] };
break;
}
}
return signingConditionQuery;
}
@ -216,3 +126,20 @@ export async function allowAllRequestsFromKey(
console.log('allowAllRequestsFromKey', e);
}
}
export async function rejectAllRequestsFromKey(remotePubkey: string, keyName: string): Promise<void> {
// Upsert the KeyUser with the given remotePubkey
const upsertedUser = await prisma.keyUser.upsert({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
update: { },
create: { keyName, userPubkey: remotePubkey },
});
// Create a new SigningCondition for the given KeyUser and set allowed to false
await prisma.signingCondition.create({
data: {
allowed: false,
keyUserId: upsertedUser.id,
},
});
}

View file

@ -1,40 +0,0 @@
/**
* Pure grant-lifecycle logic, extracted from the ACL so it can be unit-tested
* without a database and reused verbatim at redeem time and sign time.
*
* The original #24 bug was possible because redeem-time checked expiry and
* sign-time didn't two definitions of "valid" that drifted. Defining "is
* this grant valid right now?" exactly once makes them impossible to disagree.
* See aiolabs/nsecbunkerd#25.
*/
/** The lifecycle fields every grant (Token, SigningCondition) carries. */
export type Lifecycle = {
revokedAt?: Date | null;
expiresAt?: Date | null;
};
/**
* "Is this grant valid right now?" the single lifecycle predicate. A grant
* is live iff it has not been revoked and its expiry (if any) is still in the
* future. Expiry is treated as exclusive at the boundary: a grant whose
* `expiresAt` equals `now` is already dead.
*/
export function grantIsLive(grant: Lifecycle, now: Date = new Date()): boolean {
if (grant.revokedAt) return false;
if (grant.expiresAt && grant.expiresAt.getTime() <= now.getTime()) return false;
return true;
}
/**
* `grantIsLive` expressed as a Prisma `where` fragment, so the live filter
* runs in the query rather than in app code after the fetch. `now` is threaded
* in explicitly so a single request evaluates every row against one clock
* reading. Kept in lockstep with `grantIsLive` (see lifecycle.test.ts).
*/
export function liveWhere(now: Date) {
return {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
};
}

View file

@ -1,101 +0,0 @@
import NDK from "@nostr-dev-kit/ndk";
/**
* Attaches an aggressive-reconnect supervisor to an NDK instance.
*
* NDK 3.x's per-relay connectivity state machine gives up retrying after
* a few consecutive fast-fail (e.g. ECONNREFUSED returns in <1 ms)
* connection attempts:
*
* 1. Each attempt's duration is recorded in `_connectionStats.durations`.
* 2. After every 3 attempts, `isFlapping()` checks the std-dev of those
* durations against `FLAPPING_THRESHOLD_MS` (1 second). Three fast
* failures look identical tiny std-dev flapping=true status
* transitions to FLAPPING and the per-relay retry stops.
* 3. `NDKPool.handleFlapping` catches the event and reschedules a
* reconnect via doubling backoff (5s 10s 20s 40s 80s ),
* growing unbounded.
*
* For nsecbunkerd, where the admin relay is typically a single relay we
* **must** stay subscribed to, "disconnected for 80+s after every dev
* restart" is the failure mode users hit (aiolabs/nsecbunkerd#20). The
* pool's doubling backoff is too pessimistic for our use case.
*
* This helper sidesteps the give-up path: when the pool emits `flapping`
* (the symptom that NDK has internally given up), or when we see the
* relay disconnect outside our own request, we manually call
* `relay.connect()` with a SHORT capped delay. Successful connect resets
* the attempt counter so a future disconnect storm doesn't grow the
* delay.
*
* Trade-off: we may hammer a permanently-down relay every 10s. That's
* fine for a bunker being disconnected silently is strictly worse than
* a retry storm against localhost. Acceptable because:
* - The bunker's primary relay is typically on the same host or LAN
* (`ws://lnbits:5001/...`); TCP RSTs are cheap.
* - Public-relay setups can layer external supervision on top if they
* care about retry pressure.
*/
export function attachIndefiniteReconnect(ndk: NDK, label: string): void {
const RECONNECT_BASE_MS = 1_000;
const RECONNECT_CAP_MS = 10_000;
const attempts = new Map<string, number>();
const pending = new Map<string, NodeJS.Timeout>();
const reconnectDelay = (n: number): number =>
Math.min(RECONNECT_BASE_MS * 2 ** n, RECONNECT_CAP_MS);
const scheduleReconnect = (relay: any): void => {
const url: string = relay.url;
if (pending.has(url)) return;
const n = attempts.get(url) ?? 0;
const delay = reconnectDelay(n);
console.log(
`🔁 ${label}: scheduling reconnect to ${url} in ${delay}ms ` +
`(attempt ${n + 1}, overriding NDK give-up)`
);
const timer = setTimeout(() => {
pending.delete(url);
attempts.set(url, n + 1);
relay.connect().catch((e: any) => {
console.log(
`${label}: manual reconnect to ${url} failed: ` +
`${e?.message ?? e}`
);
// Don't recurse here — the next 'flapping' or 'disconnect'
// event will fire and schedule another attempt.
});
}, delay);
pending.set(url, timer);
};
ndk.pool.on("flapping", (relay: any) => {
console.log(
`⚠️ ${label}: NDK flagged ${relay.url} as flapping ` +
`(connectivity machine gave up internally)`
);
scheduleReconnect(relay);
});
ndk.pool.on("relay:disconnect", (relay: any) => {
scheduleReconnect(relay);
});
ndk.pool.on("relay:connect", (relay: any) => {
const url: string = relay.url;
const n = attempts.get(url) ?? 0;
if (n > 0) {
console.log(
`${label}: recovered ${url} after ${n} manual reconnect ` +
`attempt(s)`
);
}
attempts.delete(url);
const timer = pending.get(url);
if (timer) {
clearTimeout(timer);
pending.delete(url);
}
});
}

View file

@ -1,7 +1,10 @@
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
import { nip19, utils as nostrUtils } from 'nostr-tools';
import { nip19 } from 'nostr-tools';
import { Backend } from './backend/index.js';
import { checkIfPubkeyAllowed } from './lib/acl/index.js';
import {
IMethod,
checkIfPubkeyAllowed,
} from './lib/acl/index.js';
import AdminInterface from './admin/index.js';
import { IConfig } from '../config/index.js';
import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
@ -15,7 +18,6 @@ import FastifyView from '@fastify/view';
import Handlebars from "handlebars";
import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js";
import {processRegistrationWebHandler} from "./web/authorize.js";
import { attachIndefiniteReconnect } from "./lib/relay-reconnect.js";
export type Key = {
name: string;
@ -36,7 +38,8 @@ function getKeys(config: DaemonConfig) {
const keys: Key[] = [];
for (const [name, nsec] of Object.entries(config.keys)) {
const user = await new NDKPrivateKeySigner(nsec).user();
const hexpk = nip19.decode(nsec).data as string;
const user = await new NDKPrivateKeySigner(hexpk).user();
const key = {
name,
npub: user.npub,
@ -105,7 +108,7 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
}
try {
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method, payload);
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method as IMethod, payload);
if (keyAllowed === true || keyAllowed === false) {
console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`);
@ -161,17 +164,11 @@ class Daemon {
explicitRelayUrls: config.nostr.relays,
});
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
this.ndk.pool.on('notice', (r, n) => { console.log(`👀 Notice from ${r.url}`, n); });
this.ndk.pool.on('relay:notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); });
this.ndk.pool.on('relay:disconnect', (r) => {
console.log(`🚫 Disconnected from ${r.url}`);
});
// Override NDK's "give up after detecting flapping" behavior so the
// bunker's backend NDK keeps trying to reconnect indefinitely.
// Without this, an ECONNREFUSED storm at boot (relay not yet up)
// permanently strands the bunker. See aiolabs/nsecbunkerd#20.
attachIndefiniteReconnect(this.ndk, 'backend');
}
async startWebAuth() {
@ -209,129 +206,9 @@ class Daemon {
continue;
}
// nostr-tools v2: `nsecEncode` takes `Uint8Array`, not hex string.
// pragma: allowlist secret
// `settings.key` is the hex-encoded private key from config.
const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key));
const nsec = nip19.nsecEncode(settings.key);
this.loadNsec(keyName, nsec);
}
// Boot-time autounlock of encrypted-at-rest keys. Off by default;
// enabled by setting NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE or
// NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE. See docs/AUTOUNLOCK.md
// for the security trade-off and aiolabs/nsecbunkerd#16 for the
// design rationale.
await this.maybeAutounlock();
}
/**
* Boot-time autounlock for encrypted keys.
*
* Reads a passphrase from one of two mutually exclusive env vars:
* - NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE literal passphrase
* - NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE path to a file containing
* the passphrase (newline-trimmed)
*
* If neither is set, this is a no-op the deployment opted out and
* keys remain locked until an admin `unlock_key` RPC fires per key
* per restart (today's default).
*
* If both are set, throws at boot ambiguous config.
*
* Otherwise: enumerates `Key` table rows where `deletedAt IS NULL`,
* calls `unlockKey(keyName, passphrase)` per row. Sequential, with
* continue-on-error so one bad row doesn't block the rest of the
* fleet. Per-key INFO/WARN/ERROR log + one summary line at the end.
*
* `unlockKey` is idempotent post-#16 calling it against a key that
* was already loaded via the unencrypted paths above is safe (returns
* true without spawning a duplicate Backend).
*
* Single-passphrase invariant: every `create_new_key(name, passphrase)`
* uses the same passphrase in our usage today, so one autounlock
* passphrase covers every encrypted key. Per-key passphrase support
* is a separate feature (out of scope see issue #16).
*/
async maybeAutounlock(): Promise<void> {
const literal = process.env.NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE;
const filePath = process.env.NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE;
if (literal && filePath) {
throw new Error(
'Autounlock: NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE and ' +
'NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE are mutually exclusive. ' +
'Set exactly one (or neither, to leave autounlock off).'
);
}
if (!literal && !filePath) {
return; // autounlock off (default)
}
let passphrase: string;
let source: string;
if (literal) {
passphrase = literal;
source = 'NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE';
} else {
const fs = await import('fs');
try {
passphrase = fs.readFileSync(filePath!, 'utf8').replace(/\r?\n$/, '');
} catch (e: any) {
throw new Error(
`Autounlock: failed to read passphrase file ${filePath}: ${e.message}`
);
}
source = `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE=${filePath}`;
}
// Enumerate encrypted-at-rest keys from `config.allKeys`. The
// Prisma `Key` table is only populated by the NIP-05 `create_account`
// path (which stores keys plain-at-rest in nsecbunker.json);
// `create_new_key` provisions keys with the `{iv, data}` encrypted
// shape directly into the JSON blob without a Prisma row. So the
// canonical "what's encrypted at rest" source is `allKeys` filtered
// to entries carrying `iv`+`data` — that's the set of keys for
// which the manual `unlock_key` admin RPC was previously required
// per restart, and exactly the set we want to autounlock here.
//
// Plain-key entries (`{key: "..."}` shape, populated by `create_account`)
// were already loaded by the second loop in `startKeys` above and
// appear in `activeKeys` — `unlockKey`'s idempotency guard makes
// re-calling them safe but unnecessary, so we filter them out for
// log clarity.
const candidates = Object.entries(this.config.allKeys || {})
.filter(([, entry]) =>
entry && typeof entry === 'object' && 'iv' in entry && 'data' in entry
)
.map(([keyName]) => keyName);
const start = Date.now();
let success = 0;
for (const keyName of candidates) {
try {
const ok = await this.unlockKey(keyName, passphrase);
if (ok) {
console.log(`🔓 autounlock: unlocked ${keyName}`);
success++;
} else {
console.warn(
`⚠️ autounlock: unlockKey returned false for ${keyName} ` +
`(likely wrong passphrase — encrypted under a different secret?)`
);
}
} catch (e: any) {
console.error(
`❌ autounlock: ${keyName} failed: ${e?.message ?? e}`
);
}
}
const elapsed = Date.now() - start;
console.log(
`🔓 autounlock: enabled (source=${source}), unlocked ${success}/${candidates.length} keys in ${elapsed}ms`
);
}
async start() {
@ -349,44 +226,39 @@ class Daemon {
*/
async startKey(name: string, nsec: string) {
const cb = signingAuthorizationCallback(name, this.adminInterface);
// NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
// (see `core/src/signers/private-key/index.ts` `@ai-guardrail`
// — "DO NOT use nip19.decode() to convert nsec to hex before
// passing it here"). The bech32-decode workaround for #8 was
// tied to NDK 2.8.1's old constructor behavior and is no
// longer needed post-#14 NDK bump.
const backend = new Backend(this.ndk, this.fastify, nsec, cb, this.config.baseUrl);
let hexpk: string;
if (nsec.startsWith('nsec1')) {
try {
const key = new NDKPrivateKeySigner(nsec);
hexpk = key.privateKey!;
} catch(e) {
console.error(`Error loading key ${name}:`, e);
return
}
} else {
hexpk = nsec;
}
const backend = new Backend(this.ndk, this.fastify, hexpk, cb, this.config.baseUrl);
await backend.start();
}
async unlockKey(keyName: string, passphrase: string): Promise<boolean> {
// Idempotency guard: if a Backend instance already exists for this
// keyName, the key is already unlocked and the relay subscription
// for its kind-24133 channel is already active. Calling startKey
// again would spawn a SECOND Backend with a duplicate subscription
// — wire events would be handled twice, with race/amplification
// hazards on the response side. Return success without re-running
// startKey so callers (admin `unlock_key` RPC, autounlock loop,
// belt-and-suspenders fallback paths) can fire safely against
// already-unlocked keys.
if (this.activeKeys[keyName]) {
return true;
}
const keyData = this.config.allKeys[keyName];
const { iv, data } = keyData;
const nsec = decryptNsec(iv, data, passphrase);
this.activeKeys[keyName] = nsec;
await this.startKey(keyName, nsec);
this.startKey(keyName, nsec);
return true;
}
async loadNsec(keyName: string, nsec: string) {
loadNsec(keyName: string, nsec: string) {
this.activeKeys[keyName] = nsec;
await this.startKey(keyName, nsec);
this.startKey(keyName, nsec);
}
}

View file

@ -1,44 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { grantIsLive, liveWhere } from '../src/daemon/lib/acl/lifecycle';
// Fixed reference clock so the assertions don't depend on wall time.
const now = new Date('2026-06-19T12:00:00.000Z');
const past = new Date(now.getTime() - 60_000);
const future = new Date(now.getTime() + 60_000);
test('grantIsLive: no revoke, no expiry -> live', () => {
assert.equal(grantIsLive({}, now), true);
assert.equal(grantIsLive({ revokedAt: null, expiresAt: null }, now), true);
});
test('grantIsLive: future expiry -> live', () => {
assert.equal(grantIsLive({ expiresAt: future }, now), true);
});
test('grantIsLive: past expiry -> dead (the #24 case the old code missed at sign time)', () => {
assert.equal(grantIsLive({ expiresAt: past }, now), false);
});
test('grantIsLive: expiry exactly now -> dead (boundary is exclusive)', () => {
assert.equal(grantIsLive({ expiresAt: new Date(now.getTime()) }, now), false);
});
test('grantIsLive: revoked -> dead even with a future expiry (revoke wins)', () => {
assert.equal(grantIsLive({ revokedAt: past, expiresAt: future }, now), false);
});
test('grantIsLive: defaults now to the current time', () => {
assert.equal(grantIsLive({ expiresAt: new Date(Date.now() + 3_600_000) }), true);
assert.equal(grantIsLive({ expiresAt: new Date(Date.now() - 3_600_000) }), false);
});
// liveWhere is the SQL mirror of grantIsLive; pin its shape so the two
// can't silently drift (a drift would re-open the redeem-vs-sign gap #25
// exists to close).
test('liveWhere: mirrors grantIsLive as a prisma where-fragment', () => {
assert.deepEqual(liveWhere(now), {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
});
});