From eb6c86a4d1ae8d053bdb1c2115f057bd4015bc99 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 13:23:52 +0200 Subject: [PATCH 1/3] chore(schema): add Token.revokedAt for surgical token revocation (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-requisite for the live-policy auth rewrite in #11. The new revoke_token admin RPC needs a way to mark a single Token as revoked without nuking the whole KeyUser (revoke_user) or conflating with future expiry cleanup (deletedAt). Nullable DateTime — existing rows default to NULL (active), no data migration needed. refs: https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11 --- .../20260530112308_add_token_revoked_at/migration.sql | 2 ++ prisma/schema.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 prisma/migrations/20260530112308_add_token_revoked_at/migration.sql diff --git a/prisma/migrations/20260530112308_add_token_revoked_at/migration.sql b/prisma/migrations/20260530112308_add_token_revoked_at/migration.sql new file mode 100644 index 0000000..4dbce97 --- /dev/null +++ b/prisma/migrations/20260530112308_add_token_revoked_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Token" ADD COLUMN "revokedAt" DATETIME; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e8b5344..f0072ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,7 @@ model Token { deletedAt DateTime? expiresAt DateTime? redeemedAt DateTime? + revokedAt DateTime? keyUserId Int? policyId Int? policy Policy? @relation(fields: [policyId], references: [id]) From 35826ab695c69f26a63f3ecd66211238e187ee15 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 13:25:48 +0200 Subject: [PATCH 2/3] feat(acl): live-policy auth in checkIfPubkeyAllowed (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shifts sign-time authorization from materialized SigningCondition snapshots (frozen at token-bind time) to a layered model: 1. fetch KeyUser; missing → undefined 2. KeyUser.revokedAt set → false 3. SigningCondition override layer (explicit reject, per-(method, kind) grant/deny) → return matching row's allowed value 4. live policy join: KeyUser → Token (revokedAt IS NULL) → Policy → PolicyRule. Match on method + kind (exact / 'all' / NULL defensive) → true 5. else → undefined (caller may still prompt admin) Backwards-compatible. The existing applyToken fan-out of SigningCondition rows continues to populate step 3 for legacy auth, so already-bound users keep working with no behavior change. New users will still go through that fan-out until the follow-up trim (#12). The key win: PolicyRule mutations via the upcoming companion RPCs (add_policy_rule / remove_policy_rule / etc.) propagate live to every KeyUser bound to that policy, rather than requiring per-user backfill RPCs that don't exist. Algorithm ratified by webapp in https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11#issuecomment-1473. refs: https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11 --- src/daemon/lib/acl/index.ts | 98 +++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/src/daemon/lib/acl/index.ts b/src/daemon/lib/acl/index.ts index 42af57d..b32f954 100644 --- a/src/daemon/lib/acl/index.ts +++ b/src/daemon/lib/acl/index.ts @@ -1,13 +1,32 @@ import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'; import prisma from '../../../db.js'; +/** + * Layered authorization check. Order matters: + * + * 1. fetch KeyUser; if missing → undefined (no binding exists) + * 2. if KeyUser.revokedAt set → false (binary user revoke beats everything) + * 3. SigningCondition override layer (per-user grants/denies): + * - explicit reject (method='*', allowed=false) → false + * - matching per-(method,kind) row → return row.allowed + * 4. Live policy join over KeyUser → Token → Policy → PolicyRule + * with Token.revokedAt IS NULL and a matching rule → true + * 5. else → undefined (denied) + * + * Step 3 must precede step 4: per-user denies override the policy, and + * per-user grants extend beyond the policy. Step 2 must precede step 3: + * a revoked KeyUser stays revoked regardless of conditions or policy. + * + * See aiolabs/nsecbunkerd#11 and the issue comment that ratified the + * algorithm (https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11#issuecomment-1473). + */ export async function checkIfPubkeyAllowed( keyName: string, remotePubkey: string, method: IMethod, payload?: string | NostrEvent ): Promise { - // find KeyUser + // Step 1: find KeyUser. const keyUser = await prisma.keyUser.findUnique({ where: { unique_key_user: { keyName, userPubkey: remotePubkey } }, }); @@ -16,9 +35,12 @@ export async function checkIfPubkeyAllowed( return undefined; } - // find SigningCondition - const signingConditionQuery = requestToSigningConditionQuery(method, payload); + // Step 2: binary user revoke. + if (keyUser.revokedAt) { + return false; + } + // Step 3a: explicit-reject override (rejectAllRequestsFromKey writes this). const explicitReject = await prisma.signingCondition.findFirst({ where: { keyUserId: keyUser.id, @@ -32,6 +54,9 @@ export async function checkIfPubkeyAllowed( return false; } + // Step 3b: matching per-(method, kind) override. + const signingConditionQuery = requestToSigningConditionQuery(method, payload); + const signingCondition = await prisma.signingCondition.findFirst({ where: { keyUserId: keyUser.id, @@ -39,32 +64,53 @@ export async function checkIfPubkeyAllowed( } }); - // if no SigningCondition found, return undefined - if (!signingCondition) { - return undefined; - } - - const allowed = signingCondition.allowed; - - // Check if the key user has been revoked - if (allowed) { - const revoked = await prisma.keyUser.findFirst({ - where: { - id: keyUser.id, - revokedAt: { not: null }, - } - }); - - if (revoked) { - return false; - } - } - - if (allowed === true || allowed === false) { + if (signingCondition && (signingCondition.allowed === true || signingCondition.allowed === false)) { console.log(`found signing condition`, signingCondition); - return allowed; + return signingCondition.allowed; } + // Step 4: live policy join. Walk every non-revoked Token bound to this + // KeyUser; if any of their policies has a matching PolicyRule, allow. + // + // PolicyRule.kind matching: + // - exact match against payload kind (stringified — matches the + // create_new_policy.ts:23 storage format `rule.kind.toString()`) + // - 'all' literal matches any kind (parity with the override-layer + // allowScopeToSigningConditionQuery convention) + // - NULL kind is a defensive branch — no current code path inserts + // PolicyRules with null kind, but if one ever appears (raw SQL, + // future code, schema migration) we treat it as a wildcard rather + // than failing closed silently. + const payloadKindString = (method === 'sign_event' && typeof payload === 'object' && payload?.kind !== undefined) + ? payload.kind.toString() + : undefined; + + const kindMatchers: Array<{ kind: string | null }> = [{ kind: null }, { kind: 'all' }]; + if (payloadKindString !== undefined) { + kindMatchers.push({ kind: payloadKindString }); + } + + const policyAllowance = await prisma.token.findFirst({ + where: { + keyUserId: keyUser.id, + revokedAt: null, + policy: { + rules: { + some: { + method, + OR: kindMatchers, + }, + }, + }, + }, + }); + + if (policyAllowance) { + return true; + } + + // Step 5: no override granted, no policy rule matched. Caller's + // requestPermission flow may still prompt the admin out-of-band. return undefined; } From 49091f722ff4d2406d150b6235099b043ec6a331 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 13:27:28 +0200 Subject: [PATCH 3/3] feat(admin): companion RPCs for live policy + token revocation (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new admin-RPC handlers that complement the live-policy auth rewrite. Each follows the canonical create_new_policy.ts pattern (single JSON-stringified param, kind-24134 response, one prisma mutation, no per-call ACL — validateRequestFromAdmin gates them via the admin-npub allowlist). Policy-level mutations propagate to every KeyUser bound to the policy at the next sign-time check (the point of #11): add_policy_rule { policyId, rule: {method, kind?, maxUsageCount?} } remove_policy_rule { ruleId } update_policy { policyId, patch: {name?, expiresAt?} } Per-KeyUser override mutations (override layer): add_signing_condition { keyUserId, condition: {method, kind?, allowed} } remove_signing_condition { conditionId } Surgical token revocation without nuking the KeyUser: revoke_token { tokenId } The 'all' kind literal is honored as a wildcard in add_policy_rule for parity with the SigningCondition override-layer convention. Param shapes ratified by webapp in https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11#issuecomment-1473. refs: https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11 --- src/daemon/admin/commands/add_policy_rule.ts | 47 +++++++++++++++++++ .../admin/commands/add_signing_condition.ts | 46 ++++++++++++++++++ .../admin/commands/remove_policy_rule.ts | 33 +++++++++++++ .../commands/remove_signing_condition.ts | 26 ++++++++++ src/daemon/admin/commands/revoke_token.ts | 36 ++++++++++++++ src/daemon/admin/commands/update_policy.ts | 43 +++++++++++++++++ src/daemon/admin/index.ts | 12 +++++ 7 files changed, 243 insertions(+) create mode 100644 src/daemon/admin/commands/add_policy_rule.ts create mode 100644 src/daemon/admin/commands/add_signing_condition.ts create mode 100644 src/daemon/admin/commands/remove_policy_rule.ts create mode 100644 src/daemon/admin/commands/remove_signing_condition.ts create mode 100644 src/daemon/admin/commands/revoke_token.ts create mode 100644 src/daemon/admin/commands/update_policy.ts diff --git a/src/daemon/admin/commands/add_policy_rule.ts b/src/daemon/admin/commands/add_policy_rule.ts new file mode 100644 index 0000000..041fd1e --- /dev/null +++ b/src/daemon/admin/commands/add_policy_rule.ts @@ -0,0 +1,47 @@ +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import AdminInterface from "../index.js"; +import prisma from "../../../db.js"; + +/** + * Add a single PolicyRule to an existing Policy. Under the live-policy + * auth model (#11), this propagates to every KeyUser bound to the + * policy at the next sign-time check. + * + * Param shape (JSON-stringified): + * { policyId: number, + * rule: { method: string, + * kind?: number | "all" | null, + * maxUsageCount?: number } } + * + * `kind` is stored as a string for parity with create_new_policy.ts's + * `rule.kind.toString()` storage and the override-layer convention. The + * `'all'` literal is honored at sign-time as a wildcard across kinds. + */ +export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) { + const [ _payload ] = req.params as [ string ]; + + if (!_payload) throw new Error("Invalid params"); + + const payload = JSON.parse(_payload); + const { policyId, rule } = payload; + + if (typeof policyId !== "number" || !rule || !rule.method) { + throw new Error("Invalid params"); + } + + const policy = await prisma.policy.findUnique({ where: { id: policyId } }); + if (!policy) throw new Error("Policy not found"); + + await prisma.policyRule.create({ + data: { + policyId, + method: rule.method, + kind: rule.kind !== undefined && rule.kind !== null ? rule.kind.toString() : null, + maxUsageCount: rule.maxUsageCount, + currentUsageCount: 0, + } + }); + + const result = JSON.stringify(["ok"]); + return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); +} diff --git a/src/daemon/admin/commands/add_signing_condition.ts b/src/daemon/admin/commands/add_signing_condition.ts new file mode 100644 index 0000000..6cfad88 --- /dev/null +++ b/src/daemon/admin/commands/add_signing_condition.ts @@ -0,0 +1,46 @@ +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import AdminInterface from "../index.js"; +import prisma from "../../../db.js"; + +/** + * Add a SigningCondition override row for a specific KeyUser. Used to + * grant a user a (method, kind) combination beyond the policy, or to + * deny one explicitly even when the policy allows. + * + * Param shape (JSON-stringified): + * { keyUserId: number, + * condition: { method: string, + * kind?: number | "all", + * allowed: boolean } } + * + * The override layer is consulted before the live policy join in + * checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here + * denies regardless of the policy. + */ +export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { + const [ _payload ] = req.params as [ string ]; + + if (!_payload) throw new Error("Invalid params"); + + const payload = JSON.parse(_payload); + const { keyUserId, condition } = payload; + + if (typeof keyUserId !== "number" || !condition || !condition.method || typeof condition.allowed !== "boolean") { + throw new Error("Invalid params"); + } + + const keyUser = await prisma.keyUser.findUnique({ where: { id: keyUserId } }); + if (!keyUser) throw new Error("KeyUser not found"); + + await prisma.signingCondition.create({ + data: { + keyUserId, + method: condition.method, + kind: condition.kind !== undefined && condition.kind !== null ? condition.kind.toString() : null, + allowed: condition.allowed, + } + }); + + const result = JSON.stringify(["ok"]); + return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); +} diff --git a/src/daemon/admin/commands/remove_policy_rule.ts b/src/daemon/admin/commands/remove_policy_rule.ts new file mode 100644 index 0000000..b983acd --- /dev/null +++ b/src/daemon/admin/commands/remove_policy_rule.ts @@ -0,0 +1,33 @@ +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import AdminInterface from "../index.js"; +import prisma from "../../../db.js"; + +/** + * Delete a single PolicyRule by id. Under the live-policy auth model + * (#11), removal takes effect at the next sign-time check for every + * KeyUser bound to that policy. + * + * Param shape (JSON-stringified): + * { ruleId: number } + * + * Multi-instance bunker-sharing caveat (per comment 1473 §2): + * downstream reconcilers (e.g. lnbits' lnbits-default policy + * convergence) should treat this as an admin-only op — concurrent + * removes across instance versions can race. Adds are safe, removes + * are not. + */ +export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { + const [ _payload ] = req.params as [ string ]; + + if (!_payload) throw new Error("Invalid params"); + + const payload = JSON.parse(_payload); + const { ruleId } = payload; + + if (typeof ruleId !== "number") throw new Error("Invalid params"); + + await prisma.policyRule.delete({ where: { id: ruleId } }); + + const result = JSON.stringify(["ok"]); + return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); +} diff --git a/src/daemon/admin/commands/remove_signing_condition.ts b/src/daemon/admin/commands/remove_signing_condition.ts new file mode 100644 index 0000000..65eff96 --- /dev/null +++ b/src/daemon/admin/commands/remove_signing_condition.ts @@ -0,0 +1,26 @@ +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import AdminInterface from "../index.js"; +import prisma from "../../../db.js"; + +/** + * Delete a SigningCondition override row by id. Used to walk back a + * per-user grant or deny without affecting the policy. + * + * Param shape (JSON-stringified): + * { conditionId: number } + */ +export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { + const [ _payload ] = req.params as [ string ]; + + if (!_payload) throw new Error("Invalid params"); + + const payload = JSON.parse(_payload); + const { conditionId } = payload; + + if (typeof conditionId !== "number") throw new Error("Invalid params"); + + await prisma.signingCondition.delete({ where: { id: conditionId } }); + + const result = JSON.stringify(["ok"]); + return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); +} diff --git a/src/daemon/admin/commands/revoke_token.ts b/src/daemon/admin/commands/revoke_token.ts new file mode 100644 index 0000000..1224d0a --- /dev/null +++ b/src/daemon/admin/commands/revoke_token.ts @@ -0,0 +1,36 @@ +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import AdminInterface from "../index.js"; +import prisma from "../../../db.js"; + +/** + * Revoke a single Token without affecting other tokens for the same + * KeyUser. Sets Token.revokedAt to now; the live-policy auth check + * (#11) filters tokens with `revokedAt IS NULL` so a revoked token's + * policy no longer contributes to any sign-time grant for the + * associated KeyUser. + * + * Param shape (JSON-stringified): + * { tokenId: number } + * + * The KeyUser remains intact and any other (non-revoked) tokens + * bound to it continue to grant via their own policies. Use + * revoke_user for the binary "this user is gone" case. + */ +export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) { + const [ _payload ] = req.params as [ string ]; + + if (!_payload) throw new Error("Invalid params"); + + const payload = JSON.parse(_payload); + const { tokenId } = payload; + + if (typeof tokenId !== "number") throw new Error("Invalid params"); + + await prisma.token.update({ + where: { id: tokenId }, + data: { revokedAt: new Date() }, + }); + + const result = JSON.stringify(["ok"]); + return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); +} diff --git a/src/daemon/admin/commands/update_policy.ts b/src/daemon/admin/commands/update_policy.ts new file mode 100644 index 0000000..43028e5 --- /dev/null +++ b/src/daemon/admin/commands/update_policy.ts @@ -0,0 +1,43 @@ +import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import AdminInterface from "../index.js"; +import prisma from "../../../db.js"; + +/** + * Update mutable fields on a Policy. Currently `name` and `expiresAt`. + * Other Policy columns (`description`, `deletedAt`, etc.) are not + * exposed by this RPC — they aren't in the ratified shape, and we want + * a single point of intent. + * + * Param shape (JSON-stringified): + * { policyId: number, + * patch: { name?: string, + * expiresAt?: string | null } } + * + * `expiresAt: null` explicitly clears the field; `expiresAt` absent + * from the patch leaves it alone. + */ +export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) { + const [ _payload ] = req.params as [ string ]; + + if (!_payload) throw new Error("Invalid params"); + + const payload = JSON.parse(_payload); + const { policyId, patch } = payload; + + if (typeof policyId !== "number" || !patch || typeof patch !== "object") { + throw new Error("Invalid params"); + } + + const data: { name?: string; expiresAt?: Date | null } = {}; + if (patch.name !== undefined) data.name = patch.name; + if (patch.expiresAt !== undefined) { + data.expiresAt = patch.expiresAt === null ? null : new Date(patch.expiresAt); + } + + if (Object.keys(data).length === 0) throw new Error("Empty patch"); + + await prisma.policy.update({ where: { id: policyId }, data }); + + const result = JSON.stringify(["ok"]); + return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); +} diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index db8733b..21e5f23 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -13,6 +13,12 @@ import createNewToken from './commands/create_new_token'; import unlockKey from './commands/unlock_key'; import renameKeyUser from './commands/rename_key_user.js'; import revokeUser from './commands/revoke_user'; +import addPolicyRule from './commands/add_policy_rule'; +import removePolicyRule from './commands/remove_policy_rule'; +import updatePolicy from './commands/update_policy'; +import addSigningCondition from './commands/add_signing_condition'; +import removeSigningCondition from './commands/remove_signing_condition'; +import revokeToken from './commands/revoke_token'; import fs from 'fs'; import { validateRequestFromAdmin } from './validations/request-from-admin'; import { dmUser } from '../../utils/dm-user'; @@ -202,6 +208,12 @@ class AdminInterface { case 'create_new_policy': await createNewPolicy(this, req); break; case 'get_policies': await this.reqListPolicies(req); break; case 'create_new_token': await createNewToken(this, req); break; + case 'add_policy_rule': await addPolicyRule(this, req); break; + case 'remove_policy_rule': await removePolicyRule(this, req); break; + case 'update_policy': await updatePolicy(this, req); break; + case 'add_signing_condition': await addSigningCondition(this, req); break; + case 'remove_signing_condition': await removeSigningCondition(this, req); break; + case 'revoke_token': await revokeToken(this, req); break; default: const originalKind = req.event.kind!; console.log(`Unknown method ${req.method}`);