@otskit/client 0.5.0 → 0.6.1
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 +8 -6
- package/dist/browser.d.ts +36 -0
- package/dist/browser.js +742 -0
- package/dist/index.cjs +297 -253
- package/dist/index.d.cts +13 -13
- package/dist/index.d.ts +13 -13
- package/dist/index.js +286 -249
- package/package.json +15 -6
package/dist/index.js
CHANGED
|
@@ -29,7 +29,8 @@ var OpenTimestampsClientError = class extends Error {
|
|
|
29
29
|
constructor(message, options) {
|
|
30
30
|
super(message);
|
|
31
31
|
this.name = this.constructor.name;
|
|
32
|
-
if (options?.cause !== void 0)
|
|
32
|
+
if (options?.cause !== void 0)
|
|
33
|
+
this.cause = options.cause;
|
|
33
34
|
Error.captureStackTrace?.(this, this.constructor);
|
|
34
35
|
}
|
|
35
36
|
};
|
|
@@ -328,7 +329,8 @@ async function executeRequest(request, maxBytes) {
|
|
|
328
329
|
if (error instanceof NetworkError) throw error;
|
|
329
330
|
if (error instanceof Error) {
|
|
330
331
|
if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
|
|
331
|
-
if (error.message.includes("timeout"))
|
|
332
|
+
if (error.message.includes("timeout"))
|
|
333
|
+
throw new NetworkError("Request timeout", { cause: error });
|
|
332
334
|
throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
|
|
333
335
|
}
|
|
334
336
|
throw new NetworkError("Unknown network error");
|
|
@@ -342,10 +344,14 @@ function createTimeoutController(timeoutMs, parentSignal) {
|
|
|
342
344
|
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
343
345
|
controller.abort(parentSignal?.reason);
|
|
344
346
|
};
|
|
345
|
-
controller.signal.addEventListener(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
controller.signal.addEventListener(
|
|
348
|
+
"abort",
|
|
349
|
+
() => {
|
|
350
|
+
clearTimeout(timeout);
|
|
351
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
352
|
+
},
|
|
353
|
+
{ once: true }
|
|
354
|
+
);
|
|
349
355
|
if (parentSignal) {
|
|
350
356
|
if (parentSignal.aborted) {
|
|
351
357
|
clearTimeout(timeout);
|
|
@@ -437,15 +443,8 @@ var ResilientNetworkLayer = class {
|
|
|
437
443
|
}
|
|
438
444
|
};
|
|
439
445
|
|
|
440
|
-
// src/core/
|
|
441
|
-
import {
|
|
442
|
-
DetachedTimestampFile,
|
|
443
|
-
OpSHA256,
|
|
444
|
-
OpAppend,
|
|
445
|
-
OpSHA1,
|
|
446
|
-
OpRIPEMD160,
|
|
447
|
-
makeMerkleTree
|
|
448
|
-
} from "@otskit/core";
|
|
446
|
+
// src/core/stamp.ts
|
|
447
|
+
import { DetachedTimestampFile, OpSHA256, OpAppend, makeMerkleTree } from "@otskit/core";
|
|
449
448
|
|
|
450
449
|
// src/network/calendar.ts
|
|
451
450
|
import {
|
|
@@ -603,6 +602,146 @@ var UrlWhitelist = class {
|
|
|
603
602
|
var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...TRUSTED_CALENDAR_WHITELIST_PATTERNS]);
|
|
604
603
|
var DEFAULT_AGGREGATORS = [...DEFAULT_AGGREGATOR_URLS];
|
|
605
604
|
|
|
605
|
+
// src/utils/hex.ts
|
|
606
|
+
function hexToBytes(hex) {
|
|
607
|
+
const clean = hex.trim().toLowerCase();
|
|
608
|
+
if (clean.length % 2 !== 0 || !/^[0-9a-f]*$/.test(clean)) throw new Error("Invalid hex string");
|
|
609
|
+
const out = new Uint8Array(clean.length / 2);
|
|
610
|
+
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
function bytesToHex2(bytes) {
|
|
614
|
+
let s = "";
|
|
615
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
616
|
+
return s;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/core/shared.ts
|
|
620
|
+
function validateHash(hash) {
|
|
621
|
+
if (typeof hash === "string") {
|
|
622
|
+
const hex = hash.trim().toLowerCase();
|
|
623
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
|
624
|
+
throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
|
|
625
|
+
}
|
|
626
|
+
return hexToBytes(hex);
|
|
627
|
+
}
|
|
628
|
+
if (hash.length !== 32) {
|
|
629
|
+
throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
|
|
630
|
+
}
|
|
631
|
+
return Uint8Array.from(hash);
|
|
632
|
+
}
|
|
633
|
+
function secureNonce(n) {
|
|
634
|
+
const bytes = new Uint8Array(n);
|
|
635
|
+
if (!globalThis.crypto?.getRandomValues) {
|
|
636
|
+
throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
|
|
637
|
+
}
|
|
638
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
639
|
+
return bytes;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/core/stamp.ts
|
|
643
|
+
async function orchestrateStamp(hash, calendars, networkLayer, validateCalendarUrl, logger, signal, minimumSuccessfulSubmissions = 2) {
|
|
644
|
+
if (calendars.length === 0) {
|
|
645
|
+
throw new ValidationError("at least one calendar is required to stamp");
|
|
646
|
+
}
|
|
647
|
+
if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
|
|
648
|
+
throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
|
|
649
|
+
}
|
|
650
|
+
if (minimumSuccessfulSubmissions > calendars.length) {
|
|
651
|
+
throw new ValidationError(
|
|
652
|
+
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
await Promise.all(calendars.map((url) => validateCalendarUrl(url)));
|
|
656
|
+
const digest = validateHash(hash);
|
|
657
|
+
logger?.info(`Starting stamp for ${bytesToHex2(digest)}`);
|
|
658
|
+
const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
|
|
659
|
+
const nonceAppended = detached.timestamp.add(new OpAppend(secureNonce(16)));
|
|
660
|
+
const merkleRoot = nonceAppended.add(new OpSHA256());
|
|
661
|
+
const merkleTip = makeMerkleTree([merkleRoot]);
|
|
662
|
+
const results = await Promise.allSettled(
|
|
663
|
+
calendars.map(
|
|
664
|
+
(url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal)
|
|
665
|
+
)
|
|
666
|
+
);
|
|
667
|
+
const successful = [];
|
|
668
|
+
const failed = [];
|
|
669
|
+
results.forEach((r, i) => {
|
|
670
|
+
const calendar = calendars[i];
|
|
671
|
+
if (r.status === "fulfilled") {
|
|
672
|
+
merkleTip.merge(r.value);
|
|
673
|
+
successful.push({ calendar });
|
|
674
|
+
logger?.info(`Submitted to ${calendar}`);
|
|
675
|
+
} else {
|
|
676
|
+
const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
677
|
+
failed.push({ calendar, error });
|
|
678
|
+
logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
if (successful.length < minimumSuccessfulSubmissions) {
|
|
682
|
+
throw new StampError(
|
|
683
|
+
`Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
|
|
684
|
+
successful,
|
|
685
|
+
failed
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return detached.serializeToBytes();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/core/upgrade.ts
|
|
692
|
+
import { DetachedTimestampFile as DetachedTimestampFile2 } from "@otskit/core";
|
|
693
|
+
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
694
|
+
async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
|
|
695
|
+
let detached;
|
|
696
|
+
try {
|
|
697
|
+
detached = DetachedTimestampFile2.deserialize(new Uint8Array(incompleteProof));
|
|
698
|
+
} catch (error) {
|
|
699
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
700
|
+
/* v8 ignore next */
|
|
701
|
+
...error instanceof Error ? { cause: error } : {}
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
if (detached.timestamp.isTimestampComplete()) {
|
|
705
|
+
logger?.info("Proof already complete; nothing to upgrade");
|
|
706
|
+
return Buffer.from(incompleteProof);
|
|
707
|
+
}
|
|
708
|
+
const before = detached.serializeToBytes();
|
|
709
|
+
for (const subStamp of detached.timestamp.directlyVerified()) {
|
|
710
|
+
if (subStamp.isTimestampComplete()) continue;
|
|
711
|
+
for (const att of subStamp.attestations) {
|
|
712
|
+
if (att.kind !== "pending") continue;
|
|
713
|
+
if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
|
|
714
|
+
logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
|
|
719
|
+
subStamp.getDigest(),
|
|
720
|
+
signal
|
|
721
|
+
);
|
|
722
|
+
subStamp.merge(upgraded);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
if (err instanceof CommitmentNotFoundError) {
|
|
725
|
+
logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
logger?.warn(
|
|
729
|
+
`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const after = detached.serializeToBytes();
|
|
735
|
+
if (bytesEqFast(before, after)) {
|
|
736
|
+
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
737
|
+
}
|
|
738
|
+
return Buffer.from(after);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/core/verify.ts
|
|
742
|
+
import { DetachedTimestampFile as DetachedTimestampFile3, OpSHA1, OpRIPEMD160 } from "@otskit/core";
|
|
743
|
+
import { timingSafeEqual } from "crypto";
|
|
744
|
+
|
|
606
745
|
// src/network/esplora.ts
|
|
607
746
|
import { createHash } from "crypto";
|
|
608
747
|
import { verifyAgainstRawHeader, VerificationError } from "@otskit/core";
|
|
@@ -696,10 +835,18 @@ var EsploraClient = class {
|
|
|
696
835
|
this.#logger?.debug(`Esplora raw header ${hash}`);
|
|
697
836
|
const response = await this.#networkLayer.request(
|
|
698
837
|
this.#url,
|
|
699
|
-
{ url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "
|
|
838
|
+
{ url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "text/plain" } },
|
|
700
839
|
signal
|
|
701
840
|
);
|
|
702
|
-
const
|
|
841
|
+
const hex = this.#decode(response.data).trim();
|
|
842
|
+
let data;
|
|
843
|
+
try {
|
|
844
|
+
data = hexToBytes(hex);
|
|
845
|
+
} catch (cause) {
|
|
846
|
+
throw new EsploraResponseError("esplora returned a non-hex raw block header", {
|
|
847
|
+
...cause instanceof Error ? { cause } : {}
|
|
848
|
+
});
|
|
849
|
+
}
|
|
703
850
|
if (data.length !== RAW_HEADER_SIZE) {
|
|
704
851
|
throw new EsploraResponseError(
|
|
705
852
|
`raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
|
|
@@ -738,8 +885,98 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
|
|
|
738
885
|
return verifyAgainstRawHeader(digest, rawHeader);
|
|
739
886
|
}
|
|
740
887
|
|
|
741
|
-
// src/core/
|
|
742
|
-
|
|
888
|
+
// src/core/verify.ts
|
|
889
|
+
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
890
|
+
function timingSafeEq(a, b) {
|
|
891
|
+
if (a.length !== b.length) return false;
|
|
892
|
+
return timingSafeEqual(a, b);
|
|
893
|
+
}
|
|
894
|
+
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
895
|
+
let detached;
|
|
896
|
+
try {
|
|
897
|
+
detached = DetachedTimestampFile3.deserialize(new Uint8Array(proof));
|
|
898
|
+
} catch (cause) {
|
|
899
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
900
|
+
...cause instanceof Error ? { cause } : {}
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
if (detached.fileHashOp instanceof OpSHA1 || detached.fileHashOp instanceof OpRIPEMD160) {
|
|
904
|
+
return {
|
|
905
|
+
status: "invalid",
|
|
906
|
+
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
if (originalDataHash !== void 0) {
|
|
910
|
+
let expected;
|
|
911
|
+
try {
|
|
912
|
+
expected = validateHash(originalDataHash);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
throw new ValidationError(err instanceof Error ? err.message : "Invalid hash format", {
|
|
915
|
+
...err instanceof Error ? { cause: err } : {}
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
919
|
+
return {
|
|
920
|
+
status: "invalid",
|
|
921
|
+
reason: "File hash does not match proof \u2014 file may have been modified"
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
926
|
+
const seenHeights = /* @__PURE__ */ new Set();
|
|
927
|
+
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
928
|
+
if (attestation.kind !== "bitcoin") return false;
|
|
929
|
+
if (seenHeights.has(attestation.height)) {
|
|
930
|
+
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
seenHeights.add(attestation.height);
|
|
934
|
+
return true;
|
|
935
|
+
});
|
|
936
|
+
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
937
|
+
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
938
|
+
logger?.warn(
|
|
939
|
+
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
if (bitcoinAtts.length === 0) {
|
|
943
|
+
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
944
|
+
return {
|
|
945
|
+
status: "pending",
|
|
946
|
+
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
const explorer = new EsploraClient(networkLayer, {
|
|
950
|
+
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
951
|
+
...logger !== void 0 ? { logger } : {}
|
|
952
|
+
});
|
|
953
|
+
let lastNetworkError;
|
|
954
|
+
let lastCryptoError;
|
|
955
|
+
for (const { msg, attestation } of bitcoinAtts) {
|
|
956
|
+
if (attestation.kind !== "bitcoin") continue;
|
|
957
|
+
try {
|
|
958
|
+
const blockTime = await verifyTimestampAttestation(msg, attestation, explorer, signal);
|
|
959
|
+
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
960
|
+
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
961
|
+
} catch (err) {
|
|
962
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
963
|
+
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
964
|
+
lastNetworkError = message;
|
|
965
|
+
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
966
|
+
} else {
|
|
967
|
+
lastCryptoError = message;
|
|
968
|
+
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (lastCryptoError !== void 0) {
|
|
973
|
+
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
974
|
+
}
|
|
975
|
+
return {
|
|
976
|
+
status: "network_error",
|
|
977
|
+
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
978
|
+
};
|
|
979
|
+
}
|
|
743
980
|
|
|
744
981
|
// src/security/ssrf.ts
|
|
745
982
|
import { lookup } from "dns/promises";
|
|
@@ -758,7 +995,7 @@ var BLOCKED_CIDRS_V4 = [
|
|
|
758
995
|
];
|
|
759
996
|
function ipv4ToUint32(ip) {
|
|
760
997
|
const parts = ip.split(".");
|
|
761
|
-
return (parseInt(parts[0], 10) << 24 | parseInt(parts[1], 10) << 16 | parseInt(parts[2], 10) << 8 | parseInt(parts[3], 10)) >>> 0;
|
|
998
|
+
return (Number.parseInt(parts[0], 10) << 24 | Number.parseInt(parts[1], 10) << 16 | Number.parseInt(parts[2], 10) << 8 | Number.parseInt(parts[3], 10)) >>> 0;
|
|
762
999
|
}
|
|
763
1000
|
function assertNotPrivateIPv4(ip, calendarUrl) {
|
|
764
1001
|
const n = ipv4ToUint32(ip);
|
|
@@ -837,215 +1074,6 @@ async function assertSafeCalendarUrl(url, options) {
|
|
|
837
1074
|
}
|
|
838
1075
|
}
|
|
839
1076
|
|
|
840
|
-
// src/core/orchestration.ts
|
|
841
|
-
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
842
|
-
function validateHash(hash) {
|
|
843
|
-
if (typeof hash === "string") {
|
|
844
|
-
const hex = hash.trim().toLowerCase();
|
|
845
|
-
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
|
846
|
-
throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
|
|
847
|
-
}
|
|
848
|
-
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
849
|
-
}
|
|
850
|
-
if (hash.length !== 32) {
|
|
851
|
-
throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
|
|
852
|
-
}
|
|
853
|
-
return Uint8Array.from(hash);
|
|
854
|
-
}
|
|
855
|
-
function secureNonce(n) {
|
|
856
|
-
const bytes = new Uint8Array(n);
|
|
857
|
-
if (!globalThis.crypto?.getRandomValues) {
|
|
858
|
-
throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
|
|
859
|
-
}
|
|
860
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
861
|
-
return bytes;
|
|
862
|
-
}
|
|
863
|
-
function timingSafeEq(a, b) {
|
|
864
|
-
if (a.length !== b.length) return false;
|
|
865
|
-
return timingSafeEqual(a, b);
|
|
866
|
-
}
|
|
867
|
-
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
868
|
-
async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
|
|
869
|
-
if (calendars.length === 0) {
|
|
870
|
-
throw new ValidationError("at least one calendar is required to stamp");
|
|
871
|
-
}
|
|
872
|
-
if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
|
|
873
|
-
throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
|
|
874
|
-
}
|
|
875
|
-
if (minimumSuccessfulSubmissions > calendars.length) {
|
|
876
|
-
throw new ValidationError(
|
|
877
|
-
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
await Promise.all(
|
|
881
|
-
calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
|
|
882
|
-
);
|
|
883
|
-
const digest = validateHash(hash);
|
|
884
|
-
logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
|
|
885
|
-
const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
|
|
886
|
-
const nonceAppended = detached.timestamp.add(new OpAppend(secureNonce(16)));
|
|
887
|
-
const merkleRoot = nonceAppended.add(new OpSHA256());
|
|
888
|
-
const merkleTip = makeMerkleTree([merkleRoot]);
|
|
889
|
-
const results = await Promise.allSettled(
|
|
890
|
-
calendars.map((url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal))
|
|
891
|
-
);
|
|
892
|
-
const successful = [];
|
|
893
|
-
const failed = [];
|
|
894
|
-
results.forEach((r, i) => {
|
|
895
|
-
const calendar = calendars[i];
|
|
896
|
-
if (r.status === "fulfilled") {
|
|
897
|
-
merkleTip.merge(r.value);
|
|
898
|
-
successful.push({ calendar });
|
|
899
|
-
logger?.info(`Submitted to ${calendar}`);
|
|
900
|
-
} else {
|
|
901
|
-
const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
902
|
-
failed.push({ calendar, error });
|
|
903
|
-
logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
|
|
904
|
-
}
|
|
905
|
-
});
|
|
906
|
-
if (successful.length < minimumSuccessfulSubmissions) {
|
|
907
|
-
throw new StampError(
|
|
908
|
-
`Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
|
|
909
|
-
successful,
|
|
910
|
-
failed
|
|
911
|
-
);
|
|
912
|
-
}
|
|
913
|
-
return Buffer.from(detached.serializeToBytes());
|
|
914
|
-
}
|
|
915
|
-
async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
|
|
916
|
-
let detached;
|
|
917
|
-
try {
|
|
918
|
-
detached = DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
|
|
919
|
-
} catch (error) {
|
|
920
|
-
throw new ValidationError("Invalid .ots proof format", {
|
|
921
|
-
/* v8 ignore next */
|
|
922
|
-
...error instanceof Error ? { cause: error } : {}
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
if (detached.timestamp.isTimestampComplete()) {
|
|
926
|
-
logger?.info("Proof already complete; nothing to upgrade");
|
|
927
|
-
return Buffer.from(incompleteProof);
|
|
928
|
-
}
|
|
929
|
-
const before = detached.serializeToBytes();
|
|
930
|
-
for (const subStamp of detached.timestamp.directlyVerified()) {
|
|
931
|
-
if (subStamp.isTimestampComplete()) continue;
|
|
932
|
-
for (const att of subStamp.attestations) {
|
|
933
|
-
if (att.kind !== "pending") continue;
|
|
934
|
-
if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
|
|
935
|
-
logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
|
-
try {
|
|
939
|
-
const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
|
|
940
|
-
subStamp.getDigest(),
|
|
941
|
-
signal
|
|
942
|
-
);
|
|
943
|
-
subStamp.merge(upgraded);
|
|
944
|
-
} catch (err) {
|
|
945
|
-
if (err instanceof CommitmentNotFoundError) {
|
|
946
|
-
logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
949
|
-
logger?.warn(`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
const after = detached.serializeToBytes();
|
|
954
|
-
if (bytesEqFast(before, after)) {
|
|
955
|
-
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
956
|
-
}
|
|
957
|
-
return Buffer.from(after);
|
|
958
|
-
}
|
|
959
|
-
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
960
|
-
let detached;
|
|
961
|
-
try {
|
|
962
|
-
detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
963
|
-
} catch (cause) {
|
|
964
|
-
throw new ValidationError("Invalid .ots proof format", {
|
|
965
|
-
...cause instanceof Error ? { cause } : {}
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
if (detached.fileHashOp instanceof OpSHA1 || detached.fileHashOp instanceof OpRIPEMD160) {
|
|
969
|
-
return {
|
|
970
|
-
status: "invalid",
|
|
971
|
-
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
if (originalDataHash !== void 0) {
|
|
975
|
-
let expected;
|
|
976
|
-
try {
|
|
977
|
-
expected = validateHash(originalDataHash);
|
|
978
|
-
} catch (err) {
|
|
979
|
-
throw new ValidationError(
|
|
980
|
-
err instanceof Error ? err.message : "Invalid hash format",
|
|
981
|
-
{ ...err instanceof Error ? { cause: err } : {} }
|
|
982
|
-
);
|
|
983
|
-
}
|
|
984
|
-
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
985
|
-
return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
989
|
-
const seenHeights = /* @__PURE__ */ new Set();
|
|
990
|
-
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
991
|
-
if (attestation.kind !== "bitcoin") return false;
|
|
992
|
-
if (seenHeights.has(attestation.height)) {
|
|
993
|
-
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
994
|
-
return false;
|
|
995
|
-
}
|
|
996
|
-
seenHeights.add(attestation.height);
|
|
997
|
-
return true;
|
|
998
|
-
});
|
|
999
|
-
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
1000
|
-
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
1001
|
-
logger?.warn(
|
|
1002
|
-
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
if (bitcoinAtts.length === 0) {
|
|
1006
|
-
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
1007
|
-
return {
|
|
1008
|
-
status: "pending",
|
|
1009
|
-
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
1010
|
-
};
|
|
1011
|
-
}
|
|
1012
|
-
const explorer = new EsploraClient(networkLayer, {
|
|
1013
|
-
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
1014
|
-
...logger !== void 0 ? { logger } : {}
|
|
1015
|
-
});
|
|
1016
|
-
let lastNetworkError;
|
|
1017
|
-
let lastCryptoError;
|
|
1018
|
-
for (const { msg, attestation } of bitcoinAtts) {
|
|
1019
|
-
if (attestation.kind !== "bitcoin") continue;
|
|
1020
|
-
try {
|
|
1021
|
-
const blockTime = await verifyTimestampAttestation(
|
|
1022
|
-
Uint8Array.from(msg).reverse(),
|
|
1023
|
-
attestation,
|
|
1024
|
-
explorer,
|
|
1025
|
-
signal
|
|
1026
|
-
);
|
|
1027
|
-
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
1028
|
-
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
1029
|
-
} catch (err) {
|
|
1030
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1031
|
-
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
1032
|
-
lastNetworkError = message;
|
|
1033
|
-
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
1034
|
-
} else {
|
|
1035
|
-
lastCryptoError = message;
|
|
1036
|
-
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
if (lastCryptoError !== void 0) {
|
|
1041
|
-
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
1042
|
-
}
|
|
1043
|
-
return {
|
|
1044
|
-
status: "network_error",
|
|
1045
|
-
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
1077
|
// src/client.ts
|
|
1050
1078
|
var OpenTimestampsClient = class {
|
|
1051
1079
|
calendars;
|
|
@@ -1057,7 +1085,7 @@ var OpenTimestampsClient = class {
|
|
|
1057
1085
|
esploraUrl;
|
|
1058
1086
|
/**
|
|
1059
1087
|
* Create a new OpenTimestamps client
|
|
1060
|
-
*
|
|
1088
|
+
*
|
|
1061
1089
|
* @param options Client configuration options
|
|
1062
1090
|
*/
|
|
1063
1091
|
constructor(options = {}) {
|
|
@@ -1103,15 +1131,15 @@ var OpenTimestampsClient = class {
|
|
|
1103
1131
|
}
|
|
1104
1132
|
/**
|
|
1105
1133
|
* Create a timestamp by submitting a hash to calendar servers
|
|
1106
|
-
*
|
|
1134
|
+
*
|
|
1107
1135
|
* @param hash SHA-256 hash of the data to timestamp (as Buffer or hex string)
|
|
1108
1136
|
* @param options Operation-specific options
|
|
1109
1137
|
* @returns Initial .ots proof with pending attestations
|
|
1110
|
-
*
|
|
1138
|
+
*
|
|
1111
1139
|
* @throws {ValidationError} If the hash is invalid
|
|
1112
1140
|
* @throws {StampError} If submission fails to all calendars
|
|
1113
1141
|
* @throws {NetworkError} If network errors occur
|
|
1114
|
-
*
|
|
1142
|
+
*
|
|
1115
1143
|
* @example
|
|
1116
1144
|
* ```typescript
|
|
1117
1145
|
* const hash = crypto.createHash('sha256').update('my data').digest()
|
|
@@ -1121,34 +1149,36 @@ var OpenTimestampsClient = class {
|
|
|
1121
1149
|
*/
|
|
1122
1150
|
async stamp(hash, options) {
|
|
1123
1151
|
const signal = options?.signal || this.globalSignal;
|
|
1124
|
-
|
|
1152
|
+
const allowPrivate = this.allowPrivateCalendars;
|
|
1153
|
+
const bytes = await orchestrateStamp(
|
|
1125
1154
|
hash,
|
|
1126
1155
|
this.calendars,
|
|
1127
1156
|
this.networkLayer,
|
|
1157
|
+
(url) => assertSafeCalendarUrl(url, { allowPrivate }),
|
|
1128
1158
|
this.logger,
|
|
1129
1159
|
signal,
|
|
1130
|
-
this.minimumSuccessfulSubmissions
|
|
1131
|
-
this.allowPrivateCalendars
|
|
1160
|
+
this.minimumSuccessfulSubmissions
|
|
1132
1161
|
);
|
|
1162
|
+
return Buffer.from(bytes);
|
|
1133
1163
|
}
|
|
1134
1164
|
/**
|
|
1135
1165
|
* Upgrade an incomplete timestamp proof by querying calendars for Bitcoin confirmation
|
|
1136
|
-
*
|
|
1166
|
+
*
|
|
1137
1167
|
* @param incompleteProof The initial .ots proof returned by stamp()
|
|
1138
1168
|
* @param options Operation-specific options
|
|
1139
1169
|
* @returns Upgraded .ots proof with Bitcoin attestation (if available)
|
|
1140
|
-
*
|
|
1170
|
+
*
|
|
1141
1171
|
* @throws {ValidationError} If the proof format is invalid
|
|
1142
1172
|
* @throws {UpgradeError} If no calendar has confirmed the timestamp yet
|
|
1143
1173
|
* @throws {NetworkError} If network errors occur
|
|
1144
|
-
*
|
|
1174
|
+
*
|
|
1145
1175
|
* @example
|
|
1146
1176
|
* ```typescript
|
|
1147
1177
|
* // Proof already has pending attestations from stamp()
|
|
1148
1178
|
* const upgradedProof = await client.upgrade(incompleteProof)
|
|
1149
|
-
*
|
|
1179
|
+
*
|
|
1150
1180
|
* // If upgrade throws UpgradeError, Bitcoin hasn't confirmed yet
|
|
1151
|
-
* // Retry later (typically
|
|
1181
|
+
* // Retry later (typically ~60 minutes after stamp)
|
|
1152
1182
|
* ```
|
|
1153
1183
|
*/
|
|
1154
1184
|
async upgrade(incompleteProof, options) {
|
|
@@ -1163,15 +1193,15 @@ var OpenTimestampsClient = class {
|
|
|
1163
1193
|
}
|
|
1164
1194
|
/**
|
|
1165
1195
|
* Verify a complete timestamp proof against the Bitcoin blockchain
|
|
1166
|
-
*
|
|
1196
|
+
*
|
|
1167
1197
|
* @param proof The complete .ots proof with Bitcoin attestation
|
|
1168
1198
|
* @param originalDataHash Optional: the original data hash to verify against
|
|
1169
1199
|
* @returns Verification result with block details
|
|
1170
|
-
*
|
|
1200
|
+
*
|
|
1171
1201
|
* @example
|
|
1172
1202
|
* ```typescript
|
|
1173
1203
|
* const result = await client.verify(completeProof, originalHash)
|
|
1174
|
-
*
|
|
1204
|
+
*
|
|
1175
1205
|
* if (result.valid) {
|
|
1176
1206
|
* console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
|
|
1177
1207
|
* console.log(`Block timestamp: ${new Date(result.timestamp! * 1000)}`)
|
|
@@ -1181,12 +1211,19 @@ var OpenTimestampsClient = class {
|
|
|
1181
1211
|
* ```
|
|
1182
1212
|
*/
|
|
1183
1213
|
async verify(proof, originalDataHash) {
|
|
1184
|
-
return orchestrateVerify(
|
|
1214
|
+
return orchestrateVerify(
|
|
1215
|
+
proof,
|
|
1216
|
+
this.networkLayer,
|
|
1217
|
+
originalDataHash,
|
|
1218
|
+
this.logger,
|
|
1219
|
+
this.globalSignal,
|
|
1220
|
+
this.esploraUrl
|
|
1221
|
+
);
|
|
1185
1222
|
}
|
|
1186
1223
|
/**
|
|
1187
1224
|
* Get the current state of the circuit breaker for a calendar
|
|
1188
1225
|
* Useful for monitoring and debugging
|
|
1189
|
-
*
|
|
1226
|
+
*
|
|
1190
1227
|
* @param calendarUrl The calendar URL to check
|
|
1191
1228
|
* @returns Circuit state: 'CLOSED', 'OPEN', or 'HALF_OPEN' (undefined if not yet initialized)
|
|
1192
1229
|
*/
|
|
@@ -1196,7 +1233,7 @@ var OpenTimestampsClient = class {
|
|
|
1196
1233
|
/**
|
|
1197
1234
|
* Reset the circuit breaker for a specific calendar
|
|
1198
1235
|
* Use this to manually recover a calendar that has been marked as failing
|
|
1199
|
-
*
|
|
1236
|
+
*
|
|
1200
1237
|
* @param calendarUrl The calendar URL to reset
|
|
1201
1238
|
*/
|
|
1202
1239
|
resetCircuit(calendarUrl) {
|
|
@@ -1214,7 +1251,7 @@ var OpenTimestampsClient = class {
|
|
|
1214
1251
|
};
|
|
1215
1252
|
|
|
1216
1253
|
// src/index.ts
|
|
1217
|
-
import { DetachedTimestampFile as
|
|
1254
|
+
import { DetachedTimestampFile as DetachedTimestampFile4, Timestamp as Timestamp2 } from "@otskit/core";
|
|
1218
1255
|
import { verifyAgainstBlockheader } from "@otskit/core";
|
|
1219
1256
|
|
|
1220
1257
|
// src/utils/hash.ts
|
|
@@ -1239,7 +1276,7 @@ export {
|
|
|
1239
1276
|
DEFAULT_CALENDARS,
|
|
1240
1277
|
DEFAULT_CALENDAR_WHITELIST,
|
|
1241
1278
|
DEFAULT_RESILIENCE,
|
|
1242
|
-
|
|
1279
|
+
DetachedTimestampFile4 as DetachedTimestampFile,
|
|
1243
1280
|
EsploraClient,
|
|
1244
1281
|
EsploraResponseError,
|
|
1245
1282
|
MAX_CALENDAR_RESPONSE_SIZE,
|