Skip to content

fix(security): connection authz hardening — owner-scoped keys, default-deny permissions, signer binding, session revocation#18

Merged
dsbaars merged 10 commits into
mainfrom
fix/connection-authz-default-deny
Jun 24, 2026
Merged

fix(security): connection authz hardening — owner-scoped keys, default-deny permissions, signer binding, session revocation#18
dsbaars merged 10 commits into
mainfrom
fix/connection-authz-default-deny

Conversation

@dsbaars

@dsbaars dsbaars commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the highest-severity findings from a security/quality review of the connection-authorization and NIP-46 signing path — the core of what a bunker must enforce. Four commits, each independently testable.

Sev Fix Commit
🔴 Critical C1 — cross-tenant private-key takeover via unowned nsecKeyId 8c5172d
🟠 High H2 — fail-open permissions (empty set allowed everything) 8c5172d
🟠 High H1 — password change didn't revoke sessions 0322cd6
🟡 Medium M1 — signer key not bound to served connection (key confusion) ba7b6ba
🟡 Medium M5 — core RPC dispatch had no unit tests 8c5172d
grandfather data migration (companion to H2) 0e191d7

Details

C1 — scope nsec keys to their owner

createConnection now verifies the nsecKeyId belongs to the authenticated user (nsecKey.findFirst({ where: { id, userId } })NotFoundException) before creating the connection or starting a relay listener. Previously a user could create a connection referencing another user's nsecKeyId and have the bunker sign/decrypt with the victim's key. The sister method generate-bunker-uri already did this check; createConnection did not.

H2 — default-deny permissions

The NIP-46 handler is now default-deny for every capability method (sign_event, nip04/nip44_encrypt/decrypt): the permissions.length > 0 && short-circuit is removed, so an empty permission set no longer grants everything. ping/get_public_key/connect/switch_relays stay unguarded. New connections are seeded with a conservative default set (sign_event kinds 0/1/3/4/7, no decrypt oracle); explicit perms from the connect request or dashboard still override the defaults.

M1 — bind resolution to the signer key

The handler resolved the connection by clientPubkey alone, ignoring the signer pubkey the request was addressed to. A client with connections to several of the user's keys could have a request for key A served by a connection bound to key B. findByClientPubkey is replaced by findByClientAndSigner(clientPubkey, signerPubkey) which also filters on nsecKey.publicKey === signerPubkey.

H1 — revoke sessions on password change

updatePassword now deletes the user's sessions (keeping the current one via the JWT sessionId), so a leaked/stale refresh token cannot survive the canonical post-compromise remediation. Wires in the previously-unused revoke-all-other-sessions intent.

Grandfather migration

20260624130000_grandfather_connection_permissions makes existing perm-less ACTIVE/PENDING connections' prior implicit allow-all access explicit, so default-deny doesn't break live signers. Idempotent; only touches connections with zero permissions. Validated against a throwaway PostgreSQL 17 (full migrate deploy + grandfather logic + idempotency).

⚠️ Behavior change

Under default-deny, any existing perm-less connection that the migration does not cover (e.g. a REVOKED/EXPIRED one later reactivated, or rows created out-of-band) will be denied until permissions are granted via the dashboard or a fresh connect. The migration grandfathers the live (ACTIVE/PENDING) ones to avoid downtime.

Tests & verification

  • New bunker-rpc.handler.spec.ts (the previously-untested signing/permission dispatch): unknown/revoked clients, default-deny, per-kind enforcement, signer binding, unguarded ping/get_public_key, connect handshake, audit logging.
  • Extended connections.service.spec.ts (C1 ownership + default-permission seeding) and users.service.spec.ts (H1 keep-current / revoke-all / no-revoke-on-wrong-password).
  • 156 server unit tests pass (3 DB-integration skipped locally), prisma migrate deploy clean on PG17, nest build 0 TS issues, ESLint + lint:security clean, prettier format:check clean.

Not included (remaining review items)

M2 (TOTP replay window), M3 (SSRF via IPv4-mapped IPv6), M4 (SSE connection cap), and the low-severity items (username-enumeration timing, refresh-expiry unit bug, CSP header, etc.). Happy to follow up in separate PRs.

dsbaars added 8 commits June 24, 2026 23:13
… permissions

C1 (critical IDOR): createConnection now verifies the nsecKeyId belongs to the
authenticated user (findFirst scoped by { id, userId }) before binding a
connection or starting a relay listener, closing a cross-tenant private-key
takeover where a user could sign/decrypt with another user's key.

H2 (fail-open permissions): the NIP-46 RPC handler is now default-deny for every
capability method (sign_event, nip04/nip44 encrypt/decrypt). An empty permission
set no longer grants everything. New connections are seeded with a conservative
default set (sign_event kinds 0/1/3/4/7, no decrypt oracle); explicit perms from
the connect request or dashboard still override the defaults.

M5: adds bunker-rpc.handler.spec.ts (previously untested) covering unknown/revoked
clients, default-deny, per-kind permission enforcement, unguarded ping/get_public_key,
the connect handshake, and audit logging; extends connections.service.spec.ts with
the C1 ownership check and default-permission seeding.

