@intyga/verify 0.0.0-bootstrap.0 → 1.1.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.
@@ -0,0 +1,195 @@
1
+ import crypto from "node:crypto";
2
+ import { type Rfc3161Trust } from "./ledger-rfc3161.js";
3
+ export declare const ANCHOR_TAG = 3;
4
+ /** RSA-PSS anchors (DEWP §5.2): SHA-256, MGF1-SHA-256, salt length = hash length. */
5
+ export declare const RSA_PSS_SALT_LENGTH = 32;
6
+ /** Smallest RSA modulus an RSA-PSS anchor key may have (DEWP §5.2). */
7
+ export declare const RSA_MIN_MODULUS_BITS = 2048;
8
+ /**
9
+ * The signed part of an anchor (DEWP §5.2): the daily root, the checkpoint's claimed time, the issuer
10
+ * and algorithm, AND the checkpoint's position — its global seq range and its §5.4 chain hash.
11
+ *
12
+ * The position fields are what make an anchor evidence about ONE checkpoint rather than about a root
13
+ * string. Without them a witness attests only "someone showed me this 64-hex value at time T", and a
14
+ * party able to rewrite the log could recompute a root and have it witnessed at any later time with no
15
+ * binding to where in the log it claims to sit. With them, the chain hash commits to every earlier
16
+ * root, so a later anchor also re-attests the history before it.
17
+ */
18
+ export interface AnchorInput {
19
+ dailyRoot: string;
20
+ timestamp: string;
21
+ issuer: string;
22
+ algorithm: "ES256" | "Ed25519" | "RSA-PSS";
23
+ /** First global `seq` the checkpoint commits (stringified integer). */
24
+ seqStart: string;
25
+ /** Last global `seq` the checkpoint commits (stringified integer). */
26
+ seqEnd: string;
27
+ /** The checkpoint's §5.4 continuity chain hash (64-char lowercase hex). */
28
+ chainHash: string;
29
+ }
30
+ /** A signed commitment to a daily checkpoint root, published to an independent public anchor. */
31
+ export interface SignedAnchor extends AnchorInput {
32
+ keyId: string;
33
+ signature: string;
34
+ /**
35
+ * SELF | REKOR | RFC3161 | WEBHOOK. Decides WHICH verification applies; absent ⇒ treated as
36
+ * DEWP-signed (SELF). WEBHOOK and unrecognized kinds never count toward quorum (DEWP §5.2.1) —
37
+ * this verifier has no evidence verifier for them.
38
+ */
39
+ kind?: string;
40
+ /** The external log's own attestation, base64 (Rekor: entry + SET + inclusion proof). */
41
+ evidence?: string | null;
42
+ }
43
+ /**
44
+ * The canonical anchor preimage: RFC 8785 JCS of the 7-element array
45
+ * [dailyRoot, timestamp, issuer, algorithm, seqStart, seqEnd, chainHash]. Every element is a string,
46
+ * so JCS is exactly `JSON.stringify` with no insignificant space.
47
+ */
48
+ export declare function anchorPreimage(a: AnchorInput): string;
49
+ /** The raw 32-byte anchor digest: SHA-256(0x03 || UTF8(anchorPreimage)). This is what gets signed. */
50
+ export declare function anchorDigest(a: AnchorInput): Buffer;
51
+ /** Hex form of the anchor digest (for display / vectors). */
52
+ export declare function anchorDigestHex(a: AnchorInput): string;
53
+ /**
54
+ * Sign an anchor digest with a producer key. Helper for anchor providers and tests — a relying party
55
+ * only needs `verifyAnchorSignature`. The message is the RAW 32-byte digest (never its hex string):
56
+ * ES256/RSA-PSS then apply their own SHA-256, Ed25519 signs the bytes directly.
57
+ */
58
+ export declare function signAnchor(a: AnchorInput, privateKey: crypto.KeyObject): string;
59
+ /**
60
+ * Verify an anchor's signature over its digest against a resolved public key. Pins the key type to the
61
+ * declared algorithm so an anchor labelled ES256 can't be verified under some other scheme.
62
+ */
63
+ export declare function verifyAnchorSignature(anchor: SignedAnchor, publicKey: crypto.KeyObject): boolean;
64
+ /**
65
+ * The §5.2 anchor signature algorithms. The label is part of the signed preimage, so an anchor naming
66
+ * anything else (RSA-OAEP, DSA, an empty string) is not a §5.2 anchor — and must not be verified under
67
+ * whatever scheme a fallthrough happens to pick. External anchors (Rekor, RFC 3161) carry the
68
+ * producer's own anchor algorithm, so the same registry applies to every kind.
69
+ */
70
+ export declare const ANCHOR_ALGORITHMS: readonly string[];
71
+ /**
72
+ * An anchor whose seven signed fields have the shapes §5.2 requires. Anything else is refused before a
73
+ * digest is computed: `JSON.stringify` would happily turn a missing position field into `null`, and a
74
+ * digest over `[…, null, null, null]` is not the preimage any conformant producer signed.
75
+ */
76
+ export declare function isWellFormedAnchor(a: AnchorInput): boolean;
77
+ /**
78
+ * Milliseconds since the epoch for a DEWP §4.3 timestamp (`YYYY-MM-DDTHH:mm:ss.sssZ`, exactly), or null.
79
+ * Strict on purpose: every port must read the same instant from the same bytes, and a lenient parser in
80
+ * one of them (offsets, missing milliseconds, 30 February rolled into March) is how two verifiers come
81
+ * to disagree about whether an anchor was on time.
82
+ */
83
+ export declare function parseAnchorTimestampMs(ts: string): number | null;
84
+ /**
85
+ * Default bound on how long after a checkpoint's claimed time an external witness may have first seen
86
+ * its anchor (DEWP §5.3). An anchor obtained later than this proves only that the root existed when it
87
+ * was finally witnessed — which says nothing about whether it existed at the time the checkpoint
88
+ * claims, and is exactly what a party rewriting old history and re-anchoring it today produces.
89
+ */
90
+ export declare const DEFAULT_MAX_ANCHOR_LAG_SECONDS = 86400;
91
+ /**
92
+ * Tolerated clock disagreement in the other direction: a witness time EARLIER than the checkpoint's
93
+ * claimed time. The producer chose that time before submitting, so a witness seeing the anchor before
94
+ * it claims to exist means a producer clock ahead of the witness's — bounded, never unbounded.
95
+ */
96
+ export declare const ANCHOR_CLOCK_SKEW_SECONDS = 300;
97
+ /**
98
+ * How an anchor of a given kind is verified. SELF/DEWP anchors carry a §5.2 signature; external ones
99
+ * (Rekor, TSA) carry the third party's own attestation in `evidence` instead, and their `signature`
100
+ * is empty by construction.
101
+ */
102
+ export interface ExternalAnchorKeys {
103
+ /** Rekor log public key (PEM or base64 SPKI), pinned by the CALLER — Sigstore publishes it via TUF. */
104
+ rekor?: string;
105
+ /** Issuer identity assigned to the pinned log key. Required for policies with multiple issuers. */
106
+ rekorIssuer?: string;
107
+ /**
108
+ * The PRODUCER's Rekor submission key(s) (PEM or base64 SPKI) — normally its published anchor key.
109
+ * When set, a Rekor entry counts only if its hashedrekord was submitted under one of these keys and
110
+ * that key's signature over the anchor digest verifies. Unset, the log accepts a submission under
111
+ * ANY key, so anyone who can compute the digest can have it logged.
112
+ */
113
+ rekorSubmitterKeys?: string[];
114
+ /** Optional OpenSSL 3 TSA verification, keyed by caller-trusted issuer. */
115
+ rfc3161?: Record<string, Rfc3161Trust>;
116
+ }
117
+ /** The verifier's anchor-trust policy (DEWP §5.3). */
118
+ export interface AnchorPolicy {
119
+ /** Minimum count of distinct trusted issuers that must sign the SAME root. SHOULD be ≥ 2. */
120
+ requiredAnchors: number;
121
+ /** Allowed issuer identifiers; anchors from other issuers do not count toward quorum. */
122
+ trustedIssuers: string[];
123
+ /** ALL_MUST_AGREE = every present trusted anchor must sign the same root; N_OF_M = at least N. */
124
+ quorum: "ALL_MUST_AGREE" | "N_OF_M";
125
+ /**
126
+ * Maximum seconds between the checkpoint's claimed time and an EXTERNAL witness's authenticated
127
+ * time (Rekor integratedTime, TSA genTime). Absent ⇒ DEFAULT_MAX_ANCHOR_LAG_SECONDS. A witness
128
+ * outside [-ANCHOR_CLOCK_SKEW_SECONDS, this] does not count toward quorum.
129
+ */
130
+ maxAnchorLagSeconds?: number;
131
+ }
132
+ /** Resolve the verifying public key for an anchor (by issuer/keyId). Returns null when unknown. */
133
+ export type AnchorKeyResolver = (anchor: SignedAnchor) => crypto.KeyObject | null;
134
+ /**
135
+ * The checkpoint an anchor is being counted FOR. Every field the caller knows is compared with the
136
+ * anchor's signed fields; an anchor that names another position, chain or time is not evidence for
137
+ * this checkpoint, whatever root it carries.
138
+ */
139
+ export interface ExpectedCheckpoint {
140
+ seqStart?: string | null;
141
+ seqEnd?: string | null;
142
+ chainHash?: string | null;
143
+ /**
144
+ * The checkpoint's own claimed time. MUST equal the anchor's signed `timestamp`.
145
+ *
146
+ * When an expected checkpoint is supplied WITHOUT this, an external witness (Rekor, RFC 3161) does
147
+ * not count: the §5.3 time bound would be measured against the anchor's own `timestamp`, which the
148
+ * producer chose, so a months-late witness could simply be re-dated to look prompt.
149
+ */
150
+ anchoredAt?: string | null;
151
+ }
152
+ export interface AnchorQuorumResult {
153
+ ok: boolean;
154
+ /** Distinct trusted issuers whose signature over `dailyRoot` verified. */
155
+ verifiedIssuers: string[];
156
+ /** True if two trusted issuers signed DIFFERENT roots for this checkpoint (fatal → not ok). */
157
+ divergence: boolean;
158
+ reason?: string;
159
+ /** Why evidence that was present did not count (unverifiable TSA, outside the time bound, wrong position). */
160
+ note?: string;
161
+ /**
162
+ * Authenticated external witness time per issuer, Unix seconds — Rekor `integratedTime`, TSA
163
+ * `genTime`, the earliest when an issuer has several. Reported for every external anchor whose
164
+ * evidence verified, INCLUDING ones refused by the time bound, so a relying party can see when each
165
+ * witness actually saw the checkpoint rather than only whether it counted.
166
+ */
167
+ witnessTimes: Record<string, number>;
168
+ }
169
+ /**
170
+ * Evaluate an anchor quorum for one `dailyRoot`. `anchorVerified` in a bundle is true iff this returns
171
+ * ok. Divergence (a trusted issuer signing a different root) is fatal, never a silent pick.
172
+ *
173
+ * An anchor counts only when (1) its issuer is trusted, (2) its evidence verifies under trust the
174
+ * caller supplied, (3) its signed position matches `opts.checkpoint` where the caller knows it, and
175
+ * (4) for an external witness, the witness time falls within the policy's time bound of the
176
+ * checkpoint's claimed time.
177
+ */
178
+ export declare function verifyAnchorQuorum(anchors: SignedAnchor[], dailyRoot: string, policy: AnchorPolicy, resolveKey: AnchorKeyResolver, opts?: {
179
+ /**
180
+ * Anchors that may be used to declare DIVERGENCE. Defaults to none.
181
+ *
182
+ * Divergence is a fatal, tamper-shaped verdict, so what feeds it matters. An anchor from another
183
+ * checkpoint is entirely normal — every anchor an issuer has ever published over some other day
184
+ * looks like one — so treating any non-matching anchor as divergence would let anyone who can add
185
+ * an anchor to a bundle attach a GENUINE, publicly available anchor from another day and force an
186
+ * INVALID verdict with a tamper alarm. So the caller must say which anchors it fetched itself, per
187
+ * checkpoint, from each issuer. One whose signed seq range names a different checkpoint than
188
+ * `checkpoint` is not divergence evidence either.
189
+ */
190
+ divergenceAnchors?: SignedAnchor[];
191
+ /** Pinned public keys for external logs, so their attestations can actually be checked. */
192
+ externalKeys?: ExternalAnchorKeys;
193
+ /** The checkpoint the anchors are offered for; see ExpectedCheckpoint. */
194
+ checkpoint?: ExpectedCheckpoint;
195
+ }): AnchorQuorumResult;
@@ -0,0 +1,314 @@
1
+ import crypto from "node:crypto";
2
+ import { verifyRfc3161Anchor } from "./ledger-rfc3161.js";
3
+ import { parseRekorEvidence, verifyRekorAnchor } from "./ledger-rekor.js";
4
+ // DEWP signed anchor objects (docs/DEWP.md §5.2/§5.3). A Daily Checkpoint Root becomes trustworthy
5
+ // only when independent external parties SIGN it. This module computes the domain-separated anchor
6
+ // digest (0x03 tag), verifies an anchor's signature, and evaluates a multi-anchor QUORUM so a single
7
+ // compromised anchor provider cannot forge non-repudiation. Zero deps beyond node:crypto.
8
+ export const ANCHOR_TAG = 0x03;
9
+ /** RSA-PSS anchors (DEWP §5.2): SHA-256, MGF1-SHA-256, salt length = hash length. */
10
+ export const RSA_PSS_SALT_LENGTH = 32;
11
+ /** Smallest RSA modulus an RSA-PSS anchor key may have (DEWP §5.2). */
12
+ export const RSA_MIN_MODULUS_BITS = 2048;
13
+ /**
14
+ * The canonical anchor preimage: RFC 8785 JCS of the 7-element array
15
+ * [dailyRoot, timestamp, issuer, algorithm, seqStart, seqEnd, chainHash]. Every element is a string,
16
+ * so JCS is exactly `JSON.stringify` with no insignificant space.
17
+ */
18
+ export function anchorPreimage(a) {
19
+ return JSON.stringify([a.dailyRoot, a.timestamp, a.issuer, a.algorithm, a.seqStart, a.seqEnd, a.chainHash]);
20
+ }
21
+ /** The raw 32-byte anchor digest: SHA-256(0x03 || UTF8(anchorPreimage)). This is what gets signed. */
22
+ export function anchorDigest(a) {
23
+ return crypto
24
+ .createHash("sha256")
25
+ .update(Buffer.from([ANCHOR_TAG]))
26
+ .update(Buffer.from(anchorPreimage(a), "utf8"))
27
+ .digest();
28
+ }
29
+ /** Hex form of the anchor digest (for display / vectors). */
30
+ export function anchorDigestHex(a) {
31
+ return anchorDigest(a).toString("hex");
32
+ }
33
+ /**
34
+ * Sign an anchor digest with a producer key. Helper for anchor providers and tests — a relying party
35
+ * only needs `verifyAnchorSignature`. The message is the RAW 32-byte digest (never its hex string):
36
+ * ES256/RSA-PSS then apply their own SHA-256, Ed25519 signs the bytes directly.
37
+ */
38
+ export function signAnchor(a, privateKey) {
39
+ const digest = anchorDigest(a);
40
+ if (a.algorithm === "Ed25519")
41
+ return crypto.sign(null, digest, privateKey).toString("base64");
42
+ if (a.algorithm === "RSA-PSS") {
43
+ // Salt length = hash length (32), the only one verifiers accept (DEWP §5.2). Node's default for
44
+ // signing is the MAXIMUM salt, which no longer verifies.
45
+ return crypto
46
+ .sign("sha256", digest, {
47
+ key: privateKey,
48
+ padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
49
+ saltLength: RSA_PSS_SALT_LENGTH,
50
+ })
51
+ .toString("base64");
52
+ }
53
+ // ES256 (P-256 + SHA-256), DER encoding.
54
+ return crypto.sign("sha256", digest, { key: privateKey, dsaEncoding: "der" }).toString("base64");
55
+ }
56
+ /**
57
+ * Verify an anchor's signature over its digest against a resolved public key. Pins the key type to the
58
+ * declared algorithm so an anchor labelled ES256 can't be verified under some other scheme.
59
+ */
60
+ export function verifyAnchorSignature(anchor, publicKey) {
61
+ try {
62
+ if (!isWellFormedAnchor(anchor))
63
+ return false;
64
+ const digest = anchorDigest(anchor);
65
+ const sig = Buffer.from(anchor.signature, "base64");
66
+ if (anchor.algorithm === "Ed25519") {
67
+ if (publicKey.asymmetricKeyType !== "ed25519")
68
+ return false;
69
+ return crypto.verify(null, digest, publicKey, sig);
70
+ }
71
+ if (anchor.algorithm === "RSA-PSS") {
72
+ if (publicKey.asymmetricKeyType !== "rsa" && publicKey.asymmetricKeyType !== "rsa-pss")
73
+ return false;
74
+ if ((publicKey.asymmetricKeyDetails?.modulusLength ?? 0) < RSA_MIN_MODULUS_BITS)
75
+ return false;
76
+ // A fixed salt length, not auto-detection: RSASSA-PSS/SHA-256 with MGF1-SHA-256 and a 32-byte
77
+ // salt is the one §5.2 profile, identical in every port.
78
+ return crypto.verify("sha256", digest, { key: publicKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: RSA_PSS_SALT_LENGTH }, sig);
79
+ }
80
+ // ES256
81
+ if (publicKey.asymmetricKeyType !== "ec")
82
+ return false;
83
+ if (publicKey.asymmetricKeyDetails?.namedCurve !== "prime256v1")
84
+ return false;
85
+ const tryEnc = (dsaEncoding) => {
86
+ try {
87
+ return crypto.verify("sha256", digest, { key: publicKey, dsaEncoding }, sig);
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ };
93
+ if (sig.length === 64 && tryEnc("ieee-p1363"))
94
+ return true;
95
+ return tryEnc("der");
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ const HEX64 = /^[0-9a-f]{64}$/;
102
+ const SEQ = /^[0-9]{1,20}$/;
103
+ const DEWP_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
104
+ /**
105
+ * The §5.2 anchor signature algorithms. The label is part of the signed preimage, so an anchor naming
106
+ * anything else (RSA-OAEP, DSA, an empty string) is not a §5.2 anchor — and must not be verified under
107
+ * whatever scheme a fallthrough happens to pick. External anchors (Rekor, RFC 3161) carry the
108
+ * producer's own anchor algorithm, so the same registry applies to every kind.
109
+ */
110
+ export const ANCHOR_ALGORITHMS = ["ES256", "Ed25519", "RSA-PSS"];
111
+ /**
112
+ * An anchor whose seven signed fields have the shapes §5.2 requires. Anything else is refused before a
113
+ * digest is computed: `JSON.stringify` would happily turn a missing position field into `null`, and a
114
+ * digest over `[…, null, null, null]` is not the preimage any conformant producer signed.
115
+ */
116
+ export function isWellFormedAnchor(a) {
117
+ return (typeof a === "object" &&
118
+ a !== null &&
119
+ typeof a.dailyRoot === "string" &&
120
+ HEX64.test(a.dailyRoot) &&
121
+ typeof a.timestamp === "string" &&
122
+ parseAnchorTimestampMs(a.timestamp) !== null &&
123
+ typeof a.issuer === "string" &&
124
+ typeof a.algorithm === "string" &&
125
+ ANCHOR_ALGORITHMS.includes(a.algorithm) &&
126
+ typeof a.seqStart === "string" &&
127
+ SEQ.test(a.seqStart) &&
128
+ typeof a.seqEnd === "string" &&
129
+ SEQ.test(a.seqEnd) &&
130
+ typeof a.chainHash === "string" &&
131
+ HEX64.test(a.chainHash));
132
+ }
133
+ /**
134
+ * Milliseconds since the epoch for a DEWP §4.3 timestamp (`YYYY-MM-DDTHH:mm:ss.sssZ`, exactly), or null.
135
+ * Strict on purpose: every port must read the same instant from the same bytes, and a lenient parser in
136
+ * one of them (offsets, missing milliseconds, 30 February rolled into March) is how two verifiers come
137
+ * to disagree about whether an anchor was on time.
138
+ */
139
+ export function parseAnchorTimestampMs(ts) {
140
+ if (typeof ts !== "string" || !DEWP_TIMESTAMP.test(ts))
141
+ return null;
142
+ const ms = Date.parse(ts);
143
+ if (!Number.isFinite(ms) || new Date(ms).toISOString() !== ts)
144
+ return null;
145
+ return ms;
146
+ }
147
+ /**
148
+ * Default bound on how long after a checkpoint's claimed time an external witness may have first seen
149
+ * its anchor (DEWP §5.3). An anchor obtained later than this proves only that the root existed when it
150
+ * was finally witnessed — which says nothing about whether it existed at the time the checkpoint
151
+ * claims, and is exactly what a party rewriting old history and re-anchoring it today produces.
152
+ */
153
+ export const DEFAULT_MAX_ANCHOR_LAG_SECONDS = 86_400;
154
+ /**
155
+ * Tolerated clock disagreement in the other direction: a witness time EARLIER than the checkpoint's
156
+ * claimed time. The producer chose that time before submitting, so a witness seeing the anchor before
157
+ * it claims to exist means a producer clock ahead of the witness's — bounded, never unbounded.
158
+ */
159
+ export const ANCHOR_CLOCK_SKEW_SECONDS = 300;
160
+ /** Where an anchor's signed fields disagree with the checkpoint it is offered for, or null. */
161
+ function positionMismatch(a, expected) {
162
+ if (!expected)
163
+ return null;
164
+ if (expected.seqStart != null && a.seqStart !== expected.seqStart)
165
+ return "seqStart";
166
+ if (expected.seqEnd != null && a.seqEnd !== expected.seqEnd)
167
+ return "seqEnd";
168
+ if (expected.chainHash != null && a.chainHash !== expected.chainHash)
169
+ return "chainHash";
170
+ if (expected.anchoredAt != null && a.timestamp !== expected.anchoredAt)
171
+ return "timestamp";
172
+ return null;
173
+ }
174
+ /**
175
+ * Evaluate an anchor quorum for one `dailyRoot`. `anchorVerified` in a bundle is true iff this returns
176
+ * ok. Divergence (a trusted issuer signing a different root) is fatal, never a silent pick.
177
+ *
178
+ * An anchor counts only when (1) its issuer is trusted, (2) its evidence verifies under trust the
179
+ * caller supplied, (3) its signed position matches `opts.checkpoint` where the caller knows it, and
180
+ * (4) for an external witness, the witness time falls within the policy's time bound of the
181
+ * checkpoint's claimed time.
182
+ */
183
+ export function verifyAnchorQuorum(anchors, dailyRoot, policy, resolveKey, opts = {}) {
184
+ const verifies = (a) => {
185
+ if (!isWellFormedAnchor(a))
186
+ return { ok: false };
187
+ if (a.kind === "RFC3161") {
188
+ const tsa = opts.externalKeys?.rfc3161;
189
+ const trust = tsa && Object.hasOwn(tsa, a.issuer) ? tsa[a.issuer] : undefined;
190
+ if (trust === undefined)
191
+ return { ok: false };
192
+ const r = verifyRfc3161Anchor(a, trust);
193
+ return { ok: r.ok && typeof r.genTime === "number", witnessTime: r.genTime };
194
+ }
195
+ if (a.kind === "REKOR") {
196
+ const key = opts.externalKeys?.rekor;
197
+ // The log signs arbitrary submitted digests, including producer-chosen issuer strings.
198
+ // Its key must therefore be assigned to an issuer by the CALLER, never by the anchor.
199
+ const scopedIssuer = opts.externalKeys?.rekorIssuer ??
200
+ (new Set(policy.trustedIssuers).size === 1 ? policy.trustedIssuers[0] : undefined);
201
+ if (a.issuer !== scopedIssuer)
202
+ return { ok: false };
203
+ const evidence = parseRekorEvidence(a.evidence);
204
+ if (!key || !evidence)
205
+ return { ok: false };
206
+ const r = verifyRekorAnchor(evidence, a, key, { submitterKeys: opts.externalKeys?.rekorSubmitterKeys });
207
+ return { ok: r.ok && typeof r.integratedTime === "number", witnessTime: r.integratedTime };
208
+ }
209
+ if (a.kind != null && a.kind !== "SELF")
210
+ return { ok: false };
211
+ const key = resolveKey(a);
212
+ return { ok: key !== null && verifyAnchorSignature(a, key) };
213
+ };
214
+ const maxLagMs = (policy.maxAnchorLagSeconds ?? DEFAULT_MAX_ANCHOR_LAG_SECONDS) * 1000;
215
+ const skewMs = ANCHOR_CLOCK_SKEW_SECONDS * 1000;
216
+ /** Signed lag of an external witness behind the anchor's claimed time, in ms. */
217
+ const witnessLagMs = (a, witnessTime) => witnessTime * 1000 - (parseAnchorTimestampMs(a.timestamp) ?? Number.NaN);
218
+ /** The §5.3 lag window around the anchor's signed (claimed) checkpoint time. */
219
+ const withinWitnessBound = (a, witnessTime) => {
220
+ const lagMs = witnessLagMs(a, witnessTime);
221
+ return lagMs >= -skewMs && lagMs <= maxLagMs;
222
+ };
223
+ const trusted = anchors.filter((a) => policy.trustedIssuers.includes(a.issuer));
224
+ // Divergence: a trusted issuer that validly signed a DIFFERENT root for this checkpoint. Only
225
+ // anchors the CALLER vouched for as being for this checkpoint can establish that — and not one
226
+ // whose own signed range says it is for another checkpoint.
227
+ const expected = opts.checkpoint;
228
+ //
229
+ // Divergence is a fatal, tamper-shaped verdict, so a candidate is held to the same evidence rules
230
+ // as a quorum anchor (DEWP §5.3): the seq range must be this checkpoint's, and an external witness
231
+ // must have seen the digest within the time bound of the anchor's own signed checkpoint time. The
232
+ // chain hash and claimed time are deliberately NOT required to match: both commit to the root, so
233
+ // a rewritten checkpoint necessarily differs in them, and requiring equality would hide exactly
234
+ // the rewrite divergence exists to expose.
235
+ const divergenceCandidates = (opts.divergenceAnchors ?? []).filter((a) => policy.trustedIssuers.includes(a.issuer) &&
236
+ (expected?.seqStart == null || a.seqStart === expected.seqStart) &&
237
+ (expected?.seqEnd == null || a.seqEnd === expected.seqEnd));
238
+ for (const a of divergenceCandidates) {
239
+ if (a.dailyRoot === dailyRoot)
240
+ continue;
241
+ // Rekor logs a digest under whatever key submits it, and the digest is computed from public
242
+ // fields — so without the producer's submission key pinned, anyone could log a different root
243
+ // under a trusted issuer's name and raise a false alarm. Such an entry cannot establish divergence.
244
+ if (a.kind === "REKOR" && !(opts.externalKeys?.rekorSubmitterKeys?.length ?? 0))
245
+ continue;
246
+ const r = verifies(a);
247
+ if (r.ok && r.witnessTime !== undefined && !withinWitnessBound(a, r.witnessTime))
248
+ continue;
249
+ if (r.ok) {
250
+ return {
251
+ ok: false,
252
+ verifiedIssuers: [],
253
+ divergence: true,
254
+ reason: `anchor divergence: issuer ${a.issuer} signed a different root for this checkpoint`,
255
+ witnessTimes: {},
256
+ };
257
+ }
258
+ }
259
+ const verifiedIssuers = new Set();
260
+ const witnessTimes = {};
261
+ const notes = [];
262
+ let rfc3161Present = 0;
263
+ for (const a of trusted) {
264
+ if (a.dailyRoot !== dailyRoot)
265
+ continue;
266
+ const mismatch = positionMismatch(a, expected);
267
+ if (mismatch) {
268
+ notes.push(`anchor from ${a.issuer} binds a different checkpoint ${mismatch}; it does not count`);
269
+ continue;
270
+ }
271
+ const r = verifies(a);
272
+ if (!r.ok) {
273
+ if (a.kind === "RFC3161")
274
+ rfc3161Present++;
275
+ continue;
276
+ }
277
+ if (r.witnessTime !== undefined) {
278
+ const prior = witnessTimes[a.issuer];
279
+ witnessTimes[a.issuer] = prior === undefined ? r.witnessTime : Math.min(prior, r.witnessTime);
280
+ // The bound needs a checkpoint time the caller can vouch for. With a checkpoint named but its
281
+ // time unknown, the only time left is the anchor's own `timestamp` — the producer's choice.
282
+ if (expected && expected.anchoredAt == null) {
283
+ notes.push(`anchor from ${a.issuer} has an external witness time but no trusted checkpoint time to hold ` +
284
+ "it to (DEWP §5.3); it does not count");
285
+ continue;
286
+ }
287
+ // isWellFormedAnchor has already parsed this timestamp, so it is non-null here.
288
+ const lagMs = witnessLagMs(a, r.witnessTime);
289
+ if (!withinWitnessBound(a, r.witnessTime)) {
290
+ notes.push(`anchor from ${a.issuer} was witnessed ${Math.round(lagMs / 1000)}s from its checkpoint time ` +
291
+ `(allowed ${-ANCHOR_CLOCK_SKEW_SECONDS}..${maxLagMs / 1000}s); it does not count`);
292
+ continue;
293
+ }
294
+ }
295
+ verifiedIssuers.add(a.issuer);
296
+ }
297
+ if (rfc3161Present > 0) {
298
+ notes.push(`${rfc3161Present} RFC 3161 TSA anchor(s) over this root are present but not verifiable ` +
299
+ "with the supplied configuration — configure RFC 3161 trust and OpenSSL 3, or inspect the evidence out of band.");
300
+ }
301
+ const count = verifiedIssuers.size;
302
+ const trustedPresent = new Set(trusted.filter((a) => a.dailyRoot === dailyRoot).map((a) => a.issuer)).size;
303
+ const need = policy.quorum === "ALL_MUST_AGREE"
304
+ ? Math.max(policy.requiredAnchors, trustedPresent)
305
+ : policy.requiredAnchors;
306
+ return {
307
+ ok: count >= need && count >= 1,
308
+ verifiedIssuers: [...verifiedIssuers],
309
+ divergence: false,
310
+ reason: count >= need ? undefined : `anchor quorum not met (${count}/${need})`,
311
+ ...(notes.length > 0 ? { note: notes.join("; ") } : {}),
312
+ witnessTimes,
313
+ };
314
+ }