From a2216320de0d6b4eb25cc699456d5d995f267590 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 19 Sep 2026 21:53:30 -0700 Subject: ecash: identify our own tokens by keyset, not by the mint url The mint url in a cashu token is a label the sending wallet writes in, and tokens of ours turn up carrying stray ports and paths. Fetching it to compare pubkeys therefore rejected perfectly good tokens, and meant every /cash entry point made an outbound request to a user-supplied address. Keyset ids are derived from the mint's public keys, so a token whose proofs are all on our keysets is ours whatever url it names; a foreign one still fails, and now with a clear message instead of cashu-ts's short-id mapping error. A miss refreshes the cached keyset list once, in case of a rotation. Co-Authored-By: Claude Opus 5 (1M context) --- lib/ecash.ts | 50 +++++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 21 deletions(-) (limited to 'lib') diff --git a/lib/ecash.ts b/lib/ecash.ts index e9e099c3..599bd7b0 100644 --- a/lib/ecash.ts +++ b/lib/ecash.ts @@ -2,7 +2,6 @@ import config from "$config"; import { g } from "$lib/db"; import { generate } from "$lib/invoices"; import { warn } from "$lib/logging"; -import { safeGot } from "$lib/safe-fetch"; import { PaymentType } from "$lib/types"; import { fail, getPayment, sleep, wait } from "$lib/utils"; import { @@ -38,21 +37,31 @@ const keysets = async () => { return keysets; }; -const decode = async (token) => getDecodedToken(token, await keysets()); - -// `mint` comes straight out of an attacker-supplied cashu token, and this is -// the first thing we do with it — so fetching it with cashu-ts's plain fetch -// made every /cash and /ecash entry point an unauthenticated SSRF probe into -// the internal network. Use the same IP-pinning fetcher lnurl resolution uses -// (it rejects loopback/private/link-local/CGNAT/metadata targets and closes the -// DNS-rebinding window by pinning the socket to the IP it validated). NUT-06 -// /v1/info is exactly what CashuMint.getInfo() would have called. -const ext = async (mint) => { - const { pubkey: issuerPk } = await safeGot( - `${String(mint).replace(/\/+$/, "")}/v1/info`, - ); - const { pubkey: ourPk } = await m.getInfo(); - return issuerPk !== ourPk; +// A short keyset id that doesn't expand against our keysets belongs to some +// other mint; say so instead of surfacing cashu-ts's mapping error. +const decode = async (token) => { + try { + return getDecodedToken(token, await keysets()); + } catch (e) { + if (/short keyset id/i.test(e.message)) + fail("Unable to receive from other mints"); + throw e; + } +}; + +// A token is ours when every proof is on one of our keysets. Keyset ids are +// derived from the mint's public keys, so they identify the issuer; the token's +// `mint` URL is just a label the sending wallet wrote in (tokens of ours turn +// up with stray ports and paths on it) and is never fetched. A miss refreshes +// the cached list once in case a keyset was rotated in since it was fetched. +const ext = async (proofs) => { + const ours = (ks) => { + const ids = new Set(ks.map((k) => k.id)); + return proofs.every((p) => ids.has(p.id)); + }; + if (ours(await keysets())) return false; + cached = undefined; + return !ours(await keysets()); }; const sum = (proofs) => proofs.reduce((a, p) => a + p.amount, 0); @@ -83,9 +92,9 @@ export async function get(id) { // the amount the user receives: the token's value less the mint's input fee // and Lightning fee reserve. export async function redeem({ token, user, invoice = undefined, memo = undefined }) { - const { mint, proofs } = await decode(token); + const { proofs } = await decode(token); if (!proofs?.length) fail("Token has no proofs"); - if (await ext(mint)) fail("Unable to receive from other mints"); + if (await ext(proofs)) fail("Unable to receive from other mints"); // Cheap pre-check so a spent token fails before an invoice is created for // it (the melt would reject it anyway, but not before leaving an unpaid @@ -146,10 +155,9 @@ export async function check(token) { const { mint, proofs } = await decode(token); const total = sum(proofs); - const external = await ext(mint); + const external = await ext(proofs); - // Only ask our own mint about proof states; an external mint URL is never - // contacted with cashu-ts's unpinned fetch. + // Only ask our own mint about proof states. let spent = 0; if (!external) { const w = new CashuWallet(m); -- cgit v1.2.3