thunder-bridge 0.8.4 → 0.8.6

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/README.md CHANGED
@@ -167,7 +167,8 @@ const tipJar = lnurlToSvg("https://agora.gripe/tip");
167
167
 
168
168
  **Webhooks** - [`src/webhook.ts`](src/webhook.ts): `parseWebhookRequest`,
169
169
  `parseWebhook`, `parseWatchedWebhookRequest`, `parseWatchedWebhook`,
170
- `verifyWebhookSignature`. See [Webhooks](#webhooks).
170
+ `verifyWebhookSignature`, `answerWebhookChallengeRequest`,
171
+ `answerWebhookChallenge`. See [Webhooks](#webhooks).
171
172
 
172
173
  **Errors** - [`src/errors.ts`](src/errors.ts): `ProblemError`,
173
174
  `NoWalletAvailableError`, `GatewayCheatError`, `UnverifiedRecipientError`,
@@ -339,17 +340,34 @@ try {
339
340
 
340
341
  Pass `webhookUrl` and optionally `webhookSecret` when you create a payment, or on
341
342
  any rail. Once it reaches `paid` the gateway POSTs the same JSON the API returns,
342
- so the body is a `Payment`. Every delivery carries `x-timestamp`, and with a secret set
343
- `x-signature: sha256=<hmac>` over `<timestamp>.<body>` rather than the body alone,
344
- so a captured delivery cannot be replayed at you later. Six attempts on a widening
345
- backoff, then it parks. An invoice that expires fires nothing.
343
+ so the body is a `Payment`. Every delivery carries `x-timestamp` and an
344
+ `x-signature` over `<timestamp>.<body>` rather than the body alone, so a captured
345
+ delivery cannot be replayed at you later. With a secret set it is
346
+ `sha256=<hmac>` keyed with that secret, and without one it is `ed25519=<signature>`
347
+ from the gateway's own key, which is the better default and is below. Retries widen
348
+ until the payment itself runs out, never sooner than an hour. An invoice that expires
349
+ fires nothing.
346
350
 
347
351
  Delivery is at-least-once, so deduplicate on `id`.
348
352
 
353
+ Your handler answers one challenge before any of that. The gateway POSTs
354
+ `{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still open
355
+ and refuses the payment with a 424 unless the nonce comes back, so the endpoint has to
356
+ be deployed before you register it. `answerWebhookChallengeRequest` verifies that
357
+ challenge and hands you the response to return, or `null` when the delivery was a real
358
+ settlement, and it leaves the body unread either way.
359
+
349
360
  ```ts
350
- import { parseWebhookRequest, proveSettlement } from "thunder-bridge";
361
+ import {
362
+ answerWebhookChallengeRequest,
363
+ parseWebhookRequest,
364
+ proveSettlement,
365
+ } from "thunder-bridge";
351
366
 
352
367
  app.post("/hooks/paid", async (context) => {
368
+ const challenge = await answerWebhookChallengeRequest(context.req.raw, secret);
369
+ if (challenge) return challenge;
370
+
353
371
  const payment = await parseWebhookRequest(context.req.raw, secret);
354
372
  if (payment === null) return context.text("bad signature", 401);
355
373
 
@@ -382,7 +400,38 @@ app.post("/hooks/bank", async (context) => {
382
400
  ```
383
401
 
384
402
  Give each rail its own path, as above, and neither endpoint has to guess which body
385
- it was handed.
403
+ it was handed. Both events also carry `kind`, `"minted"` or `"watched"`, so a single
404
+ path serving a trigger that both rails settle on can branch on the field instead of
405
+ on which fields are missing.
406
+
407
+ ### Or hand the gateway no secret at all
408
+
409
+ A secret you give the gateway is kept in its ledger and replicated to its peers,
410
+ because any instance may be the one that delivers. Leave `webhookSecret` out and the
411
+ delivery is signed with the gateway's own key instead, `x-signature:
412
+ ed25519=<signature>` over the same `<timestamp>.<body>`. Fetch the public half once
413
+ and pass it as `{ publicKey }` wherever a secret would go.
414
+
415
+ ```ts
416
+ const publicKey = await gateway.webhookKey();
417
+
418
+ app.post("/hooks/paid", async (context) => {
419
+ const challenge = await answerWebhookChallengeRequest(context.req.raw, { publicKey });
420
+ if (challenge) return challenge;
421
+
422
+ const payment = await parseWebhookRequest(context.req.raw, { publicKey });
423
+ if (payment === null) return context.text("bad signature", 401);
424
+ ...
425
+ });
426
+ ```
427
+
428
+ Answering echoes the nonce and nothing else here, because there is no secret to sign it
429
+ with. Holding the URL the gateway challenged is the whole proof in that case.
430
+
431
+ The key is derived from the gateway's `CLUSTER_KEY`, so every instance in one cluster
432
+ signs alike and an operator rotating that key changes this one too. Neither
433
+ credential is ever accepted for the other's scheme, so a secret cannot check an
434
+ `ed25519=` delivery and a public key cannot check a `sha256=` one.
386
435
 
387
436
  `parseWebhookRequest` refuses anything more than five minutes out of date,
388
437
  adjustable with `toleranceSecs`. The signature proves the body came from someone
package/dist/index.cjs CHANGED
@@ -30,6 +30,8 @@ __export(index_exports, {
30
30
  REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
31
31
  ThunderBridge: () => ThunderBridge,
32
32
  UnverifiedRecipientError: () => UnverifiedRecipientError,
33
+ answerWebhookChallenge: () => answerWebhookChallenge,
34
+ answerWebhookChallengeRequest: () => answerWebhookChallengeRequest,
33
35
  bankRail: () => bankRail,
34
36
  bankTransfer: () => bankTransfer,
35
37
  bankVerifyEndpoint: () => bankVerifyEndpoint,
@@ -632,6 +634,7 @@ function triggerEventFromWire(body) {
632
634
  if (sealed !== null && typeof sealed !== "string") return null;
633
635
  return {
634
636
  id,
637
+ kind: kindOf(wire),
635
638
  paymentHash,
636
639
  verifyUrl,
637
640
  status,
@@ -643,6 +646,11 @@ function triggerEventFromWire(body) {
643
646
  amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
644
647
  };
645
648
  }
649
+ function kindOf(wire) {
650
+ const said = wire["kind"];
651
+ if (said === "minted" || said === "watched") return said;
652
+ return text(wire["ln_address"]) === null ? "watched" : "minted";
653
+ }
646
654
  function toAmount(msat) {
647
655
  return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
648
656
  }
@@ -780,6 +788,23 @@ var ThunderBridge = class {
780
788
  return quote;
781
789
  }
782
790
  /** Read a payment back, null when the gateway has never heard of it */
791
+ /**
792
+ * The key this gateway signs webhooks with when you registered none of your own,
793
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
794
+ * keep it: it is the same for every instance in the cluster
795
+ */
796
+ async webhookKey() {
797
+ const response = await fetch(`${this.baseUrl}/webhook-key`);
798
+ if (!response.ok) throw await problemFrom(response);
799
+ const body = await response.json().catch(() => null);
800
+ if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
801
+ throw new ProblemError({
802
+ status: response.status,
803
+ title: "The gateway published no ed25519 webhook key"
804
+ });
805
+ }
806
+ return body.public_key;
807
+ }
783
808
  async getPayment(id) {
784
809
  const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
785
810
  headers: this.reading()
@@ -1763,18 +1788,59 @@ function refusals2(answers) {
1763
1788
  ).join("; ");
1764
1789
  }
1765
1790
 
1791
+ // ../core/ed25519.ts
1792
+ var ALGORITHM = { name: "Ed25519" };
1793
+ var SIGNATURE_BYTES = 64;
1794
+ var PUBLIC_KEY_BYTES = 32;
1795
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
1796
+ async function verifyHex(publicKeyHex, signatureHex, payload) {
1797
+ if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
1798
+ if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
1799
+ try {
1800
+ const key = await crypto.subtle.importKey(
1801
+ "raw",
1802
+ asBuffer2(hexToBytes(publicKeyHex)),
1803
+ ALGORITHM,
1804
+ false,
1805
+ ["verify"]
1806
+ );
1807
+ return await crypto.subtle.verify(
1808
+ ALGORITHM,
1809
+ key,
1810
+ asBuffer2(hexToBytes(signatureHex)),
1811
+ asBuffer2(payload)
1812
+ );
1813
+ } catch {
1814
+ return false;
1815
+ }
1816
+ }
1817
+ function asBuffer2(bytes) {
1818
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1819
+ }
1820
+
1766
1821
  // src/webhook.ts
1767
1822
  var SIGNATURE_HEADER = "x-signature";
1768
1823
  var TIMESTAMP_HEADER = "x-timestamp";
1769
- var SIGNATURE_PREFIX = "sha256=";
1824
+ var SHARED_SECRET_PREFIX = "sha256=";
1825
+ var GATEWAY_KEY_PREFIX = "ed25519=";
1770
1826
  var DEFAULT_TOLERANCE_SECS = 300;
1771
- async function verifyWebhookSignature(body, signature, secret, timestamp, options = {}) {
1827
+ var CHALLENGE = "webhook-challenge";
1828
+ async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1772
1829
  if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1773
- const received = signature.startsWith(SIGNATURE_PREFIX) ? signature.slice(SIGNATURE_PREFIX.length) : signature;
1774
- return equalInConstantTime(await sign(secret, timestamp, body), received.toLowerCase());
1830
+ if (typeof credential !== "string") {
1831
+ if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1832
+ return verifyHex(
1833
+ credential.publicKey.toLowerCase(),
1834
+ signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1835
+ signed(timestamp, body)
1836
+ );
1837
+ }
1838
+ if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1839
+ const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
1840
+ return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
1775
1841
  }
1776
- async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1777
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1842
+ async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1843
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1778
1844
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1779
1845
  try {
1780
1846
  return paymentFromWire(JSON.parse(text2));
@@ -1782,14 +1848,14 @@ async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1782
1848
  return null;
1783
1849
  }
1784
1850
  }
1785
- async function parseWebhookRequest(request, secret, options = {}) {
1851
+ async function parseWebhookRequest(request, credential, options = {}) {
1786
1852
  const signature = request.headers.get(SIGNATURE_HEADER);
1787
1853
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1788
1854
  if (signature === null || timestamp === null) return null;
1789
- return parseWebhook(await request.text(), signature, secret, timestamp, options);
1855
+ return parseWebhook(await request.text(), signature, credential, timestamp, options);
1790
1856
  }
1791
- async function parseWatchedWebhook(body, signature, secret, timestamp, options = {}) {
1792
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1857
+ async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1858
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1793
1859
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1794
1860
  try {
1795
1861
  return triggerEventFromWire(JSON.parse(text2));
@@ -1797,11 +1863,44 @@ async function parseWatchedWebhook(body, signature, secret, timestamp, options =
1797
1863
  return null;
1798
1864
  }
1799
1865
  }
1800
- async function parseWatchedWebhookRequest(request, secret, options = {}) {
1866
+ async function parseWatchedWebhookRequest(request, credential, options = {}) {
1867
+ const signature = request.headers.get(SIGNATURE_HEADER);
1868
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1869
+ if (signature === null || timestamp === null) return null;
1870
+ return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1871
+ }
1872
+ async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1873
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1874
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1875
+ const nonce = challenged(text2);
1876
+ if (nonce === null) return null;
1877
+ if (typeof credential !== "string") return JSON.stringify({ nonce });
1878
+ return JSON.stringify({
1879
+ nonce,
1880
+ signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
1881
+ });
1882
+ }
1883
+ async function answerWebhookChallengeRequest(request, credential, options = {}) {
1801
1884
  const signature = request.headers.get(SIGNATURE_HEADER);
1802
1885
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1803
1886
  if (signature === null || timestamp === null) return null;
1804
- return parseWatchedWebhook(await request.text(), signature, secret, timestamp, options);
1887
+ const answer = await answerWebhookChallenge(
1888
+ await request.clone().text(),
1889
+ signature,
1890
+ credential,
1891
+ timestamp,
1892
+ options
1893
+ );
1894
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1895
+ }
1896
+ function challenged(text2) {
1897
+ try {
1898
+ const said = JSON.parse(text2);
1899
+ if (said["type"] !== CHALLENGE || typeof said["nonce"] !== "string") return null;
1900
+ return said["nonce"];
1901
+ } catch {
1902
+ return null;
1903
+ }
1805
1904
  }
1806
1905
  function recent(timestamp, toleranceSecs) {
1807
1906
  const sent = Number(timestamp);
@@ -1832,6 +1931,8 @@ function signed(timestamp, body) {
1832
1931
  REQUEST_IN_FLIGHT,
1833
1932
  ThunderBridge,
1834
1933
  UnverifiedRecipientError,
1934
+ answerWebhookChallenge,
1935
+ answerWebhookChallengeRequest,
1835
1936
  bankRail,
1836
1937
  bankTransfer,
1837
1938
  bankVerifyEndpoint,
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-QZtUN1D-.cjs';
2
- export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentStatus, Q as Quote, R as Rail, f as ThunderBridgeOptions, g as WaitOptions, h as WalletReason, i as WatchPaymentParams, j as bankRail, l as lightningRail } from './rail-QZtUN1D-.cjs';
1
+ import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-CdKeyVGn.cjs';
2
+ export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentKind, f as PaymentStatus, Q as Quote, R as Rail, g as ThunderBridgeOptions, h as WaitOptions, i as WalletReason, j as WatchPaymentParams, k as bankRail, l as lightningRail } from './rail-CdKeyVGn.cjs';
3
3
 
4
4
  /**
5
5
  * Encrypt what the watcher needs and the gateway must not have. The gateway
@@ -338,23 +338,42 @@ declare function toLnurl(endpoint: string): string;
338
338
  type WebhookOptions = {
339
339
  toleranceSecs?: number;
340
340
  };
341
+ /**
342
+ * What checks a delivery. A string is the `webhook.secret` you registered. Pass
343
+ * `{ publicKey }` instead, the hex from the gateway's `/webhook-key`, when you
344
+ * registered no secret and would rather it held nothing of yours
345
+ */
346
+ type WebhookCredential = string | {
347
+ publicKey: string;
348
+ };
341
349
  /** Verify the `X-Signature` header against the raw body and the `X-Timestamp` that came with it */
342
- declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<boolean>;
350
+ declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<boolean>;
343
351
  /** Verify and parse in one step, returns null on a bad signature or a body that is not a payment */
344
- declare function parseWebhook(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
352
+ declare function parseWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
345
353
  /**
346
354
  * Verify and parse from a Fetch API `Request` as used by Hono, Next, SvelteKit,
347
355
  * Cloudflare Workers and Deno, WebCrypto only so it runs anywhere fetch does
348
356
  */
349
- declare function parseWebhookRequest(request: Request, secret: string, options?: WebhookOptions): Promise<Payment | null>;
357
+ declare function parseWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Payment | null>;
350
358
  /**
351
359
  * The same, for a payment the gateway only watched. A bank transfer and a blind
352
360
  * Lightning leg carry no address, amount or invoice, so they arrive in the shape
353
361
  * `followTrigger` and `getWatched` hand back rather than the minted one
354
362
  */
355
- declare function parseWatchedWebhook(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
363
+ declare function parseWatchedWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
356
364
  /** `parseWatchedWebhook` from a Fetch API `Request`, the way `parseWebhookRequest` is */
357
- declare function parseWatchedWebhookRequest(request: Request, secret: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
365
+ declare function parseWatchedWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<TriggerEvent | null>;
366
+ /**
367
+ * Answer the one challenge the gateway sends before it will watch a payment your
368
+ * webhook is registered on. Returns the body to send back with a 200, or null when
369
+ * this delivery is not a challenge, so a handler tries this first and then parses
370
+ */
371
+ declare function answerWebhookChallenge(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<string | null>;
372
+ /**
373
+ * `answerWebhookChallenge` from a Fetch API `Request`, leaving the body unread so
374
+ * the same handler can go on to `parseWebhookRequest` when this was no challenge
375
+ */
376
+ declare function answerWebhookChallengeRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Response | null>;
358
377
 
359
378
  /**
360
379
  * The way a gateway was caught out, every code is a check that held against the
@@ -432,4 +451,4 @@ declare class NoWalletAvailableError extends ProblemError {
432
451
  }, wallets: WalletFailure[]);
433
452
  }
434
453
 
435
- export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, TriggerEvent, UnverifiedRecipientError, WalletFailure, type WebhookOptions, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWatchedWebhook, parseWatchedWebhookRequest, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
454
+ export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, TriggerEvent, UnverifiedRecipientError, WalletFailure, type WebhookCredential, type WebhookOptions, answerWebhookChallenge, answerWebhookChallengeRequest, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWatchedWebhook, parseWatchedWebhookRequest, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-QZtUN1D-.js';
2
- export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentStatus, Q as Quote, R as Rail, f as ThunderBridgeOptions, g as WaitOptions, h as WalletReason, i as WatchPaymentParams, j as bankRail, l as lightningRail } from './rail-QZtUN1D-.js';
1
+ import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-CdKeyVGn.js';
2
+ export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentKind, f as PaymentStatus, Q as Quote, R as Rail, g as ThunderBridgeOptions, h as WaitOptions, i as WalletReason, j as WatchPaymentParams, k as bankRail, l as lightningRail } from './rail-CdKeyVGn.js';
3
3
 
4
4
  /**
5
5
  * Encrypt what the watcher needs and the gateway must not have. The gateway
@@ -338,23 +338,42 @@ declare function toLnurl(endpoint: string): string;
338
338
  type WebhookOptions = {
339
339
  toleranceSecs?: number;
340
340
  };
341
+ /**
342
+ * What checks a delivery. A string is the `webhook.secret` you registered. Pass
343
+ * `{ publicKey }` instead, the hex from the gateway's `/webhook-key`, when you
344
+ * registered no secret and would rather it held nothing of yours
345
+ */
346
+ type WebhookCredential = string | {
347
+ publicKey: string;
348
+ };
341
349
  /** Verify the `X-Signature` header against the raw body and the `X-Timestamp` that came with it */
342
- declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<boolean>;
350
+ declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<boolean>;
343
351
  /** Verify and parse in one step, returns null on a bad signature or a body that is not a payment */
344
- declare function parseWebhook(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
352
+ declare function parseWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
345
353
  /**
346
354
  * Verify and parse from a Fetch API `Request` as used by Hono, Next, SvelteKit,
347
355
  * Cloudflare Workers and Deno, WebCrypto only so it runs anywhere fetch does
348
356
  */
349
- declare function parseWebhookRequest(request: Request, secret: string, options?: WebhookOptions): Promise<Payment | null>;
357
+ declare function parseWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Payment | null>;
350
358
  /**
351
359
  * The same, for a payment the gateway only watched. A bank transfer and a blind
352
360
  * Lightning leg carry no address, amount or invoice, so they arrive in the shape
353
361
  * `followTrigger` and `getWatched` hand back rather than the minted one
354
362
  */
355
- declare function parseWatchedWebhook(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
363
+ declare function parseWatchedWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
356
364
  /** `parseWatchedWebhook` from a Fetch API `Request`, the way `parseWebhookRequest` is */
357
- declare function parseWatchedWebhookRequest(request: Request, secret: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
365
+ declare function parseWatchedWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<TriggerEvent | null>;
366
+ /**
367
+ * Answer the one challenge the gateway sends before it will watch a payment your
368
+ * webhook is registered on. Returns the body to send back with a 200, or null when
369
+ * this delivery is not a challenge, so a handler tries this first and then parses
370
+ */
371
+ declare function answerWebhookChallenge(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<string | null>;
372
+ /**
373
+ * `answerWebhookChallenge` from a Fetch API `Request`, leaving the body unread so
374
+ * the same handler can go on to `parseWebhookRequest` when this was no challenge
375
+ */
376
+ declare function answerWebhookChallengeRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Response | null>;
358
377
 
359
378
  /**
360
379
  * The way a gateway was caught out, every code is a check that held against the
@@ -432,4 +451,4 @@ declare class NoWalletAvailableError extends ProblemError {
432
451
  }, wallets: WalletFailure[]);
433
452
  }
434
453
 
435
- export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, TriggerEvent, UnverifiedRecipientError, WalletFailure, type WebhookOptions, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWatchedWebhook, parseWatchedWebhookRequest, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
454
+ export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, TriggerEvent, UnverifiedRecipientError, WalletFailure, type WebhookCredential, type WebhookOptions, answerWebhookChallenge, answerWebhookChallengeRequest, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWatchedWebhook, parseWatchedWebhookRequest, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -562,6 +562,7 @@ function triggerEventFromWire(body) {
562
562
  if (sealed !== null && typeof sealed !== "string") return null;
563
563
  return {
564
564
  id,
565
+ kind: kindOf(wire),
565
566
  paymentHash,
566
567
  verifyUrl,
567
568
  status,
@@ -573,6 +574,11 @@ function triggerEventFromWire(body) {
573
574
  amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
574
575
  };
575
576
  }
577
+ function kindOf(wire) {
578
+ const said = wire["kind"];
579
+ if (said === "minted" || said === "watched") return said;
580
+ return text(wire["ln_address"]) === null ? "watched" : "minted";
581
+ }
576
582
  function toAmount(msat) {
577
583
  return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
578
584
  }
@@ -710,6 +716,23 @@ var ThunderBridge = class {
710
716
  return quote;
711
717
  }
712
718
  /** Read a payment back, null when the gateway has never heard of it */
719
+ /**
720
+ * The key this gateway signs webhooks with when you registered none of your own,
721
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
722
+ * keep it: it is the same for every instance in the cluster
723
+ */
724
+ async webhookKey() {
725
+ const response = await fetch(`${this.baseUrl}/webhook-key`);
726
+ if (!response.ok) throw await problemFrom(response);
727
+ const body = await response.json().catch(() => null);
728
+ if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
729
+ throw new ProblemError({
730
+ status: response.status,
731
+ title: "The gateway published no ed25519 webhook key"
732
+ });
733
+ }
734
+ return body.public_key;
735
+ }
713
736
  async getPayment(id) {
714
737
  const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
715
738
  headers: this.reading()
@@ -1693,18 +1716,59 @@ function refusals2(answers) {
1693
1716
  ).join("; ");
1694
1717
  }
1695
1718
 
1719
+ // ../core/ed25519.ts
1720
+ var ALGORITHM = { name: "Ed25519" };
1721
+ var SIGNATURE_BYTES = 64;
1722
+ var PUBLIC_KEY_BYTES = 32;
1723
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
1724
+ async function verifyHex(publicKeyHex, signatureHex, payload) {
1725
+ if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
1726
+ if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
1727
+ try {
1728
+ const key = await crypto.subtle.importKey(
1729
+ "raw",
1730
+ asBuffer2(hexToBytes(publicKeyHex)),
1731
+ ALGORITHM,
1732
+ false,
1733
+ ["verify"]
1734
+ );
1735
+ return await crypto.subtle.verify(
1736
+ ALGORITHM,
1737
+ key,
1738
+ asBuffer2(hexToBytes(signatureHex)),
1739
+ asBuffer2(payload)
1740
+ );
1741
+ } catch {
1742
+ return false;
1743
+ }
1744
+ }
1745
+ function asBuffer2(bytes) {
1746
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1747
+ }
1748
+
1696
1749
  // src/webhook.ts
1697
1750
  var SIGNATURE_HEADER = "x-signature";
1698
1751
  var TIMESTAMP_HEADER = "x-timestamp";
1699
- var SIGNATURE_PREFIX = "sha256=";
1752
+ var SHARED_SECRET_PREFIX = "sha256=";
1753
+ var GATEWAY_KEY_PREFIX = "ed25519=";
1700
1754
  var DEFAULT_TOLERANCE_SECS = 300;
1701
- async function verifyWebhookSignature(body, signature, secret, timestamp, options = {}) {
1755
+ var CHALLENGE = "webhook-challenge";
1756
+ async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1702
1757
  if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1703
- const received = signature.startsWith(SIGNATURE_PREFIX) ? signature.slice(SIGNATURE_PREFIX.length) : signature;
1704
- return equalInConstantTime(await sign(secret, timestamp, body), received.toLowerCase());
1758
+ if (typeof credential !== "string") {
1759
+ if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1760
+ return verifyHex(
1761
+ credential.publicKey.toLowerCase(),
1762
+ signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1763
+ signed(timestamp, body)
1764
+ );
1765
+ }
1766
+ if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1767
+ const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
1768
+ return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
1705
1769
  }
1706
- async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1707
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1770
+ async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1771
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1708
1772
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1709
1773
  try {
1710
1774
  return paymentFromWire(JSON.parse(text2));
@@ -1712,14 +1776,14 @@ async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1712
1776
  return null;
1713
1777
  }
1714
1778
  }
1715
- async function parseWebhookRequest(request, secret, options = {}) {
1779
+ async function parseWebhookRequest(request, credential, options = {}) {
1716
1780
  const signature = request.headers.get(SIGNATURE_HEADER);
1717
1781
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1718
1782
  if (signature === null || timestamp === null) return null;
1719
- return parseWebhook(await request.text(), signature, secret, timestamp, options);
1783
+ return parseWebhook(await request.text(), signature, credential, timestamp, options);
1720
1784
  }
1721
- async function parseWatchedWebhook(body, signature, secret, timestamp, options = {}) {
1722
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1785
+ async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1786
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1723
1787
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1724
1788
  try {
1725
1789
  return triggerEventFromWire(JSON.parse(text2));
@@ -1727,11 +1791,44 @@ async function parseWatchedWebhook(body, signature, secret, timestamp, options =
1727
1791
  return null;
1728
1792
  }
1729
1793
  }
1730
- async function parseWatchedWebhookRequest(request, secret, options = {}) {
1794
+ async function parseWatchedWebhookRequest(request, credential, options = {}) {
1795
+ const signature = request.headers.get(SIGNATURE_HEADER);
1796
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1797
+ if (signature === null || timestamp === null) return null;
1798
+ return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1799
+ }
1800
+ async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1801
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1802
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1803
+ const nonce = challenged(text2);
1804
+ if (nonce === null) return null;
1805
+ if (typeof credential !== "string") return JSON.stringify({ nonce });
1806
+ return JSON.stringify({
1807
+ nonce,
1808
+ signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
1809
+ });
1810
+ }
1811
+ async function answerWebhookChallengeRequest(request, credential, options = {}) {
1731
1812
  const signature = request.headers.get(SIGNATURE_HEADER);
1732
1813
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1733
1814
  if (signature === null || timestamp === null) return null;
1734
- return parseWatchedWebhook(await request.text(), signature, secret, timestamp, options);
1815
+ const answer = await answerWebhookChallenge(
1816
+ await request.clone().text(),
1817
+ signature,
1818
+ credential,
1819
+ timestamp,
1820
+ options
1821
+ );
1822
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1823
+ }
1824
+ function challenged(text2) {
1825
+ try {
1826
+ const said = JSON.parse(text2);
1827
+ if (said["type"] !== CHALLENGE || typeof said["nonce"] !== "string") return null;
1828
+ return said["nonce"];
1829
+ } catch {
1830
+ return null;
1831
+ }
1735
1832
  }
1736
1833
  function recent(timestamp, toleranceSecs) {
1737
1834
  const sent = Number(timestamp);
@@ -1761,6 +1858,8 @@ export {
1761
1858
  REQUEST_IN_FLIGHT,
1762
1859
  ThunderBridge,
1763
1860
  UnverifiedRecipientError,
1861
+ answerWebhookChallenge,
1862
+ answerWebhookChallengeRequest,
1764
1863
  bankRail,
1765
1864
  bankTransfer,
1766
1865
  bankVerifyEndpoint,
@@ -1,5 +1,7 @@
1
1
  /** Where a payment stands, `paid` is the only status that carries a preimage */
2
2
  type PaymentStatus = "pending" | "paid" | "expired";
3
+ /** Whether the gateway resolved the address and got the invoice, or was handed one to watch */
4
+ type PaymentKind = "minted" | "watched";
3
5
  /** A payment as the gateway reports it, every field is checkable against the recipient */
4
6
  interface Payment {
5
7
  id: string;
@@ -53,6 +55,7 @@ interface Quote {
53
55
  */
54
56
  interface TriggerEvent {
55
57
  id: string;
58
+ kind: PaymentKind;
56
59
  paymentHash: string;
57
60
  verifyUrl: string;
58
61
  status: PaymentStatus;
@@ -189,6 +192,12 @@ declare class ThunderBridge {
189
192
  */
190
193
  createQuote(params: CreateQuoteParams): Promise<Quote>;
191
194
  /** Read a payment back, null when the gateway has never heard of it */
195
+ /**
196
+ * The key this gateway signs webhooks with when you registered none of your own,
197
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
198
+ * keep it: it is the same for every instance in the cluster
199
+ */
200
+ webhookKey(): Promise<string>;
192
201
  getPayment(id: string): Promise<Payment | null>;
193
202
  /**
194
203
  * Read back a payment the gateway is only watching, null when it has never
@@ -378,4 +387,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
378
387
  */
379
388
  declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
380
389
 
381
- export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentStatus as e, type ThunderBridgeOptions as f, type WaitOptions as g, type WalletReason as h, type WatchPaymentParams as i, bankRail as j, type BlindLightningRailConfig as k, lightningRail as l, blindLightningRail as m };
390
+ export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentKind as e, type PaymentStatus as f, type ThunderBridgeOptions as g, type WaitOptions as h, type WalletReason as i, type WatchPaymentParams as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, blindLightningRail as n };
@@ -1,5 +1,7 @@
1
1
  /** Where a payment stands, `paid` is the only status that carries a preimage */
2
2
  type PaymentStatus = "pending" | "paid" | "expired";
3
+ /** Whether the gateway resolved the address and got the invoice, or was handed one to watch */
4
+ type PaymentKind = "minted" | "watched";
3
5
  /** A payment as the gateway reports it, every field is checkable against the recipient */
4
6
  interface Payment {
5
7
  id: string;
@@ -53,6 +55,7 @@ interface Quote {
53
55
  */
54
56
  interface TriggerEvent {
55
57
  id: string;
58
+ kind: PaymentKind;
56
59
  paymentHash: string;
57
60
  verifyUrl: string;
58
61
  status: PaymentStatus;
@@ -189,6 +192,12 @@ declare class ThunderBridge {
189
192
  */
190
193
  createQuote(params: CreateQuoteParams): Promise<Quote>;
191
194
  /** Read a payment back, null when the gateway has never heard of it */
195
+ /**
196
+ * The key this gateway signs webhooks with when you registered none of your own,
197
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
198
+ * keep it: it is the same for every instance in the cluster
199
+ */
200
+ webhookKey(): Promise<string>;
192
201
  getPayment(id: string): Promise<Payment | null>;
193
202
  /**
194
203
  * Read back a payment the gateway is only watching, null when it has never
@@ -378,4 +387,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
378
387
  */
379
388
  declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
380
389
 
381
- export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentStatus as e, type ThunderBridgeOptions as f, type WaitOptions as g, type WalletReason as h, type WatchPaymentParams as i, bankRail as j, type BlindLightningRailConfig as k, lightningRail as l, blindLightningRail as m };
390
+ export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentKind as e, type PaymentStatus as f, type ThunderBridgeOptions as g, type WaitOptions as h, type WalletReason as i, type WatchPaymentParams as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, blindLightningRail as n };
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-QZtUN1D-.cjs';
2
- export { k as BlindLightningRailConfig, m as blindLightningRail } from './rail-QZtUN1D-.cjs';
1
+ import { T as ThunderBridge } from './rail-CdKeyVGn.cjs';
2
+ export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-CdKeyVGn.cjs';
3
3
 
4
4
  interface TriggerConfig {
5
5
  /** The gateway that quotes the addresses and mints the invoice */
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-QZtUN1D-.js';
2
- export { k as BlindLightningRailConfig, m as blindLightningRail } from './rail-QZtUN1D-.js';
1
+ import { T as ThunderBridge } from './rail-CdKeyVGn.js';
2
+ export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-CdKeyVGn.js';
3
3
 
4
4
  interface TriggerConfig {
5
5
  /** The gateway that quotes the addresses and mints the invoice */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thunder-bridge",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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",