aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/nwc.ts172
-rw-r--r--lib/payments.ts18
-rw-r--r--lib/spend-budget.ts39
3 files changed, 211 insertions, 18 deletions
diff --git a/lib/nwc.ts b/lib/nwc.ts
index d830e8cd..c714ae5a 100644
--- a/lib/nwc.ts
+++ b/lib/nwc.ts
@@ -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;
+};