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/spend-budget.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 lib/spend-budget.ts (limited to 'lib/spend-budget.ts') diff --git a/lib/spend-budget.ts b/lib/spend-budget.ts new file mode 100644 index 00000000..3f2a70ac --- /dev/null +++ b/lib/spend-budget.ts @@ -0,0 +1,39 @@ +const number = (value: unknown) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +export const debitTotal = ({ + amount, + tip = 0, + fee = 0, + ourfee = 0, +}: { + amount: unknown; + tip?: unknown; + fee?: unknown; + ourfee?: unknown; +}) => number(amount) + number(tip) + number(fee) + number(ourfee); + +// Enforce a caller-specific spending allowance at debit's final choke point, +// after mutable invoice metadata (notably an internal invoice tip) and fees have +// been captured. Checking earlier lets a recipient mutate the tip between the +// NWC budget check and the ledger debit. +export const assertWithinSpendLimit = ({ + amount, + tip = 0, + fee = 0, + ourfee = 0, + maxTotal, +}: { + amount: unknown; + tip?: unknown; + fee?: unknown; + ourfee?: unknown; + maxTotal?: number; +}) => { + const total = debitTotal({ amount, tip, fee, ourfee }); + if (maxTotal !== undefined && total > maxTotal) + throw new Error(`Budget exceeded: ${total} of ${maxTotal} remaining`); + return total; +}; -- cgit v1.2.3