@forgesworn/moneyer 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-08-23
4
+
5
+ - **Both doors, named.** With `MONEYER_ONION_URL` and
6
+ `MONEYER_PUBLIC_ORIGIN` both set, `/w` and the mint address now carry
7
+ `mirrors`: this mint's other origin. Both serve the same notes - a note
8
+ is keyed by `sha256(k1)` and not by host - but a note travels as a URL,
9
+ and a URL names one host, so an onion note handed to somebody without
10
+ Tor is unspendable to them. Naming the other door is what lets a wallet
11
+ offer the holder a way across.
12
+ - Absent rather than empty when there is only one door, because a wallet
13
+ reading `[]` would think it had been told something. The hourly Nostr
14
+ announcement carries it too.
15
+ - Documented, because it is a real limit rather than a missing feature: a
16
+ wallet must **not** fail over to a mirror on its own. Every request that
17
+ identifies a note carries the k1, and nothing in LUD-25 lets a host
18
+ prove it is the same mint before receiving that secret - a hostile
19
+ discovery document can name any host and claim any `mintPubkey`, since
20
+ claiming is not proving. Closing it needs a signed challenge the spec
21
+ does not have.
22
+
23
+ ## [0.4.0] - 2026-08-23
24
+
25
+ - **Reaching the mint over Tor.** `MONEYER_ONION_URL` names this mint's
26
+ hidden service, and a request arriving on that host gets every URL built
27
+ from it: the callback, the `withdrawLink`, the `payLink`, the lightning
28
+ address in the metadata. Without it a Tor visitor was answered with the
29
+ clearnet origin, so their wallet was told to leave Tor to finish the job
30
+ - the callback fetch going out over clearnet, from their address, naming
31
+ the mint they bank with. That is worse than not working, because it looks
32
+ like it worked.
33
+ - The Host header **chooses** between origins here and never builds one.
34
+ `MONEYER_PUBLIC_ORIGIN` exists because that header is attacker
35
+ controlled, and the property is unchanged: the worst a forged Host can do
36
+ is get back the onion URL instead of the clearnet one, and the operator
37
+ configured both.
38
+
3
39
  ## [0.3.3] - 2026-08-22
4
40
 
5
41
  - **Refusals are logged, so an operator can see why a wallet is failing.**
package/README.md CHANGED
@@ -76,6 +76,7 @@ default, and a variable set to an empty string counts as unset.
76
76
  | `MONEYER_HOST` | `127.0.0.1` | listen address |
77
77
  | `MONEYER_PORT` | `3737` | listen port |
78
78
  | `MONEYER_PUBLIC_ORIGIN` | derived from `Host` | the origin wallets are told to call back on. Required behind a reverse proxy, and required for zap-to-note |
79
+ | `MONEYER_ONION_URL` | | this mint as a Tor hidden service, e.g. `http://<v3>.onion`. A request arriving on that host gets its URLs built from it (see below) |
79
80
  | `MONEYER_USERNAME` | `mint` | the local part of the mint's own lightning address |
80
81
  | `MONEYER_DESCRIPTION` | `an LNURLcash note` | what a note is called on the wire (`defaultDescription`, and `description` on discovery) |
81
82
  | `MONEYER_DB` | `moneyer.sqlite` | SQLite path; `:memory:` allowed |
@@ -489,6 +490,59 @@ A registered name resolves on both rails at once:
489
490
  The discovery endpoint advertises `namePriceMsat` while registration is
490
491
  open, so a wallet can offer the flow without asking.
491
492
 
493
+ ## Reaching the mint over Tor
494
+
495
+ Set `MONEYER_ONION_URL` to this mint's hidden service address and a request
496
+ that arrives on that host gets every URL built from it: the callback, the
497
+ `withdrawLink`, the `payLink`, the lightning address in the metadata.
498
+
499
+ Without it a Tor visitor is answered with the clearnet origin, and their
500
+ wallet is then told to leave Tor to finish the job - the callback fetch
501
+ goes out over clearnet, from their address, naming the mint they bank
502
+ with. That is worse than not working, because it looks like it worked.
503
+
504
+ The Host header **chooses** between origins here; it never builds one.
505
+ `MONEYER_PUBLIC_ORIGIN` exists precisely because the header is attacker
506
+ controlled, and that property is unchanged: the worst a forged Host can do
507
+ is get back the onion URL instead of the clearnet one, and the operator
508
+ configured both. Anything else falls through to `MONEYER_PUBLIC_ORIGIN`.
509
+
510
+ ### Serving both audiences at once
511
+
512
+ Both doors serve the **same notes**. A note is keyed by `sha256(k1)` and
513
+ not by host, so a k1 struck on the onion is valid at the clearnet host and
514
+ the other way round. What is bound to a host is only the URL a note
515
+ travels as - and an onion note handed to somebody without Tor is
516
+ unspendable to them, while a clearnet note redeemed over Tor is private
517
+ only if their wallet proxies.
518
+
519
+ So when both are configured, `/w` and the mint address name the other one
520
+ in `mirrors`:
521
+
522
+ ```json
523
+ {"tag": "withdrawRequest", "callback": "http://<v3>.onion/w/cb",
524
+ "mirrors": ["https://mint.example"], "mintPubkey": "..."}
525
+ ```
526
+
527
+ Absent when there is only one door, rather than an empty array - a wallet
528
+ reading `[]` would think it had been told something. The hourly Nostr
529
+ announcement carries it too, so a wallet that discovers this mint on a
530
+ relay learns both doors before it holds anything.
531
+
532
+ **A wallet must not fail over to a mirror on its own.** Every request that
533
+ identifies a note carries the k1, so switching hosts discloses a bearer
534
+ secret to the new one, and nothing in LUD-25 lets a host prove it is the
535
+ same mint before receiving that secret - a hostile discovery document can
536
+ name any host and claim any `mintPubkey`, because claiming is not proving.
537
+ Show the holder the other door and let them choose it. Closing that
538
+ properly needs a challenge the mint signs, which the spec does not have
539
+ yet.
540
+
541
+ Running the hidden service itself is not this mint's job. Point a Tor
542
+ `HiddenServiceDir` (or a proxy that speaks onion) at whatever host and port
543
+ it already listens on, the same way you would front it with Caddy for
544
+ clearnet.
545
+
492
546
  ## Endpoints
