Compare commits

..

50 commits

Author SHA1 Message Date
4a0a3e7cd4 Merge pull request 'fix(admin): make create_new_key idempotent — never clobber an existing key (#39)' (#40) from fix-create-new-key-idempotent into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #40
2026-06-21 14:01:47 +00:00
2cf3e55bf4 fix(admin): make create_new_key idempotent — never clobber an existing key
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
`create_new_key` unconditionally generated a fresh keypair and let
`saveEncrypted` overwrite `config.keys[keyName]` on disk. So calling it
with a name that already exists SILENTLY DESTROYED the in-use signing
key: the old encrypted nsec was overwritten, surviving only in the
running process's in-memory Backend until the next restart, then gone.

This breaks the contract callers already rely on. spirekeeper's
`pair_spire` re-pairs a machine through the same `spire-<id>` keyName and
documents the assumption verbatim — "create_new_key is idempotent —
returns the existing key if the name is taken" (pairing.py). It wasn't:
a re-pair rotated the machine's spire identity and orphaned everything
bound to the old key (tokens, beacons, wallet routing), unrecoverably.

Guard at the top of the command: if a key with this name already exists,
recover and return it instead of generating + overwriting.
  - no `_nsec`: decrypt the existing entry with the supplied passphrase
    and return its npub (the idempotent re-pair path).
  - explicit `_nsec`, or an unrecognized/!iv/!data entry, or a passphrase
    that doesn't decrypt the existing key: throw rather than overwrite —
    destroying a key must never be a silent side effect of "create".

Functionally verified on the dev bunker: a repeat `/pair` for an
already-paired machine now returns the existing spire pubkey with the
on-disk key entry unchanged, where before it minted a new identity and
clobbered the old blob.
2026-06-21 15:51:13 +02:00
3468972733 Merge pull request 'fix(acl): hard-reject a lapsed token binding instead of prompting (#36)' (#38) from issue-36-expired-binding-hard-reject into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #38
2026-06-21 12:57:46 +00:00
14d48ca0f9 fix(acl): hard-reject a lapsed token binding instead of prompting (#36)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
A request that finds no live token grant exited `checkIfPubkeyAllowed`
at `undefined` regardless of *why* — whether the binding never existed
or had simply lapsed (expired / token-revoked). `undefined` routes the
caller into the admin-prompt path, which for an unattended client (an
ATM spire) means the request hangs until a BunkerTimeoutError.

The Sintra smoke proved the divergence directly: a KeyUser-level revoke
exits at step 2 with `false` and the spire sees a clean BunkerRejected
("Pairing Required"), but a TTL expiry fell through to `undefined` and
the spire saw a BunkerTimeout ("Signer Unreachable") — same operator
intent ("this pairing is over"), two different, one-broken outcomes.

Classify the no-live-grant case before returning: if a token bound to
this KeyUser *would* have granted the request (its policy carries a
matching rule; for `connect`, any bound token) but is now expired or
token-revoked, return `false` so the client re-pairs immediately. Only
a genuinely never-granted (method/kind) request stays `undefined` so an
admin can still approve new permission out-of-band.

Usage-cap exhaustion is left at `undefined` deliberately: a windowed
cap is a temporary rate-limit that refills as the window rolls, not a
permanent lapse, so it must not be reclassed as the re-pair signal. A
dedicated rate-limit reply is a separate follow-up.

Tests: the #24 expired-token and token-revoke guards now assert `false`;
added connect-lapse, and two distinction cases proving a never-granted
method (live token, or a method the lapsed token never covered) stays
`undefined`.
2026-06-21 12:44:36 +02:00
281ca1c39f Merge pull request 'feat(acl): per-rule windowed usage caps enforced live at sign time (#28)' (#34) from issue-28-usage-caps into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #34
2026-06-21 10:34:22 +00:00
c76bbf2791 test(acl)(#28): integration cases for windowed + stacked usage caps
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
9 cases: under/at limit, signings outside the window excluded, uncapped,
lifetime (null window) all-time count, kind-specific counting, both
stacked-cap directions (hourly binds vs daily binds), and the
record->count->deny loop via recordSigning. 22 integration + 7 unit green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:29:51 +02:00
6929f42115 feat(acl)(#28): per-rule windowed usage caps enforced live at sign time
Completes the lifecycle family from #25 — usage caps were the third
sibling (after expiry #24 and revoke), written-but-never-enforced.

Model:
- PolicyRule gains windowSeconds; drops the never-enforced mutable
  currentUsageCount. A cap = (maxUsageCount, windowSeconds): at most N
  signings of this (method,kind) per rolling window. windowSeconds NULL
  = lifetime; maxUsageCount NULL = uncapped.
- New SigningLog: durable append-only record of allowed signings — the
  source of truth caps count against (derive-don't-count; no counter to
  drift).

Enforcement (checkIfPubkeyAllowed step 4): among the live token's
matching rules, every capped rule must have remaining budget in its
window (COUNT(SigningLog) < maxUsageCount), counted live. Stacked caps
all bind — 20/hr AND 200/day enforced together. recordSigning() writes
a SigningLog row from the permit callback when a consequential request
(sign_event / encrypt / decrypt) is allowed.

Retune live: new update_policy_rule admin RPC patches maxUsageCount/
windowSeconds/method/kind in place; takes effect next request, no
re-pairing (a payoff of the #27 Option D design). get_policies now
returns each rule's id + window_seconds so callers can target it.

Retention/pruning of SigningLog is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:29:51 +02:00
0b9ffe8ca6 test(acl)(#29): DB-backed integration tests for checkIfPubkeyAllowed
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Closes the gap flagged in #27 review: the wiring that actually closes
#24 (step-4 Token join filtered by liveWhere) was untested — only the
pure predicate was. Now covered end-to-end against a throwaway SQLite DB
+ the real prisma client.

Harness (no new dependency; pnpm add is blocked by the nix node_modules
hoist pattern):
- tests/register-ts.cjs: ts-node (transpile-only) + a CommonJS resolver
  that maps the app's '.js' ESM-style specifiers to their '.ts' sources.
- node:test temp DB via 'prisma db push'; a before() guard refuses to run
  unless DATABASE_URL points at tests/.tmp/ (never truncates a real DB).
- npm run test:integration / test:all.

13 cases incl. the #24 regression guard (expired token -> denied),
revoke, connect-off-live-token, override expiry/revoke ignored,
deny-beats-grant, kind mismatch, no-KeyUser.

Also: acl/index.ts NDK import -> 'import type' (NostrEvent/NIP46Method are
type-only) so the ACL module no longer pulls ESM-only NDK at runtime —
required for the CommonJS test import, and a correct cleanup besides.

Requires the prisma engine env (CI/nix ok; devShell pending #30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:09:41 +00:00
14e20d50d4 docs: add migration & DB-maintenance runbook (never full-wipe nsecbunker.db)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Captures the deploy hazard found during #27 rollout (cfaun): the
nsecbunkerd<->LNbits pairing is split across both systems, so a full
nsecbunker.db wipe orphans LNbits's signer_config and forces an
identity-changing re-provision. Documents the targeted
'DELETE FROM SigningCondition' procedure, the keys-live-in-json fact,
and the migrate-on-boot no-op (#31).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:58:12 +02:00
992c6a8d4a Merge pull request 'fix(acl): enforce token grant lifecycle live at sign time (#24, #25)' (#27) from issue-25-live-grant-lifecycle into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #27
2026-06-19 16:05:18 +00:00
7dcf97a296 refactor(acl)(#27 review): remove dead reject-all sentinel
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
PR #27 review finding #3: step 3a queried SigningCondition method='*'
and the docstring attributed it to rejectAllRequestsFromKey — but that
function writes method=null (never '*') and has zero callers, so the
'reject all' branch could never match. Subject-level reject is already
KeyUser.revokedAt (step 2, via the revoke_user admin command).

Drop the dead step-3a branch and the orphaned rejectAllRequestsFromKey
so the code matches reality. Per-(method,kind) denies (step 3, written
by add_signing_condition) are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:02:13 +02:00
e2cf10a66d test(acl)(#25): extract pure grantIsLive/liveWhere + unit tests
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Move the lifecycle predicate into lib/acl/lifecycle.ts (re-exported from
the ACL module) so it can be unit-tested without a database. Adds Node
built-in test-runner coverage for the boundary conditions that define
the fix: past expiry -> dead, expiry == now -> dead (exclusive), revoke
beats a future expiry, and liveWhere kept in lockstep with grantIsLive.

Runner is node:test via ts-node (no new dependency; pnpm add is blocked
by the nix-built node_modules hoist pattern). 'npm test' -> 7 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:16:37 +02:00
85e781dfa9 fix(acl)(#24,#25): enforce token expiry+revoke live at sign time
The bug (#24): applyToken photocopied a token's policy rules into
SigningCondition rows at redeem; checkIfPubkeyAllowed matched those rows
(step 3) and short-circuited before the live Token join (step 4), so an
expired or revoked token kept signing forever — the copy carried no
lifecycle. Same cause re-shipped by upstream Signet (see docs survey).

Option D fix:
- grantIsLive(grant, now): the single 'valid right now?' predicate
  (revokedAt null AND not past expiresAt), used identically at redeem
  (Backend.validateToken) and sign (checkIfPubkeyAllowed). Redeem and
  sign can no longer disagree.
- Backend.applyToken records ONLY the KeyUser<-Token binding; it no
  longer materializes SigningCondition rows. Token policy is evaluated
  live every request.
- checkIfPubkeyAllowed step 4 filters tokens through liveWhere(now)
  (revoke + expiry) and grants connect off a live bound token; the
  manual-override layer (step 3) now honors SigningCondition
  expiresAt/revokedAt too (denials beat grants).

Closes the materialization-drift family: a new lifecycle rule is one
more predicate, never a forgotten photocopy. Token-revoke sibling
(spirekeeper#22) falls out of the same seam. Usage caps deferred (no
durable signing log exists yet to count) — follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:11:23 +02:00
6397c7988d feat(schema)(#25): Request.keyUserId + SigningCondition lifecycle for live grant eval
Additive, non-breaking schema prep for the Option D live-evaluation ACL:

- Request gains keyUserId (FK) + @@index([keyUserId, method]) so token
  usage caps can be derived live by COUNTing allowed Requests, replacing
  the never-enforced mutable PolicyRule.currentUsageCount (derive-don't-count,
  per lnbits/nostr_bunker prior art).
- SigningCondition gains createdAt/expiresAt/revokedAt so the manual-override
  layer carries its own lifecycle and runs through the same grantIsLive(now)
  predicate as token grants (D1: two typed sources, one shared rule).

No behavior change yet; the ACL hot path and applyToken de-materialization
follow in subsequent commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:00:33 +02:00
a707d203a1 docs(#25): source-verified ACL prior-art survey + keep-our-fork decision
Surveys Signet, Amber, FROSTR, promenade, NDK/rust-nostr/nak against
actual source; records the decision to keep our fork and treat Signet
as a parts donor (NIP-46 wire boundary keeps the signer substitutable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:54:18 +02:00
8326a16ea9 docs(#25): add lnbits/nostr_bunker comparison (prior art)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:54:18 +02:00
cb8dd0ceb0 Merge pull request 'fix(backend): pin per-key kind:24133 subscription to explicit relays (#21)' (#23) from fix-21-pin-backend-sub-to-explicit-relays into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #23
2026-06-03 17:07:23 +00:00
59e90d07c0 fix(backend): pin per-key kind:24133 subscription to explicit relays (#21)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
`Backend.start()` calls `this.ndk.subscribe(filter, opts)` to listen for
NIP-46 events targeted at each unlocked key's pubkey (kind:24133 with
`#p=[localUser.pubkey]`). Pre-fix this subscription opts didn't pin a
relay set, so NDK 3.x's outbox routing kicked in: it looked up the
`localUser.pubkey`'s NIP-65 relay list (kind:10002) to decide where to
send the REQ. Newly-provisioned bunker keys have no kind:10002 published
yet, so NDK's subscription manager queued the REQ waiting for a relay
list that would never arrive — the subscription never landed on the
wire.

The user-visible symptom: every NIP-46 RPC from lnbits to a freshly-
provisioned key (`connect`, `get_public_key`, `sign_event`, the
`nip04_*` / `nip44_*` family) was published into the relay, the relay
tried to route, found no subscribed peer matching `["p", new_key_pubkey]`,
and emitted "Filter didn't match". The lnbits-side RPC then timed out
at 15s, breaking eager merchant provisioning (aiolabs/lnbits#46) and
satmachineadmin's per-cassette `nip44_decrypt` polling.

Reproduced + diagnosed by patching the lnbits `nostrrelay` extension's
`_handle_request` to log incoming REQ filters: only the admin
subscription (`{kinds:[24133,24134], #p:[bunker_admin_pubkey]}` from
`AdminInterface.connect()`) appeared on the wire. The per-key Backend
filters from `Backend.start()` did not.

Fix: pass `relayUrls: this.ndk.explicitRelayUrls` in the subscription
opts. `relayUrls` was added in NDK 2.13.0 as the supported way to bypass
outbox routing per subscription; the relay set built from these URLs
matches what the rest of the daemon uses (admin RPC channel + every
per-key Backend channel), so events flow through the same connection
the admin interface already established.

Verified on the regtest dev stack with bunker enabled, fresh signup
provisioning a new key + immediately publishing a kind:30017 stall via
NIP-46 sign_event:

  POST /auth/register → HTTP 200 in 1.1s
  stalls.event_id = 8a2eb20b929…   (populated by bunker signature)
  relay sees: nostr event: [30017, <new-key-pubkey>, '{...store...}']

Pre-fix this same flow timed out at `NsecBunkerTimeoutError: no NIP-46
response for 'sign_event' within 15.0s`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 19:05:03 +02:00
dad42a7669 Merge pull request 'fix(daemon): keep retrying relay reconnect indefinitely, overriding NDK give-up (#20)' (#22) from fix-20-indefinite-relay-reconnect into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #22
2026-06-03 16:58:48 +00:00
a690596b85 fix(daemon): keep retrying relay reconnect indefinitely, overriding NDK give-up (#20)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
NDK 3.x's per-relay connectivity machine gives up after ~3 fast-fail
(ECONNREFUSED) cycles. Three sub-second failures look identical, so
`isFlapping()` (std-dev < 1s) returns true and the relay transitions
to FLAPPING; NDKPool's `handleFlapping` then reschedules with doubling
backoff (5s → 10s → 20s → 40s → 80s …). For nsecbunkerd, "disconnected
for 80+s after every lnbits restart" is the failure mode users hit on
the regtest dev stack: bunker container boots before lnbits's
nostrrelay extension is accepting WebSockets → ECONNREFUSED storm →
NDK flagged FLAPPING → bunker stays silently deaf until manual restart.

Symptom is particularly hostile because:
- `relay:connect` fires optimistically; the immediate ECONNREFUSED
  follow-up doesn't propagate to user-facing logs.
- `NSEC_BUNKER_DISABLE_WATCHDOG=1` (the dev-stack default) skips the
  exit-and-restart safety net.
- Manual `docker compose restart nsecbunker` is the only recovery.

Fix: attach a small supervisor (`attachIndefiniteReconnect`) to both
NDK instances (daemon's backend NDK in run.ts, AdminInterface's admin
NDK in admin/index.ts). On `relay:disconnect` or `flapping`, schedule
a manual `relay.connect()` with a SHORT capped delay (1s → 2s → 4s →
8s → 10s, capped at 10s instead of NDK's unbounded doubling). Successful
connect resets the attempt counter so a future disconnect storm starts
fresh.

Coexists cleanly with the relay-connection watchdog (admin/index.ts:500):
- Brief disconnects (e.g. lnbits restart): supervisor recovers within
  seconds, watchdog never fires.
- Persistent disconnects (relay truly down): supervisor keeps trying
  every ≤10s; if it can't recover within 60s, watchdog still exits and
  the process supervisor restarts the bunker. So the watchdog becomes
  a long-tail safety net; this supervisor handles the common case.

Operators with `NSEC_BUNKER_DISABLE_WATCHDOG=1` set as a workaround for
this bug can re-enable the watchdog once this lands.

Trade-off: we may hammer a permanently-down relay every 10s. Acceptable
because the bunker's primary relay is typically on the same host or LAN
(loopback or docker-internal); TCP RSTs are cheap. Public-relay setups
can layer external supervision on top.

Verified on regtest dev stack (cold-boot race): bunker logs
  🔁 admin: scheduling reconnect to ws://lnbits:5001/nostrrelay/test/ in 1000ms (attempt 1, overriding NDK give-up)
  🔁 backend: scheduling reconnect to ws://lnbits:5001/nostrrelay/test/ in 1000ms (attempt 1, overriding NDK give-up)
on each disconnect, where pre-fix the bunker stayed silently deaf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 18:55:55 +02:00
131f689c6f deps: bump prisma 5.4.1 → 6.19.3 (nix build fix)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Required to keep the nix package buildable: nixpkgs unstable no longer
ships prisma-engines 5.x — the unsuffixed `prisma-engines` attr now
aliases 7.x (no libquery_engine.node), and the only versioned attrs are
`prisma-engines_6` (6.19.3) and `prisma-engines_7`. Bump both
`@prisma/client` and `prisma` to ^6.19.0 so the client matches the only
engine we can pin to.

Also:
- package.nix takes `prisma-engines_6` directly. flake.nix passes
  `pkgs.prisma-engines_6 or pkgs.prisma-engines` so the package still
  builds on nixos-25.05 (where prisma-engines is 6.7.0 unsuffixed).
- Drop PRISMA_INTROSPECTION_ENGINE_BINARY — prisma 6 collapsed the
  introspection engine into schema-engine, the binary no longer ships.

Schema is unchanged so existing fresh installs migrate identically.
Existing dev instances with a prisma_5-tracked _prisma_migrations table
will need a one-time `prisma migrate resolve` step on first boot under
the new client; deploy targets are all fresh installs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 15:04:40 +02:00
8ee0595ea8 fix(nix): build under pnpm_9 + drop stale NDK substitute
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
NDK 2.8.1 → 3.0.3 bump (041f431) regenerated pnpm-lock.yaml at
lockfile v9, which pnpm_8 refuses to read. Switch the derivation to
pnpm_9 and regen the pnpmDeps hash to match the v9 lockfile.

The package.json/pnpm-lock realignment that `patchNdk` used to fix is
no longer needed — the same bump also pinned NDK as `"3.0.3"` in
package.json, so manifest + lockfile already agree. Drop the
substitute (kept as a no-op shim for the next time a bump diverges
them) instead of carrying a substituteStream that errors out under
--replace-fail because the source string no longer exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 14:51:40 +02:00
67b1f46266 Merge pull request 'feat(#16): boot-time autounlock of encrypted keys from a configured passphrase source' (#17) from issue-16-autounlock into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #17
2026-05-31 13:39:18 +00:00
030d3cea0f fix(daemon): autounlock walks config.allKeys, not prisma.key (#16)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
The first cut of `maybeAutounlock` enumerated `prisma.key` based on
the design issue's pseudocode. Empirically that's the wrong source:
the Prisma `Key` table is only populated by the NIP-05
`create_account` path, which stores keys *plain-at-rest* in
`nsecbunker.json` (no encryption involved). The `create_new_key`
flow that lnbits's `RemoteBunkerSigner` uses provisions encrypted
`{iv, data}` blobs directly into the JSON `keys` map without
touching the Prisma table at all.

Result of the v1 enumeration on regtest:

  🔓 autounlock: enabled (source=NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE),
     unlocked 0/0 keys in 0ms

…despite 67 encrypted blobs sitting in nsecbunker.json. The Prisma
table was empty because none of the regtest keys came from
`create_account`. Greg's key would have been a no-op even with the
autounlock env set; the manual `unlock_key` admin RPC would still
have been required.

Fix: enumerate `this.config.allKeys` (the in-memory snapshot of
`nsecbunker.json`'s `keys` map, populated at daemon-fork time per
`src/commands/start.ts:144`) filtered to entries with the `iv`+`data`
shape. That's the canonical "what's encrypted at rest" set —
exactly the rows for which manual `unlock_key` was previously
required per restart.

Plain-key entries (`{key: ...}` from `create_account`) are skipped
here for log clarity — they were already loaded by `startKeys`'
second pass and live in `activeKeys`; `unlockKey`'s post-#16
idempotency guard would no-op them anyway, but emitting "unlocked"
log lines for keys that didn't need unlocking is noise.

Updates `docs/AUTOUNLOCK.md` accordingly so the description matches
the implementation.

Refs aiolabs/nsecbunkerd#16.
2026-05-31 15:34:51 +02:00
7a3cb4f3da feat(daemon): boot-time autounlock of encrypted keys (#16)
Adds opt-in autounlock to the daemon's boot sequence. Closes the
"O(N) manual unlock_key RPC per bunker restart" paper-cut without
breaking the secure-by-default posture: deployments that want every
restart to gate crypto capability on a human action keep that
property by leaving both env vars unset.

Configuration — two mutually exclusive env vars:

  NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE       literal passphrase
  NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE  path (newline-trimmed)

Both set → fail loud at boot. Neither set → no-op (default,
behavior unchanged from pre-#16). Var names follow the bunker's
existing NSEC_BUNKER_* convention (see NSEC_BUNKER_DEBUG_TRANSPORT,
NSEC_BUNKER_DISABLE_WATCHDOG); the design issue spec'd NSECBUNKER_*
but aligning with the existing prefix matters more for operator
muscle-memory than matching the issue text verbatim.

Implementation:

  - `Daemon.maybeAutounlock()` wedged at the tail of `startKeys()`.
    Inherits the relay-subscription lifecycle (EOSE-awaited per #9)
    that the existing per-key startKey calls established, so there's
    no "client sees key locked" race window.
  - Enumeration via `prisma.key.findMany({ where: { deletedAt: null } })`
    — Key table is the canonical source of truth for what keys exist
    on the bunker; respects soft-delete.
  - Per-key call to the existing `unlockKey(keyName, passphrase)`,
    which is idempotent post-#16 — encrypted-at-rest keys get unlocked
    on first call; rows already loaded via the unencrypted-config
    passes above are no-ops.
  - Sequential loop with continue-on-error. One bad row (corrupted
    blob, key encrypted under a historical passphrase, etc.) doesn't
    block the rest of the fleet. Per-key INFO/WARN/ERROR + one
    summary line.
  - File-source error (missing path, permission denied) is fatal at
    boot — same severity as a misconfig.

Observability output:

  🔓 autounlock: unlocked <keyName>                                    (success)
  ⚠️  autounlock: unlockKey returned false for <keyName> (...)         (soft fail)
   autounlock: <keyName> failed: <message>                           (throw)
  🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>    (summary)

Single-passphrase invariant: every `create_new_key(name, passphrase)`
in our usage today uses the same passphrase
(LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE on the lnbits side), so one
autounlock passphrase covers every encrypted key. Per-key passphrase
support is a separate feature (out of scope — see #16 "out of scope"
section + docs/AUTOUNLOCK.md "What's not in scope").

`docs/AUTOUNLOCK.md` ships alongside: usage, the security trade
spelled out by deployment shape, observability hooks, what's
deliberately not in scope. Required-reading link before any operator
flips the env var on for a production-shaped deployment.

Refs aiolabs/nsecbunkerd#16. Builds on idempotent unlockKey from the
previous commit on this branch.
2026-05-31 15:31:25 +02:00
b6f8abdb23 fix(daemon): make unlockKey idempotent (#16)
`unlockKey(keyName, passphrase)` previously had no short-circuit on
re-entry — calling it against an already-unlocked key would happily
run through the full path:

  1. decryptNsec (cheap, same result)
  2. overwrite this.activeKeys[keyName] with the same nsec
  3. call startKey(keyName, nsec) → spawn a SECOND Backend instance

Step 3 is the actual hazard. Each Backend opens its own NIP-46 kind-
24133 subscription with the relay, scoped to the key's pubkey. Two
Backends → duplicate subscription → wire events delivered twice and
each handler races to publish its response. Response amplification +
ordering hazards downstream, plus a slow leak of NDK subscription
state every time unlock fires.

This bug was latent under the manual-unlock posture today (admins
rarely re-issue unlock_key for the same name in one session) but
becomes load-bearing for #16's autounlock loop, which is designed
to run alongside the existing startKeys() loops and may legitimately
encounter a key that was already loaded via the unencrypted-config
path. Belt-and-suspenders lnbits-side scripts + future periodic
"re-unlock sweeps for paranoia" can also fire this.

Fix: short-circuit on `this.activeKeys[keyName]` already set. Return
true so callers can rely on "after this call returns, the key is
unlocked and ready" regardless of whether work was done. Doesn't
break the manual flow (still unlocks first-time), doesn't change
the failure path (corrupt blob / wrong passphrase still throws),
just closes the re-entry foot-gun.

Refs aiolabs/nsecbunkerd#16 (autounlock — this is the idempotency
sub-task lnbits flagged in the design surface).
2026-05-31 15:29:07 +02:00
106fa807a1 Merge pull request 'feat(#14): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2.20 + acl wire-name vocabulary' (#15) from issue-14-ndk-bump into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #15
2026-05-31 11:49:29 +00:00
e8f245c917 fix(deps): cap nostr-tools at ~2.20.0 (regtest Node 20 / curves v2 ESM-only) (#14)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Caught during regtest dogfood after the previous three commits
landed. With `nostr-tools: ^2.17.2` pnpm resolved to 2.23.5, which
in turn pulls `@noble/curves@2.0.1` — ESM-only. The regtest
Dockerfile runs on Node 20.11.1, where CJS `require()` of pure-ESM
modules is hard-blocked:

  Error [ERR_REQUIRE_ESM]: require() of ES Module
  /app/node_modules/.pnpm/@noble+curves@2.0.1/.../secp256k1.js
  from /app/node_modules/.pnpm/nostr-tools@2.23.5/.../index.js
  not supported.

nostr-tools 2.21.0 was the cutover — that release flipped
`@noble/curves` from `1.2.0` to `2.0.1`. 2.20.0 is the last
nostr-tools 2.x release that's still CJS-friendly via @noble/curves
1.2.0. Capping our pin at `~2.20.0` keeps us within the
"nostr-tools >= 2.17.2" range NDK 3.0.3 asks for in its
peerDependency while sidestepping the ESM/CJS hazard.

This isn't a regression we introduce — it's a CJS-output footgun
unique to the regtest container's Node 20 + tsup-default-CJS
combination. Long-term fix paths (out of scope here):

  * Bump the container's Node base image to >= 22 (where
    `--experimental-require-module` is on by default for `.js`
    files inside `package.json type: "commonjs"`)
  * Switch tsup output to ESM (`tsup --format esm`) — wider
    surface change across the daemon, the client CLI, and the
    Dockerfile entrypoint
  * Accept the cap forever (small downside: 2.21+ patch fixes
    won't reach us until we fix one of the above)

The cap is intentionally tight (`~2.20.0` allows 2.20.x patches,
nothing newer) so a future `pnpm update` doesn't silently jump us
back over the 2.21 edge. Revisit when one of the long-term paths
above lands.

Refs aiolabs/nsecbunkerd#14, regtest dogfood 2026-05-31.
2026-05-31 13:43:37 +02:00
db1a834587 refactor(acl): align IMethod with NIP-46 wire-name vocabulary (#14)
NDK 3.x's `NDKNip46Backend` passes the wire method name verbatim
to `pubkeyAllowed` — `nip04_encrypt`, `nip04_decrypt`,
`nip44_encrypt`, `nip44_decrypt`, etc. NDK 2.8.1 normalized these to
`encrypt`/`decrypt` before calling the permit callback; that
normalization was the root of why our encrypt/decrypt path had
never worked end-to-end against lnbits's bunker-backed signer
(lnbits stores `PolicyRule.method` using wire names, our auth
lookup looked for the normalized name → no match → request fell
through to the never-resolved admin prompt and timed out at 15s).

Source `IMethod` directly from NDK's exported `NIP46Method` union so
it can't drift across future bumps. If NDK adds a new method
(e.g. `nip60_*`) we pick it up for free. Drop the `method as IMethod`
cast at the `signingAuthorizationCallback` call site — both sides
now share the same vocabulary by construction.

This is the substantive win that aiolabs/nsecbunkerd#14 is filed for.
With this commit:

- `sign_event` policy rules with kinds continue to match exactly as
  before (kind stringification path unchanged).
- `nip04_encrypt` / `nip04_decrypt` / `nip44_encrypt` / `nip44_decrypt`
  policy rules — kind-less — now match the live-policy join (step 4
  of `checkIfPubkeyAllowed`) by their method-name alone. lnbits's
  bunker-mediated `signer.nip44_decrypt` and `signer.nip44_encrypt`
  calls (per `aiolabs/lnbits` PR #38 phase 2.4) start succeeding
  end-to-end against any operator account whose Policy carries those
  rules — which `_ensure_policy`'s self-heal already ensures for
  every newly-bound operator (per coord log 2026-05-30T22:00Z).
- `switch_relays` (new in NDK 3) flows through the auth check the
  same way as any other method.

`requestToSigningConditionQuery` needs no further change — the
existing `sign_event` switch case covers the only method that
discriminates on kind; all other methods use the default
`{ method }` query against the override layer, which is correct
for the kind-less wire names too.

Refs aiolabs/nsecbunkerd#14, aiolabs/nsecbunkerd#11 (whose live-policy
join this finally puts to use).
2026-05-31 12:15:57 +02:00
94b5d55376 refactor: adapt source to NDK 3.0.3 / nostr-tools v2 surface (#14)
Mechanical adjustments to the source after the dep bump in the
previous commit. No semantic changes — every site adapts to API
drift between the pinned versions.

Surface changes addressed:

  * `NDKKind` strict numeric enum (was wider in 2.8.1). 18 sites
    passed the literal `24134` (NIP-46 admin-RPC response kind) to
    `rpc.sendResponse` / `rpc.sendRequest`; NDK 3's `NDKKind` enum
    omits 24134. Introduced `src/daemon/admin/kinds.ts` exporting
    `NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind` so the cast lives
    once, and routed all 18 sites through the named constant.

  * `NDKPrivateKeySigner` constructor now accepts nsec1 or hex
    directly (the `@ai-guardrail` in NDK 3 source explicitly tells
    callers not to `nip19.decode` ahead of construction). Simplified
    `Daemon.startKey` and `createNewKey` accordingly — the bech32
    decode workaround for #8 was tied to NDK 2.8.1's old behavior
    and is no longer needed.

  * `NDKPrivateKeySigner.privateKey` is `string` (hex) on the public
    surface, not `Uint8Array`. `nostr-tools` v2's `nip19.nsecEncode`
    wants `Uint8Array`. Replaced `nip19.nsecEncode(key.privateKey!)`
    with `key.nsec` (NDK 3 exposes the getter directly), avoiding
    both the type mismatch and the unnecessary round-trip. For the
    one remaining hex-string-from-config call site, used
    `nostrUtils.hexToBytes` to convert before encoding.

  * `NDKPool` event rename: `'relay:notice'` → `'notice'`, with
    flipped arg order `(notice, relay)` → `(relay, notice)`.

  * `NDKUser.fromNip05` now requires the `ndk` instance as a 2nd
    positional arg (was implicit-global before).

  * `Nip46PermitCallbackParams.params` narrowed to `string |
    NostrEvent`; type guards added at the two access sites
    (`authorize.ts` and `acl/index.ts:requestToSigningConditionQuery`).

  * `req.params` is now `(string | undefined)[]` instead of `any[]`;
    `create_account.ts` `authorizationWithPayload` branch now
    explicitly throws on missing username/domain before passing to
    `createAccountReal` (validates what was implicit before).

  * Removed `src/daemon/backend/publish-event.ts` (defined a strategy
    that's never registered — wiring is commented out in
    `backend/index.ts:22`; in NDK 3 the file refs the removed
    `NDKNip46Backend.signEvent`). Dead since at least NDK 2.x; the
    bump just made the breakage visible.

Pre-existing `tsc` errors at `src/db.ts` and `src/daemon/authorize.ts`
on `'PrismaClient'` / `'Request'` exports are unrelated to this PR —
the regtest container's nix derivation can't reach the prisma engine
binary store on this host (`nsecbunkerd#14` parked separately).
`pnpm run build` (tsup) is green; the Docker container runs
`prisma generate` against its own engine at image-build time and
resolves these at runtime.

#11's wire-name policy convention adoption is the next commit —
this one is purely keep-it-compiling work.

Refs aiolabs/nsecbunkerd#14.
2026-05-31 12:14:29 +02:00
041f431bc2 chore(deps): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2 (#14)
NDK 2.8.1 (Apr 2024) is 2 years old and predates NIP-46 backend-side
nip44 support. With aiolabs/lnbits#38's phase-2.4 client-side migration
to bunker-mediated nip44_*, the bunker's lack of a `nip44_decrypt`
strategy registration causes wire RPCs to fall through to
`sendResponse(id, remotePubkey, "error", undefined, "Not authorized")`
at NDK 2.8.1's backend/index.ts:179. Even nip04 was silently broken:
2.8.1 normalizes the wire method to `encrypt`/`decrypt` for
`pubkeyAllowed` while lnbits's policy stores wire names. The
encrypt/decrypt path through nsecbunkerd has never actually worked
end-to-end; it just hadn't been exercised until phase 2.4 landed.

3.0.3 (Feb 2026) is the current `latest` dist-tag and ships:

  - `nip44_encrypt` / `nip44_decrypt` backend handlers registered
    by default + wire-name `pubkeyAllowed` semantics (the immediate fix)
  - `switch_relays` NIP-46 support for client-side relay migration
  - Configurable NDKNip46Signer timeout (pairs with lnbits PR #38's
    matching client-side config)
  - NIP-44 default outgoing encryption with NIP-04 compat fallback
  - Async error handling fix in backend dispatch — failed strategies
    report errors instead of silent drop (deb7f93d)
  - "Not enough relays received this event" race-condition fix on
    publish (relevant to open #7 — may close that one too)
  - Signature verification moved in-house (off legacy nostr-tools v1
    path)
  - 2 years of security/perf updates in transitive @noble/* crypto
    primitives

`nostr-tools` bumped from ^1.17.0 to ^2.17.2 alongside because NDK
3.x's `NDKPrivateKeySigner` imports `finalizeEvent` / `generateSecretKey`
+ uses the `./nip49` subpath, none of which exist in nostr-tools v1.17.
With v1.17 installed, `require('@nostr-dev-kit/ndk')` fails with
"Package subpath './nip49' is not defined". Confirmed against the
post-install module graph.

Source migrations for NDK 3 / nostr-tools v2 API surface land in the
follow-up commit; this commit is intentionally just the dep bump so
the diff stays reviewable. Refs aiolabs/nsecbunkerd#14 +
coord-log 2026-05-31T09:55Z.
2026-05-31 12:02:03 +02:00
f2a9697bf9 Merge pull request 'feat(#11): live-policy auth + 6 companion admin RPCs + Token.revokedAt' (#13) from issue-11-live-policy-auth into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #13
2026-05-30 15:25:16 +00:00
49091f722f feat(admin): companion RPCs for live policy + token revocation (#11)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Six new admin-RPC handlers that complement the live-policy auth
rewrite. Each follows the canonical create_new_policy.ts pattern
(single JSON-stringified param, kind-24134 response, one prisma
mutation, no per-call ACL — validateRequestFromAdmin gates them
via the admin-npub allowlist).

Policy-level mutations propagate to every KeyUser bound to the
policy at the next sign-time check (the point of #11):

  add_policy_rule    { policyId, rule: {method, kind?, maxUsageCount?} }
  remove_policy_rule { ruleId }
  update_policy      { policyId, patch: {name?, expiresAt?} }

Per-KeyUser override mutations (override layer):

  add_signing_condition    { keyUserId, condition: {method, kind?, allowed} }
  remove_signing_condition { conditionId }

Surgical token revocation without nuking the KeyUser:

  revoke_token { tokenId }

The 'all' kind literal is honored as a wildcard in add_policy_rule
for parity with the SigningCondition override-layer convention.

Param shapes ratified by webapp in
#11 (comment).

refs: #11
2026-05-30 13:27:28 +02:00
35826ab695 feat(acl): live-policy auth in checkIfPubkeyAllowed (#11)
Shifts sign-time authorization from materialized SigningCondition
snapshots (frozen at token-bind time) to a layered model:

  1. fetch KeyUser; missing → undefined
  2. KeyUser.revokedAt set → false
  3. SigningCondition override layer (explicit reject, per-(method,
     kind) grant/deny) → return matching row's allowed value
  4. live policy join: KeyUser → Token (revokedAt IS NULL) →
     Policy → PolicyRule. Match on method + kind (exact /
     'all' / NULL defensive) → true
  5. else → undefined (caller may still prompt admin)

Backwards-compatible. The existing applyToken fan-out of
SigningCondition rows continues to populate step 3 for legacy
auth, so already-bound users keep working with no behavior change.
New users will still go through that fan-out until the follow-up
trim (#12). The key win: PolicyRule mutations via the upcoming
companion RPCs (add_policy_rule / remove_policy_rule / etc.)
propagate live to every KeyUser bound to that policy, rather than
requiring per-user backfill RPCs that don't exist.

Algorithm ratified by webapp in
#11 (comment).

refs: #11
2026-05-30 13:25:48 +02:00
eb6c86a4d1 chore(schema): add Token.revokedAt for surgical token revocation (#11)
Pre-requisite for the live-policy auth rewrite in #11. The new
revoke_token admin RPC needs a way to mark a single Token as
revoked without nuking the whole KeyUser (revoke_user) or
conflating with future expiry cleanup (deletedAt).

Nullable DateTime — existing rows default to NULL (active), no
data migration needed.

refs: #11
2026-05-30 13:24:14 +02:00
3ec413b70d Merge pull request 'fix(#9): close race between create_new_key and NIP-46 connect' (#10) from issue-9-fix-create-new-key-race into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #10
2026-05-30 11:23:42 +00:00
65a6966b9f fix(#9): close race between create_new_key and NIP-46 connect
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Two-layer fix for the issue where a fresh client chaining
create_new_key + NIP-46 connect on the same target key would
time out — bunker had no subscription registered for the new
key by the time the connect event arrived at the relay.

Layer 1 — run.ts: loadNsec and unlockKey were synchronous and
fire-and-forgot the async startKey promise. create_new_key.ts:35
already awaited loadNsec, but the await was a no-op against a sync
return. Promoted both to async and properly awaited startKey, so
backend.start() at least gets a chance to run before the caller's
response goes out.

Layer 2 — backend/index.ts: NDKNip46Backend.start() registers the
kind-24133 subscription via this.ndk.subscribe(...) but returns
immediately, before the relay's EOSE confirms it has the
subscription on file. Override start() in our Backend subclass to
await EOSE before resolving. This is the actual race-closer —
layer 1's await alone wasn't enough because start() was still
returning before the relay registered the subscription.

Surfaced by aiolabs/lnbits#33's eager-bind chain, which publishes
a NIP-46 connect event in the same HTTP round-trip as
create_new_key. Pre-fix lnbits deferred the connect to first
sign_event (minutes-to-hours after provisioning), so the race
window was hidden.

Verified end-to-end on bohm regtest: demo account creation through
the webapp now completes cleanly, with bunker logs showing
connect + sign_event for the freshly-provisioned key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 12:25:45 +02:00
fb1c239e15 fix(#4): re-enable connection watchdog with env-flag opt-out
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Calls `relayConnectionWatchdog` (introduced in the previous commit) at
the end of admin-interface connect(). Gated by NSEC_BUNKER_DISABLE_WATCHDOG=1
for operators who run external liveness checks (Prometheus probes, k8s
readiness, etc.) and don't want the daemon to self-terminate.

This restores the watchdog behavior that was commented out in commit
42dbbd7 (the emergency stopgap for the old self-echo false positives),
but on top of the now-reliable pool-status mechanism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:43:12 +02:00
1792bc489c fix(#4): replace pingOrDie self-echo watchdog with pool-status check
The original watchdog published a kind-24133 event to its own pubkey
every 20s and exited if no echo arrived within 50s. On a single private
relay setup (LNbits's nostrrelay extension channel), 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 every 50s
even though admin RPCs over the same channel still work fine. The
upstream patches we landed previously (commit 42dbbd7) commented the
call out as an emergency stopgap; this commit replaces the mechanism
with one that actually answers the right question.

Pool-status watchdog: poll `ndk.pool.connectedRelays().length` every
10s, track the most recent moment any relay was connected, exit if no
relay has been connected for 60s. Uses NDK's own connection-lifecycle
tracking which works reliably across all relay configurations — no
self-publish, no subscription dependency, no relay traffic. Same intent
as pingOrDie (detect partition from relay layer and let the supervisor
restart us), reliable signal.

Call site re-enable + env-flag opt-out follow in the next commit.

Drops the now-unused NostrEvent import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:42:43 +02:00
662dd21a60 fix(nix): include prisma CLI + scripts/, wrapper invokes start.js
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Three correctness fixes to the nix derivation that mirror the Dockerfile
correctness fixes:

1. Drop `pnpm prune --prod --ignore-scripts` from the build phase. The
   prune step removed the prisma CLI (devDependency) from the output,
   so the runtime invocation of `prisma migrate deploy` had nothing to
   exec. Same trap the upstream Dockerfile fell into via `--prod` install.

2. Copy `scripts/` into `$out/share/nsecbunkerd/` alongside dist,
   node_modules, prisma, templates. Without it the launcher script
   (which contains the migration step) wasn't present.

3. The makeWrapper target switches from `dist/index.js` to
   `scripts/start.js`. Same change the Dockerfile ENTRYPOINT got in
   the previous commit. Also adds nodejs_20 to PATH so `npm` is
   resolvable from inside start.js, and drops `--chdir` so the caller
   (systemd, docker compose) controls cwd — start.js now resolves
   sibling paths from `__dirname`, independently committed.

The `patchNdk` substitution narrows from the old `workspace:*` form
(no longer in the package.json after fork commit 06272c8) to the
current `"2.8.1"` → `"^2.8.1"` rewrite needed to align package.json
with the lockfile under --frozen-lockfile.

Remaining known gap: nixpkgs ships prisma-engines 7.7.0 while the
JS prisma CLI in node_modules is 5.4.1, an RPC vocabulary mismatch
that breaks the migrate step at runtime (`Method not found:
listMigrationDirectories`). Either bump prisma JS to ^7.x or overlay
prisma-engines to 5.4.1. Out of scope for this commit; docker build
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:08:42 +02:00
ccfde02d70 fix(start.js): resolve sibling paths from script location, not cwd
The launcher previously assumed cwd was the package root: `mkdir config`
in cwd, `npm run prisma:migrate` in cwd, `node ./dist/index.js`. Works
under docker (WORKDIR /app, writable) but breaks anywhere cwd differs
from the package root — e.g. a nix-built bunker invoked from a systemd
unit whose WorkingDirectory is the state dir (/var/lib/nsecbunkerd) and
not the nix store path that holds dist/, scripts/, prisma/.

Resolve sibling paths via `path.resolve(__dirname, '..')` so the
package-internal layout is robust to cwd. Use `path.join(pkgRoot, 'dist/index.js')`
for the daemon spawn and `{ cwd: pkgRoot }` for the npm migrate exec.
Switch `mkdir config` (which only works in writable cwd) to
`fs.mkdirSync(configDir, { recursive: true })` where configDir defaults
to `./config` relative to cwd, overrideable via NSEC_BUNKER_CONFIG_DIR.

This lets the nix package install the launcher into the read-only store
while the systemd unit still does its config/state work in
/var/lib/nsecbunkerd with no shell wrapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:05:24 +02:00
053357899d fix(docker): entrypoint runs migrations via scripts/start.js
Upstream Dockerfile sets `ENTRYPOINT [ "node", "./dist/index.js" ]`,
which boots the daemon directly and silently bypasses `scripts/start.js`
— the only place that runs `prisma migrate deploy`. On a clean install,
the SQLite db file at $DATABASE_URL is created empty (0 bytes) and
every Policy / KeyUser / Token / SigningCondition operation throws
"table does not exist." `ping` / `get_keys` / `create_new_key` happen
to survive because they only touch the JSON config, not the db.

Two changes:

1. ENTRYPOINT switches to `node ./scripts/start.js`. The CMD arg
   (`start`) and any additional argv pass through to the daemon
   unchanged via process.argv.

2. Runtime pnpm install drops `--prod`. The prisma CLI lives in
   devDependencies; with `--prod`, `npx prisma migrate deploy` tries to
   download prisma@latest at runtime, which OOMs in modest containers.
   Including devDeps at runtime adds modest image bulk for correctness.

Validated end-to-end against the local regtest stack — after the
rebuild the SQLite db boots populated with 22 migrations, and the
lnbits-side admin spike harness passes all 9 steps including NIP-46
sign_event with Schnorr-valid signatures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:05:10 +02:00
5e77de1202 fix: convert policyId to Int before Prisma insert in create_new_token
The wire-level `create_new_token` RPC carries `policyId` as a string
(everything in NDK RPC params is string). The handler correctly
parseInts it for the `findUnique({where:{id:parseInt(policyId)}})` call
but then forwards the unparsed string straight into the Prisma
`token.create({data:{...policyId}})` payload. Prisma rejects with
"Argument `policyId`: Invalid value provided. Expected Int or Null,
provided String" because `Token.policyId` is declared `Int` per the
schema (references `Policy.id`, which is autoincrement Int).

Hoist `policyIdInt = parseInt(policyId)` and use it for both the
findUnique lookup and the create payload. Latent upstream bug — no one
would have seen it before because the wrong-kind error response (fixed
in the previous commit) made the symptom look like a transport timeout
rather than a Prisma validation error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:04:53 +02:00
0a510b7f9a fix(#7): route error responses to the request's kind
The catch block in handleRequest and both response paths in create_account
pass `NDKKind.NostrConnectAdmin` as the response kind. That constant does
NOT exist in NDK 2.8.1 — only `NostrConnect = 24133` is exported — so it
resolves to `undefined` and NDKNostrRpc.sendResponse falls through to its
own default of `NDKKind.NostrConnect = 24133`. Net effect: any error
response to an admin-channel (kind 24134) request is published on the
NIP-46 signing channel (24133) instead, which clients subscribed for
24134 never see. Looks like a transport-layer NDK-echo / silent-drop
issue from the client's perspective, but the bunker IS publishing
reliably — just on the wrong kind.

Mirror `req.event.kind` so the error response goes back on the same
channel the request came in on. Same pattern the unknown-method path
and create_account's validation-error path already used; just propagate
it to the remaining sites. Drops the now-unused NDKKind import from
create_account.ts.

Validated end-to-end against the local bunker via the lnbits-side admin
spike harness — after this fix + the migration entrypoint fix + the
policyId type fix, all 9 spike steps including NIP-46 sign_event pass
with Schnorr-valid signatures. See coordination log entry 2026-05-27T14:30Z.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:04:31 +02:00
8caf856ab2 diag(#7): env-gated per-relay transport instrumentation
Add NSEC_BUNKER_DEBUG_TRANSPORT=1 opt-in logging that emits REQUEST_IN
on inbound NIP-46 RPCs, RESPONSE_SENT around NDKNostrRpc.sendResponse,
and PUBLISHED / PUBLISH_FAILED per-relay on the bunker's pool. Surfaces
the diagnostic signal NDKNostrRpc itself discards: sendResponse calls
`event.publish(this.relaySet)` and throws away the Set<NDKRelay> it
returns, so silent outbox-drops and wrong-kind responses are invisible
without hooking the pool's per-relay events directly.

Validated against the local bunker via the lnbits-side admin spike
harness (~/dev/lnbits/misc-aio/bunker_admin_spike.py): the instrumentation
made the 9-step harness reveal a wrong-kind error response path (separate
fix in the next commit) that had been masquerading as an NDK echo issue
for a week. With the env flag unset the daemon stays as quiet as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:56:27 +02:00
e39eaa632d startKey: decode bech32 nsec to hex before constructing NDKPrivateKeySigner
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
NDK 2.8.1's NDKPrivateKeySigner constructor forwards its arg straight
to nostr-tools getPublicKey() which requires 32-byte hex/bytes/bigint
and throws on bech32 input. Every key loaded through startKey (i.e.
every key created via create_new_key, plus boot-time reloads of any
plain-nsec entries in the config) was failing silently with the
nostr-tools type error. The try/catch caught the throw and returned
without loading the key, so the bunker would happily report
create_new_key as successful, the key would persist encrypted on
disk, but the runtime keystore would not have a signer for it.
NIP-46 connect / sign_event against any admin-provisioned target
therefore silently timed out from the client side — blocking
essentially every signing flow.

Sister bug to #5 (getKeys iterator) in a different code path. The
fix matches the existing pattern in create_new_key.ts:16:

    hexpk = nip19.decode(nsec).data as string;

Verified against the local spike harness: create_new_key now loads
the target into runtime; get_keys returns the new entry (assuming
#5 is patched separately for the iterator path).

Fixes #8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:32:39 +02:00
42dbbd7536 disable pingOrDie watchdog — false-positives on non-public relays
NDK 2.8.1's outbox model doesn't reliably deliver self-published
events back through subscriptions when the configured relay set is
a single custom (non-public) relay. The pingOrDie self-watchdog
publishes a kind-24133 event to its own pubkey every 20s and exits
the bunker if it doesn't see the echo within 50s — which means on
a private relay channel (e.g. LNbits's nostrrelay extension), the
bunker exits cleanly every 50s even though admin RPCs over that
same channel are working fine.

Plain-WebSocket round-trips to the same relay echo correctly in
<1s, so the issue is on NDK's side, not the relay's.

Commenting out the watchdog is the minimum patch to keep the
daemon alive. Real fix is either an env-flag opt-out, a simpler
connectivity check that doesn't depend on self-echo, or an NDK
upgrade that fixes the outbox-vs-subscribe race.

Fixes #4. See also #7 for the underlying NDK echo investigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:29:53 +02:00
960b9399e8 Dockerfile: switch from npm to pnpm + drop --frozen-lockfile
Two upstream-rot issues fixed in one commit (same root cause: the
upstream Dockerfile predates the move to pnpm and the lockfile has
drifted):

- npm install can't resolve workspace:* deps (which package.json used
  to declare for @nostr-dev-kit/ndk — see prior commit for the pin).
  Switching to pnpm@9 matches the lockfile that ships in-repo.

- pnpm-lock.yaml is out of date vs package.json (likely from
  generation-time vs commit-time drift), so --frozen-lockfile fails
  with ERR_PNPM_OUTDATED_LOCKFILE. Drop the flag in both build and
  runtime stages to let pnpm resolve fresh, at the cost of giving up
  determinism — to be restored once the lockfile is regenerated.

Also reorders the build stage to COPY lockfile + manifest before the
source, so the install layer caches across source-only edits.

Fixes #1, #2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:29:41 +02:00
06272c8f2c pin @nostr-dev-kit/ndk to 2.8.1 instead of workspace:*
Upstream declares the dependency as workspace:*, but the repo has no
pnpm-workspace.yaml and no sibling @nostr-dev-kit/ndk package — so
pnpm install fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND on a clean
clone. The shipped pnpm-lock.yaml was resolving to ndk 2.8.1, so pin
to that exact version to match what the lockfile already expects.

Fixes #3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:29:29 +02:00
711a017e8c add nix flake with devShell and native package build
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
devShell: nodejs_20, pnpm_8, prisma + prisma-engines, sqlite, openssl,
plus the env wiring so prisma uses nix-provided engines instead of
fetching from binaries.prisma.sh.

packages.default: full native build via pnpm_8.fetchDeps + configHook.
Patches the workspace:* ndk spec to the lockfile-resolved ^2.8.1 so
--frozen-lockfile accepts it, then re-runs install with scripts to
trigger bcrypt's node-pre-gyp fallback-to-build (uses python311 since
node-gyp 9.4.1 bundled with pnpm 8 still imports distutils).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:59:31 +02:00
44 changed files with 7114 additions and 2784 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ config
.env .env
.turbo .turbo
prisma prisma
tests/.tmp

View file

@ -1,20 +1,32 @@
# 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 FROM node:20.11-bullseye AS build
WORKDIR /app WORKDIR /app
# Copy package files and install dependencies RUN npm install -g pnpm@9
COPY package*.json ./
RUN npm install # 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 application files # Copy application files
COPY . . COPY . .
# Generate prisma client and build the application # Generate prisma client and build the application
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN pnpm run build
# Runtime stage # Runtime stage
FROM node:20.11-alpine as runtime FROM node:20.11-alpine AS runtime
WORKDIR /app WORKDIR /app
@ -22,13 +34,25 @@ RUN apk update && \
apk add --no-cache openssl && \ apk add --no-cache openssl && \
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*
RUN npm install -g pnpm@9
# Copy built files from the build stage # Copy built files from the build stage
COPY --from=build /app . COPY --from=build /app .
# Install only runtime dependencies # Install all dependencies (including devDeps). The prisma CLI lives in
RUN npm install --only=production # 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
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT [ "node", "./dist/index.js" ] # 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" ]
CMD ["start"] CMD ["start"]

143
docs/AUTOUNLOCK.md Normal file
View file

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

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

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

@ -0,0 +1,65 @@
# 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
Migrations are applied by the deploy's `prisma migrate deploy`, **not** by the
daemon on boot — the in-`start.js` `npm run prisma:migrate` step is a no-op
(tracked in #31). After adding a migration, make sure the deploy applies it.
Prisma on NixOS needs the engine env pinned to `prisma-engines_6` (the bare
`prisma-engines` attr is now 7.x with no `libquery_engine.node`; devShell fix
tracked in #30). The deploy's `package.nix` already pins `_6`.

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"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
}

57
flake.nix Normal file
View file

@ -0,0 +1,57 @@
{
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,6 +21,9 @@
"scripts": { "scripts": {
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client", "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", "build:client": "tsup src/client.ts -d dist/client",
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts",
"test:integration": "DATABASE_URL=\"file:./tests/.tmp/acl-int.db\" node -r ./tests/register-ts.cjs --test tests/acl.integration.test.ts",
"test:all": "npm run test && npm run test:integration",
"prisma:generate": "npx prisma generate", "prisma:generate": "npx prisma generate",
"prisma:migrate": "npx prisma migrate deploy", "prisma:migrate": "npx prisma migrate deploy",
"prisma:create": "npx prisma db push --preview-feature", "prisma:create": "npx prisma db push --preview-feature",
@ -39,8 +42,8 @@
"@fastify/view": "^8.2.0", "@fastify/view": "^8.2.0",
"@inquirer/password": "^1.1.2", "@inquirer/password": "^1.1.2",
"@inquirer/prompts": "^1.2.3", "@inquirer/prompts": "^1.2.3",
"@nostr-dev-kit/ndk": "workspace:*", "@nostr-dev-kit/ndk": "3.0.3",
"@prisma/client": "^5.4.1", "@prisma/client": "^6.19.0",
"@scure/base": "^1.1.1", "@scure/base": "^1.1.1",
"@types/yargs": "^17.0.24", "@types/yargs": "^17.0.24",
"axios": "^1.6.2", "axios": "^1.6.2",
@ -57,7 +60,7 @@
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^5.0.0",
"lnbits": "^1.1.5", "lnbits": "^1.1.5",
"lnbits-ts": "^0.0.2", "lnbits-ts": "^0.0.2",
"nostr-tools": "^1.17.0", "nostr-tools": "~2.20.0",
"websocket-polyfill": "^0.0.3", "websocket-polyfill": "^0.0.3",
"ws": "^8.13.0", "ws": "^8.13.0",
"yargs": "^17.7.2" "yargs": "^17.7.2"
@ -65,7 +68,7 @@
"devDependencies": { "devDependencies": {
"@types/debug": "^4.1.8", "@types/debug": "^4.1.8",
"@types/node": "^18.16.18", "@types/node": "^18.16.18",
"prisma": "^5.4.1", "prisma": "^6.19.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsup": "^7.2.0", "tsup": "^7.2.0",
"typescript": "^5.1.3" "typescript": "^5.1.3"

146
package.nix Normal file
View file

@ -0,0 +1,146 @@
{
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;
};
})

6950
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,37 @@
-- 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

@ -0,0 +1,36 @@
/*
Warnings:
- You are about to drop the column `currentUsageCount` on the `PolicyRule` table. All the data in the column will be lost.
*/
-- CreateTable
CREATE TABLE "SigningLog" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"keyUserId" INTEGER NOT NULL,
"method" TEXT NOT NULL,
"kind" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SigningLog_keyUserId_fkey" FOREIGN KEY ("keyUserId") REFERENCES "KeyUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_PolicyRule" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"method" TEXT NOT NULL,
"kind" TEXT,
"maxUsageCount" INTEGER,
"windowSeconds" INTEGER,
"policyId" INTEGER,
CONSTRAINT "PolicyRule_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "Policy" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_PolicyRule" ("id", "kind", "maxUsageCount", "method", "policyId") SELECT "id", "kind", "maxUsageCount", "method", "policyId" FROM "PolicyRule";
DROP TABLE "PolicyRule";
ALTER TABLE "new_PolicyRule" RENAME TO "PolicyRule";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "SigningLog_keyUserId_method_createdAt_idx" ON "SigningLog"("keyUserId", "method", "createdAt");

View file

@ -17,6 +17,14 @@ model Request {
method String method String
params String? params String?
allowed Boolean? 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 { model KeyUser {
@ -31,6 +39,8 @@ model KeyUser {
logs Log[] logs Log[]
signingConditions SigningCondition[] signingConditions SigningCondition[]
Token Token[] Token Token[]
requests Request[]
signingLogs SigningLog[]
@@unique([keyName, userPubkey], name: "unique_key_user") @@unique([keyName, userPubkey], name: "unique_key_user")
} }
@ -56,6 +66,13 @@ model User {
pubkey String 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 { model SigningCondition {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
method String? method String?
@ -64,6 +81,9 @@ model SigningCondition {
keyUserKeyName String? keyUserKeyName String?
allowed Boolean? allowed Boolean?
keyUserId Int? keyUserId Int?
createdAt DateTime @default(now())
expiresAt DateTime?
revokedAt DateTime?
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id]) KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
} }
@ -93,12 +113,33 @@ model PolicyRule {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
method String method String
kind String? kind String?
// Usage cap (aiolabs/nsecbunkerd#28): allow at most `maxUsageCount`
// signings of this (method, kind) per rolling `windowSeconds`, counted
// live from SigningLog. `maxUsageCount` NULL = uncapped; `windowSeconds`
// NULL = lifetime window (count all-time). Replaces the never-enforced
// mutable `currentUsageCount` (derive-don't-count). Multiple capped rules
// for one request all bind (stacked caps, e.g. 20/hr AND 200/day).
maxUsageCount Int? maxUsageCount Int?
currentUsageCount Int? windowSeconds Int?
policyId Int? policyId Int?
Policy Policy? @relation(fields: [policyId], references: [id]) Policy Policy? @relation(fields: [policyId], references: [id])
} }
// Durable, append-only record of ALLOWED signings — the source of truth
// usage caps count against (aiolabs/nsecbunkerd#28). One row per allowed
// consequential request (sign_event + encrypt/decrypt; connect/ping/
// get_public_key are never recorded). Retention/pruning is a follow-up.
model SigningLog {
id Int @id @default(autoincrement())
keyUserId Int
method String
kind String?
createdAt DateTime @default(now())
KeyUser KeyUser @relation(fields: [keyUserId], references: [id])
@@index([keyUserId, method, createdAt])
}
model Token { model Token {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
keyName String keyName String
@ -110,6 +151,7 @@ model Token {
deletedAt DateTime? deletedAt DateTime?
expiresAt DateTime? expiresAt DateTime?
redeemedAt DateTime? redeemedAt DateTime?
revokedAt DateTime?
keyUserId Int? keyUserId Int?
policyId Int? policyId Int?
policy Policy? @relation(fields: [policyId], references: [id]) policy Policy? @relation(fields: [policyId], references: [id])

View file

@ -1,20 +1,32 @@
const { execSync, spawn } = require('child_process'); const { execSync, spawn } = require('child_process');
const fs = require('fs'); 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 { try {
console.log(`Running migrations`); console.log(`Running migrations`);
// check if config folder exists if (!fs.existsSync(configDir)) {
if (!fs.existsSync('./config')) { fs.mkdirSync(configDir, { recursive: true });
execSync(`mkdir config`);
} }
execSync('npm run prisma:migrate'); execSync('npm run prisma:migrate', { cwd: pkgRoot, stdio: 'inherit' });
} catch (error) { } catch (error) {
console.log(error); console.log(error);
// Handle any potential migration errors here // Handle any potential migration errors here
} }
const args = process.argv.slice(2); const args = process.argv.slice(2);
const childProcess = spawn('node', ['./dist/index.js', ...args], { const childProcess = spawn('node', [path.join(pkgRoot, 'dist/index.js'), ...args], {
stdio: 'inherit', stdio: 'inherit',
}); });

View file

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

View file

@ -0,0 +1,53 @@
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,
* windowSeconds?: number } }
*
* `maxUsageCount` + `windowSeconds` form a usage cap (#28): at most
* `maxUsageCount` signings of this (method, kind) per rolling
* `windowSeconds`; `windowSeconds` absent/null = lifetime window.
*
* `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,
windowSeconds: rule.windowSeconds,
}
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -0,0 +1,47 @@
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, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk"; import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
import AdminInterface from ".."; import AdminInterface from "..";
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { setupSkeletonProfile } from "../../lib/profile"; import { setupSkeletonProfile } from "../../lib/profile";
@ -131,12 +131,15 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
username = payload[0]; username = payload[0];
domain = payload[1]; domain = payload[1];
email = payload[2]; email = payload[2];
if (!username || !domain) {
throw new Error('Invalid authorization payload: missing username/domain');
}
return createAccountReal(admin, req, username, domain, email); 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 * This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen pragma: allowlist secret
*/ */
export async function createAccountReal( export async function createAccountReal(
admin: AdminInterface, admin: AdminInterface,
@ -195,7 +198,7 @@ export async function createAccountReal(
} }
const keyName = nip05; const keyName = nip05;
const nsec = nip19.nsecEncode(key.privateKey!); const nsec = key.nsec;
currentConfig.keys[keyName] = { key: key.privateKey }; currentConfig.keys[keyName] = { key: key.privateKey };
saveCurrentConfig(admin.configFile, currentConfig); saveCurrentConfig(admin.configFile, currentConfig);
@ -209,11 +212,18 @@ export async function createAccountReal(
// access it without having to go through an approval flow // access it without having to go through an approval flow
await grantPermissions(req, keyName); await grantPermissions(req, keyName);
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin); // 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);
} catch (e: any) { } catch (e: any) {
console.trace('error', e); console.trace('error', e);
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, const originalKind = req.event.kind!;
e.message); return admin.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, e.message);
} }
} }

View file

@ -1,7 +1,9 @@
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import { saveEncrypted } from "../../../commands/add.js"; import { saveEncrypted } from "../../../commands/add.js";
import { nip19 } from 'nostr-tools'; import { getCurrentConfig } from "../../../config/index.js";
import { decryptNsec } from "../../../config/keys.js";
import { setupSkeletonProfile } from "../../lib/profile.js"; import { setupSkeletonProfile } from "../../lib/profile.js";
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) { export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
@ -10,10 +12,48 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
if (!keyName || !passphrase) throw new Error("Invalid params"); if (!keyName || !passphrase) throw new Error("Invalid params");
if (!admin.loadNsec) throw new Error("No unlockKey method"); if (!admin.loadNsec) throw new Error("No unlockKey method");
// Idempotency guard. Callers re-pair a machine through the same keyName
// (e.g. spirekeeper's `spire-<id>`) and rely on create_new_key being
// idempotent — "returns the existing key if the name is taken". Without
// this guard the command unconditionally generated a FRESH key and
// `saveEncrypted` overwrote the existing encrypted blob on disk, silently
// destroying the in-use signing identity (unrecoverable — the old nsec then
// survives only in the running process until the next restart). NEVER
// generate-and-overwrite an existing name: recover and return it instead.
const currentConfig = await getCurrentConfig(admin.configFile);
const existing = currentConfig.keys?.[keyName];
if (existing) {
if (_nsec) {
throw new Error(
`key '${keyName}' already exists; refusing to overwrite it with an ` +
`imported nsec — delete it explicitly first if replacement is intended`,
);
}
if (!existing.iv || !existing.data) {
throw new Error(
`key '${keyName}' already exists in an unrecognized form; refusing to overwrite`,
);
}
let existingNsec: string;
try {
existingNsec = decryptNsec(existing.iv, existing.data, passphrase);
} catch (e: any) {
throw new Error(
`key '${keyName}' already exists but the supplied passphrase did not ` +
`decrypt it; refusing to overwrite (${e.message})`,
);
}
const existingUser = await new NDKPrivateKeySigner(existingNsec).user();
const result = JSON.stringify({ npub: existingUser.npub });
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}
let key; let key;
if (_nsec) { if (_nsec) {
key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string); // NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
// (see core/src/signers/private-key/index.ts `@ai-guardrail`).
key = new NDKPrivateKeySigner(_nsec);
} else { } else {
key = NDKPrivateKeySigner.generate(); key = NDKPrivateKeySigner.generate();
@ -23,7 +63,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
} }
const user = await key.user(); const user = await key.user();
const nsec = nip19.nsecEncode(key.privateKey!); const nsec = key.nsec;
await saveEncrypted( await saveEncrypted(
admin.configFile, admin.configFile,
@ -38,5 +78,5 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
npub: user.npub, npub: user.npub,
}); });
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
} }

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) { export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) {
@ -23,11 +24,11 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
kind: rule.kind.toString(), kind: rule.kind.toString(),
method: rule.method, method: rule.method,
maxUsageCount: rule.use_count, maxUsageCount: rule.use_count,
currentUsageCount: 0, windowSeconds: rule.window_seconds,
} }
}); });
} }
const result = JSON.stringify(["ok"]); const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
} }

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js"; import prisma from "../../../db.js";
export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) { export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) {
@ -7,15 +8,19 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
if (!clientName || !policyId) throw new Error("Invalid params"); if (!clientName || !policyId) throw new Error("Invalid params");
const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } }); const policyIdInt = parseInt(policyId);
const policy = await prisma.policy.findUnique({ where: { id: policyIdInt }, include: { rules: true } });
if (!policy) throw new Error("Policy not found"); if (!policy) throw new Error("Policy not found");
console.log({clientName, policy, durationInHours}); console.log({clientName, policy, durationInHours});
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); 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 = { const data: any = {
keyName, clientName, policyId, keyName, clientName, policyId: policyIdInt,
createdBy: req.pubkey, createdBy: req.pubkey,
token token
}; };
@ -26,5 +31,5 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
if (!tokenRecord) throw new Error("Token not created"); if (!tokenRecord) throw new Error("Token not created");
const result = JSON.stringify(["ok"]); const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
} }

View file

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

View file

@ -0,0 +1,34 @@
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

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

View file

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

View file

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

View file

@ -0,0 +1,44 @@
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

@ -0,0 +1,58 @@
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 single PolicyRule in place primarily to retune
* a usage cap (#28) without the remove+add dance. Under the live-policy model
* the change takes effect at the next sign-time check for every KeyUser bound
* to the policy; no re-pairing, no migration. So switching e.g. 200/day to
* 20/hour is `{ maxUsageCount: 20, windowSeconds: 3600 }`.
*
* Param shape (JSON-stringified):
* { ruleId: number,
* patch: { method?: string,
* kind?: number | "all" | null,
* maxUsageCount?: number | null,
* windowSeconds?: number | null } }
*
* A field absent from `patch` is left alone; `null` clears it
* (`maxUsageCount: null` = uncapped, `windowSeconds: null` = lifetime window).
*
* Tightening a cap takes effect immediately a client already over the new
* limit within the window is denied until its trailing count falls below it.
*/
export default async function updatePolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { ruleId, patch } = payload;
if (typeof ruleId !== "number" || !patch || typeof patch !== "object") {
throw new Error("Invalid params");
}
const data: {
method?: string;
kind?: string | null;
maxUsageCount?: number | null;
windowSeconds?: number | null;
} = {};
if (patch.method !== undefined) data.method = patch.method;
if (patch.kind !== undefined) {
data.kind = patch.kind === null ? null : patch.kind.toString();
}
if (patch.maxUsageCount !== undefined) data.maxUsageCount = patch.maxUsageCount;
if (patch.windowSeconds !== undefined) data.windowSeconds = patch.windowSeconds;
if (Object.keys(data).length === 0) throw new Error("Empty patch");
await prisma.policyRule.update({ where: { id: ruleId }, 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 "websocket-polyfill";
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk'; import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk';
import { NDKNostrRpc } from '@nostr-dev-kit/ndk'; import { NDKNostrRpc } from '@nostr-dev-kit/ndk';
import createDebug from 'debug'; import createDebug from 'debug';
import { Key, KeyUser } from '../run'; import { Key, KeyUser } from '../run';
@ -13,11 +13,20 @@ import createNewToken from './commands/create_new_token';
import unlockKey from './commands/unlock_key'; import unlockKey from './commands/unlock_key';
import renameKeyUser from './commands/rename_key_user.js'; import renameKeyUser from './commands/rename_key_user.js';
import revokeUser from './commands/revoke_user'; 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 updatePolicyRule from './commands/update_policy_rule';
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 fs from 'fs';
import { validateRequestFromAdmin } from './validations/request-from-admin'; import { validateRequestFromAdmin } from './validations/request-from-admin';
import { dmUser } from '../../utils/dm-user'; import { dmUser } from '../../utils/dm-user';
import { IConfig, getCurrentConfig } from "../../config"; import { IConfig, getCurrentConfig } from "../../config";
import path from 'path'; import path from 'path';
import { attachIndefiniteReconnect } from '../lib/relay-reconnect.js';
const debug = createDebug("nsecbunker:admin"); const debug = createDebug("nsecbunker:admin");
@ -55,6 +64,15 @@ class AdminInterface {
explicitRelayUrls: opts.adminRelays, explicitRelayUrls: opts.adminRelays,
signer: new NDKPrivateKeySigner(opts.key), 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) => { this.ndk.signer?.user().then((user: NDKUser) => {
let connectionString = `bunker://${user.npub}`; let connectionString = `bunker://${user.npub}`;
@ -111,18 +129,74 @@ class AdminInterface {
return; return;
} }
this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready')); 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:disconnect', () => console.log('❌ admin disconnected')); this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
this.ndk.connect(2500).then(() => { this.ndk.connect(2500).then(() => {
// connect for whitelisted admins // connect for whitelisted admins
this.rpc.subscribe({ this.rpc.subscribe({
"kinds": [NDKKind.NostrConnect, 24134 as number], "kinds": [NDKKind.NostrConnect, NIP46_ADMIN_RESPONSE_KIND],
"#p": [this.signerUser!.pubkey] "#p": [this.signerUser!.pubkey]
}); });
this.rpc.on('request', (req) => this.handleRequest(req)); // 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));
pingOrDie(this.ndk); // 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');
}
}).catch((err) => { }).catch((err) => {
console.log('❌ admin connection failed'); console.log('❌ admin connection failed');
console.log(err); console.log(err);
@ -146,6 +220,13 @@ class AdminInterface {
case 'create_new_policy': await createNewPolicy(this, req); break; case 'create_new_policy': await createNewPolicy(this, req); break;
case 'get_policies': await this.reqListPolicies(req); break; case 'get_policies': await this.reqListPolicies(req); break;
case 'create_new_token': await createNewToken(this, 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 'update_policy_rule': await updatePolicyRule(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: default:
const originalKind = req.event.kind!; const originalKind = req.event.kind!;
console.log(`Unknown method ${req.method}`); console.log(`Unknown method ${req.method}`);
@ -158,7 +239,15 @@ class AdminInterface {
} }
} catch (err: any) { } catch (err: any) {
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params); debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message); // 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);
} }
} }
@ -196,7 +285,7 @@ class AdminInterface {
const key = keys.find((k) => k.name === keyName); const key = keys.find((k) => k.name === keyName);
if (!key || !key.npub) { if (!key || !key.npub) {
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), 24134); return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), NIP46_ADMIN_RESPONSE_KIND);
} }
const npub = key.npub; const npub = key.npub;
@ -218,7 +307,7 @@ class AdminInterface {
}; };
})); }));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134); return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
} }
/** /**
@ -241,16 +330,17 @@ class AdminInterface {
expires_at: p.expiresAt, expires_at: p.expiresAt,
rules: p.rules.map((r) => { rules: p.rules.map((r) => {
return { return {
id: r.id,
method: r.method, method: r.method,
kind: r.kind, kind: r.kind,
max_usage_count: r.maxUsageCount, max_usage_count: r.maxUsageCount,
current_usage_count: r.currentUsageCount, window_seconds: r.windowSeconds,
}; };
}) })
}; };
})); }));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134); return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
} }
/** /**
@ -262,7 +352,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeys()); const result = JSON.stringify(await this.getKeys());
const pubkey = req.pubkey; const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134 return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
} }
/** /**
@ -274,7 +364,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeyUsers(req)); const result = JSON.stringify(await this.getKeyUsers(req));
const pubkey = req.pubkey; const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134 return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
} }
/** /**
@ -340,7 +430,7 @@ class AdminInterface {
remoteUser.pubkey, remoteUser.pubkey,
'acl', 'acl',
[params], [params],
24134, NIP46_ADMIN_RESPONSE_KIND,
(res: NDKRpcResponse) => { (res: NDKRpcResponse) => {
this.requestPermissionResponse( this.requestPermissionResponse(
remotePubkey, remotePubkey,
@ -395,44 +485,47 @@ class AdminInterface {
} }
} }
async function pingOrDie(ndk: NDK) { /**
let deathTimer: NodeJS.Timeout | null = null; * Pool-status connection watchdog. Exits the daemon if every relay in
* the pool stays disconnected for longer than PARTITION_THRESHOLD_MS.
function resetDeath() { *
if (deathTimer) clearTimeout(deathTimer); * Replaces the original `pingOrDie` self-echo watchdog, which published
deathTimer = setTimeout(() => { * a kind-24133 event to its own pubkey every 20s and exited if it
console.log(`❌ No ping event received in 30 seconds. Exiting.`); * didn't see the echo within 50s. That works on public relays but
process.exit(1); * silently breaks on single-private-relay setups: NDK 2.8.1's outbox
}, 50000); * 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
const self = await ndk.signer!.user(); * work fine. See aiolabs/nsecbunkerd#4 + #7.
const sub = ndk.subscribe({ *
authors: [self.pubkey], * The pool-status approach uses NDK's own connection-lifecycle
kinds: [NDKKind.NostrConnect], * tracking `pool.connectedRelays()` reports relays in
"#p": [self.pubkey] * NDKRelayStatus.CONNECTED which is reliable across all relay
}); * configurations because it doesn't depend on round-trip
sub.on("event", (event: NDKEvent) => { * publish/subscribe. No event is published; no relay traffic.
console.log(`🔔 Received ping event:`, event.created_at); *
resetDeath(); * Detects partition within POLL_INTERVAL + PARTITION_THRESHOLD ms.
}); * Transient disconnects shorter than PARTITION_THRESHOLD don't trip
sub.start(); * the watchdog useful for relays that flap or briefly drop on
* network blips.
resetDeath(); */
async function relayConnectionWatchdog(ndk: NDK) {
const POLL_INTERVAL_MS = 10_000;
const PARTITION_THRESHOLD_MS = 60_000;
let lastConnectedAt = Date.now();
setInterval(() => { setInterval(() => {
const event = new NDKEvent(ndk, { const connectedCount = ndk.pool.connectedRelays().length;
kind: NDKKind.NostrConnect, if (connectedCount > 0) {
tags: [ ["p", self.pubkey] ], lastConnectedAt = Date.now();
content: "ping" return;
} as NostrEvent); }
event.publish().then(() => { const elapsed = Date.now() - lastConnectedAt;
console.log(`🔔 Sent ping event:`, event.created_at); if (elapsed > PARTITION_THRESHOLD_MS) {
}).catch((e: any) => { console.log(`❌ No connected relays for ${Math.floor(elapsed / 1000)}s. Exiting.`);
console.log(`❌ Failed to send ping event:`, e.message);
process.exit(1); process.exit(1);
}); }
}, 20000); }, POLL_INTERVAL_MS);
} }
export default AdminInterface; export default AdminInterface;

14
src/daemon/admin/kinds.ts Normal file
View file

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

View file

@ -1,6 +1,7 @@
import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '@nostr-dev-kit/ndk'; import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '@nostr-dev-kit/ndk';
import prisma from '../../db.js'; import prisma from '../../db.js';
import type {FastifyInstance} from "fastify"; import type {FastifyInstance} from "fastify";
import { grantIsLive } from '../lib/acl/index.js';
export class Backend extends NDKNip46Backend { export class Backend extends NDKNip46Backend {
public baseUrl?: string; public baseUrl?: string;
@ -18,8 +19,67 @@ export class Backend extends NDKNip46Backend {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.fastify = fastify; this.fastify = fastify;
}
// this.setStrategy('publish_event', new PublishEventHandlingStrategy()); /**
* 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());
});
} }
private async validateToken(token: string) { private async validateToken(token: string) {
@ -32,7 +92,10 @@ export class Backend extends NDKNip46Backend {
if (!tokenRecord) throw new Error("Token not found"); if (!tokenRecord) throw new Error("Token not found");
if (tokenRecord.redeemedAt) throw new Error("Token already redeemed"); if (tokenRecord.redeemedAt) throw new Error("Token already redeemed");
if (!tokenRecord.policy) throw new Error("Policy not found"); if (!tokenRecord.policy) throw new Error("Policy not found");
if (tokenRecord.expiresAt && tokenRecord.expiresAt < new Date()) throw new Error("Token expired"); // 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");
return tokenRecord; return tokenRecord;
} }
@ -41,39 +104,20 @@ export class Backend extends NDKNip46Backend {
const tokenRecord = await this.validateToken(token); const tokenRecord = await this.validateToken(token);
const keyName = tokenRecord.keyName; const keyName = tokenRecord.keyName;
// Upsert the KeyUser with the given remotePubkey // 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.
const upsertedUser = await prisma.keyUser.upsert({ const upsertedUser = await prisma.keyUser.upsert({
where: { unique_key_user: { keyName, userPubkey } }, where: { unique_key_user: { keyName, userPubkey } },
update: { }, update: { },
create: { keyName, userPubkey, description: tokenRecord.clientName }, 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({ await prisma.token.update({
where: { id: tokenRecord.id }, where: { id: tokenRecord.id },
data: { data: {

View file

@ -1,14 +0,0 @@
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,13 +1,55 @@
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'; import type { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
import prisma from '../../../db.js'; 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';
/**
* Does a PolicyRule's stored `kind` match this request's kind? Mirrors the
* `kindMatchers` used in the token query: a NULL or 'all' rule matches any
* kind; otherwise it must equal the (stringified) payload kind.
*/
function ruleKindMatches(ruleKind: string | null, payloadKindString?: string): boolean {
if (ruleKind === null || ruleKind === 'all') return true;
return payloadKindString !== undefined && ruleKind === payloadKindString;
}
/**
* 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( export async function checkIfPubkeyAllowed(
keyName: string, keyName: string,
remotePubkey: string, remotePubkey: string,
method: IMethod, method: IMethod,
payload?: string | NostrEvent payload?: string | NostrEvent,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
// find KeyUser // One clock reading for the whole decision.
const now = new Date();
// Step 1: find KeyUser.
const keyUser = await prisma.keyUser.findUnique({ const keyUser = await prisma.keyUser.findUnique({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } }, where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
}); });
@ -16,59 +58,169 @@ export async function checkIfPubkeyAllowed(
return undefined; return undefined;
} }
// find SigningCondition // 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.)
const signingConditionQuery = requestToSigningConditionQuery(method, payload); const signingConditionQuery = requestToSigningConditionQuery(method, payload);
const explicitReject = await prisma.signingCondition.findFirst({ const liveDeny = await prisma.signingCondition.findFirst({
where: { where: { keyUserId: keyUser.id, ...signingConditionQuery, allowed: false, ...live },
keyUserId: keyUser.id,
method: '*',
allowed: false,
}
}); });
if (explicitReject) { if (liveDeny) {
console.log(`explicit reject`, explicitReject);
return false; return false;
} }
const signingCondition = await prisma.signingCondition.findFirst({ const liveGrant = await prisma.signingCondition.findFirst({
where: { where: { keyUserId: keyUser.id, ...signingConditionQuery, allowed: true, ...live },
keyUserId: keyUser.id,
...signingConditionQuery,
}
}); });
// if no SigningCondition found, return undefined if (liveGrant) {
if (!signingCondition) { 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.
// 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 });
}
// A token "grants" this request when its policy carries a matching rule —
// except `connect`, which any bound token grants (it IS the pairing). This
// predicate is reused below to tell a lapsed binding from a never-granted
// one (#36).
const policyMatch =
method === 'connect'
? {}
: { policy: { rules: { some: { method, OR: kindMatchers } } } };
if (method === 'connect') {
const liveToken = await prisma.token.findFirst({
where: { keyUserId: keyUser.id, ...live },
});
if (liveToken) {
return true;
}
} else {
// Find live tokens bound to this KeyUser whose policy has at least one
// rule matching (method, kind), and pull the rules so usage caps can be
// enforced live off the SigningLog (#28).
const liveTokens = await prisma.token.findMany({
where: {
keyUserId: keyUser.id,
...live,
...policyMatch,
},
include: { policy: { include: { rules: true } } },
});
const matchingRules = liveTokens
.flatMap((t) => t.policy?.rules ?? [])
.filter((r) => r.method === method && ruleKindMatches(r.kind, payloadKindString));
if (matchingRules.length > 0) {
// Stacked caps (#28): every matching rule that carries a cap must
// have remaining budget in its window — so e.g. 20/hr AND 200/day
// both bind. An uncapped matching rule grants access with no limit.
for (const rule of matchingRules) {
if (rule.maxUsageCount == null) continue;
// null window = lifetime: count all-time, no createdAt floor.
const since = rule.windowSeconds != null
? new Date(now.getTime() - rule.windowSeconds * 1000)
: undefined;
const used = await prisma.signingLog.count({
where: {
keyUserId: keyUser.id,
method,
// A kind-specific rule caps only that kind; an
// 'all'/NULL rule caps every kind of the method.
...(rule.kind === null || rule.kind === 'all' ? {} : { kind: rule.kind }),
...(since ? { createdAt: { gt: since } } : {}),
},
});
if (used >= rule.maxUsageCount) {
// Cap exhausted. Left as `undefined` deliberately: a windowed
// cap is a temporary rate-limit (it refills as the window
// rolls), not a permanent lapse, so it must NOT be reclassed
// as the re-pair signal below. A dedicated rate-limit reply
// is a separate follow-up to #36.
return undefined; return undefined;
} }
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 },
} }
return true;
}
}
// Step 5: no LIVE grant. Distinguish a LAPSED binding — a token that would
// have granted this request but is now expired or token-revoked — from a
// request that was never granted at all. A lapsed binding hard-rejects
// (`false`) so an unattended client (e.g. a spire) re-pairs immediately
// instead of hanging on the admin-prompt path; a never-granted request
// stays `undefined` so the caller's requestPermission flow can still prompt
// an admin to approve a genuinely new permission. See aiolabs/nsecbunkerd#36.
const lapsedGrant = await prisma.token.findFirst({
where: {
keyUserId: keyUser.id,
...policyMatch,
OR: [{ revokedAt: { not: null } }, { expiresAt: { lte: now } }],
},
}); });
if (revoked) { if (lapsedGrant) {
return false; return false;
} }
}
if (allowed === true || allowed === false) {
console.log(`found signing condition`, signingCondition);
return allowed;
}
// Step 6: genuinely no grant for this (method, kind).
return undefined; return undefined;
} }
export type IMethod = "connect" | "sign_event" | "encrypt" | "decrypt" | "ping"; /**
* 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;
export type IAllowScope = { export type IAllowScope = {
kind?: number | 'all'; kind?: number | 'all';
@ -78,10 +230,14 @@ export function requestToSigningConditionQuery(method: IMethod, payload?: string
const signingConditionQuery: any = { method }; const signingConditionQuery: any = { method };
switch (method) { switch (method) {
case 'sign_event': case 'sign_event': {
signingConditionQuery.kind = { in: [ payload?.kind?.toString(), 'all' ] }; const kindString = (typeof payload === 'object' && payload?.kind !== undefined)
? payload.kind.toString()
: undefined;
signingConditionQuery.kind = { in: [kindString, 'all'] };
break; break;
} }
}
return signingConditionQuery; return signingConditionQuery;
} }
@ -127,19 +283,46 @@ export async function allowAllRequestsFromKey(
} }
} }
export async function rejectAllRequestsFromKey(remotePubkey: string, keyName: string): Promise<void> { /**
// Upsert the KeyUser with the given remotePubkey * Consequential methods whose allowed requests are recorded in SigningLog
const upsertedUser = await prisma.keyUser.upsert({ * the set usage caps (#28) can count against. `connect`/`ping`/
where: { unique_key_user: { keyName, userPubkey: remotePubkey } }, * `get_public_key` are never recorded (they're not signings and are never
update: { }, * meaningfully capped).
create: { keyName, userPubkey: remotePubkey }, */
}); const RECORDED_METHODS = new Set<string>([
'sign_event',
'encrypt', 'decrypt',
'nip04_encrypt', 'nip04_decrypt',
'nip44_encrypt', 'nip44_decrypt',
]);
// Create a new SigningCondition for the given KeyUser and set allowed to false /**
await prisma.signingCondition.create({ * Append a durable SigningLog row for an ALLOWED consequential request the
data: { * source of truth `checkIfPubkeyAllowed` counts usage against (#28, the
allowed: false, * derive-don't-count approach: no mutable counter to drift). Called from the
keyUserId: upsertedUser.id, * permit callback after a request is granted. Best-effort: a failure here must
}, * not block a sign that was already authorized.
*/
export async function recordSigning(
keyName: string,
remotePubkey: string,
method: IMethod,
payload?: string | NostrEvent,
): Promise<void> {
if (!RECORDED_METHODS.has(method)) return;
const keyUser = await prisma.keyUser.findUnique({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
select: { id: true },
});
if (!keyUser) return;
const kind =
method === 'sign_event' && typeof payload === 'object' && payload?.kind !== undefined
? payload.kind.toString()
: null;
await prisma.signingLog.create({
data: { keyUserId: keyUser.id, method, kind },
}); });
} }

View file

@ -0,0 +1,40 @@
/**
* 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

@ -0,0 +1,101 @@
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,10 +1,7 @@
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk'; import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools'; import { nip19, utils as nostrUtils } from 'nostr-tools';
import { Backend } from './backend/index.js'; import { Backend } from './backend/index.js';
import { import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js';
IMethod,
checkIfPubkeyAllowed,
} from './lib/acl/index.js';
import AdminInterface from './admin/index.js'; import AdminInterface from './admin/index.js';
import { IConfig } from '../config/index.js'; import { IConfig } from '../config/index.js';
import { NDKRpcRequest } from '@nostr-dev-kit/ndk'; import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
@ -18,6 +15,7 @@ import FastifyView from '@fastify/view';
import Handlebars from "handlebars"; import Handlebars from "handlebars";
import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js"; import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js";
import {processRegistrationWebHandler} from "./web/authorize.js"; import {processRegistrationWebHandler} from "./web/authorize.js";
import { attachIndefiniteReconnect } from "./lib/relay-reconnect.js";
export type Key = { export type Key = {
name: string; name: string;
@ -38,8 +36,7 @@ function getKeys(config: DaemonConfig) {
const keys: Key[] = []; const keys: Key[] = [];
for (const [name, nsec] of Object.entries(config.keys)) { for (const [name, nsec] of Object.entries(config.keys)) {
const hexpk = nip19.decode(nsec).data as string; const user = await new NDKPrivateKeySigner(nsec).user();
const user = await new NDKPrivateKeySigner(hexpk).user();
const key = { const key = {
name, name,
npub: user.npub, npub: user.npub,
@ -108,10 +105,16 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
} }
try { try {
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method as IMethod, payload); const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method, payload);
if (keyAllowed === true || keyAllowed === false) { if (keyAllowed === true || keyAllowed === false) {
console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`); console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`);
if (keyAllowed === true) {
// Record the allowed signing so usage caps can count it (#28).
// Best-effort: never block an already-authorized sign.
await recordSigning(keyName, remotePubkey, method, payload)
.catch((e) => console.log('recordSigning error:', e));
}
return keyAllowed; return keyAllowed;
} }
@ -124,7 +127,11 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
method, method,
payload payload
) )
.then(() => resolve(true)) .then(async () => {
await recordSigning(keyName, remotePubkey, method, payload)
.catch((e) => console.log('recordSigning error:', e));
resolve(true);
})
.catch(() => resolve(false)); .catch(() => resolve(false));
}); });
} catch(e) { } catch(e) {
@ -164,11 +171,17 @@ class Daemon {
explicitRelayUrls: config.nostr.relays, explicitRelayUrls: config.nostr.relays,
}); });
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) ); this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
this.ndk.pool.on('relay:notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); }); this.ndk.pool.on('notice', (r, n) => { console.log(`👀 Notice from ${r.url}`, n); });
this.ndk.pool.on('relay:disconnect', (r) => { this.ndk.pool.on('relay:disconnect', (r) => {
console.log(`🚫 Disconnected from ${r.url}`); 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() { async startWebAuth() {
@ -206,9 +219,129 @@ class Daemon {
continue; continue;
} }
const nsec = nip19.nsecEncode(settings.key); // 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));
this.loadNsec(keyName, nsec); 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() { async start() {
@ -226,39 +359,44 @@ class Daemon {
*/ */
async startKey(name: string, nsec: string) { async startKey(name: string, nsec: string) {
const cb = signingAuthorizationCallback(name, this.adminInterface); const cb = signingAuthorizationCallback(name, this.adminInterface);
let hexpk: string; // NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
// (see `core/src/signers/private-key/index.ts` `@ai-guardrail`
if (nsec.startsWith('nsec1')) { // — "DO NOT use nip19.decode() to convert nsec to hex before
try { // passing it here"). The bech32-decode workaround for #8 was
const key = new NDKPrivateKeySigner(nsec); // tied to NDK 2.8.1's old constructor behavior and is no
hexpk = key.privateKey!; // longer needed post-#14 NDK bump.
} catch(e) { const backend = new Backend(this.ndk, this.fastify, nsec, cb, this.config.baseUrl);
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(); await backend.start();
} }
async unlockKey(keyName: string, passphrase: string): Promise<boolean> { 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 keyData = this.config.allKeys[keyName];
const { iv, data } = keyData; const { iv, data } = keyData;
const nsec = decryptNsec(iv, data, passphrase); const nsec = decryptNsec(iv, data, passphrase);
this.activeKeys[keyName] = nsec; this.activeKeys[keyName] = nsec;
this.startKey(keyName, nsec); await this.startKey(keyName, nsec);
return true; return true;
} }
loadNsec(keyName: string, nsec: string) { async loadNsec(keyName: string, nsec: string) {
this.activeKeys[keyName] = nsec; this.activeKeys[keyName] = nsec;
this.startKey(keyName, nsec); await this.startKey(keyName, nsec);
} }
} }

View file

@ -0,0 +1,340 @@
// Integration tests for checkIfPubkeyAllowed against a real (throwaway) SQLite
// DB — the wiring that actually closes #24 (step-4 Token join filtered by
// liveWhere) which the pure lifecycle.test.ts cannot exercise.
//
// Run via `npm run test:integration`, which sets DATABASE_URL to a temp file
// and routes through tests/register-ts.cjs (ts-node + `.js`->`.ts` resolution).
// Requires the prisma engine env (PRISMA_QUERY_ENGINE_LIBRARY etc.) on PATH —
// present in CI/nix; in the devShell pending #30. See #29.
import { test, before, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import prisma from '../src/db';
import { checkIfPubkeyAllowed, recordSigning } from '../src/daemon/lib/acl/index';
const KEY = 'test-key';
const PUB = 'client-pubkey-1';
const past = () => new Date(Date.now() - 60_000);
const future = () => new Date(Date.now() + 60_000);
const signEvt = { kind: 1 } as any;
// ---- seeding helpers -------------------------------------------------------
async function seedPolicy(method: string, kind: string | null): Promise<number> {
const policy = await prisma.policy.create({
data: { name: 'p', rules: { create: [{ method, kind: kind ?? undefined }] } },
});
return policy.id;
}
async function seedKeyUser(opts: { revokedAt?: Date | null } = {}): Promise<number> {
const ku = await prisma.keyUser.create({
data: { keyName: KEY, userPubkey: PUB, revokedAt: opts.revokedAt ?? null },
});
return ku.id;
}
async function seedToken(
keyUserId: number,
policyId: number,
opts: { expiresAt?: Date | null; revokedAt?: Date | null } = {},
): Promise<void> {
await prisma.token.create({
data: {
keyName: KEY,
token: 't-' + Math.random().toString(36).slice(2),
clientName: 'c',
createdBy: 'admin',
keyUserId,
policyId,
redeemedAt: new Date(),
expiresAt: opts.expiresAt ?? null,
revokedAt: opts.revokedAt ?? null,
},
});
}
async function seedCondition(
keyUserId: number,
opts: { method: string; kind?: string | null; allowed: boolean; expiresAt?: Date | null; revokedAt?: Date | null },
): Promise<void> {
await prisma.signingCondition.create({
data: {
keyUserId,
method: opts.method,
kind: opts.kind ?? undefined,
allowed: opts.allowed,
expiresAt: opts.expiresAt ?? null,
revokedAt: opts.revokedAt ?? null,
},
});
}
type RuleSeed = { method: string; kind: string | null; maxUsageCount?: number | null; windowSeconds?: number | null };
async function seedPolicyRules(rules: RuleSeed[]): Promise<number> {
const policy = await prisma.policy.create({
data: {
name: 'p',
rules: {
create: rules.map((r) => ({
method: r.method,
kind: r.kind ?? undefined,
maxUsageCount: r.maxUsageCount ?? null,
windowSeconds: r.windowSeconds ?? null,
})),
},
},
});
return policy.id;
}
async function seedSigningLog(
keyUserId: number,
method: string,
kind: string | null,
count: number,
ageMs = 0,
): Promise<void> {
const createdAt = new Date(Date.now() - ageMs);
for (let i = 0; i < count; i++) {
await prisma.signingLog.create({ data: { keyUserId, method, kind, createdAt } });
}
}
// ---- lifecycle -------------------------------------------------------------
before(() => {
const url = process.env.DATABASE_URL || '';
// Hard guard: these tests truncate tables. Never let that touch a real DB.
if (!url.includes('tests/.tmp/')) {
throw new Error(
`Refusing to run: DATABASE_URL must point at tests/.tmp/ (got "${url}"). Use 'npm run test:integration'.`,
);
}
const dir = path.resolve(process.cwd(), 'tests/.tmp');
fs.mkdirSync(dir, { recursive: true });
fs.rmSync(path.resolve(dir, 'acl-int.db'), { force: true });
execSync('npx prisma db push --skip-generate --accept-data-loss', {
stdio: 'pipe',
env: process.env,
});
});
beforeEach(async () => {
// FK-safe truncation order.
await prisma.signingLog.deleteMany();
await prisma.signingCondition.deleteMany();
await prisma.request.deleteMany();
await prisma.token.deleteMany();
await prisma.policyRule.deleteMany();
await prisma.policy.deleteMany();
await prisma.keyUser.deleteMany();
});
after(async () => {
await prisma.$disconnect();
});
// ---- cases -----------------------------------------------------------------
test('live token + matching policy rule -> sign_event allowed', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
});
// A token bound to the KeyUser that has lapsed (expiry or token-revoke) means
// the pairing WAS granted and is now spent. It must hard-reject with `false` so
// an unattended client re-pairs immediately, NOT `undefined` (which routes to
// the admin-prompt path and hangs an ATM until a BunkerTimeoutError). The smoke
// on the Sintra proved the divergence: revoke -> clean reject, expiry -> hang.
// See aiolabs/nsecbunkerd#36 (and #24, which made the expired token stop
// granting in the first place).
test('expired token -> sign_event hard-rejected (false) [#24 + #36]', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, { expiresAt: past() });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
});
test('token-revoked -> sign_event hard-rejected (false) [#36]', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, { revokedAt: past() });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
});
test('live token -> connect allowed (pairing)', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'connect'), true);
});
test('expired token -> connect hard-rejected (false) [#36]', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, { expiresAt: past() });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'connect'), false);
});
// The reject above is reserved for a binding that LAPSED. A request whose method
// was never in the (still-live) token's policy is genuinely new permission and
// must stay `undefined` so an admin could approve it out-of-band — it must NOT
// be swept up by the #36 re-pair signal.
test('live token, method outside its policy -> undefined (never granted, not lapsed) [#36]', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'nip44_encrypt'), undefined);
});
// And a lapsed token only rejects the method IT covered: a different method has
// no lapsed grant of its own, so it stays a never-granted `undefined`.
test('expired token, request a method it never covered -> undefined [#36]', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, { expiresAt: past() });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'nip44_encrypt'), undefined);
});
test('KeyUser.revokedAt denies (false) and beats a live token', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser({ revokedAt: past() });
await seedToken(ku, pid, {});
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
});
test('live SigningCondition grant -> allowed', async () => {
const ku = await seedKeyUser();
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
});
test('expired SigningCondition grant -> ignored (falls through to undefined)', async () => {
const ku = await seedKeyUser();
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, expiresAt: past() });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('revoked SigningCondition grant -> ignored (falls through to undefined)', async () => {
const ku = await seedKeyUser();
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, revokedAt: past() });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('live SigningCondition deny beats a live token grant -> false', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: false });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
});
test('future-dated token + future-dated grant stay live', async () => {
const ku = await seedKeyUser();
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, expiresAt: future() });
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
});
test('policy rule kind mismatch -> undefined', async () => {
const pid = await seedPolicy('sign_event', '1');
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', { kind: 2 } as any), undefined);
});
test('no KeyUser -> undefined', async () => {
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
// ---- usage caps (#28) ------------------------------------------------------
test('usage cap: under the limit -> allowed', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 3, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 2); // 2 < 3
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
});
test('usage cap: at the limit -> denied', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 3, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 3); // 3 >= 3
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('usage cap: signings outside the window do not count', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 5, 2 * 3600 * 1000); // 5, aged 2h (outside 1h)
await seedSigningLog(ku, 'sign_event', '1', 1); // 1 recent
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // only 1 in window
});
test('uncapped rule -> allowed regardless of log volume', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: null }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 100);
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
});
test('lifetime cap (windowSeconds null) counts all-time', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: null }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 2, 10 * 24 * 3600 * 1000); // 2, aged 10 days
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); // lifetime: 2 >= 2
});
test('kind-specific cap counts only that kind', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '2', 5); // kind 2 — irrelevant to a kind-1 cap
await seedSigningLog(ku, 'sign_event', '1', 1);
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // kind-1 count = 1 < 2
});
test('stacked caps: the hourly cap binds even when the daily is fine', async () => {
const pid = await seedPolicyRules([
{ method: 'sign_event', kind: '1', maxUsageCount: 20, windowSeconds: 3600 },
{ method: 'sign_event', kind: '1', maxUsageCount: 200, windowSeconds: 86400 },
]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 20); // hits the 20/hr cap; well under 200/day
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('stacked caps: the daily cap binds even when the hourly is fine', async () => {
const pid = await seedPolicyRules([
{ method: 'sign_event', kind: '1', maxUsageCount: 20, windowSeconds: 3600 },
{ method: 'sign_event', kind: '1', maxUsageCount: 200, windowSeconds: 86400 },
]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 15); // hourly: 15 < 20 OK
await seedSigningLog(ku, 'sign_event', '1', 190, 2 * 3600 * 1000); // +190 aged 2h: in day, not hour
// hourly = 15 (OK), daily = 205 >= 200 -> denied
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('recordSigning feeds the cap: record then re-check denies at the limit', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 1, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // 0 < 1
await recordSigning(KEY, PUB, 'sign_event', signEvt); // now 1 recorded
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); // 1 >= 1
});

44
tests/lifecycle.test.ts Normal file
View file

@ -0,0 +1,44 @@
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 } }],
});
});

32
tests/register-ts.cjs Normal file
View file

@ -0,0 +1,32 @@
// Test bootstrap: register ts-node (transpile-only) AND teach CommonJS to
// resolve the project's `.js`-extension, ESM-style import specifiers to their
// `.ts` sources. The app is written `import x from './y.js'` but compiled by
// tsup; under a plain CommonJS require there is no `y.js` on disk, only `y.ts`.
// This lets integration tests import the real app modules (acl, db, backend)
// without a build step or a bundler. Used by the `test:integration` script.
const Module = require('module');
require('ts-node').register({ transpileOnly: true });
const originalResolve = Module._resolveFilename;
Module._resolveFilename = function (request, parent, isMain, options) {
try {
return originalResolve.call(this, request, parent, isMain, options);
} catch (err) {
// Only retry relative `.js` specifiers as `.ts` — never touch package
// imports (e.g. `@prisma/client`) or anything that already resolved.
if (
(request.startsWith('./') || request.startsWith('../')) &&
request.endsWith('.js')
) {
return originalResolve.call(
this,
request.slice(0, -3) + '.ts',
parent,
isMain,
options,
);
}
throw err;
}
};