From 0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 22 Aug 2026 08:08:04 -0700 Subject: nwc: enforce per-connection spend budgets + flag-gated incident containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the remaining allowance through every spend path (pay_invoice, pay, pay_keysend, internal transfer) as maxTotal, and have debit() assert the spend stays within it after it reloads the invoice and captures the tip — so a delegated NWC credential can no longer exceed its configured budget, and a mutated tip can't slip a small-budget check and drain via the internal path. checkBudget now returns the remaining allowance alongside any error; a blank budget is treated as deliberately unlimited and a positive budget requires a known renewal period. Add operator-controlled containment for a suspected credential disclosure, all gated behind Redis flags with no hardcoded scope: nwc:disabled fail every method closed nwc:credential-cutoff retire connections created before an epoch nwc:quarantined block an individual disclosed connection nwc:guard:dormant-keysend quarantine the first keysend on a long-idle app Fix an ownership check that read the bare pubkey instead of app:, which let an authenticated user overwrite another connection's config, and validate budget/fee/renewal inputs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7 --- lib/payments.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) (limited to 'lib/payments.ts') diff --git a/lib/payments.ts b/lib/payments.ts index 5df028b3..6329bfcf 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -2,6 +2,7 @@ import config from "$config"; import { existsSync } from "fs"; import api from "$lib/api"; import { archive, db, g, ga, gf, s, sa } from "$lib/db"; +import { assertWithinSpendLimit } from "$lib/spend-budget"; // External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a // human) can `touch /home/adam/locks/.locked` on the host, which is @@ -108,6 +109,7 @@ export const debit = async ({ user, type = PaymentType.internal, rate = undefined, + maxTotal = undefined, }) => { amount = Number.parseInt(amount); @@ -234,6 +236,13 @@ export const debit = async ({ : 0; if (aid !== uid) ourfee = 0; + + // NWC and other delegated spenders can provide the exact allowance remaining + // for this call. Enforce it only after debit has reloaded the invoice and + // captured its tip, so a recipient cannot pass a small-invoice budget check, + // mutate the tip, and drain the account through the internal-payment path. + assertWithinSpendLimit({ amount, tip, fee, ourfee, maxTotal }); + const frozenBalance = !blacklisted || whitelisted ? 0 : await g(`balance:${uid}`); @@ -476,7 +485,7 @@ export const credit = async ({ ); } - m.set(`invoice:${inv.id}`, JSON.stringify(inv)) + await m.set(`invoice:${inv.id}`, JSON.stringify(inv)) .set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${aid || uid}:payments`, p.id) .incrBy(`${balanceKey}:${aid || uid}`, amount) @@ -741,6 +750,7 @@ export const sendKeysend = async ({ memo = undefined, user, extratlvs = undefined, + maxTotal = undefined, }) => { fee = Math.max(Number.parseInt(fee || amount * 0.005), 5); @@ -754,6 +764,7 @@ export const sendKeysend = async ({ memo, user, type: PaymentType.lightning, + maxTotal, }); let outcome = "unknown"; @@ -848,6 +859,7 @@ export const sendLightning = async ({ fee = undefined, memo = undefined, payerNote = undefined, + maxTotal = undefined, }) => { let p; @@ -928,6 +940,7 @@ export const sendLightning = async ({ memo, user, type: PaymentType.lightning, + maxTotal, }); await db.sAdd("pending", pr); @@ -1067,6 +1080,7 @@ export const sendInternal = async ({ memo = undefined, recipient, sender, + maxTotal = undefined, }) => { if (!invoice) invoice = await generate({ @@ -1075,7 +1089,7 @@ export const sendInternal = async ({ }); const { hash } = invoice; - const p = await debit({ hash, amount, memo, user: sender }); + const p = await debit({ hash, amount, memo, user: sender, maxTotal }); await credit({ hash, amount, memo, ref: sender.id, tip: p.tip }); if (invoice.memo?.includes("9734")) { -- cgit v1.2.3