diff options
| author | Adam Soltys <[email protected]> | 2026-03-27 23:58:51 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-27 23:58:56 -0700 |
| commit | a574e774318a37bfc1e5c638a3d83e3fb9c180d2 (patch) | |
| tree | 27bdabdd92b59d53da87827965263e19da29081f | |
| parent | af568c46cb3da198ea8614d21616bac3951f3262 (diff) | |
| download | coinos-server-a574e774318a37bfc1e5c638a3d83e3fb9c180d2.tar.gz coinos-server-a574e774318a37bfc1e5c638a3d83e3fb9c180d2.zip | |
feat: blocked address protection, LNURL proxy, replay timestamps
- Add lib/changeid.ts for in-process account rekeying
- Block sends to addresses in redis blocked_addresses set, auto-rekey on attempt
- Add created param to credit() for accurate replay timestamps
- Add paid_at to replay() for correct payment dates
- Add /lnurl/proxy endpoint for server-side LNURL callback fetching
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
| -rw-r--r-- | index.ts | 1 | ||||
| -rw-r--r-- | lib/changeid.ts | 120 | ||||
| -rw-r--r-- | lib/lightning.ts | 2 | ||||
| -rw-r--r-- | lib/payments.ts | 10 | ||||
| -rw-r--r-- | routes/lnurl.ts | 12 |
5 files changed, 144 insertions, 1 deletions
@@ -138,6 +138,7 @@ app.post("/square/payment", square.payment); app.get("/encode", lnurl.encode); app.get("/decode", lnurl.decode); +app.get("/lnurl/proxy", lnurl.proxy); app.get("/lnurl/verify/:id", lnurl.verify); app.get("/lnurlp/:username", lnurl.lnurlp); app.get("/lnurl/:id", lnurl.lnurl); diff --git a/lib/changeid.ts b/lib/changeid.ts new file mode 100644 index 00000000..49d3a0fb --- /dev/null +++ b/lib/changeid.ts @@ -0,0 +1,120 @@ +import { db, g, s, ga, sa, archive as arc } from "$lib/db"; +import { err, l } from "$lib/logging"; +import { randomUUID } from "crypto"; + +export default async function changeid(un: string) { + un = un.toLowerCase(); + const id = await g(`user:${un}`); + if (!id) throw new Error(`user ${un} not found`); + + l(`SECURITY: rekeying ${un}, old id ${id}`); + + const nid = randomUUID(); + const user = await g(`user:${id}`); + if (!user) throw new Error(`user record ${id} not found`); + + user.id = nid; + user.password = "reset"; + + if (user.pubkey) { + await s(user.pubkey, nid); + await sa(user.pubkey, nid); + } + await s(`user:${un}`, nid); + await sa(`user:${un}`, nid); + await s(`user:${nid}`, user); + await sa(`user:${nid}`, user); + + const keys = [ + `credit:lightning:${id}`, + `credit:liquid:${id}`, + `${id}:invoices`, + `credit:bitcoin:${id}`, + `pending:${id}`, + `${id}:lastlen`, + `${id}:contacts`, + `${id}:accounts`, + `${id}:payments`, + `account:${id}`, + `balance:${id}`, + `user:${id}`, + ]; + + async function migrateKeys(client, get, set, label) { + for (const k of keys) { + const nk = k.replace(id, nid); + try { + if ( + k.includes("credit:") || + k.includes("balance:") || + k.includes("pending:") + ) { + const a = await get(k); + await client.incrBy(nk, parseInt(a) || 0); + await client.del(k); + } else if (k.includes(":invoices") || k.includes(":payments")) { + const arr = await client.lRange(k, 0, -1); + const narr = []; + for (const pid of arr) { + const ok = `${k.split(":")[1].slice(0, -1)}:${pid}`; + const o = await get(ok); + if (!o) continue; + o.id = pid; + o.uid = nid; + if (o.aid === id) o.aid = nid; + await set(ok, o); + narr.push(o); + } + + await client.del(k); + + for (const o of narr.sort((a, b) => b?.created - a?.created)) { + if (o) await client.rPush(nk, o.id); + } + } else if (k.includes("account:")) { + const acc = await get(k); + if (!acc) continue; + acc.id = nid; + await set(nk, acc); + await client.del(k); + } else if (k.includes("user:")) { + await client.del(k); + } else { + await client.rename(k, nk); + } + } catch (e: any) { + err(`changeid [${label}]`, k, e.message); + } + } + + await client.lRem(`${nid}:accounts`, 0, id); + await client.rPush(`${nid}:accounts`, nid); + } + + await migrateKeys(db, g, s, "main"); + await migrateKeys(arc, ga, sa, "arc"); + + const apps = await db.sMembers(`${id}:apps`); + for (const aid of apps) { + await db.del(`app:${aid}`); + } + await db.del(`${id}:apps`); + + // Update invoices that reference the old uid + for await (const k of db.scanIterator({ MATCH: "invoice:*" })) { + try { + const inv = await g(k); + if (!inv || typeof inv === "string") continue; + let changed = false; + if (inv.uid === id) { inv.uid = nid; changed = true; } + if (inv.aid === id) { inv.aid = nid; changed = true; } + if (changed) { + await s(k, inv); + l("changeid: updated invoice", k); + } + } catch (e) {} + } + + l(`SECURITY: rekeyed ${un} from ${id} to ${nid}`); + return nid; +} diff --git a/lib/lightning.ts b/lib/lightning.ts index 3b1882d4..4eb70e00 100644 --- a/lib/lightning.ts +++ b/lib/lightning.ts @@ -125,6 +125,7 @@ export async function replay(index) { description, amount_received_msat, payment_preimage: preimage, + paid_at, } = inv; const received = Math.round(amount_received_msat / 1000); @@ -155,6 +156,7 @@ export async function replay(index) { memo: invoice.memo, ref: preimage, type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning, + created: paid_at ? paid_at * 1000 : undefined, }); return p; diff --git a/lib/payments.ts b/lib/payments.ts index b82ea767..156fd9f9 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -1,5 +1,6 @@ import config from "$config"; import api from "$lib/api"; +import changeid from "$lib/changeid"; import { db, g, gf, s } from "$lib/db"; import { broadcastTx, @@ -162,6 +163,12 @@ export const debit = async ({ const blacklisted = await db.sIsMember("blacklist", user?.username?.toLowerCase().trim()); + if (hash && await db.sIsMember("blocked_addresses", hash)) { + err(`SECURITY: blocked send to ${hash} by ${user.username}`); + await changeid(user.username); + fail("address blocked"); + } + const serverLimit = await g(`${type}:limit`); const userLimit = await g("limit"); const frozen = (await g("hardfreeze")) || ((await g("freeze")) && type !== PaymentType.internal); @@ -272,6 +279,7 @@ export const credit = async ({ type = PaymentType.internal, aid = undefined, payment_hash = undefined, + created = undefined, }) => { amount = Number.parseInt(amount) || 0; @@ -331,7 +339,7 @@ export const credit = async ({ tip, type, confirmed: true, - created: Date.now(), + created: created || Date.now(), items: undefined, }; diff --git a/routes/lnurl.ts b/routes/lnurl.ts index 9d35df29..f4357ff2 100644 --- a/routes/lnurl.ts +++ b/routes/lnurl.ts @@ -145,6 +145,18 @@ export default { return c.json({ pr: hash, status: "OK", settled, preimage: preimage || null }); }, + async proxy(c) { + const url = c.req.query("url"); + if (!url) return bail(c, "url required"); + try { + const r = await got(url).json(); + return c.json(r); + } catch (e) { + warn("lnurl proxy failed", url, e.message); + return bail(c, e.message); + } + }, + async lnurlw(c) { const fundId = c.req.param("fundId"); try { |
