aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-19 21:53:30 -0700
committerAdam Soltys <[email protected]>2026-09-21 19:23:57 -0700
commita2216320de0d6b4eb25cc699456d5d995f267590 (patch)
tree2be4dca569139b2155f3a6d7553b36a1118ab10c
parent4ac8ff322a71465542162c17072ae6be524aaf6f (diff)
downloadcoinos-server-a2216320de0d6b4eb25cc699456d5d995f267590.tar.gz
coinos-server-a2216320de0d6b4eb25cc699456d5d995f267590.zip
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) <[email protected]>
-rw-r--r--lib/ecash.ts48
1 files changed, 28 insertions, 20 deletions
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());
+// 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;
+ }
+};
-// `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 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);