@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/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
- 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) {
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 arrayBuffer = await response.arrayBuffer();
324
- const data = new Uint8Array(arrayBuffer);
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
- throw new NetworkError("Request aborted", { cause: error });
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
- controller.abort(new Error("Timeout"));
348
- }, 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 });
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
- signal: attemptController.signal
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.signal.removeEventListener("abort", () => {
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.signal.removeEventListener("abort", () => {
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
- /** 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. */
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 más completo para `commitment` (upgrade). */
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 wildcardToRegExp(pattern) {
1967
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "[^/]*");
1968
- 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(".");
1969
2035
  }
1970
2036
  var UrlWhitelist = class {
1971
- #patterns = /* @__PURE__ */ new Set();
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
- /** 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. */
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
- this.#patterns.add(url);
2049
+ const pattern = parseWhitelistPattern(url);
2050
+ if (pattern !== void 0) this.#patterns.set(url, pattern);
1984
2051
  } else {
1985
- this.#patterns.add("http://" + url);
1986
- this.#patterns.add("https://" + url);
2052
+ this.add("http://" + url);
2053
+ this.add("https://" + url);
1987
2054
  }
1988
2055
  }
1989
- /** Verdadero si `url` casa con algún patrón de la whitelist. */
2056
+ /** Verdadero si `url` casa con algun patron de la whitelist. */
1990
2057
  contains(url) {
1991
- for (const pattern of this.#patterns) {
1992
- 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;
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
- 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
+ }
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
- var bytesEq = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
2130
- function assertHttpUrl(url, label) {
2131
- let parsed;
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
- 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) {
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
- for (const url of calendars) assertHttpUrl(url, "calendar");
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 (bytesEq(before, after)) {
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
- 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
+ });
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
- 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
+ );
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
- if (!bytesEq(expected, detached.fileDigest())) {
2245
- return { valid: false, error: "File hash does not match proof" };
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
- if (hasLitecoin) {
2252
- return { valid: false, error: "Litecoin verification is not supported by this client" };
2253
- }
2254
- 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
+ };
2255
2457
  }
2256
2458
  const explorer = new EsploraClient(networkLayer);
2257
- let lastError = "";
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 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
+ );
2262
2470
  logger?.info(`Verified against Bitcoin block ${attestation.height}`);
2263
- return { valid: true, blockHeight: attestation.height, timestamp: time };
2471
+ return { status: "verified", blockHeight: attestation.height, blockTime };
2264
2472
  } catch (err) {
2265
- lastError = err instanceof Error ? err.message : String(err);
2266
- 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
+ }
2267
2481
  }
2268
2482
  }
2269
- 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
+ };
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
- this.networkLayer = options.networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
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
  });