@otskit/client 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/browser.d.ts +36 -0
- package/dist/browser.js +742 -0
- package/dist/index.cjs +292 -251
- package/dist/index.d.cts +13 -13
- package/dist/index.d.ts +13 -13
- package/dist/index.js +281 -247
- 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;
|
|
@@ -774,15 +920,110 @@ var EsploraClient = class {
|
|
|
774
920
|
};
|
|
775
921
|
async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
|
|
776
922
|
if (attestation.kind !== "bitcoin" && attestation.kind !== "litecoin") {
|
|
777
|
-
throw new
|
|
923
|
+
throw new import_core5.VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
|
|
778
924
|
}
|
|
779
925
|
const hash = await explorer.blockHash(attestation.height, signal);
|
|
780
926
|
const rawHeader = await explorer.rawBlockHeader(hash, signal);
|
|
781
|
-
return (0,
|
|
927
|
+
return (0, import_core5.verifyAgainstRawHeader)(digest, rawHeader);
|
|
782
928
|
}
|
|
783
929
|
|
|
784
|
-
// src/core/
|
|
785
|
-
var
|
|
930
|
+
// src/core/verify.ts
|
|
931
|
+
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
932
|
+
function timingSafeEq(a, b) {
|
|
933
|
+
if (a.length !== b.length) return false;
|
|
934
|
+
return (0, import_node_crypto2.timingSafeEqual)(a, b);
|
|
935
|
+
}
|
|
936
|
+
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
937
|
+
let detached;
|
|
938
|
+
try {
|
|
939
|
+
detached = import_core6.DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
940
|
+
} catch (cause) {
|
|
941
|
+
throw new ValidationError("Invalid .ots proof format", {
|
|
942
|
+
...cause instanceof Error ? { cause } : {}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
if (detached.fileHashOp instanceof import_core6.OpSHA1 || detached.fileHashOp instanceof import_core6.OpRIPEMD160) {
|
|
946
|
+
return {
|
|
947
|
+
status: "invalid",
|
|
948
|
+
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
if (originalDataHash !== void 0) {
|
|
952
|
+
let expected;
|
|
953
|
+
try {
|
|
954
|
+
expected = validateHash(originalDataHash);
|
|
955
|
+
} catch (err) {
|
|
956
|
+
throw new ValidationError(err instanceof Error ? err.message : "Invalid hash format", {
|
|
957
|
+
...err instanceof Error ? { cause: err } : {}
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
961
|
+
return {
|
|
962
|
+
status: "invalid",
|
|
963
|
+
reason: "File hash does not match proof \u2014 file may have been modified"
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
968
|
+
const seenHeights = /* @__PURE__ */ new Set();
|
|
969
|
+
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
970
|
+
if (attestation.kind !== "bitcoin") return false;
|
|
971
|
+
if (seenHeights.has(attestation.height)) {
|
|
972
|
+
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
seenHeights.add(attestation.height);
|
|
976
|
+
return true;
|
|
977
|
+
});
|
|
978
|
+
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
979
|
+
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
980
|
+
logger?.warn(
|
|
981
|
+
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
if (bitcoinAtts.length === 0) {
|
|
985
|
+
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
986
|
+
return {
|
|
987
|
+
status: "pending",
|
|
988
|
+
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const explorer = new EsploraClient(networkLayer, {
|
|
992
|
+
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
993
|
+
...logger !== void 0 ? { logger } : {}
|
|
994
|
+
});
|
|
995
|
+
let lastNetworkError;
|
|
996
|
+
let lastCryptoError;
|
|
997
|
+
for (const { msg, attestation } of bitcoinAtts) {
|
|
998
|
+
if (attestation.kind !== "bitcoin") continue;
|
|
999
|
+
try {
|
|
1000
|
+
const blockTime = await verifyTimestampAttestation(
|
|
1001
|
+
Uint8Array.from(msg).reverse(),
|
|
1002
|
+
attestation,
|
|
1003
|
+
explorer,
|
|
1004
|
+
signal
|
|
1005
|
+
);
|
|
1006
|
+
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
1007
|
+
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1010
|
+
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
1011
|
+
lastNetworkError = message;
|
|
1012
|
+
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
1013
|
+
} else {
|
|
1014
|
+
lastCryptoError = message;
|
|
1015
|
+
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (lastCryptoError !== void 0) {
|
|
1020
|
+
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
status: "network_error",
|
|
1024
|
+
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
786
1027
|
|
|
787
1028
|
// src/security/ssrf.ts
|
|
788
1029
|
var import_promises = require("dns/promises");
|
|
@@ -801,7 +1042,7 @@ var BLOCKED_CIDRS_V4 = [
|
|
|
801
1042
|
];
|
|
802
1043
|
function ipv4ToUint32(ip) {
|
|
803
1044
|
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;
|
|
1045
|
+
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
1046
|
}
|
|
806
1047
|
function assertNotPrivateIPv4(ip, calendarUrl) {
|
|
807
1048
|
const n = ipv4ToUint32(ip);
|
|
@@ -880,215 +1121,6 @@ async function assertSafeCalendarUrl(url, options) {
|
|
|
880
1121
|
}
|
|
881
1122
|
}
|
|
882
1123
|
|
|
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
1124
|
// src/client.ts
|
|
1093
1125
|
var OpenTimestampsClient = class {
|
|
1094
1126
|
calendars;
|
|
@@ -1100,7 +1132,7 @@ var OpenTimestampsClient = class {
|
|
|
1100
1132
|
esploraUrl;
|
|
1101
1133
|
/**
|
|
1102
1134
|
* Create a new OpenTimestamps client
|
|
1103
|
-
*
|
|
1135
|
+
*
|
|
1104
1136
|
* @param options Client configuration options
|
|
1105
1137
|
*/
|
|
1106
1138
|
constructor(options = {}) {
|
|
@@ -1146,15 +1178,15 @@ var OpenTimestampsClient = class {
|
|
|
1146
1178
|
}
|
|
1147
1179
|
/**
|
|
1148
1180
|
* Create a timestamp by submitting a hash to calendar servers
|
|
1149
|
-
*
|
|
1181
|
+
*
|
|
1150
1182
|
* @param hash SHA-256 hash of the data to timestamp (as Buffer or hex string)
|
|
1151
1183
|
* @param options Operation-specific options
|
|
1152
1184
|
* @returns Initial .ots proof with pending attestations
|
|
1153
|
-
*
|
|
1185
|
+
*
|
|
1154
1186
|
* @throws {ValidationError} If the hash is invalid
|
|
1155
1187
|
* @throws {StampError} If submission fails to all calendars
|
|
1156
1188
|
* @throws {NetworkError} If network errors occur
|
|
1157
|
-
*
|
|
1189
|
+
*
|
|
1158
1190
|
* @example
|
|
1159
1191
|
* ```typescript
|
|
1160
1192
|
* const hash = crypto.createHash('sha256').update('my data').digest()
|
|
@@ -1164,34 +1196,36 @@ var OpenTimestampsClient = class {
|
|
|
1164
1196
|
*/
|
|
1165
1197
|
async stamp(hash, options) {
|
|
1166
1198
|
const signal = options?.signal || this.globalSignal;
|
|
1167
|
-
|
|
1199
|
+
const allowPrivate = this.allowPrivateCalendars;
|
|
1200
|
+
const bytes = await orchestrateStamp(
|
|
1168
1201
|
hash,
|
|
1169
1202
|
this.calendars,
|
|
1170
1203
|
this.networkLayer,
|
|
1204
|
+
(url) => assertSafeCalendarUrl(url, { allowPrivate }),
|
|
1171
1205
|
this.logger,
|
|
1172
1206
|
signal,
|
|
1173
|
-
this.minimumSuccessfulSubmissions
|
|
1174
|
-
this.allowPrivateCalendars
|
|
1207
|
+
this.minimumSuccessfulSubmissions
|
|
1175
1208
|
);
|
|
1209
|
+
return Buffer.from(bytes);
|
|
1176
1210
|
}
|
|
1177
1211
|
/**
|
|
1178
1212
|
* Upgrade an incomplete timestamp proof by querying calendars for Bitcoin confirmation
|
|
1179
|
-
*
|
|
1213
|
+
*
|
|
1180
1214
|
* @param incompleteProof The initial .ots proof returned by stamp()
|
|
1181
1215
|
* @param options Operation-specific options
|
|
1182
1216
|
* @returns Upgraded .ots proof with Bitcoin attestation (if available)
|
|
1183
|
-
*
|
|
1217
|
+
*
|
|
1184
1218
|
* @throws {ValidationError} If the proof format is invalid
|
|
1185
1219
|
* @throws {UpgradeError} If no calendar has confirmed the timestamp yet
|
|
1186
1220
|
* @throws {NetworkError} If network errors occur
|
|
1187
|
-
*
|
|
1221
|
+
*
|
|
1188
1222
|
* @example
|
|
1189
1223
|
* ```typescript
|
|
1190
1224
|
* // Proof already has pending attestations from stamp()
|
|
1191
1225
|
* const upgradedProof = await client.upgrade(incompleteProof)
|
|
1192
|
-
*
|
|
1226
|
+
*
|
|
1193
1227
|
* // If upgrade throws UpgradeError, Bitcoin hasn't confirmed yet
|
|
1194
|
-
* // Retry later (typically
|
|
1228
|
+
* // Retry later (typically ~60 minutes after stamp)
|
|
1195
1229
|
* ```
|
|
1196
1230
|
*/
|
|
1197
1231
|
async upgrade(incompleteProof, options) {
|
|
@@ -1206,15 +1240,15 @@ var OpenTimestampsClient = class {
|
|
|
1206
1240
|
}
|
|
1207
1241
|
/**
|
|
1208
1242
|
* Verify a complete timestamp proof against the Bitcoin blockchain
|
|
1209
|
-
*
|
|
1243
|
+
*
|
|
1210
1244
|
* @param proof The complete .ots proof with Bitcoin attestation
|
|
1211
1245
|
* @param originalDataHash Optional: the original data hash to verify against
|
|
1212
1246
|
* @returns Verification result with block details
|
|
1213
|
-
*
|
|
1247
|
+
*
|
|
1214
1248
|
* @example
|
|
1215
1249
|
* ```typescript
|
|
1216
1250
|
* const result = await client.verify(completeProof, originalHash)
|
|
1217
|
-
*
|
|
1251
|
+
*
|
|
1218
1252
|
* if (result.valid) {
|
|
1219
1253
|
* console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
|
|
1220
1254
|
* console.log(`Block timestamp: ${new Date(result.timestamp! * 1000)}`)
|
|
@@ -1224,12 +1258,19 @@ var OpenTimestampsClient = class {
|
|
|
1224
1258
|
* ```
|
|
1225
1259
|
*/
|
|
1226
1260
|
async verify(proof, originalDataHash) {
|
|
1227
|
-
return orchestrateVerify(
|
|
1261
|
+
return orchestrateVerify(
|
|
1262
|
+
proof,
|
|
1263
|
+
this.networkLayer,
|
|
1264
|
+
originalDataHash,
|
|
1265
|
+
this.logger,
|
|
1266
|
+
this.globalSignal,
|
|
1267
|
+
this.esploraUrl
|
|
1268
|
+
);
|
|
1228
1269
|
}
|
|
1229
1270
|
/**
|
|
1230
1271
|
* Get the current state of the circuit breaker for a calendar
|
|
1231
1272
|
* Useful for monitoring and debugging
|
|
1232
|
-
*
|
|
1273
|
+
*
|
|
1233
1274
|
* @param calendarUrl The calendar URL to check
|
|
1234
1275
|
* @returns Circuit state: 'CLOSED', 'OPEN', or 'HALF_OPEN' (undefined if not yet initialized)
|
|
1235
1276
|
*/
|
|
@@ -1239,7 +1280,7 @@ var OpenTimestampsClient = class {
|
|
|
1239
1280
|
/**
|
|
1240
1281
|
* Reset the circuit breaker for a specific calendar
|
|
1241
1282
|
* Use this to manually recover a calendar that has been marked as failing
|
|
1242
|
-
*
|
|
1283
|
+
*
|
|
1243
1284
|
* @param calendarUrl The calendar URL to reset
|
|
1244
1285
|
*/
|
|
1245
1286
|
resetCircuit(calendarUrl) {
|
|
@@ -1257,19 +1298,19 @@ var OpenTimestampsClient = class {
|
|
|
1257
1298
|
};
|
|
1258
1299
|
|
|
1259
1300
|
// src/index.ts
|
|
1260
|
-
var
|
|
1261
|
-
var
|
|
1301
|
+
var import_core7 = require("@otskit/core");
|
|
1302
|
+
var import_core8 = require("@otskit/core");
|
|
1262
1303
|
|
|
1263
1304
|
// src/utils/hash.ts
|
|
1264
|
-
var
|
|
1265
|
-
var
|
|
1305
|
+
var import_node_crypto3 = require("crypto");
|
|
1306
|
+
var import_node_fs = require("fs");
|
|
1266
1307
|
function hashBuffer(data) {
|
|
1267
|
-
return (0,
|
|
1308
|
+
return (0, import_node_crypto3.createHash)("sha256").update(data).digest();
|
|
1268
1309
|
}
|
|
1269
1310
|
function hashFile(path) {
|
|
1270
1311
|
return new Promise((resolve, reject) => {
|
|
1271
|
-
const hash = (0,
|
|
1272
|
-
(0,
|
|
1312
|
+
const hash = (0, import_node_crypto3.createHash)("sha256");
|
|
1313
|
+
(0, import_node_fs.createReadStream)(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
|
|
1273
1314
|
});
|
|
1274
1315
|
}
|
|
1275
1316
|
// Annotate the CommonJS export names for ESM import in node:
|