thunder-bridge 0.8.6 → 0.8.9

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/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-CdKeyVGn.js';
2
- export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-CdKeyVGn.js';
1
+ import { T as ThunderBridge } from './rail-J8QoYbSr.js';
2
+ export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-J8QoYbSr.js';
3
3
 
4
4
  interface TriggerConfig {
5
5
  /** The gateway that quotes the addresses and mints the invoice */
@@ -61,4 +61,43 @@ interface Minted {
61
61
  */
62
62
  declare function lnurlPayEndpoint(config: TriggerConfig): (request: Request) => Promise<Response>;
63
63
 
64
- export { type Minted, type TriggerConfig, lnurlPayEndpoint };
64
+ /** The wallet's own LUD-21 URL and the hash its preimage has to match */
65
+ interface Relayed {
66
+ url: string;
67
+ hash: string;
68
+ }
69
+ interface LightningVerifyConfig {
70
+ /** The secret the sealed wallet URL was made with, and nothing else uses it */
71
+ secret: string;
72
+ /**
73
+ * How often you want the gateway to ask, in seconds. It goes out as
74
+ * `Cache-Control: max-age`, so the pace is yours rather than the operator's.
75
+ * Five by default, which is what a Lightning checkout wants
76
+ */
77
+ pollEverySecs?: number;
78
+ }
79
+ /**
80
+ * A verify endpoint of your own that asks the recipient's wallet for you, so the
81
+ * gateway polls you and never the wallet.
82
+ *
83
+ * `relayedVerifyUrl` seals the wallet's own LUD-21 URL into the query with your
84
+ * secret, so what the gateway stores and replicates is opaque: not the wallet's
85
+ * host, not which provider the recipient uses, nothing but a blob it cannot read.
86
+ * This handler unseals it, asks the wallet, and answers the same LUD-21 shape.
87
+ *
88
+ * It relays rather than decides. The preimage still comes from the recipient's
89
+ * own server and still has to hash to the payment hash, so standing between the
90
+ * two buys privacy and pacing without becoming something anyone has to trust.
91
+ *
92
+ * A wallet it cannot reach answers `502` rather than "not settled", because those
93
+ * are different claims and only one of them is true. The gateway logs a failed
94
+ * poll and asks again, which is what a broken relay should look like.
95
+ */
96
+ declare function lightningVerifyEndpoint(config: LightningVerifyConfig): (request: Request) => Promise<Response>;
97
+ /**
98
+ * The URL to hand the gateway instead of the wallet's own, with the wallet's
99
+ * sealed inside it. Point it at wherever `lightningVerifyEndpoint` is mounted
100
+ */
101
+ declare function relayedVerifyUrl(endpoint: string, wallet: Relayed, secret: string): Promise<string>;
102
+
103
+ export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, lightningVerifyEndpoint, lnurlPayEndpoint, relayedVerifyUrl };
package/dist/server.js CHANGED
@@ -4,6 +4,16 @@ function bytesToHex(bytes) {
4
4
  for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
5
5
  return hex;
6
6
  }
7
+ function hexToBytes(hex) {
8
+ const bytes = new Uint8Array(hex.length >> 1);
9
+ for (let at = 0; at < bytes.length; at++) {
10
+ bytes[at] = parseInt(hex.slice(at * 2, at * 2 + 2), 16);
11
+ }
12
+ return bytes;
13
+ }
14
+ function isHex(text2) {
15
+ return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
16
+ }
7
17
 
8
18
  // ../core/hmac.ts
9
19
  async function hmacHex(secret, payload) {
@@ -211,6 +221,10 @@ function decodeInvoice(bolt11) {
211
221
  expiresAt: expiryOf(parts.words, tagged)
212
222
  };
213
223
  }
224
+ function preimageMatchesHash(preimage, paymentHash) {
225
+ if (!isHex(preimage)) return false;
226
+ return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
227
+ }
214
228
  function splitBech32(bolt11) {
215
229
  const lower = bolt11.toLowerCase();
216
230
  if (isBolt12Encoding(lower)) return null;
@@ -391,19 +405,19 @@ async function refuseUnlessPublic(url) {
391
405
  if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
392
406
  }
393
407
  async function answerOf(response) {
394
- const { status, ok } = response;
408
+ const { status, ok, headers } = response;
395
409
  const reader = response.body?.getReader();
396
- if (!reader) return { status, ok, body: "", truncated: false };
410
+ if (!reader) return { status, ok, body: "", truncated: false, headers };
397
411
  const read = [];
398
412
  let bytes = 0;
399
413
  while (bytes <= BODY_LIMIT_BYTES) {
400
414
  const { done, value } = await reader.read();
401
- if (done) return { status, ok, body: text(read), truncated: false };
415
+ if (done) return { status, ok, body: text(read), truncated: false, headers };
402
416
  read.push(value);
403
417
  bytes += value.length;
404
418
  }
405
419
  await reader.cancel();
406
- return { status, ok, body: text(read), truncated: true };
420
+ return { status, ok, body: text(read), truncated: true, headers };
407
421
  }
408
422
  function withoutBody(sent) {
409
423
  const headers = { ...sent.headers };
@@ -441,6 +455,11 @@ var NoWalletAvailable = class extends Error {
441
455
  // ../core/lnurl.ts
442
456
  var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
443
457
  var RESOLVE_TIMEOUT_MS = 3e4;
458
+ var NAMES_ONE_ACCOUNT = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
459
+ var MIN_PACE_SECS = 1;
460
+ var MAX_PACE_SECS = 3600;
461
+ var MIN_PER_SECOND = 0.01;
462
+ var MAX_PER_SECOND = 100;
444
463
  async function resolve(addresses, amountMsat) {
445
464
  const served = await firstThatServes(
446
465
  addresses,
@@ -528,6 +547,31 @@ function decodeIssued(address, bolt11) {
528
547
  }
529
548
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
530
549
  }
550
+ async function checkSettled(verifyUrl, paymentHash) {
551
+ const answer = await answeredJson(verifyUrl);
552
+ const asked = {
553
+ pace: paceAskedFor(answer.headers),
554
+ ceiling: ceilingAskedFor(answer.headers)
555
+ };
556
+ if (!answer.said.settled || !answer.said.preimage) return { preimage: null, ...asked };
557
+ if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
558
+ throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
559
+ }
560
+ return { preimage: answer.said.preimage, ...asked };
561
+ }
562
+ function paceAskedFor(headers) {
563
+ const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
564
+ if (!asked) return null;
565
+ return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
566
+ }
567
+ function ceilingAskedFor(headers) {
568
+ const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
569
+ if (!asked) return null;
570
+ const perWindow = Number(asked[1]);
571
+ const window = asked[2] === void 0 ? 1 : Number(asked[2]);
572
+ if (perWindow === 0 || window === 0) return null;
573
+ return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
574
+ }
531
575
  function cannotReleaseAPreimage(host) {
532
576
  const lowered = host.toLowerCase();
533
577
  return VERIFY_WITHOUT_PREIMAGE.some(
@@ -540,18 +584,29 @@ function withAmount(callback, amountMsat) {
540
584
  }
541
585
  function splitAddress(address) {
542
586
  const [user, domain, ...rest] = address.split("@");
543
- if (!user || !domain || rest.length > 0 || !publicHttps(`https://${domain}/`)) {
587
+ if (!user || !domain || rest.length > 0 || !NAMES_ONE_ACCOUNT.test(user) || !isNothingButAHost(domain) || !publicHttps(`https://${domain}/`)) {
544
588
  throw new WalletRefused("address-unusable", `${address} is not a usable lightning address`);
545
589
  }
546
590
  return [user, domain];
547
591
  }
592
+ function isNothingButAHost(domain) {
593
+ try {
594
+ const parsed = new URL(`https://${domain}/`);
595
+ return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
596
+ } catch {
597
+ return false;
598
+ }
599
+ }
548
600
  async function fetchJson(url, deadline) {
601
+ return (await answeredJson(url, deadline)).said;
602
+ }
603
+ async function answeredJson(url, deadline) {
549
604
  const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
550
605
  if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
551
606
  if (answer.truncated) {
552
607
  throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
553
608
  }
554
- return JSON.parse(answer.body);
609
+ return { said: JSON.parse(answer.body), headers: answer.headers };
555
610
  }
556
611
 
557
612
  // ../core/sealed.ts
@@ -573,6 +628,22 @@ async function seal(secret, plaintext) {
573
628
  joined.set(cipher, iv.length);
574
629
  return `${VERSION}.${toBase64Url(joined)}`;
575
630
  }
631
+ async function unseal(secret, sealed) {
632
+ const key = await keyFor(secret);
633
+ if (!sealed.startsWith(`${VERSION}.`)) return null;
634
+ const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
635
+ if (joined === null || joined.length <= IV_BYTES) return null;
636
+ try {
637
+ const body = await crypto.subtle.decrypt(
638
+ { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
639
+ key,
640
+ joined.slice(IV_BYTES)
641
+ );
642
+ return new TextDecoder().decode(body);
643
+ } catch {
644
+ return null;
645
+ }
646
+ }
576
647
  async function keyFor(secret) {
577
648
  if (secret.length < MIN_SECRET_CHARS) {
578
649
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
@@ -597,6 +668,17 @@ function toBase64Url(bytes) {
597
668
  for (const byte of bytes) binary += String.fromCharCode(byte);
598
669
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
599
670
  }
671
+ function fromBase64Url(text2) {
672
+ if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
673
+ try {
674
+ const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
675
+ const bytes = new Uint8Array(binary.length);
676
+ for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
677
+ return bytes;
678
+ } catch {
679
+ return null;
680
+ }
681
+ }
600
682
 
601
683
  // src/errors.ts
602
684
  var ProblemError = class extends Error {
@@ -719,6 +801,30 @@ function refuse(reason) {
719
801
  return Response.json({ status: "ERROR", reason });
720
802
  }
721
803
 
804
+ // ../core/ed25519.ts
805
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
806
+
807
+ // src/webhook.ts
808
+ var VERIFY_CHALLENGE = "verify-challenge";
809
+ function nonceOf(text2, type) {
810
+ try {
811
+ const said = JSON.parse(text2);
812
+ if (said["type"] !== type || typeof said["nonce"] !== "string") return null;
813
+ return said["nonce"];
814
+ } catch {
815
+ return null;
816
+ }
817
+ }
818
+ function answerVerifyChallenge(body) {
819
+ const nonce = nonceOf(body, VERIFY_CHALLENGE);
820
+ return nonce === null ? null : JSON.stringify({ nonce });
821
+ }
822
+ async function answerVerifyChallengeRequest(request) {
823
+ if (request.method !== "POST") return null;
824
+ const answer = answerVerifyChallenge(await request.clone().text());
825
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
826
+ }
827
+
722
828
  // src/qr.ts
723
829
  import { encode } from "uqr";
724
830
  function encodeForQr(destination) {
@@ -730,14 +836,48 @@ function isLnAddress(destination) {
730
836
  return at > 0 && destination.slice(at + 1).includes(".");
731
837
  }
732
838
 
839
+ // src/relay.ts
840
+ var DEFAULT_POLL_EVERY_SECS = 5;
841
+ var WALLET = "w";
842
+ function lightningVerifyEndpoint(config) {
843
+ const paced = {
844
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
845
+ };
846
+ return async (request) => {
847
+ const consented = await answerVerifyChallengeRequest(request);
848
+ if (consented !== null) return consented;
849
+ const sealed = new URL(request.url).searchParams.get(WALLET);
850
+ if (sealed === null) return Response.json({ settled: false }, { status: 400 });
851
+ const opened = await unseal(config.secret, sealed);
852
+ if (opened === null) return Response.json({ settled: false }, { status: 403 });
853
+ const wallet = JSON.parse(opened);
854
+ const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
855
+ if (asked === null) return Response.json({ settled: false }, { status: 502 });
856
+ return Response.json(
857
+ { settled: asked.preimage !== null, preimage: asked.preimage },
858
+ { headers: paced }
859
+ );
860
+ };
861
+ }
862
+ async function relayedVerifyUrl(endpoint, wallet, secret) {
863
+ const relayed = new URL(endpoint);
864
+ relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
865
+ return relayed.toString();
866
+ }
867
+
733
868
  // src/rail.ts
734
869
  var LIGHTNING = "lightning";
735
870
  function blindLightningRail(config) {
736
871
  return async (order) => {
737
872
  const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
873
+ const relay = config.relayVerifyThrough;
738
874
  const watched = await config.gateway.watchPayment({
739
875
  paymentHash: resolved.paymentHash,
740
- verifyUrl: resolved.verifyUrl,
876
+ verifyUrl: relay ? await relayedVerifyUrl(
877
+ relay.endpoint,
878
+ { url: resolved.verifyUrl, hash: resolved.paymentHash },
879
+ relay.secret
880
+ ) : resolved.verifyUrl,
741
881
  expiresAt: resolved.expiresAt,
742
882
  trigger: config.trigger,
743
883
  sealed: await config.sealed?.(order),
@@ -765,5 +905,7 @@ async function invoiceFrom(lnAddresses, amountMsat) {
765
905
  }
766
906
  export {
767
907
  blindLightningRail,
768
- lnurlPayEndpoint
908
+ lightningVerifyEndpoint,
909
+ lnurlPayEndpoint,
910
+ relayedVerifyUrl
769
911
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thunder-bridge",
3
- "version": "0.8.6",
3
+ "version": "0.8.9",
4
4
  "description": "Trustless JavaScript client for the Thunder Bridge Lightning payment gateway. Proves the invoice came from your own wallet before the payer sees it.",
5
5
  "author": "i-am-fatik",
6
6
  "homepage": "https://agora.gripe/en/tools/thunder-bridge",