diff --git a/docs/acl-prior-art-survey.md b/docs/acl-prior-art-survey.md new file mode 100644 index 0000000..01a9f57 --- /dev/null +++ b/docs/acl-prior-art-survey.md @@ -0,0 +1,297 @@ +# 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 26430–26434; 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` (`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`.