@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.cjs
CHANGED
|
@@ -39,19 +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,
|
|
47
49
|
hashBuffer: () => hashBuffer,
|
|
48
50
|
hashFile: () => hashFile,
|
|
51
|
+
isVerified: () => isVerified,
|
|
49
52
|
verifyAgainstBlockheader: () => verifyAgainstBlockheader,
|
|
50
53
|
verifyTimestampAttestation: () => verifyTimestampAttestation
|
|
51
54
|
});
|
|
52
55
|
module.exports = __toCommonJS(index_exports);
|
|
53
56
|
|
|
54
57
|
// src/types.ts
|
|
58
|
+
var isVerified = (r) => r.status === "verified";
|
|
55
59
|
var DEFAULT_CALENDARS = [
|
|
56
60
|
"https://alice.btc.calendar.opentimestamps.org",
|
|
57
61
|
"https://bob.btc.calendar.opentimestamps.org",
|
|
@@ -121,6 +125,18 @@ var CalendarResponseTooLargeError = class extends NetworkError {
|
|
|
121
125
|
};
|
|
122
126
|
var EsploraResponseError = class extends NetworkError {
|
|
123
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
|
+
};
|
|
124
140
|
|
|
125
141
|
// src/network/circuit-breaker.ts
|
|
126
142
|
var CircuitState = /* @__PURE__ */ ((CircuitState2) => {
|
|
@@ -309,33 +325,62 @@ async function withRetry(fn, options, logger, signal) {
|
|
|
309
325
|
}
|
|
310
326
|
|
|
311
327
|
// src/adapters/fetch-adapter.ts
|
|
312
|
-
|
|
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) {
|
|
313
369
|
try {
|
|
314
370
|
const response = await globalThis.fetch(request.url, {
|
|
315
371
|
method: request.method,
|
|
316
|
-
headers: {
|
|
317
|
-
"Content-Type": "application/octet-stream",
|
|
318
|
-
...request.headers
|
|
319
|
-
},
|
|
372
|
+
headers: { "Content-Type": "application/octet-stream", ...request.headers },
|
|
320
373
|
body: request.body,
|
|
321
374
|
signal: request.signal
|
|
322
375
|
});
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
return {
|
|
326
|
-
ok: response.ok,
|
|
327
|
-
status: response.status,
|
|
328
|
-
statusText: response.statusText,
|
|
329
|
-
data
|
|
330
|
-
};
|
|
376
|
+
const data = await readResponseBody(response, maxBytes);
|
|
377
|
+
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
|
331
378
|
} catch (error) {
|
|
379
|
+
if (error instanceof SizeLimitExceededError) throw error;
|
|
380
|
+
if (error instanceof NetworkError) throw error;
|
|
332
381
|
if (error instanceof Error) {
|
|
333
|
-
if (error.name === "AbortError") {
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
if (error.message.includes("timeout")) {
|
|
337
|
-
throw new NetworkError("Request timeout", { cause: error });
|
|
338
|
-
}
|
|
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 });
|
|
339
384
|
throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
|
|
340
385
|
}
|
|
341
386
|
throw new NetworkError("Unknown network error");
|
|
@@ -343,23 +388,24 @@ async function executeRequest(request) {
|
|
|
343
388
|
}
|
|
344
389
|
function createTimeoutController(timeoutMs, parentSignal) {
|
|
345
390
|
const controller = new AbortController();
|
|
346
|
-
const timeout = setTimeout(() =>
|
|
347
|
-
|
|
348
|
-
|
|
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 });
|
|
349
401
|
if (parentSignal) {
|
|
350
402
|
if (parentSignal.aborted) {
|
|
351
403
|
clearTimeout(timeout);
|
|
352
404
|
controller.abort(parentSignal.reason);
|
|
353
405
|
} else {
|
|
354
|
-
parentSignal.addEventListener("abort",
|
|
355
|
-
clearTimeout(timeout);
|
|
356
|
-
controller.abort(parentSignal.reason);
|
|
357
|
-
});
|
|
406
|
+
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
358
407
|
}
|
|
359
408
|
}
|
|
360
|
-
controller.signal.addEventListener("abort", () => {
|
|
361
|
-
clearTimeout(timeout);
|
|
362
|
-
});
|
|
363
409
|
return controller;
|
|
364
410
|
}
|
|
365
411
|
|
|
@@ -391,10 +437,10 @@ var ResilientNetworkLayer = class {
|
|
|
391
437
|
totalController.signal
|
|
392
438
|
);
|
|
393
439
|
try {
|
|
394
|
-
const response = await executeRequest(
|
|
395
|
-
...request,
|
|
396
|
-
|
|
397
|
-
|
|
440
|
+
const response = await executeRequest(
|
|
441
|
+
{ ...request, signal: attemptController.signal },
|
|
442
|
+
this.options.maxResponseBytes ?? 1e5
|
|
443
|
+
);
|
|
398
444
|
const elapsed = Date.now() - startTime;
|
|
399
445
|
this.logger?.debug(`Request to ${calendarUrl} succeeded in ${elapsed}ms`);
|
|
400
446
|
if (!response.ok) {
|
|
@@ -413,8 +459,7 @@ var ResilientNetworkLayer = class {
|
|
|
413
459
|
}
|
|
414
460
|
return response;
|
|
415
461
|
} finally {
|
|
416
|
-
attemptController.
|
|
417
|
-
});
|
|
462
|
+
attemptController.abort(new Error("Attempt complete"));
|
|
418
463
|
}
|
|
419
464
|
},
|
|
420
465
|
this.options.retries,
|
|
@@ -427,8 +472,7 @@ var ResilientNetworkLayer = class {
|
|
|
427
472
|
this.logger?.error(`Request to ${calendarUrl} failed after ${elapsed}ms`, error);
|
|
428
473
|
throw error;
|
|
429
474
|
} finally {
|
|
430
|
-
totalController.
|
|
431
|
-
});
|
|
475
|
+
totalController.abort(new Error("Request complete"));
|
|
432
476
|
}
|
|
433
477
|
}
|
|
434
478
|
/** Get circuit breaker state for a calendar */
|
|
@@ -1917,7 +1961,7 @@ var CalendarClient = class {
|
|
|
1917
1961
|
url;
|
|
1918
1962
|
networkLayer;
|
|
1919
1963
|
logger;
|
|
1920
|
-
/**
|
|
1964
|
+
/** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
|
|
1921
1965
|
async submit(digest, signal) {
|
|
1922
1966
|
assertCommitment(digest);
|
|
1923
1967
|
this.logger?.debug(`Submitting digest to ${this.url}/digest`);
|
|
@@ -1928,7 +1972,7 @@ var CalendarClient = class {
|
|
|
1928
1972
|
);
|
|
1929
1973
|
return this.#parseTimestamp(response.data, digest);
|
|
1930
1974
|
}
|
|
1931
|
-
/** Pregunta al calendario si tiene un Timestamp
|
|
1975
|
+
/** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
|
|
1932
1976
|
async getTimestamp(commitment, signal) {
|
|
1933
1977
|
assertCommitment(commitment);
|
|
1934
1978
|
const path = `/timestamp/${bytesToHex(commitment)}`;
|
|
@@ -1963,43 +2007,79 @@ var CalendarClient = class {
|
|
|
1963
2007
|
return timestamp;
|
|
1964
2008
|
}
|
|
1965
2009
|
};
|
|
1966
|
-
function
|
|
1967
|
-
|
|
1968
|
-
|
|
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(".");
|
|
1969
2035
|
}
|
|
1970
2036
|
var UrlWhitelist = class {
|
|
1971
|
-
#patterns = /* @__PURE__ */ new
|
|
2037
|
+
#patterns = /* @__PURE__ */ new Map();
|
|
1972
2038
|
constructor(urls) {
|
|
1973
2039
|
if (urls) {
|
|
1974
2040
|
for (const u of urls) this.add(u);
|
|
1975
2041
|
}
|
|
1976
2042
|
}
|
|
1977
|
-
/**
|
|
2043
|
+
/** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
|
|
1978
2044
|
add(url) {
|
|
1979
2045
|
if (typeof url !== "string") {
|
|
1980
2046
|
throw new TypeError("UrlWhitelist: URL must be a string");
|
|
1981
2047
|
}
|
|
1982
2048
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
1983
|
-
|
|
2049
|
+
const pattern = parseWhitelistPattern(url);
|
|
2050
|
+
if (pattern !== void 0) this.#patterns.set(url, pattern);
|
|
1984
2051
|
} else {
|
|
1985
|
-
this
|
|
1986
|
-
this
|
|
2052
|
+
this.add("http://" + url);
|
|
2053
|
+
this.add("https://" + url);
|
|
1987
2054
|
}
|
|
1988
2055
|
}
|
|
1989
|
-
/** Verdadero si `url` casa con
|
|
2056
|
+
/** Verdadero si `url` casa con algun patron de la whitelist. */
|
|
1990
2057
|
contains(url) {
|
|
1991
|
-
|
|
1992
|
-
|
|
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;
|
|
1993
2071
|
}
|
|
1994
2072
|
return false;
|
|
1995
2073
|
}
|
|
1996
2074
|
toString() {
|
|
1997
|
-
return `UrlWhitelist([${[...this.#patterns].join(", ")}])`;
|
|
2075
|
+
return `UrlWhitelist([${[...this.#patterns.keys()].join(", ")}])`;
|
|
1998
2076
|
}
|
|
1999
2077
|
};
|
|
2000
2078
|
var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([
|
|
2001
2079
|
"https://*.calendar.opentimestamps.org",
|
|
2002
2080
|
// Peter Todd
|
|
2081
|
+
"https://*.btc.calendar.opentimestamps.org",
|
|
2082
|
+
// Peter Todd Bitcoin calendars
|
|
2003
2083
|
"https://*.calendar.eternitywall.com",
|
|
2004
2084
|
// Eternity Wall
|
|
2005
2085
|
"https://*.calendar.catallaxy.com"
|
|
@@ -2092,7 +2172,13 @@ var EsploraClient = class {
|
|
|
2092
2172
|
`esplora response of ${data.length} bytes exceeds limit ${MAX_ESPLORA_RESPONSE_SIZE}`
|
|
2093
2173
|
);
|
|
2094
2174
|
}
|
|
2095
|
-
|
|
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
|
+
}
|
|
2096
2182
|
}
|
|
2097
2183
|
};
|
|
2098
2184
|
async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
|
|
@@ -2105,6 +2191,106 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
|
|
|
2105
2191
|
}
|
|
2106
2192
|
|
|
2107
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;
|
|
2108
2294
|
function validateHash(hash) {
|
|
2109
2295
|
if (typeof hash === "string") {
|
|
2110
2296
|
const hex = hash.trim().toLowerCase();
|
|
@@ -2126,19 +2312,12 @@ function secureNonce(n) {
|
|
|
2126
2312
|
globalThis.crypto.getRandomValues(bytes);
|
|
2127
2313
|
return bytes;
|
|
2128
2314
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
try {
|
|
2133
|
-
parsed = new URL(url);
|
|
2134
|
-
} catch {
|
|
2135
|
-
throw new ValidationError(`${label} is not a valid URL: ${url}`);
|
|
2136
|
-
}
|
|
2137
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
2138
|
-
throw new ValidationError(`${label} must use http(s): ${url}`);
|
|
2139
|
-
}
|
|
2315
|
+
function timingSafeEq(a, b) {
|
|
2316
|
+
if (a.length !== b.length) return false;
|
|
2317
|
+
return (0, import_node_crypto.timingSafeEqual)(a, b);
|
|
2140
2318
|
}
|
|
2141
|
-
|
|
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) {
|
|
2142
2321
|
if (calendars.length === 0) {
|
|
2143
2322
|
throw new ValidationError("at least one calendar is required to stamp");
|
|
2144
2323
|
}
|
|
@@ -2150,7 +2329,9 @@ async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, m
|
|
|
2150
2329
|
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
2151
2330
|
);
|
|
2152
2331
|
}
|
|
2153
|
-
|
|
2332
|
+
await Promise.all(
|
|
2333
|
+
calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
|
|
2334
|
+
);
|
|
2154
2335
|
const digest = validateHash(hash);
|
|
2155
2336
|
logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
|
|
2156
2337
|
const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
|
|
@@ -2222,7 +2403,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
|
|
|
2222
2403
|
}
|
|
2223
2404
|
}
|
|
2224
2405
|
const after = detached.serializeToBytes();
|
|
2225
|
-
if (
|
|
2406
|
+
if (bytesEqFast(before, after)) {
|
|
2226
2407
|
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
2227
2408
|
}
|
|
2228
2409
|
return Buffer.from(after);
|
|
@@ -2231,42 +2412,81 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
|
|
|
2231
2412
|
let detached;
|
|
2232
2413
|
try {
|
|
2233
2414
|
detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
2234
|
-
} catch {
|
|
2235
|
-
|
|
2415
|
+
} catch (cause) {
|
|
2416
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
2417
|
+
cause: cause instanceof Error ? cause : void 0
|
|
2418
|
+
});
|
|
2236
2419
|
}
|
|
2237
2420
|
if (originalDataHash !== void 0) {
|
|
2238
2421
|
let expected;
|
|
2239
2422
|
try {
|
|
2240
2423
|
expected = validateHash(originalDataHash);
|
|
2241
2424
|
} catch (err) {
|
|
2242
|
-
|
|
2425
|
+
throw new ValidationError(
|
|
2426
|
+
err instanceof Error ? err.message : "Invalid hash format",
|
|
2427
|
+
{ cause: err instanceof Error ? err : void 0 }
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
2431
|
+
return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
|
|
2243
2432
|
}
|
|
2244
|
-
|
|
2245
|
-
|
|
2433
|
+
}
|
|
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;
|
|
2246
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
|
+
);
|
|
2247
2450
|
}
|
|
2248
|
-
const bitcoinAtts = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
2249
2451
|
if (bitcoinAtts.length === 0) {
|
|
2250
2452
|
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
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
|
+
};
|
|
2255
2457
|
}
|
|
2256
2458
|
const explorer = new EsploraClient(networkLayer);
|
|
2257
|
-
let
|
|
2459
|
+
let lastNetworkError;
|
|
2460
|
+
let lastCryptoError;
|
|
2258
2461
|
for (const { msg, attestation } of bitcoinAtts) {
|
|
2259
2462
|
if (attestation.kind !== "bitcoin") continue;
|
|
2260
2463
|
try {
|
|
2261
|
-
const
|
|
2464
|
+
const blockTime = await verifyTimestampAttestation(
|
|
2465
|
+
Uint8Array.from(msg).reverse(),
|
|
2466
|
+
attestation,
|
|
2467
|
+
explorer,
|
|
2468
|
+
signal
|
|
2469
|
+
);
|
|
2262
2470
|
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
2263
|
-
return {
|
|
2471
|
+
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
2264
2472
|
} catch (err) {
|
|
2265
|
-
|
|
2266
|
-
|
|
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
|
+
}
|
|
2267
2481
|
}
|
|
2268
2482
|
}
|
|
2269
|
-
|
|
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
|
+
};
|
|
2270
2490
|
}
|
|
2271
2491
|
|
|
2272
2492
|
// src/client.ts
|
|
@@ -2276,6 +2496,7 @@ var OpenTimestampsClient = class {
|
|
|
2276
2496
|
logger;
|
|
2277
2497
|
globalSignal;
|
|
2278
2498
|
minimumSuccessfulSubmissions;
|
|
2499
|
+
allowPrivateCalendars;
|
|
2279
2500
|
/**
|
|
2280
2501
|
* Create a new OpenTimestamps client
|
|
2281
2502
|
*
|
|
@@ -2289,6 +2510,7 @@ var OpenTimestampsClient = class {
|
|
|
2289
2510
|
this.calendars = options.calendars;
|
|
2290
2511
|
}
|
|
2291
2512
|
this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
|
|
2513
|
+
this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
|
|
2292
2514
|
const resilienceConfig = {
|
|
2293
2515
|
...DEFAULT_RESILIENCE,
|
|
2294
2516
|
...options.resilience,
|
|
@@ -2307,7 +2529,8 @@ var OpenTimestampsClient = class {
|
|
|
2307
2529
|
};
|
|
2308
2530
|
this.logger = options.logger;
|
|
2309
2531
|
this.globalSignal = options.signal;
|
|
2310
|
-
|
|
2532
|
+
const internalOptions = options;
|
|
2533
|
+
this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
|
|
2311
2534
|
this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
|
|
2312
2535
|
}
|
|
2313
2536
|
/**
|
|
@@ -2336,7 +2559,8 @@ var OpenTimestampsClient = class {
|
|
|
2336
2559
|
this.networkLayer,
|
|
2337
2560
|
this.logger,
|
|
2338
2561
|
signal,
|
|
2339
|
-
this.minimumSuccessfulSubmissions
|
|
2562
|
+
this.minimumSuccessfulSubmissions,
|
|
2563
|
+
this.allowPrivateCalendars
|
|
2340
2564
|
);
|
|
2341
2565
|
}
|
|
2342
2566
|
/**
|
|
@@ -2454,13 +2678,16 @@ function hashFile(path) {
|
|
|
2454
2678
|
OpenTimestampsClientError,
|
|
2455
2679
|
PUBLIC_ESPLORA_URL,
|
|
2456
2680
|
ResilientNetworkLayer,
|
|
2681
|
+
SizeLimitExceededError,
|
|
2457
2682
|
StampError,
|
|
2458
2683
|
Timestamp,
|
|
2459
2684
|
UpgradeError,
|
|
2460
2685
|
UrlWhitelist,
|
|
2461
2686
|
ValidationError,
|
|
2687
|
+
assertSafeCalendarUrl,
|
|
2462
2688
|
hashBuffer,
|
|
2463
2689
|
hashFile,
|
|
2690
|
+
isVerified,
|
|
2464
2691
|
verifyAgainstBlockheader,
|
|
2465
2692
|
verifyTimestampAttestation
|
|
2466
2693
|
});
|