NDK NIP-46 backend: get_public_key bypasses the permit callback — pubkey disclosure is ungated/unauditable through our ACL seam #26

Open
opened 2026-06-19 12:41:41 +00:00 by padreug · 0 comments
Owner

Summary

Our daemon's entire authorization model sits behind NDK's single Nip46PermitCallback / NDKNip46Backend.pubkeyAllowed() seam. While source-reading NDK for the #25 ACL redesign I confirmed that get_public_key does not go through that seam — so our checkIfPubkeyAllowed (and any future grantIsLive(now) predicate) is never consulted when a client asks for the signer's pubkey.

Verified against source

NDK nostr-dev-kit/ndk @ 4b86acd (2026-04-05), nip46 under core/src/signers/nip46/:

  • The permit seam — Nip46PermitCallback = (params: {id, pubkey, method, params?}) => Promise<boolean> (backend/index.ts:29-43) — is invoked via the overridable pubkeyAllowed() (backend/index.ts:229-231) from inside each method strategy: connect.ts:27, sign-event.ts:33, nip44-encrypt.ts:27 (+ nip04/nip44 siblings), ping.ts:17, switch-relays.ts:20.
  • get_public_key has no such call. backend/get-public-key.ts:3-11 returns backend.localUser?.pubkey directly, with no pubkeyAllowed invocation. So every other method is gated; get_public_key is not.

For contrast, rust-nostr's NostrConnectSignerActions::approve() (signer/nostr-connect/src/signer.rs:201-202, 342-345) wraps the entire request match — including get_public_key. So this is an NDK-specific gap, not inherent to NIP-46.

Why it matters for us

  1. Unauditable disclosure. Any client that has reached our backend can learn which pubkey the signer manages without producing a Request row or hitting the ACL. If our threat model ever wants to (a) gate pubkey disclosure per-app, (b) audit/log who queried identity, or (c) deny it for revoked/suspended/expired grants, the NDK seam cannot express it.
  2. Interacts with the #25 redesign. A core goal of #25 is "one grantIsLive(now) predicate consulted on every request." get_public_key is a silent exception to "every request." Worth deciding deliberately whether that's acceptable (pubkey is arguably public-ish) or whether we override it.

Options

  • Accept + document — treat get_public_key as intentionally ungated (the pubkey is not secret), and note the exception explicitly in the ACL so it's not mistaken for full coverage.
  • Override the strategysetStrategy('get_public_key', ...) (backend/index.ts:156-158) with a variant that calls pubkeyAllowed() first, so disclosure is gated/audited like everything else.
  • Upstream — optionally propose NDK gate get_public_key through pubkeyAllowed (behind a flag, for back-compat) so embedders can choose.

Cross-refs

  • #25 — the ACL redesign that assumes a single live predicate on every request.
  • Captured in docs/acl-prior-art-survey.md (NDK section).

No action needed before #25 lands; filing so the exception is tracked rather than discovered later.

## Summary Our daemon's entire authorization model sits behind NDK's single `Nip46PermitCallback` / `NDKNip46Backend.pubkeyAllowed()` seam. While source-reading NDK for the #25 ACL redesign I confirmed that **`get_public_key` does not go through that seam** — so our `checkIfPubkeyAllowed` (and any future `grantIsLive(now)` predicate) is never consulted when a client asks for the signer's pubkey. ## Verified against source NDK `nostr-dev-kit/ndk` @ `4b86acd` (2026-04-05), nip46 under `core/src/signers/nip46/`: - The permit seam — `Nip46PermitCallback = (params: {id, pubkey, method, params?}) => Promise<boolean>` (`backend/index.ts:29-43`) — is invoked via the overridable `pubkeyAllowed()` (`backend/index.ts:229-231`) from inside each method strategy: `connect.ts:27`, `sign-event.ts:33`, `nip44-encrypt.ts:27` (+ nip04/nip44 siblings), `ping.ts:17`, `switch-relays.ts:20`. - **`get_public_key` has no such call.** `backend/get-public-key.ts:3-11` returns `backend.localUser?.pubkey` directly, with no `pubkeyAllowed` invocation. So every other method is gated; `get_public_key` is not. For contrast, rust-nostr's `NostrConnectSignerActions::approve()` (`signer/nostr-connect/src/signer.rs:201-202, 342-345`) wraps the *entire* request match — including `get_public_key`. So this is an NDK-specific gap, not inherent to NIP-46. ## Why it matters for us 1. **Unauditable disclosure.** Any client that has reached our backend can learn which pubkey the signer manages without producing a `Request` row or hitting the ACL. If our threat model ever wants to (a) gate pubkey disclosure per-app, (b) audit/log who queried identity, or (c) deny it for revoked/suspended/expired grants, **the NDK seam cannot express it.** 2. **Interacts with the #25 redesign.** A core goal of #25 is "one `grantIsLive(now)` predicate consulted on every request." `get_public_key` is a silent exception to "every request." Worth deciding deliberately whether that's acceptable (pubkey is arguably public-ish) or whether we override it. ## Options - **Accept + document** — treat `get_public_key` as intentionally ungated (the pubkey is not secret), and note the exception explicitly in the ACL so it's not mistaken for full coverage. - **Override the strategy** — `setStrategy('get_public_key', ...)` (`backend/index.ts:156-158`) with a variant that calls `pubkeyAllowed()` first, so disclosure is gated/audited like everything else. - **Upstream** — optionally propose NDK gate `get_public_key` through `pubkeyAllowed` (behind a flag, for back-compat) so embedders can choose. ## Cross-refs - #25 — the ACL redesign that assumes a single live predicate on every request. - Captured in `docs/acl-prior-art-survey.md` (NDK section). No action needed before #25 lands; filing so the exception is tracked rather than discovered later.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd#26
No description provided.