@otskit/client 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -13
- package/dist/index.cjs +328 -84
- package/dist/index.d.cts +84 -16
- package/dist/index.d.ts +84 -16
- package/dist/index.js +323 -84
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -39,17 +39,23 @@ __export(index_exports, {
|
|
|
39
39
|
OpenTimestampsClientError: () => OpenTimestampsClientError,
|
|
40
40
|
PUBLIC_ESPLORA_URL: () => PUBLIC_ESPLORA_URL,
|
|
41
41
|
ResilientNetworkLayer: () => ResilientNetworkLayer,
|
|
42
|
+
SizeLimitExceededError: () => SizeLimitExceededError,
|
|
42
43
|
StampError: () => StampError,
|
|
43
44
|
Timestamp: () => Timestamp,
|
|
44
45
|
UpgradeError: () => UpgradeError,
|
|
45
46
|
UrlWhitelist: () => UrlWhitelist,
|
|
46
47
|
ValidationError: () => ValidationError,
|
|
48
|
+
assertSafeCalendarUrl: () => assertSafeCalendarUrl,
|
|
49
|
+
hashBuffer: () => hashBuffer,
|
|
50
|
+
hashFile: () => hashFile,
|
|
51
|
+
isVerified: () => isVerified,
|
|
47
52
|
verifyAgainstBlockheader: () => verifyAgainstBlockheader,
|
|
48
53
|
verifyTimestampAttestation: () => verifyTimestampAttestation
|
|
49
54
|
});
|
|
50
55
|
module.exports = __toCommonJS(index_exports);
|
|
51
56
|
|
|
52
57
|
// src/types.ts
|
|
58
|
+
var isVerified = (r) => r.status === "verified";
|
|
53
59
|
var DEFAULT_CALENDARS = [
|
|
54
60
|
"https://alice.btc.calendar.opentimestamps.org",
|
|
55
61
|
"https://bob.btc.calendar.opentimestamps.org",
|
|
@@ -119,6 +125,18 @@ var CalendarResponseTooLargeError = class extends NetworkError {
|
|
|
119
125
|
};
|
|
120
126
|
var EsploraResponseError = class extends NetworkError {
|
|
121
127
|
};
|
|
128
|
+
var SizeLimitExceededError = class extends NetworkError {
|
|
129
|
+
maxBytes;
|
|
130
|
+
actualBytes;
|
|
131
|
+
constructor(maxBytes, actualBytes, options) {
|
|
132
|
+
super(
|
|
133
|
+
actualBytes === void 0 ? `Response size exceeds limit of ${maxBytes} bytes` : `Response size ${actualBytes} bytes exceeds limit of ${maxBytes} bytes`,
|
|
134
|
+
options
|
|
135
|
+
);
|
|
136
|
+
this.maxBytes = maxBytes;
|
|
137
|
+
this.actualBytes = actualBytes;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
122
140
|
|
|
123
141
|
// src/network/circuit-breaker.ts
|
|
124
142
|
var CircuitState = /* @__PURE__ */ ((CircuitState2) => {
|
|
@@ -307,33 +325,62 @@ async function withRetry(fn, options, logger, signal) {
|
|
|
307
325
|
}
|
|
308
326
|
|
|
309
327
|
// src/adapters/fetch-adapter.ts
|
|
310
|
-
|
|
328
|
+
function getDeclaredContentLength(response) {
|
|
329
|
+
const value = response.headers.get("content-length");
|
|
330
|
+
if (value === null || !/^\d+$/.test(value)) return void 0;
|
|
331
|
+
const n = Number(value);
|
|
332
|
+
return Number.isSafeInteger(n) ? n : void 0;
|
|
333
|
+
}
|
|
334
|
+
async function readStreamLimited(body, maxBytes, status) {
|
|
335
|
+
const reader = body.getReader();
|
|
336
|
+
const buffer = new Uint8Array(maxBytes);
|
|
337
|
+
let received = 0;
|
|
338
|
+
try {
|
|
339
|
+
while (true) {
|
|
340
|
+
const { done, value } = await reader.read();
|
|
341
|
+
if (done) return buffer.subarray(0, received);
|
|
342
|
+
const next = received + value.byteLength;
|
|
343
|
+
if (next > maxBytes) {
|
|
344
|
+
await reader.cancel();
|
|
345
|
+
throw new SizeLimitExceededError(maxBytes, next, { status });
|
|
346
|
+
}
|
|
347
|
+
buffer.set(value, received);
|
|
348
|
+
received = next;
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
reader.releaseLock();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function readResponseBody(response, maxBytes) {
|
|
355
|
+
const contentLength = getDeclaredContentLength(response);
|
|
356
|
+
if (contentLength !== void 0 && contentLength > maxBytes) {
|
|
357
|
+
throw new SizeLimitExceededError(maxBytes, contentLength, { status: response.status });
|
|
358
|
+
}
|
|
359
|
+
if (response.body === null) {
|
|
360
|
+
const ab = await response.arrayBuffer();
|
|
361
|
+
if (ab.byteLength > maxBytes) {
|
|
362
|
+
throw new SizeLimitExceededError(maxBytes, ab.byteLength, { status: response.status });
|
|
363
|
+
}
|
|
364
|
+
return new Uint8Array(ab);
|
|
365
|
+
}
|
|
366
|
+
return readStreamLimited(response.body, maxBytes, response.status);
|
|
367
|
+
}
|
|
368
|
+
async function executeRequest(request, maxBytes) {
|
|
311
369
|
try {
|
|
312
370
|
const response = await globalThis.fetch(request.url, {
|
|
313
371
|
method: request.method,
|
|
314
|
-
headers: {
|
|
315
|
-
"Content-Type": "application/octet-stream",
|
|
316
|
-
...request.headers
|
|
317
|
-
},
|
|
372
|
+
headers: { "Content-Type": "application/octet-stream", ...request.headers },
|
|
318
373
|
body: request.body,
|
|
319
374
|
signal: request.signal
|
|
320
375
|
});
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
return {
|
|
324
|
-
ok: response.ok,
|
|
325
|
-
status: response.status,
|
|
326
|
-
statusText: response.statusText,
|
|
327
|
-
data
|
|
328
|
-
};
|
|
376
|
+
const data = await readResponseBody(response, maxBytes);
|
|
377
|
+
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
|
329
378
|
} catch (error) {
|
|
379
|
+
if (error instanceof SizeLimitExceededError) throw error;
|
|
380
|
+
if (error instanceof NetworkError) throw error;
|
|
330
381
|
if (error instanceof Error) {
|
|
331
|
-
if (error.name === "AbortError") {
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
if (error.message.includes("timeout")) {
|
|
335
|
-
throw new NetworkError("Request timeout", { cause: error });
|
|
336
|
-
}
|
|
382
|
+
if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
|
|
383
|
+
if (error.message.includes("timeout")) throw new NetworkError("Request timeout", { cause: error });
|
|
337
384
|
throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
|
|
338
385
|
}
|
|
339
386
|
throw new NetworkError("Unknown network error");
|
|
@@ -341,23 +388,24 @@ async function executeRequest(request) {
|
|
|
341
388
|
}
|
|
342
389
|
function createTimeoutController(timeoutMs, parentSignal) {
|
|
343
390
|
const controller = new AbortController();
|
|
344
|
-
const timeout = setTimeout(() =>
|
|
345
|
-
|
|
346
|
-
|
|
391
|
+
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), timeoutMs);
|
|
392
|
+
const onParentAbort = () => {
|
|
393
|
+
clearTimeout(timeout);
|
|
394
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
395
|
+
controller.abort(parentSignal?.reason);
|
|
396
|
+
};
|
|
397
|
+
controller.signal.addEventListener("abort", () => {
|
|
398
|
+
clearTimeout(timeout);
|
|
399
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
400
|
+
}, { once: true });
|
|
347
401
|
if (parentSignal) {
|
|
348
402
|
if (parentSignal.aborted) {
|
|
349
403
|
clearTimeout(timeout);
|
|
350
404
|
controller.abort(parentSignal.reason);
|
|
351
405
|
} else {
|
|
352
|
-
parentSignal.addEventListener("abort",
|
|
353
|
-
clearTimeout(timeout);
|
|
354
|
-
controller.abort(parentSignal.reason);
|
|
355
|
-
});
|
|
406
|
+
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
356
407
|
}
|
|
357
408
|
}
|
|
358
|
-
controller.signal.addEventListener("abort", () => {
|
|
359
|
-
clearTimeout(timeout);
|
|
360
|
-
});
|
|
361
409
|
return controller;
|
|
362
410
|
}
|
|
363
411
|
|
|
@@ -389,10 +437,10 @@ var ResilientNetworkLayer = class {
|
|
|
389
437
|
totalController.signal
|
|
390
438
|
);
|
|
391
439
|
try {
|
|
392
|
-
const response = await executeRequest(
|
|
393
|
-
...request,
|
|
394
|
-
|
|
395
|
-
|
|
440
|
+
const response = await executeRequest(
|
|
441
|
+
{ ...request, signal: attemptController.signal },
|
|
442
|
+
this.options.maxResponseBytes ?? 1e5
|
|
443
|
+
);
|
|
396
444
|
const elapsed = Date.now() - startTime;
|
|
397
445
|
this.logger?.debug(`Request to ${calendarUrl} succeeded in ${elapsed}ms`);
|
|
398
446
|
if (!response.ok) {
|
|
@@ -411,8 +459,7 @@ var ResilientNetworkLayer = class {
|
|
|
411
459
|
}
|
|
412
460
|
return response;
|
|
413
461
|
} finally {
|
|
414
|
-
attemptController.
|
|
415
|
-
});
|
|
462
|
+
attemptController.abort(new Error("Attempt complete"));
|
|
416
463
|
}
|
|
417
464
|
},
|
|
418
465
|
this.options.retries,
|
|
@@ -425,8 +472,7 @@ var ResilientNetworkLayer = class {
|
|
|
425
472
|
this.logger?.error(`Request to ${calendarUrl} failed after ${elapsed}ms`, error);
|
|
426
473
|
throw error;
|
|
427
474
|
} finally {
|
|
428
|
-
totalController.
|
|
429
|
-
});
|
|
475
|
+
totalController.abort(new Error("Request complete"));
|
|
430
476
|
}
|
|
431
477
|
}
|
|
432
478
|
/** Get circuit breaker state for a calendar */
|
|
@@ -1915,7 +1961,7 @@ var CalendarClient = class {
|
|
|
1915
1961
|
url;
|
|
1916
1962
|
networkLayer;
|
|
1917
1963
|
logger;
|
|
1918
|
-
/**
|
|
1964
|
+
/** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
|
|
1919
1965
|
async submit(digest, signal) {
|
|
1920
1966
|
assertCommitment(digest);
|
|
1921
1967
|
this.logger?.debug(`Submitting digest to ${this.url}/digest`);
|
|
@@ -1926,7 +1972,7 @@ var CalendarClient = class {
|
|
|
1926
1972
|
);
|
|
1927
1973
|
return this.#parseTimestamp(response.data, digest);
|
|
1928
1974
|
}
|
|
1929
|
-
/** Pregunta al calendario si tiene un Timestamp
|
|
1975
|
+
/** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
|
|
1930
1976
|
async getTimestamp(commitment, signal) {
|
|
1931
1977
|
assertCommitment(commitment);
|
|
1932
1978
|
const path = `/timestamp/${bytesToHex(commitment)}`;
|
|
@@ -1961,43 +2007,79 @@ var CalendarClient = class {
|
|
|
1961
2007
|
return timestamp;
|
|
1962
2008
|
}
|
|
1963
2009
|
};
|
|
1964
|
-
function
|
|
1965
|
-
|
|
1966
|
-
|
|
2010
|
+
function parseWhitelistPattern(raw) {
|
|
2011
|
+
let parsed;
|
|
2012
|
+
try {
|
|
2013
|
+
parsed = new URL(raw);
|
|
2014
|
+
} catch {
|
|
2015
|
+
return void 0;
|
|
2016
|
+
}
|
|
2017
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
|
|
2018
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
2019
|
+
const wildcardSuffix = hostname.startsWith("*.") ? hostname.slice(2) : void 0;
|
|
2020
|
+
if (hostname.includes("*") && wildcardSuffix === void 0) return void 0;
|
|
2021
|
+
if (wildcardSuffix !== void 0 && (wildcardSuffix.length === 0 || wildcardSuffix.includes("*"))) return void 0;
|
|
2022
|
+
return {
|
|
2023
|
+
protocol: parsed.protocol,
|
|
2024
|
+
hostname,
|
|
2025
|
+
port: parsed.port,
|
|
2026
|
+
pathname: parsed.pathname,
|
|
2027
|
+
wildcardSuffix
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
function hostnameMatchesPattern(hostname, pattern) {
|
|
2031
|
+
if (pattern.wildcardSuffix === void 0) return hostname === pattern.hostname;
|
|
2032
|
+
if (!hostname.endsWith("." + pattern.wildcardSuffix)) return false;
|
|
2033
|
+
const label = hostname.slice(0, -pattern.wildcardSuffix.length - 1);
|
|
2034
|
+
return label.length > 0 && !label.includes(".");
|
|
1967
2035
|
}
|
|
1968
2036
|
var UrlWhitelist = class {
|
|
1969
|
-
#patterns = /* @__PURE__ */ new
|
|
2037
|
+
#patterns = /* @__PURE__ */ new Map();
|
|
1970
2038
|
constructor(urls) {
|
|
1971
2039
|
if (urls) {
|
|
1972
2040
|
for (const u of urls) this.add(u);
|
|
1973
2041
|
}
|
|
1974
2042
|
}
|
|
1975
|
-
/**
|
|
2043
|
+
/** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
|
|
1976
2044
|
add(url) {
|
|
1977
2045
|
if (typeof url !== "string") {
|
|
1978
2046
|
throw new TypeError("UrlWhitelist: URL must be a string");
|
|
1979
2047
|
}
|
|
1980
2048
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
1981
|
-
|
|
2049
|
+
const pattern = parseWhitelistPattern(url);
|
|
2050
|
+
if (pattern !== void 0) this.#patterns.set(url, pattern);
|
|
1982
2051
|
} else {
|
|
1983
|
-
this
|
|
1984
|
-
this
|
|
2052
|
+
this.add("http://" + url);
|
|
2053
|
+
this.add("https://" + url);
|
|
1985
2054
|
}
|
|
1986
2055
|
}
|
|
1987
|
-
/** Verdadero si `url` casa con
|
|
2056
|
+
/** Verdadero si `url` casa con algun patron de la whitelist. */
|
|
1988
2057
|
contains(url) {
|
|
1989
|
-
|
|
1990
|
-
|
|
2058
|
+
let parsed;
|
|
2059
|
+
try {
|
|
2060
|
+
parsed = new URL(url);
|
|
2061
|
+
} catch {
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
2065
|
+
if (parsed.search !== "" || parsed.hash !== "") return false;
|
|
2066
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
2067
|
+
for (const pattern of this.#patterns.values()) {
|
|
2068
|
+
if (parsed.protocol !== pattern.protocol || parsed.port !== pattern.port) continue;
|
|
2069
|
+
if (parsed.pathname !== pattern.pathname) continue;
|
|
2070
|
+
if (hostnameMatchesPattern(hostname, pattern)) return true;
|
|
1991
2071
|
}
|
|
1992
2072
|
return false;
|
|
1993
2073
|
}
|
|
1994
2074
|
toString() {
|
|
1995
|
-
return `UrlWhitelist([${[...this.#patterns].join(", ")}])`;
|
|
2075
|
+
return `UrlWhitelist([${[...this.#patterns.keys()].join(", ")}])`;
|
|
1996
2076
|
}
|
|
1997
2077
|
};
|
|
1998
2078
|
var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([
|
|
1999
2079
|
"https://*.calendar.opentimestamps.org",
|
|
2000
2080
|
// Peter Todd
|
|
2081
|
+
"https://*.btc.calendar.opentimestamps.org",
|
|
2082
|
+
// Peter Todd Bitcoin calendars
|
|
2001
2083
|
"https://*.calendar.eternitywall.com",
|
|
2002
2084
|
// Eternity Wall
|
|
2003
2085
|
"https://*.calendar.catallaxy.com"
|
|
@@ -2090,7 +2172,13 @@ var EsploraClient = class {
|
|
|
2090
2172
|
`esplora response of ${data.length} bytes exceeds limit ${MAX_ESPLORA_RESPONSE_SIZE}`
|
|
2091
2173
|
);
|
|
2092
2174
|
}
|
|
2093
|
-
|
|
2175
|
+
try {
|
|
2176
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
2177
|
+
} catch (cause) {
|
|
2178
|
+
throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
|
|
2179
|
+
cause: cause instanceof Error ? cause : void 0
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2094
2182
|
}
|
|
2095
2183
|
};
|
|
2096
2184
|
async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
|
|
@@ -2103,6 +2191,106 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
|
|
|
2103
2191
|
}
|
|
2104
2192
|
|
|
2105
2193
|
// src/core/orchestration.ts
|
|
2194
|
+
var import_node_crypto = require("crypto");
|
|
2195
|
+
|
|
2196
|
+
// src/security/ssrf.ts
|
|
2197
|
+
var import_promises = require("dns/promises");
|
|
2198
|
+
var import_node_net = require("net");
|
|
2199
|
+
var BLOCKED_CIDRS_V4 = [
|
|
2200
|
+
{ network: 0, mask: 4278190080, label: "0.0.0.0/8 (This Network)" },
|
|
2201
|
+
{ network: 167772160, mask: 4278190080, label: "10.0.0.0/8 (RFC 1918)" },
|
|
2202
|
+
{ network: 2130706432, mask: 4278190080, label: "127.0.0.0/8 (Loopback)" },
|
|
2203
|
+
{ network: 2851995648, mask: 4294901760, label: "169.254.0.0/16 (Link-local/IMDS)" },
|
|
2204
|
+
{ network: 2886729728, mask: 4293918720, label: "172.16.0.0/12 (RFC 1918)" },
|
|
2205
|
+
{ network: 3232235520, mask: 4294901760, label: "192.168.0.0/16 (RFC 1918)" },
|
|
2206
|
+
{ network: 3323068416, mask: 4294836224, label: "198.18.0.0/15 (Benchmarking)" },
|
|
2207
|
+
{ network: 3758096384, mask: 4026531840, label: "224.0.0.0/4 (Multicast)" },
|
|
2208
|
+
{ network: 4026531840, mask: 4026531840, label: "240.0.0.0/4 (Reserved)" },
|
|
2209
|
+
{ network: 4294967295, mask: 4294967295, label: "255.255.255.255 (Broadcast)" }
|
|
2210
|
+
];
|
|
2211
|
+
function ipv4ToUint32(ip) {
|
|
2212
|
+
const parts = ip.split(".");
|
|
2213
|
+
return (parseInt(parts[0], 10) << 24 | parseInt(parts[1], 10) << 16 | parseInt(parts[2], 10) << 8 | parseInt(parts[3], 10)) >>> 0;
|
|
2214
|
+
}
|
|
2215
|
+
function assertNotPrivateIPv4(ip, calendarUrl) {
|
|
2216
|
+
const n = ipv4ToUint32(ip);
|
|
2217
|
+
for (const cidr of BLOCKED_CIDRS_V4) {
|
|
2218
|
+
if ((n & cidr.mask) >>> 0 === cidr.network) {
|
|
2219
|
+
throw new ValidationError(
|
|
2220
|
+
`Calendar URL "${calendarUrl}" resolves to a private/reserved IPv4 address (${ip} \u2014 ${cidr.label}). Set allowPrivateCalendars: true to override.`
|
|
2221
|
+
);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
var BLOCKED_IPV6_PREFIXES = [
|
|
2226
|
+
{ prefix: "::1", label: "Loopback" },
|
|
2227
|
+
{ prefix: "::", label: "Unspecified" },
|
|
2228
|
+
{ prefix: "fc", label: "fc00::/7 (Unique Local)" },
|
|
2229
|
+
{ prefix: "fd", label: "fd00::/8 (Unique Local)" },
|
|
2230
|
+
{ prefix: "fe8", label: "fe80::/10 (Link-local)" },
|
|
2231
|
+
{ prefix: "fe9", label: "fe80::/10 (Link-local)" },
|
|
2232
|
+
{ prefix: "fea", label: "fe80::/10 (Link-local)" },
|
|
2233
|
+
{ prefix: "feb", label: "fe80::/10 (Link-local)" },
|
|
2234
|
+
{ prefix: "ff", label: "ff00::/8 (Multicast)" },
|
|
2235
|
+
{ prefix: "::ffff:", label: "IPv4-mapped IPv6" },
|
|
2236
|
+
{ prefix: "64:ff9b:", label: "64:ff9b::/96 (NAT64)" },
|
|
2237
|
+
{ prefix: "2001:db8", label: "2001:db8::/32 (Documentation)" }
|
|
2238
|
+
];
|
|
2239
|
+
function assertNotPrivateIPv6(ip, calendarUrl) {
|
|
2240
|
+
const lower = ip.toLowerCase();
|
|
2241
|
+
for (const { prefix, label } of BLOCKED_IPV6_PREFIXES) {
|
|
2242
|
+
if (lower === prefix || lower.startsWith(prefix)) {
|
|
2243
|
+
throw new ValidationError(
|
|
2244
|
+
`Calendar URL "${calendarUrl}" resolves to a private/reserved IPv6 address (${ip} \u2014 ${label}). Set allowPrivateCalendars: true to override.`
|
|
2245
|
+
);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
async function assertSafeCalendarUrl(url, options) {
|
|
2250
|
+
let parsed;
|
|
2251
|
+
try {
|
|
2252
|
+
parsed = new URL(url);
|
|
2253
|
+
} catch {
|
|
2254
|
+
throw new ValidationError(`Calendar URL is not valid: "${url}"`);
|
|
2255
|
+
}
|
|
2256
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
2257
|
+
throw new ValidationError(`Calendar URL must use http or https: "${url}"`);
|
|
2258
|
+
}
|
|
2259
|
+
if (parsed.username || parsed.password) {
|
|
2260
|
+
throw new ValidationError(`Calendar URL must not contain embedded credentials: "${url}"`);
|
|
2261
|
+
}
|
|
2262
|
+
if (options.allowPrivate) return;
|
|
2263
|
+
const hostname = parsed.hostname;
|
|
2264
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
2265
|
+
const ipVersion = (0, import_node_net.isIP)(host);
|
|
2266
|
+
if (ipVersion === 4) {
|
|
2267
|
+
assertNotPrivateIPv4(host, url);
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
if (ipVersion === 6) {
|
|
2271
|
+
assertNotPrivateIPv6(host, url);
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
let addresses;
|
|
2275
|
+
try {
|
|
2276
|
+
addresses = await (0, import_promises.lookup)(hostname, { all: true });
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2279
|
+
throw new ValidationError(
|
|
2280
|
+
`Calendar URL hostname "${hostname}" could not be resolved: ${message}`
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
if (addresses.length === 0) {
|
|
2284
|
+
throw new ValidationError(`Calendar URL hostname "${hostname}" resolved to no addresses`);
|
|
2285
|
+
}
|
|
2286
|
+
for (const { address, family } of addresses) {
|
|
2287
|
+
if (family === 4) assertNotPrivateIPv4(address, url);
|
|
2288
|
+
else if (family === 6) assertNotPrivateIPv6(address, url);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// src/core/orchestration.ts
|
|
2293
|
+
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
2106
2294
|
function validateHash(hash) {
|
|
2107
2295
|
if (typeof hash === "string") {
|
|
2108
2296
|
const hex = hash.trim().toLowerCase();
|
|
@@ -2124,19 +2312,12 @@ function secureNonce(n) {
|
|
|
2124
2312
|
globalThis.crypto.getRandomValues(bytes);
|
|
2125
2313
|
return bytes;
|
|
2126
2314
|
}
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
try {
|
|
2131
|
-
parsed = new URL(url);
|
|
2132
|
-
} catch {
|
|
2133
|
-
throw new ValidationError(`${label} is not a valid URL: ${url}`);
|
|
2134
|
-
}
|
|
2135
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
2136
|
-
throw new ValidationError(`${label} must use http(s): ${url}`);
|
|
2137
|
-
}
|
|
2315
|
+
function timingSafeEq(a, b) {
|
|
2316
|
+
if (a.length !== b.length) return false;
|
|
2317
|
+
return (0, import_node_crypto.timingSafeEqual)(a, b);
|
|
2138
2318
|
}
|
|
2139
|
-
|
|
2319
|
+
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
2320
|
+
async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
|
|
2140
2321
|
if (calendars.length === 0) {
|
|
2141
2322
|
throw new ValidationError("at least one calendar is required to stamp");
|
|
2142
2323
|
}
|
|
@@ -2148,7 +2329,9 @@ async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, m
|
|
|
2148
2329
|
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
2149
2330
|
);
|
|
2150
2331
|
}
|
|
2151
|
-
|
|
2332
|
+
await Promise.all(
|
|
2333
|
+
calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
|
|
2334
|
+
);
|
|
2152
2335
|
const digest = validateHash(hash);
|
|
2153
2336
|
logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
|
|
2154
2337
|
const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
|
|
@@ -2220,7 +2403,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
|
|
|
2220
2403
|
}
|
|
2221
2404
|
}
|
|
2222
2405
|
const after = detached.serializeToBytes();
|
|
2223
|
-
if (
|
|
2406
|
+
if (bytesEqFast(before, after)) {
|
|
2224
2407
|
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
2225
2408
|
}
|
|
2226
2409
|
return Buffer.from(after);
|
|
@@ -2229,42 +2412,81 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
|
|
|
2229
2412
|
let detached;
|
|
2230
2413
|
try {
|
|
2231
2414
|
detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
2232
|
-
} catch {
|
|
2233
|
-
|
|
2415
|
+
} catch (cause) {
|
|
2416
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
2417
|
+
cause: cause instanceof Error ? cause : void 0
|
|
2418
|
+
});
|
|
2234
2419
|
}
|
|
2235
2420
|
if (originalDataHash !== void 0) {
|
|
2236
2421
|
let expected;
|
|
2237
2422
|
try {
|
|
2238
2423
|
expected = validateHash(originalDataHash);
|
|
2239
2424
|
} catch (err) {
|
|
2240
|
-
|
|
2425
|
+
throw new ValidationError(
|
|
2426
|
+
err instanceof Error ? err.message : "Invalid hash format",
|
|
2427
|
+
{ cause: err instanceof Error ? err : void 0 }
|
|
2428
|
+
);
|
|
2241
2429
|
}
|
|
2242
|
-
if (!
|
|
2243
|
-
return {
|
|
2430
|
+
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
2431
|
+
return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
|
|
2244
2432
|
}
|
|
2245
2433
|
}
|
|
2246
|
-
const
|
|
2434
|
+
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
2435
|
+
const seenHeights = /* @__PURE__ */ new Set();
|
|
2436
|
+
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
2437
|
+
if (attestation.kind !== "bitcoin") return false;
|
|
2438
|
+
if (seenHeights.has(attestation.height)) {
|
|
2439
|
+
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
2440
|
+
return false;
|
|
2441
|
+
}
|
|
2442
|
+
seenHeights.add(attestation.height);
|
|
2443
|
+
return true;
|
|
2444
|
+
});
|
|
2445
|
+
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
2446
|
+
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
2447
|
+
logger?.warn(
|
|
2448
|
+
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2247
2451
|
if (bitcoinAtts.length === 0) {
|
|
2248
2452
|
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2453
|
+
return {
|
|
2454
|
+
status: "pending",
|
|
2455
|
+
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
2456
|
+
};
|
|
2253
2457
|
}
|
|
2254
2458
|
const explorer = new EsploraClient(networkLayer);
|
|
2255
|
-
let
|
|
2459
|
+
let lastNetworkError;
|
|
2460
|
+
let lastCryptoError;
|
|
2256
2461
|
for (const { msg, attestation } of bitcoinAtts) {
|
|
2257
2462
|
if (attestation.kind !== "bitcoin") continue;
|
|
2258
2463
|
try {
|
|
2259
|
-
const
|
|
2464
|
+
const blockTime = await verifyTimestampAttestation(
|
|
2465
|
+
Uint8Array.from(msg).reverse(),
|
|
2466
|
+
attestation,
|
|
2467
|
+
explorer,
|
|
2468
|
+
signal
|
|
2469
|
+
);
|
|
2260
2470
|
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
2261
|
-
return {
|
|
2471
|
+
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
2262
2472
|
} catch (err) {
|
|
2263
|
-
|
|
2264
|
-
|
|
2473
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2474
|
+
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
2475
|
+
lastNetworkError = message;
|
|
2476
|
+
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
2477
|
+
} else {
|
|
2478
|
+
lastCryptoError = message;
|
|
2479
|
+
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
2480
|
+
}
|
|
2265
2481
|
}
|
|
2266
2482
|
}
|
|
2267
|
-
|
|
2483
|
+
if (lastCryptoError !== void 0) {
|
|
2484
|
+
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
2485
|
+
}
|
|
2486
|
+
return {
|
|
2487
|
+
status: "network_error",
|
|
2488
|
+
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
2489
|
+
};
|
|
2268
2490
|
}
|
|
2269
2491
|
|
|
2270
2492
|
// src/client.ts
|
|
@@ -2274,6 +2496,7 @@ var OpenTimestampsClient = class {
|
|
|
2274
2496
|
logger;
|
|
2275
2497
|
globalSignal;
|
|
2276
2498
|
minimumSuccessfulSubmissions;
|
|
2499
|
+
allowPrivateCalendars;
|
|
2277
2500
|
/**
|
|
2278
2501
|
* Create a new OpenTimestamps client
|
|
2279
2502
|
*
|
|
@@ -2287,6 +2510,7 @@ var OpenTimestampsClient = class {
|
|
|
2287
2510
|
this.calendars = options.calendars;
|
|
2288
2511
|
}
|
|
2289
2512
|
this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
|
|
2513
|
+
this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
|
|
2290
2514
|
const resilienceConfig = {
|
|
2291
2515
|
...DEFAULT_RESILIENCE,
|
|
2292
2516
|
...options.resilience,
|
|
@@ -2305,7 +2529,8 @@ var OpenTimestampsClient = class {
|
|
|
2305
2529
|
};
|
|
2306
2530
|
this.logger = options.logger;
|
|
2307
2531
|
this.globalSignal = options.signal;
|
|
2308
|
-
|
|
2532
|
+
const internalOptions = options;
|
|
2533
|
+
this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
|
|
2309
2534
|
this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
|
|
2310
2535
|
}
|
|
2311
2536
|
/**
|
|
@@ -2334,7 +2559,8 @@ var OpenTimestampsClient = class {
|
|
|
2334
2559
|
this.networkLayer,
|
|
2335
2560
|
this.logger,
|
|
2336
2561
|
signal,
|
|
2337
|
-
this.minimumSuccessfulSubmissions
|
|
2562
|
+
this.minimumSuccessfulSubmissions,
|
|
2563
|
+
this.allowPrivateCalendars
|
|
2338
2564
|
);
|
|
2339
2565
|
}
|
|
2340
2566
|
/**
|
|
@@ -2418,6 +2644,19 @@ var OpenTimestampsClient = class {
|
|
|
2418
2644
|
this.networkLayer.resetAllCircuits();
|
|
2419
2645
|
}
|
|
2420
2646
|
};
|
|
2647
|
+
|
|
2648
|
+
// src/utils/hash.ts
|
|
2649
|
+
var import_crypto = require("crypto");
|
|
2650
|
+
var import_fs = require("fs");
|
|
2651
|
+
function hashBuffer(data) {
|
|
2652
|
+
return (0, import_crypto.createHash)("sha256").update(data).digest();
|
|
2653
|
+
}
|
|
2654
|
+
function hashFile(path) {
|
|
2655
|
+
return new Promise((resolve, reject) => {
|
|
2656
|
+
const hash = (0, import_crypto.createHash)("sha256");
|
|
2657
|
+
(0, import_fs.createReadStream)(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2421
2660
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2422
2661
|
0 && (module.exports = {
|
|
2423
2662
|
CalendarClient,
|
|
@@ -2439,11 +2678,16 @@ var OpenTimestampsClient = class {
|
|
|
2439
2678
|
OpenTimestampsClientError,
|
|
2440
2679
|
PUBLIC_ESPLORA_URL,
|
|
2441
2680
|
ResilientNetworkLayer,
|
|
2681
|
+
SizeLimitExceededError,
|
|
2442
2682
|
StampError,
|
|
2443
2683
|
Timestamp,
|
|
2444
2684
|
UpgradeError,
|
|
2445
2685
|
UrlWhitelist,
|
|
2446
2686
|
ValidationError,
|
|
2687
|
+
assertSafeCalendarUrl,
|
|
2688
|
+
hashBuffer,
|
|
2689
|
+
hashFile,
|
|
2690
|
+
isVerified,
|
|
2447
2691
|
verifyAgainstBlockheader,
|
|
2448
2692
|
verifyTimestampAttestation
|
|
2449
2693
|
});
|