Compare commits
No commits in common. "dev" and "master" have entirely different histories.
44 changed files with 2788 additions and 7118 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,4 +9,3 @@ config
|
|||
.env
|
||||
.turbo
|
||||
prisma
|
||||
tests/.tmp
|
||||
|
|
|
|||
40
Dockerfile
40
Dockerfile
|
|
@ -1,32 +1,20 @@
|
|||
# Patched from upstream kind-0/nsecbunkerd Dockerfile to use pnpm — the
|
||||
# upstream version uses `npm install` but package.json declares
|
||||
# `@nostr-dev-kit/ndk` as `workspace:*`, which only pnpm understands.
|
||||
# A clean clone of upstream fails to build with `EUNSUPPORTEDPROTOCOL`
|
||||
# under npm. Switching to pnpm matches the lockfile that ships in-repo.
|
||||
# Also drops `--frozen-lockfile` because the upstream pnpm-lock.yaml is
|
||||
# out of date vs. package.json (ERR_PNPM_OUTDATED_LOCKFILE) — bug to
|
||||
# file upstream once we've verified the rest of the stack works.
|
||||
|
||||
FROM node:20.11-bullseye AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
# Copy lockfile + manifest first so the install layer caches across
|
||||
# source changes.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Generate prisma client and build the application
|
||||
RUN npx prisma generate
|
||||
RUN pnpm run build
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20.11-alpine AS runtime
|
||||
FROM node:20.11-alpine as runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -34,25 +22,13 @@ RUN apk update && \
|
|||
apk add --no-cache openssl && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
# Copy built files from the build stage
|
||||
COPY --from=build /app .
|
||||
|
||||
# Install all dependencies (including devDeps). The prisma CLI lives in
|
||||
# devDependencies but scripts/start.js invokes `prisma migrate deploy`
|
||||
# at boot, so it must be available at runtime. Dropping --prod adds the
|
||||
# CLI tooling to the runtime image — a modest size cost for the
|
||||
# correctness of the migration step.
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
# Install only runtime dependencies
|
||||
RUN npm install --only=production
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Run via scripts/start.js so `prisma migrate deploy` applies pending
|
||||
# migrations before the daemon spawns. The upstream Dockerfile invokes
|
||||
# ./dist/index.js directly, which silently bypasses the migration step
|
||||
# and leaves the SQLite db empty on first boot — every command that
|
||||
# touches Policy/KeyUser/Token/etc. then throws "table does not exist."
|
||||
# Caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
|
||||
ENTRYPOINT [ "node", "./scripts/start.js" ]
|
||||
ENTRYPOINT [ "node", "./dist/index.js" ]
|
||||
CMD ["start"]
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
# Boot-time autounlock
|
||||
|
||||
`nsecbunkerd` stores each managed key encrypted at rest in
|
||||
`nsecbunker.db`. By default, every key is **locked** after the daemon
|
||||
starts — clients must drive an `unlock_key` admin RPC against the
|
||||
bunker before signing / encrypting / decrypting works for that key.
|
||||
|
||||
Autounlock is an opt-in feature that, when enabled, reads a
|
||||
passphrase from a configured source at boot and unlocks every
|
||||
non-soft-deleted key in the `Key` table automatically. This trades
|
||||
operational simplicity for a documented security weakening; read
|
||||
this whole document before enabling.
|
||||
|
||||
## Configuration
|
||||
|
||||
Two mutually-exclusive environment variables:
|
||||
|
||||
| Var | Meaning |
|
||||
|---|---|
|
||||
| `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE` | Literal passphrase string. Useful for dev / `docker compose .env` flows. |
|
||||
| `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE` | Path to a file containing the passphrase (newline-trimmed at read). Idiomatic for sops / systemd-LoadCredential / k8s-secret / external secrets-manager flows where the passphrase comes from a separate credential store. |
|
||||
|
||||
**If both are set, the daemon fails loud at boot** with an explicit
|
||||
error. Ambiguous config is never allowed to silently pick one.
|
||||
|
||||
**If neither is set, autounlock is off** — behavior is identical to
|
||||
pre-#16: keys remain locked until an admin `unlock_key` RPC fires per
|
||||
key per restart.
|
||||
|
||||
## What happens at boot when autounlock is on
|
||||
|
||||
After the daemon's existing key-loading passes complete (unencrypted
|
||||
keys from in-process config, plain-key entries in `nsecbunker.json`),
|
||||
the autounlock pass runs:
|
||||
|
||||
1. Read the passphrase from the configured source. Failure to read
|
||||
(missing file, no permission) is fatal at boot.
|
||||
2. Enumerate the encrypted-at-rest entries in `nsecbunker.json`'s
|
||||
`keys` map — entries carrying the `{iv, data}` shape from
|
||||
`create_new_key`. Plain-key entries (`{key: ...}` shape from
|
||||
`create_account`) are already loaded by the existing
|
||||
`startKeys()` passes and are skipped here for log clarity.
|
||||
3. For each candidate, call `unlockKey(keyName, passphrase)`.
|
||||
`unlockKey` is idempotent post-#16: if the key was already
|
||||
unlocked by a prior pass, it's a no-op.
|
||||
4. Log per-key INFO on success, WARN on `unlockKey → false`
|
||||
(typically: wrong passphrase, possibly the key was created under a
|
||||
historical passphrase that differs from the current one), ERROR on
|
||||
throw (typically: corrupted blob).
|
||||
5. Log one summary line:
|
||||
`🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>`.
|
||||
|
||||
The loop is sequential — log clarity > parallelism, the unlock op
|
||||
itself is cheap (one ChaCha20 decrypt per key). For 100 keys it's
|
||||
milliseconds. If a fleet ever needs the thousands, parallelize then.
|
||||
|
||||
The NIP-46 client channel doesn't accept RPCs that route to a key
|
||||
until that key's `Backend.start()` resolves — which happens inside
|
||||
`unlockKey`. So there's no race window where a freshly-restarted
|
||||
bunker would say "key locked" to a client while the loop is in
|
||||
flight on that key.
|
||||
|
||||
## The security trade-off
|
||||
|
||||
Enabling autounlock means **whoever can read the passphrase source
|
||||
can recover any key from the bunker disk.** Specifically:
|
||||
|
||||
- The encrypt-at-rest property of `nsecbunker.db` is *preserved*
|
||||
against `cat /var/lib/nsecbunker/*.db` alone — the database holds
|
||||
ciphertext + IV per key, not plaintext.
|
||||
- The encrypt-at-rest property is *lost* if the attacker also has
|
||||
access to the passphrase source. Anyone with read access to the
|
||||
passphrase env var, the passphrase file, or the process memory at
|
||||
the moment of autounlock can decrypt every key.
|
||||
|
||||
This is the same trade today's deployments already make when they
|
||||
hold the passphrase in `lnbits`'s env to drive `unlock_key` RPCs
|
||||
post-restart. Autounlock makes the trade *explicit at the bunker
|
||||
level* and *visible per-deployment*, but it doesn't introduce a new
|
||||
trust requirement that didn't already exist for any deployment using
|
||||
external automation to drive unlocks.
|
||||
|
||||
### Recommendations by deployment shape
|
||||
|
||||
- **Dev / regtest / single-host:** literal `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE`
|
||||
in `docker compose .env` is fine. The threat model on a dev box
|
||||
doesn't justify the file-source ceremony.
|
||||
- **Single-tenant production:** passphrase file on a separate
|
||||
volume / mount with stricter access. Mount via
|
||||
`systemd-LoadCredential` so the file is only readable by the
|
||||
bunker process and is materialized from a sops-decrypted source
|
||||
at boot. Avoid baking the passphrase into the container image or
|
||||
process env list (which leaks into `ps aux`, container labels, etc.).
|
||||
- **Multi-tenant / high-security:** leave autounlock off. Orchestrate
|
||||
unlock per-restart from an external process that prompts for the
|
||||
passphrase out-of-band (hardware token, HSM-derived secret, human
|
||||
approval). This preserves the property that bunker startup alone
|
||||
doesn't restore crypto capability — a deliberate human action is
|
||||
required.
|
||||
|
||||
## What's *not* in scope
|
||||
|
||||
These are deliberately out of scope for the autounlock feature.
|
||||
Separate issues to file if needed:
|
||||
|
||||
- **Per-key passphrase support.** The current `Key` table doesn't
|
||||
carry per-key passphrase metadata; every `create_new_key(name, passphrase)`
|
||||
in our usage today uses the same passphrase
|
||||
(`LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE`). The autounlock
|
||||
passphrase covers every encrypted key by virtue of this
|
||||
single-passphrase invariant. If a deployment ever needs per-key
|
||||
passphrases, that's a separate feature (per-key passphrase-selector
|
||||
column + per-key passphrase map).
|
||||
- **Passphrase rotation.** Re-encrypting every key under a new
|
||||
passphrase belongs in a dedicated admin RPC (`rotate_keystore`),
|
||||
not in autounlock.
|
||||
- **HSM / hardware-derived passphrase delivery.** Orthogonal to
|
||||
where the passphrase comes from at unlock time — autounlock just
|
||||
reads a string. An HSM integration would land between the
|
||||
hardware and the file the bunker reads from.
|
||||
|
||||
## Observability hooks
|
||||
|
||||
The autounlock pass emits:
|
||||
|
||||
- `🔓 autounlock: unlocked <keyName>` (INFO, one per success)
|
||||
- `⚠️ autounlock: unlockKey returned false for <keyName> ...` (WARN, one per soft failure)
|
||||
- `❌ autounlock: <keyName> failed: <err.message>` (ERROR, one per throw)
|
||||
- `🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>` (summary, once)
|
||||
|
||||
When the optional Prometheus exporter lands, counters
|
||||
`nsecbunkerd_keys_unlocked_total` and `nsecbunkerd_keys_locked_total`
|
||||
will be reported from the autounlock summary state. The current
|
||||
implementation doesn't export metrics — the log line is the
|
||||
canonical signal.
|
||||
|
||||
## See also
|
||||
|
||||
- `src/daemon/run.ts:Daemon.maybeAutounlock` — implementation
|
||||
- `src/daemon/run.ts:Daemon.unlockKey` — the idempotent per-key call
|
||||
- `src/daemon/admin/commands/unlock_key.ts` — the admin-RPC wrapper for manual unlock
|
||||
- aiolabs/nsecbunkerd#16 — issue with full design rationale + acceptance criteria
|
||||
- aiolabs/nsecbunkerd#15 — NDK 3.0.3 bump (the structural fix this builds on)
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
# nsecbunkerd vs. `lnbits/nostr_bunker`
|
||||
|
||||
A comparison of this daemon (the aiolabs fork of `kind-0/nsecbunkerd`) against the
|
||||
upstream LNbits extension [`lnbits/nostr_bunker`](https://github.com/lnbits/nostr_bunker).
|
||||
|
||||
> Source verified 2026-06-19 against `lnbits/nostr_bunker@main` (`services.py`,
|
||||
> `models.py`, `crud.py`). The two projects share a name and a NIP (NIP-46 remote
|
||||
> signing) but are architecturally **inverted**: this daemon *uses* LNbits as a
|
||||
> downstream wallet provider; the upstream extension *is* an LNbits extension that
|
||||
> turns a wallet account into the bunker.
|
||||
|
||||
## The one thing that matters: where the nsec lives
|
||||
|
||||
| | nsecbunkerd (this fork) | `lnbits/nostr_bunker` |
|
||||
|---|---|---|
|
||||
| **Signing key location** | On the **daemon** host, separate process from LNbits | On the **LNbits** host, inside the extension DB |
|
||||
| **At-rest protection** | Passphrase-encrypted (LND-style unlock) for manually-added keys | **Plaintext** in `nostr_bunker.bunkers_data.nsec` — no encryption |
|
||||
| **Integration direction** | LNbits is a *downstream dependency* (wallet factory) | LNbits is the *host* (wallet account = signer identity) |
|
||||
|
||||
`crud.py:create_bunkers_data()` writes the nsec straight through
|
||||
`db.insert("nostr_bunker.bunkers_data", ...)` with no encryption step; `models.py`
|
||||
`BunkersData.nsec` is "the normalized private key stored directly." This is the exact
|
||||
posture the aiolabs roadmap (`aiolabs/lnbits#18`, "no nsec at rest on LNbits") exists
|
||||
to eliminate: the LNbits host runs extension code, payment plumbing, and a public API,
|
||||
so disk/root compromise there must NOT equal Nostr-identity compromise. The
|
||||
standalone-daemon model keeps signing off that host; the upstream extension puts the
|
||||
key right back on it, unencrypted.
|
||||
|
||||
## Full side-by-side
|
||||
|
||||
| Dimension | nsecbunkerd (this fork) | `lnbits/nostr_bunker` |
|
||||
|---|---|---|
|
||||
| **Form factor** | Standalone Node daemon (own process/container) | LNbits extension, runs inside the LNbits process |
|
||||
| **Stack** | TypeScript + NDK 3.0.3 + nostr-tools 2.20 + Prisma/SQLite | Python + Vue/Quasar UMD frontend |
|
||||
| **Relay transport** | Daemon opens its own relay connections (NDK); per-key kind:24133 subs pinned to explicit relays (#21) | Piggybacks the `nostrclient` extension's shared relay layer (`nostr_client.relay_manager.publish_message()`) |
|
||||
| **Tenancy** | Multi-key, multi-domain, multi-user from one daemon | One bunker per wallet account; multiplexes clients via multiple `bunker://` URLs |
|
||||
| **Admin / control plane** | Whitelisted admin npubs over E2E-encrypted Nostr events; separate bunker key holds no user key material; optional remote `app.nsecbunker.com` UI | LNbits admin UI; wallet owner is implicitly the operator |
|
||||
| **Account provisioning** | OAuth-like flow: remote `create_account` → NIP-05 file write → NIP-89 (`kind:31990`) announce → mints LNbits wallet via `usermanager` API + nostdress `lud16` | None — the LNbits account already exists; the wallet *is* the identity |
|
||||
|
||||
## NIP-46 surface
|
||||
|
||||
Both implement NIP-46 over kind:24133 and accept **both** NIP-04 and NIP-44 v2
|
||||
(upstream `services.py` tries `nip44_decrypt` first, falls back to `nip04_decrypt`).
|
||||
|
||||
| Method | nsecbunkerd | `lnbits/nostr_bunker` |
|
||||
|---|---|---|
|
||||
| `connect` | ✓ | ✓ (returns secret/ack after permission check) |
|
||||
| `get_public_key` | ✓ | ✓ |
|
||||
| `sign_event` | ✓ (ACL-gated, wire-name vocab #14) | ✓ (`_assert_method_allowed` + auto/confirm flow) |
|
||||
| `nip04_encrypt` / `decrypt` | ✓ | ✓ |
|
||||
| `nip44_encrypt` / `decrypt` | ✓ | ✓ |
|
||||
| `ping` | ✓ | ✓ (`pong`) |
|
||||
| `switch_relays` | — | ✓ (returns relay list as JSON) |
|
||||
|
||||
## Policy / permission model
|
||||
|
||||
This is where the designs genuinely diverge, and where upstream has something worth
|
||||
borrowing.
|
||||
|
||||
**nsecbunkerd** — relational ACL across several tables:
|
||||
- `KeyUser` — a (keyName, userPubkey) grant
|
||||
- `SigningCondition` — per-method/kind/content allow rules
|
||||
- `Policy` / `PolicyRule` — reusable rule sets with per-rule `maxUsageCount` + expiry
|
||||
- `Token` — redeemable connection grant bound to a policy, with `redeemedAt` / `revokedAt`
|
||||
- Live-policy auth re-evaluated at request time (#11)
|
||||
|
||||
**`lnbits/nostr_bunker`** — policy is **the `bunker://` URL itself**. Each `UrlData`
|
||||
row carries its own:
|
||||
- `relays`, `secret`, `client_pubkey`
|
||||
- `permissions` (e.g. `sign_event:{kind}`), `can_read`, `can_write`
|
||||
- `auto_sign` (default `False`) vs `confirm_sign` (default `True`)
|
||||
- `expires_at`
|
||||
- `post_rate_limit_per_day` — daily cap on kind:1, enforced by counting
|
||||
`get_signing_requests_since()` over 24h (`_assert_post_rate_limit`)
|
||||
|
||||
Pending approvals live in `SigningRequest` (status: pending/approved/signed/rejected/error),
|
||||
mirroring this fork's `Request` + manual-approval flow.
|
||||
|
||||
**Takeaway:** upstream's "one bunker, many scoped URLs, each URL is a self-contained
|
||||
grant" is arguably cleaner than this fork's `Token`+`Policy`+`SigningCondition` triad
|
||||
for the common case of "issue a narrowly-scoped grant to one client." If the ACL surface
|
||||
here is ever simplified, that URL-as-grant model is the reference design — note in
|
||||
particular the built-in `post_rate_limit_per_day`, which this fork has no direct
|
||||
equivalent for.
|
||||
|
||||
## Where each fits the aiolabs stack
|
||||
|
||||
- **nsecbunkerd is the signer; LNbits is a client of it.** This is the `#18` endgame:
|
||||
LNbits routes signing through a `RemoteBunkerSigner` over NIP-46 (the
|
||||
protocol-over-loopback boundary chosen deliberately over a Unix socket), and every
|
||||
nsec — operator *and* server identity — is retired from the LNbits host.
|
||||
|
||||
- **`lnbits/nostr_bunker` is the convenience inversion we're explicitly avoiding.**
|
||||
Useful prior art for per-URL policy ergonomics, but adopting it as the *signer
|
||||
location* would reintroduce plaintext nsec-at-rest on the payments host — the precise
|
||||
thing `#18` is designed to kill.
|
||||
|
||||
## Gaps to track on our side
|
||||
|
||||
1. **OAuth-created keys are stored recoverable, not encrypted.**
|
||||
`create_account.ts` writes `currentConfig.keys[keyName] = { key: key.privateKey }`,
|
||||
unlike the passphrase-encrypted path the SECURITY-MODEL doc describes for
|
||||
manually-added keys. The doc promises non-exfiltratable keys; the OAuth path doesn't
|
||||
meet that bar. (We're still strictly better than upstream, which stores *all* nsecs
|
||||
plaintext — but the doc/behavior gap is real.)
|
||||
|
||||
2. **No per-grant rate limiting.** Upstream's `post_rate_limit_per_day` is a clean
|
||||
primitive we lack. Worth considering as a `PolicyRule` field.
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
# ACL prior-art survey — NIP-46 bunker implementations
|
||||
|
||||
Source-verified survey of how other open-source NIP-46 remote signers model
|
||||
authorization and grant lifecycle, run to inform the #25 ACL redesign (enforce
|
||||
token + grant lifecycle live at sign time instead of via a materialized cache).
|
||||
|
||||
> **Verification status.** Every claim below was read against actual source on
|
||||
> 2026-06-19 (clones at the commits noted per project). An initial automated
|
||||
> survey overstated several implementations (notably "Signet enforces all
|
||||
> lifecycle live" — false); the corrections are called out inline. Treat the
|
||||
> file:line citations as the authority, not the prose summaries.
|
||||
|
||||
## TL;DR for the redesign
|
||||
|
||||
- **Amber** is the one *positive* live-lifecycle template: store the absolute
|
||||
deadline on the grant row, recompute the verdict against `now()` on every
|
||||
request, treat the periodic sweep as cleanup only. It also time-boxes
|
||||
*denials*, not just grants.
|
||||
- **Signet** (a fork of our own codebase) re-shipped our #24 bug — proof that
|
||||
materializing a policy photocopy without a live join cannot enforce
|
||||
grant-level TTL/usage. Its schema is still the best reference for the
|
||||
token/policy decomposition (minus the one `applyToken` materialization line).
|
||||
- **FROSTR** has the cleanest *revocation decomposition* (3 independent layers)
|
||||
and a good auditable-credential table — but enforces **no** live expiry
|
||||
anywhere.
|
||||
- **promenade** confirms the **revoke = re-key** anti-pattern to avoid, and
|
||||
debunks "FROST can't decrypt DMs" (it's a design choice, not a math limit).
|
||||
- **NDK** (which we embed) is a deliberately *blank* permit seam: we own 100% of
|
||||
policy — and `get_public_key` bypasses the seam entirely (see #26).
|
||||
|
||||
Decision unchanged: **Option D, leaning D1.** Amber = live-evaluation reference;
|
||||
Signet = schema reference; FROSTR = revocation-decomposition reference; NDK =
|
||||
confirmed blank seam.
|
||||
|
||||
## Strategic decision: keep our fork, treat Signet as a parts donor (2026-06-19)
|
||||
|
||||
Signet is a fork/re-architecture of the same kind-0/nsecbunkerd lineage we
|
||||
maintain, and is feature-richer on the standalone-operator surface (trust dial,
|
||||
suspension, NIP-49 at-rest, two-tier tokens, kill-switch, React dashboard,
|
||||
Android companion). We considered adopting it wholesale. **Decision: no — keep
|
||||
our fork as what we ship; lift Signet's patterns as needed.**
|
||||
|
||||
Why:
|
||||
- **Replacing doesn't solve #25.** Signet re-ships our exact #24 (materialized
|
||||
photocopy, no live grant-level join). We'd still have to do the live-join work
|
||||
— after paying a migration cost.
|
||||
- **We'd lose the integration that makes it ours.** LNbits wallet provisioning
|
||||
(`usermanager` + nostdress), the OAuth-like `create_account` flow, and being
|
||||
the signer target for the #18 `RemoteBunkerSigner` endgame. Porting those into
|
||||
Signet just means maintaining a fork of a more opinionated upstream.
|
||||
- **Lineage/bus-factor.** Our `master` tracks the canonical kind-0 upstream;
|
||||
Signet is a solo-maintainer rewrite with choices we may not want (removed JWT
|
||||
auth, Android surface). For a security-load-bearing component that's more risk,
|
||||
not less.
|
||||
|
||||
Why it's low-stakes either way: LNbits ↔ bunker is **NIP-46 over the wire** (the
|
||||
deliberate protocol-over-IPC choice), so the signer is substitutable by design.
|
||||
If our fork ever becomes a maintenance burden we can drop in any conformant
|
||||
NIP-46 signer (Signet, Amber-as-bunker, HSM-backed) with config-only changes —
|
||||
**not a one-way door.**
|
||||
|
||||
Escape hatch (option 3, parked): run Signet unmodified behind the protocol. Only
|
||||
attractive if the LNbits provisioning/OAuth flows move out of the bunker into
|
||||
LNbits proper (plausible under #18), which would shrink the integration gap
|
||||
that's the main reason to stay. Revisit if #25 implementation reveals our
|
||||
daemon's NDK/relay/ACL plumbing is materially rougher than Signet's.
|
||||
|
||||
---
|
||||
|
||||
## A — daemon/server implementations with a real policy model
|
||||
|
||||
### Signet — `Letdown2491/signet` (TS daemon + React UI + Kotlin companion)
|
||||
MIT, very active (v1.11.0, 2026-06). An extensive re-architecture of the same
|
||||
kind-0/nsecbunkerd codebase we maintain.
|
||||
|
||||
- **Re-ships our #24.** `applyToken` (`nip46-backend.ts:807`) checks
|
||||
`Token.expiresAt` once at redeem (`:895`), then materializes `policy.rules`
|
||||
into lifecycle-free `SigningCondition` rows (`:845-862`); the sign-time path
|
||||
(`acl.ts:checkRequestPermission`) never reads `Token` again.
|
||||
`maxUsageCount`/`currentUsageCount` are touched only in the policy CRUD route —
|
||||
never enforced. Same materialization-drift bug as ours.
|
||||
- **What it adds over us:** a coarse-cache layer for **subject-level** state on
|
||||
`KeyUser` — `revokedAt`, `suspendedAt`/`suspendUntil`, `trustLevel` — read live
|
||||
per request and invalidated on change (`invalidateAclCache`). Genuinely fixes
|
||||
live *revoke* (our sibling spirekeeper#22). Puts revoke on **`KeyUser`, not
|
||||
`Token`** — corroborating our revoke=subject / expiry=grant split.
|
||||
- **Trust dial** over a kind-risk classifier: `trustLevel ∈ {paranoid,
|
||||
reasonable, full}`, `SAFE_KINDS` auto / `SENSITIVE_KINDS` (0/3/4/5/wallet/
|
||||
auth/NIP-04) forced manual (`acl.ts:129-161`).
|
||||
- **Two-tier tokens:** one-time `ConnectionToken` (mandatory `expiresAt`,
|
||||
validates connect but never auto-approves) vs policy-backed `Token` (atomic
|
||||
claim `updateMany where redeemedAt:null`, `nip46-backend.ts:813`).
|
||||
- **Key-at-rest:** NIP-49 ncryptsec + AES-256-GCM envelope (PBKDF2-SHA256 @600k).
|
||||
- **Takeaway:** adopt its `KeyUser` subject-state + `Request` indexing; reject
|
||||
its `applyToken` materialization; the `ConnectionToken`-vs-`Token` split *is*
|
||||
D1 in schema form.
|
||||
|
||||
### Amber — `greenart7c3/Amber` (Android, Kotlin/Room) ⭐ live-lifecycle reference
|
||||
MIT, very active (last commit 2026-06-19). Android signer (NIP-55 intents **and**
|
||||
NIP-46 over relays). Listed in tier A despite being mobile because its permission
|
||||
model is the strongest of any surveyed.
|
||||
|
||||
- **Grant schema** (`ApplicationPermissionsEntity.kt:18-41`): unique composite
|
||||
index over `(pkKey, type, kind, relay)` — per-(app × method × kind × relay).
|
||||
Columns include `acceptable: Boolean`, `rememberType: Int`, `acceptUntil:
|
||||
Long`, `rejectUntil: Long`.
|
||||
- **Expiry enforced LIVE** (the key finding): `IntentUtils.isRemembered()`
|
||||
(`IntentUtils.kt:1087-1101`) is the per-request verdict and recomputes
|
||||
`acceptUntil > TimeUtils.now()` / `rejectUntil > now()` fresh every call;
|
||||
expired → returns `null` → falls through to a user prompt. Called on both the
|
||||
NIP-46 relay path (`EventNotificationConsumer.kt:440-441`) and the NIP-55
|
||||
intent path (`SignerProviderQuery.kt:183` etc.).
|
||||
- **The sweep is non-load-bearing.** `updateExpiredPermissions(time)`
|
||||
(`ApplicationDao.kt:51`, exempts `rememberType <> 4`=ALWAYS) runs every 24h via
|
||||
WorkManager — pure cleanup; correctness doesn't depend on it firing because the
|
||||
decision is recomputed against `now()` on read.
|
||||
- **Time-boxed denials too:** `rejectUntil` means "reject for 5 min" decays back
|
||||
to a prompt rather than a permanent no — a nicer primitive than a single
|
||||
allow/deny flag.
|
||||
- **Wildcard-as-distinct-tier:** lookup ladder is exact-kind → all-kinds
|
||||
(`kind IS NULL`, `getPermissionAllKinds`, `ApplicationDao.kt:87-91`); relay
|
||||
wildcard matches `'*' OR '' OR NULL` in one query (`getWildcardRelayPermission`,
|
||||
`:101-106`). Wildcard rows are explicitly queried, never an accidental
|
||||
missing-WHERE match.
|
||||
- **Read-through LRU caches rows, not verdicts** (`CachingApplicationDao`) — keeps
|
||||
the live `now()` re-check on every cache hit; invalidation is write-driven and
|
||||
coarse per-app.
|
||||
- **Sign policies** (`ChooseSignPolicy.kt:32-45`, stored as `signPolicy: Int`):
|
||||
`0` basic / `1` manual-per-new-app / `2` fully-auto (short-circuits to allow
|
||||
before any row lookup, `IntentUtils.kt:1090`).
|
||||
- **Key-at-rest** (`SecureCryptoHelper.kt`): Android Keystore AES-256-GCM, 96-bit
|
||||
IV / 128-bit tag, StrongBox-backed when available with TEE fallback and a
|
||||
MediaTek denylist; optional app-level biometric gate.
|
||||
- **NIP-46 coverage** (`SignerType.kt`, `BunkerRequestUtils.kt:232-248`): connect,
|
||||
sign_event, nip04/nip44 (+v3) encrypt/decrypt, get_public_key,
|
||||
decrypt_zap_event, ping, switch_relays, sign_psbt, logout; both `bunker://` and
|
||||
`nostrconnect://`.
|
||||
- **Steal for us:** absolute-deadline-on-row + recompute-vs-now per request;
|
||||
time-boxed denials; wildcard as a distinct explicitly-queried tier; cache rows
|
||||
not answers.
|
||||
|
||||
### FROSTR — `FROSTR-ORG/igloo-server` + `bifrost` (TS, FROST k-of-n)
|
||||
MIT. igloo-server v1.2.0 (2026-05-28); bifrost v2.0.2 (2026-01-24). Threshold
|
||||
Schnorr over Nostr; igloo-server exposes the NIP-46 endpoint, bifrost is the node
|
||||
SDK.
|
||||
|
||||
- **Three independent authorization layers** (the prize):
|
||||
1. **App NIP-46 policy** — `Nip46Policy { methods?, kinds? }` (`db/nip46.ts:8-11`),
|
||||
sessions keyed `(user_id, client_pubkey)` (`:92`), checked live per request
|
||||
(`service.ts:508-509, 766-795`). No TTL/expiry. Session revoke is **explicit**
|
||||
(`status='revoked'`, `:792-826`); per-method/kind revoke is **implicit** (flip
|
||||
boolean false, audited at `:722-790`).
|
||||
2. **Peer-transport policy** — per-peer directional `allowSend`/`allowReceive`
|
||||
(`util/peer-policy.ts:3-9`, `docs/PEER_POLICIES.md`), enforced in bifrost
|
||||
`_filter`/`get_recv_pubkeys` (`client.ts:226-245`). **Correction:** it's
|
||||
*default-allow + explicit per-peer deny + last-layer-wins*, not "deny-override".
|
||||
3. **Operator API auth** — keys stored SHA-256 hash+prefix with `revoked_at`
|
||||
(checked first, timing-safe) + `last_used_at/ip` (`migrations/..._api_keys.sql`,
|
||||
`database.ts:815-1047`); Argon2id password hashing (`config/crypto.ts:26-31`).
|
||||
- **No layer enforces live expiry.** `nip46_requests.expires_at` exists but is
|
||||
never populated; the only time-based enforcement is the in-memory derived-key
|
||||
vault (TTL + bounded reads + zeroize, `auth.ts:359-459`).
|
||||
- **Key-at-rest:** DB mode AES-256-GCM in SQLite, PBKDF2-HMAC-SHA256 **@600k**
|
||||
(corrected from "~200k", `config/crypto.ts:7-11`); headless mode = plaintext env
|
||||
(`GROUP_CRED`/`SHARE_CRED`).
|
||||
- **Distributed veto** is real at the participation level (a co-signer withholding
|
||||
its partial below threshold blocks the sig) but the default signer auto-signs
|
||||
(`middleware: {}`, `client.ts:55`) — realizing a veto needs a custom
|
||||
`middleware.sign` not shipped by default.
|
||||
- **Share rotation** (recover → re-split, same group npub, old shares can't
|
||||
combine) exists as a **bifrost SDK primitive** (`generate_dealer_package`), **not**
|
||||
as an igloo-server endpoint; recovery reconstructs the full nsec in memory and
|
||||
`/api/recover` even returns it over HTTP (`routes/recovery.ts:147-157`).
|
||||
- **Steal for us:** the 3-layer revocation decomposition; audit-event-on-grant-
|
||||
change; `revoked_at`-checked-first + last-used credential table.
|
||||
|
||||
### promenade — fiatjaf (Go, FROST coordinator + signer split)
|
||||
Off GitHub; cloned from fiatjaf's nostr-git (`relay.ngit.dev/npub180c…/promenade.git`),
|
||||
HEAD `70ff8439` 2026-06-18. NIP-46 method logic lives in the pinned dep
|
||||
`fiatjaf.com/nostr` (`nip46.DynamicSigner`).
|
||||
|
||||
- **Architecture:** khatru coordinator-relay doubles as the NIP-46 endpoint, runs
|
||||
the FROST ceremony, holds a transport/handler key but **no shard**
|
||||
(`account_registration.go:44` carries only `frost.PublicKeyShard`); separate
|
||||
signer daemons each hold one shard; m-of-n with m≤20 (`:79`). Signing-ceremony
|
||||
kinds 26430–26434; 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`.
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# nsecbunkerd migration & DB-maintenance runbook
|
||||
|
||||
Operational notes for applying schema migrations and ACL/pairing maintenance on
|
||||
deployed, **LNbits-connected** nsecbunkerd instances.
|
||||
|
||||
## ⚠️ Never full-wipe `nsecbunker.db` on an LNbits-connected instance
|
||||
|
||||
The nsecbunkerd ↔ LNbits pairing is **split across both systems**:
|
||||
|
||||
- **Bunker** (`nsecbunker.db`): per-account `KeyUser` binding (keyed by LNbits's
|
||||
stable client pubkey) + redeemed `Token` + a shared `Policy`.
|
||||
- **LNbits** (`accounts.signer_config`, `RemoteBunkerSigner`):
|
||||
`{token, client_nsec, policy_id}`.
|
||||
|
||||
`RemoteBunkerSigner.sign_event()` signs **directly with the stored `client_nsec`**
|
||||
— it does NOT re-connect/re-redeem, and there is **no auto-repair** on restart or
|
||||
sign-failure. `provision()` runs only at new-account creation and mints a NEW
|
||||
npub (which changes the user's nostr identity).
|
||||
|
||||
**Consequence of a full `nsecbunker.db` wipe:** the `KeyUser` bindings are
|
||||
deleted → every LNbits account's stored config dangles → all signing fails, and
|
||||
the only standard "repair" (`provision()`) changes identities. **Do not do it.**
|
||||
|
||||
### Correct way to strip the #24 materialized photocopies
|
||||
|
||||
Post-#27, token grants are evaluated live via the ACL step-4 `Token → Policy →
|
||||
PolicyRule` join, so the old materialized `SigningCondition` rows are redundant
|
||||
— and, written with `expiresAt = NULL`, they would keep granting past a token's
|
||||
expiry (silently re-opening #24 for already-paired clients). Strip them with a
|
||||
**targeted delete** that preserves the pairing:
|
||||
|
||||
```sql
|
||||
-- Verify first.
|
||||
SELECT COUNT(*) FROM Token WHERE redeemedAt IS NOT NULL; -- bindings to preserve
|
||||
SELECT COUNT(*) FROM SigningCondition; -- photocopies to strip
|
||||
|
||||
-- Keeps KeyUser + Token + Policy intact. Live-token clients keep working
|
||||
-- untouched; only the stale photocopies are removed.
|
||||
DELETE FROM SigningCondition;
|
||||
```
|
||||
|
||||
Run against each instance's `nsecbunker.db`. If an instance was already
|
||||
full-wiped, recover by restoring the pre-wipe `nsecbunker.db` backup, then run
|
||||
the targeted delete.
|
||||
|
||||
> Manual-override grants (`add_signing_condition`, web-approval) also live in
|
||||
> `SigningCondition`. On an LNbits-only bunker there typically are none, so a
|
||||
> blanket `DELETE FROM SigningCondition` is safe. If an instance uses manual
|
||||
> overrides, delete only the policy-derived rows you intend to strip.
|
||||
|
||||
## Keys are never in the DB
|
||||
|
||||
Key material lives in `nsecbunker.json` (`keys`), never in `nsecbunker.db`. A DB
|
||||
wipe loses ACL/pairing state, never keys. LNbits holds the bunker **admin nsec**
|
||||
(`LNBITS_NSEC_BUNKER_ADMIN_NSEC`) and is the sole admin client.
|
||||
|
||||
## Schema migrations
|
||||
|
||||
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
27
flake.lock
generated
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
57
flake.nix
57
flake.nix
|
|
@ -1,57 +0,0 @@
|
|||
{
|
||||
description = "nsecbunkerd — Nostr remote signing daemon (NIP-46)";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system:
|
||||
let pkgs = pkgsFor system; in
|
||||
rec {
|
||||
default = nsecbunkerd;
|
||||
nsecbunkerd = pkgs.callPackage ./package.nix {
|
||||
# nixos-unstable splits prisma-engines into versioned attrs and
|
||||
# aliases the bare `prisma-engines` to 7.x (no libquery_engine
|
||||
# for our 6.x client). nixos-25.05 has only the bare attr at
|
||||
# 6.7.0. Pick whichever exists so the package builds on both.
|
||||
prisma-engines_6 = pkgs.prisma-engines_6 or pkgs.prisma-engines;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let pkgs = pkgsFor system; in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_20
|
||||
pnpm_8
|
||||
prisma
|
||||
prisma-engines
|
||||
python3
|
||||
gcc
|
||||
pkg-config
|
||||
openssl
|
||||
sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
# Point prisma at the nix-provided engines so it doesn't try to
|
||||
# download them from binaries.prisma.sh on every install.
|
||||
export PRISMA_QUERY_ENGINE_BINARY=${pkgs.prisma-engines}/bin/query-engine
|
||||
export PRISMA_QUERY_ENGINE_LIBRARY=${pkgs.prisma-engines}/lib/libquery_engine.node
|
||||
export PRISMA_SCHEMA_ENGINE_BINARY=${pkgs.prisma-engines}/bin/schema-engine
|
||||
export PRISMA_FMT_BINARY=${pkgs.prisma-engines}/bin/prisma-fmt
|
||||
export PRISMA_INTROSPECTION_ENGINE_BINARY=${pkgs.prisma-engines}/bin/introspection-engine
|
||||
export PRISMA_CLIENT_ENGINE_TYPE=binary
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
11
package.json
11
package.json
|
|
@ -21,9 +21,6 @@
|
|||
"scripts": {
|
||||
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client",
|
||||
"build:client": "tsup src/client.ts -d dist/client",
|
||||
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/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:migrate": "npx prisma migrate deploy",
|
||||
"prisma:create": "npx prisma db push --preview-feature",
|
||||
|
|
@ -42,8 +39,8 @@
|
|||
"@fastify/view": "^8.2.0",
|
||||
"@inquirer/password": "^1.1.2",
|
||||
"@inquirer/prompts": "^1.2.3",
|
||||
"@nostr-dev-kit/ndk": "3.0.3",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@nostr-dev-kit/ndk": "workspace:*",
|
||||
"@prisma/client": "^5.4.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"axios": "^1.6.2",
|
||||
|
|
@ -60,7 +57,7 @@
|
|||
"isomorphic-ws": "^5.0.0",
|
||||
"lnbits": "^1.1.5",
|
||||
"lnbits-ts": "^0.0.2",
|
||||
"nostr-tools": "~2.20.0",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"websocket-polyfill": "^0.0.3",
|
||||
"ws": "^8.13.0",
|
||||
"yargs": "^17.7.2"
|
||||
|
|
@ -68,7 +65,7 @@
|
|||
"devDependencies": {
|
||||
"@types/debug": "^4.1.8",
|
||||
"@types/node": "^18.16.18",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma": "^5.4.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
|
|
|
|||
146
package.nix
146
package.nix
|
|
@ -1,146 +0,0 @@
|
|||
{
|
||||
lib,
|
||||
stdenv,
|
||||
pnpm_9,
|
||||
nodejs_20,
|
||||
makeWrapper,
|
||||
# Pin to prisma-engines_6 (6.19.3) — package.json's `@prisma/client` +
|
||||
# `prisma` are at ^6.19.0. The unversioned `prisma-engines` attr is now
|
||||
# 7.x in nixpkgs which doesn't ship libquery_engine.node, so we'd fail
|
||||
# at postinstall.
|
||||
prisma-engines_6,
|
||||
openssl,
|
||||
sqlite,
|
||||
python311,
|
||||
pkg-config,
|
||||
node-gyp,
|
||||
}:
|
||||
|
||||
let
|
||||
prisma-engines = prisma-engines_6;
|
||||
|
||||
# The NDK 2.8.1 → 3.0.3 bump (commit 041f431) regenerated pnpm-lock.yaml
|
||||
# at lockfile v9 and pinned NDK as `"3.0.3"`. Lockfile + manifest agree
|
||||
# post-bump, so the historical patch-back-to-caret-form is no longer
|
||||
# required. Leave the no-op shim in place as a structural anchor; if a
|
||||
# future bump regenerates the lockfile under a non-caret manifest spec
|
||||
# again, this is the seam where the realignment goes.
|
||||
patchNdk = "";
|
||||
|
||||
prismaEnv = {
|
||||
PRISMA_SCHEMA_ENGINE_BINARY = lib.getExe' prisma-engines "schema-engine";
|
||||
PRISMA_QUERY_ENGINE_BINARY = lib.getExe' prisma-engines "query-engine";
|
||||
PRISMA_QUERY_ENGINE_LIBRARY = "${prisma-engines}/lib/libquery_engine.node";
|
||||
# Prisma 6 collapsed introspection-engine into schema-engine — the
|
||||
# binary no longer ships in prisma-engines_6. The env var is still
|
||||
# honored if present (drops gracefully), but pointing it at a path
|
||||
# that doesn't exist would fail at startup.
|
||||
PRISMA_FMT_BINARY = lib.getExe' prisma-engines "prisma-fmt";
|
||||
PRISMA_CLIENT_ENGINE_TYPE = "binary";
|
||||
};
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "nsecbunkerd";
|
||||
version = "0.10.5";
|
||||
|
||||
src = ./.;
|
||||
|
||||
pnpmDeps = pnpm_9.fetchDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 2;
|
||||
prePnpmInstall = patchNdk;
|
||||
hash = "sha256-DkFzzsQTuptRR8+rWfr9RGC+5XjSQrZlsZtspWfBW8w=";
|
||||
};
|
||||
|
||||
postPatch = patchNdk;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pnpm_9.configHook
|
||||
pnpm_9
|
||||
nodejs_20
|
||||
makeWrapper
|
||||
node-gyp
|
||||
python311
|
||||
pkg-config
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
openssl
|
||||
sqlite
|
||||
];
|
||||
|
||||
env = prismaEnv;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
export npm_config_nodedir=${nodejs_20}
|
||||
pnpm config set nodedir ${nodejs_20}
|
||||
|
||||
# configHook ran with --ignore-scripts; re-run install to trigger
|
||||
# native-module postinstall (bcrypt). --offline keeps it inside the
|
||||
# store seeded by configHook.
|
||||
pnpm install --force --offline --frozen-lockfile --reporter=append-only
|
||||
|
||||
pnpm prisma generate
|
||||
pnpm build
|
||||
|
||||
# Do NOT `pnpm prune --prod` here — the prisma CLI lives in
|
||||
# devDependencies and `scripts/start.js` invokes it at boot via
|
||||
# `npx prisma migrate deploy`. Without the CLI, the migration step
|
||||
# silently fails (npx falls back to downloading prisma fresh, which
|
||||
# OOMs on most containers) and the SQLite db stays empty. See
|
||||
# `aiolabs/nsecbunkerd#7` diagnosis 2026-05-27.
|
||||
find node_modules -xtype l -delete
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/{bin,share/nsecbunkerd}
|
||||
# scripts/ MUST be copied — it contains the start.js launcher that
|
||||
# runs `prisma migrate deploy` before spawning the daemon. The
|
||||
# upstream packaging (and the upstream Dockerfile) bypassed this by
|
||||
# invoking dist/index.js directly, leaving migrations unapplied.
|
||||
cp -r dist node_modules prisma scripts templates package.json \
|
||||
$out/share/nsecbunkerd/
|
||||
|
||||
# Wrapper invokes scripts/start.js, which runs `prisma migrate deploy`
|
||||
# then spawns dist/index.js. start.js resolves sibling paths from
|
||||
# __dirname, so the caller (systemd unit, docker compose, etc.) can
|
||||
# set its own WorkingDirectory for the writable state dir without
|
||||
# interfering with how the launcher finds its own package files.
|
||||
# NSEC_BUNKER_CONFIG_DIR can override the config directory location;
|
||||
# by default it's `./config` relative to cwd.
|
||||
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunkerd \
|
||||
--add-flags $out/share/nsecbunkerd/scripts/start.js \
|
||||
--set NODE_ENV production \
|
||||
--prefix PATH : ${lib.makeBinPath [ openssl nodejs_20 ]} \
|
||||
${
|
||||
lib.concatStringsSep " \\\n " (
|
||||
lib.mapAttrsToList (n: v: "--set ${n} ${lib.escapeShellArg v}") prismaEnv
|
||||
)
|
||||
}
|
||||
|
||||
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunker-client \
|
||||
--chdir $out/share/nsecbunkerd \
|
||||
--add-flags $out/share/nsecbunkerd/dist/client/client.js \
|
||||
--set NODE_ENV production
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit prisma-engines;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Nostr remote signing daemon (NIP-46)";
|
||||
homepage = "https://github.com/kind-0/nsecbunkerd";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "nsecbunkerd";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
})
|
||||
7074
pnpm-lock.yaml
generated
7074
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Token" ADD COLUMN "revokedAt" DATETIME;
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Request" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"keyName" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"requestId" TEXT NOT NULL,
|
||||
"remotePubkey" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"params" TEXT,
|
||||
"allowed" BOOLEAN,
|
||||
"keyUserId" INTEGER,
|
||||
CONSTRAINT "Request_keyUserId_fkey" FOREIGN KEY ("keyUserId") REFERENCES "KeyUser" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Request" ("allowed", "createdAt", "id", "keyName", "method", "params", "remotePubkey", "requestId") SELECT "allowed", "createdAt", "id", "keyName", "method", "params", "remotePubkey", "requestId" FROM "Request";
|
||||
DROP TABLE "Request";
|
||||
ALTER TABLE "new_Request" RENAME TO "Request";
|
||||
CREATE INDEX "Request_keyUserId_method_idx" ON "Request"("keyUserId", "method");
|
||||
CREATE TABLE "new_SigningCondition" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"method" TEXT,
|
||||
"kind" TEXT,
|
||||
"content" TEXT,
|
||||
"keyUserKeyName" TEXT,
|
||||
"allowed" BOOLEAN,
|
||||
"keyUserId" INTEGER,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME,
|
||||
"revokedAt" DATETIME,
|
||||
CONSTRAINT "SigningCondition_keyUserId_fkey" FOREIGN KEY ("keyUserId") REFERENCES "KeyUser" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_SigningCondition" ("allowed", "content", "id", "keyUserId", "keyUserKeyName", "kind", "method") SELECT "allowed", "content", "id", "keyUserId", "keyUserKeyName", "kind", "method" FROM "SigningCondition";
|
||||
DROP TABLE "SigningCondition";
|
||||
ALTER TABLE "new_SigningCondition" RENAME TO "SigningCondition";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/*
|
||||
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");
|
||||
|
|
@ -17,14 +17,6 @@ model Request {
|
|||
method String
|
||||
params String?
|
||||
allowed Boolean?
|
||||
// Bind each request to the KeyUser it was evaluated against so usage
|
||||
// caps can be derived live by COUNTing allowed Requests, instead of
|
||||
// maintaining a mutable PolicyRule.currentUsageCount that drifts.
|
||||
// See aiolabs/nsecbunkerd#25 (Option D, derive-don't-count).
|
||||
keyUserId Int?
|
||||
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
|
||||
|
||||
@@index([keyUserId, method])
|
||||
}
|
||||
|
||||
model KeyUser {
|
||||
|
|
@ -39,8 +31,6 @@ model KeyUser {
|
|||
logs Log[]
|
||||
signingConditions SigningCondition[]
|
||||
Token Token[]
|
||||
requests Request[]
|
||||
signingLogs SigningLog[]
|
||||
|
||||
@@unique([keyName, userPubkey], name: "unique_key_user")
|
||||
}
|
||||
|
|
@ -66,25 +56,15 @@ model User {
|
|||
pubkey String
|
||||
}
|
||||
|
||||
// The SigningCondition layer is the MANUAL-OVERRIDE source of truth
|
||||
// (web-approval / add_signing_condition / create_account bootstrap) — it is
|
||||
// no longer materialized from token policies (see aiolabs/nsecbunkerd#25:
|
||||
// applyToken stopped photocopying; token grants are evaluated live off
|
||||
// Token -> Policy -> PolicyRule). Under D1 the override layer carries its
|
||||
// own lifecycle so it runs through the same grantIsLive(now) predicate as
|
||||
// token grants.
|
||||
model SigningCondition {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
method String?
|
||||
kind String?
|
||||
content String?
|
||||
keyUserKeyName String?
|
||||
allowed Boolean?
|
||||
keyUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
revokedAt DateTime?
|
||||
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
|
||||
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
|
||||
}
|
||||
|
||||
model Log {
|
||||
|
|
@ -110,34 +90,13 @@ model Policy {
|
|||
}
|
||||
|
||||
model PolicyRule {
|
||||
id Int @id @default(autoincrement())
|
||||
method 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?
|
||||
windowSeconds Int?
|
||||
policyId Int?
|
||||
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])
|
||||
id Int @id @default(autoincrement())
|
||||
method String
|
||||
kind String?
|
||||
maxUsageCount Int?
|
||||
currentUsageCount Int?
|
||||
policyId Int?
|
||||
Policy Policy? @relation(fields: [policyId], references: [id])
|
||||
}
|
||||
|
||||
model Token {
|
||||
|
|
@ -151,7 +110,6 @@ model Token {
|
|||
deletedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
redeemedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
keyUserId Int?
|
||||
policyId Int?
|
||||
policy Policy? @relation(fields: [policyId], references: [id])
|
||||
|
|
|
|||
|
|
@ -1,32 +1,20 @@
|
|||
const { execSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Resolve sibling paths from this script's location so the launcher
|
||||
// works whether cwd is /app (docker), the nix store, or a writable
|
||||
// state dir set by systemd's WorkingDirectory. The prisma CLI and
|
||||
// dist/index.js live alongside this file in `<pkg>/share/nsecbunkerd/`
|
||||
// (nix) or `/app/` (docker). The migration-side env knobs:
|
||||
// NSEC_BUNKER_CONFIG_DIR — directory holding nsecbunker.{json,db};
|
||||
// defaults to ./config relative to cwd.
|
||||
// DATABASE_URL — prisma's source of truth for the sqlite
|
||||
// path; honor whatever the caller set.
|
||||
const pkgRoot = path.resolve(__dirname, '..');
|
||||
const configDir = process.env.NSEC_BUNKER_CONFIG_DIR || path.resolve(process.cwd(), 'config');
|
||||
|
||||
try {
|
||||
console.log(`Running migrations`);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
console.log(`Running migrations`);
|
||||
// check if config folder exists
|
||||
if (!fs.existsSync('./config')) {
|
||||
execSync(`mkdir config`);
|
||||
}
|
||||
execSync('npm run prisma:migrate', { cwd: pkgRoot, stdio: 'inherit' });
|
||||
execSync('npm run prisma:migrate');
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log(error);
|
||||
// Handle any potential migration errors here
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const childProcess = spawn('node', [path.join(pkgRoot, 'dist/index.js'), ...args], {
|
||||
const childProcess = spawn('node', ['./dist/index.js', ...args], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ function loadPrivateKey(): string | undefined {
|
|||
} else {
|
||||
// check if we have a @ so we try to get the npub from nip05
|
||||
if (remotePubkey.includes('@')) {
|
||||
const u = await NDKUser.fromNip05(remotePubkey, ndk);
|
||||
const u = await NDKUser.fromNip05(remotePubkey);
|
||||
if (!u) {
|
||||
console.log(`Invalid nip05 ${remotePubkey}`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
/**
|
||||
* Add a single PolicyRule to an existing Policy. Under the live-policy
|
||||
* auth model (#11), this propagates to every KeyUser bound to the
|
||||
* policy at the next sign-time check.
|
||||
*
|
||||
* Param shape (JSON-stringified):
|
||||
* { policyId: number,
|
||||
* rule: { method: string,
|
||||
* kind?: number | "all" | null,
|
||||
* maxUsageCount?: number,
|
||||
* 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);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
/**
|
||||
* Add a SigningCondition override row for a specific KeyUser. Used to
|
||||
* grant a user a (method, kind) combination beyond the policy, or to
|
||||
* deny one explicitly even when the policy allows.
|
||||
*
|
||||
* Param shape (JSON-stringified):
|
||||
* { keyUserId: number,
|
||||
* condition: { method: string,
|
||||
* kind?: number | "all",
|
||||
* allowed: boolean } }
|
||||
*
|
||||
* The override layer is consulted before the live policy join in
|
||||
* checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here
|
||||
* denies regardless of the policy.
|
||||
*/
|
||||
export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [ _payload ] = req.params as [ string ];
|
||||
|
||||
if (!_payload) throw new Error("Invalid params");
|
||||
|
||||
const payload = JSON.parse(_payload);
|
||||
const { keyUserId, condition } = payload;
|
||||
|
||||
if (typeof keyUserId !== "number" || !condition || !condition.method || typeof condition.allowed !== "boolean") {
|
||||
throw new Error("Invalid params");
|
||||
}
|
||||
|
||||
const keyUser = await prisma.keyUser.findUnique({ where: { id: keyUserId } });
|
||||
if (!keyUser) throw new Error("KeyUser not found");
|
||||
|
||||
await prisma.signingCondition.create({
|
||||
data: {
|
||||
keyUserId,
|
||||
method: condition.method,
|
||||
kind: condition.kind !== undefined && condition.kind !== null ? condition.kind.toString() : null,
|
||||
allowed: condition.allowed,
|
||||
}
|
||||
});
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||
import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "..";
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { setupSkeletonProfile } from "../../lib/profile";
|
||||
|
|
@ -131,15 +131,12 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
|
|||
username = payload[0];
|
||||
domain = payload[1];
|
||||
email = payload[2];
|
||||
if (!username || !domain) {
|
||||
throw new Error('Invalid authorization payload: missing username/domain');
|
||||
}
|
||||
return createAccountReal(admin, req, username, domain, email);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen — pragma: allowlist secret
|
||||
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen
|
||||
*/
|
||||
export async function createAccountReal(
|
||||
admin: AdminInterface,
|
||||
|
|
@ -198,7 +195,7 @@ export async function createAccountReal(
|
|||
}
|
||||
|
||||
const keyName = nip05;
|
||||
const nsec = key.nsec;
|
||||
const nsec = nip19.nsecEncode(key.privateKey!);
|
||||
currentConfig.keys[keyName] = { key: key.privateKey };
|
||||
|
||||
saveCurrentConfig(admin.configFile, currentConfig);
|
||||
|
|
@ -212,18 +209,11 @@ export async function createAccountReal(
|
|||
// access it without having to go through an approval flow
|
||||
await grantPermissions(req, keyName);
|
||||
|
||||
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — it resolves
|
||||
// to `undefined` and sendResponse defaults to NDKKind.NostrConnect
|
||||
// (24133), sending the response on the wrong channel. Mirror the
|
||||
// request's kind so the response goes back on the same channel the
|
||||
// client subscribed for. Filed as part of aiolabs/nsecbunkerd#7
|
||||
// diagnosis 2026-05-27.
|
||||
const originalKind = req.event.kind!;
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, originalKind);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin);
|
||||
} catch (e: any) {
|
||||
console.trace('error', e);
|
||||
const originalKind = req.event.kind!;
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, e.message);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin,
|
||||
e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import { saveEncrypted } from "../../../commands/add.js";
|
||||
import { getCurrentConfig } from "../../../config/index.js";
|
||||
import { decryptNsec } from "../../../config/keys.js";
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { setupSkeletonProfile } from "../../lib/profile.js";
|
||||
|
||||
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
|
|
@ -12,48 +10,10 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
|
|||
if (!keyName || !passphrase) throw new Error("Invalid params");
|
||||
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;
|
||||
|
||||
if (_nsec) {
|
||||
// NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
|
||||
// (see core/src/signers/private-key/index.ts `@ai-guardrail`).
|
||||
key = new NDKPrivateKeySigner(_nsec);
|
||||
key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
|
||||
} else {
|
||||
key = NDKPrivateKeySigner.generate();
|
||||
|
||||
|
|
@ -63,7 +23,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
|
|||
}
|
||||
|
||||
const user = await key.user();
|
||||
const nsec = key.nsec;
|
||||
const nsec = nip19.nsecEncode(key.privateKey!);
|
||||
|
||||
await saveEncrypted(
|
||||
admin.configFile,
|
||||
|
|
@ -78,5 +38,5 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
|
|||
npub: user.npub,
|
||||
});
|
||||
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
|
|
@ -24,11 +23,11 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
|
|||
kind: rule.kind.toString(),
|
||||
method: rule.method,
|
||||
maxUsageCount: rule.use_count,
|
||||
windowSeconds: rule.window_seconds,
|
||||
currentUsageCount: 0,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
|
|
@ -8,19 +7,15 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
|
|||
|
||||
if (!clientName || !policyId) throw new Error("Invalid params");
|
||||
|
||||
const policyIdInt = parseInt(policyId);
|
||||
const policy = await prisma.policy.findUnique({ where: { id: policyIdInt }, include: { rules: true } });
|
||||
const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } });
|
||||
|
||||
if (!policy) throw new Error("Policy not found");
|
||||
|
||||
console.log({clientName, policy, durationInHours});
|
||||
|
||||
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
// policyId must be Int per the Prisma schema (Token.policyId references
|
||||
// Policy.id which is autoincrement Int). Upstream passes the raw string
|
||||
// from the wire — caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
|
||||
const data: any = {
|
||||
keyName, clientName, policyId: policyIdInt,
|
||||
keyName, clientName, policyId,
|
||||
createdBy: req.pubkey,
|
||||
token
|
||||
};
|
||||
|
|
@ -31,5 +26,5 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
|
|||
if (!tokenRecord) throw new Error("Token not created");
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
|
||||
export default async function ping(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", 24134);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
/**
|
||||
* Delete a single PolicyRule by id. Under the live-policy auth model
|
||||
* (#11), removal takes effect at the next sign-time check for every
|
||||
* KeyUser bound to that policy.
|
||||
*
|
||||
* Param shape (JSON-stringified):
|
||||
* { ruleId: number }
|
||||
*
|
||||
* Multi-instance bunker-sharing caveat (per comment 1473 §2):
|
||||
* downstream reconcilers (e.g. lnbits' lnbits-default policy
|
||||
* convergence) should treat this as an admin-only op — concurrent
|
||||
* removes across instance versions can race. Adds are safe, removes
|
||||
* are not.
|
||||
*/
|
||||
export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [ _payload ] = req.params as [ string ];
|
||||
|
||||
if (!_payload) throw new Error("Invalid params");
|
||||
|
||||
const payload = JSON.parse(_payload);
|
||||
const { ruleId } = payload;
|
||||
|
||||
if (typeof ruleId !== "number") throw new Error("Invalid params");
|
||||
|
||||
await prisma.policyRule.delete({ where: { id: ruleId } });
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
/**
|
||||
* Delete a SigningCondition override row by id. Used to walk back a
|
||||
* per-user grant or deny without affecting the policy.
|
||||
*
|
||||
* Param shape (JSON-stringified):
|
||||
* { conditionId: number }
|
||||
*/
|
||||
export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [ _payload ] = req.params as [ string ];
|
||||
|
||||
if (!_payload) throw new Error("Invalid params");
|
||||
|
||||
const payload = JSON.parse(_payload);
|
||||
const { conditionId } = payload;
|
||||
|
||||
if (typeof conditionId !== "number") throw new Error("Invalid params");
|
||||
|
||||
await prisma.signingCondition.delete({ where: { id: conditionId } });
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
|
|
@ -26,5 +25,5 @@ export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRe
|
|||
});
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
/**
|
||||
* Revoke a single Token without affecting other tokens for the same
|
||||
* KeyUser. Sets Token.revokedAt to now; the live-policy auth check
|
||||
* (#11) filters tokens with `revokedAt IS NULL` so a revoked token's
|
||||
* policy no longer contributes to any sign-time grant for the
|
||||
* associated KeyUser.
|
||||
*
|
||||
* Param shape (JSON-stringified):
|
||||
* { tokenId: number }
|
||||
*
|
||||
* The KeyUser remains intact and any other (non-revoked) tokens
|
||||
* bound to it continue to grant via their own policies. Use
|
||||
* revoke_user for the binary "this user is gone" case.
|
||||
*/
|
||||
export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [ _payload ] = req.params as [ string ];
|
||||
|
||||
if (!_payload) throw new Error("Invalid params");
|
||||
|
||||
const payload = JSON.parse(_payload);
|
||||
const { tokenId } = payload;
|
||||
|
||||
if (typeof tokenId !== "number") throw new Error("Invalid params");
|
||||
|
||||
await prisma.token.update({
|
||||
where: { id: tokenId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
export default async function revokeUser(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
|
|
@ -21,5 +20,5 @@ export default async function revokeUser(admin: AdminInterface, req: NDKRpcReque
|
|||
});
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
|
||||
export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [ keyName, passphrase ] = req.params as [ string, string ];
|
||||
|
|
@ -17,5 +16,5 @@ export default async function unlockKey(admin: AdminInterface, req: NDKRpcReques
|
|||
result = JSON.stringify({ success: false, error: e.message });
|
||||
}
|
||||
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
/**
|
||||
* Update mutable fields on a Policy. Currently `name` and `expiresAt`.
|
||||
* Other Policy columns (`description`, `deletedAt`, etc.) are not
|
||||
* exposed by this RPC — they aren't in the ratified shape, and we want
|
||||
* a single point of intent.
|
||||
*
|
||||
* Param shape (JSON-stringified):
|
||||
* { policyId: number,
|
||||
* patch: { name?: string,
|
||||
* expiresAt?: string | null } }
|
||||
*
|
||||
* `expiresAt: null` explicitly clears the field; `expiresAt` absent
|
||||
* from the patch leaves it alone.
|
||||
*/
|
||||
export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [ _payload ] = req.params as [ string ];
|
||||
|
||||
if (!_payload) throw new Error("Invalid params");
|
||||
|
||||
const payload = JSON.parse(_payload);
|
||||
const { policyId, patch } = payload;
|
||||
|
||||
if (typeof policyId !== "number" || !patch || typeof patch !== "object") {
|
||||
throw new Error("Invalid params");
|
||||
}
|
||||
|
||||
const data: { name?: string; expiresAt?: Date | null } = {};
|
||||
if (patch.name !== undefined) data.name = patch.name;
|
||||
if (patch.expiresAt !== undefined) {
|
||||
data.expiresAt = patch.expiresAt === null ? null : new Date(patch.expiresAt);
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) throw new Error("Empty patch");
|
||||
|
||||
await prisma.policy.update({ where: { id: policyId }, data });
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
/**
|
||||
* Update mutable fields on a 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);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import "websocket-polyfill";
|
||||
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk';
|
||||
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
import { NDKNostrRpc } from '@nostr-dev-kit/ndk';
|
||||
import createDebug from 'debug';
|
||||
import { Key, KeyUser } from '../run';
|
||||
|
|
@ -13,20 +13,11 @@ import createNewToken from './commands/create_new_token';
|
|||
import unlockKey from './commands/unlock_key';
|
||||
import renameKeyUser from './commands/rename_key_user.js';
|
||||
import revokeUser from './commands/revoke_user';
|
||||
import addPolicyRule from './commands/add_policy_rule';
|
||||
import removePolicyRule from './commands/remove_policy_rule';
|
||||
import updatePolicy from './commands/update_policy';
|
||||
import 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 { validateRequestFromAdmin } from './validations/request-from-admin';
|
||||
import { dmUser } from '../../utils/dm-user';
|
||||
import { IConfig, getCurrentConfig } from "../../config";
|
||||
import path from 'path';
|
||||
import { attachIndefiniteReconnect } from '../lib/relay-reconnect.js';
|
||||
|
||||
|
||||
const debug = createDebug("nsecbunker:admin");
|
||||
|
|
@ -64,15 +55,6 @@ class AdminInterface {
|
|||
explicitRelayUrls: opts.adminRelays,
|
||||
signer: new NDKPrivateKeySigner(opts.key),
|
||||
});
|
||||
|
||||
// Override NDK's "give up after detecting flapping" behavior so the
|
||||
// bunker's admin NDK keeps trying to reconnect indefinitely. The
|
||||
// watchdog (when enabled) still fires after 60s of zero connected
|
||||
// relays; this helper handles shorter disconnects (e.g. an lnbits
|
||||
// restart that pulls the nostrrelay extension's WS for a few
|
||||
// seconds) without involving the supervisor. See aiolabs/nsecbunkerd#20.
|
||||
attachIndefiniteReconnect(this.ndk, 'admin');
|
||||
|
||||
this.ndk.signer?.user().then((user: NDKUser) => {
|
||||
let connectionString = `bunker://${user.npub}`;
|
||||
|
||||
|
|
@ -129,74 +111,18 @@ class AdminInterface {
|
|||
return;
|
||||
}
|
||||
|
||||
const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1';
|
||||
|
||||
// Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7.
|
||||
// NDKNostrRpc.sendResponse calls event.publish() and discards the
|
||||
// returned Set<NDKRelay>, so a silent outbox-drop is invisible without
|
||||
// hooking the underlying per-relay events. Gated by env flag so
|
||||
// production deployments stay quiet.
|
||||
const attachRelayLogging = (relay: any) => {
|
||||
relay.on('published', (event: NDKEvent) => {
|
||||
console.log(`📤 PUBLISHED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)}`);
|
||||
});
|
||||
relay.on('publish:failed', (event: NDKEvent, err: any) => {
|
||||
console.log(`❌ PUBLISH_FAILED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)} err=${err?.message ?? err}`);
|
||||
});
|
||||
};
|
||||
|
||||
this.ndk.pool.on('relay:connect', (relay: any) => {
|
||||
console.log('✅ nsecBunker Admin Interface ready');
|
||||
if (debugTransport) attachRelayLogging(relay);
|
||||
});
|
||||
this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready'));
|
||||
this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
|
||||
|
||||
this.ndk.connect(2500).then(() => {
|
||||
// connect for whitelisted admins
|
||||
this.rpc.subscribe({
|
||||
"kinds": [NDKKind.NostrConnect, NIP46_ADMIN_RESPONSE_KIND],
|
||||
"kinds": [NDKKind.NostrConnect, 24134 as number],
|
||||
"#p": [this.signerUser!.pubkey]
|
||||
});
|
||||
|
||||
// Attach per-relay logging to relays that connected before our
|
||||
// 'relay:connect' listener was registered above (NDK can connect
|
||||
// synchronously inside .connect() under some paths).
|
||||
if (debugTransport) {
|
||||
this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay));
|
||||
this.rpc.on('request', (req) => this.handleRequest(req));
|
||||
|
||||
// Wrap sendResponse to log id + kind + elapsed time so we
|
||||
// can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED.
|
||||
const originalSendResponse = this.rpc.sendResponse.bind(this.rpc);
|
||||
this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await originalSendResponse(id, remotePubkey, result, kind, error);
|
||||
console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`);
|
||||
} catch (e: any) {
|
||||
console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.rpc.on('request', (req) => {
|
||||
if (debugTransport) {
|
||||
console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`);
|
||||
}
|
||||
this.handleRequest(req);
|
||||
});
|
||||
|
||||
// Connection watchdog: exit if pool reports no connected relays
|
||||
// for >60s so the process supervisor (systemd / docker restart
|
||||
// policy / k8s) can recover. Replaces the original self-echo
|
||||
// pingOrDie — see relayConnectionWatchdog comment + #4 + #7.
|
||||
// Operators with external liveness checking can disable via
|
||||
// NSEC_BUNKER_DISABLE_WATCHDOG=1.
|
||||
if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') {
|
||||
relayConnectionWatchdog(this.ndk);
|
||||
} else {
|
||||
console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1');
|
||||
}
|
||||
pingOrDie(this.ndk);
|
||||
}).catch((err) => {
|
||||
console.log('❌ admin connection failed');
|
||||
console.log(err);
|
||||
|
|
@ -220,13 +146,6 @@ class AdminInterface {
|
|||
case 'create_new_policy': await createNewPolicy(this, req); break;
|
||||
case 'get_policies': await this.reqListPolicies(req); break;
|
||||
case 'create_new_token': await createNewToken(this, req); break;
|
||||
case 'add_policy_rule': await addPolicyRule(this, req); break;
|
||||
case 'remove_policy_rule': await removePolicyRule(this, req); break;
|
||||
case 'update_policy': await updatePolicy(this, req); break;
|
||||
case '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:
|
||||
const originalKind = req.event.kind!;
|
||||
console.log(`Unknown method ${req.method}`);
|
||||
|
|
@ -239,15 +158,7 @@ class AdminInterface {
|
|||
}
|
||||
} catch (err: any) {
|
||||
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
|
||||
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it
|
||||
// makes sendResponse fall through to its default of 24133, which
|
||||
// sends the error on a different channel than the request came in
|
||||
// on. Mirror req.event.kind so the response goes back where the
|
||||
// client is listening. Filed as part of aiolabs/nsecbunkerd#7
|
||||
// diagnosis 2026-05-27.
|
||||
const originalKind = req.event.kind!;
|
||||
console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +196,7 @@ class AdminInterface {
|
|||
const key = keys.find((k) => k.name === keyName);
|
||||
|
||||
if (!key || !key.npub) {
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), NIP46_ADMIN_RESPONSE_KIND);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), 24134);
|
||||
}
|
||||
|
||||
const npub = key.npub;
|
||||
|
|
@ -307,7 +218,7 @@ class AdminInterface {
|
|||
};
|
||||
}));
|
||||
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -330,17 +241,16 @@ class AdminInterface {
|
|||
expires_at: p.expiresAt,
|
||||
rules: p.rules.map((r) => {
|
||||
return {
|
||||
id: r.id,
|
||||
method: r.method,
|
||||
kind: r.kind,
|
||||
max_usage_count: r.maxUsageCount,
|
||||
window_seconds: r.windowSeconds,
|
||||
current_usage_count: r.currentUsageCount,
|
||||
};
|
||||
})
|
||||
};
|
||||
}));
|
||||
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -352,7 +262,7 @@ class AdminInterface {
|
|||
const result = JSON.stringify(await this.getKeys());
|
||||
const pubkey = req.pubkey;
|
||||
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -364,7 +274,7 @@ class AdminInterface {
|
|||
const result = JSON.stringify(await this.getKeyUsers(req));
|
||||
const pubkey = req.pubkey;
|
||||
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -430,7 +340,7 @@ class AdminInterface {
|
|||
remoteUser.pubkey,
|
||||
'acl',
|
||||
[params],
|
||||
NIP46_ADMIN_RESPONSE_KIND,
|
||||
24134,
|
||||
(res: NDKRpcResponse) => {
|
||||
this.requestPermissionResponse(
|
||||
remotePubkey,
|
||||
|
|
@ -485,47 +395,44 @@ class AdminInterface {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool-status connection watchdog. Exits the daemon if every relay in
|
||||
* the pool stays disconnected for longer than PARTITION_THRESHOLD_MS.
|
||||
*
|
||||
* Replaces the original `pingOrDie` self-echo watchdog, which published
|
||||
* a kind-24133 event to its own pubkey every 20s and exited if it
|
||||
* didn't see the echo within 50s. That works on public relays but
|
||||
* silently breaks on single-private-relay setups: NDK 2.8.1's outbox
|
||||
* model doesn't reliably route self-publishes back through the
|
||||
* matching subscription, so the watchdog fires false positives and
|
||||
* exits the daemon every 50s while RPCs over the same channel still
|
||||
* work fine. See aiolabs/nsecbunkerd#4 + #7.
|
||||
*
|
||||
* The pool-status approach uses NDK's own connection-lifecycle
|
||||
* tracking — `pool.connectedRelays()` reports relays in
|
||||
* NDKRelayStatus.CONNECTED — which is reliable across all relay
|
||||
* configurations because it doesn't depend on round-trip
|
||||
* publish/subscribe. No event is published; no relay traffic.
|
||||
*
|
||||
* Detects partition within POLL_INTERVAL + PARTITION_THRESHOLD ms.
|
||||
* Transient disconnects shorter than PARTITION_THRESHOLD don't trip
|
||||
* the watchdog — useful for relays that flap or briefly drop on
|
||||
* network blips.
|
||||
*/
|
||||
async function relayConnectionWatchdog(ndk: NDK) {
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const PARTITION_THRESHOLD_MS = 60_000;
|
||||
let lastConnectedAt = Date.now();
|
||||
async function pingOrDie(ndk: NDK) {
|
||||
let deathTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function resetDeath() {
|
||||
if (deathTimer) clearTimeout(deathTimer);
|
||||
deathTimer = setTimeout(() => {
|
||||
console.log(`❌ No ping event received in 30 seconds. Exiting.`);
|
||||
process.exit(1);
|
||||
}, 50000);
|
||||
}
|
||||
|
||||
const self = await ndk.signer!.user();
|
||||
const sub = ndk.subscribe({
|
||||
authors: [self.pubkey],
|
||||
kinds: [NDKKind.NostrConnect],
|
||||
"#p": [self.pubkey]
|
||||
});
|
||||
sub.on("event", (event: NDKEvent) => {
|
||||
console.log(`🔔 Received ping event:`, event.created_at);
|
||||
resetDeath();
|
||||
});
|
||||
sub.start();
|
||||
|
||||
resetDeath();
|
||||
|
||||
setInterval(() => {
|
||||
const connectedCount = ndk.pool.connectedRelays().length;
|
||||
if (connectedCount > 0) {
|
||||
lastConnectedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - lastConnectedAt;
|
||||
if (elapsed > PARTITION_THRESHOLD_MS) {
|
||||
console.log(`❌ No connected relays for ${Math.floor(elapsed / 1000)}s. Exiting.`);
|
||||
const event = new NDKEvent(ndk, {
|
||||
kind: NDKKind.NostrConnect,
|
||||
tags: [ ["p", self.pubkey] ],
|
||||
content: "ping"
|
||||
} as NostrEvent);
|
||||
event.publish().then(() => {
|
||||
console.log(`🔔 Sent ping event:`, event.created_at);
|
||||
}).catch((e: any) => {
|
||||
console.log(`❌ Failed to send ping event:`, e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
});
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
export default AdminInterface;
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import type { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
|
||||
/**
|
||||
* NIP-46 admin-RPC response channel — kind-24134. Distinct from the
|
||||
* standard NIP-46 client channel kind-24133 (`NDKKind.NostrConnect`)
|
||||
* which carries `sign_event` / `nip04_*` / `nip44_*` / etc.
|
||||
*
|
||||
* nsecbunkerd's admin surface uses a dedicated kind so signer clients
|
||||
* and admin clients don't subscribe to each other's events.
|
||||
*
|
||||
* NDK 3.x's `NDKKind` enum does not include 24134; the cast happens
|
||||
* once here so callers can pass a typed value to `rpc.sendResponse`.
|
||||
*/
|
||||
export const NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind;
|
||||
|
|
@ -59,8 +59,9 @@ async function createRecord(
|
|||
) {
|
||||
let params: string | undefined;
|
||||
|
||||
if (typeof param === 'object' && param !== null && 'rawEvent' in param) {
|
||||
params = JSON.stringify(param.rawEvent());
|
||||
if (param?.rawEvent) {
|
||||
const e = param as NDKEvent;
|
||||
params = JSON.stringify(e.rawEvent());
|
||||
} else if (param) {
|
||||
params = param.toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '@nostr-dev-kit/ndk';
|
||||
import prisma from '../../db.js';
|
||||
import type {FastifyInstance} from "fastify";
|
||||
import { grantIsLive } from '../lib/acl/index.js';
|
||||
|
||||
export class Backend extends NDKNip46Backend {
|
||||
public baseUrl?: string;
|
||||
|
|
@ -19,67 +18,8 @@ export class Backend extends NDKNip46Backend {
|
|||
|
||||
this.baseUrl = baseUrl;
|
||||
this.fastify = fastify;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override NDKNip46Backend.start() to await the kind-24133
|
||||
* subscription's EOSE before resolving. The base implementation
|
||||
* calls `this.ndk.subscribe(...)` and returns immediately — the
|
||||
* NDKSubscription queues a REQ on the relay connection but the
|
||||
* relay's acknowledgement (EOSE) hasn't arrived yet. Any caller
|
||||
* that publishes a NIP-46 event in the immediate window after
|
||||
* `start()` returns races against the relay registering this
|
||||
* subscription.
|
||||
*
|
||||
* aiolabs/lnbits#33's eager-bind chain publishes a NIP-46
|
||||
* `connect` event in the same HTTP round-trip as `create_new_key`,
|
||||
* which loses this race deterministically — the bunker never
|
||||
* sees the connect event because its subscription wasn't yet
|
||||
* registered with the relay when the event was broadcast.
|
||||
*
|
||||
* Awaiting EOSE closes the race: by the time `start()` resolves,
|
||||
* the relay has confirmed it has the bunker's subscription on
|
||||
* file and will route matching kind-24133 events to it.
|
||||
*
|
||||
* See aiolabs/nsecbunkerd#9 for the full diagnosis.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.localUser = await this.signer.user();
|
||||
await new Promise<void>((resolve) => {
|
||||
// Pin this subscription to the daemon's explicit relays via
|
||||
// `relayUrls`. Without that, NDK 3.x's outbox routing tries to
|
||||
// resolve the relay set from `this.localUser.pubkey`'s NIP-65
|
||||
// relay list (kind:10002). Newly-provisioned bunker keys have
|
||||
// no published kind:10002 yet, so NDK's subscription manager
|
||||
// queues the REQ waiting for a relay list that will never
|
||||
// arrive — the kind:24133 subscription never lands on the
|
||||
// wire, and inbound NIP-46 events (sign_event, get_public_key,
|
||||
// nip44_*) targeted at this key get dropped by the relay
|
||||
// with "Filter didn't match" because the bunker isn't actually
|
||||
// subscribed for them.
|
||||
//
|
||||
// `relayUrls` was added in NDK 2.13.0 as the supported way to
|
||||
// bypass outbox routing per subscription (see
|
||||
// NDKSubscriptionOptions.relayUrls in @nostr-dev-kit/ndk).
|
||||
// The relay set built from these URLs matches what the rest
|
||||
// of the bunker uses (admin RPC channel + per-key Backend
|
||||
// channels alike), so events flow through the same connection
|
||||
// the admin interface already established.
|
||||
//
|
||||
// See aiolabs/nsecbunkerd#21.
|
||||
const sub = this.ndk.subscribe(
|
||||
{
|
||||
kinds: [24133],
|
||||
"#p": [this.localUser!.pubkey],
|
||||
},
|
||||
{
|
||||
closeOnEose: false,
|
||||
relayUrls: this.ndk.explicitRelayUrls,
|
||||
}
|
||||
);
|
||||
sub.on("event", (e: any) => this.handleIncomingEvent(e));
|
||||
sub.on("eose", () => resolve());
|
||||
});
|
||||
// this.setStrategy('publish_event', new PublishEventHandlingStrategy());
|
||||
}
|
||||
|
||||
private async validateToken(token: string) {
|
||||
|
|
@ -92,10 +32,7 @@ export class Backend extends NDKNip46Backend {
|
|||
if (!tokenRecord) throw new Error("Token not found");
|
||||
if (tokenRecord.redeemedAt) throw new Error("Token already redeemed");
|
||||
if (!tokenRecord.policy) throw new Error("Policy not found");
|
||||
// Revoke + expiry via the single grantIsLive predicate — the exact
|
||||
// check the sign-time ACL uses, so redeem-time and sign-time cannot
|
||||
// drift (the root of #24). See aiolabs/nsecbunkerd#25.
|
||||
if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked");
|
||||
if (tokenRecord.expiresAt && tokenRecord.expiresAt < new Date()) throw new Error("Token expired");
|
||||
|
||||
return tokenRecord;
|
||||
}
|
||||
|
|
@ -104,20 +41,39 @@ export class Backend extends NDKNip46Backend {
|
|||
const tokenRecord = await this.validateToken(token);
|
||||
const keyName = tokenRecord.keyName;
|
||||
|
||||
// Record ONLY the binding (KeyUser <- Token). Under #25 the token's
|
||||
// policy is evaluated live at sign time (checkIfPubkeyAllowed step 4)
|
||||
// off Token -> Policy -> PolicyRule, NOT photocopied into
|
||||
// SigningCondition rows here. That photocopy was the root of #24: the
|
||||
// copy carried no expiry/revoke and short-circuited the live check, so
|
||||
// an expired or revoked token kept signing forever. With no copy, the
|
||||
// token's lifecycle is re-checked on every request and there is nothing
|
||||
// to keep in sync.
|
||||
// Upsert the KeyUser with the given remotePubkey
|
||||
const upsertedUser = await prisma.keyUser.upsert({
|
||||
where: { unique_key_user: { keyName, userPubkey } },
|
||||
update: { },
|
||||
create: { keyName, userPubkey, description: tokenRecord.clientName },
|
||||
});
|
||||
|
||||
await prisma.signingCondition.create({
|
||||
data: {
|
||||
keyUserId: upsertedUser.id,
|
||||
method: 'connect',
|
||||
allowed: true,
|
||||
}
|
||||
});
|
||||
|
||||
// Go through the rules of this policy and apply them to the user
|
||||
for (const rule of tokenRecord!.policy!.rules) {
|
||||
const signingConditionQuery: any = { method: rule.method };
|
||||
|
||||
if (rule && rule.kind) {
|
||||
signingConditionQuery.kind = rule.kind.toString();
|
||||
}
|
||||
|
||||
await prisma.signingCondition.create({
|
||||
data: {
|
||||
keyUserId: upsertedUser.id,
|
||||
method: rule.method,
|
||||
allowed: true,
|
||||
...signingConditionQuery,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.token.update({
|
||||
where: { id: tokenRecord.id },
|
||||
data: {
|
||||
|
|
|
|||
14
src/daemon/backend/publish-event.ts
Normal file
14
src/daemon/backend/publish-event.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NDKNip46Backend } from "@nostr-dev-kit/ndk";
|
||||
import { IEventHandlingStrategy } from '@nostr-dev-kit/ndk';
|
||||
|
||||
export default class PublishEventHandlingStrategy implements IEventHandlingStrategy {
|
||||
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]): Promise<string|undefined> {
|
||||
const event = await backend.signEvent(remotePubkey, params);
|
||||
if (!event) return undefined;
|
||||
|
||||
console.log('Publishing event', event);
|
||||
await event.publish();
|
||||
|
||||
return JSON.stringify(await event.toNostrEvent());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +1,13 @@
|
|||
import type { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
|
||||
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
import prisma from '../../../db.js';
|
||||
import { liveWhere } from './lifecycle.js';
|
||||
|
||||
// Re-export the single lifecycle predicate so callers (e.g.
|
||||
// Backend.validateToken) import it from the ACL module. The implementation
|
||||
// lives in ./lifecycle.ts so it can be unit-tested without a database.
|
||||
export { grantIsLive } from './lifecycle.js';
|
||||
|
||||
/**
|
||||
* 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(
|
||||
keyName: string,
|
||||
remotePubkey: string,
|
||||
method: IMethod,
|
||||
payload?: string | NostrEvent,
|
||||
payload?: string | NostrEvent
|
||||
): Promise<boolean | undefined> {
|
||||
// One clock reading for the whole decision.
|
||||
const now = new Date();
|
||||
|
||||
// Step 1: find KeyUser.
|
||||
// find KeyUser
|
||||
const keyUser = await prisma.keyUser.findUnique({
|
||||
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
|
||||
});
|
||||
|
|
@ -58,169 +16,59 @@ export async function checkIfPubkeyAllowed(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
// Step 2: subject-level revoke (sticky ban, beats everything).
|
||||
if (keyUser.revokedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const live = liveWhere(now);
|
||||
|
||||
// Step 3: live matching per-(method, kind) override — deny beats grant.
|
||||
// (Subject-level "reject all from this user" is KeyUser.revokedAt, applied
|
||||
// at step 2 via the revoke_user admin command. There is no method='*'
|
||||
// SigningCondition sentinel — nothing writes one.)
|
||||
// find SigningCondition
|
||||
const signingConditionQuery = requestToSigningConditionQuery(method, payload);
|
||||
|
||||
const liveDeny = await prisma.signingCondition.findFirst({
|
||||
where: { keyUserId: keyUser.id, ...signingConditionQuery, allowed: false, ...live },
|
||||
});
|
||||
|
||||
if (liveDeny) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const liveGrant = await prisma.signingCondition.findFirst({
|
||||
where: { keyUserId: keyUser.id, ...signingConditionQuery, allowed: true, ...live },
|
||||
});
|
||||
|
||||
if (liveGrant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Step 4: live token grant.
|
||||
//
|
||||
// A redeemed token that is live (not revoked, not past expiry) grants
|
||||
// `connect` (the pairing) outright, and grants other methods when its
|
||||
// policy has a matching PolicyRule. The live filter is what closes #24:
|
||||
// an expired or revoked token simply stops matching here, every request,
|
||||
// with no photocopy to outlive it.
|
||||
// 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 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({
|
||||
const explicitReject = await prisma.signingCondition.findFirst({
|
||||
where: {
|
||||
keyUserId: keyUser.id,
|
||||
...policyMatch,
|
||||
OR: [{ revokedAt: { not: null } }, { expiresAt: { lte: now } }],
|
||||
},
|
||||
method: '*',
|
||||
allowed: false,
|
||||
}
|
||||
});
|
||||
|
||||
if (lapsedGrant) {
|
||||
if (explicitReject) {
|
||||
console.log(`explicit reject`, explicitReject);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 6: genuinely no grant for this (method, kind).
|
||||
const signingCondition = await prisma.signingCondition.findFirst({
|
||||
where: {
|
||||
keyUserId: keyUser.id,
|
||||
...signingConditionQuery,
|
||||
}
|
||||
});
|
||||
|
||||
// if no SigningCondition found, return undefined
|
||||
if (!signingCondition) {
|
||||
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 },
|
||||
}
|
||||
});
|
||||
|
||||
if (revoked) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed === true || allowed === false) {
|
||||
console.log(`found signing condition`, signingCondition);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-time auth method names follow the NIP-46 wire convention as
|
||||
* NDK 3.x's `NDKNip46Backend` passes them through to `pubkeyAllowed`
|
||||
* verbatim (it stopped normalizing `nip04_encrypt`/`nip04_decrypt`
|
||||
* to `encrypt`/`decrypt` somewhere between 2.8.1 and current
|
||||
* upstream).
|
||||
*
|
||||
* lnbits's `_ensure_policy` writes `PolicyRule.method` using the same
|
||||
* wire-name vocabulary (`nip04_encrypt`, `nip04_decrypt`,
|
||||
* `nip44_encrypt`, `nip44_decrypt`, `sign_event`, `get_public_key`,
|
||||
* `connect`, `ping`). With the wire-name vocabulary on both sides,
|
||||
* the post-#11 live-policy join (step 4 of `checkIfPubkeyAllowed`)
|
||||
* naturally matches lnbits's stored rules — no `encrypt → nip04_encrypt`
|
||||
* adapter layer needed.
|
||||
*
|
||||
* Source the type from NDK itself so it can't drift across future
|
||||
* NDK bumps; if NDK adds a new method (e.g. `nip60_*`) we pick it up
|
||||
* for free.
|
||||
*/
|
||||
export type IMethod = NIP46Method;
|
||||
export type IMethod = "connect" | "sign_event" | "encrypt" | "decrypt" | "ping";
|
||||
|
||||
export type IAllowScope = {
|
||||
kind?: number | 'all';
|
||||
|
|
@ -230,13 +78,9 @@ export function requestToSigningConditionQuery(method: IMethod, payload?: string
|
|||
const signingConditionQuery: any = { method };
|
||||
|
||||
switch (method) {
|
||||
case 'sign_event': {
|
||||
const kindString = (typeof payload === 'object' && payload?.kind !== undefined)
|
||||
? payload.kind.toString()
|
||||
: undefined;
|
||||
signingConditionQuery.kind = { in: [kindString, 'all'] };
|
||||
case 'sign_event':
|
||||
signingConditionQuery.kind = { in: [ payload?.kind?.toString(), 'all' ] };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return signingConditionQuery;
|
||||
|
|
@ -283,46 +127,19 @@ export async function allowAllRequestsFromKey(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consequential methods whose allowed requests are recorded in SigningLog —
|
||||
* the set usage caps (#28) can count against. `connect`/`ping`/
|
||||
* `get_public_key` are never recorded (they're not signings and are never
|
||||
* meaningfully capped).
|
||||
*/
|
||||
const RECORDED_METHODS = new Set<string>([
|
||||
'sign_event',
|
||||
'encrypt', 'decrypt',
|
||||
'nip04_encrypt', 'nip04_decrypt',
|
||||
'nip44_encrypt', 'nip44_decrypt',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Append a durable SigningLog row for an ALLOWED consequential request — the
|
||||
* source of truth `checkIfPubkeyAllowed` counts usage against (#28, the
|
||||
* derive-don't-count approach: no mutable counter to drift). Called from the
|
||||
* 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({
|
||||
export async function rejectAllRequestsFromKey(remotePubkey: string, keyName: string): Promise<void> {
|
||||
// Upsert the KeyUser with the given remotePubkey
|
||||
const upsertedUser = await prisma.keyUser.upsert({
|
||||
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
|
||||
select: { id: true },
|
||||
update: { },
|
||||
create: { keyName, userPubkey: remotePubkey },
|
||||
});
|
||||
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 },
|
||||
// Create a new SigningCondition for the given KeyUser and set allowed to false
|
||||
await prisma.signingCondition.create({
|
||||
data: {
|
||||
allowed: false,
|
||||
keyUserId: upsertedUser.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
/**
|
||||
* Pure grant-lifecycle logic, extracted from the ACL so it can be unit-tested
|
||||
* without a database and reused verbatim at redeem time and sign time.
|
||||
*
|
||||
* The original #24 bug was possible because redeem-time checked expiry and
|
||||
* sign-time didn't — two definitions of "valid" that drifted. Defining "is
|
||||
* this grant valid right now?" exactly once makes them impossible to disagree.
|
||||
* See aiolabs/nsecbunkerd#25.
|
||||
*/
|
||||
|
||||
/** The lifecycle fields every grant (Token, SigningCondition) carries. */
|
||||
export type Lifecycle = {
|
||||
revokedAt?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Is this grant valid right now?" — the single lifecycle predicate. A grant
|
||||
* is live iff it has not been revoked and its expiry (if any) is still in the
|
||||
* future. Expiry is treated as exclusive at the boundary: a grant whose
|
||||
* `expiresAt` equals `now` is already dead.
|
||||
*/
|
||||
export function grantIsLive(grant: Lifecycle, now: Date = new Date()): boolean {
|
||||
if (grant.revokedAt) return false;
|
||||
if (grant.expiresAt && grant.expiresAt.getTime() <= now.getTime()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `grantIsLive` expressed as a Prisma `where` fragment, so the live filter
|
||||
* runs in the query rather than in app code after the fetch. `now` is threaded
|
||||
* in explicitly so a single request evaluates every row against one clock
|
||||
* reading. Kept in lockstep with `grantIsLive` (see lifecycle.test.ts).
|
||||
*/
|
||||
export function liveWhere(now: Date) {
|
||||
return {
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
};
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import NDK from "@nostr-dev-kit/ndk";
|
||||
|
||||
/**
|
||||
* Attaches an aggressive-reconnect supervisor to an NDK instance.
|
||||
*
|
||||
* NDK 3.x's per-relay connectivity state machine gives up retrying after
|
||||
* a few consecutive fast-fail (e.g. ECONNREFUSED returns in <1 ms)
|
||||
* connection attempts:
|
||||
*
|
||||
* 1. Each attempt's duration is recorded in `_connectionStats.durations`.
|
||||
* 2. After every 3 attempts, `isFlapping()` checks the std-dev of those
|
||||
* durations against `FLAPPING_THRESHOLD_MS` (1 second). Three fast
|
||||
* failures look identical → tiny std-dev → flapping=true → status
|
||||
* transitions to FLAPPING and the per-relay retry stops.
|
||||
* 3. `NDKPool.handleFlapping` catches the event and reschedules a
|
||||
* reconnect via doubling backoff (5s → 10s → 20s → 40s → 80s …),
|
||||
* growing unbounded.
|
||||
*
|
||||
* For nsecbunkerd, where the admin relay is typically a single relay we
|
||||
* **must** stay subscribed to, "disconnected for 80+s after every dev
|
||||
* restart" is the failure mode users hit (aiolabs/nsecbunkerd#20). The
|
||||
* pool's doubling backoff is too pessimistic for our use case.
|
||||
*
|
||||
* This helper sidesteps the give-up path: when the pool emits `flapping`
|
||||
* (the symptom that NDK has internally given up), or when we see the
|
||||
* relay disconnect outside our own request, we manually call
|
||||
* `relay.connect()` with a SHORT capped delay. Successful connect resets
|
||||
* the attempt counter so a future disconnect storm doesn't grow the
|
||||
* delay.
|
||||
*
|
||||
* Trade-off: we may hammer a permanently-down relay every 10s. That's
|
||||
* fine for a bunker — being disconnected silently is strictly worse than
|
||||
* a retry storm against localhost. Acceptable because:
|
||||
* - The bunker's primary relay is typically on the same host or LAN
|
||||
* (`ws://lnbits:5001/...`); TCP RSTs are cheap.
|
||||
* - Public-relay setups can layer external supervision on top if they
|
||||
* care about retry pressure.
|
||||
*/
|
||||
export function attachIndefiniteReconnect(ndk: NDK, label: string): void {
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_CAP_MS = 10_000;
|
||||
|
||||
const attempts = new Map<string, number>();
|
||||
const pending = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
const reconnectDelay = (n: number): number =>
|
||||
Math.min(RECONNECT_BASE_MS * 2 ** n, RECONNECT_CAP_MS);
|
||||
|
||||
const scheduleReconnect = (relay: any): void => {
|
||||
const url: string = relay.url;
|
||||
if (pending.has(url)) return;
|
||||
const n = attempts.get(url) ?? 0;
|
||||
const delay = reconnectDelay(n);
|
||||
console.log(
|
||||
`🔁 ${label}: scheduling reconnect to ${url} in ${delay}ms ` +
|
||||
`(attempt ${n + 1}, overriding NDK give-up)`
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(url);
|
||||
attempts.set(url, n + 1);
|
||||
relay.connect().catch((e: any) => {
|
||||
console.log(
|
||||
`❌ ${label}: manual reconnect to ${url} failed: ` +
|
||||
`${e?.message ?? e}`
|
||||
);
|
||||
// Don't recurse here — the next 'flapping' or 'disconnect'
|
||||
// event will fire and schedule another attempt.
|
||||
});
|
||||
}, delay);
|
||||
pending.set(url, timer);
|
||||
};
|
||||
|
||||
ndk.pool.on("flapping", (relay: any) => {
|
||||
console.log(
|
||||
`⚠️ ${label}: NDK flagged ${relay.url} as flapping ` +
|
||||
`(connectivity machine gave up internally)`
|
||||
);
|
||||
scheduleReconnect(relay);
|
||||
});
|
||||
|
||||
ndk.pool.on("relay:disconnect", (relay: any) => {
|
||||
scheduleReconnect(relay);
|
||||
});
|
||||
|
||||
ndk.pool.on("relay:connect", (relay: any) => {
|
||||
const url: string = relay.url;
|
||||
const n = attempts.get(url) ?? 0;
|
||||
if (n > 0) {
|
||||
console.log(
|
||||
`✅ ${label}: recovered ${url} after ${n} manual reconnect ` +
|
||||
`attempt(s)`
|
||||
);
|
||||
}
|
||||
attempts.delete(url);
|
||||
const timer = pending.get(url);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
|
||||
import { nip19, utils as nostrUtils } from 'nostr-tools';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Backend } from './backend/index.js';
|
||||
import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js';
|
||||
import {
|
||||
IMethod,
|
||||
checkIfPubkeyAllowed,
|
||||
} from './lib/acl/index.js';
|
||||
import AdminInterface from './admin/index.js';
|
||||
import { IConfig } from '../config/index.js';
|
||||
import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
|
||||
|
|
@ -15,7 +18,6 @@ import FastifyView from '@fastify/view';
|
|||
import Handlebars from "handlebars";
|
||||
import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js";
|
||||
import {processRegistrationWebHandler} from "./web/authorize.js";
|
||||
import { attachIndefiniteReconnect } from "./lib/relay-reconnect.js";
|
||||
|
||||
export type Key = {
|
||||
name: string;
|
||||
|
|
@ -36,7 +38,8 @@ function getKeys(config: DaemonConfig) {
|
|||
const keys: Key[] = [];
|
||||
|
||||
for (const [name, nsec] of Object.entries(config.keys)) {
|
||||
const user = await new NDKPrivateKeySigner(nsec).user();
|
||||
const hexpk = nip19.decode(nsec).data as string;
|
||||
const user = await new NDKPrivateKeySigner(hexpk).user();
|
||||
const key = {
|
||||
name,
|
||||
npub: user.npub,
|
||||
|
|
@ -105,16 +108,10 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
|
|||
}
|
||||
|
||||
try {
|
||||
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method, payload);
|
||||
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method as IMethod, payload);
|
||||
|
||||
if (keyAllowed === true || keyAllowed === false) {
|
||||
console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -127,11 +124,7 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
|
|||
method,
|
||||
payload
|
||||
)
|
||||
.then(async () => {
|
||||
await recordSigning(keyName, remotePubkey, method, payload)
|
||||
.catch((e) => console.log('recordSigning error:', e));
|
||||
resolve(true);
|
||||
})
|
||||
.then(() => resolve(true))
|
||||
.catch(() => resolve(false));
|
||||
});
|
||||
} catch(e) {
|
||||
|
|
@ -171,17 +164,11 @@ class Daemon {
|
|||
explicitRelayUrls: config.nostr.relays,
|
||||
});
|
||||
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
|
||||
this.ndk.pool.on('notice', (r, n) => { console.log(`👀 Notice from ${r.url}`, n); });
|
||||
this.ndk.pool.on('relay:notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); });
|
||||
|
||||
this.ndk.pool.on('relay:disconnect', (r) => {
|
||||
console.log(`🚫 Disconnected from ${r.url}`);
|
||||
});
|
||||
|
||||
// Override NDK's "give up after detecting flapping" behavior so the
|
||||
// bunker's backend NDK keeps trying to reconnect indefinitely.
|
||||
// Without this, an ECONNREFUSED storm at boot (relay not yet up)
|
||||
// permanently strands the bunker. See aiolabs/nsecbunkerd#20.
|
||||
attachIndefiniteReconnect(this.ndk, 'backend');
|
||||
}
|
||||
|
||||
async startWebAuth() {
|
||||
|
|
@ -219,129 +206,9 @@ class Daemon {
|
|||
continue;
|
||||
}
|
||||
|
||||
// nostr-tools v2: `nsecEncode` takes `Uint8Array`, not hex string.
|
||||
// pragma: allowlist secret
|
||||
// `settings.key` is the hex-encoded private key from config.
|
||||
const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key));
|
||||
const nsec = nip19.nsecEncode(settings.key);
|
||||
this.loadNsec(keyName, nsec);
|
||||
}
|
||||
|
||||
// Boot-time autounlock of encrypted-at-rest keys. Off by default;
|
||||
// enabled by setting NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE or
|
||||
// NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE. See docs/AUTOUNLOCK.md
|
||||
// for the security trade-off and aiolabs/nsecbunkerd#16 for the
|
||||
// design rationale.
|
||||
await this.maybeAutounlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time autounlock for encrypted keys.
|
||||
*
|
||||
* Reads a passphrase from one of two mutually exclusive env vars:
|
||||
* - NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE — literal passphrase
|
||||
* - NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE — path to a file containing
|
||||
* the passphrase (newline-trimmed)
|
||||
*
|
||||
* If neither is set, this is a no-op — the deployment opted out and
|
||||
* keys remain locked until an admin `unlock_key` RPC fires per key
|
||||
* per restart (today's default).
|
||||
*
|
||||
* If both are set, throws at boot — ambiguous config.
|
||||
*
|
||||
* Otherwise: enumerates `Key` table rows where `deletedAt IS NULL`,
|
||||
* calls `unlockKey(keyName, passphrase)` per row. Sequential, with
|
||||
* continue-on-error so one bad row doesn't block the rest of the
|
||||
* fleet. Per-key INFO/WARN/ERROR log + one summary line at the end.
|
||||
*
|
||||
* `unlockKey` is idempotent post-#16 — calling it against a key that
|
||||
* was already loaded via the unencrypted paths above is safe (returns
|
||||
* true without spawning a duplicate Backend).
|
||||
*
|
||||
* Single-passphrase invariant: every `create_new_key(name, passphrase)`
|
||||
* uses the same passphrase in our usage today, so one autounlock
|
||||
* passphrase covers every encrypted key. Per-key passphrase support
|
||||
* is a separate feature (out of scope — see issue #16).
|
||||
*/
|
||||
async maybeAutounlock(): Promise<void> {
|
||||
const literal = process.env.NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE;
|
||||
const filePath = process.env.NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE;
|
||||
|
||||
if (literal && filePath) {
|
||||
throw new Error(
|
||||
'Autounlock: NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE and ' +
|
||||
'NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE are mutually exclusive. ' +
|
||||
'Set exactly one (or neither, to leave autounlock off).'
|
||||
);
|
||||
}
|
||||
|
||||
if (!literal && !filePath) {
|
||||
return; // autounlock off (default)
|
||||
}
|
||||
|
||||
let passphrase: string;
|
||||
let source: string;
|
||||
if (literal) {
|
||||
passphrase = literal;
|
||||
source = 'NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE';
|
||||
} else {
|
||||
const fs = await import('fs');
|
||||
try {
|
||||
passphrase = fs.readFileSync(filePath!, 'utf8').replace(/\r?\n$/, '');
|
||||
} catch (e: any) {
|
||||
throw new Error(
|
||||
`Autounlock: failed to read passphrase file ${filePath}: ${e.message}`
|
||||
);
|
||||
}
|
||||
source = `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE=${filePath}`;
|
||||
}
|
||||
|
||||
// Enumerate encrypted-at-rest keys from `config.allKeys`. The
|
||||
// Prisma `Key` table is only populated by the NIP-05 `create_account`
|
||||
// path (which stores keys plain-at-rest in nsecbunker.json);
|
||||
// `create_new_key` provisions keys with the `{iv, data}` encrypted
|
||||
// shape directly into the JSON blob without a Prisma row. So the
|
||||
// canonical "what's encrypted at rest" source is `allKeys` filtered
|
||||
// to entries carrying `iv`+`data` — that's the set of keys for
|
||||
// which the manual `unlock_key` admin RPC was previously required
|
||||
// per restart, and exactly the set we want to autounlock here.
|
||||
//
|
||||
// Plain-key entries (`{key: "..."}` shape, populated by `create_account`)
|
||||
// were already loaded by the second loop in `startKeys` above and
|
||||
// appear in `activeKeys` — `unlockKey`'s idempotency guard makes
|
||||
// re-calling them safe but unnecessary, so we filter them out for
|
||||
// log clarity.
|
||||
const candidates = Object.entries(this.config.allKeys || {})
|
||||
.filter(([, entry]) =>
|
||||
entry && typeof entry === 'object' && 'iv' in entry && 'data' in entry
|
||||
)
|
||||
.map(([keyName]) => keyName);
|
||||
|
||||
const start = Date.now();
|
||||
let success = 0;
|
||||
|
||||
for (const keyName of candidates) {
|
||||
try {
|
||||
const ok = await this.unlockKey(keyName, passphrase);
|
||||
if (ok) {
|
||||
console.log(`🔓 autounlock: unlocked ${keyName}`);
|
||||
success++;
|
||||
} else {
|
||||
console.warn(
|
||||
`⚠️ autounlock: unlockKey returned false for ${keyName} ` +
|
||||
`(likely wrong passphrase — encrypted under a different secret?)`
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(
|
||||
`❌ autounlock: ${keyName} failed: ${e?.message ?? e}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(
|
||||
`🔓 autounlock: enabled (source=${source}), unlocked ${success}/${candidates.length} keys in ${elapsed}ms`
|
||||
);
|
||||
}
|
||||
|
||||
async start() {
|
||||
|
|
@ -359,44 +226,39 @@ class Daemon {
|
|||
*/
|
||||
async startKey(name: string, nsec: string) {
|
||||
const cb = signingAuthorizationCallback(name, this.adminInterface);
|
||||
// NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
|
||||
// (see `core/src/signers/private-key/index.ts` `@ai-guardrail`
|
||||
// — "DO NOT use nip19.decode() to convert nsec to hex before
|
||||
// passing it here"). The bech32-decode workaround for #8 was
|
||||
// tied to NDK 2.8.1's old constructor behavior and is no
|
||||
// longer needed post-#14 NDK bump.
|
||||
const backend = new Backend(this.ndk, this.fastify, nsec, cb, this.config.baseUrl);
|
||||
let hexpk: string;
|
||||
|
||||
if (nsec.startsWith('nsec1')) {
|
||||
try {
|
||||
const key = new NDKPrivateKeySigner(nsec);
|
||||
hexpk = key.privateKey!;
|
||||
} catch(e) {
|
||||
console.error(`Error loading key ${name}:`, e);
|
||||
return
|
||||
}
|
||||
} else {
|
||||
hexpk = nsec;
|
||||
}
|
||||
|
||||
const backend = new Backend(this.ndk, this.fastify, hexpk, cb, this.config.baseUrl);
|
||||
await backend.start();
|
||||
}
|
||||
|
||||
async unlockKey(keyName: string, passphrase: string): Promise<boolean> {
|
||||
// Idempotency guard: if a Backend instance already exists for this
|
||||
// keyName, the key is already unlocked and the relay subscription
|
||||
// for its kind-24133 channel is already active. Calling startKey
|
||||
// again would spawn a SECOND Backend with a duplicate subscription
|
||||
// — wire events would be handled twice, with race/amplification
|
||||
// hazards on the response side. Return success without re-running
|
||||
// startKey so callers (admin `unlock_key` RPC, autounlock loop,
|
||||
// belt-and-suspenders fallback paths) can fire safely against
|
||||
// already-unlocked keys.
|
||||
if (this.activeKeys[keyName]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const keyData = this.config.allKeys[keyName];
|
||||
const { iv, data } = keyData;
|
||||
|
||||
const nsec = decryptNsec(iv, data, passphrase);
|
||||
this.activeKeys[keyName] = nsec;
|
||||
|
||||
await this.startKey(keyName, nsec);
|
||||
this.startKey(keyName, nsec);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadNsec(keyName: string, nsec: string) {
|
||||
loadNsec(keyName: string, nsec: string) {
|
||||
this.activeKeys[keyName] = nsec;
|
||||
|
||||
await this.startKey(keyName, nsec);
|
||||
this.startKey(keyName, nsec);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
// 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
|
||||
});
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { grantIsLive, liveWhere } from '../src/daemon/lib/acl/lifecycle';
|
||||
|
||||
// Fixed reference clock so the assertions don't depend on wall time.
|
||||
const now = new Date('2026-06-19T12:00:00.000Z');
|
||||
const past = new Date(now.getTime() - 60_000);
|
||||
const future = new Date(now.getTime() + 60_000);
|
||||
|
||||
test('grantIsLive: no revoke, no expiry -> live', () => {
|
||||
assert.equal(grantIsLive({}, now), true);
|
||||
assert.equal(grantIsLive({ revokedAt: null, expiresAt: null }, now), true);
|
||||
});
|
||||
|
||||
test('grantIsLive: future expiry -> live', () => {
|
||||
assert.equal(grantIsLive({ expiresAt: future }, now), true);
|
||||
});
|
||||
|
||||
test('grantIsLive: past expiry -> dead (the #24 case the old code missed at sign time)', () => {
|
||||
assert.equal(grantIsLive({ expiresAt: past }, now), false);
|
||||
});
|
||||
|
||||
test('grantIsLive: expiry exactly now -> dead (boundary is exclusive)', () => {
|
||||
assert.equal(grantIsLive({ expiresAt: new Date(now.getTime()) }, now), false);
|
||||
});
|
||||
|
||||
test('grantIsLive: revoked -> dead even with a future expiry (revoke wins)', () => {
|
||||
assert.equal(grantIsLive({ revokedAt: past, expiresAt: future }, now), false);
|
||||
});
|
||||
|
||||
test('grantIsLive: defaults now to the current time', () => {
|
||||
assert.equal(grantIsLive({ expiresAt: new Date(Date.now() + 3_600_000) }), true);
|
||||
assert.equal(grantIsLive({ expiresAt: new Date(Date.now() - 3_600_000) }), false);
|
||||
});
|
||||
|
||||
// liveWhere is the SQL mirror of grantIsLive; pin its shape so the two
|
||||
// can't silently drift (a drift would re-open the redeem-vs-sign gap #25
|
||||
// exists to close).
|
||||
test('liveWhere: mirrors grantIsLive as a prisma where-fragment', () => {
|
||||
assert.deepEqual(liveWhere(now), {
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
});
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue