aboutsummaryrefslogtreecommitdiffstats
path: root/lib/nwc.ts
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-22 08:08:04 -0700
committerAdam Soltys <[email protected]>2026-08-22 08:08:04 -0700
commit0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f (patch)
treef288d01fdc82b2df7ba9fd3f6ef6f8792ef49880 /lib/nwc.ts
parent38bfeeecdafb678d2a495c2865d1ff4dd6255ac9 (diff)
downloadcoinos-server-0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f.tar.gz
coinos-server-0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f.zip
nwc: enforce per-connection spend budgets + flag-gated incident containment
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:<pubkey>, which let an authenticated user overwrite another connection's config, and validate budget/fee/renewal inputs. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
Diffstat (limited to 'lib/nwc.ts')
-rw-r--r--lib/nwc.ts172
1 files changed, 156 insertions, 16 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