Note: existing perm-less connections will be denied under default-deny until perms
are granted (dashboard or connect); no data migration is included.
The RPC handler resolved the connection by clientPubkey alone, ignoring the signer
pubkey the request was addressed to (the relay #p tag / listener key). A client with
connections to several of the user's keys could have a request meant for key A served
by a connection bound to key B - signing/decrypting with the wrong key under a
possibly broader permission set (key confusion).

Replace findByClientPubkey with findByClientAndSigner(clientPubkey, signerPubkey),
additionally filtering on nsecKey.publicKey === signerPubkey, and pass the signer
through in both the main dispatch and the auto-connect path. Adds a handler test.
updatePassword wrote a new hash but never deleted Session rows, so a leaked/stale
refresh token kept working for the full refresh lifetime (~7d) after the canonical
post-compromise remediation. It now deletes all of the user's sessions, keeping the
caller's current session (sessionId from the JWT) so they stay signed in here. Wires
the previously-unused revoke-all-other-sessions intent into the flow. Adds tests for
keep-current, revoke-all, and no-revoke-on-wrong-password.
Companion to the default-deny permission model: existing ACTIVE/PENDING connections
with no permission rows were operating under the old fail-open (allow-all) behaviour
and would be denied outright by the new handler. This data migration makes their prior
implicit access explicit (method-level sign_event + nip04/nip44 encrypt/decrypt, kind
NULL) so live signers keep working; operators can then tighten in the dashboard. Only
touches ACTIVE/PENDING connections that currently have zero permissions; idempotent.
Validated against a throwaway PostgreSQL 17 (full migrate deploy + logic + idempotency).
Wrap the password-hash update and session deleteMany in a single $transaction so a crash
between them cannot persist the new password while leaving old refresh sessions valid.
Surfaced by the panel review.
…eny UI

Per NIP-46 a client's requested permissions are a REQUEST, not a grant. Replace the
auto-grant-on-connect behaviour with an interactive approval flow:

- The connect handler records client-requested perms as PENDING (ConnectionPermission.allowed
  = false) instead of granting them; the handshake still completes (ack/secret) and the
  connection works with its already-granted (seeded/operator) perms. The capability check
  enforces only granted (allowed = true) perms, so a client can never self-escalate.
- New service methods requestPermissions / approveRequests / denyRequests, and ownership-scoped
  POST /connections/:id/permissions/{approve,deny} endpoints. setPermissions now manages the
  GRANTED whitelist and preserves outstanding pending requests. Repurposes the previously-dead
  'allowed' column (granted vs pending) — no schema migration needed.
- Frontend: ConnectionDetailView gets a 'requests awaiting approval' section (approve/deny,
  per-item and all) and a corrected default-deny permission editor (the old empty=allow-all
  framing was wrong after the default-deny change); ConnectionsView shows granted count + a
  pending badge.

Also folds in two panel-review defense-in-depth fixes: ensureListeningForConnection and
sendConnectResponse now take a userId and scope the nsec-key lookup to its owner (same class
as the C1 fix), so they can never start a listener / sign a response with another user's key.
…-kind grant UI

A gated request denied at runtime (e.g. sign_event:30078) is now recorded as a
PENDING permission request so the operator can approve it in the dashboard —
extending the interactive approval flow from connect-declared perms to the
capabilities a client actually exercises. Recorded with skipDuplicates so a client
retrying the same method/kind can't spam the queue, and it never downgrades a grant.

Frontend: the capability toggle now reflects ONLY a method-level (all-kinds) grant
instead of any kind-scoped row, so it no longer overstates what is allowed; adds a
kind input to grant a specific sign_event kind (e.g. 30078) and a clear
'allow all kinds' control.
…E_SECURE

The refresh cookie was marked Secure whenever NODE_ENV=production. A Secure cookie
is silently dropped by the browser when the app is served over plain HTTP on a
non-localhost host (e.g. http://mini7:8080), so the in-memory access token worked
while navigating but every full page reload — which can only restore the session by
exchanging the refresh cookie at /api/auth/refresh — found no cookie and bounced the
user to /login.

Add a COOKIE_SECURE override (secure-by-default: unset falls back to
NODE_ENV==='production'). Operators serving over plain HTTP on a trusted network set
COOKIE_SECURE=false; HTTPS deployments are unaffected. Documented in .env.example and
wired through docker-compose.yml.
dsbaars added 2 commits June 25, 2026 01:06
…generating a bunker:// URI

In the bunker:// flow the operator generates the URI, so they are the authority over
what the connection may do. Let them pick the granted permission seed up front in the
'Generate bunker:// URI' dialog instead of always falling back to the conservative
default set. The chosen perms ride along the pending secret and become the auto-created
connection's GRANTED permissions on connect (default-deny, operator-authoritative).

Per NIP-46 the bunker:// URI carries no perms (unlike nostrconnect://), so this is the
natural place for the operator to declare intent. An absent/empty/invalid selection
still falls back to DEFAULT_CONNECTION_PERMISSIONS so a connection is never fail-open.

- createConnection() takes an optional operator seed; bunker generate-uri validates a
  PermissionDescriptor[] and threads it through PendingSecretInfo -> handleNewConnect.
- Frontend: permission picker (all-kinds toggles + sign_event kind input), prefilled
  with the defaults, in the generate dialog.
- Tests: seed override + default fallback + pending-secret perms round-trip (server 173).
…er:// URI picker

A single toggle grants every gated method (all kinds) at once, or clears the whole
selection, alongside the per-method toggles and the sign_event kind input.
@dsbaars dsbaars merged commit 387d8a6 into main Jun 24, 2026
4 checks passed
@dsbaars dsbaars deleted the fix/connection-authz-default-deny branch June 24, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant