@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/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
- async function executeRequest(request) {
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 arrayBuffer = await response.arrayBuffer();
322
- const data = new Uint8Array(arrayBuffer);
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
- throw new NetworkError("Request aborted", { cause: error });
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
- controller.abort(new Error("Timeout"));
346
- }, timeoutMs);
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
- signal: attemptController.signal
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.signal.removeEventListener("abort", () => {
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.signal.removeEventListener("abort", () => {
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
- /** Envía un digest al calendario y devuelve el Timestamp que lo commit-ea. */
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 más completo para `commitment` (upgrade). */
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 wildcardToRegExp(pattern) {
1965
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "[^/]*");
1966
- return new RegExp(`^${escaped}$`, "i");
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 Set();
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
- /** Añade un patrón; si no trae esquema, se añaden las variantes http y https. */
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
- this.#patterns.add(url);
2049
+ const pattern = parseWhitelistPattern(url);
2050
+ if (pattern !== void 0) this.#patterns.set(url, pattern);
1982
2051
  } else {
1983
- this.#patterns.add("http://" + url);
1984
- this.#patterns.add("https://" + url);
2052
+ this.add("http://" + url);
2053
+ this.add("https://" + url);
1985
2054
  }
1986
2055
  }
1987
- /** Verdadero si `url` casa con algún patrón de la whitelist. */
2056
+ /** Verdadero si `url` casa con algun patron de la whitelist. */
1988
2057
  contains(url) {
1989
- for (const pattern of this.#patterns) {
1990
- if (wildcardToRegExp(pattern).test(url)) return true;
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
- return new TextDecoder("utf-8", { fatal: false }).decode(data);
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
- var bytesEq = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
2128
- function assertHttpUrl(url, label) {
2129
- let parsed;
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
- async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2) {
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
- for (const url of calendars) assertHttpUrl(url, "calendar");
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 (bytesEq(before, after)) {
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
- return { valid: false, error: "Invalid .ots proof format" };
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
- return { valid: false, error: err instanceof Error ? err.message : "Invalid hash format" };
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 (!bytesEq(expected, detached.fileDigest())) {
2243
- return { valid: false, error: "File hash does not match proof" };
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 bitcoinAtts = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
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
- if (hasLitecoin) {
2250
- return { valid: false, error: "Litecoin verification is not supported by this client" };
2251
- }
2252
- return { valid: false, error: "No Bitcoin attestation found (timestamp not yet confirmed)" };
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 lastError = "";
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 time = await verifyTimestampAttestation(Uint8Array.from(msg).reverse(), attestation, explorer, signal);
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 { valid: true, blockHeight: attestation.height, timestamp: time };
2471
+ return { status: "verified", blockHeight: attestation.height, blockTime };
2262
2472
  } catch (err) {
2263
- lastError = err instanceof Error ? err.message : String(err);
2264
- logger?.warn(`Bitcoin attestation at height ${attestation.height} failed: ${lastError}`);
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
- return { valid: false, error: `Could not verify against the Bitcoin blockchain: ${lastError}` };
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
- this.networkLayer = options.networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
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
  });