fix(acl): enforce token grant lifecycle live at sign time (#24, #25) #27

Merged
padreug merged 6 commits from issue-25-live-grant-lifecycle into dev 2026-06-19 16:05:19 +00:00
Showing only changes of commit a707d203a1 - Show all commits

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>
Padreug 2026-06-19 14:54:18 +02:00

View file

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