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>
17 KiB
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
applyTokenmaterialization 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_keybypasses 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-likecreate_accountflow, and being the signer target for the #18RemoteBunkerSignerendgame. Porting those into Signet just means maintaining a fork of a more opinionated upstream. - Lineage/bus-factor. Our
mastertracks 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) checksToken.expiresAtonce at redeem (:895), then materializespolicy.rulesinto lifecycle-freeSigningConditionrows (:845-862); the sign-time path (acl.ts:checkRequestPermission) never readsTokenagain.maxUsageCount/currentUsageCountare 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 onKeyUser, notToken— corroborating our revoke=subject / expiry=grant split. - Trust dial over a kind-risk classifier:
trustLevel ∈ {paranoid, reasonable, full},SAFE_KINDSauto /SENSITIVE_KINDS(0/3/4/5/wallet/ auth/NIP-04) forced manual (acl.ts:129-161). - Two-tier tokens: one-time
ConnectionToken(mandatoryexpiresAt, validates connect but never auto-approves) vs policy-backedToken(atomic claimupdateMany where redeemedAt:null,nip46-backend.ts:813). - Key-at-rest: NIP-49 ncryptsec + AES-256-GCM envelope (PBKDF2-SHA256 @600k).
- Takeaway: adopt its
KeyUsersubject-state +Requestindexing; reject itsapplyTokenmaterialization; theConnectionToken-vs-Tokensplit 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 includeacceptable: 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 recomputesacceptUntil > TimeUtils.now()/rejectUntil > now()fresh every call; expired → returnsnull→ 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:183etc.). - The sweep is non-load-bearing.
updateExpiredPermissions(time)(ApplicationDao.kt:51, exemptsrememberType <> 4=ALWAYS) runs every 24h via WorkManager — pure cleanup; correctness doesn't depend on it firing because the decision is recomputed againstnow()on read. - Time-boxed denials too:
rejectUntilmeans "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 NULLin 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 livenow()re-check on every cache hit; invalidation is write-driven and coarse per-app. - Sign policies (
ChooseSignPolicy.kt:32-45, stored assignPolicy: Int):0basic /1manual-per-new-app /2fully-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; bothbunker://andnostrconnect://. - 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):
- 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). - 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". - 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).
- App NIP-46 policy —
- No layer enforces live expiry.
nip46_requests.expires_atexists 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 custommiddleware.signnot 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/recovereven 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:44carries onlyfrost.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.
DynamicSignerrecognizesnip44_encrypt/nip44_decrypt/switch_relays(dynamic-signer.go), but promenade hardwiresAuthorizeEncryption → false(coordinator/nip46.go:167) andGroupContext.Encrypt/Decrypt → "not implemented"(sign.go:288-302). README: "destroyer of encryption." Correction: threshold ECDH is NOT impossible for FROST —frost/ecdh.goimplementsCreateECDHShare/AggregateECDHShards; it's simply not plumbed in. - ACL:
AuthorizeSigningper sign_event (coordinator/nip46.go:86); named profiles["profile", name, secret, restrictions]where restrictions is anostr.Filterbut onlyKinds+Untilare enforced (:139-159). The secret is a reusable bearer capability. - Lifecycle: per-profile
Untilis 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), clientNDKNip46Signer(index.ts:60). - Permit seam:
Nip46PermitCallback = (params: {id, pubkey, method, params?}) => Promise<boolean>(backend/index.ts:29-43), invoked via overridablepubkeyAllowed()(:229-231) from each strategy. get_public_keybypasses the seam —backend/get-public-key.ts:3-11returns the pubkey with nopubkeyAllowedcall. (rust-nostr'sapprove()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) +NostrConnectclient.- Trait
NostrConnectSignerActions::approve(&self, public_key, req) -> bool(signer.rs:342-345), synchronous bool, wraps the entire request match inserve()(:201-202) — gates every method includingget_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
LocalSignerenvelope reference. - Primal, nowser (Flutter) — clients that also serve NIP-46/NIP-55; use the
standard
optional_requested_permsper-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
- 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. - Time-box denials too (Amber
rejectUntil): a deny decays to a prompt. - Wildcard as a distinct, explicitly-queried tier (Amber): never a fuzzy missing-WHERE match in the auto-decide path.
- Cache rows, never verdicts (Amber
CachingApplicationDao, Signet coarse cache): keep thenow()re-check on every hit; invalidate on write. - Subject vs grant separation (Signet): revoke/suspend/trust on
KeyUser(cheap, cache+invalidate); expiry/usage onToken/Policy(must join live). - Usage = COUNT(Request) in window (lnbits/nostr_bunker), not a mutable
counter: drop
currentUsageCount; needsRequest.keyUserId+ index. - Revocation decomposition (FROSTR): app-grant revoke ≠ transport quarantine ≠ key rotation. Never collapse grant-revoke into re-key (promenade anti-pattern).
- Auditable, revocable credentials (FROSTR):
revoked_atchecked first + last-used tracking; audit-event-on-grant-change decoupled from enforcement. - Single predicate
grantIsLive(now)used at both redeem and sign time (the discipline that prevents the original drift). - NDK seam reality: we own all policy; design around
get_public_keybypassingpubkeyAllowed.