nsecbunkerd/docs/acl-prior-art-survey.md
Padreug a707d203a1 docs(#25): source-verified ACL prior-art survey + keep-our-fork decision
Surveys Signet, Amber, FROSTR, promenade, NDK/rust-nostr/nak against
actual source; records the decision to keep our fork and treat Signet
as a parts donor (NIP-46 wire boundary keeps the signer substitutable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:54:18 +02:00

17 KiB
Raw Permalink Blame History

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 KeyUserrevokedAt, 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 policyNip46Policy { methods?, kinds? } (db/nip46.ts:8-11), sessions keyed (user_id, client_pubkey) (:92), checked live per request (service.ts:508-509, 766-795). No TTL/expiry. Session revoke is explicit (status='revoked', :792-826); per-method/kind revoke is implicit (flip boolean false, audited at :722-790).
    2. Peer-transport policy — per-peer directional allowSend/allowReceive (util/peer-policy.ts:3-9, docs/PEER_POLICIES.md), enforced in bifrost _filter/get_recv_pubkeys (client.ts:226-245). Correction: it's default-allow + explicit per-peer deny + last-layer-wins, not "deny-override".
    3. Operator API auth — keys stored SHA-256 hash+prefix with revoked_at (checked first, timing-safe) + last_used_at/ip (migrations/..._api_keys.sql, database.ts:815-1047); Argon2id password hashing (config/crypto.ts:26-31).
  • No layer enforces live expiry. nip46_requests.expires_at exists but is never populated; the only time-based enforcement is the in-memory derived-key vault (TTL + bounded reads + zeroize, auth.ts:359-459).
  • Key-at-rest: DB mode AES-256-GCM in SQLite, PBKDF2-HMAC-SHA256 @600k (corrected from "~200k", config/crypto.ts:7-11); headless mode = plaintext env (GROUP_CRED/SHARE_CRED).
  • Distributed veto is real at the participation level (a co-signer withholding its partial below threshold blocks the sig) but the default signer auto-signs (middleware: {}, client.ts:55) — realizing a veto needs a custom middleware.sign not shipped by default.
  • Share rotation (recover → re-split, same group npub, old shares can't combine) exists as a bifrost SDK primitive (generate_dealer_package), not as an igloo-server endpoint; recovery reconstructs the full nsec in memory and /api/recover even returns it over HTTP (routes/recovery.ts:147-157).
  • Steal for us: the 3-layer revocation decomposition; audit-event-on-grant- change; revoked_at-checked-first + last-used credential table.

promenade — fiatjaf (Go, FROST coordinator + signer split)

Off GitHub; cloned from fiatjaf's nostr-git (relay.ngit.dev/npub180c…/promenade.git), HEAD 70ff8439 2026-06-18. NIP-46 method logic lives in the pinned dep fiatjaf.com/nostr (nip46.DynamicSigner).

  • Architecture: khatru coordinator-relay doubles as the NIP-46 endpoint, runs the FROST ceremony, holds a transport/handler key but no shard (account_registration.go:44 carries only frost.PublicKeyShard); separate signer daemons each hold one shard; m-of-n with m≤20 (:79). Signing-ceremony kinds 2643026434; account registration is kind 16430 (replaceable).
  • No encrypted DMs — by choice, not by math. DynamicSigner recognizes nip44_encrypt/nip44_decrypt/switch_relays (dynamic-signer.go), but promenade hardwires AuthorizeEncryption → false (coordinator/nip46.go:167) and GroupContext.Encrypt/Decrypt → "not implemented" (sign.go:288-302). README: "destroyer of encryption." Correction: threshold ECDH is NOT impossible for FROST — frost/ecdh.go implements CreateECDHShare / AggregateECDHShards; it's simply not plumbed in.
  • ACL: AuthorizeSigning per sign_event (coordinator/nip46.go:86); named profiles ["profile", name, secret, restrictions] where restrictions is a nostr.Filter but only Kinds + Until are enforced (:139-159). The secret is a reusable bearer capability.
  • Lifecycle: per-profile Until is the only time-bound; no revoke API — dropping one capability means re-publishing the whole kind:16430 account signed by the master nsec. The revoke = re-key anti-pattern to avoid.
  • Key-at-rest: nsec sharded client-side (never whole), but shards stored plaintext in each signer's BoltDB (acceptor.go:209); coordinator/signer identity keys from plaintext env.
  • Relevance: confirms (1) keep grant-revoke independent of key rotation, and (2) for the #18 "bunker for everything" endgame, threshold-protecting the server identity wouldn't mathematically preclude DM decryption — but keeping ECDH on a separate non-threshold key is the cheaper path.

B — library/SDK signer seams

NDK — nostr-dev-kit/ndk (we embed this) @ 4b86acd (2026-04-05)

nip46 under core/src/signers/nip46/.

  • Backend NDKNip46Backend (backend/index.ts:58), client NDKNip46Signer (index.ts:60).
  • Permit seam: Nip46PermitCallback = (params: {id, pubkey, method, params?}) => Promise<boolean> (backend/index.ts:29-43), invoked via overridable pubkeyAllowed() (:229-231) from each strategy.
  • get_public_key bypasses the seambackend/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), --persists 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.