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

297 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 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 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`.