493
547
 
494
548
  | | |
package/dist/config.d.ts CHANGED
@@ -21,6 +21,7 @@ export type MoneyerConfig = {
21
21
  host: string;
22
22
  port: number;
23
23
  publicOrigin?: string;
24
+ onionUrl?: string;
24
25
  username: string;
25
26
  description: string;
26
27
  name?: string;
package/dist/config.js CHANGED
@@ -139,6 +139,32 @@ export const configFromEnv = (env = process.env) => {
139
139
  throw new Error(`MONEYER_PUBLIC_ORIGIN must be http or https, got ${JSON.stringify(publicOrigin)}.`);
140
140
  }
141
141
  }
142
+ // The same mint reached over Tor. A hidden service is a different
143
+ // origin, and a mint that answers a Tor visitor with its clearnet URL
144
+ // has told that visitor's wallet to leave Tor to finish the job - which
145
+ // is both broken and the exact thing they came here to avoid.
146
+ //
147
+ // Setting this does not weaken the reason publicOrigin exists. The Host
148
+ // header is spoofable, which is why it is not trusted to BUILD an
149
+ // origin; it is only used to CHOOSE between origins the operator
150
+ // configured. The worst a forged Host can do is get the onion URL back
151
+ // instead of the clearnet one, and the operator set both.
152
+ const onionUrl = env.MONEYER_ONION_URL;
153
+ if (onionUrl) {
154
+ let parsed;
155
+ try {
156
+ parsed = new URL(onionUrl);
157
+ }
158
+ catch {
159
+ throw new Error(`MONEYER_ONION_URL is not a URL: ${JSON.stringify(onionUrl)}.`);
160
+ }
161
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
162
+ throw new Error(`MONEYER_ONION_URL must be http or https, got ${JSON.stringify(onionUrl)}.`);
163
+ }
164
+ if (!parsed.hostname.endsWith('.onion')) {
165
+ throw new Error(`MONEYER_ONION_URL must be a .onion address, got ${JSON.stringify(parsed.hostname)}.`);
166
+ }
167
+ }
142
168
  const motd = text(env.MONEYER_MOTD);
