Files
minibits_wallet/src/services/cashu/mintUrl.ts
T
minibits-cashandClaude Opus 4.8 9d759f83ab Key derivation counters by keysetId; make mint URL change safe
A mint URL is a network locator, not an identity, but it had become the de
facto foreign key for most persisted state. Changing a mint's URL therefore
had to rewrite every one of those references, and only proofs were ever
rewritten. This removes the URL from the key space where it never belonged,
and repairs the rename for what remains.

Counters (the fund-losing one)

mint_counters was PRIMARY KEY (mintUrl, keysetId), which asserts a key space
that does not exist: NUT-13 derives from (seed, keysetId, counter), with no
mint component in either path (`m/129372'/0'/{keysetIdInt}'/{counter}'` for
`00` ids, HMAC-SHA256 over the id for v2 `01`). Two rows could track ONE
derivation path independently, and a URL edit left the row unaddressable —
hydration matched on URL, found nothing, and silently restarted the counter
at 0, reusing blinded secrets the mint had already signed.

Re-keyed on keysetId alone, which is sound because keyset ids are already
globally unique wallet-wide, enforced at the door by isCollidingKeysetId per
NUT-02. Migration 32 collapses duplicates to MAX(counter), so it HEALS
wallets already split by this rather than only preventing new splits — a
too-high counter skips indices, a too-low one reuses them.

This also deletes code: persistCounter no longer walks getParent() for a URL,
so a counter detached from its Mint now persists instead of dropping its
write.

Keyset collisions and NUT-02 v2

isCollidingKeysetId applied the mod-2^31-1 keysetIdInt check to every id. That
integer only exists on the deprecated BIP-32 path; v2 ids derive by HMAC over
the full 32 bytes and never compute it. Checking it there would reject a
legitimate mint over a number nothing consumes, and re-impose v1's ~2^31
birthday bound on ids whose whole point is full-width SHA-256 resistance. The
check is now gated on derivation kind; exact-id equality still always applies.

Mint URL change

- Validation is shared with addMint via a new normalizeMintUrl, so adding and
  renaming can no longer disagree. The rename previously did neither the
  trailing-slash strip (NUT-00 MUST) nor the https check.
- Canonical form matches cashu-ts normalizeUrl (`href` then strip trailing
  slashes). WalletStore compares our stored string to CashuMint.mintUrl to
  find cached instances, so normalizing the raw input would let
  `https://Mint.Example` be stored while cashu-ts held `https://mint.example`:
  every cache lookup missing, two spellings looking like two mints. Pinned by
  tests asserting agreement with CashuMint.mintUrl.
- The onion exemption tested `includes('.onion')`, so `http://evil.example/.onion`
  bought a plain-http exemption for an ordinary host. It now tests the parsed
  hostname. `startsWith('https')` also passed `https-evil://host`; now protocol
  equality.
- Duplicate detection uses mintExists (normalized), not alreadyExists
  (literal), which missed a trailing-slash twin and let one real mint become
  two Mint nodes. Renaming to the URL already held is now a no-op, not an error.
- hostname is recomputed; it used to keep the old mint's host forever.
- transactions.mint is repointed for IN-FLIGHT rows only. That column means two
  things by status: for a terminal row it is a historical record of where the
  payment happened, but for an open one it is a live pointer the wallet still
  calls (checkLightningMintQuote, checkLightningMeltQuote/checkOnchainMeltQuote,
  findByUrl on revert/receive). Stale, it strands a paid topup at a dead URL
  forever. One UPDATE, so the status test cannot straddle a transition.
- ProofsStore.updateMintUrl now writes SQLite before memory; the reverse left
  the UI showing a balance the database never received.

Still URL-keyed, and documented on setMintUrl: onchain mint quotes, in-flight
requests, melt recovery and open reservations. Renaming a mint with any of
those outstanding still strands them. They need a stable mint id, which is the
next step.

