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/README.md +49 -0
- package/dist/index.cjs +160 -137
- package/dist/index.d.cts +22 -3
- package/dist/index.d.ts +22 -3
- package/dist/index.js +158 -137
- package/dist/{rail-CdKeyVGn.d.cts → rail-J8QoYbSr.d.cts} +10 -0
- package/dist/{rail-CdKeyVGn.d.ts → rail-J8QoYbSr.d.ts} +10 -0
- package/dist/server.cjs +153 -9
- package/dist/server.d.cts +42 -3
- package/dist/server.d.ts +42 -3
- package/dist/server.js +150 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1215,9 +1215,155 @@ function minorScaleOf(currency) {
|
|
|
1215
1215
|
return 10 ** minorUnitsOf(currency);
|
|
1216
1216
|
}
|
|
1217
1217
|
|
|
1218
|
+
// ../core/ed25519.ts
|
|
1219
|
+
var ALGORITHM = { name: "Ed25519" };
|
|
1220
|
+
var SIGNATURE_BYTES = 64;
|
|
1221
|
+
var PUBLIC_KEY_BYTES = 32;
|
|
1222
|
+
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
1223
|
+
async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
1224
|
+
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
|
|
1225
|
+
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
|
|
1226
|
+
try {
|
|
1227
|
+
const key = await crypto.subtle.importKey(
|
|
1228
|
+
"raw",
|
|
1229
|
+
asBuffer2(hexToBytes(publicKeyHex)),
|
|
1230
|
+
ALGORITHM,
|
|
1231
|
+
false,
|
|
1232
|
+
["verify"]
|
|
1233
|
+
);
|
|
1234
|
+
return await crypto.subtle.verify(
|
|
1235
|
+
ALGORITHM,
|
|
1236
|
+
key,
|
|
1237
|
+
asBuffer2(hexToBytes(signatureHex)),
|
|
1238
|
+
asBuffer2(payload)
|
|
1239
|
+
);
|
|
1240
|
+
} catch {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function asBuffer2(bytes) {
|
|
1245
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// src/webhook.ts
|
|
1249
|
+
var SIGNATURE_HEADER = "x-signature";
|
|
1250
|
+
var TIMESTAMP_HEADER = "x-timestamp";
|
|
1251
|
+
var SHARED_SECRET_PREFIX = "sha256=";
|
|
1252
|
+
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
1253
|
+
var DEFAULT_TOLERANCE_SECS = 300;
|
|
1254
|
+
var CHALLENGE = "webhook-challenge";
|
|
1255
|
+
var VERIFY_CHALLENGE = "verify-challenge";
|
|
1256
|
+
async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
|
|
1257
|
+
if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
|
|
1258
|
+
if (typeof credential !== "string") {
|
|
1259
|
+
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
|
|
1260
|
+
return verifyHex(
|
|
1261
|
+
credential.publicKey.toLowerCase(),
|
|
1262
|
+
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1263
|
+
signed(timestamp, body)
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
|
|
1267
|
+
const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
|
|
1268
|
+
return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
|
|
1269
|
+
}
|
|
1270
|
+
async function parseWebhook(body, signature, credential, timestamp, options = {}) {
|
|
1271
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1272
|
+
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1273
|
+
try {
|
|
1274
|
+
return paymentFromWire(JSON.parse(text2));
|
|
1275
|
+
} catch {
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
async function parseWebhookRequest(request, credential, options = {}) {
|
|
1280
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1281
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1282
|
+
if (signature === null || timestamp === null) return null;
|
|
1283
|
+
return parseWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1284
|
+
}
|
|
1285
|
+
async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
|
|
1286
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1287
|
+
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1288
|
+
try {
|
|
1289
|
+
return triggerEventFromWire(JSON.parse(text2));
|
|
1290
|
+
} catch {
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
async function parseWatchedWebhookRequest(request, credential, options = {}) {
|
|
1295
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1296
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1297
|
+
if (signature === null || timestamp === null) return null;
|
|
1298
|
+
return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1299
|
+
}
|
|
1300
|
+
async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
|
|
1301
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1302
|
+
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1303
|
+
const nonce = challenged(text2);
|
|
1304
|
+
if (nonce === null) return null;
|
|
1305
|
+
if (typeof credential !== "string") return JSON.stringify({ nonce });
|
|
1306
|
+
return JSON.stringify({
|
|
1307
|
+
nonce,
|
|
1308
|
+
signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
async function answerWebhookChallengeRequest(request, credential, options = {}) {
|
|
1312
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1313
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1314
|
+
if (signature === null || timestamp === null) return null;
|
|
1315
|
+
const answer = await answerWebhookChallenge(
|
|
1316
|
+
await request.clone().text(),
|
|
1317
|
+
signature,
|
|
1318
|
+
credential,
|
|
1319
|
+
timestamp,
|
|
1320
|
+
options
|
|
1321
|
+
);
|
|
1322
|
+
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1323
|
+
}
|
|
1324
|
+
function challenged(text2) {
|
|
1325
|
+
return nonceOf(text2, CHALLENGE);
|
|
1326
|
+
}
|
|
1327
|
+
function nonceOf(text2, type) {
|
|
1328
|
+
try {
|
|
1329
|
+
const said = JSON.parse(text2);
|
|
1330
|
+
if (said["type"] !== type || typeof said["nonce"] !== "string") return null;
|
|
1331
|
+
return said["nonce"];
|
|
1332
|
+
} catch {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
function answerVerifyChallenge(body) {
|
|
1337
|
+
const nonce = nonceOf(body, VERIFY_CHALLENGE);
|
|
1338
|
+
return nonce === null ? null : JSON.stringify({ nonce });
|
|
1339
|
+
}
|
|
1340
|
+
async function answerVerifyChallengeRequest(request) {
|
|
1341
|
+
if (request.method !== "POST") return null;
|
|
1342
|
+
const answer = answerVerifyChallenge(await request.clone().text());
|
|
1343
|
+
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1344
|
+
}
|
|
1345
|
+
function recent(timestamp, toleranceSecs) {
|
|
1346
|
+
const sent = Number(timestamp);
|
|
1347
|
+
if (!Number.isFinite(sent)) return false;
|
|
1348
|
+
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1349
|
+
}
|
|
1350
|
+
async function sign(secret, timestamp, body) {
|
|
1351
|
+
return hmacHex(secret, signed(timestamp, body));
|
|
1352
|
+
}
|
|
1353
|
+
function signed(timestamp, body) {
|
|
1354
|
+
const encoder = new TextEncoder();
|
|
1355
|
+
const stamp = encoder.encode(`${timestamp}.`);
|
|
1356
|
+
const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
|
|
1357
|
+
const payload = new Uint8Array(stamp.length + rest.length);
|
|
1358
|
+
payload.set(stamp);
|
|
1359
|
+
payload.set(rest, stamp.length);
|
|
1360
|
+
return payload;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1218
1363
|
// src/bank.ts
|
|
1219
1364
|
var DEFAULT_CURRENCY = "CZK";
|
|
1220
1365
|
var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
1366
|
+
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1221
1367
|
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1222
1368
|
var FORBIDDEN_IN_SPD = "*";
|
|
1223
1369
|
async function bankTransfer(params) {
|
|
@@ -1244,7 +1390,12 @@ async function bankTransfer(params) {
|
|
|
1244
1390
|
return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
|
|
1245
1391
|
}
|
|
1246
1392
|
function bankVerifyEndpoint(config) {
|
|
1393
|
+
const paced = {
|
|
1394
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1395
|
+
};
|
|
1247
1396
|
return async (request) => {
|
|
1397
|
+
const consented = await answerVerifyChallengeRequest(request);
|
|
1398
|
+
if (consented !== null) return consented;
|
|
1248
1399
|
const asked = readQuery(new URL(request.url));
|
|
1249
1400
|
if (asked === null) return Response.json({ settled: false }, { status: 400 });
|
|
1250
1401
|
const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
|
|
@@ -1254,11 +1405,11 @@ function bankVerifyEndpoint(config) {
|
|
|
1254
1405
|
}
|
|
1255
1406
|
const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
|
|
1256
1407
|
const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
|
|
1257
|
-
if (!landed) return Response.json({ settled: false });
|
|
1258
|
-
return Response.json(
|
|
1259
|
-
settled: true,
|
|
1260
|
-
|
|
1261
|
-
|
|
1408
|
+
if (!landed) return Response.json({ settled: false }, { headers: paced });
|
|
1409
|
+
return Response.json(
|
|
1410
|
+
{ settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
|
|
1411
|
+
{ headers: paced }
|
|
1412
|
+
);
|
|
1262
1413
|
};
|
|
1263
1414
|
}
|
|
1264
1415
|
function readQuery(url) {
|
|
@@ -1715,138 +1866,6 @@ function refusals2(answers) {
|
|
|
1715
1866
|
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
1716
1867
|
).join("; ");
|
|
1717
1868
|
}
|
|
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
|
-
|
|
1749
|
-
// src/webhook.ts
|
|
1750
|
-
var SIGNATURE_HEADER = "x-signature";
|
|
1751
|
-
var TIMESTAMP_HEADER = "x-timestamp";
|
|
1752
|
-
var SHARED_SECRET_PREFIX = "sha256=";
|
|
1753
|
-
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
1754
|
-
var DEFAULT_TOLERANCE_SECS = 300;
|
|
1755
|
-
var CHALLENGE = "webhook-challenge";
|
|
1756
|
-
async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
|
|
1757
|
-
if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
|
|
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());
|
|
1769
|
-
}
|
|
1770
|
-
async function parseWebhook(body, signature, credential, timestamp, options = {}) {
|
|
1771
|
-
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1772
|
-
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1773
|
-
try {
|
|
1774
|
-
return paymentFromWire(JSON.parse(text2));
|
|
1775
|
-
} catch {
|
|
1776
|
-
return null;
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
async function parseWebhookRequest(request, credential, options = {}) {
|
|
1780
|
-
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1781
|
-
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1782
|
-
if (signature === null || timestamp === null) return null;
|
|
1783
|
-
return parseWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1784
|
-
}
|
|
1785
|
-
async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
|
|
1786
|
-
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1787
|
-
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1788
|
-
try {
|
|
1789
|
-
return triggerEventFromWire(JSON.parse(text2));
|
|
1790
|
-
} catch {
|
|
1791
|
-
return null;
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
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 = {}) {
|
|
1812
|
-
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1813
|
-
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1814
|
-
if (signature === null || timestamp === null) return null;
|
|
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
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
function recent(timestamp, toleranceSecs) {
|
|
1834
|
-
const sent = Number(timestamp);
|
|
1835
|
-
if (!Number.isFinite(sent)) return false;
|
|
1836
|
-
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1837
|
-
}
|
|
1838
|
-
async function sign(secret, timestamp, body) {
|
|
1839
|
-
return hmacHex(secret, signed(timestamp, body));
|
|
1840
|
-
}
|
|
1841
|
-
function signed(timestamp, body) {
|
|
1842
|
-
const encoder = new TextEncoder();
|
|
1843
|
-
const stamp = encoder.encode(`${timestamp}.`);
|
|
1844
|
-
const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
|
|
1845
|
-
const payload = new Uint8Array(stamp.length + rest.length);
|
|
1846
|
-
payload.set(stamp);
|
|
1847
|
-
payload.set(rest, stamp.length);
|
|
1848
|
-
return payload;
|
|
1849
|
-
}
|
|
1850
1869
|
export {
|
|
1851
1870
|
GatewayCheatError,
|
|
1852
1871
|
IDEMPOTENCY_KEY_REUSED,
|
|
@@ -1858,6 +1877,8 @@ export {
|
|
|
1858
1877
|
REQUEST_IN_FLIGHT,
|
|
1859
1878
|
ThunderBridge,
|
|
1860
1879
|
UnverifiedRecipientError,
|
|
1880
|
+
answerVerifyChallenge,
|
|
1881
|
+
answerVerifyChallengeRequest,
|
|
1861
1882
|
answerWebhookChallenge,
|
|
1862
1883
|
answerWebhookChallengeRequest,
|
|
1863
1884
|
bankRail,
|
|
@@ -362,6 +362,16 @@ interface BlindLightningRailConfig {
|
|
|
362
362
|
sealed?: (order: Order) => string | Promise<string>;
|
|
363
363
|
webhookUrl?: string;
|
|
364
364
|
webhookSecret?: string;
|
|
365
|
+
/**
|
|
366
|
+
* Where your own `lightningVerifyEndpoint` is mounted, and the secret it
|
|
367
|
+
* unseals with. Set both and the gateway is handed your URL rather than the
|
|
368
|
+
* wallet's, so it polls you, never a third party, and learns nothing about
|
|
369
|
+
* which provider the recipient uses. Leave them out and it polls the wallet
|
|
370
|
+
*/
|
|
371
|
+
relayVerifyThrough?: {
|
|
372
|
+
endpoint: string;
|
|
373
|
+
secret: string;
|
|
374
|
+
};
|
|
365
375
|
/** What `Leg.rail` reads, for a shop running more than one wallet */
|
|
366
376
|
name?: string;
|
|
367
377
|
}
|
|
@@ -362,6 +362,16 @@ interface BlindLightningRailConfig {
|
|
|
362
362
|
sealed?: (order: Order) => string | Promise<string>;
|
|
363
363
|
webhookUrl?: string;
|
|
364
364
|
webhookSecret?: string;
|
|
365
|
+
/**
|
|
366
|
+
* Where your own `lightningVerifyEndpoint` is mounted, and the secret it
|
|
367
|
+
* unseals with. Set both and the gateway is handed your URL rather than the
|
|
368
|
+
* wallet's, so it polls you, never a third party, and learns nothing about
|
|
369
|
+
* which provider the recipient uses. Leave them out and it polls the wallet
|
|
370
|
+
*/
|
|
371
|
+
relayVerifyThrough?: {
|
|
372
|
+
endpoint: string;
|
|
373
|
+
secret: string;
|
|
374
|
+
};
|
|
365
375
|
/** What `Leg.rail` reads, for a shop running more than one wallet */
|
|
366
376
|
name?: string;
|
|
367
377
|
}
|
package/dist/server.cjs
CHANGED
|
@@ -31,7 +31,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var server_exports = {};
|
|
32
32
|
__export(server_exports, {
|
|
33
33
|
blindLightningRail: () => blindLightningRail,
|
|
34
|
-
|
|
34
|
+
lightningVerifyEndpoint: () => lightningVerifyEndpoint,
|
|
35
|
+
lnurlPayEndpoint: () => lnurlPayEndpoint,
|
|
36
|
+
relayedVerifyUrl: () => relayedVerifyUrl
|
|
35
37
|
});
|
|
36
38
|
module.exports = __toCommonJS(server_exports);
|
|
37
39
|
|
|
@@ -41,6 +43,16 @@ function bytesToHex(bytes) {
|
|
|
41
43
|
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
42
44
|
return hex;
|
|
43
45
|
}
|
|
46
|
+
function hexToBytes(hex) {
|
|
47
|
+
const bytes = new Uint8Array(hex.length >> 1);
|
|
48
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
49
|
+
bytes[at] = parseInt(hex.slice(at * 2, at * 2 + 2), 16);
|
|
50
|
+
}
|
|
51
|
+
return bytes;
|
|
52
|
+
}
|
|
53
|
+
function isHex(text2) {
|
|
54
|
+
return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
|
|
55
|
+
}
|
|
44
56
|
|
|
45
57
|
// ../core/hmac.ts
|
|
46
58
|
async function hmacHex(secret, payload) {
|
|
@@ -248,6 +260,10 @@ function decodeInvoice(bolt11) {
|
|
|
248
260
|
expiresAt: expiryOf(parts.words, tagged)
|
|
249
261
|
};
|
|
250
262
|
}
|
|
263
|
+
function preimageMatchesHash(preimage, paymentHash) {
|
|
264
|
+
if (!isHex(preimage)) return false;
|
|
265
|
+
return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
|
|
266
|
+
}
|
|
251
267
|
function splitBech32(bolt11) {
|
|
252
268
|
const lower = bolt11.toLowerCase();
|
|
253
269
|
if (isBolt12Encoding(lower)) return null;
|
|
@@ -428,19 +444,19 @@ async function refuseUnlessPublic(url) {
|
|
|
428
444
|
if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
|
|
429
445
|
}
|
|
430
446
|
async function answerOf(response) {
|
|
431
|
-
const { status, ok } = response;
|
|
447
|
+
const { status, ok, headers } = response;
|
|
432
448
|
const reader = response.body?.getReader();
|
|
433
|
-
if (!reader) return { status, ok, body: "", truncated: false };
|
|
449
|
+
if (!reader) return { status, ok, body: "", truncated: false, headers };
|
|
434
450
|
const read = [];
|
|
435
451
|
let bytes = 0;
|
|
436
452
|
while (bytes <= BODY_LIMIT_BYTES) {
|
|
437
453
|
const { done, value } = await reader.read();
|
|
438
|
-
if (done) return { status, ok, body: text(read), truncated: false };
|
|
454
|
+
if (done) return { status, ok, body: text(read), truncated: false, headers };
|
|
439
455
|
read.push(value);
|
|
440
456
|
bytes += value.length;
|
|
441
457
|
}
|
|
442
458
|
await reader.cancel();
|
|
443
|
-
return { status, ok, body: text(read), truncated: true };
|
|
459
|
+
return { status, ok, body: text(read), truncated: true, headers };
|
|
444
460
|
}
|
|
445
461
|
function withoutBody(sent) {
|
|
446
462
|
const headers = { ...sent.headers };
|
|
@@ -478,6 +494,11 @@ var NoWalletAvailable = class extends Error {
|
|
|
478
494
|
// ../core/lnurl.ts
|
|
479
495
|
var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
|
|
480
496
|
var RESOLVE_TIMEOUT_MS = 3e4;
|
|
497
|
+
var NAMES_ONE_ACCOUNT = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
|
|
498
|
+
var MIN_PACE_SECS = 1;
|
|
499
|
+
var MAX_PACE_SECS = 3600;
|
|
500
|
+
var MIN_PER_SECOND = 0.01;
|
|
501
|
+
var MAX_PER_SECOND = 100;
|
|
481
502
|
async function resolve(addresses, amountMsat) {
|
|
482
503
|
const served = await firstThatServes(
|
|
483
504
|
addresses,
|
|
@@ -565,6 +586,31 @@ function decodeIssued(address, bolt11) {
|
|
|
565
586
|
}
|
|
566
587
|
return { paymentHash, descriptionHash, amountMsat, expiresAt };
|
|
567
588
|
}
|
|
589
|
+
async function checkSettled(verifyUrl, paymentHash) {
|
|
590
|
+
const answer = await answeredJson(verifyUrl);
|
|
591
|
+
const asked = {
|
|
592
|
+
pace: paceAskedFor(answer.headers),
|
|
593
|
+
ceiling: ceilingAskedFor(answer.headers)
|
|
594
|
+
};
|
|
595
|
+
if (!answer.said.settled || !answer.said.preimage) return { preimage: null, ...asked };
|
|
596
|
+
if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
|
|
597
|
+
throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
|
|
598
|
+
}
|
|
599
|
+
return { preimage: answer.said.preimage, ...asked };
|
|
600
|
+
}
|
|
601
|
+
function paceAskedFor(headers) {
|
|
602
|
+
const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
|
|
603
|
+
if (!asked) return null;
|
|
604
|
+
return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
|
|
605
|
+
}
|
|
606
|
+
function ceilingAskedFor(headers) {
|
|
607
|
+
const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
|
|
608
|
+
if (!asked) return null;
|
|
609
|
+
const perWindow = Number(asked[1]);
|
|
610
|
+
const window = asked[2] === void 0 ? 1 : Number(asked[2]);
|
|
611
|
+
if (perWindow === 0 || window === 0) return null;
|
|
612
|
+
return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
|
|
613
|
+
}
|
|
568
614
|
function cannotReleaseAPreimage(host) {
|
|
569
615
|
const lowered = host.toLowerCase();
|
|
570
616
|
return VERIFY_WITHOUT_PREIMAGE.some(
|
|
@@ -577,18 +623,29 @@ function withAmount(callback, amountMsat) {
|
|
|
577
623
|
}
|
|
578
624
|
function splitAddress(address) {
|
|
579
625
|
const [user, domain, ...rest] = address.split("@");
|
|
580
|
-
if (!user || !domain || rest.length > 0 || !publicHttps(`https://${domain}/`)) {
|
|
626
|
+
if (!user || !domain || rest.length > 0 || !NAMES_ONE_ACCOUNT.test(user) || !isNothingButAHost(domain) || !publicHttps(`https://${domain}/`)) {
|
|
581
627
|
throw new WalletRefused("address-unusable", `${address} is not a usable lightning address`);
|
|
582
628
|
}
|
|
583
629
|
return [user, domain];
|
|
584
630
|
}
|
|
631
|
+
function isNothingButAHost(domain) {
|
|
632
|
+
try {
|
|
633
|
+
const parsed = new URL(`https://${domain}/`);
|
|
634
|
+
return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
|
|
635
|
+
} catch {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
585
639
|
async function fetchJson(url, deadline) {
|
|
640
|
+
return (await answeredJson(url, deadline)).said;
|
|
641
|
+
}
|
|
642
|
+
async function answeredJson(url, deadline) {
|
|
586
643
|
const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
|
|
587
644
|
if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
|
|
588
645
|
if (answer.truncated) {
|
|
589
646
|
throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
|
|
590
647
|
}
|
|
591
|
-
return JSON.parse(answer.body);
|
|
648
|
+
return { said: JSON.parse(answer.body), headers: answer.headers };
|
|
592
649
|
}
|
|
593
650
|
|
|
594
651
|
// ../core/sealed.ts
|
|
@@ -610,6 +667,22 @@ async function seal(secret, plaintext) {
|
|
|
610
667
|
joined.set(cipher, iv.length);
|
|
611
668
|
return `${VERSION}.${toBase64Url(joined)}`;
|
|
612
669
|
}
|
|
670
|
+
async function unseal(secret, sealed) {
|
|
671
|
+
const key = await keyFor(secret);
|
|
672
|
+
if (!sealed.startsWith(`${VERSION}.`)) return null;
|
|
673
|
+
const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
|
|
674
|
+
if (joined === null || joined.length <= IV_BYTES) return null;
|
|
675
|
+
try {
|
|
676
|
+
const body = await crypto.subtle.decrypt(
|
|
677
|
+
{ name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
|
|
678
|
+
key,
|
|
679
|
+
joined.slice(IV_BYTES)
|
|
680
|
+
);
|
|
681
|
+
return new TextDecoder().decode(body);
|
|
682
|
+
} catch {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
613
686
|
async function keyFor(secret) {
|
|
614
687
|
if (secret.length < MIN_SECRET_CHARS) {
|
|
615
688
|
throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
|
|
@@ -634,6 +707,17 @@ function toBase64Url(bytes) {
|
|
|
634
707
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
635
708
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
636
709
|
}
|
|
710
|
+
function fromBase64Url(text2) {
|
|
711
|
+
if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
|
|
712
|
+
try {
|
|
713
|
+
const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
|
|
714
|
+
const bytes = new Uint8Array(binary.length);
|
|
715
|
+
for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
|
|
716
|
+
return bytes;
|
|
717
|
+
} catch {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
637
721
|
|
|
638
722
|
// src/errors.ts
|
|
639
723
|
var ProblemError = class extends Error {
|
|
@@ -756,6 +840,30 @@ function refuse(reason) {
|
|
|
756
840
|
return Response.json({ status: "ERROR", reason });
|
|
757
841
|
}
|
|
758
842
|
|
|
843
|
+
// ../core/ed25519.ts
|
|
844
|
+
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
845
|
+
|
|
846
|
+
// src/webhook.ts
|
|
847
|
+
var VERIFY_CHALLENGE = "verify-challenge";
|
|
848
|
+
function nonceOf(text2, type) {
|
|
849
|
+
try {
|
|
850
|
+
const said = JSON.parse(text2);
|
|
851
|
+
if (said["type"] !== type || typeof said["nonce"] !== "string") return null;
|
|
852
|
+
return said["nonce"];
|
|
853
|
+
} catch {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function answerVerifyChallenge(body) {
|
|
858
|
+
const nonce = nonceOf(body, VERIFY_CHALLENGE);
|
|
859
|
+
return nonce === null ? null : JSON.stringify({ nonce });
|
|
860
|
+
}
|
|
861
|
+
async function answerVerifyChallengeRequest(request) {
|
|
862
|
+
if (request.method !== "POST") return null;
|
|
863
|
+
const answer = answerVerifyChallenge(await request.clone().text());
|
|
864
|
+
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
865
|
+
}
|
|
866
|
+
|
|
759
867
|
// src/qr.ts
|
|
760
868
|
var import_uqr = require("uqr");
|
|
761
869
|
function encodeForQr(destination) {
|
|
@@ -767,14 +875,48 @@ function isLnAddress(destination) {
|
|
|
767
875
|
return at > 0 && destination.slice(at + 1).includes(".");
|
|
768
876
|
}
|
|
769
877
|
|
|
878
|
+
// src/relay.ts
|
|
879
|
+
var DEFAULT_POLL_EVERY_SECS = 5;
|
|
880
|
+
var WALLET = "w";
|
|
881
|
+
function lightningVerifyEndpoint(config) {
|
|
882
|
+
const paced = {
|
|
883
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
884
|
+
};
|
|
885
|
+
return async (request) => {
|
|
886
|
+
const consented = await answerVerifyChallengeRequest(request);
|
|
887
|
+
if (consented !== null) return consented;
|
|
888
|
+
const sealed = new URL(request.url).searchParams.get(WALLET);
|
|
889
|
+
if (sealed === null) return Response.json({ settled: false }, { status: 400 });
|
|
890
|
+
const opened = await unseal(config.secret, sealed);
|
|
891
|
+
if (opened === null) return Response.json({ settled: false }, { status: 403 });
|
|
892
|
+
const wallet = JSON.parse(opened);
|
|
893
|
+
const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
|
|
894
|
+
if (asked === null) return Response.json({ settled: false }, { status: 502 });
|
|
895
|
+
return Response.json(
|
|
896
|
+
{ settled: asked.preimage !== null, preimage: asked.preimage },
|
|
897
|
+
{ headers: paced }
|
|
898
|
+
);
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
902
|
+
const relayed = new URL(endpoint);
|
|
903
|
+
relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
|
|
904
|
+
return relayed.toString();
|
|
905
|
+
}
|
|
906
|
+
|
|
770
907
|
// src/rail.ts
|
|
771
908
|
var LIGHTNING = "lightning";
|
|
772
909
|
function blindLightningRail(config) {
|
|
773
910
|
return async (order) => {
|
|
774
911
|
const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
|
|
912
|
+
const relay = config.relayVerifyThrough;
|
|
775
913
|
const watched = await config.gateway.watchPayment({
|
|
776
914
|
paymentHash: resolved.paymentHash,
|
|
777
|
-
verifyUrl:
|
|
915
|
+
verifyUrl: relay ? await relayedVerifyUrl(
|
|
916
|
+
relay.endpoint,
|
|
917
|
+
{ url: resolved.verifyUrl, hash: resolved.paymentHash },
|
|
918
|
+
relay.secret
|
|
919
|
+
) : resolved.verifyUrl,
|
|
778
920
|
expiresAt: resolved.expiresAt,
|
|
779
921
|
trigger: config.trigger,
|
|
780
922
|
sealed: await config.sealed?.(order),
|
|
@@ -803,5 +945,7 @@ async function invoiceFrom(lnAddresses, amountMsat) {
|
|
|
803
945
|
// Annotate the CommonJS export names for ESM import in node:
|
|
804
946
|
0 && (module.exports = {
|
|
805
947
|
blindLightningRail,
|
|
806
|
-
|
|
948
|
+
lightningVerifyEndpoint,
|
|
949
|
+
lnurlPayEndpoint,
|
|
950
|
+
relayedVerifyUrl
|
|
807
951
|
});
|
package/dist/server.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { T as ThunderBridge } from './rail-
|
|
2
|
-
export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-
|
|
1
|
+
import { T as ThunderBridge } from './rail-J8QoYbSr.cjs';
|
|
2
|
+
export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-J8QoYbSr.cjs';
|
|
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
|
-
|
|
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 };
|