143
169
  if (motd !== undefined && motd.length > MOTD_MAX) {
144
170
  throw new Error(`MONEYER_MOTD must be at most ${MOTD_MAX} characters - it is a banner, not a page.`);
@@ -174,6 +200,7 @@ export const configFromEnv = (env = process.env) => {
174
200
  host: env.MONEYER_HOST ?? DEFAULTS.host,
175
201
  port: int(env.MONEYER_PORT, DEFAULTS.port),
176
202
  ...(publicOrigin ? { publicOrigin } : {}),
203
+ ...(onionUrl ? { onionUrl } : {}),
177
204
  username: env.MONEYER_USERNAME ?? DEFAULTS.username,
178
205
  description: env.MONEYER_DESCRIPTION ?? DEFAULTS.description,
179
206
  ...(name ? { name } : {}),
package/dist/server.js CHANGED
@@ -208,7 +208,7 @@ export const createMoneyer = async (config, deps = {}) => {
208
208
  // LUD-25 discovery, built here rather than inline in its route because
209
209
  // the mint also announces itself with it, and there must be one
210
210
  // description of a mint rather than two that can drift apart.
211
- const mintAddressDocument = (origin, user) => ({
211
+ const mintAddressDocument = (origin, user, mirrors = []) => ({
212
212
  tag: 'withdrawRequest',
213
213
  callback: `${origin}/w`,
214
214
  minWithdrawable: config.minMintMsat,
@@ -217,6 +217,7 @@ export const createMoneyer = async (config, deps = {}) => {
217
217
  : config.maxSendableMsat,
218
218
  defaultDescription: config.description,
219
219
  payLink: `${origin}/.well-known/lnurlp/${user}`,
220
+ ...(mirrors.length ? { mirrors } : {}),
220
221
  ...(signer ? { mintPubkey: signer.pubkey } : {}),
221
222
  // The human layer: who runs this, how to reach them, the terms,
222
223
  // and today's message. Absent unless the operator set it.
@@ -294,7 +295,7 @@ export const createMoneyer = async (config, deps = {}) => {
294
295
  kind: ANNOUNCE_KIND,
295
296
  created_at: Math.floor(Date.now() / 1000),
296
297
  tags: [['d', ANNOUNCE_D_TAG]],
297
- content: announcementContent(mintAddressDocument(config.publicOrigin, config.username), config.signingKey)
298
+ content: announcementContent(mintAddressDocument(config.publicOrigin, config.username, config.onionUrl ? [config.onionUrl] : []), config.signingKey)
298
299
  }, hexToBytes(config.zap.nostrKey));
299
300
  const { ok, failed } = await nostr.publish(config.zap.relays, event);
300
301
  log(`mint announced to ${ok.length} relay${ok.length === 1 ? '' : 's'}${failed.length ? `, ${failed.length} refused` : ''}`);
@@ -302,7 +303,23 @@ export const createMoneyer = async (config, deps = {}) => {
302
303
  const handle = async (req, res) => {
303
304
  const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
304
305
  const q = requestUrl.searchParams;
305
- const origin = config.publicOrigin ?? `http://${req.headers.host ?? '127.0.0.1'}`;
306
+ // Which of this mint's own origins the caller reached it on. The Host
307
+ // header chooses between them and never builds one: it is attacker
308
+ // controlled, and the worst a forged value can do here is get back an
309
+ // origin the operator configured anyway.
310
+ const onionHost = config.onionUrl ? new URL(config.onionUrl).host.toLowerCase() : null;
311
+ const askedFor = (req.headers.host ?? '').toLowerCase();
312
+ const origin = onionHost && askedFor === onionHost
313
+ ? config.onionUrl
314
+ : (config.publicOrigin ?? `http://${req.headers.host ?? '127.0.0.1'}`);
315
+ // This mint's OTHER doors. A note is keyed by sha256(k1) and not by
316
+ // host, so every one of these serves the same notes - but a note
317
+ // travels as a URL, and a URL names one host. An onion note handed to
318
+ // somebody without Tor is unspendable to them, and a clearnet note
319
+ // redeemed over Tor is only private if their wallet proxies. Saying
320
+ // which other host answers is what lets a wallet move a note between
321
+ // them instead of the holder finding out it cannot.
322
+ const mirrors = [config.publicOrigin, config.onionUrl].filter((candidate) => Boolean(candidate) && candidate !== origin);
306
323
  const host = new URL(origin).host;
307
324
  const send = (body, status = 200) => {
308
325
  res.writeHead(status, {
@@ -547,7 +564,7 @@ export const createMoneyer = async (config, deps = {}) => {
547
564
  if (lnurlwMatch) {
548
565
  if (!knownUser(lnurlwMatch[1]))
549
566
  return fail('Unknown user.', 404);
550
- return send(mintAddressDocument(origin, lnurlwMatch[1]));
567
+ return send(mintAddressDocument(origin, lnurlwMatch[1], mirrors));
551
568
  }
552
569
  // ---- LUD-06 pay callback: issue a mint invoice ----
553
570
  if (requestUrl.pathname === '/p/cb') {
@@ -717,6 +734,10 @@ export const createMoneyer = async (config, deps = {}) => {
717
734
  // signing keys. Without it a wallet that only ever received notes
718
735
  // cannot tell an announced key rotation from a substituted key.
719
736
  payLink: `${origin}/.well-known/lnurlp/${config.username}`,
737
+ // Where else this same note can be redeemed. A holder with nothing
738
+ // but a note learns from the note itself that the other door
739
+ // exists.
740
+ ...(mirrors.length ? { mirrors } : {}),
720
741
  ...(signer ? { mintPubkey: signer.pubkey } : {})
721
742
  });
722
743
  }
package/llms.txt CHANGED
@@ -65,6 +65,11 @@ verifyAnnouncement(content, mintPubkey) -> {valid, document}
65
65
  discovery document + sig by the note signing key. MONEYER_ANNOUNCE=true,
66
66
  off by default.
67
67
  configFromEnv(env?) -> MoneyerConfig MONEYER_* environment variables
68
+ MONEYER_ONION_URL: this mint as a Tor hidden service. A request arriving on
69
+ that Host gets every URL (callback, withdrawLink, payLink, the address in
70
+ the metadata) built from it; anything else falls through to
71
+ MONEYER_PUBLIC_ORIGIN. The Host header only CHOOSES between origins the
72
+ operator configured - it never builds one.
68
73
  createFakeBackend() -> LightningBackend with .control test hooks
69
74
  createClnBackend({url, rune}) / createLndBackend({url, macaroon})
70
75
  createNoteSigner(privHex) -> {pubkey, sign(noteId, amountMsat)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "An LNURLcash (LUD-25) mint - strikes Lightning bearer notes. Independent implementation, cln/lnd funding sources, SQLite, zero HTTP framework.",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",