Tests: 413 pass. The two new v2 collision tests fail against the previous
code and pass here, while the v1 cases pass in both. counters.test.ts mirrored
the production SQL by hand and so had asserted the old key — including a test
that two mints sharing a keyset id keep independent counters, exactly the
unsound behaviour removed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:41:02 +02:00

86 lines
3.8 KiB
TypeScript

import AppError, {Err} from '../../utils/AppError'
/**
* Mint URL normalization and validation — the single definition of what a mint
* url may look like.
*
* Extracted because the two entry points had drifted: `MintsStore.addMint`
* stripped the trailing slash and demanded https, while `Mint.setMintUrl` did
* neither (it only checked `new URL()` parsed). A RENAME could therefore install
* a url that ADDING the same mint would have rejected — most damagingly a
* trailing-slash twin of a mint already held, since the duplicate check compares
* urls literally: two Mint nodes for one real mint, each accumulating its own
* state.
*
* Kept free of model imports so both callers can use it without a cycle.
*/
/**
* Whether the url points at a Tor hidden service, which is exempt from the https
* requirement (onion routing already authenticates the endpoint).
*
* Tests the parsed HOSTNAME, not the raw string. A substring test for '.onion'
* (what addMint used to do) also matches a path or query — `http://evil.com/.onion`
* would earn a plain-http exemption for an ordinary host.
*/
export const isOnionMintUrl = function (mintUrl: string): boolean {
try {
return new URL(mintUrl).hostname.endsWith('.onion')
} catch {
return false
}
}
/**
* Normalize a mint url to its canonical form, or throw AppError(VALIDATION_ERROR).
*
* Two rules, from different authorities:
*
* 1. NUT-00 requires the trailing slash be gone. On the v3 token: "The mint URL
* must be stripped of any trailing slashes (/)"; on v4: "The mint URL MUST be
* normalized by stripping any trailing slashes (/)". That is the whole of what
* the spec mandates — it says nothing about case or any other form.
*
* 2. cashu-ts canonicalizes further, and we MUST match it. `new CashuMint(url)`
* stores `normalizeUrl(url)` = `parsed.href` with trailing slashes stripped,
* which also lowercases scheme and host and drops a default port. WalletStore
* compares our stored string against that value directly (`m.mintUrl ===
* mintUrl`, `w.mint.mintUrl === mintUrl`) to find cached CashuMint/CashuWallet
* instances. Normalizing the raw input instead would let `https://Mint.Example`
* be stored while cashu-ts holds `https://mint.example`: every cache lookup
* misses, and the wallet would treat the two spellings as two different mints.
* cashu-ts's normalizeUrl is @internal (not exported), hence the reimplementation
* here — it must be kept in step with it.
*
* The https requirement is ours alone and stricter than cashu-ts, which permits
* http for any host.
*
* cashu-ts additionally rejects credentials, query strings, fragments and
* percent-encoded paths. Those are deliberately NOT re-checked here: both callers
* construct a CashuMint against the url before anything is stored, so cashu-ts
* raises them itself — duplicating the rules would only invite drift.
*/
export const normalizeMintUrl = function (mintUrl: string): string {
if (!mintUrl || !mintUrl.trim()) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL is required.')
}
let parsed: URL
try {
parsed = new URL(mintUrl.trim())
} catch {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid Mint URL.', {mintUrl})
}
// Protocol equality, not `startsWith('https')` — the latter also accepts a
// scheme merely PREFIXED with https (`https-evil://host` parses fine).
if (parsed.protocol !== 'https:' && !isOnionMintUrl(parsed.href)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL needs to start with https.', {mintUrl})
}
// `href` first (canonical), THEN strip: the parser appends a trailing slash to
// an origin-only url, so stripping last removes both that and any the caller
// typed. Identical to cashu-ts normalizeUrl.
return parsed.href.replace(/\/+$/, '')
}