diff options
Diffstat (limited to 'routes')
| -rw-r--r-- | routes/ecash.ts | 146 | ||||
| -rw-r--r-- | routes/email.ts | 91 | ||||
| -rw-r--r-- | routes/info.ts | 16 | ||||
| -rw-r--r-- | routes/invoices.ts | 103 | ||||
| -rw-r--r-- | routes/lnurl.ts | 269 | ||||
| -rw-r--r-- | routes/nostr.ts | 98 | ||||
| -rw-r--r-- | routes/payments.ts | 643 | ||||
| -rw-r--r-- | routes/preimages.ts | 113 | ||||
| -rw-r--r-- | routes/rates.ts | 3 | ||||
| -rw-r--r-- | routes/square.ts | 92 | ||||
| -rw-r--r-- | routes/users.ts | 861 |
11 files changed, 1794 insertions, 641 deletions
diff --git a/routes/ecash.ts b/routes/ecash.ts index 96783cc4..995a8dce 100644 --- a/routes/ecash.ts +++ b/routes/ecash.ts @@ -1,40 +1,21 @@ -import { db, g, s } from "$lib/db"; -import { check, claim, get, mint } from "$lib/ecash"; -import { err, l } from "$lib/logging"; -import { credit, debit } from "$lib/payments"; -import { emit } from "$lib/sockets"; -import { bail, fail, getInvoice } from "$lib/utils"; +import { check, get, redeem } from "$lib/ecash"; +import { err } from "$lib/logging"; +import { bail, fail, getInvoice, getUser } from "$lib/utils"; import { getEncodedToken } from "@cashu/cashu-ts"; -import { v4 } from "uuid"; - -import { PaymentType } from "$lib/types"; -const { ecash: type } = PaymentType; - -Error.stackTraceLimit = 100; // Set this to the desired limit - -const sendCash = async ({ amount, user }) => { - const id = v4(); - const hash = v4(); - - const p = await debit({ hash, amount, user, type }); - const token = await mint(parseInt(amount)); - p.memo = id; - const { id: pid } = p; - await s(`payment:${pid}`, p); - s(`cash:${id}`, token); - - return { id, token, pid }; -}; export default { - async save(req, res) { + // Stateless status lookup for a token the client is holding. This replaced + // an unauthenticated POST that stored any submitted string under a fresh id + // forever (thousands of forged and foreign tokens accumulated); `get` below + // still serves the ids that were handed out while it existed. + async status(req, res) { const { body: { token }, } = req; try { - const id = v4(); - await s(`cash:${id}`, token); - res.send({ id }); + if (typeof token !== "string" || !token.startsWith("cashu")) + fail("Invalid token"); + res.send(await check(token)); } catch (e) { err(e.message); bail(res, e.message); @@ -47,6 +28,7 @@ export default { } = req; try { const token = await get(id); + if (!token) fail("Ecash not found"); const status = await check(token); res.send({ token, status }); } catch (e) { @@ -61,108 +43,34 @@ export default { user, } = req; try { - const amount = await claim(token); - - let memo; - const hash = v4(); - const { currency, id: uid } = user; - const rates = await g("rates"); - await s(`invoice:${hash}`, { - currency, - id: hash, - hash, - rate: rates[currency], - uid, - received: 0, - }); - - await credit({ hash, amount, memo, ref: user.id, type }); - - res.send({ ok: true }); + const amount = await redeem({ token, user }); + res.send({ ok: true, amount }); } catch (e) { err(e.message); bail(res, e.message); } }, - async mint(req, res) { - const { body, user } = req; - const amount = parseInt(body.amount); - res.send(await sendCash({ amount, user })); - }, - - async melt(req, res) { - let { - body: { amount, bolt11: hash, preimage }, - user, - } = req; - try { - amount = Math.round(amount / 1000); - const ref = preimage; - const { lightning: type } = PaymentType; - if (user.username !== "mint") fail("unauthorized"); - const { id: uid, currency } = user; - const ourfee = await db.debit( - `balance:${uid}`, - `credit:${type}:${uid}`, - "Insufficient funds", - amount || 0, - 0, - 0, - 0, - 0 - ); - - const rates = await g("rates"); - const rate = rates[currency]; - - if (ourfee.err) fail(ourfee.err); - - const id = v4(); - const p = { - id, - amount: -amount, - hash, - ourfee, - uid, - confirmed: true, - rate, - currency, - type, - ref, - created: Date.now(), - }; - - await s(`payment:${hash}`, id); - await s(`payment:${id}`, p); - await db.lPush(`${uid}:payments`, id); - - l(user.username, "sent", type, amount); - emit(user.id, "payment", p); - - res.send(p); - } catch (e) { - bail(res, e.message); - } - }, - + // NUT-18 payment-request transport: a wallet POSTs proofs for one of our + // ecash-type invoices, which are melted into a Lightning invoice for the + // invoice's owner under the same id. async receive(req, res) { try { const { id, proofs, mint, memo } = req.body; - const { uid: ref } = await getInvoice(id); - - const amount = await claim( - getEncodedToken({ - mint, - proofs, - }), - ); + const invoice = await getInvoice(id); + if (!invoice) fail("Invoice not found"); + const user = await getUser(invoice.uid); + if (!user) fail("User not found"); - await credit({ hash: id, amount, memo, ref, type }); + await redeem({ + token: getEncodedToken({ mint, proofs }), + user, + invoice, + memo, + }); res.send({ id }); } catch (e) { - console.log(e); err(e.message); bail(res, e.message); } diff --git a/routes/email.ts b/routes/email.ts index 9f0a8228..749fba5e 100644 --- a/routes/email.ts +++ b/routes/email.ts @@ -1,61 +1,78 @@ -import { bail } from "$lib/utils"; +import { bail, fail } from "$lib/utils"; import got from "got"; import config from "$config"; import { l, warn } from "$lib/logging"; import { SESClient } from "@aws-sdk/client-ses"; import { SendEmailCommand } from "@aws-sdk/client-ses"; +// reCAPTCHA v3 never challenges: siteverify returns success:true for any +// well-formed token and reports how human the request looked in `score`. +// Checking `success` alone let a headless scanner submit freely. +const MIN_SCORE = 0.5; + +const escapeHtml = (s: string) => + s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +// Strip CR/LF and other control characters from single-line header values. +const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f]+/g, " ").trim(); + export default { - async send({ body }, res) { + async send({ body, headers }, res) { try { const { email, message, username, token: response } = body; + if (typeof message !== "string" || !message.trim()) fail("Message required"); + if (typeof email !== "string" || !email.trim()) fail("Email required"); + if (username != null && typeof username !== "string") fail("Invalid username"); + if (typeof response !== "string" || !response) fail("failed captcha"); const Charset = "UTF-8"; const { recaptcha: secret } = config; - const { success } = await got + const remoteip = headers?.["cf-connecting-ip"]; + const { success, score, action } = await got .post("https://www.google.com/recaptcha/api/siteverify", { - form: { - secret, - response, - }, + form: { secret, response, ...(remoteip && { remoteip }) }, + timeout: { request: 6000 }, }) - .json(); + .json<any>(); - if (success || response === config.adminpass) { - body.token = undefined; + if (!success || action !== "submit" || !(score >= MIN_SCORE)) { + warn("support request rejected", email, "score", score, "action", action); + fail("failed captcha"); + } - warn("support request from", email); - const client = new SESClient({ region: "us-east-2" }); - await client.send( - new SendEmailCommand({ - Destination: { - CcAddresses: [], - ToAddresses: [config.support], + l("support request from", email, "score", score); + const name = username ? oneLine(username).slice(0, 100) : ""; + const client = new SESClient({ region: "us-east-2" }); + await client.send( + new SendEmailCommand({ + Destination: { + CcAddresses: [], + ToAddresses: [config.support], + }, + Message: { + Body: { + Html: { Charset, Data: escapeHtml(message).replace(/\n/g, "<br>") }, + Text: { Charset, Data: message }, }, - Message: { - Body: { - Html: { Charset, Data: message.replace(/\n/g, "<br>") }, - Text: { Charset, Data: message }, - }, - Subject: { - Charset, - Data: - body.subject || - `Support Request${username ? ` From ${username}` : ""}`, - }, + Subject: { + Charset, + Data: `Support Request${name ? ` From ${name}` : ""}`, }, - ReplyToAddresses: [email], - Source: config.support, - }), - ); + }, + ReplyToAddresses: [oneLine(email)], + Source: config.support, + }), + ); - res.send({ ok: true }); - } else { - bail(res, "failed captcha"); - } + res.send({ ok: true }); } catch (e) { - console.log(e); + bail(res, e.message); } }, }; diff --git a/routes/info.ts b/routes/info.ts index c6d03e4d..4ab11872 100644 --- a/routes/info.ts +++ b/routes/info.ts @@ -1,9 +1,15 @@ import { archive } from "$lib/db"; -import { db, g } from "$lib/db"; +import { db } from "$lib/db"; +import { getHealthStatus } from "$lib/health"; import ln from "$lib/ln"; -import { getDecodedToken } from "@cashu/cashu-ts"; export default { + async health(_, res) { + const status = getHealthStatus(); + const httpStatus = status.healthy ? 200 : 503; + res.status(httpStatus).send(status); + }, + async balances(_, res) { let total = 0; @@ -21,13 +27,7 @@ export default { ); const lnwallet = parseInt(funds.outputs.reduce((a, b) => a + b.value, 0)); - const cash = getDecodedToken(await g("cash")).proofs.reduce( - (a, b) => a + b.amount, - 0, - ); - const info = { - cash, lnchannel, lnwallet, }; diff --git a/routes/invoices.ts b/routes/invoices.ts index ecac713c..b8d06bba 100644 --- a/routes/invoices.ts +++ b/routes/invoices.ts @@ -1,25 +1,30 @@ import config from "$config"; -import { db } from "$lib/db"; +import { db, g, gf, s } from "$lib/db"; import { generate } from "$lib/invoices"; import { err } from "$lib/logging"; -import { bail, fields, getInvoice, getUser } from "$lib/utils"; +import { bail, fail, fields, getInvoice, getUser } from "$lib/utils"; import rpc from "@coinos/rpc"; export default { async get(req, res) { - const { - params: { id }, - } = req; - const invoice = await getInvoice(id); + try { + const { + params: { id }, + } = req; + if (id === "undefined") fail("invalid id"); + const invoice = await getInvoice(id); - if (invoice) { - invoice.secret = undefined; - invoice.user = await getUser(invoice.uid, fields); + if (invoice) { + invoice.secret = undefined; + invoice.user = await getUser(invoice.uid, fields); - invoice.items ||= []; + invoice.items ||= []; + } + if (invoice) res.send(invoice); + else fail("invoice not found"); + } catch (e) { + bail(res, e.message); } - if (invoice) res.send(invoice); - else res.code(500).send("invoice not found"); }, async create(req, res) { @@ -28,17 +33,61 @@ export default { if (body.user) user = body.user; if (req.user.username === user.username) invoice.own = true; + else invoice.own = false; try { + // A supplied id that already exists is an UPDATE of that record — + // generate() rewrites it wholesale. Only the authenticated owner may do + // that. Without this, an anonymous pay-page session passing + // body.user = merchant could rewrite the amount/memo of a merchant's + // live invoice. generate() separately refuses to move an invoice to a + // different uid. + if (invoice?.id) { + const existing = await getInvoice(invoice.id); + if (existing && existing.uid !== req.user.id) fail("invoice not found"); + } const result = await generate({ invoice, user }); res.send(result); } catch (e) { - err( - "problem generating invoice", - req.user?.username, - body.user?.username, - e.message, - ); + bail(res, e.message); + } + }, + + async update(req, res) { + try { + const { id } = req.params; + const { body } = req; + const { tip, webhook, secret } = body.invoice; + + let invoice = await gf(`invoice:${id}`); + if (!invoice) fail("invoice not found"); + const user = await g(`user:${invoice.uid}`); + + if (typeof tip !== "undefined") { + const t = Number.parseInt(tip); + if (!Number.isSafeInteger(t) || t < 0 || t > 2_100_000_000_000_000) + fail("Invalid tip"); + // Defense-in-depth: never change the tip once the invoice is paid/ + // settled. The primary mint guard is on the credit side (it uses the + // tip the sender was actually debited); this stops post-settlement + // tampering. The endpoint stays open by design — payers set tips on + // merchants' invoices before paying — so we do NOT require ownership. + if (invoice.received > 0 || invoice.settled) + fail("Invoice already settled"); + invoice.tip = t; + } + + if (webhook && secret) { + if (invoice.uid !== req.user?.id) fail("Unauthorized"); + invoice.webhook = webhook; + invoice.secret = secret; + } + + invoice = await generate({ invoice, user }); + await s(`invoice:${id}`, invoice); + + res.send(invoice); + } catch (e) { bail(res, e.message); } }, @@ -46,13 +95,29 @@ export default { async list(req, res) { const { id } = req.user; let invoices = await db.lRange(`${id}:invoices`, 0, -1); - invoices = await Promise.all(invoices.map((i) => getInvoice(i))); + invoices = (await Promise.all(invoices.map((i) => getInvoice(i)))).filter( + Boolean, + ); res.send(invoices); }, async sign(req, res) { try { const { address, message, type = "bitcoin" } = req.body; + + // Sign only with an address the CALLER owns. Users have no per-user keys + // in this custodial design — every address belongs to the server hot + // wallet — so without this any authenticated account could have the node + // sign an arbitrary message under any address the wallet controls + // (another user's deposit address, a change address, a cold-storage + // address published as ours), which is a proof-of-control forgery + // primitive. The legitimate flow (coinos.io/sign, for Ocean offers that + // need a signable on-chain address) mints a fresh invoice address for + // the caller and posts that back, so it always satisfies this check. + const invoice = await getInvoice(address); + if (!invoice || (invoice.uid !== req.user.id && invoice.aid !== req.user.id)) + fail("unauthorized"); + const node = rpc(config[type]); if (config[type].walletpass) diff --git a/routes/lnurl.ts b/routes/lnurl.ts index 16f366d4..7f04db5d 100644 --- a/routes/lnurl.ts +++ b/routes/lnurl.ts @@ -1,20 +1,72 @@ -import { db, g, s } from "$lib/db"; +import { db, g, gf, s } from "$lib/db"; import { generate } from "$lib/invoices"; -import ln from "$lib/ln"; -import { err, warn } from "$lib/logging"; -import { serverPubkey } from "$lib/nostr"; -import { credit } from "$lib/payments"; -import { bail, fail, getInvoice, getUser } from "$lib/utils"; +import { err, quiet, suppressed, warn } from "$lib/logging"; +import { serverPubkey2 } from "$lib/nostr"; +import { SATS, bail, fail, getInvoice, getUser } from "$lib/utils"; import { bech32 } from "bech32"; -import got from "got"; +import { safeGot } from "$lib/safe-fetch"; +import { verifyEvent } from "nostr-tools"; import { v4 } from "uuid"; import { PaymentType } from "$lib/types"; +// A payRequest callback id is fetched once, minutes after the metadata +// request, and never again; without an expiry these one-shot pointers were +// ~45% of all keys in the main db. +const LNURL_TTL = 7 * 24 * 60 * 60; + const { URL } = process.env; const host = URL.split("/").at(-1); const fiveMinutes = 1000 * 60 * 5; +// Unauthenticated invoice generation (GET /pay/:user/:amount, GET /lnurl/:id) +// runs for whoever holds the link, so it needs a per-target budget: a scanner +// spread across a large proxy pool can mint junk invoices on many accounts +// (each a CLN invoice plus a :invoices entry) while staying invisible to +// per-IP limits. 120 fresh invoices per target per hour is far +// above any static-QR merchant's scan rate; reusing an existing invoice (the +// own-invoice path in pay(), the pinned invoice in lnurl()) doesn't count. +const unauthInvoiceBudget = async (uid: string) => { + const key = `invbudget:${uid}`; + const n = await db.incr(key); + if (n === 1) await db.expire(key, 3600); + if (n > 120) { + const q = quiet(`invbudget:${uid}`); + if (q !== null) warn("invoice budget exceeded", uid, n, suppressed(q)); + fail("Too many payment requests for this user, try again later"); + } +}; +// v3 payment addresses (coinos v3 / halwallet) are registered here. +const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io"; + +// Does the v3 registrar serve this name? Returns its payRequest, or null. +// Cached briefly: claims change on the order of days, and this sits on the +// payment path for every coinos.io address lookup. Only definitive answers +// (payRequest / 404) are cached — a sick registrar shouldn't be remembered. +const registrarCache = new Map(); +const REGISTRAR_TTL = 60_000; +async function registrarLookup(name) { + const hit = registrarCache.get(name); + if (hit && Date.now() - hit.at < REGISTRAR_TTL) return hit.body; + try { + const r = await fetch( + `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${host}`, + { signal: AbortSignal.timeout(3000) }, + ); + if (r.ok) { + const body: any = await r.json(); + if (body?.tag === "payRequest") { + registrarCache.set(name, { at: Date.now(), body }); + return body; + } + } + if (r.status === 404) registrarCache.set(name, { at: Date.now(), body: null }); + } catch (e) { + warn("names registrar lookup failed for", name, (e as any).message); + } + return null; +} + export default { async encode(req, res) { const { @@ -26,7 +78,7 @@ export default { .replace(/\s/g, "")}`; try { - const r = await got(url).json(); + const r = await safeGot(url); if (r.tag !== "payRequest") fail("not an ln address"); } catch (e) { const m = `failed to lookup lightning address ${address}`; @@ -47,7 +99,7 @@ export default { bech32.fromWords(bech32.decode(text, 20000).words), ).toString(); - const r = await got(url).json(); + const r = await safeGot(url); res.send(r); } catch (e) { bail(res, e.message); @@ -60,15 +112,36 @@ export default { query: { minSendable = 1000, maxSendable = 100000000000 }, } = req; try { - const user = await getUser( - username - .replace("lightning:", "") - .replace(/\s/g, "") - .replace("=", "") - .toLowerCase(), - ); + const name = username + .replace("lightning:", "") + .replace(/\s/g, "") + .replace("=", "") + .toLowerCase(); - if (!user) fail(`User ${username} not found`); + // Resolution order for [email protected] — this has flip-flopped twice, so + // the invariant, in full: + // 1. A name CLAIMED in the v3 registrar is served by it. That is where + // migrated accounts live — their legacy user record still exists + // here, so "local account exists" does NOT mean the name is ours — + // and also v3-native custom names, which have no local account and + // only a npub1* Cloudflare rule redirecting for them today. + // 2. Otherwise a local account is served locally. Never key this on + // `migrated`: register.ts stamps that flag on essentially every + // account (it means "don't reserve this name for v3", not "user + // moved away"). + // 3. Neither → not found. + // No loop: step 1 defers only for names the registrar HAS, and the + // registrar never queries this endpoint for those. If the registrar is + // unreachable we fall through and serve the local account — a briefly + // stale destination beats an unpayable address. + const v3 = await registrarLookup(name); + if (v3) return res.send(v3); + + const user = await getUser(name); + // Evicted accounts can't receive either: stops a lightning address that + // a scam page points victims at from issuing new invoices. + if (!user || (await db.sIsMember("evicted", user.id))) + fail(`User ${username} not found`); const { id: uid } = user; const metadata = JSON.stringify([ @@ -77,21 +150,24 @@ export default { ]); const id = v4(); - await s(`lnurl:${id}`, uid); + await s(`lnurl:${id}`, uid, LNURL_TTL); res.send({ allowsNostr: true, minSendable, maxSendable, metadata, - nostrPubkey: serverPubkey, + nostrPubkey: serverPubkey2, commentAllowed: 512, callback: `${URL}/api/lnurl/${id}`, tag: "payRequest", }); } catch (e) { - if (!e.message.includes("found")) - warn("problem generating lnurlp request", username, e.message); + if (!e.message.includes("found")) { + const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000); + if (n !== null) + warn("problem generating lnurlp request", username, e.message, suppressed(n)); + } bail(res, e.message); } }, @@ -102,22 +178,27 @@ export default { query: { amount, comment, nostr }, } = req; try { + const iid = await g(`lnurl:${id}:invoice`); const uid = await g(`lnurl:${id}`); const user = await getUser(uid); - if (!user) fail("user not found"); + + if (!user || (await db.sIsMember("evicted", user.id))) + fail("user not found"); let { username } = user; username = username.replace(/\s/g, "").toLowerCase(); - const memo = comment ?? `Paying ${username}@${host}`; let metadata = JSON.stringify([ - ["text/plain", memo], + ["text/plain", `Paying ${username}@${host}`], ["text/identifier", `${username}@${host}`], ]); if (nostr) { try { const event = JSON.parse(decodeURIComponent(nostr)); - // TODO: validate the event + // NIP-57: must be a signed kind-9734 zap request. Reject anything + // else so we can't be tricked into storing a forged zap receipt. + if (event.kind !== 9734 || !verifyEvent(event)) + throw new Error("invalid zap request"); await s(`zap:${id}`, event); metadata = nostr; } catch (e) { @@ -125,17 +206,44 @@ export default { } } - const { id: iid, text: pr } = await generate({ - invoice: { - amount: Math.round(amount / 1000), - memo: metadata, - type: PaymentType.lightning, - }, - user, + if (!iid) await unauthInvoiceBudget(user.id); + const invoice = iid + ? await gf(`invoice:${iid}`) + : await generate({ + invoice: { + amount: Math.round(amount / 1000), + memo: metadata, + type: PaymentType.lightning, + }, + user, + }); + + if (comment) { + // Enforce the commentAllowed:512 we advertise in the lnurlp response. + // This overwrite happens after generate()'s own memo validation, so an + // uncapped comment would otherwise reach credit() at settlement time. + invoice.memo = String(comment).slice(0, 512); + await s(`invoice:${invoice.id}`, invoice); + } + + res.send({ + pr: invoice.text, + routes: [], + verify: `${URL}/api/lnurl/verify/${invoice.id}`, }); + } catch (e) { + bail(res, e.message); + } + }, - res.send({ pr, routes: [], verify: `${URL}/api/lnurl/verify/${iid}` }); + async proxy(req, res) { + const { url } = req.query; + if (!url) return bail(res, "url required"); + try { + const r = await safeGot(url); + res.send(r); } catch (e) { + warn("lnurl proxy failed", url, e.message); bail(res, e.message); } }, @@ -154,29 +262,49 @@ export default { }, async pay(req, res) { - const { username } = req.params; + const { amount, username } = req.params; try { const user = await getUser(username); + if (!user) fail(`User ${username} not found`); const invoices = await db.lRange(`${user.id}:invoices`, 0, 10); let invoice; + for (const iid of invoices) { const i = await getInvoice(iid); + if (!i) continue; const paid = i.amount > 0 && i.received >= i.amount; const old = Date.now() - i.created > fiveMinutes; - if (i.own && !(paid || old)) { + if (paid) break; + if (i.own && !old) { invoice = i; break; } } - if (!invoice) + if (invoice) { + if (amount?.startsWith("+")) { + await unauthInvoiceBudget(user.id); + const tip = invoice.amount * amount.split("+")[1]; + invoice = await generate({ + invoice: { + ...invoice, + tip, + }, + user, + }); + } + } else { + await unauthInvoiceBudget(user.id); invoice = await generate({ invoice: { + amount, + prompt: user.prompt, type: PaymentType.lightning, }, user, }); + } const { id: uid } = user; @@ -186,71 +314,30 @@ export default { ]); const id = v4(); - await s(`lnurl:${id}`, uid); - const total = (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000; + + const total = + (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000; + + await s(`lnurl:${id}`, uid, LNURL_TTL); + if (total > 0) await s(`lnurl:${id}:invoice`, invoice.id, LNURL_TTL); res.send({ allowsNostr: true, - minSendable: total, - maxSendable: invoice.amount ? total : undefined, + minSendable: invoice.amount ? total : 1000, + maxSendable: invoice.amount ? total : 10 * 1000 * SATS, metadata, - nostrPubkey: serverPubkey, + nostrPubkey: serverPubkey2, commentAllowed: 512, callback: `${URL}/api/lnurl/${id}`, tag: "payRequest", }); } catch (e) { - if (!e.message.includes("found")) - warn("problem generating lnurlp request", username, e.message); + if (!e.message.includes("found")) { + const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000); + if (n !== null) + warn("problem generating lnurlp request", username, e.message, suppressed(n)); + } bail(res, e.message); } }, - - // async withdraw(req, res) { - // try { - // const { pr, k1: id } = req.query; - // const invoice = await getInvoice(pr); - // const { amount_msat } = await ln.decode(pr); - // const amount = Math.ceil(amount_msat / 1000); - // await db.debit(`fund:${id}`, "", amount, 0, 0, 0); - // let p; - // if (invoice) { - // const { hash } = invoice; - // p = await credit({ - // hash, - // amount, - // memo: id, - // ref: id, - // type: PaymentType.fund, - // }); - // } else { - // await ln.xpay({ - // invstring: pr.replace(/\s/g, "").toLowerCase(), - // maxfee: 0, - // retry_for: 10, - // }); - // const rates = await g("rates"); - // p = { - // id: v4(), - // amount: -amount, - // hash: pr, - // confirmed: true, - // rate: rates.USD, - // currency: "USD", - // type: PaymentType.fund, - // ref: id, - // created: Date.now(), - // }; - // await s(`payment:${p.id}`, p); - // await s(`payment:${pr}`, p); - // } - // - // await db.lPush(`fund:${id}:payments`, p.id); - // res.send({ status: "OK" }); - // } catch (e) { - // console.log(e); - // warn("lnurlw failed", e.message); - // res.send({ status: "ERROR", reason: "Withdrawal failed" }); - // } - // }, }; diff --git a/routes/nostr.ts b/routes/nostr.ts index 9c61bf16..3b28076a 100644 --- a/routes/nostr.ts +++ b/routes/nostr.ts @@ -21,6 +21,36 @@ import { decode } from "nostr-tools/nip19"; import type { ProfilePointer } from "nostr-tools/nip19"; import { getZapEndpoint, makeZapRequest } from "nostr-tools/nip57"; +// Amount and payer of a zap receipt. NIP-57 kind 9735: amount comes from +// decoding the paid bolt11, payer from the embedded 9734 zap request in the +// description tag. NIP-177 kind 9736 (BOLT12 zaps): amount is an explicit +// msat tag, and the receipt is payer-published with the signed 9737 intent in +// the description tag. +const parseZap = async (ev) => { + const { kind, tags, pubkey } = ev; + const description = tags.find((t) => t[0] === "description")?.[1]; + + let payer; + try { + payer = JSON.parse(description).pubkey; + } catch (e) {} + payer ||= pubkey; + + let amount = 0; + if (kind === 9736) { + const msat = Number.parseInt(tags.find((t) => t[0] === "amount")?.[1]); + if (msat > 0) amount = Math.round(msat / 1000); + } else { + try { + const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1]; + const { amount_msat } = await ln.decode(bolt11); + if (amount_msat) amount = Math.round(amount_msat / 1000); + } catch (e) {} + } + + return { amount, pubkey: payer }; +}; + export default { async event(req, res) { try { @@ -35,6 +65,7 @@ export default { if (event) return res.send(event); event = await get({ ids: [id] }); + if (!event) return res.code(404).send("Not found"); if (!full) return res.send(event); const parts = parseContent(event); @@ -45,23 +76,14 @@ export default { ) .map(({ value }) => value.pubkey); - const zapEvents = await scan({ kinds: [9735], "#e": [id] }); + const zapEvents = await scan({ kinds: [9735, 9736], "#e": [id] }); const zaps = []; - for (const { tags } of zapEvents) { - const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1]; - const description = tags.find((t) => t[0] === "description")?.[1]; - const { pubkey } = JSON.parse(description); - let amount = 0; - try { - const { amount_msat } = await ln.decode(bolt11); - if (amount_msat) amount = Math.round(amount_msat / 1000); - } catch (e) {} - - zaps.push({ amount, pubkey }); + for (const zapEvent of zapEvents) { + zaps.push(await parseZap(zapEvent)); } pubkeys.push(event.pubkey); - pubkeys.push(...zaps.map((z) => z.pubkey)); + pubkeys.push(...zaps.map((z) => z.pubkey).filter((p) => p)); pubkeys = [...new Set(pubkeys)]; const profiles = await scan({ kinds: [0], authors: pubkeys }); const found = profiles.map((p) => p.pubkey); @@ -107,7 +129,6 @@ export default { res.send(event); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -158,7 +179,6 @@ export default { res.send(thread); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -168,24 +188,16 @@ export default { let { id } = req.params; if (id.startsWith("nevent")) id = decode(id).data.id; if (id.startsWith("note")) id = decode(id).data; - const filter = { kinds: [9735], "#e": [id] }; - let events = await scan(filter); - if (!events.length) { - await sync("wss://relay.primal.net", filter); - events = await scan(filter); - } + // q() scans the local strfry and falls back to primal (the old code + // called a `sync` helper that never existed and threw ReferenceError + // whenever the local relay had no receipts) + const filter = { kinds: [9735, 9736], "#e": [id] }; + const events = await q(filter); if (!events.length) return res.send([]); const zaps = []; - for (const { tags } of events) { - const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1]; - const description = tags.find((t) => t[0] === "description")?.[1]; - const { pubkey } = JSON.parse(description); - let amount = 0; - try { - const { amount_msat } = await ln.decode(bolt11); - if (amount_msat) amount = Math.round(amount_msat / 1000); - } catch (e) {} + for (const zapEvent of events) { + const { amount, pubkey } = await parseZap(zapEvent); let user; if (pubkey) { @@ -198,7 +210,6 @@ export default { res.send(zaps.filter((z) => z.amount > 0)); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -217,7 +228,6 @@ export default { res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -244,7 +254,6 @@ export default { res.send(events); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -319,7 +328,6 @@ export default { res.send(followers); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -330,7 +338,6 @@ export default { // res.send({ followers: 0, follows: 0 }); res.send(await getCount(pubkey)); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -341,7 +348,21 @@ export default { } = req; let names = {}; if (name) { - names = { [name]: (await getUser(name, fields)).pubkey }; + // v3 registrar first: a claimed name's NIP-05 belongs to its wallet key. + // Legacy accounts answer as before when the registrar doesn't know it. + try { + const r = await fetch( + `${process.env.NAMES_URL || "https://names.coinos.io"}/.well-known/nostr.json?name=${encodeURIComponent(name)}`, + { signal: AbortSignal.timeout(3000) }, + ); + if (r.ok) { + const j = await r.json(); + if (j?.names?.[name]) return res.send({ names: { [name]: j.names[name] } }); + } + } catch {} + const u = await getUser(name, fields); + if (!u) return res.send({ names: {} }); // unknown name: empty per NIP-05, not a 500 + names = { [name]: u.pubkey }; } else { const records = await db.sMembers("nip5"); for (const s of records) { @@ -379,9 +400,7 @@ export default { }); res.send(event); - } catch (e) { - console.log(e); - } + } catch (e) {} }, async zap(req, res) { @@ -399,7 +418,6 @@ export default { res.send(json); } catch (e) { - console.log(e); bail(res, e.message); } }, diff --git a/routes/payments.ts b/routes/payments.ts index 66a19e53..cfb34af7 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -1,9 +1,12 @@ import config from "$config"; import api from "$lib/api"; import { requirePin } from "$lib/auth"; -import { db, g, s } from "$lib/db"; +import { archive, db, g, gf, s, sa } from "$lib/db"; +import { generate, getUserOffer } from "$lib/invoices"; +import { replay } from "$lib/lightning"; import ln from "$lib/ln"; -import { err, l, warn } from "$lib/logging"; +import { platformFee, quoteMax, quoteRoutingFee } from "$lib/lnquote"; +import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging"; import mqtt from "$lib/mqtt"; import { build, @@ -31,6 +34,19 @@ import rpc from "@coinos/rpc"; import got from "got"; import { v4 } from "uuid"; +// Funds are closed. The mechanism let a caller pull unbacked balance out of a +// fund (the /take drain, 2026-08-25) and its deposit path never had a +// kill-switch of its own, so with `fund:disabled` set users could still pay +// INTO funds they were then unable to withdraw from. Every value-moving entry +// point — deposit (POST /payments with `fund`), withdrawal (POST /take) and +// pre-authorization (POST /authorize) — now refuses unconditionally, rather +// than depending on a redis flag someone can clear by accident. +// +// Read paths are deliberately left working: GET /fund/:id and the manager +// endpoints still serve existing funds so balances stay visible and can be +// settled out of band. +const fundsClosed = () => fail("Funds are no longer supported"); + export default { async info(_, res) { res.send(await ln.getinfo()); @@ -43,8 +59,21 @@ export default { const balance = await g(`balance:${user.id}`); try { + // Reject non-string payreq/hash up front. A client sending a number/object + // here otherwise reaches a decoder that calls `.startsWith` on it and throws + // an opaque "n.startsWith is not a function" (logged bare via the catch + // below). A malformed *string* is fine — it fails later with a clear error. + if (payreq != null && typeof payreq !== "string") + fail("Invalid payment request"); + if (hash != null && typeof hash !== "string") fail("Invalid invoice"); + // Invoices are keyed lowercase. An uppercase payreq (QR-scanned) missed + // the lookup below and was paid over cl to ourselves instead of + // internally; the listener then found the sender's payment:<bolt11> + // pointer and skipped crediting the recipient. + if (payreq) payreq = payreq.replace(/\s/g, "").toLowerCase(); + if (typeof amount !== "undefined") { - amount = parseInt(amount); + amount = Number.parseInt(amount); if (amount < 0 || amount > SATS || Number.isNaN(amount)) fail("Invalid amount"); } @@ -56,7 +85,7 @@ export default { const invoice = await getInvoice(payreq || hash); const recipient = invoice ? await getUser(invoice.uid) : undefined; if (payreq) { - if (invoice && recipient.username !== "mint") { + if (invoice) { if (invoice.aid === user.id) fail("Cannot send to self"); hash = payreq; if (!amount) ({ amount } = invoice); @@ -75,23 +104,19 @@ export default { sender: user, }); } else if (fund) { - p = await debit({ - hash, - amount, - memo: fund, - user, - type: PaymentType.fund, - }); - await db.incrBy(`fund:${fund}`, amount); - await db.lPush(`fund:${fund}:payments`, p.id); - l("funded fund", fund); + fundsClosed(); } } res.send(p); } catch (e) { - warn(user.username, "payment failed", amount, balance, hash, payreq); - err(e.message); + // One line per account per 5 min: frozen/empty throwaway accounts retry + // failed sends in a loop and otherwise dominate the log. + const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000); + if (n !== null) { + warn(user.username, "payment failed", amount, balance, hash, payreq, suppressed(n)); + err(shortError(e.message)); + } bail(res, e.message); } }, @@ -99,25 +124,42 @@ export default { async list(req, res) { let { user: { id }, - query: { aid, start, end, limit, offset }, + query: { aid, start, end, limit, offset, received }, } = req; if (!aid || aid === "undefined") aid = id; - limit = parseInt(limit); - offset = parseInt(offset) || 0; + const index = await db.lPos(`${id}:accounts`, aid); + if (index === null) fail("unauthorized"); - const range = !limit || start || end ? -1 : limit - 1; - let payments = (await db.lRange(`${aid || id}:payments`, 0, range)) || []; + limit = Number.parseInt(limit); + offset = Number.parseInt(offset) || 0; + + const range = !limit || received || start || end ? -1 : limit - 1; + const listKey = `${aid || id}:payments`; + let payments = (await db.lRange(listKey, 0, range)) || []; + + if (range === -1) { + const archived = (await archive.lRange(listKey, 0, -1)) || []; + payments = [...new Set([...payments, ...archived])]; + } else if (limit) { + const needed = Math.max(0, limit + offset - payments.length); + if (needed > 0) { + const archived = + (await archive.lRange(listKey, 0, limit + offset - 1)) || []; + payments = [...new Set([...payments, ...archived])]; + } + } payments = ( await Promise.all( payments.map(async (pid) => { - const p = await g(`payment:${pid}`); + const p = await gf(`payment:${pid}`); if (!p) { warn("user", id, "missing payment", pid); - await db.lRem(`${aid || id}:payments`, 0, pid); - return p; + return; } + if (p.revertedDuplicate) return; + if (received && p.amount < 0) return; if (p.created < start || p.created > end) return; if (p.type === PaymentType.internal) p.with = await getUser(p.ref, fields); @@ -133,7 +175,7 @@ export default { [b.currency]: { tips: (a[b.currency] ? a[b.currency].tips : 0) + (b.tip || 0), fiatTips: ( - parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) + + Number.parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) + ((b.tip || 0) * b.rate) / SATS ).toFixed(2), sats: @@ -143,7 +185,7 @@ export default { (b.fee || 0) - (b.ourfee || 0), fiat: ( - parseFloat(a[b.currency] ? a[b.currency].fiat : 0) + + Number.parseFloat(a[b.currency] ? a[b.currency].fiat : 0) + (((b.amount || 0) + ((b.amount > 0 ? b.tip : -b.tip) || 0) - (b.fee || 0) - @@ -171,9 +213,9 @@ export default { const p = await getPayment(hash); if (p?.type === PaymentType.internal) p.with = await getUser(p.ref, fields); + if (p?.type === PaymentType.fund) p.with = await getUser(p.uid, fields); res.send(p); } catch (e) { - console.log(e); err("failed to get payment", e.message); bail(res, e.message); } @@ -185,14 +227,19 @@ export default { user, } = req; try { + // Node alias lookup, refreshed hourly. Only the id→alias map is kept: + // the old code stored the full listnodes output (~10MB for 16k nodes) + // as an array, so the `last` stamp it hung on the array was dropped by + // JSON.stringify and the condition was inverted anyway — every send + // page view refetched and rewrote the whole thing. const hour = 1000 * 60 * 60; - let nodes = await g("nodes"); - const { last } = nodes || {}; - - if (!last || last > Date.now() - hour) { - ({ nodes } = await ln.listnodes()); - nodes.last = Date.now(); - await s("nodes", nodes); + let cache = await g("nodes"); + if (!cache?.aliases || !cache.last || cache.last < Date.now() - hour) { + const { nodes } = await ln.listnodes(); + const aliases = {}; + for (const n of nodes) if (n.alias) aliases[n.nodeid] = n.alias; + cache = { last: Date.now(), aliases }; + await s("nodes", cache); } const decoded = await ln.decode(payreq); @@ -200,23 +247,30 @@ export default { let amount_msat; let payee; - if (decoded.type.includes("bolt12")) { + if (decoded.type === "bolt12 offer") { + ({ offer_amount_msat: amount_msat } = decoded); + payee = decoded.offer_issuer_id || decoded.offer_node_id; + } else if (decoded.type.includes("bolt12")) { ({ invoice_amount_msat: amount_msat, invoice_node_id: payee } = decoded); } else ({ amount_msat, payee } = decoded); - const node = nodes.find((n) => n.nodeid === payee); - const alias = node ? node.alias : payee.substr(0, 12); + const alias = cache.aliases[payee] || (payee || "").substr(0, 12); - const amount = Math.round(amount_msat / 1000); + const amount = Math.round((amount_msat || 0) / 1000); let ourfee = Math.round(amount * config.fee[PaymentType.lightning]); const credit = await g(`credit:lightning:${user.id}`); const covered = Math.min(credit, ourfee) || 0; ourfee -= covered; - res.send({ alias, amount, ourfee }); + res.send({ + alias, + amount, + ourfee, + type: decoded.type, + description: decoded.offer_description || decoded.description, + }); } catch (e) { - console.log(e); err("problem parsing", e.message); bail(res, e.message); } @@ -224,20 +278,47 @@ export default { async fund(req, res) { const { - params: { name }, + params: { id }, } = req; - const amount = await g(`fund:${name}`); + let amount = await g(`fund:${id}`); + let fid = id; + + // A rotated fund no longer exists at its OLD id (security fix 2026-08-25: + // fund secret uids were rotated to invalidate an exfiltrated id list). Serve + // the new fund ONLY to an authenticated MANAGER of it — never anonymously or + // to a non-owner. An open old->new redirect would defeat the rotation, since + // the attacker holds the leaked OLD ids; gating on manager membership means a + // replayed old id reveals nothing unless you already control the fund. The + // route's `optional` auth populates req.user without requiring it. Bearer + // funds (no managers) are intentionally not recoverable this way. + if (typeof amount === "undefined" || amount === null) { + const rotatedTo = await g(`fund:rotated:${id}`); + const uid = req.user?.id; + if (rotatedTo && uid && (await db.sIsMember(`fund:${rotatedTo}:managers`, uid))) { + fid = rotatedTo; + amount = await g(`fund:${rotatedTo}`); + } + } + if (typeof amount === "undefined" || amount === null) return bail(res, "fund not found"); - let payments = (await db.lRange(`fund:${name}:payments`, 0, -1)) || []; - payments = await Promise.all(payments.map((hash) => g(`payment:${hash}`))); + + let payments = (await db.lRange(`fund:${fid}:payments`, 0, -1)) || []; + payments = await Promise.all(payments.map((hash) => gf(`payment:${hash}`))); await Promise.all( payments.map(async (p: any) => (p.user = await getUser(p.uid, fields))), ); payments = payments.filter((p) => p); - res.send({ amount, payments }); + + const authorization = await g(`authorization:${fid}`); + res.send({ + amount, + authorization: authorization?.amount, + payments, + ...(fid !== id ? { id: fid, rotatedFrom: id } : {}), + }); }, // async withdraw(req, res) { @@ -258,38 +339,134 @@ export default { // }); // }, + async authorize(req, res) { + fundsClosed(); + + const { id: uid } = req.user; + const { id, fiat, currency, amount } = req.body; + + const managers = await db.sMembers(`fund:${id}:managers`); + if (managers.length && !managers.includes(uid)) fail("Unauthorized"); + + // The authorization's fiat/currency is the ONLY ceiling on how much a later + // /take can pull from the authorizer (cap = sats(fiat / rates[currency])). + // Reject a NaN/zero/non-finite rate or a non-positive fiat so the ceiling + // can never be turned into an astronomically large or non-finite value. + const rates = await g("rates"); + const rate = Number(rates?.[currency]); + const fiatAmount = Number(fiat); + if (!Number.isFinite(rate) || rate <= 0) fail("Invalid currency"); + if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) fail("Invalid amount"); + + const authorization = { + uid, + currency, + fiat: fiatAmount, + amount: Number.parseInt(amount) || 0, + }; + + await s(`authorization:${id}`, authorization); + res.send({}); + }, + async take(req, res) { let { body: { id, amount, invoice: iid }, user, } = req; try { - const { id: uid } = user; - amount = parseInt(amount); - if (amount < 0) fail("Invalid amount"); - - const managers = await db.sMembers(`fund:${id}:managers`); - if (managers.length && !managers.includes(user.id)) fail("Unauthorized"); + fundsClosed(); - const result: any = await db.debit(`fund:${id}`, "", "Insufficient funds", amount, 0, 0, 0, 0); - if (result.err) fail(result.err); + amount = Number.parseInt(amount); + if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount"); const rates = await g("rates"); - const { currency } = user; - const rate = rates[currency]; if (!iid) { - iid = v4(); - await s(`invoice:${iid}`, { - currency, - id: iid, - hash: iid, - rate, - uid, - received: 0, + const inv = await generate({ + invoice: { amount, type: "lightning" }, + user, }); + iid = inv.id; } + const authorization = await g(`authorization:${id}`); + if (authorization && !authorization.claimed) { + const { currency, fiat } = authorization; + // Bound the take to the authorized fiat value using a VALIDATED fiat + // rate. A non-finite/zero rate or a crypto-denominated authorization + // (rate ~1) would otherwise make sats(fiat / rate) an astronomically + // large ceiling, defeating the cap (the drain vector). + const rate = Number(rates?.[currency]); + const fiatAmount = Number(fiat); + if (!Number.isFinite(rate) || rate <= 0) + fail("Invalid authorization currency"); + if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) + fail("Invalid authorization amount"); + const cap = sats(fiatAmount / rate); + if (!Number.isFinite(cap) || cap <= 0) fail("Invalid authorization"); + amount = Math.min(amount, cap); + if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount"); + + // Atomic single-use claim. The read above is not a lock: concurrent + // POST /take for the same authorization all pass the `!claimed` gate and, + // without an atomic gate, each would debit the authorizer and fund the + // pool — redeeming a single-use authorization N times (COINOS-3). SET NX + // lets exactly one caller win the claim; the rest skip the funding. + const claimed = await db.set(`authorization:${id}:claimed`, user.id, { + NX: true, + }); + if (claimed) { + // Mark the record claimed only once the funding has actually landed, + // and release the claim if it throws — otherwise a failed funding + // (an insufficient balance, a server limit) burns the authorization + // permanently: the key is set, the fund never gets the money, and + // every later /take skips the funding block. + try { + const sender = await getUser(authorization.uid); + if (!sender) fail("authorizer not found"); + + const { hash } = await generate({ + invoice: { amount, type: "lightning" }, + user: sender, + }); + + const { id: pid } = await debit({ + hash, + amount, + memo: id, + user: sender, + type: PaymentType.fund, + }); + + await db.incrBy(`fund:${id}`, amount); + await db.lPush(`fund:${id}:payments`, pid); + + authorization.claimed = true; + await s(`authorization:${id}`, authorization); + l("funded fund", id); + } catch (e) { + await db.del(`authorization:${id}:claimed`); + throw e; + } + } + } + + const managers = await db.sMembers(`fund:${id}:managers`); + if (managers.length && !managers.includes(user.id)) fail("Unauthorized"); + + const result: any = await db.debit( + `fund:${id}`, + "", + "Insufficient funds", + amount, + 0, + 0, + 0, + 0, + ); + if (result.err) fail(result.err); + const payment = await credit({ aid: user.id, hash: iid, @@ -301,7 +478,7 @@ export default { await db.lPush(`fund:${id}:payments`, payment.id); - res.send({ payment }); + res.send(payment); } catch (e) { warn("problem withdrawing from fund", user.username, e.message); bail(res, e.message); @@ -313,9 +490,9 @@ export default { const ids = await db.sMembers(`fund:${name}:managers`); - const managers = await Promise.all( - ids.map(async (id) => await getUser(id)), - ); + const managers = (await Promise.all( + ids.map(async (id) => await getUser(id, fields)), + )).filter(Boolean); res.send(managers); }, @@ -333,17 +510,42 @@ export default { await db.sAdd(k, user.id); } - const u = await getUser(username); + const u = await getUser(username, fields); if (!u) fail("User not found"); const { id: uid } = u; await db.sAdd(k, uid); const ids = await db.sMembers(k); - if (!managers.length) - managers = await Promise.all(ids.map(async (id) => await getUser(id))); + const resolved = (await Promise.all( + ids.map(async (id) => await getUser(id, fields)), + )).filter(Boolean); - res.send(managers); + res.send(resolved); + }, + + async deleteManager(req, res) { + try { + const { name } = req.params; + const { id: uid } = req.body; + const { user } = req; + + const k = `fund:${name}:managers`; + let managers = await db.sMembers(k); + + if (managers.length) { + if (!managers.includes(user.id)) fail("Unauthorized"); + } + + await db.sRem(k, uid); + + const ids = await db.sMembers(k); + managers = await Promise.all( + ids.map(async (id) => await getUser(id, fields)), + ); + + res.send(managers); + } catch (e) {} }, async confirm(req, res) { @@ -358,11 +560,34 @@ export default { let aid; if (!hot) aid = wallet; + // Change-recredit guard (2026-08-18). If our own wallet contributed inputs + // to this transaction, a "send" detail is present. In that case every + // receive output is our own CHANGE or an internal move — never an external + // deposit — so it must not be credited: doing so mints phantom balance + // (the withdrawal-change re-credit exploit that drove the L-C creep). A + // genuine coinos->coinos transfer is settled off-chain in the send path + // (credit() resolves getInvoice(hash) and books an internal transfer), so + // an on-chain receive funded by our own spend is only ever change. + const weSpent = details.some((d: any) => d.category === "send"); + for (const { address, amount, asset, category, vout } of details) { if (!address) continue; if (type === PaymentType.liquid && asset !== config.liquid.btc) continue; + // Kill switch: `liquid:deposits:disabled` stops every Liquid receive + // from being credited or confirmed (sends still get their + // confirmation bookkeeping below). Set during a security incident; + // clear the key to re-enable. + if ( + type === PaymentType.liquid && + category !== "send" && + (await g("liquid:deposits:disabled")) + ) { + warn("liquid deposit blocked (disabled)", txid, vout, sats(amount), address); + continue; + } + if (category === "send") { const p = await getPayment(txid); if (!p) continue; @@ -379,11 +604,42 @@ export default { continue; } + // See weSpent above: a receive output on a transaction our wallet funded + // is change, not a deposit. Skip it so we never re-credit our own funds. + // Log the blocked amount as a tripwire: sums the phantom credit prevented + // and surfaces any continued exploitation attempts. + if (weSpent) { + if (sats(amount) >= 300) + warn("blocked change re-credit", txid, vout, sats(amount), address); + continue; + } + const p = await getPayment(`${txid}:${vout}`); + // A pointer whose payment record is gone (account deleted, record + // overwritten) means this output was already credited once. Never + // treat it as a first sighting. + if ( + !p && + ((await db.exists(`payment:${txid}:${vout}`)) || + (await db.exists(`credited:${txid}:${vout}`))) + ) { + warn("blocked re-credit of seen output", txid, vout, sats(amount), address); + continue; + } + if (!p) { const invoice = await getInvoice(address); if (!hot && aid !== invoice?.aid) continue; + if (sats(amount) < 300) continue; + // Atomic first-seen guard (COINOS-2). The getPayment() read above is not + // a lock: concurrent callers (walletnotify, catchUp, bulk /confirm + // sweeps — /confirm is unauthenticated) can all read p==null for one + // deposit and each run credit(), double-crediting the pending balance + // AND inflating the spendable credit:<net>:<uid> fee accumulator. Mirror + // the confirm-stage lock so only the first caller credits. + const firstlock = `firstseenlock:${txid}:${vout}`; + if (!(await db.set(firstlock, "1", { NX: true, EX: 60 }))) continue; await credit({ hash: address, amount: sats(amount), @@ -393,15 +649,30 @@ export default { }); } else if (confirmations >= 1) { const id = `payment:${txid}:${vout}`; + // Atomic guard against concurrent /confirm callers double-crediting: + // walletnotify, catchUp, and bulk sweeps can all fire for the same + // txid:vout in parallel. Without this, two callers can each read + // p.confirmed=false and each run the multi() that increments balance. + const lockKey = `confirmlock:${txid}:${vout}`; + const acquired = await db.set(lockKey, "1", { NX: true, EX: 60 }); + if (!acquired) continue; + const p = await getPayment(`${txid}:${vout}`); - if (!p) return db.sAdd("missed", id); - if (p.confirmed) return; + if (!p) { + await db.sAdd("missed", id); + await db.del(lockKey); + return; + } + if (p.confirmed) { + await db.del(lockKey); + return; + } const invoice = await getInvoice(address); const { id: iid } = invoice; p.confirmed = true; - invoice.received += parseInt(invoice.pending); + invoice.received += Number.parseInt(invoice.pending); invoice.pending = 0; l("confirming", id, p.id, p.amount); @@ -414,13 +685,20 @@ export default { .incrBy(`balance:${p.aid || p.uid}`, p.amount) .exec(); + // Mirror the now-confirmed record into arc so a future bulk sweep + // finds it via gf() fallback even if the main-db pointer is wiped + // (see feedback_apr29_double_credit_incident.md). + await sa(`payment:${p.id}`, p); + await sa(`invoice:${iid}`, invoice); + + await db.del(lockKey); + const user = await g(`user:${p.uid}`); await completePayment(invoice, p, user); } } res.send({}); } catch (e) { - console.log(e); warn(`problem processing ${txid}`); bail(res, e.message); } @@ -454,7 +732,9 @@ export default { res.send(p); } catch (e) { - warn(user.username, "payment failed", e.message); + const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000); + if (n !== null) + warn(user.username, "payment failed", shortError(e.message), suppressed(n)); res.code(500).send(e.message); } }, @@ -464,11 +744,10 @@ export default { body: { secret }, } = req; try { - if (secret !== config.adminpass) fail("unauthorized"); + if (!config.adminpass || secret !== config.adminpass) fail("unauthorized"); await s("freeze", true); res.send("ok"); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -479,7 +758,8 @@ export default { user, } = req; try { - const p = await g(`payment:${id}`); + const p = await gf(`payment:${id}`); + if (!p) fail("Payment not found"); if (p.uid !== user.id) fail("unauthorized"); emit(user.id, "payment", p); @@ -488,6 +768,7 @@ export default { mqtt.publish( username, `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}`, + { qos: 1 }, ); res.send({ ok: true }); @@ -498,10 +779,11 @@ export default { async lnaddress(req, res) { let { - params: { lnaddress, amount, fee = 5000 }, + params: { lnaddress, amount }, body, user, } = req; + const { fee } = body; try { lnaddress = decodeURIComponent(lnaddress); await requirePin({ body, user }); @@ -525,32 +807,22 @@ export default { let p; if (payee === id) { p = await debit({ hash: pr, amount, memo, user }); - await credit({ hash: pr, amount, memo, ref: user.id }); + await credit({ hash: pr, amount, memo, ref: user.id, tip: p.tip }); } else p = await sendLightning({ user, pr, amount, fee, memo }); res.send(p); } catch (e) { - console.log(e); bail(res, e.message); } }, - async gateway(req, res) { - const { - body: { short_channel_id, webhook }, - } = req; - - await s(short_channel_id, webhook); - res.send({ ok: true }); - }, - async replace(req, res) { const { body: { id }, user, } = req; try { - const p = await g(`payment:${id}`); + const p = await gf(`payment:${id}`); if (!p) fail("Payment not found"); if (p.uid !== user.id) fail("unauthorized"); @@ -610,8 +882,191 @@ export default { res.send(await ln.decode(bolt11)); }, + // Up-front cost of a lightning send: the routing fee askrene quotes for this + // invoice (the same answer xpay will get) plus the platform fee debit() will + // charge. With `max`, instead solve for the largest amount whose amount + + // fees exactly clears the account balance — the "send everything" case where + // guessing a maxfee always left dust behind or bounced on insufficient funds. + async quote(req, res) { + const { body, user } = req; + try { + let { payreq, amount, max, aid, ceiling } = body; + if (typeof payreq !== "string" || !payreq.trim()) + fail("Invalid payment request"); + payreq = payreq.replace(/\s/g, "").toLowerCase(); + + if (!aid) aid = user.id; + else if (typeof aid !== "string") fail("Invalid account"); + else if (aid !== user.id) { + const pos = await db.lPos(`${user.id}:accounts`, aid); + if (pos == null) fail("account not found"); + } + + const parseSats = (v, name) => { + if (v === undefined || v === null || v === "") return undefined; + const n = Number.parseInt(v); + if (Number.isNaN(n) || n < 0 || n > SATS) fail(`Invalid ${name}`); + return n; + }; + amount = parseSats(amount, "amount"); + ceiling = parseSats(ceiling, "ceiling"); + const balance = Number.parseInt(await g(`balance:${aid}`)) || 0; + + // Paying another coinos user never leaves the ledger: no routing, no + // platform fee, the whole balance is sendable. + const invoice = await getInvoice(payreq); + if (invoice) { + const recipient = await getUser(invoice.uid); + if (recipient?.username !== "mint") { + const a = max ? balance : (amount ?? invoice.amount); + return res.send({ + amount: a, + fee: 0, + ourfee: 0, + total: a, + balance, + internal: true, + }); + } + } + + let decoded = await ln.decode(payreq); + let pr = payreq; + let fetched: string | undefined; + + // An offer isn't routable by itself — the blinded paths live in the + // invoice fetched from it. Fetch one to learn them; in max mode that's a + // placeholder amount, and the final invoice is fetched once we know the + // real one so the caller can pay exactly what we quoted. + const fetchFromOffer = async (a: number) => { + const { invoice } = await ln.fetchinvoice({ + offer: payreq, + amount_msat: decoded.offer_amount_msat ? undefined : a * 1000, + timeout: 60, + }); + return invoice.replace(/\s/g, "").toLowerCase(); + }; + + if (decoded.type === "bolt12 offer") { + if (decoded.offer_currency) + fail("Currency-denominated offers are not supported"); + const offerAmount = decoded.offer_amount_msat + ? Math.round(decoded.offer_amount_msat / 1000) + : undefined; + if (offerAmount) max = false; + const a = offerAmount ?? amount ?? (max ? balance : undefined); + if (!a) fail("Amount required"); + pr = fetched = await fetchFromOffer(a); + decoded = await ln.decode(pr); + } + + let invAmount: number | undefined; + if (decoded.type === "bolt12 invoice") + invAmount = decoded.invoice_amount_msat + ? Math.round(decoded.invoice_amount_msat / 1000) + : undefined; + else + invAmount = decoded.amount_msat + ? Math.round(decoded.amount_msat / 1000) + : undefined; + + if (max) { + // The invoice's own amount is ignored here: an LNURL caller has to + // name an amount to get any invoice at all, so it probes with one at + // the ceiling, takes the amount we solve for, and fetches the real + // invoice for that. Anything that pays a fixed-amount invoice with + // a different amount fails at send time, not here. + const q = await quoteMax({ pr, uid: user.id, aid, ceiling }); + // Offer: the placeholder invoice carried the balance, not the answer. + if (fetched && q.amount !== invAmount) { + fetched = await fetchFromOffer(q.amount); + // Re-quote against the real invoice: same paths, so same fee, but + // never hand back a number we didn't verify against what gets paid. + const { fee } = await quoteRoutingFee({ pr: fetched, amount: q.amount }); + if (fee !== q.fee) fail("Routing fee changed, please try again"); + } + l("quote max", user.username, q.amount, "fee", q.fee, "ourfee", q.ourfee, "balance", q.balance); + return res.send({ ...q, ...(fetched ? { payreq: fetched } : {}) }); + } + + const a = invAmount ?? amount; + if (!a) fail("Amount required"); + if (invAmount && amount && amount !== invAmount) + fail("Amount does not match invoice"); + const { fee, parts } = await quoteRoutingFee({ pr, amount: a }); + const ourfee = await platformFee({ uid: user.id, aid, amount: a, fee }); + l("quote", user.username, a, "fee", fee, "ourfee", ourfee, "parts", parts); + res.send({ + amount: a, + fee, + ourfee, + parts, + total: a + fee + ourfee, + balance, + ...(fetched ? { payreq: fetched } : {}), + }); + } catch (e) { + warn("problem quoting", user.username, shortError(e.message)); + bail(res, e.message); + } + }, + + // The user's standing bolt12 offer (lno1...) — reusable receive code they + // can publish (e.g. in a nostr kind 10058 list for bolt12 zaps) + async offer(req, res) { + try { + res.send(await getUserOffer(req.user)); + } catch (e) { + bail(res, e.message); + } + }, + async fetchinvoice(req, res) { - const { amount, offer } = req.body; - res.send(await ln.fetchinvoice(offer, amount ? amount * 1000 : null)); + const { amount, offer, payer_note } = req.body; + res.send( + await ln.fetchinvoice({ + offer, + amount_msat: amount ? amount * 1000 : undefined, + payer_note, + timeout: 60, + }), + ); + }, + + async auth(req, res) { + console.log(req.query); + res.send(req.query); + }, + + async order(req, res) { + console.log(req.body); + res.send(req.body); + }, + + async sendinvoice(req, res) { + try { + const { user } = req; + const { invreq } = req.body; + + const { amount_msat, bolt12, pay_index } = await ln.sendinvoice({ + invreq, + label: v4(), + }); + + await generate({ + invoice: { + amount: Math.round(amount_msat / 1000), + type: "bolt12", + bolt12, + }, + user, + }); + + const p = await replay(pay_index); + + res.send(p); + } catch (e) { + bail(res, e.message); + } }, }; diff --git a/routes/preimages.ts b/routes/preimages.ts new file mode 100644 index 00000000..5e7c484f --- /dev/null +++ b/routes/preimages.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import { db, g } from "$lib/db"; +import { parseEntry } from "$lib/invoices"; +import { bail, fail } from "$lib/utils"; + +// Merchants pre-load payment preimages here, then have buyers create invoices +// with usePreimage: true — each such invoice consumes the next queued preimage +// so its payment hash is sha256(preimage). Once paid, the preimage is exposed +// on the invoice record, letting a static storefront sell decryption secrets +// with no backend of its own. An optional per-batch price — sats, or a fiat +// amount converted at invoice-creation time — is enforced at invoice creation; +// creation is unauthenticated, so without it a buyer could name their own +// price for the secret. + +const MAX_QUEUE = 1000; +const key = (uid) => `${uid}:preimages`; +const sha256 = (hex) => + createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex"); + +export default { + async add(req, res) { + try { + const { preimages, price = null, fiat = null } = req.body; + let { currency = null } = req.body; + if (!Array.isArray(preimages) || !preimages.length) + fail("preimages array required"); + if ( + !preimages.every( + (p) => typeof p === "string" && /^[0-9a-f]{64}$/i.test(p), + ) + ) + fail("preimages must be 64-character hex strings"); + if (price !== null && fiat !== null) + fail("specify price (sats) or fiat, not both"); + if (price !== null && (!Number.isInteger(price) || price <= 0)) + fail("price must be a positive integer amount of sats"); + if (fiat !== null) { + if (typeof fiat !== "number" || !(fiat > 0)) + fail("fiat must be a positive number"); + currency ||= req.user.currency; + const rates = await g("rates"); + if (!rates?.[currency]) fail(`unsupported currency ${currency}`); + } else { + currency = null; + } + + const k = key(req.user.id); + const existing = await db.lRange(k, 0, -1); + const seen = new Set(existing.map((e) => parseEntry(e).preimage)); + const fresh = []; + for (let p of preimages) { + p = p.toLowerCase(); + if (seen.has(p)) continue; + // A preimage that already settled (or backs a live invoice) can't be + // credited again — reject it rather than let a buyer pay for nothing. + if ( + (await db.exists(`credited:${p}`)) || + (await db.exists(`preimage:${sha256(p)}`)) + ) + fail("preimage already used"); + seen.add(p); + fresh.push(p); + } + + if (existing.length + fresh.length > MAX_QUEUE) + fail(`queue limited to ${MAX_QUEUE} preimages`); + if (fresh.length) + await db.rPush( + k, + fresh.map((preimage) => + JSON.stringify({ preimage, price, fiat, currency }), + ), + ); + + res.send({ + queued: existing.length + fresh.length, + added: fresh.map((preimage) => ({ + preimage, + hash: sha256(preimage), + price, + fiat, + currency, + })), + }); + } catch (e) { + bail(res, e.message); + } + }, + + async list(req, res) { + try { + const entries = await db.lRange(key(req.user.id), 0, -1); + res.send({ + queued: entries.length, + preimages: entries.map((e) => { + const { preimage, price, fiat, currency } = parseEntry(e); + return { preimage, hash: sha256(preimage), price, fiat, currency }; + }), + }); + } catch (e) { + bail(res, e.message); + } + }, + + async clear(req, res) { + try { + await db.del(key(req.user.id)); + res.send({ queued: 0 }); + } catch (e) { + bail(res, e.message); + } + }, +}; diff --git a/routes/rates.ts b/routes/rates.ts index 67c111d7..e532ec78 100644 --- a/routes/rates.ts +++ b/routes/rates.ts @@ -12,6 +12,7 @@ export default { }, async index(_, res) { - res.send(await g("rates")); + const { date, fx, ...rates } = await g("rates"); + res.send(rates); }, }; diff --git a/routes/square.ts b/routes/square.ts new file mode 100644 index 00000000..fb06e082 --- /dev/null +++ b/routes/square.ts @@ -0,0 +1,92 @@ +import * as crypto from "crypto"; +import config from "$config"; +import { db, g, s } from "$lib/db"; +import { generate } from "$lib/invoices"; +import { SATS, bail, getUser } from "$lib/utils"; +import { sha256 } from "@noble/hashes/sha256"; +import { utf8ToBytes } from "@noble/hashes/utils"; +import { base64urlnopad as base64 } from "@scure/base"; +import { SquareClient } from "square"; +import { v4 } from "uuid"; + +const { scopes, url, appId, clientSecret, environment } = config.square; + +export default { + async connect(req, res) { + const { user } = req; + + if (await db.exists(`${user.id}:square`)) { + return res.send("connected"); + } + + const codeVerifier = base64.encode(crypto.randomBytes(32)); + await s(`${user.id}:codeVerifier`, codeVerifier); + + const challenge = base64.encode(sha256(utf8ToBytes(codeVerifier))); + + const state = base64.encode(crypto.randomBytes(12)); + const scope = scopes.join("+"); + + res.send( + `${url}oauth2/authorize?client_id=${appId}&session=false&scope=${scope}&state=${state}&code_challenge=${challenge}`, + ); + }, + + async auth(req, res) { + const { user } = req; + + const codeVerifier = await g(`${user.id}:codeVerifier`); + + const client = new SquareClient({ + environment, + }); + + const { code } = req.query; + + try { + const result = await client.oAuth.obtainToken({ + code, + clientId: appId, + grantType: "authorization_code", + codeVerifier, + }); + + await s(`${user.id}:square`, result); + await s(result.merchantId, user.id); + res.send({}); + } catch (e) {} + }, + + async payment(req, res) { + try { + const { body } = req; + const { data, type, merchant_id } = body; + const { payment } = data.object; + + if ( + type === "payment.created" && + (payment.source_type === "CASH" || payment.source_type === "EXTERNAL") + ) { + const { + amount_money: { amount, currency }, + } = payment; + const rates = await g("rates"); + const rate = rates[currency]; + const uid = await g(merchant_id); + const user = await getUser(uid); + const invoice = { + amount: Math.round(((amount / 100) * SATS) / rate), + own: true, + prompt: user.prompt, + type: "lightning", + }; + + await generate({ invoice, user }); + } + + res.send({}); + } catch (e) { + bail(res, e.message); + } + }, +}; diff --git a/routes/users.ts b/routes/users.ts index 34c2a655..77ddbdc6 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -1,21 +1,28 @@ -import { createReadStream } from "node:fs"; -import { appendFile, unlink } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import config from "$config"; import { requirePin } from "$lib/auth"; -import { db, g, ga, s } from "$lib/db"; +import { db, g, gf, s } from "$lib/db"; import { err, l, warn } from "$lib/logging"; +import { isDefaultName } from "$lib/default-names"; +import { upgradeLegacyNsec } from "$lib/legacynsec"; import { mail, templates } from "$lib/mail"; -import { getNostrUser, getProfile, serverPubkey } from "$lib/nostr"; -import { reconcile } from "$lib/payments"; +import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr"; +import deleteUser from "$lib/deluser"; import register from "$lib/register"; +import { assertNameFree } from "$lib/names"; +import { isReserved } from "$lib/reserved"; import { emit } from "$lib/sockets"; import upload from "$lib/upload"; import { bail, fail, fields, getUser, pick } from "$lib/utils"; import whitelist from "$lib/whitelist"; -import rpc from "@coinos/rpc"; + import { $ } from "bun"; +import got from "got"; import jwt from "jsonwebtoken"; import { getPublicKey, nip19, verifyEvent } from "nostr-tools"; +import { hexToBytes } from "@noble/hashes/utils"; import { authenticator } from "otplib"; import { v4 } from "uuid"; @@ -25,6 +32,116 @@ import type { ProfilePointer } from "nostr-tools/nip19"; const { host } = new URL(process.env.URL); const relay = encodeURIComponent(config.publicRelay); +// reCAPTCHA v3 is score-based: siteverify answers success:true for any +// validly minted token, bot or not, and puts the verdict in `score` (0 = bot, +// 1 = human). Checking `success` alone let a headless-browser signup wave +// through on 2026-10-18 with a fresh token on every request. `actions` and +// the hostname check stop a token minted for another page or site from being +// replayed here. +const verifyRecaptcha = async ( + response, + req?, + { actions = [] as string[], minScore = 0 } = {}, +) => { + const { recaptcha: secret } = config; + if (!secret) return true; + + // Skip captcha for Tor users accessing via .onion + const reqHost = req?.headers?.["x-forwarded-host"] || req?.headers?.["host"] || ""; + if (reqHost.endsWith(".onion")) return true; + + // Skip captcha for allowlisted User-Agents (prefix match) + const uaRaw = req?.headers?.["user-agent"] || req?.headers?.["User-Agent"]; + const ua = Array.isArray(uaRaw) ? uaRaw[0] : uaRaw; + if (typeof ua === "string") { + const prefixes = await db.sMembers("nocaptcha_ua"); + const uaLower = ua.toLowerCase(); + if (prefixes.some((p) => uaLower.startsWith(p.toLowerCase()))) { + return true; + } + } + + // Check for valid API key to bypass captcha + const apiKey = req?.headers?.["x-api-key"]; + if (apiKey && (await db.sIsMember("apikeys", apiKey))) { + return true; + } + + const { username } = req.body; + if (username && + await db.sIsMember("nocaptcha", username.toLowerCase().replace(/\s/g, "")) + ) + return true; + + if (!response) return false; + + try { + const ip = req?.headers?.["cf-connecting-ip"] || req?.socket?.remoteAddress; + const { success, score, action, hostname } = await got + .post("https://www.google.com/recaptcha/api/siteverify", { + form: { + secret, + response, + remoteip: ip, + }, + // Bound the wait so a slow/unreachable Google can't hang the login. + timeout: { request: 6000 }, + }) + .json(); + // A real verdict from Google: fail CLOSED only when the captcha itself is + // invalid (success === false). + if (!!config.adminpass && response === config.adminpass) return true; + if (!success) return false; + if (hostname && hostname !== host && !hostname.endsWith(`.${host}`)) { + warn("recaptcha wrong hostname", ip, hostname); + return false; + } + if (actions.length && !actions.includes(action)) { + warn("recaptcha wrong action", ip, action); + return false; + } + if (action === "register") l("recaptcha register score", ip, score); + if (typeof score === "number" && score < minScore) { + warn("recaptcha low score", ip, action, score); + return false; + } + return true; + } catch (e) { + // Fail OPEN on an INFRASTRUCTURE error (network blip, timeout, Google 5xx). + // Reaching Google is not part of authentication; failing closed here turned + // a transient coinos<->Google connectivity hiccup into a login outage — + // users got "failed captcha" until it cleared (2026-08-25 incident). The + // password and the per-IP login rate limits (ipCount>30 / ipFailures>20 -> + // 429) still gate the request; captcha is only bot-mitigation, so allowing + // it through when the verifier is unreachable is the safer failure mode. + warn( + "recaptcha verify unreachable — allowing login (fail-open)", + (e as any)?.code ?? (e as any)?.message ?? String(e), + ); + return true; + } +}; + +// Cloudflare Turnstile, on trial next to reCAPTCHA for the signup page: +// reCAPTCHA v3 scores the 2026-10 farm 0.9, so this only logs its verdict +// until we've seen whether it tells the farm apart. Returns null when the +// verifier can't be reached so callers can tell "unknown" from "failed". +const verifyTurnstile = async (token, ip) => { + if (!config.turnstile || !token) return null; + try { + const r: any = await got + .post("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + form: { secret: config.turnstile, response: token, remoteip: ip }, + timeout: { request: 6000 }, + }) + .json(); + return r; + } catch (e) { + warn("turnstile verify unreachable", (e as any)?.code ?? String(e)); + return null; + } +}; + export default { upload, @@ -32,21 +149,10 @@ export default { const { user } = req; try { user.balance = await g(`balance:${user.id}`); - user.locked = await ga(`balance:${user.id}`); - - if (user.locked) { - const blacklisted = await db.sIsMember( - "blacklist", - user?.username?.toLowerCase().trim(), - ); - - const whitelisted = await db.sIsMember( - "whitelist", - user?.username?.toLowerCase().trim(), - ); - - if (!blacklisted || whitelisted) user.locked = 0; - } + // The password-encrypted nostr key is only handed out by /login, after + // the password (and 2FA) check. A bearer token alone mustn't be enough + // to fetch it and brute-force or decrypt it offline. + user.hasnsec = !!(user.nsec || user.cipher); user.prompt = !!user.prompt; if (user.pubkey) user.npub = nip19.npubEncode(user.pubkey); @@ -58,42 +164,6 @@ export default { } }, - async list(req, res) { - const { user } = req; - if (!user.admin) fail("unauthorized"); - - const users = []; - - for await (const k of db.scanIterator({ MATCH: "balance:*" })) { - const uid = k.split(":")[1]; - const user = await getUser(uid); - - if (!user) { - await db.del(`balance:${uid}`); - continue; - } - - user.balance = await g(k); - - const payments = await db.lRange(`${uid}:payments`, 0, -1); - - let total = 0; - for (const pid of payments) { - const p = await g(`payment:${pid}`); - if (!p) continue; - total += p.amount; - if (p.amount < 0) - total -= (p.fee || 0) + (p.ourfee || 0) + (p.tip || 0); - else total += p.tip || 0; - } - - user.expected = total; - users.push(user); - } - - res.send(users); - }, - async get(req, res) { let { params: { key }, @@ -115,8 +185,7 @@ export default { const user = await getNostrUser(key); res.send(pick(user, fields)); } catch (e) { - err("problem getting user", key, e.message); - res.code(500).send(e.message); + res.code(404).send("User not found"); } }, @@ -127,7 +196,128 @@ export default { if (!body.user) fail("no user object provided"); let { user } = body; - const fields = ["pubkey", "password", "username", "picture", "fresh"]; + // Per-IP registration cap. Nothing else throttles /register (the + // recaptcha check only runs on login; the fastify limiters cover + // /login and /send), so one VPN exit created ~500 empty accounts at + // 3/min on 2026-09-08 without tripping anything. Five an hour is far + // above any household; an internal/private source IP (proxy without + // the Cloudflare header) is exempt so a misrouted request can't lock + // everyone out. Counted before validation so failed attempts count too. + if (ip && !/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1$|fc|fd)/.test(ip)) { + const k = `register:ip:${ip}`; + const n = await db.incr(k); + if (n === 1) await db.expire(k, 3600); + if (n > 5) { + warn("registration cap hit", ip, n); + fail("Too many accounts created from this address, try again later"); + } + + // Same idea one level up: a residential proxy pool hands out a fresh + // address per request, but they cluster in a few ISP ranges. All + // signups site-wide normally run ~25/hour, so 10/hour from one IPv4 + // /16 or IPv6 /48 only bites a farm. + const range = ip.includes(":") + ? ip.split(":").slice(0, 3).join(":") + : ip.split(".").slice(0, 2).join("."); + const rk = `register:range:${range}`; + const rn = await db.incr(rk); + if (rn === 1) await db.expire(rk, 3600); + if (rn > 10) { + warn("registration range cap hit", ip, range, rn); + fail("Too many accounts created from this network, try again later"); + } + + // The farm came back spread over ~90 ranges an hour, each staying + // under the hourly cap, so also cap a range per day. Counted on + // attempts that got past the hourly caps. + const dk = `register:range:day:${range}`; + const dn = await db.incr(dk); + if (dn === 1) await db.expire(dk, 86400); + if (dn > 5) { + warn("registration daily range cap hit", ip, range, dn); + fail("Too many accounts created from this network, try again later"); + } + } + + // Forged browser User-Agent. Every engine that reports AppleWebKit also + // reports the "(KHTML, like Gecko)" token right behind it, so a UA that + // claims AppleWebKit without it was assembled by hand. Across 4.8M + // logged requests this matches 2,366: the c<epoch> registration bot + // (1,203 accounts from 596 rotating residential IPs — which is exactly + // why the per-IP cap above never touched it), PetalBot, Bytespider, and + // nothing else. Real browsers carry the token; non-browser clients that + // register legitimately (python-requests, curl, damus, our own Bun SSR) + // never claim AppleWebKit at all, so none of them match. + const ua = String(headers["user-agent"] || ""); + if (ua.includes("AppleWebKit/") && !ua.includes("KHTML")) { + warn("registration blocked, forged user-agent", ip, ua.slice(0, 80)); + fail("Registration unavailable"); + } + + // The signup form has always sent a recaptcha token and /register has + // always discarded it — it isn't even in the `fields` pick below — so + // the captcha only ever gated login. Verify it when one is supplied. + // Supplied-and-invalid is the only rejection: an ABSENT token still + // passes, because half of all registrations are API clients that never + // had one and requiring it would break them. verifyRecaptcha keeps its + // own bypasses (onion host, x-api-key, allowlisted UA) and fails open + // when Google is unreachable. + // Our own signup page (SSR, Bun UA) always sends a token on the + // clearnet; only the .onion page, which reaches us without a + // cf-connecting-ip, and the fund sweep (internal x-api-key, bypassed + // inside verifyRecaptcha) legitimately don't. So from the web UI an + // absent token is a scripted submit, not an API client. + if (!user.recaptcha && ip && ua.startsWith("Bun/")) { + if (!(await verifyRecaptcha(undefined, req))) { + warn("registration blocked, missing captcha", ip, user.username); + fail("Failed captcha"); + } + } + + if ( + user.recaptcha && + !(await verifyRecaptcha(user.recaptcha, req, { + actions: ["register"], + minScore: 0.5, + })) + ) { + warn("registration blocked, failed captcha", ip, user.username); + fail("Failed captcha"); + } + + if (ip && ua.startsWith("Bun/")) { + const t = await verifyTurnstile(user.turnstile, ip); + l( + "turnstile register", + ip, + user.username, + !user.turnstile + ? "missing" + : t + ? `${t.success ? "pass" : "fail"} ${t.hostname || ""} ${(t["error-codes"] || []).join(",")}` + : "unverified", + ); + } + + // Site-wide cap on signups that kept the auto-filled animal+NN name. + // The 2026-10 farm passes reCAPTCHA (0.9) and Cloudflare's challenge + // from thousands of residential IPs, but never changes the default + // name; real users keep it ~8 times a day. Hitting the cap only asks + // for a different name. + if (isDefaultName(user.username)) { + const k = `register:default:${Math.floor(Date.now() / 3600000)}`; + const n = await db.incr(k); + if (n === 1) await db.expire(k, 7200); + if (n > 10) { + warn("registration default-name cap hit", ip, user.username, n); + fail("Please choose your own username"); + } + } + + // No pubkey: nothing here proves the caller holds that key, and register() + // points `user:<pubkey>` at the new account. Nostr accounts come from + // /nostrAuth, which verifies a signed challenge first. + const fields = ["password", "username", "picture", "fresh"]; user = await register(pick(user, fields), ip); const payload = { id: user.id }; @@ -135,7 +325,7 @@ export default { l("registered new user", user.username); - res.send({ ...pick(user, whitelist), sk: user.sk, token }); + res.send({ ...pick(user, whitelist), nsec: user.nsec, sk: user.sk, token }); } catch (e) { err("problem registering", e.message); res.code(500).send(e.message); @@ -152,9 +342,12 @@ export default { return res.code(401).send("2fa required"); } + // Fresh seed on disable so a seed that leaked while 2FA was on is dead + // when it's re-enabled. user.twofa = false; + user.otpsecret = authenticator.generateSecret(); await s(`user:${id}`, user); - emit(username, "user", user); + emit(username, "user", pick(user, whitelist)); emit(username, "otpsecret", user.otpsecret); l("disabled 2fa", username); res.send({}); @@ -171,7 +364,7 @@ export default { if (isValid) { user.twofa = true; await s(`user:${id}`, user); - emit(username, "user", user); + emit(username, "user", pick(user, whitelist)); } else { return res.code(500).send("Invalid token"); } @@ -179,7 +372,6 @@ export default { l("enabled 2fa", username); res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -199,32 +391,40 @@ export default { const valid = /^[\p{L}\p{N}]{2,24}$/u; if (!valid.test(username)) fail("Usernames can only have letters and numbers"); + if (isReserved(username)) fail("Invalid username"); + if (username?.includes("undefined")) fail("Invalid username"); let exists; let { pubkey } = body; if (pubkey) { pubkey = pubkey.trim(); + if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data; exists = await getUser(pubkey); + const un = user.username.toLowerCase().replace(/\s/g, ""); const existingUsername = exists?.username ?.toLowerCase() .replace(/\s/g, ""); - if (exists && username !== existingUsername) { - warn("key in use", pubkey, existingUsername); + if (exists && un !== existingUsername) { + warn("key in use", pubkey, username, existingUsername); if (exists.anon) await db.del(`user:${pubkey}`); else fail("Key in use by another account"); } const event = JSON.parse(body.event); const challenge = event.tags.find((t) => t[0] === "challenge")[1]; - const c = await g(`challenge:${challenge}`); + // Single-use: GETDEL claims the challenge atomically, so the identical + // signed event can't be replayed within the 5-minute TTL (it is also + // written to the request log, so a log reader could otherwise reuse it). + // Burn it on read rather than on success — a failed attempt costs the + // client one extra GET /challenge, and two racing replays cannot both win. + const c = await db.getDel(`challenge:${challenge}`); if (!c) fail("Invalid or expired challenge"); if (!verifyEvent(event) || event.pubkey !== pubkey) fail("Invalid signature or challenge mismatch."); pubkey = pubkey.replace(/\s*/g, ""); - if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data; if (pubkey.length !== 64) fail(`Invalid pubkey ${pubkey}`); await db.del(`user:${user.pubkey}`); user.pubkey = pubkey; @@ -237,18 +437,20 @@ export default { if (user.pin === "delete") user.pin = undefined; if (username) { - exists = await db.exists(`user:${username}`); const currentUsername = user.username.replace(/\s/g, "").toLowerCase(); + if (username !== currentUsername) { + exists = await db.exists(`user:${username}`); - if (currentUsername !== username && exists) { - err("username taken", username, currentUsername); - fail("Username taken"); - } else if (username) { - if (currentUsername !== username) + if (exists) { + err("username taken", username, currentUsername); + fail("Username taken"); + } else { + // A name the v3 registrar holds is taken too (see lib/names.ts). + await assertNameFree(username, user.pubkey); l("changing username", currentUsername, username); - - await db.del(`user:${currentUsername}`); - user.username = username; + await db.del(`user:${currentUsername}`); + user.username = username; + } } } @@ -265,6 +467,7 @@ export default { "language", "locktime", "memoPrompt", + "migrated", "nip5", "notify", "nsec", @@ -281,23 +484,28 @@ export default { "twofa", ]; + if (body.email) { + user.verified = false; + user.notify = false; + } + for (const a of attributes) { if (typeof body[a] !== "undefined") user[a] = body[a]; } user.fresh = false; - user.tip = Math.max(0, Math.min(1000, parseInt(user.tip))); + user.tip = Math.max(0, Math.min(1000, Number.parseInt(user.tip))); if (password && password === confirm) { user.password = await Bun.password.hash(password, { algorithm: "bcrypt", - cost: 4, + cost: 12, }); } user.haspin = !!user.pin; if (user.destination) user.destination = user.destination.trim(); - await s(`user:${user.pubkey}`, user.id); + if (user.pubkey) await s(`user:${user.pubkey}`, user.id); await s( `user:${user.username.toLowerCase().replace(/\s/g, "")}`, user.id, @@ -306,8 +514,8 @@ export default { await s(`user:${user.id}`, user); if (user.nip5) await db.sAdd("nip5", `${user.username}:${user.pubkey}`); - emit(user.id, "user", user); - res.send({ user }); + emit(user.id, "user", pick(user, whitelist)); + res.send({ user: pick(user, whitelist) }); } catch (e) { warn("failed to update", user.username, e.message); bail(res, e.message); @@ -316,22 +524,61 @@ export default { async login(req, res) { try { - let { username, password, token: twofa } = req.body; + let { username, password, token: twofa, recaptcha } = req.body; + const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress; + + const ipKey = `ip:${ip}:login`; + const ipCount = await db.incr(ipKey); + if (ipCount === 1) await db.expire(ipKey, 10); + if (ipCount > 30) return res.code(429).send({}); + + // Fail closed: an unset adminpass must never authenticate, and an omitted + // password field must never coincide with an unset value (undefined === + // undefined). Both the configured value and the supplied one must be + // non-empty and match exactly. + const isAdmin = + !!config?.adminpass && !!password && password === config.adminpass; + + if (!isAdmin) { + const recaptchaOk = await verifyRecaptcha(recaptcha, req, { + actions: ["login", "register"], + minScore: 0.3, + }); + if (!recaptchaOk) { + return res.code(401).send("failed captcha"); + } + } username = username.toLowerCase().replace(/\s/g, ""); + username = username.split("@")[0]; + + const ipFailKey = `ip:${ip}:login:fail`; + const ipFailures = await g(ipFailKey); + if (!isAdmin && ipFailures > 20) return res.code(429).send({}); + let user = await getUser(username); - if (password !== config?.adminpass) { - if ( - !user || - !user.password || - !(await Bun.password.verify(password, user.password)) - ) { - // warn("invalid username or password attempt", username); - await appendFile( - "failedlogins.txt", - `${Date.now()} : ${username} : ${password}\n`, - ); + if (!isAdmin) { + // changeid leaves the literal "reset" in place of a hash, so no + // password can ever match. Say so, letting the client point the + // owner at support instead of a generic login failure. This runs + // after the captcha and the per-IP failure cap, and counts as a + // failure itself. + if (user?.password === "reset") { + await db.incrBy(ipFailKey, 1); + if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600); + return res.code(401).send("account reset"); + } + + let verified = false; + try { + if (user?.password) + verified = await Bun.password.verify(password, user.password); + } catch (e) {} + + if (!user || !verified) { + await db.incrBy(ipFailKey, 1); + if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600); return res.code(401).send({}); } @@ -340,17 +587,49 @@ export default { (typeof twofa === "undefined" || !authenticator.check(twofa, user.otpsecret)) ) { + await db.incrBy(ipFailKey, 1); + if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600); return res.code(401).send("2fa required"); } + + // Transparent bcrypt upgrade: legacy accounts were hashed at cost 4 + // (~1,300 guesses/s). Now that we hold the verified plaintext, re-hash + // at the current cost so the stored hash strengthens on next login. + const cost = Number.parseInt( + user.password.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0", + 10, + ); + let changed = false; + if (cost < 12) { + user.password = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 12, + }); + changed = true; + } + + if (await upgradeLegacyNsec(user, password)) changed = true; + if (changed) await s(`user:${user.id}`, user); } + // Evicted accounts can't use a token anyway (see lib/auth.ts), so don't + // mint one — checked after the password so it can't be used to probe. + if (!isAdmin && (await db.sIsMember("evicted", user.id))) + return res.code(403).send("account disabled"); + if (username !== "coinos") l("logged in", username, req.headers["cf-connecting-ip"]); const payload = { id: user.id }; const token = jwt.sign(payload, config.jwt); - res.cookie("token", token, { expires: new Date(Date.now() + 432000000) }); - user = pick(user, whitelist); + res.cookie("token", token, { + expires: new Date(Date.now() + 432000000), + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + }); + user = { ...pick(user, whitelist), hasnsec: !!(user.nsec || user.cipher), nsec: user.nsec }; res.send({ user, token }); } catch (e) { err("login error", e.message, req.socket.remoteAddress); @@ -366,9 +645,18 @@ export default { async nostrAuth(req, res) { try { - const { event, challenge, twofa } = req.body; + const { event, challenge, twofa, recaptcha } = req.body; const ip = req.headers["cf-connecting-ip"]; - const c = await g(`challenge:${challenge}`); + const recaptchaOk = await verifyRecaptcha(recaptcha, req, { + actions: ["login", "register"], + minScore: 0.3, + }); + if (!recaptchaOk) { + return res.code(401).send("failed captcha"); + } + // Single-use — see the GETDEL note on the other challenge consumer. The + // captcha is checked above, so a captcha failure doesn't burn a challenge. + const c = await db.getDel(`challenge:${challenge}`); const { pubkey: key, kind } = event; if (kind !== 27235) fail("Invalid event"); if (!c) fail("Invalid or expired login challenge"); @@ -401,25 +689,21 @@ export default { } const { username } = user; - if ( - await db.sIsMember( - "compromised", - username.replace(/\s/g, "").toLowerCase(), - ) - ) { - warn("compromised nostr auth attempt"); - fail("unauthorized"); - } - l("nostr login", username, ip); const payload = { id: user.id }; const token = jwt.sign(payload, config.jwt); - res.cookie("token", token, { expires: new Date(Date.now() + 432000000) }); - user = pick(user, whitelist); + res.cookie("token", token, { + expires: new Date(Date.now() + 432000000), + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + }); + user = { ...pick(user, whitelist), hasnsec: !!(user.nsec || user.cipher) }; res.send({ user, token }); } catch (e) { - err("login error", e.message, req.socket.remoteAddress); + err("nostr login error", e.message, req.socket.remoteAddress); res.code(401).send({}); } }, @@ -441,7 +725,6 @@ export default { await db.sAdd(`${id}:subscriptions`, JSON.stringify(subscription)); res.send(subscription); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -453,7 +736,6 @@ export default { await db.sRem(`${id}:subscriptions`, JSON.stringify(subscription)); res.send(subscription); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -473,6 +755,42 @@ export default { } }, + // The encrypted nostr key, for clients that need to reveal or sign with it + // after a session that didn't start with a password login. Re-checking the + // password keeps a bearer token alone from fetching it (see /me). + async nsec(req, res) { + const { + body: { password }, + user, + } = req; + const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress; + const ipFailKey = `ip:${ip}:login:fail`; + + try { + if (!user.nsec && !user.cipher) fail("nsec not available"); + if ((await g(ipFailKey)) > 20) return res.code(429).send({}); + + let verified = false; + try { + if (user.password && password) + verified = await Bun.password.verify(password, user.password); + } catch (e) {} + + if (!verified) { + await db.incrBy(ipFailKey, 1); + if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600); + return res.code(401).send("invalid password"); + } + + if (await upgradeLegacyNsec(user, password)) await s(`user:${user.id}`, user); + if (!user.nsec) fail("nsec could not be decrypted"); + + res.send({ nsec: user.nsec }); + } catch (e) { + bail(res, e.message); + } + }, + async pin(req, res) { const { body: { pin }, @@ -484,6 +802,10 @@ export default { async otpsecret(req, res) { try { await requirePin(req); + // Only needed to enrol. Once 2FA is on, the seed never leaves the + // server again — otherwise a stolen token on a PIN-less account is + // enough to mint valid codes. + if (req.user.twofa) fail("2FA is already enabled"); const { otpsecret, username } = req.user; res.send({ secret: otpsecret, username }); } catch (e) { @@ -506,7 +828,7 @@ export default { for (const { ref } of ( await Promise.all( - payments.reverse().map(async (id) => await g(`payment:${id}`)), + payments.reverse().map(async (id) => await gf(`payment:${id}`)), ) ).filter((p) => p && p.type === PaymentType.internal && p.ref)) { if (ref === id) continue; @@ -550,6 +872,7 @@ export default { params: { username }, headers: { authorization }, } = req; + fail("Unauthorized"); username = username.toLowerCase(); if (!authorization?.includes(config.admin)) return res.code(401).send("unauthorized"); @@ -569,47 +892,100 @@ export default { res.send({}); }, + // Authenticated self-delete: wipes every key tied to the calling user via + // lib/deluser (the same sweep as scripts/delall.ts, offers and archive + // included). Gated by typing the username (works for password and nostr + // accounts alike) plus the PIN when one is set, and refuses while a balance + // remains so a user can't accidentally destroy funds. + async deleteSelf(req, res) { + try { + const { user } = req; + const { id, username } = user; + + const confirm = (req.body?.confirm || "").toString().trim().toLowerCase(); + if (confirm !== username.toLowerCase()) + fail("Type your username to confirm account deletion"); + await requirePin(req); + + // Allow deletion through dust (often too small to withdraw); only block + // a meaningful balance so nobody destroys real funds by accident. Sum + // EVERY account (main + sub-accounts) — the sweep wipes each + // sub-account's balance:/pending: keys unconditionally, so a guard that + // only checked the main account let a user with funds parked in a + // sub-account pass and silently destroy them. + const aids = await db.lRange(`${id}:accounts`, 0, -1); + if (!aids.includes(id)) aids.push(id); + let total = 0; + for (const aid of aids) { + total += Number(await db.get(`balance:${aid}`)) || 0; + total += Number(await db.get(`pending:${aid}`)) || 0; + } + if (total > 10000) + fail("Withdraw your balance before deleting your account"); + + const { deleted, disabled } = await deleteUser(user); + + l("user self-deleted", username, id, `${deleted} keys, ${disabled} offers`); + try { + res.clearCookie("token"); + } catch {} + res.send({ deleted: true }); + } catch (e) { + bail(res, e.message); + } + }, + async reset(req, res) { const { body: { code, username, password }, user: u, } = req; - const admin = u?.admin; - let id; - let user; + try { + let id; + let user; - if (admin) { + // Non-admins must not be able to tell this admin-only endpoint exists. + // Log the probe (so exploit-watch can flag it) but return a generic 404 + // identical to an unregistered route — previously it returned "disabled", + // which confirmed the endpoint AND its admin-gating to attackers. + if (u?.username !== config.admin) { + err("password reset failed disabled", req.headers["cf-connecting-ip"]); + return res + .code(404) + .send({ message: "Route POST:/reset not found", error: "Not Found", statusCode: 404 }); + } id = await g(`user:${username.toLowerCase().replace(/\s/g, "")}`); user = await g(`user:${id}`); - } else { - id = await g(`reset:${code}`); - user = await g(`user:${id}`); - } - if (!user) fail("user not found"); + if (!user) fail("user not found"); - warn( - "password reset", - user.username, - code, - req.headers["cf-connecting-ip"], - ); + warn( + "password reset", + user.username, + code, + req.headers["cf-connecting-ip"], + ); - try { user.pin = null; user.nsec = null; + user.cipher = null; + user.salt = null; user.password = await Bun.password.hash(password, { algorithm: "bcrypt", - cost: 4, + cost: 12, }); await s(`user:${id}`, user); await db.del(`reset:${code}`); res.send(pick(user, whitelist)); + + for await (const k of db.scanIterator({ MATCH: "ip:*:login:fail" })) { + await db.del(k); + } } catch (e) { - err("password reset failed", e.message); + err("password reset failed", e.message, req.headers["cf-connecting-ip"]); bail(res, e.message); } }, @@ -685,7 +1061,7 @@ export default { await s(`user:${id}`, user); await s(`email:${email.toLowerCase()}`, id); - res.send(pick(user, whitelist)); + res.send(pick(user, fields)); } catch (e) { bail(res, e.message); } @@ -716,28 +1092,13 @@ export default { } }, - async hidepay(req, res) { - const { - body: { username }, - } = req; - const u = await getUser(username); - u.hidepay = true; - await s(`user:${u.id}`, u); - res.send({}); - }, - - async unlimit(req, res) { - const { - body: { username }, - } = req; - const u = await getUser(username); - u.unlimited = true; - await s(`user:${u.id}`, u); - res.send({}); - }, - async account(req, res) { const { id } = req.params; + const { id: uid } = req.user; + + const pos = await db.lPos(`${uid}:accounts`, id); + if (pos == null) fail("account not found"); + const account = await g(`account:${id}`); if (account) account.balance = await g(`balance:${id}`); res.send(account); @@ -758,45 +1119,17 @@ export default { res.send(accounts.reverse()); } catch (e) { - console.log(e); bail(res, e.message); } }, async createAccount(req, res) { try { - const { fingerprint, pubkey, name, seed, type } = req.body; + const { name, type } = req.body; const { user } = req; - const { id: uid } = user; const id = v4(); - const account = { id, name, seed, type, uid, descriptors: [] }; - - let node = rpc(config[type]); - - await node.createWallet({ - wallet_name: id, - descriptors: true, - disable_private_keys: true, - load_on_startup: true, - }); - - node = rpc({ ...config[type], wallet: id }); - - for (const i of [0, 1]) { - const desc = `wpkh([${fingerprint}]${pubkey}/${i}/*)`; - const { checksum } = await node.getDescriptorInfo(desc); - account.descriptors.push({ - desc: `${desc}#${checksum}`, - range: [0, 100], - next_index: 0, - timestamp: "now", - internal: i === 1, - active: true, - }); - } - - await node.importDescriptors(account.descriptors); + const account = { id, name, type, uid: user.id }; await db .multi() @@ -806,10 +1139,6 @@ export default { .lPush(`${user.id}:accounts`, id) .exec(); - reconcile(account, true); - - await appendFile("/bitcoin.conf", `wallet=${id}\n`); - res.send(account); } catch (e) { bail(res, e.message); @@ -835,17 +1164,10 @@ export default { try { const { id } = req.body; const { id: uid } = req.user; - const { type } = await g(`account:${id}`); + const account = await g(`account:${id}`); const pos = await db.lPos(`${uid}:accounts`, id); - if (!(type && pos != null)) fail("account not found"); - - try { - const node = rpc({ ...config[type], wallet: id }); - await node.unloadWallet(id); - } catch (e) { - warn("failed to unload wallet", id); - } + if (!(account && pos != null)) fail("account not found"); await db .multi() @@ -857,39 +1179,44 @@ export default { res.send({ ok: true }); } catch (e) { - console.log(e); bail(res, e.message); } }, - async printer(req, res) { - const { ssid, key, username, password } = req.body; - const dir = "./printer"; - const path = `${dir}/config.txt`; - + async flash(req, res) { + // Build in a per-request scratch dir. A shared ./printer/config.txt and + // ./littlefs.img let two overlapping requests hand one user the other's + // wifi key and API token. + const dir = await mkdtemp(join(tmpdir(), "flash-")); try { - await unlink(path); - await appendFile(path, `${ssid}\n`, "utf8"); - await appendFile(path, `${key}\n`, "utf8"); - await appendFile(path, `${username}\n`, "utf8"); - await appendFile(path, `${password}\n`, "utf8"); - - const output = "./littlefs.img"; - await $`./mklittlefs -c ${dir} -p 256 -b 4096 -s 0x20000 ${output}`; - + const { ssid, key, token } = req.body; + if (!ssid || !key || !token) fail("ssid, key and token required"); + const cfg = `${ssid.trim()}\n${key.trim()}\n${token.trim()}\n`; + const src = join(dir, "fs"); + const img = join(dir, "littlefs.img"); + await mkdir(src); + await writeFile(join(src, "config.txt"), cfg, "utf8"); + await $`./mklittlefs -c ${src} -p 256 -b 4096 -s 0x20000 ${img}`; res.header("Content-Type", "application/octet-stream"); - res.header("Content-Disposition", "attachment; filename=littlefs.img"); - console.log("sending back"); - return res.send(createReadStream(output)); - } catch (error) { - res.code(500).send({ error: "Failed to generate LittleFS image" }); + // Read it fully before the finally below removes the directory. + return res.send(await readFile(img)); + } catch (e) { + bail(res, e.message); + } finally { + await rm(dir, { recursive: true, force: true }); } }, async app(req, res) { const { pubkey } = req.params; const { user } = req; + if (!/^[0-9a-f]{64}$/.test(pubkey)) + return res.code(404).send({ error: "connection not found" }); const app = await g(`app:${pubkey}`); + // Missing records must 404, not crash into a 500: clients (Damus one-click + // setup) probe this endpoint and treat 404 as "not configured yet, create + // one" — anything else aborts their whole connection flow. + if (!app) return res.code(404).send({ error: "connection not found" }); if (app.uid !== user.id) fail("unauthorized"); const lud16 = `${user.username}@${host}`; @@ -898,13 +1225,13 @@ export default { const payments = await Promise.all( pids.map(async (pid) => { - const p = await g(`payment:${pid}`); - p.user = await g(`user:${p.uid}`); + const p = await gf(`payment:${pid}`); + if (p) p.user = pick((await g(`user:${p.uid}`)) || {}, fields); return p; }), ); - app.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`; + app.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`; app.payments = payments.filter((p) => p); res.send(app); @@ -913,26 +1240,31 @@ export default { async apps(req, res) { const { user } = req; const pubkeys = await db.sMembers(`${user.id}:apps`); - const apps = await Promise.all(pubkeys.map((p) => g(`app:${p}`))); + // Drop set members whose app record was deleted (e.g. an invalidation + // sweep) so one stale pubkey can't 500 the whole listing. + const apps = (await Promise.all(pubkeys.map((p) => g(`app:${p}`)))).filter( + Boolean, + ); const lud16 = `${user.username}@${host}`; await Promise.all( apps.map(async (a) => { if (a.secret) - a.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`; + a.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`; const pids = await db.lRange(`${a.pubkey}:payments`, 0, -1); let payments = await Promise.all( - pids.map((pid) => g(`payment:${pid}`)), + pids.map((pid) => gf(`payment:${pid}`)), ); payments = payments.filter((p) => p); a.spent = payments.reduce( (a, b) => a + - (Math.abs(parseInt(b.amount || 0)) + - parseInt(b.fee || 0) + - parseInt(b.ourfee || 0)), + (Math.abs(Number.parseInt(b.amount || 0)) + + Number.parseInt(b.tip || 0) + + Number.parseInt(b.fee || 0) + + Number.parseInt(b.ourfee || 0)), 0, ); }), @@ -955,10 +1287,64 @@ export default { const { user } = req; const uid = user.id; - let app = await g(pubkey); + if (secret) pubkey = getPublicKey(hexToBytes(secret)); + + // The pubkey names `app:<pubkey>` and `<pubkey>:payments`, so it must be + // a real NWC key: a user id here would hand the caller that user's + // payment list through GET /app. + if (!/^[0-9a-f]{64}$/.test(pubkey)) fail("Invalid pubkey"); + // 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(secret); + + // Rotation is mandatory after a credential disclosure: the NWC pubkey is + // derived from the bearer secret, so re-registering a retired or + // quarantined pubkey would re-authorize the exact secret that was + // disclosed (we store client-supplied secrets, so they're part of any DB + // leak). Reject it and make the client mint a fresh secret — a new secret + // yields a new pubkey and never trips this check. + const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0; + const retired = + (app && + credentialCutoff > 0 && + (!Number(app.created) || Number(app.created) < credentialCutoff)) || + (await db.sIsMember("nwc:quarantined", pubkey)); + if (retired) + return res.code(403).send({ + error: + "This connection's key was retired for security reasons; connect again with a newly generated 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 + // connection. (Reverted a brief default-on change, 2026-06-06.) notify = String(notify) === "true"; app = { @@ -973,14 +1359,16 @@ export default { secret, }; - if (!app?.created) app.created = Date.now(); + // Refresh the creation epoch on every owner post: this record passed the + // retirement check above, so the owner updating it (or minting it fresh) + // asserts it's live and keeps it clear of the credential cutoff. + app.created = Date.now(); await s(`app:${pubkey}`, app); await db.sAdd(`${uid}:apps`, pubkey); res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -990,13 +1378,15 @@ export default { const { user } = req; const uid = user.id; const { pubkey } = req.body; - const app = await g(pubkey); - if (app && uid !== app.uid) fail("Unauthorized"); + const app = await g(`app:${pubkey}`); + if (app && uid !== app.uid) { + warn(app.uid, uid); + fail("Unauthorized"); + } await db.sRem(`${uid}:apps`, pubkey); await db.del(`app:${pubkey}`); res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -1041,4 +1431,11 @@ export default { const liquid = await g(`credit:liquid:${id}`); res.send({ bitcoin, lightning, liquid }); }, + + async ro(req, res) { + const { user } = req; + const payload = { id: `${user.id}-ro` }; + const token = jwt.sign(payload, config.jwt); + res.send(token); + }, }; |
