@otskit/client 0.2.0 → 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 +311 -84
- package/dist/index.d.cts +81 -16
- package/dist/index.d.ts +81 -16
- package/dist/index.js +308 -84
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/types.ts
|
|
2
|
+
var isVerified = (r) => r.status === "verified";
|
|
2
3
|
var DEFAULT_CALENDARS = [
|
|
3
4
|
"https://alice.btc.calendar.opentimestamps.org",
|
|
4
5
|
"https://bob.btc.calendar.opentimestamps.org",
|
|
@@ -68,6 +69,18 @@ var CalendarResponseTooLargeError = class extends NetworkError {
|
|
|
68
69
|
};
|
|
69
70
|
var EsploraResponseError = class extends NetworkError {
|
|
70
71
|
};
|
|
72
|
+
var SizeLimitExceededError = class extends NetworkError {
|
|
73
|
+
maxBytes;
|
|
74
|
+
actualBytes;
|
|
75
|
+
constructor(maxBytes, actualBytes, options) {
|
|
76
|
+
super(
|
|
77
|
+
actualBytes === void 0 ? `Response size exceeds limit of ${maxBytes} bytes` : `Response size ${actualBytes} bytes exceeds limit of ${maxBytes} bytes`,
|
|
78
|
+
options
|
|
79
|
+
);
|
|
80
|
+
this.maxBytes = maxBytes;
|
|
81
|
+
this.actualBytes = actualBytes;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
71
84
|
|
|
72
85
|
// src/network/circuit-breaker.ts
|
|
73
86
|
var CircuitState = /* @__PURE__ */ ((CircuitState2) => {
|
|
@@ -256,33 +269,62 @@ async function withRetry(fn, options, logger, signal) {
|
|
|
256
269
|
}
|
|
257
270
|
|
|
258
271
|
// src/adapters/fetch-adapter.ts
|
|
259
|
-
|
|
272
|
+
function getDeclaredContentLength(response) {
|
|
273
|
+
const value = response.headers.get("content-length");
|
|
274
|
+
if (value === null || !/^\d+$/.test(value)) return void 0;
|
|
275
|
+
const n = Number(value);
|
|
276
|
+
return Number.isSafeInteger(n) ? n : void 0;
|
|
277
|
+
}
|
|
278
|
+
async function readStreamLimited(body, maxBytes, status) {
|
|
279
|
+
const reader = body.getReader();
|
|
280
|
+
const buffer = new Uint8Array(maxBytes);
|
|
281
|
+
let received = 0;
|
|
282
|
+
try {
|
|
283
|
+
while (true) {
|
|
284
|
+
const { done, value } = await reader.read();
|
|
285
|
+
if (done) return buffer.subarray(0, received);
|
|
286
|
+
const next = received + value.byteLength;
|
|
287
|
+
if (next > maxBytes) {
|
|
288
|
+
await reader.cancel();
|
|
289
|
+
throw new SizeLimitExceededError(maxBytes, next, { status });
|
|
290
|
+
}
|
|
291
|
+
buffer.set(value, received);
|
|
292
|
+
received = next;
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
reader.releaseLock();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function readResponseBody(response, maxBytes) {
|
|
299
|
+
const contentLength = getDeclaredContentLength(response);
|
|
300
|
+
if (contentLength !== void 0 && contentLength > maxBytes) {
|
|
301
|
+
throw new SizeLimitExceededError(maxBytes, contentLength, { status: response.status });
|
|
302
|
+
}
|
|
303
|
+
if (response.body === null) {
|
|
304
|
+
const ab = await response.arrayBuffer();
|
|
305
|
+
if (ab.byteLength > maxBytes) {
|
|
306
|
+
throw new SizeLimitExceededError(maxBytes, ab.byteLength, { status: response.status });
|
|
307
|
+
}
|
|
308
|
+
return new Uint8Array(ab);
|
|
309
|
+
}
|
|
310
|
+
return readStreamLimited(response.body, maxBytes, response.status);
|
|
311
|
+
}
|
|
312
|
+
async function executeRequest(request, maxBytes) {
|
|
260
313
|
try {
|
|
261
314
|
const response = await globalThis.fetch(request.url, {
|
|
262
315
|
method: request.method,
|
|
263
|
-
headers: {
|
|
264
|
-
"Content-Type": "application/octet-stream",
|
|
265
|
-
...request.headers
|
|
266
|
-
},
|
|
316
|
+
headers: { "Content-Type": "application/octet-stream", ...request.headers },
|
|
267
317
|
body: request.body,
|
|
268
318
|
signal: request.signal
|
|
269
319
|
});
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
return {
|
|
273
|
-
ok: response.ok,
|
|
274
|
-
status: response.status,
|
|
275
|
-
statusText: response.statusText,
|
|
276
|
-
data
|
|
277
|
-
};
|
|
320
|
+
const data = await readResponseBody(response, maxBytes);
|
|
321
|
+
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
|
278
322
|
} catch (error) {
|
|
323
|
+
if (error instanceof SizeLimitExceededError) throw error;
|
|
324
|
+
if (error instanceof NetworkError) throw error;
|
|
279
325
|
if (error instanceof Error) {
|
|
280
|
-
if (error.name === "AbortError") {
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
if (error.message.includes("timeout")) {
|
|
284
|
-
throw new NetworkError("Request timeout", { cause: error });
|
|
285
|
-
}
|
|
326
|
+
if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
|
|
327
|
+
if (error.message.includes("timeout")) throw new NetworkError("Request timeout", { cause: error });
|
|
286
328
|
throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
|
|
287
329
|
}
|
|
288
330
|
throw new NetworkError("Unknown network error");
|
|
@@ -290,23 +332,24 @@ async function executeRequest(request) {
|
|
|
290
332
|
}
|
|
291
333
|
function createTimeoutController(timeoutMs, parentSignal) {
|
|
292
334
|
const controller = new AbortController();
|
|
293
|
-
const timeout = setTimeout(() =>
|
|
294
|
-
|
|
295
|
-
|
|
335
|
+
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), timeoutMs);
|
|
336
|
+
const onParentAbort = () => {
|
|
337
|
+
clearTimeout(timeout);
|
|
338
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
339
|
+
controller.abort(parentSignal?.reason);
|
|
340
|
+
};
|
|
341
|
+
controller.signal.addEventListener("abort", () => {
|
|
342
|
+
clearTimeout(timeout);
|
|
343
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
344
|
+
}, { once: true });
|
|
296
345
|
if (parentSignal) {
|
|
297
346
|
if (parentSignal.aborted) {
|
|
298
347
|
clearTimeout(timeout);
|
|
299
348
|
controller.abort(parentSignal.reason);
|
|
300
349
|
} else {
|
|
301
|
-
parentSignal.addEventListener("abort",
|
|
302
|
-
clearTimeout(timeout);
|
|
303
|
-
controller.abort(parentSignal.reason);
|
|
304
|
-
});
|
|
350
|
+
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
305
351
|
}
|
|
306
352
|
}
|
|
307
|
-
controller.signal.addEventListener("abort", () => {
|
|
308
|
-
clearTimeout(timeout);
|
|
309
|
-
});
|
|
310
353
|
return controller;
|
|
311
354
|
}
|
|
312
355
|
|
|
@@ -338,10 +381,10 @@ var ResilientNetworkLayer = class {
|
|
|
338
381
|
totalController.signal
|
|
339
382
|
);
|
|
340
383
|
try {
|
|
341
|
-
const response = await executeRequest(
|
|
342
|
-
...request,
|
|
343
|
-
|
|
344
|
-
|
|
384
|
+
const response = await executeRequest(
|
|
385
|
+
{ ...request, signal: attemptController.signal },
|
|
386
|
+
this.options.maxResponseBytes ?? 1e5
|
|
387
|
+
);
|
|
345
388
|
const elapsed = Date.now() - startTime;
|
|
346
389
|
this.logger?.debug(`Request to ${calendarUrl} succeeded in ${elapsed}ms`);
|
|
347
390
|
if (!response.ok) {
|
|
@@ -360,8 +403,7 @@ var ResilientNetworkLayer = class {
|
|
|
360
403
|
}
|
|
361
404
|
return response;
|
|
362
405
|
} finally {
|
|
363
|
-
attemptController.
|
|
364
|
-
});
|
|
406
|
+
attemptController.abort(new Error("Attempt complete"));
|
|
365
407
|
}
|
|
366
408
|
},
|
|
367
409
|
this.options.retries,
|
|
@@ -374,8 +416,7 @@ var ResilientNetworkLayer = class {
|
|
|
374
416
|
this.logger?.error(`Request to ${calendarUrl} failed after ${elapsed}ms`, error);
|
|
375
417
|
throw error;
|
|
376
418
|
} finally {
|
|
377
|
-
totalController.
|
|
378
|
-
});
|
|
419
|
+
totalController.abort(new Error("Request complete"));
|
|
379
420
|
}
|
|
380
421
|
}
|
|
381
422
|
/** Get circuit breaker state for a calendar */
|
|
@@ -1864,7 +1905,7 @@ var CalendarClient = class {
|
|
|
1864
1905
|
url;
|
|
1865
1906
|
networkLayer;
|
|
1866
1907
|
logger;
|
|
1867
|
-
/**
|
|
1908
|
+
/** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
|
|
1868
1909
|
async submit(digest, signal) {
|
|
1869
1910
|
assertCommitment(digest);
|
|
1870
1911
|
this.logger?.debug(`Submitting digest to ${this.url}/digest`);
|
|
@@ -1875,7 +1916,7 @@ var CalendarClient = class {
|
|
|
1875
1916
|
);
|
|
1876
1917
|
return this.#parseTimestamp(response.data, digest);
|
|
1877
1918
|
}
|
|
1878
|
-
/** Pregunta al calendario si tiene un Timestamp
|
|
1919
|
+
/** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
|
|
1879
1920
|
async getTimestamp(commitment, signal) {
|
|
1880
1921
|
assertCommitment(commitment);
|
|
1881
1922
|
const path = `/timestamp/${bytesToHex(commitment)}`;
|
|
@@ -1910,43 +1951,79 @@ var CalendarClient = class {
|
|
|
1910
1951
|
return timestamp;
|
|
1911
1952
|
}
|
|
1912
1953
|
};
|
|
1913
|
-
function
|
|
1914
|
-
|
|
1915
|
-
|
|
1954
|
+
function parseWhitelistPattern(raw) {
|
|
1955
|
+
let parsed;
|
|
1956
|
+
try {
|
|
1957
|
+
parsed = new URL(raw);
|
|
1958
|
+
} catch {
|
|
1959
|
+
return void 0;
|
|
1960
|
+
}
|
|
1961
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
|
|
1962
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
1963
|
+
const wildcardSuffix = hostname.startsWith("*.") ? hostname.slice(2) : void 0;
|
|
1964
|
+
if (hostname.includes("*") && wildcardSuffix === void 0) return void 0;
|
|
1965
|
+
if (wildcardSuffix !== void 0 && (wildcardSuffix.length === 0 || wildcardSuffix.includes("*"))) return void 0;
|
|
1966
|
+
return {
|
|
1967
|
+
protocol: parsed.protocol,
|
|
1968
|
+
hostname,
|
|
1969
|
+
port: parsed.port,
|
|
1970
|
+
pathname: parsed.pathname,
|
|
1971
|
+
wildcardSuffix
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
function hostnameMatchesPattern(hostname, pattern) {
|
|
1975
|
+
if (pattern.wildcardSuffix === void 0) return hostname === pattern.hostname;
|
|
1976
|
+
if (!hostname.endsWith("." + pattern.wildcardSuffix)) return false;
|
|
1977
|
+
const label = hostname.slice(0, -pattern.wildcardSuffix.length - 1);
|
|
1978
|
+
return label.length > 0 && !label.includes(".");
|
|
1916
1979
|
}
|
|
1917
1980
|
var UrlWhitelist = class {
|
|
1918
|
-
#patterns = /* @__PURE__ */ new
|
|
1981
|
+
#patterns = /* @__PURE__ */ new Map();
|
|
1919
1982
|
constructor(urls) {
|
|
1920
1983
|
if (urls) {
|
|
1921
1984
|
for (const u of urls) this.add(u);
|
|
1922
1985
|
}
|
|
1923
1986
|
}
|
|
1924
|
-
/**
|
|
1987
|
+
/** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
|
|
1925
1988
|
add(url) {
|
|
1926
1989
|
if (typeof url !== "string") {
|
|
1927
1990
|
throw new TypeError("UrlWhitelist: URL must be a string");
|
|
1928
1991
|
}
|
|
1929
1992
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
1930
|
-
|
|
1993
|
+
const pattern = parseWhitelistPattern(url);
|
|
1994
|
+
if (pattern !== void 0) this.#patterns.set(url, pattern);
|
|
1931
1995
|
} else {
|
|
1932
|
-
this
|
|
1933
|
-
this
|
|
1996
|
+
this.add("http://" + url);
|
|
1997
|
+
this.add("https://" + url);
|
|
1934
1998
|
}
|
|
1935
1999
|
}
|
|
1936
|
-
/** Verdadero si `url` casa con
|
|
2000
|
+
/** Verdadero si `url` casa con algun patron de la whitelist. */
|
|
1937
2001
|
contains(url) {
|
|
1938
|
-
|
|
1939
|
-
|
|
2002
|
+
let parsed;
|
|
2003
|
+
try {
|
|
2004
|
+
parsed = new URL(url);
|
|
2005
|
+
} catch {
|
|
2006
|
+
return false;
|
|
2007
|
+
}
|
|
2008
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
2009
|
+
if (parsed.search !== "" || parsed.hash !== "") return false;
|
|
2010
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
2011
|
+
for (const pattern of this.#patterns.values()) {
|
|
2012
|
+
if (parsed.protocol !== pattern.protocol || parsed.port !== pattern.port) continue;
|
|
2013
|
+
if (parsed.pathname !== pattern.pathname) continue;
|
|
2014
|
+
if (hostnameMatchesPattern(hostname, pattern)) return true;
|
|
1940
2015
|
}
|
|
1941
2016
|
return false;
|
|
1942
2017
|
}
|
|
1943
2018
|
toString() {
|
|
1944
|
-
return `UrlWhitelist([${[...this.#patterns].join(", ")}])`;
|
|
2019
|
+
return `UrlWhitelist([${[...this.#patterns.keys()].join(", ")}])`;
|
|
1945
2020
|
}
|
|
1946
2021
|
};
|
|
1947
2022
|
var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([
|
|
1948
2023
|
"https://*.calendar.opentimestamps.org",
|
|
1949
2024
|
// Peter Todd
|
|
2025
|
+
"https://*.btc.calendar.opentimestamps.org",
|
|
2026
|
+
// Peter Todd Bitcoin calendars
|
|
1950
2027
|
"https://*.calendar.eternitywall.com",
|
|
1951
2028
|
// Eternity Wall
|
|
1952
2029
|
"https://*.calendar.catallaxy.com"
|
|
@@ -2039,7 +2116,13 @@ var EsploraClient = class {
|
|
|
2039
2116
|
`esplora response of ${data.length} bytes exceeds limit ${MAX_ESPLORA_RESPONSE_SIZE}`
|
|
2040
2117
|
);
|
|
2041
2118
|
}
|
|
2042
|
-
|
|
2119
|
+
try {
|
|
2120
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
2121
|
+
} catch (cause) {
|
|
2122
|
+
throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
|
|
2123
|
+
cause: cause instanceof Error ? cause : void 0
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2043
2126
|
}
|
|
2044
2127
|
};
|
|
2045
2128
|
async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
|
|
@@ -2052,6 +2135,106 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
|
|
|
2052
2135
|
}
|
|
2053
2136
|
|
|
2054
2137
|
// src/core/orchestration.ts
|
|
2138
|
+
import { timingSafeEqual } from "crypto";
|
|
2139
|
+
|
|
2140
|
+
// src/security/ssrf.ts
|
|
2141
|
+
import { lookup } from "dns/promises";
|
|
2142
|
+
import { isIP } from "net";
|
|
2143
|
+
var BLOCKED_CIDRS_V4 = [
|
|
2144
|
+
{ network: 0, mask: 4278190080, label: "0.0.0.0/8 (This Network)" },
|
|
2145
|
+
{ network: 167772160, mask: 4278190080, label: "10.0.0.0/8 (RFC 1918)" },
|
|
2146
|
+
{ network: 2130706432, mask: 4278190080, label: "127.0.0.0/8 (Loopback)" },
|
|
2147
|
+
{ network: 2851995648, mask: 4294901760, label: "169.254.0.0/16 (Link-local/IMDS)" },
|
|
2148
|
+
{ network: 2886729728, mask: 4293918720, label: "172.16.0.0/12 (RFC 1918)" },
|
|
2149
|
+
{ network: 3232235520, mask: 4294901760, label: "192.168.0.0/16 (RFC 1918)" },
|
|
2150
|
+
{ network: 3323068416, mask: 4294836224, label: "198.18.0.0/15 (Benchmarking)" },
|
|
2151
|
+
{ network: 3758096384, mask: 4026531840, label: "224.0.0.0/4 (Multicast)" },
|
|
2152
|
+
{ network: 4026531840, mask: 4026531840, label: "240.0.0.0/4 (Reserved)" },
|
|
2153
|
+
{ network: 4294967295, mask: 4294967295, label: "255.255.255.255 (Broadcast)" }
|
|
2154
|
+
];
|
|
2155
|
+
function ipv4ToUint32(ip) {
|
|
2156
|
+
const parts = ip.split(".");
|
|
2157
|
+
return (parseInt(parts[0], 10) << 24 | parseInt(parts[1], 10) << 16 | parseInt(parts[2], 10) << 8 | parseInt(parts[3], 10)) >>> 0;
|
|
2158
|
+
}
|
|
2159
|
+
function assertNotPrivateIPv4(ip, calendarUrl) {
|
|
2160
|
+
const n = ipv4ToUint32(ip);
|
|
2161
|
+
for (const cidr of BLOCKED_CIDRS_V4) {
|
|
2162
|
+
if ((n & cidr.mask) >>> 0 === cidr.network) {
|
|
2163
|
+
throw new ValidationError(
|
|
2164
|
+
`Calendar URL "${calendarUrl}" resolves to a private/reserved IPv4 address (${ip} \u2014 ${cidr.label}). Set allowPrivateCalendars: true to override.`
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
var BLOCKED_IPV6_PREFIXES = [
|
|
2170
|
+
{ prefix: "::1", label: "Loopback" },
|
|
2171
|
+
{ prefix: "::", label: "Unspecified" },
|
|
2172
|
+
{ prefix: "fc", label: "fc00::/7 (Unique Local)" },
|
|
2173
|
+
{ prefix: "fd", label: "fd00::/8 (Unique Local)" },
|
|
2174
|
+
{ prefix: "fe8", label: "fe80::/10 (Link-local)" },
|
|
2175
|
+
{ prefix: "fe9", label: "fe80::/10 (Link-local)" },
|
|
2176
|
+
{ prefix: "fea", label: "fe80::/10 (Link-local)" },
|
|
2177
|
+
{ prefix: "feb", label: "fe80::/10 (Link-local)" },
|
|
2178
|
+
{ prefix: "ff", label: "ff00::/8 (Multicast)" },
|
|
2179
|
+
{ prefix: "::ffff:", label: "IPv4-mapped IPv6" },
|
|
2180
|
+
{ prefix: "64:ff9b:", label: "64:ff9b::/96 (NAT64)" },
|
|
2181
|
+
{ prefix: "2001:db8", label: "2001:db8::/32 (Documentation)" }
|
|
2182
|
+
];
|
|
2183
|
+
function assertNotPrivateIPv6(ip, calendarUrl) {
|
|
2184
|
+
const lower = ip.toLowerCase();
|
|
2185
|
+
for (const { prefix, label } of BLOCKED_IPV6_PREFIXES) {
|
|
2186
|
+
if (lower === prefix || lower.startsWith(prefix)) {
|
|
2187
|
+
throw new ValidationError(
|
|
2188
|
+
`Calendar URL "${calendarUrl}" resolves to a private/reserved IPv6 address (${ip} \u2014 ${label}). Set allowPrivateCalendars: true to override.`
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
async function assertSafeCalendarUrl(url, options) {
|
|
2194
|
+
let parsed;
|
|
2195
|
+
try {
|
|
2196
|
+
parsed = new URL(url);
|
|
2197
|
+
} catch {
|
|
2198
|
+
throw new ValidationError(`Calendar URL is not valid: "${url}"`);
|
|
2199
|
+
}
|
|
2200
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
2201
|
+
throw new ValidationError(`Calendar URL must use http or https: "${url}"`);
|
|
2202
|
+
}
|
|
2203
|
+
if (parsed.username || parsed.password) {
|
|
2204
|
+
throw new ValidationError(`Calendar URL must not contain embedded credentials: "${url}"`);
|
|
2205
|
+
}
|
|
2206
|
+
if (options.allowPrivate) return;
|
|
2207
|
+
const hostname = parsed.hostname;
|
|
2208
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
2209
|
+
const ipVersion = isIP(host);
|
|
2210
|
+
if (ipVersion === 4) {
|
|
2211
|
+
assertNotPrivateIPv4(host, url);
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
if (ipVersion === 6) {
|
|
2215
|
+
assertNotPrivateIPv6(host, url);
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
let addresses;
|
|
2219
|
+
try {
|
|
2220
|
+
addresses = await lookup(hostname, { all: true });
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2223
|
+
throw new ValidationError(
|
|
2224
|
+
`Calendar URL hostname "${hostname}" could not be resolved: ${message}`
|
|
2225
|
+
);
|
|
2226
|
+
}
|
|
2227
|
+
if (addresses.length === 0) {
|
|
2228
|
+
throw new ValidationError(`Calendar URL hostname "${hostname}" resolved to no addresses`);
|
|
2229
|
+
}
|
|
2230
|
+
for (const { address, family } of addresses) {
|
|
2231
|
+
if (family === 4) assertNotPrivateIPv4(address, url);
|
|
2232
|
+
else if (family === 6) assertNotPrivateIPv6(address, url);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// src/core/orchestration.ts
|
|
2237
|
+
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
2055
2238
|
function validateHash(hash) {
|
|
2056
2239
|
if (typeof hash === "string") {
|
|
2057
2240
|
const hex = hash.trim().toLowerCase();
|
|
@@ -2073,19 +2256,12 @@ function secureNonce(n) {
|
|
|
2073
2256
|
globalThis.crypto.getRandomValues(bytes);
|
|
2074
2257
|
return bytes;
|
|
2075
2258
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
try {
|
|
2080
|
-
parsed = new URL(url);
|
|
2081
|
-
} catch {
|
|
2082
|
-
throw new ValidationError(`${label} is not a valid URL: ${url}`);
|
|
2083
|
-
}
|
|
2084
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
2085
|
-
throw new ValidationError(`${label} must use http(s): ${url}`);
|
|
2086
|
-
}
|
|
2259
|
+
function timingSafeEq(a, b) {
|
|
2260
|
+
if (a.length !== b.length) return false;
|
|
2261
|
+
return timingSafeEqual(a, b);
|
|
2087
2262
|
}
|
|
2088
|
-
|
|
2263
|
+
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
2264
|
+
async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
|
|
2089
2265
|
if (calendars.length === 0) {
|
|
2090
2266
|
throw new ValidationError("at least one calendar is required to stamp");
|
|
2091
2267
|
}
|
|
@@ -2097,7 +2273,9 @@ async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, m
|
|
|
2097
2273
|
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
2098
2274
|
);
|
|
2099
2275
|
}
|
|
2100
|
-
|
|
2276
|
+
await Promise.all(
|
|
2277
|
+
calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
|
|
2278
|
+
);
|
|
2101
2279
|
const digest = validateHash(hash);
|
|
2102
2280
|
logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
|
|
2103
2281
|
const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
|
|
@@ -2169,7 +2347,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
|
|
|
2169
2347
|
}
|
|
2170
2348
|
}
|
|
2171
2349
|
const after = detached.serializeToBytes();
|
|
2172
|
-
if (
|
|
2350
|
+
if (bytesEqFast(before, after)) {
|
|
2173
2351
|
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
2174
2352
|
}
|
|
2175
2353
|
return Buffer.from(after);
|
|
@@ -2178,42 +2356,81 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
|
|
|
2178
2356
|
let detached;
|
|
2179
2357
|
try {
|
|
2180
2358
|
detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
2181
|
-
} catch {
|
|
2182
|
-
|
|
2359
|
+
} catch (cause) {
|
|
2360
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
2361
|
+
cause: cause instanceof Error ? cause : void 0
|
|
2362
|
+
});
|
|
2183
2363
|
}
|
|
2184
2364
|
if (originalDataHash !== void 0) {
|
|
2185
2365
|
let expected;
|
|
2186
2366
|
try {
|
|
2187
2367
|
expected = validateHash(originalDataHash);
|
|
2188
2368
|
} catch (err) {
|
|
2189
|
-
|
|
2369
|
+
throw new ValidationError(
|
|
2370
|
+
err instanceof Error ? err.message : "Invalid hash format",
|
|
2371
|
+
{ cause: err instanceof Error ? err : void 0 }
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
2375
|
+
return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
|
|
2190
2376
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2377
|
+
}
|
|
2378
|
+
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
2379
|
+
const seenHeights = /* @__PURE__ */ new Set();
|
|
2380
|
+
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
2381
|
+
if (attestation.kind !== "bitcoin") return false;
|
|
2382
|
+
if (seenHeights.has(attestation.height)) {
|
|
2383
|
+
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
2384
|
+
return false;
|
|
2193
2385
|
}
|
|
2386
|
+
seenHeights.add(attestation.height);
|
|
2387
|
+
return true;
|
|
2388
|
+
});
|
|
2389
|
+
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
2390
|
+
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
2391
|
+
logger?.warn(
|
|
2392
|
+
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
2393
|
+
);
|
|
2194
2394
|
}
|
|
2195
|
-
const bitcoinAtts = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
2196
2395
|
if (bitcoinAtts.length === 0) {
|
|
2197
2396
|
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2397
|
+
return {
|
|
2398
|
+
status: "pending",
|
|
2399
|
+
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
2400
|
+
};
|
|
2202
2401
|
}
|
|
2203
2402
|
const explorer = new EsploraClient(networkLayer);
|
|
2204
|
-
let
|
|
2403
|
+
let lastNetworkError;
|
|
2404
|
+
let lastCryptoError;
|
|
2205
2405
|
for (const { msg, attestation } of bitcoinAtts) {
|
|
2206
2406
|
if (attestation.kind !== "bitcoin") continue;
|
|
2207
2407
|
try {
|
|
2208
|
-
const
|
|
2408
|
+
const blockTime = await verifyTimestampAttestation(
|
|
2409
|
+
Uint8Array.from(msg).reverse(),
|
|
2410
|
+
attestation,
|
|
2411
|
+
explorer,
|
|
2412
|
+
signal
|
|
2413
|
+
);
|
|
2209
2414
|
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
2210
|
-
return {
|
|
2415
|
+
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
2211
2416
|
} catch (err) {
|
|
2212
|
-
|
|
2213
|
-
|
|
2417
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2418
|
+
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
2419
|
+
lastNetworkError = message;
|
|
2420
|
+
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
2421
|
+
} else {
|
|
2422
|
+
lastCryptoError = message;
|
|
2423
|
+
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
2424
|
+
}
|
|
2214
2425
|
}
|
|
2215
2426
|
}
|
|
2216
|
-
|
|
2427
|
+
if (lastCryptoError !== void 0) {
|
|
2428
|
+
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
2429
|
+
}
|
|
2430
|
+
return {
|
|
2431
|
+
status: "network_error",
|
|
2432
|
+
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
2433
|
+
};
|
|
2217
2434
|
}
|
|
2218
2435
|
|
|
2219
2436
|
// src/client.ts
|
|
@@ -2223,6 +2440,7 @@ var OpenTimestampsClient = class {
|
|
|
2223
2440
|
logger;
|
|
2224
2441
|
globalSignal;
|
|
2225
2442
|
minimumSuccessfulSubmissions;
|
|
2443
|
+
allowPrivateCalendars;
|
|
2226
2444
|
/**
|
|
2227
2445
|
* Create a new OpenTimestamps client
|
|
2228
2446
|
*
|
|
@@ -2236,6 +2454,7 @@ var OpenTimestampsClient = class {
|
|
|
2236
2454
|
this.calendars = options.calendars;
|
|
2237
2455
|
}
|
|
2238
2456
|
this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
|
|
2457
|
+
this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
|
|
2239
2458
|
const resilienceConfig = {
|
|
2240
2459
|
...DEFAULT_RESILIENCE,
|
|
2241
2460
|
...options.resilience,
|
|
@@ -2254,7 +2473,8 @@ var OpenTimestampsClient = class {
|
|
|
2254
2473
|
};
|
|
2255
2474
|
this.logger = options.logger;
|
|
2256
2475
|
this.globalSignal = options.signal;
|
|
2257
|
-
|
|
2476
|
+
const internalOptions = options;
|
|
2477
|
+
this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
|
|
2258
2478
|
this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
|
|
2259
2479
|
}
|
|
2260
2480
|
/**
|
|
@@ -2283,7 +2503,8 @@ var OpenTimestampsClient = class {
|
|
|
2283
2503
|
this.networkLayer,
|
|
2284
2504
|
this.logger,
|
|
2285
2505
|
signal,
|
|
2286
|
-
this.minimumSuccessfulSubmissions
|
|
2506
|
+
this.minimumSuccessfulSubmissions,
|
|
2507
|
+
this.allowPrivateCalendars
|
|
2287
2508
|
);
|
|
2288
2509
|
}
|
|
2289
2510
|
/**
|
|
@@ -2400,13 +2621,16 @@ export {
|
|
|
2400
2621
|
OpenTimestampsClientError,
|
|
2401
2622
|
PUBLIC_ESPLORA_URL,
|
|
2402
2623
|
ResilientNetworkLayer,
|
|
2624
|
+
SizeLimitExceededError,
|
|
2403
2625
|
StampError,
|
|
2404
2626
|
Timestamp,
|
|
2405
2627
|
UpgradeError,
|
|
2406
2628
|
UrlWhitelist,
|
|
2407
2629
|
ValidationError,
|
|
2630
|
+
assertSafeCalendarUrl,
|
|
2408
2631
|
hashBuffer,
|
|
2409
2632
|
hashFile,
|
|
2633
|
+
isVerified,
|
|
2410
2634
|
verifyAgainstBlockheader,
|
|
2411
2635
|
verifyTimestampAttestation
|
|
2412
2636
|
};
|