@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.cjs
CHANGED
|
@@ -29,7 +29,7 @@ __export(index_exports, {
|
|
|
29
29
|
DEFAULT_CALENDARS: () => DEFAULT_CALENDARS,
|
|
30
30
|
DEFAULT_CALENDAR_WHITELIST: () => DEFAULT_CALENDAR_WHITELIST,
|
|
31
31
|
DEFAULT_RESILIENCE: () => DEFAULT_RESILIENCE,
|
|
32
|
-
DetachedTimestampFile: () =>
|
|
32
|
+
DetachedTimestampFile: () => import_core7.DetachedTimestampFile,
|
|
33
33
|
EsploraClient: () => EsploraClient,
|
|
34
34
|
EsploraResponseError: () => EsploraResponseError,
|
|
35
35
|
MAX_CALENDAR_RESPONSE_SIZE: () => MAX_CALENDAR_RESPONSE_SIZE,
|
|
@@ -41,7 +41,7 @@ __export(index_exports, {
|
|
|
41
41
|
ResilientNetworkLayer: () => ResilientNetworkLayer,
|
|
42
42
|
SizeLimitExceededError: () => SizeLimitExceededError,
|
|
43
43
|
StampError: () => StampError,
|
|
44
|
-
Timestamp: () =>
|
|
44
|
+
Timestamp: () => import_core7.Timestamp,
|
|
45
45
|
UpgradeError: () => UpgradeError,
|
|
46
46
|
UrlWhitelist: () => UrlWhitelist,
|
|
47
47
|
ValidationError: () => ValidationError,
|
|
@@ -49,7 +49,7 @@ __export(index_exports, {
|
|
|
49
49
|
hashBuffer: () => hashBuffer,
|
|
50
50
|
hashFile: () => hashFile,
|
|
51
51
|
isVerified: () => isVerified,
|
|
52
|
-
verifyAgainstBlockheader: () =>
|
|
52
|
+
verifyAgainstBlockheader: () => import_core8.verifyAgainstBlockheader,
|
|
53
53
|
verifyTimestampAttestation: () => verifyTimestampAttestation
|
|
54
54
|
});
|
|
55
55
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -85,7 +85,8 @@ var OpenTimestampsClientError = class extends Error {
|
|
|
85
85
|
constructor(message, options) {
|
|
86
86
|
super(message);
|
|
87
87
|
this.name = this.constructor.name;
|
|
88
|
-
if (options?.cause !== void 0)
|
|
88
|
+
if (options?.cause !== void 0)
|
|
89
|
+
this.cause = options.cause;
|
|
89
90
|
Error.captureStackTrace?.(this, this.constructor);
|
|
90
91
|
}
|
|
91
92
|
};
|
|
@@ -384,7 +385,8 @@ async function executeRequest(request, maxBytes) {
|
|
|
384
385
|
if (error instanceof NetworkError) throw error;
|
|
385
386
|
if (error instanceof Error) {
|
|
386
387
|
if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
|
|
387
|
-
if (error.message.includes("timeout"))
|
|
388
|
+
if (error.message.includes("timeout"))
|
|
389
|
+
throw new NetworkError("Request timeout", { cause: error });
|
|
388
390
|
throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
|
|
389
391
|
}
|
|
390
392
|
throw new NetworkError("Unknown network error");
|
|
@@ -398,10 +400,14 @@ function createTimeoutController(timeoutMs, parentSignal) {
|
|
|
398
400
|
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
399
401
|
controller.abort(parentSignal?.reason);
|
|
400
402
|
};
|
|
401
|
-
controller.signal.addEventListener(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
403
|
+
controller.signal.addEventListener(
|
|
404
|
+
"abort",
|
|
405
|
+
() => {
|
|
406
|
+
clearTimeout(timeout);
|
|
407
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
408
|
+
},
|
|
409
|
+
{ once: true }
|
|
410
|
+
);
|
|
405
411
|
if (parentSignal) {
|
|
406
412
|
if (parentSignal.aborted) {
|
|
407
413
|
clearTimeout(timeout);
|
|
@@ -493,8 +499,8 @@ var ResilientNetworkLayer = class {
|
|
|
493
499
|
}
|
|
494
500
|
};
|
|
495
501
|
|
|
496
|
-
// src/core/
|
|
497
|
-
var
|
|
502
|
+
// src/core/stamp.ts
|
|
503
|
+
var import_core3 = require("@otskit/core");
|
|
498
504
|
|
|
499
505
|
// src/network/calendar.ts
|
|
500
506
|
var import_core2 = require("@otskit/core");
|
|
@@ -646,9 +652,149 @@ var UrlWhitelist = class {
|
|
|
646
652
|
var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...import_core2.TRUSTED_CALENDAR_WHITELIST_PATTERNS]);
|
|
647
653
|
var DEFAULT_AGGREGATORS = [...import_core2.DEFAULT_AGGREGATOR_URLS];
|
|
648
654
|
|
|
655
|
+
// src/utils/hex.ts
|
|
656
|
+
function hexToBytes(hex) {
|
|
657
|
+
const clean = hex.trim().toLowerCase();
|
|
658
|
+
if (clean.length % 2 !== 0 || !/^[0-9a-f]*$/.test(clean)) throw new Error("Invalid hex string");
|
|
659
|
+
const out = new Uint8Array(clean.length / 2);
|
|
660
|
+
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
function bytesToHex2(bytes) {
|
|
664
|
+
let s = "";
|
|
665
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
666
|
+
return s;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/core/shared.ts
|
|
670
|
+
function validateHash(hash) {
|
|
671
|
+
if (typeof hash === "string") {
|
|
672
|
+
const hex = hash.trim().toLowerCase();
|
|
673
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
|
674
|
+
throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
|
|
675
|
+
}
|
|
676
|
+
return hexToBytes(hex);
|
|
677
|
+
}
|
|
678
|
+
if (hash.length !== 32) {
|
|
679
|
+
throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
|
|
680
|
+
}
|
|
681
|
+
return Uint8Array.from(hash);
|
|
682
|
+
}
|
|
683
|
+
function secureNonce(n) {
|
|
684
|
+
const bytes = new Uint8Array(n);
|
|
685
|
+
if (!globalThis.crypto?.getRandomValues) {
|
|
686
|
+
throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
|
|
687
|
+
}
|
|
688
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
689
|
+
return bytes;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/core/stamp.ts
|
|
693
|
+
async function orchestrateStamp(hash, calendars, networkLayer, validateCalendarUrl, logger, signal, minimumSuccessfulSubmissions = 2) {
|
|
694
|
+
if (calendars.length === 0) {
|
|
695
|
+
throw new ValidationError("at least one calendar is required to stamp");
|
|
696
|
+
}
|
|
697
|
+
if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
|
|
698
|
+
throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
|
|
699
|
+
}
|
|
700
|
+
if (minimumSuccessfulSubmissions > calendars.length) {
|
|
701
|
+
throw new ValidationError(
|
|
702
|
+
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
await Promise.all(calendars.map((url) => validateCalendarUrl(url)));
|
|
706
|
+
const digest = validateHash(hash);
|
|
707
|
+
logger?.info(`Starting stamp for ${bytesToHex2(digest)}`);
|
|
708
|
+
const detached = import_core3.DetachedTimestampFile.fromHash(new import_core3.OpSHA256(), digest);
|
|
709
|
+
const nonceAppended = detached.timestamp.add(new import_core3.OpAppend(secureNonce(16)));
|
|
710
|
+
const merkleRoot = nonceAppended.add(new import_core3.OpSHA256());
|
|
711
|
+
const merkleTip = (0, import_core3.makeMerkleTree)([merkleRoot]);
|
|
712
|
+
const results = await Promise.allSettled(
|
|
713
|
+
calendars.map(
|
|
714
|
+
(url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal)
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
const successful = [];
|
|
718
|
+
const failed = [];
|
|
719
|
+
results.forEach((r, i) => {
|
|
720
|
+
const calendar = calendars[i];
|
|
721
|
+
if (r.status === "fulfilled") {
|
|
722
|
+
merkleTip.merge(r.value);
|
|
723
|
+
successful.push({ calendar });
|
|
724
|
+
logger?.info(`Submitted to ${calendar}`);
|
|
725
|
+
} else {
|
|
726
|
+
const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
727
|
+
failed.push({ calendar, error });
|
|
728
|
+
logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
if (successful.length < minimumSuccessfulSubmissions) {
|
|
732
|
+
throw new StampError(
|
|
733
|
+
`Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
|
|
734
|
+
successful,
|
|
735
|
+
failed
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return detached.serializeToBytes();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/core/upgrade.ts
|
|
742
|
+
var import_core4 = require("@otskit/core");
|
|
743
|
+
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
744
|
+
async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
|
|
745
|
+
let detached;
|
|
746
|
+
try {
|
|
747
|
+
detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
|
|
748
|
+
} catch (error) {
|
|
749
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
750
|
+
/* v8 ignore next */
|
|
751
|
+
...error instanceof Error ? { cause: error } : {}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
if (detached.timestamp.isTimestampComplete()) {
|
|
755
|
+
logger?.info("Proof already complete; nothing to upgrade");
|
|
756
|
+
return Buffer.from(incompleteProof);
|
|
757
|
+
}
|
|
758
|
+
const before = detached.serializeToBytes();
|
|
759
|
+
for (const subStamp of detached.timestamp.directlyVerified()) {
|
|
760
|
+
if (subStamp.isTimestampComplete()) continue;
|
|
761
|
+
for (const att of subStamp.attestations) {
|
|
762
|
+
if (att.kind !== "pending") continue;
|
|
763
|
+
if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
|
|
764
|
+
logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
|
|
769
|
+
subStamp.getDigest(),
|
|
770
|
+
signal
|
|
771
|
+
);
|
|
772
|
+
subStamp.merge(upgraded);
|
|
773
|
+
} catch (err) {
|
|
774
|
+
if (err instanceof CommitmentNotFoundError) {
|
|
775
|
+
logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
logger?.warn(
|
|
779
|
+
`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const after = detached.serializeToBytes();
|
|
785
|
+
if (bytesEqFast(before, after)) {
|
|
786
|
+
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
787
|
+
}
|
|
788
|
+
return Buffer.from(after);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/core/verify.ts
|
|
792
|
+
var import_core6 = require("@otskit/core");
|
|
793
|
+
var import_node_crypto2 = require("crypto");
|
|
794
|
+
|
|
649
795
|
// src/network/esplora.ts
|
|
650
796
|
var import_node_crypto = require("crypto");
|
|
651
|
-
var
|
|
797
|
+
var import_core5 = require("@otskit/core");
|
|
652
798
|
var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
|
|
653
799
|
var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
|
|
654
800
|
var RAW_HEADER_SIZE = 80;
|
|
@@ -739,10 +885,18 @@ var EsploraClient = class {
|
|
|
739
885
|
this.#logger?.debug(`Esplora raw header ${hash}`);
|
|
740
886
|
const response = await this.#networkLayer.request(
|
|
741
887
|
this.#url,
|
|
742
|
-
{ url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "
|
|
888
|
+
{ url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "text/plain" } },
|
|
743
889
|
signal
|
|
744
890
|
);
|
|
745
|
-
const
|
|
891
|
+
const hex = this.#decode(response.data).trim();
|
|
892
|
+
let data;
|
|
893
|
+
try {
|
|
894
|
+
data = hexToBytes(hex);
|
|
895
|
+
} catch (cause) {
|
|
896
|
+
throw new EsploraResponseError("esplora returned a non-hex raw block header", {
|
|
897
|
+
...cause instanceof Error ? { cause } : {}
|
|
898
|
+
});
|
|
899
|
+
}
|
|
746
900
|
if (data.length !== RAW_HEADER_SIZE) {
|
|
747
901
|
throw new EsploraResponseError(
|
|
748
902
|
`raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
|
|
@@ -774,15 +928,105 @@ var EsploraClient = class {
|
|
|
774
928
|
};
|
|
775
929
|
async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
|
|
776
930
|
if (attestation.kind !== "bitcoin" && attestation.kind !== "litecoin") {
|
|
777
|
-
throw new
|
|
931
|
+
throw new import_core5.VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
|
|
778
932
|
}
|
|
779
933
|
const hash = await explorer.blockHash(attestation.height, signal);
|
|
780
934
|
const rawHeader = await explorer.rawBlockHeader(hash, signal);
|
|
781
|
-
return (0,
|
|
935
|
+
return (0, import_core5.verifyAgainstRawHeader)(digest, rawHeader);
|
|
782
936
|
}
|
|
783
937
|
|
|
784
|
-
// src/core/
|
|
785
|
-
var
|
|
938
|
+
// src/core/verify.ts
|
|
939
|
+
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
940
|
+
function timingSafeEq(a, b) {
|
|
941
|
+
if (a.length !== b.length) return false;
|
|
942
|
+
return (0, import_node_crypto2.timingSafeEqual)(a, b);
|
|
943
|
+
}
|
|
944
|
+
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
945
|
+
let detached;
|
|
946
|
+
try {
|
|
947
|
+
detached = import_core6.DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
948
|
+
} catch (cause) {
|
|
949
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
950
|
+
...cause instanceof Error ? { cause } : {}
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
if (detached.fileHashOp instanceof import_core6.OpSHA1 || detached.fileHashOp instanceof import_core6.OpRIPEMD160) {
|
|
954
|
+
return {
|
|
955
|
+
status: "invalid",
|
|
956
|
+
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
if (originalDataHash !== void 0) {
|
|
960
|
+
let expected;
|
|
961
|
+
try {
|
|
962
|
+
expected = validateHash(originalDataHash);
|
|
963
|
+
} catch (err) {
|
|
964
|
+
throw new ValidationError(err instanceof Error ? err.message : "Invalid hash format", {
|
|
965
|
+
...err instanceof Error ? { cause: err } : {}
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
969
|
+
return {
|
|
970
|
+
status: "invalid",
|
|
971
|
+
reason: "File hash does not match proof \u2014 file may have been modified"
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
976
|
+
const seenHeights = /* @__PURE__ */ new Set();
|
|
977
|
+
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
978
|
+
if (attestation.kind !== "bitcoin") return false;
|
|
979
|
+
if (seenHeights.has(attestation.height)) {
|
|
980
|
+
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
seenHeights.add(attestation.height);
|
|
984
|
+
return true;
|
|
985
|
+
});
|
|
986
|
+
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
987
|
+
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
988
|
+
logger?.warn(
|
|
989
|
+
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
if (bitcoinAtts.length === 0) {
|
|
993
|
+
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
994
|
+
return {
|
|
995
|
+
status: "pending",
|
|
996
|
+
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const explorer = new EsploraClient(networkLayer, {
|
|
1000
|
+
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
1001
|
+
...logger !== void 0 ? { logger } : {}
|
|
1002
|
+
});
|
|
1003
|
+
let lastNetworkError;
|
|
1004
|
+
let lastCryptoError;
|
|
1005
|
+
for (const { msg, attestation } of bitcoinAtts) {
|
|
1006
|
+
if (attestation.kind !== "bitcoin") continue;
|
|
1007
|
+
try {
|
|
1008
|
+
const blockTime = await verifyTimestampAttestation(msg, attestation, explorer, signal);
|
|
1009
|
+
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
1010
|
+
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1013
|
+
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
1014
|
+
lastNetworkError = message;
|
|
1015
|
+
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
1016
|
+
} else {
|
|
1017
|
+
lastCryptoError = message;
|
|
1018
|
+
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (lastCryptoError !== void 0) {
|
|
1023
|
+
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
status: "network_error",
|
|
1027
|
+
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
786
1030
|
|
|
787
1031
|
// src/security/ssrf.ts
|
|
788
1032
|
var import_promises = require("dns/promises");
|
|
@@ -801,7 +1045,7 @@ var BLOCKED_CIDRS_V4 = [
|
|
|
801
1045
|
];
|
|
802
1046
|
function ipv4ToUint32(ip) {
|
|
803
1047
|
const parts = ip.split(".");
|
|
804
|
-
return (parseInt(parts[0], 10) << 24 | parseInt(parts[1], 10) << 16 | parseInt(parts[2], 10) << 8 | parseInt(parts[3], 10)) >>> 0;
|
|
1048
|
+
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;
|
|
805
1049
|
}
|
|
806
1050
|
function assertNotPrivateIPv4(ip, calendarUrl) {
|
|
807
1051
|
const n = ipv4ToUint32(ip);
|
|
@@ -880,215 +1124,6 @@ async function assertSafeCalendarUrl(url, options) {
|
|
|
880
1124
|
}
|
|
881
1125
|
}
|
|
882
1126
|
|
|
883
|
-
// src/core/orchestration.ts
|
|
884
|
-
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
885
|
-
function validateHash(hash) {
|
|
886
|
-
if (typeof hash === "string") {
|
|
887
|
-
const hex = hash.trim().toLowerCase();
|
|
888
|
-
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
|
889
|
-
throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
|
|
890
|
-
}
|
|
891
|
-
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
892
|
-
}
|
|
893
|
-
if (hash.length !== 32) {
|
|
894
|
-
throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
|
|
895
|
-
}
|
|
896
|
-
return Uint8Array.from(hash);
|
|
897
|
-
}
|
|
898
|
-
function secureNonce(n) {
|
|
899
|
-
const bytes = new Uint8Array(n);
|
|
900
|
-
if (!globalThis.crypto?.getRandomValues) {
|
|
901
|
-
throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
|
|
902
|
-
}
|
|
903
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
904
|
-
return bytes;
|
|
905
|
-
}
|
|
906
|
-
function timingSafeEq(a, b) {
|
|
907
|
-
if (a.length !== b.length) return false;
|
|
908
|
-
return (0, import_node_crypto2.timingSafeEqual)(a, b);
|
|
909
|
-
}
|
|
910
|
-
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
911
|
-
async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
|
|
912
|
-
if (calendars.length === 0) {
|
|
913
|
-
throw new ValidationError("at least one calendar is required to stamp");
|
|
914
|
-
}
|
|
915
|
-
if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
|
|
916
|
-
throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
|
|
917
|
-
}
|
|
918
|
-
if (minimumSuccessfulSubmissions > calendars.length) {
|
|
919
|
-
throw new ValidationError(
|
|
920
|
-
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
921
|
-
);
|
|
922
|
-
}
|
|
923
|
-
await Promise.all(
|
|
924
|
-
calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
|
|
925
|
-
);
|
|
926
|
-
const digest = validateHash(hash);
|
|
927
|
-
logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
|
|
928
|
-
const detached = import_core4.DetachedTimestampFile.fromHash(new import_core4.OpSHA256(), digest);
|
|
929
|
-
const nonceAppended = detached.timestamp.add(new import_core4.OpAppend(secureNonce(16)));
|
|
930
|
-
const merkleRoot = nonceAppended.add(new import_core4.OpSHA256());
|
|
931
|
-
const merkleTip = (0, import_core4.makeMerkleTree)([merkleRoot]);
|
|
932
|
-
const results = await Promise.allSettled(
|
|
933
|
-
calendars.map((url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal))
|
|
934
|
-
);
|
|
935
|
-
const successful = [];
|
|
936
|
-
const failed = [];
|
|
937
|
-
results.forEach((r, i) => {
|
|
938
|
-
const calendar = calendars[i];
|
|
939
|
-
if (r.status === "fulfilled") {
|
|
940
|
-
merkleTip.merge(r.value);
|
|
941
|
-
successful.push({ calendar });
|
|
942
|
-
logger?.info(`Submitted to ${calendar}`);
|
|
943
|
-
} else {
|
|
944
|
-
const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
945
|
-
failed.push({ calendar, error });
|
|
946
|
-
logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
|
|
947
|
-
}
|
|
948
|
-
});
|
|
949
|
-
if (successful.length < minimumSuccessfulSubmissions) {
|
|
950
|
-
throw new StampError(
|
|
951
|
-
`Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
|
|
952
|
-
successful,
|
|
953
|
-
failed
|
|
954
|
-
);
|
|
955
|
-
}
|
|
956
|
-
return Buffer.from(detached.serializeToBytes());
|
|
957
|
-
}
|
|
958
|
-
async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
|
|
959
|
-
let detached;
|
|
960
|
-
try {
|
|
961
|
-
detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
|
|
962
|
-
} catch (error) {
|
|
963
|
-
throw new ValidationError("Invalid .ots proof format", {
|
|
964
|
-
/* v8 ignore next */
|
|
965
|
-
...error instanceof Error ? { cause: error } : {}
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
if (detached.timestamp.isTimestampComplete()) {
|
|
969
|
-
logger?.info("Proof already complete; nothing to upgrade");
|
|
970
|
-
return Buffer.from(incompleteProof);
|
|
971
|
-
}
|
|
972
|
-
const before = detached.serializeToBytes();
|
|
973
|
-
for (const subStamp of detached.timestamp.directlyVerified()) {
|
|
974
|
-
if (subStamp.isTimestampComplete()) continue;
|
|
975
|
-
for (const att of subStamp.attestations) {
|
|
976
|
-
if (att.kind !== "pending") continue;
|
|
977
|
-
if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
|
|
978
|
-
logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
|
|
979
|
-
continue;
|
|
980
|
-
}
|
|
981
|
-
try {
|
|
982
|
-
const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
|
|
983
|
-
subStamp.getDigest(),
|
|
984
|
-
signal
|
|
985
|
-
);
|
|
986
|
-
subStamp.merge(upgraded);
|
|
987
|
-
} catch (err) {
|
|
988
|
-
if (err instanceof CommitmentNotFoundError) {
|
|
989
|
-
logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
|
|
990
|
-
continue;
|
|
991
|
-
}
|
|
992
|
-
logger?.warn(`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
const after = detached.serializeToBytes();
|
|
997
|
-
if (bytesEqFast(before, after)) {
|
|
998
|
-
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
999
|
-
}
|
|
1000
|
-
return Buffer.from(after);
|
|
1001
|
-
}
|
|
1002
|
-
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
1003
|
-
let detached;
|
|
1004
|
-
try {
|
|
1005
|
-
detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
1006
|
-
} catch (cause) {
|
|
1007
|
-
throw new ValidationError("Invalid .ots proof format", {
|
|
1008
|
-
...cause instanceof Error ? { cause } : {}
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1011
|
-
if (detached.fileHashOp instanceof import_core4.OpSHA1 || detached.fileHashOp instanceof import_core4.OpRIPEMD160) {
|
|
1012
|
-
return {
|
|
1013
|
-
status: "invalid",
|
|
1014
|
-
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
if (originalDataHash !== void 0) {
|
|
1018
|
-
let expected;
|
|
1019
|
-
try {
|
|
1020
|
-
expected = validateHash(originalDataHash);
|
|
1021
|
-
} catch (err) {
|
|
1022
|
-
throw new ValidationError(
|
|
1023
|
-
err instanceof Error ? err.message : "Invalid hash format",
|
|
1024
|
-
{ ...err instanceof Error ? { cause: err } : {} }
|
|
1025
|
-
);
|
|
1026
|
-
}
|
|
1027
|
-
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
1028
|
-
return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
1032
|
-
const seenHeights = /* @__PURE__ */ new Set();
|
|
1033
|
-
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
1034
|
-
if (attestation.kind !== "bitcoin") return false;
|
|
1035
|
-
if (seenHeights.has(attestation.height)) {
|
|
1036
|
-
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
1037
|
-
return false;
|
|
1038
|
-
}
|
|
1039
|
-
seenHeights.add(attestation.height);
|
|
1040
|
-
return true;
|
|
1041
|
-
});
|
|
1042
|
-
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
1043
|
-
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
1044
|
-
logger?.warn(
|
|
1045
|
-
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
1046
|
-
);
|
|
1047
|
-
}
|
|
1048
|
-
if (bitcoinAtts.length === 0) {
|
|
1049
|
-
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
1050
|
-
return {
|
|
1051
|
-
status: "pending",
|
|
1052
|
-
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
const explorer = new EsploraClient(networkLayer, {
|
|
1056
|
-
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
1057
|
-
...logger !== void 0 ? { logger } : {}
|
|
1058
|
-
});
|
|
1059
|
-
let lastNetworkError;
|
|
1060
|
-
let lastCryptoError;
|
|
1061
|
-
for (const { msg, attestation } of bitcoinAtts) {
|
|
1062
|
-
if (attestation.kind !== "bitcoin") continue;
|
|
1063
|
-
try {
|
|
1064
|
-
const blockTime = await verifyTimestampAttestation(
|
|
1065
|
-
Uint8Array.from(msg).reverse(),
|
|
1066
|
-
attestation,
|
|
1067
|
-
explorer,
|
|
1068
|
-
signal
|
|
1069
|
-
);
|
|
1070
|
-
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
1071
|
-
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
1072
|
-
} catch (err) {
|
|
1073
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1074
|
-
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
1075
|
-
lastNetworkError = message;
|
|
1076
|
-
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
1077
|
-
} else {
|
|
1078
|
-
lastCryptoError = message;
|
|
1079
|
-
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
if (lastCryptoError !== void 0) {
|
|
1084
|
-
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
1085
|
-
}
|
|
1086
|
-
return {
|
|
1087
|
-
status: "network_error",
|
|
1088
|
-
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
1089
|
-
};
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
1127
|
// src/client.ts
|
|
1093
1128
|
var OpenTimestampsClient = class {
|
|
1094
1129
|
calendars;
|
|
@@ -1100,7 +1135,7 @@ var OpenTimestampsClient = class {
|
|
|
1100
1135
|
esploraUrl;
|
|
1101
1136
|
/**
|
|
1102
1137
|
* Create a new OpenTimestamps client
|
|
1103
|
-
*
|
|
1138
|
+
*
|
|
1104
1139
|
* @param options Client configuration options
|
|
1105
1140
|
*/
|
|
1106
1141
|
constructor(options = {}) {
|
|
@@ -1146,15 +1181,15 @@ var OpenTimestampsClient = class {
|
|
|
1146
1181
|
}
|
|
1147
1182
|
/**
|
|
1148
1183
|
* Create a timestamp by submitting a hash to calendar servers
|
|
1149
|
-
*
|
|
1184
|
+
*
|
|
1150
1185
|
* @param hash SHA-256 hash of the data to timestamp (as Buffer or hex string)
|
|
1151
1186
|
* @param options Operation-specific options
|
|
1152
1187
|
* @returns Initial .ots proof with pending attestations
|
|
1153
|
-
*
|
|
1188
|
+
*
|
|
1154
1189
|
* @throws {ValidationError} If the hash is invalid
|
|
1155
1190
|
* @throws {StampError} If submission fails to all calendars
|
|
1156
1191
|
* @throws {NetworkError} If network errors occur
|
|
1157
|
-
*
|
|
1192
|
+
*
|
|
1158
1193
|
* @example
|
|
1159
1194
|
* ```typescript
|
|
1160
1195
|
* const hash = crypto.createHash('sha256').update('my data').digest()
|
|
@@ -1164,34 +1199,36 @@ var OpenTimestampsClient = class {
|
|
|
1164
1199
|
*/
|
|
1165
1200
|
async stamp(hash, options) {
|
|
1166
1201
|
const signal = options?.signal || this.globalSignal;
|
|
1167
|
-
|
|
1202
|
+
const allowPrivate = this.allowPrivateCalendars;
|
|
1203
|
+
const bytes = await orchestrateStamp(
|
|
1168
1204
|
hash,
|
|
1169
1205
|
this.calendars,
|
|
1170
1206
|
this.networkLayer,
|
|
1207
|
+
(url) => assertSafeCalendarUrl(url, { allowPrivate }),
|
|
1171
1208
|
this.logger,
|
|
1172
1209
|
signal,
|
|
1173
|
-
this.minimumSuccessfulSubmissions
|
|
1174
|
-
this.allowPrivateCalendars
|
|
1210
|
+
this.minimumSuccessfulSubmissions
|
|
1175
1211
|
);
|
|
1212
|
+
return Buffer.from(bytes);
|
|
1176
1213
|
}
|
|
1177
1214
|
/**
|
|
1178
1215
|
* Upgrade an incomplete timestamp proof by querying calendars for Bitcoin confirmation
|
|
1179
|
-
*
|
|
1216
|
+
*
|
|
1180
1217
|
* @param incompleteProof The initial .ots proof returned by stamp()
|
|
1181
1218
|
* @param options Operation-specific options
|
|
1182
1219
|
* @returns Upgraded .ots proof with Bitcoin attestation (if available)
|
|
1183
|
-
*
|
|
1220
|
+
*
|
|
1184
1221
|
* @throws {ValidationError} If the proof format is invalid
|
|
1185
1222
|
* @throws {UpgradeError} If no calendar has confirmed the timestamp yet
|
|
1186
1223
|
* @throws {NetworkError} If network errors occur
|
|
1187
|
-
*
|
|
1224
|
+
*
|
|
1188
1225
|
* @example
|
|
1189
1226
|
* ```typescript
|
|
1190
1227
|
* // Proof already has pending attestations from stamp()
|
|
1191
1228
|
* const upgradedProof = await client.upgrade(incompleteProof)
|
|
1192
|
-
*
|
|
1229
|
+
*
|
|
1193
1230
|
* // If upgrade throws UpgradeError, Bitcoin hasn't confirmed yet
|
|
1194
|
-
* // Retry later (typically
|
|
1231
|
+
* // Retry later (typically ~60 minutes after stamp)
|
|
1195
1232
|
* ```
|
|
1196
1233
|
*/
|
|
1197
1234
|
async upgrade(incompleteProof, options) {
|
|
@@ -1206,15 +1243,15 @@ var OpenTimestampsClient = class {
|
|
|
1206
1243
|
}
|
|
1207
1244
|
/**
|
|
1208
1245
|
* Verify a complete timestamp proof against the Bitcoin blockchain
|
|
1209
|
-
*
|
|
1246
|
+
*
|
|
1210
1247
|
* @param proof The complete .ots proof with Bitcoin attestation
|
|
1211
1248
|
* @param originalDataHash Optional: the original data hash to verify against
|
|
1212
1249
|
* @returns Verification result with block details
|
|
1213
|
-
*
|
|
1250
|
+
*
|
|
1214
1251
|
* @example
|
|
1215
1252
|
* ```typescript
|
|
1216
1253
|
* const result = await client.verify(completeProof, originalHash)
|
|
1217
|
-
*
|
|
1254
|
+
*
|
|
1218
1255
|
* if (result.valid) {
|
|
1219
1256
|
* console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
|
|
1220
1257
|
* console.log(`Block timestamp: ${new Date(result.timestamp! * 1000)}`)
|
|
@@ -1224,12 +1261,19 @@ var OpenTimestampsClient = class {
|
|
|
1224
1261
|
* ```
|
|
1225
1262
|
*/
|
|
1226
1263
|
async verify(proof, originalDataHash) {
|
|
1227
|
-
return orchestrateVerify(
|
|
1264
|
+
return orchestrateVerify(
|
|
1265
|
+
proof,
|
|
1266
|
+
this.networkLayer,
|
|
1267
|
+
originalDataHash,
|
|
1268
|
+
this.logger,
|
|
1269
|
+
this.globalSignal,
|
|
1270
|
+
this.esploraUrl
|
|
1271
|
+
);
|
|
1228
1272
|
}
|
|
1229
1273
|
/**
|
|
1230
1274
|
* Get the current state of the circuit breaker for a calendar
|
|
1231
1275
|
* Useful for monitoring and debugging
|
|
1232
|
-
*
|
|
1276
|
+
*
|
|
1233
1277
|
* @param calendarUrl The calendar URL to check
|
|
1234
1278
|
* @returns Circuit state: 'CLOSED', 'OPEN', or 'HALF_OPEN' (undefined if not yet initialized)
|
|
1235
1279
|
*/
|
|
@@ -1239,7 +1283,7 @@ var OpenTimestampsClient = class {
|
|
|
1239
1283
|
/**
|
|
1240
1284
|
* Reset the circuit breaker for a specific calendar
|
|
1241
1285
|
* Use this to manually recover a calendar that has been marked as failing
|
|
1242
|
-
*
|
|
1286
|
+
*
|
|
1243
1287
|
* @param calendarUrl The calendar URL to reset
|
|
1244
1288
|
*/
|
|
1245
1289
|
resetCircuit(calendarUrl) {
|
|
@@ -1257,19 +1301,19 @@ var OpenTimestampsClient = class {
|
|
|
1257
1301
|
};
|
|
1258
1302
|
|
|
1259
1303
|
// src/index.ts
|
|
1260
|
-
var
|
|
1261
|
-
var
|
|
1304
|
+
var import_core7 = require("@otskit/core");
|
|
1305
|
+
var import_core8 = require("@otskit/core");
|
|
1262
1306
|
|
|
1263
1307
|
// src/utils/hash.ts
|
|
1264
|
-
var
|
|
1265
|
-
var
|
|
1308
|
+
var import_node_crypto3 = require("crypto");
|
|
1309
|
+
var import_node_fs = require("fs");
|
|
1266
1310
|
function hashBuffer(data) {
|
|
1267
|
-
return (0,
|
|
1311
|
+
return (0, import_node_crypto3.createHash)("sha256").update(data).digest();
|
|
1268
1312
|
}
|
|
1269
1313
|
function hashFile(path) {
|
|
1270
1314
|
return new Promise((resolve, reject) => {
|
|
1271
|
-
const hash = (0,
|
|
1272
|
-
(0,
|
|
1315
|
+
const hash = (0, import_node_crypto3.createHash)("sha256");
|
|
1316
|
+
(0, import_node_fs.createReadStream)(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
|
|
1273
1317
|
});
|
|
1274
1318
|
}
|
|
1275
1319
|
// Annotate the CommonJS export names for ESM import in node:
|