@forgesworn/moneyer 0.3.2 → 0.4.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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-08-23
4
+
5
+ - **Reaching the mint over Tor.** `MONEYER_ONION_URL` names this mint's
6
+ hidden service, and a request arriving on that host gets every URL built
7
+ from it: the callback, the `withdrawLink`, the `payLink`, the lightning
8
+ address in the metadata. Without it a Tor visitor was answered with the
9
+ clearnet origin, so their wallet was told to leave Tor to finish the job
10
+ - the callback fetch going out over clearnet, from their address, naming
11
+ the mint they bank with. That is worse than not working, because it looks
12
+ like it worked.
13
+ - The Host header **chooses** between origins here and never builds one.
14
+ `MONEYER_PUBLIC_ORIGIN` exists because that header is attacker
15
+ controlled, and the property is unchanged: the worst a forged Host can do
16
+ is get back the onion URL instead of the clearnet one, and the operator
17
+ configured both.
18
+
19
+ ## [0.3.3] - 2026-08-22
20
+
21
+ - **Refusals are logged, so an operator can see why a wallet is failing.**
22
+ A holder reported "notes already spent" on every melt and the journal for
23
+ that whole day carried startup banners and one invoice sweep - nothing to
24
+ compare the report against. Refusals now log the method, the path and the
25
+ reason. Never the query string, because a note URL carries the k1 and log
26
+ files travel; and never the informational GET, which a single restore
27
+ walks twenty-odd unknown indexes of by design.
28
+
3
29
  ## [0.3.2] - 2026-08-22
4
30
 
5
31
  - **A note with a melt in flight is no longer advertised as withdrawable.**
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,28 @@ 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
+ Running the hidden service itself is not this mint's job. Point a Tor
511
+ `HiddenServiceDir` (or a proxy that speaks onion) at whatever host and port
512
+ it already listens on, the same way you would front it with Caddy for
513
+ clearnet.
514
+
492
515
  ## Endpoints
493
516
 
494
517
  | | |
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
@@ -302,7 +302,15 @@ export const createMoneyer = async (config, deps = {}) => {
302
302
  const handle = async (req, res) => {
303
303
  const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
304
304
  const q = requestUrl.searchParams;
305
- const origin = config.publicOrigin ?? `http://${req.headers.host ?? '127.0.0.1'}`;
305
+ // Which of this mint's own origins the caller reached it on. The Host
306
+ // header chooses between them and never builds one: it is attacker
307
+ // controlled, and the worst a forged value can do here is get back an
308
+ // origin the operator configured anyway.
309
+ const onionHost = config.onionUrl ? new URL(config.onionUrl).host.toLowerCase() : null;
310
+ const askedFor = (req.headers.host ?? '').toLowerCase();
311
+ const origin = onionHost && askedFor === onionHost
312
+ ? config.onionUrl
313
+ : (config.publicOrigin ?? `http://${req.headers.host ?? '127.0.0.1'}`);
306
314
  const host = new URL(origin).host;
307
315
  const send = (body, status = 200) => {
308
316
  res.writeHead(status, {
@@ -312,7 +320,20 @@ export const createMoneyer = async (config, deps = {}) => {
312
320
  });
313
321
  res.end(JSON.stringify(body));
314
322
  };
315
- const fail = (reason, status = 200) => send({ status: 'ERROR', reason }, status);
323
+ // Refusals go to the operator's log, because a mint that refuses in
324
+ // silence cannot be debugged from the outside: a wallet shows its user
325
+ // "already spent" and the operator has nothing at all to compare it to.
326
+ //
327
+ // The PATHNAME only, never the query string: a note URL carries the k1,
328
+ // and a k1 is the money. And never the informational GET, which a
329
+ // single restore walks twenty-odd unknown indexes of by design - that
330
+ // is not news, and burying the rare refusals under it defeats the point.
331
+ const fail = (reason, status = 200) => {
332
+ if (requestUrl.pathname !== '/w') {
333
+ log(`refused ${req.method ?? 'GET'} ${requestUrl.pathname}: ${reason}`);
334
+ }
335
+ send({ status: 'ERROR', reason }, status);
336
+ };
316
337
  const knownUser = (user) => user === config.username || user === '_';
317
338
  // ---- the mint's face ----
318
339
  if (requestUrl.pathname === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
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.2",
3
+ "version": "0.4.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",