diff options
| -rw-r--r-- | lib/nwc.ts | 172 | ||||
| -rw-r--r-- | lib/payments.ts | 18 | ||||
| -rw-r--r-- | lib/spend-budget.ts | 39 | ||||
| -rw-r--r-- | routes/users.ts | 32 | ||||
| -rw-r--r-- | test/spend-budget.test.ts | 38 |
5 files changed, 279 insertions, 20 deletions
@@ -80,6 +80,23 @@ const withBudgetLock = async <T>(key: string, fn: () => Promise<T>): Promise<T> } }; const budgetedMethods = new Set(["pay_invoice", "pay", "pay_keysend"]); +const nwcDormancyWindow = 30 * 24 * 60 * 60 * 1000; + +const isDormantNwcApp = async (app) => { + const cutoff = Date.now() - nwcDormancyWindow; + let lastActivity = Number(app.created) || 0; + + // Payment indexes are newest-first. Check a few entries because an archived + // or missing record must not make a recently active connection look dormant. + const pids = await db.lRange(`${app.pubkey}:payments`, 0, 19); + for (const pid of pids) { + const payment = await gf(`payment:${pid}`); + if (payment?.created) + lastActivity = Math.max(lastActivity, Number(payment.created)); + } + + return lastActivity < cutoff; +}; export default () => { let r: any; @@ -285,12 +302,94 @@ export default () => { if (!methods.includes(method)) return; + // Incident kill switch. NWC connection secrets are long-lived bearer + // credentials; if they may have been disclosed, a ledger hard-freeze is + // too broad to leave on indefinitely and per-app deletion is both slow and + // destructive. This flag makes every NWC method fail closed while keeping + // the connection records available for deliberate rotation/recovery. + if (await g("nwc:disabled")) { + await reply({ + result_type: method, + error: { + code: "INTERNAL", + message: "NWC is temporarily disabled", + }, + }); + return; + } + try { const app = await g(`app:${pubkey}`); if (!app) fail("pubkey not found"); const user = await g(`user:${app.uid}`); if (!user) fail("user not found"); + // Forced credential rotation. NWC secrets are bearer credentials, so a + // suspected disclosure cannot be repaired by changing an account + // password. The cutoff retires every connection created before the + // incident epoch while allowing users to create replacement connections + // without a global NWC outage. Keep the explicit quarantine membership + // as a durable forensic marker and as defense in depth if the cutoff is + // later removed accidentally. + const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0; + if ( + credentialCutoff > 0 && + (!Number(app.created) || Number(app.created) < credentialCutoff) + ) { + await db.sAdd("nwc:quarantined", pubkey); + // Retired clients commonly keep polling until their owners replace + // the connection. The rejection is expected and can be extremely + // noisy, so reply without logging every attempt. Explicit quarantine + // and newly detected attack-pattern events remain warning-level. + await reply({ + result_type: method, + error: { + code: "UNAUTHORIZED", + message: + "This NWC connection has been retired; create a new one", + }, + }); + return; + } + + // Incident containment without disrupting every NWC user. Quarantine + // only connections whose bearer secret shows the probe/drain pattern; + // retain the app record so support can identify and rotate it later. + if (await db.sIsMember("nwc:quarantined", pubkey)) { + warn("Blocked quarantined NWC connection", user.username, pubkey, method); + await reply({ + result_type: method, + error: { + code: "UNAUTHORIZED", + message: "This NWC connection has been disabled; create a new one", + }, + }); + return; + } + + // Active-incident guard: the leaked credential set is being walked with + // keysend probes against years-old, previously idle connections. Block + // the first keysend after 30 days of inactivity and quarantine only that + // connection. Ordinary pay_invoice/pay traffic and active keysend apps + // remain unaffected. The Redis flag makes this guard easy to retire once + // the credential-rotation campaign is complete. + if ( + method === "pay_keysend" && + (await g("nwc:guard:dormant-keysend")) && + (await isDormantNwcApp(app)) + ) { + await db.sAdd("nwc:quarantined", pubkey); + warn("SECURITY: quarantined dormant NWC keysend", user.username, pubkey); + await reply({ + result_type: method, + error: { + code: "UNAUTHORIZED", + message: "This dormant NWC connection has been disabled; create a new one", + }, + }); + return; + } + // Serialize budget-affecting methods per app so the budget check and the // resulting spend can't interleave with a concurrent request on the same // connection. Read-only methods run without contending for the lock. @@ -441,12 +540,33 @@ const checkBudget = async (app, amount) => { const { max_amount, budget_renewal, pubkey, created } = app; if (!created) { - return error({ - code: "UNAUTHORIZED", - message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`, - }); + return { + budgetError: error({ + code: "UNAUTHORIZED", + message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`, + }), + remaining: 0, + }; + } + + const unlimited = + max_amount === undefined || max_amount === null || max_amount === ""; + const limit = unlimited ? 0 : Number(max_amount); + if (!Number.isFinite(limit) || limit < 0) { + return { + budgetError: error({ + code: "UNAUTHORIZED", + message: "This NWC connection has an invalid spending budget", + }), + remaining: 0, + }; } + // A blank budget is deliberately unlimited. A positive budget must have a + // known renewal period; otherwise an unknown value makes every historical + // payment fall out of the window and silently resets the budget each call. + if (limit === 0) return { budgetError: null, remaining: undefined }; + const periods = { daily: 60 * 60 * 24 * 1000, weekly: 60 * 60 * 24 * 7 * 1000, @@ -455,10 +575,21 @@ const checkBudget = async (app, amount) => { never: 60 * 60 * 24 * 365 * 10 * 1000, }; + const period = periods[budget_renewal]; + if (!period) { + return { + budgetError: error({ + code: "UNAUTHORIZED", + message: "This NWC connection has an invalid budget renewal period", + }), + remaining: 0, + }; + } + const pids = await db.lRange(`${pubkey}:payments`, 0, -1); let payments = await Promise.all(pids.map((pid) => gf(`payment:${pid}`))); payments = payments.filter( - (p) => p?.created > Date.now() - periods[budget_renewal], + (p) => p?.created > Date.now() - period, ); const spent = payments.reduce( @@ -471,14 +602,18 @@ const checkBudget = async (app, amount) => { 0, ); - if (max_amount > 0 && spent + amount > max_amount) { - return error({ - code: "QUOTA_EXCEEDED", - message: `Budget exceeded: ${spent + amount} of ${max_amount}`, - }); + const remaining = Math.max(0, limit - spent); + if (amount > remaining) { + return { + budgetError: error({ + code: "QUOTA_EXCEEDED", + message: `Budget exceeded: ${spent + amount} of ${limit}`, + }), + remaining, + }; } - return null; + return { budgetError: null, remaining }; }; const handle = (method, params, ev, app, user) => @@ -502,7 +637,7 @@ const handle = (method, params, ev, app, user) => const amount = Math.round(amountMsat / 1000); const { max_fee, pubkey } = app; - const budgetError = await checkBudget(app, amount); + const { budgetError, remaining } = await checkBudget(app, amount); if (budgetError) return budgetError; if (payee === id) { @@ -515,10 +650,11 @@ const handle = (method, params, ev, app, user) => invoice, recipient, sender: user, + maxTotal: remaining, }); const preimage = pid; - if (pubkey !== user.pubkey) await db.lPush(`${pubkey}:payments`, pid); + await db.lPush(`${pubkey}:payments`, pid); if (invoice.memo?.includes("9734")) { const { invoices } = await ln.listinvoices({ invstring: pr }); @@ -548,6 +684,7 @@ const handle = (method, params, ev, app, user) => user, pr, memo: JSON.stringify(metadata), + maxTotal: remaining, }); await db.lPush(`${pubkey}:payments`, p.id); @@ -656,7 +793,7 @@ const handle = (method, params, ev, app, user) => } const amount = Math.round(amountMsat / 1000); - const budgetError = await checkBudget(app, amount); + const { budgetError, remaining } = await checkBudget(app, amount); if (budgetError) return budgetError; const created_at = Math.floor(Date.now() / 1000); @@ -678,9 +815,10 @@ const handle = (method, params, ev, app, user) => memo: payer_note, recipient, sender: user, + maxTotal: remaining, }); - if (pubkey !== user.pubkey) await db.lPush(`${pubkey}:payments`, pid); + await db.lPush(`${pubkey}:payments`, pid); if (invoice.memo?.includes("9734")) { const { invoices } = await ln.listinvoices({ invstring: pr }); @@ -718,6 +856,7 @@ const handle = (method, params, ev, app, user) => pr, memo: metadata ? JSON.stringify(metadata) : undefined, payerNote: payer_note, + maxTotal: remaining, }); await db.lPush(`${pubkey}:payments`, p.id); @@ -824,7 +963,7 @@ const handle = (method, params, ev, app, user) => // subject to the same budget — otherwise a budget-limited connection string // is a full-drain credential. It also left no `${app.pubkey}:payments` // entry, so subsequent budgeted calls under-counted the spend. - const budgetError = await checkBudget(app, amount); + const { budgetError, remaining } = await checkBudget(app, amount); if (budgetError) return budgetError; try { @@ -835,6 +974,7 @@ const handle = (method, params, ev, app, user) => user, extratlvs, fee: app.max_fee, + maxTotal: remaining, }); // Record the debit against the app budget. debit() stored the payment id 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/<type>.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")) { 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; +}; diff --git a/routes/users.ts b/routes/users.ts index 0a3fa415..e301d708 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -1115,6 +1115,7 @@ export default { (a, b) => a + (Math.abs(Number.parseInt(b.amount || 0)) + + Number.parseInt(b.tip || 0) + Number.parseInt(b.fee || 0) + Number.parseInt(b.ourfee || 0)), 0, @@ -1139,10 +1140,37 @@ export default { const { user } = req; const uid = user.id; - let app = await g(pubkey); + if (secret) pubkey = getPublicKey(hexToBytes(secret)); + // App records live under `app:<pubkey>`. Looking up the bare pubkey made + // this ownership check ineffective for normal NWC keys and allowed an + // authenticated user to overwrite another connection's configuration. + let app = await g(`app:${pubkey}`); if (app && uid !== app.uid) fail("Unauthorized"); - if (secret) pubkey = getPublicKey(hexToBytes(secret)); + + const validRenewals = new Set([ + "daily", + "weekly", + "monthly", + "yearly", + "never", + ]); + if (budget_renewal && !validRenewals.has(budget_renewal)) + fail("Invalid budget renewal period"); + + for (const [label, value] of [ + ["spending budget", max_amount], + ["maximum fee", max_fee], + ]) { + if ( + value !== undefined && + value !== null && + value !== "" && + (!Number.isFinite(Number(value)) || Number(value) < 0) + ) + fail(`Invalid ${label}`); + } + // Notifications are opt-in (default off): most users don't consume them and // publishing payment_sent/payment_received for every app would flood the // relay. A client enables them by sending notify:"true" when creating the diff --git a/test/spend-budget.test.ts b/test/spend-budget.test.ts new file mode 100644 index 00000000..81c16b2a --- /dev/null +++ b/test/spend-budget.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { + assertWithinSpendLimit, + debitTotal, +} from "$lib/spend-budget"; + +describe("NWC budget covers the final captured tip and fees", () => { + test("a small invoice with an oversized mutable tip is rejected", () => { + expect( + debitTotal({ amount: 19, tip: 1_701_450, fee: 0, ourfee: 0 }), + ).toBe(1_701_469); + expect(() => + assertWithinSpendLimit({ + amount: 19, + tip: 1_701_450, + maxTotal: 10_000, + }), + ).toThrow("Budget exceeded"); + }); + + test("the exact remaining allowance succeeds", () => { + expect( + assertWithinSpendLimit({ + amount: 9_000, + tip: 500, + fee: 400, + ourfee: 100, + maxTotal: 10_000, + }), + ).toBe(10_000); + }); + + test("an unlimited first-party debit remains unaffected", () => { + expect( + assertWithinSpendLimit({ amount: 19, tip: 1_701_450 }), + ).toBe(1_701_469); + }); +}); |
