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>
297 lines
17 KiB
Markdown
297 lines
17 KiB
Markdown
# 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<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`.
|