@intyga/verify 0.0.0-bootstrap.0 → 1.0.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,235 @@
1
+ import { type AuditSignaturePolicy, type AuditSignatureCheck } from "./ledger-signature.js";
2
+ import type { ExternalAnchorKeys } from "./ledger-anchor.js";
3
+ import { type AnchorKeyResolver, type AnchorPolicy, type SignedAnchor } from "./ledger-anchor.js";
4
+ import { type AuditLeaf } from "./ledger-leaf.js";
5
+ import { type InclusionProof } from "./ledger-proof.js";
6
+ export declare const BUNDLE_KIND = "dewp.audit.inclusion-proof";
7
+ /** DEWP envelope discriminator and specification version this producer/verifier implements (§6). */
8
+ export declare const DEWP_PROTOCOL = "DEWP";
9
+ export declare const DEWP_VERSION = "1.0";
10
+ /**
11
+ * The Application Profile this ledger's canonical preimage uses (DEWP §4.5, reverse-DNS
12
+ * `vendor.profileName.vVersion`). Core fields 0..10 followed by six profile fields
13
+ * (isBillable, tenantId, actorNodeId, subjectNodeId, edgeId, challengeId), with `tenantSeq` last —
14
+ * 18 elements in total, versus the 12-element bare Core.
15
+ *
16
+ * Declaring it on the wire is what lets a verifier REFUSE a preimage layout it does not know, instead
17
+ * of hashing an unfamiliar array with this one's field ordering and reporting a leaf mismatch that
18
+ * looks like tampering.
19
+ */
20
+ export declare const AUDIT_PROFILE = "trust.intyga.audit.v1";
21
+ /** DEWP §6.1 algorithm registry — what this implementation commits to. */
22
+ export declare const ALGORITHM_REGISTRY: {
23
+ readonly hashAlgorithm: "SHA-256";
24
+ readonly serialization: "RFC8785-JCS";
25
+ readonly merkleVersion: 1;
26
+ readonly signatureAlgorithms: readonly ["ES256", "WEBAUTHN", "AUTO_APPROVED"];
27
+ };
28
+ export interface AlgorithmRegistry {
29
+ hashAlgorithm: string;
30
+ serialization: string;
31
+ merkleVersion: number;
32
+ signatureAlgorithms?: string[];
33
+ }
34
+ export interface ProofBundle {
35
+ /** DEWP §6.2 envelope. Absent on bundles exported before the envelope was added. */
36
+ protocol?: string;
37
+ kind: typeof BUNDLE_KIND;
38
+ /** Spec version. String ("1.0") per §6.2; older exports carried the numeric bundle revision. */
39
+ version: string | number;
40
+ /** Canonical-preimage Application Profile (§4.5). Absent ⇒ assumed to be this implementation's. */
41
+ profile?: string;
42
+ algorithmRegistry?: AlgorithmRegistry;
43
+ exportedAt: string;
44
+ event: {
45
+ seq: string;
46
+ /** Opaque event identifier (§4.2) — distinct from the numeric ordering counter `seq`. */
47
+ id?: string;
48
+ createdAt: string;
49
+ type: string;
50
+ outcome: string;
51
+ detail: string | null;
52
+ actorDid: string | null;
53
+ subjectDid: string | null;
54
+ signerDid: string | null;
55
+ signature: string | null;
56
+ sigAlg: string | null;
57
+ canonical?: AuditLeaf;
58
+ };
59
+ proof: InclusionProof;
60
+ /**
61
+ * A SIGNED anchor over this bundle's daily root (DEWP §5.2/§6.2) — `dailyRoot`, `timestamp`,
62
+ * `issuer`, `keyId`, `algorithm`, `signature`. Present only when the root has actually been signed;
63
+ * a mere publication receipt is NOT an anchor and belongs in `anchorRef`.
64
+ */
65
+ anchor?: SignedAnchor;
66
+ /** Independently-issued signed anchors over the same daily root, for quorum verification (§5.3). */
67
+ anchors?: SignedAnchor[];
68
+ /** External publication reference for the daily root (transparency-log id, commit hash, receipt). */
69
+ anchorRef?: string | null;
70
+ /**
71
+ * Producer claim that a publication receipt exists — COMMITMENT only. The producer writes one for
72
+ * every checkpoint, so this being true says nothing about independence; see `externallyAnchored`.
73
+ */
74
+ anchored?: boolean;
75
+ /**
76
+ * Producer claim that a §5.3 external anchor quorum (distinct INDEPENDENT issuers >= the producer's
77
+ * configured requirement) exists for this root. Absent on bundles exported before the field
78
+ * shipped. Display/triage only — independence is established by THIS verifier's own quorum
79
+ * evaluation (`anchorVerified`), never by trusting the flag.
80
+ */
81
+ externallyAnchored?: boolean;
82
+ /**
83
+ * The quorum size the producer evaluated that claim against (DEWP §6.2). Absent on bundles exported
84
+ * before it shipped. Still a producer claim: it says what the producer required, not what happened.
85
+ */
86
+ externallyAnchoredRequired?: number;
87
+ /**
88
+ * Legacy pre-§6.2 shape, where `anchor` carried the root and publication status rather than a
89
+ * signature. Read for backward compatibility only; new exports use the fields above.
90
+ */
91
+ legacyAnchor?: {
92
+ dailyRoot: string | null;
93
+ anchorRef: string | null;
94
+ anchored: boolean;
95
+ };
96
+ selfVerified?: boolean;
97
+ howToVerify?: string;
98
+ }
99
+ export interface CheckResult {
100
+ /** true = passed, false = failed, null = not applicable / could not be evaluated. */
101
+ pass: boolean | null;
102
+ detail: string;
103
+ }
104
+ /**
105
+ * The four INDEPENDENT DEWP verification properties (docs/DEWP.md §7.1). They are orthogonal —
106
+ * anchor verification is not a strict superset of signature verification — so a relying party can
107
+ * apply its own policy over them rather than reading only the summary level.
108
+ */
109
+ export interface VerificationProperties {
110
+ /** Leaf is committed under the checkpoint root (two-hop inclusion recomputes). */
111
+ commitmentVerified: boolean;
112
+ /** commitmentVerified AND the canonical preimage is present and its leaf hash matches. */
113
+ contentVerified: boolean;
114
+ /** contentVerified AND the event signature verifies. See signature.trusted for caller-key trust. */
115
+ signatureVerified: boolean;
116
+ /**
117
+ * The checkpoint root was verified against an ANCHOR QUORUM under the caller's §5.3 policy — DEWP
118
+ * §3 Invariant 7 makes this an if-and-only-if. Supplying a trusted root out of band is weaker
119
+ * provenance, not this property: it is reported by `rootSource`, and leaves this false.
120
+ */
121
+ anchorVerified: boolean;
122
+ }
123
+ /** DEWP summary verification level, derived from the four properties (docs/DEWP.md §7.1). */
124
+ export type VerificationLevel = "INVALID" | "COMMITMENT_VERIFIED" | "CONTENT_VERIFIED" | "SIGNATURE_VERIFIED" | "FULLY_VERIFIED";
125
+ export interface BundleVerification {
126
+ signature: AuditSignatureCheck;
127
+ /** Overall verdict. Only true when the caller supplied the root AND every applicable check passed. */
128
+ ok: boolean;
129
+ dailyRoot: string | null;
130
+ /**
131
+ * Where the root verified against came from. `caller-supplied` means only that: the caller passed
132
+ * it in. This verifier cannot tell a root recorded independently from one copied out of this very
133
+ * bundle, so it never labels a root "independent" — that is the caller's claim to make, and it is
134
+ * only true of a root obtained before and apart from the export (an earlier record, or the
135
+ * published roots file). `self-asserted` is the bundle's own root; `none` means there was none.
136
+ */
137
+ rootSource: "caller-supplied" | "self-asserted" | "none";
138
+ /**
139
+ * Authenticated external witness time per anchor issuer, Unix seconds (Rekor integratedTime, TSA
140
+ * genTime), for every external anchor whose evidence verified — including ones refused by the
141
+ * time bound. Empty when no quorum was evaluated.
142
+ */
143
+ witnessTimes: Record<string, number>;
144
+ /** DEWP §7.1 independent properties. */
145
+ properties: VerificationProperties;
146
+ /** DEWP §7.1 summary level derived from `properties`. */
147
+ verificationLevel: VerificationLevel;
148
+ checks: {
149
+ inclusion: CheckResult;
150
+ rootConsistency: CheckResult;
151
+ leafBinding: CheckResult;
152
+ /** Displayed header fields equal the committed preimage — they are otherwise unsigned copies. */
153
+ headerBinding: CheckResult;
154
+ /**
155
+ * The producer's external-anchoring CLAIM (`externallyAnchored`), reported for display/triage.
156
+ * It is self-asserted either way — `anchorVerified` is the actual check. `pass: null` on bundles
157
+ * exported before the claim shipped, whose legacy `anchored` flag meant only "publication
158
+ * receipt exists" and must not read as independence.
159
+ */
160
+ anchored: CheckResult;
161
+ };
162
+ notes: string[];
163
+ }
164
+ /**
165
+ * Verify the event's embedded DIV signature (ES256 over `signedPayload`) from the leaf alone. This legacy helper
166
+ * checks ES256 against the embedded key only. Use verifyAuditSignature for caller-trusted keys and
167
+ * WebAuthn assertions stored in canonical.metadata.webauthn. AUTO_APPROVED has no human signature.
168
+ */
169
+ export declare function verifyEmbeddedSignature(canonical: AuditLeaf): boolean;
170
+ /** Derive the DEWP §7.1 summary level from the four properties and whether a signer was present. */
171
+ export declare function deriveVerificationLevel(p: VerificationProperties, hasSigner: boolean): VerificationLevel;
172
+ export interface VerifyOptions {
173
+ signaturePolicy?: AuditSignaturePolicy;
174
+ /** Require every selected event to have a valid signature under caller-trusted keys. */
175
+ requireSignatures?: boolean;
176
+ /**
177
+ * Pinned public keys for EXTERNAL logs (Rekor today). A Rekor anchor carries no DEWP signature —
178
+ * its Signed Entry Timestamp is the attestation — so without the log key it cannot be verified and
179
+ * does not count toward quorum. Supply from Sigstore's TUF root, never from the bundle.
180
+ */
181
+ externalKeys?: ExternalAnchorKeys;
182
+ /**
183
+ * The daily root to verify against. Required for `ok`. It is only as independent as its source:
184
+ * a root recorded earlier or taken from the published roots file is; a root copied from this
185
+ * bundle is not, and the verdict labels it `caller-supplied` either way.
186
+ */
187
+ trustedRoot?: string;
188
+ /**
189
+ * Signed anchor objects over the daily root (DEWP §5.2). When supplied with `anchorPolicy` and
190
+ * `resolveAnchorKey`, `anchorVerified` reflects a real multi-anchor quorum check (signatures over
191
+ * the 0x03-tagged digest) instead of the weaker "a root was handed to me" signal.
192
+ */
193
+ anchors?: SignedAnchor[];
194
+ anchorPolicy?: AnchorPolicy;
195
+ resolveAnchorKey?: AnchorKeyResolver;
196
+ /**
197
+ * The checkpoint this proof's root belongs to, as YOU hold it — normally the chain-verified line of
198
+ * the published roots file for that root (DEWP §5.4.1). A single proof carries no checkpoint of its
199
+ * own, so this is the only checkpoint time and chain hash an anchor can be held to: without it an
200
+ * external witness (Rekor, RFC 3161) does not count, because the §5.3 time bound would be measured
201
+ * against the anchor's own producer-chosen `timestamp`. Its `root` stands in for `trustedRoot` when
202
+ * that is absent, and must equal it when both are given; its `entryCount` bounds the proof's leaf
203
+ * counts.
204
+ */
205
+ trustedCheckpoint?: TrustedCheckpoint;
206
+ }
207
+ /**
208
+ * A checkpoint record the CALLER trusts (DEWP §5.4.1 roots-file line). Every field but `root` is
209
+ * optional because a hand-built minimal list may carry only roots; a field that is present is binding.
210
+ */
211
+ export interface TrustedCheckpoint {
212
+ root: string;
213
+ seqStart?: string | null;
214
+ seqEnd?: string | null;
215
+ entryCount?: number | null;
216
+ anchoredAt?: string | null;
217
+ chainHash?: string | null;
218
+ }
219
+ /**
220
+ * Why a proof's leaf counts cannot belong to a checkpoint committing `entryCount` events, or null.
221
+ *
222
+ * `blockLeafCount`/`checkpointLeafCount` arrive inside the proof, so a prover can shrink them: an
223
+ * interior node of a 4-leaf block then verifies as "leaf 0 of a 2-leaf block" — a redacted event that
224
+ * never existed. A checkpoint's entry count is the sum of its blocks' leaf counts, which bounds both.
225
+ */
226
+ export declare function leafCountMismatch(proof: InclusionProof, entryCount: number | null | undefined): string | null;
227
+ /** DEWP §7 check 1 / §12: never interpret a future protocol under today's algorithms.
228
+ * Numeric revisions 1 and 2 without a protocol is the explicitly supported legacy export format.
229
+ */
230
+ export declare function supportedEnvelope(bundle: {
231
+ protocol?: string;
232
+ version: unknown;
233
+ algorithmRegistry?: AlgorithmRegistry;
234
+ }): boolean;
235
+ export declare function verifyBundle(bundle: ProofBundle, opts?: VerifyOptions): BundleVerification;
@@ -0,0 +1,419 @@
1
+ import crypto from "node:crypto";
2
+ import { verifyAuditSignature, uncheckedSignature, } from "./ledger-signature.js";
3
+ import { verifyAnchorQuorum, } from "./ledger-anchor.js";
4
+ import { leafHash } from "./ledger-leaf.js";
5
+ import { verifyInclusionProof } from "./ledger-proof.js";
6
+ // The downloadable proof bundle a member exports (GET /api/audit/proof/:seq) and what it means to
7
+ // verify one. A verdict is only as strong as the daily root you check against: use a root you
8
+ // obtained before and independently of this bundle — one you recorded earlier, or one from the
9
+ // published roots file — never one copied out of the bundle itself. (An external anchor cannot hand
10
+ // you the root: Rekor stores a hash of the anchor digest and a TSA the digest, neither reveals it.)
11
+ // DEWP canonical kind (docs/DEWP.md §6.2). Producers emit this form and verifiers require it; there
12
+ // is no vendor-prefixed alias, since no bundle has ever been exported under one.
13
+ export const BUNDLE_KIND = "dewp.audit.inclusion-proof";
14
+ /** DEWP envelope discriminator and specification version this producer/verifier implements (§6). */
15
+ export const DEWP_PROTOCOL = "DEWP";
16
+ export const DEWP_VERSION = "1.0";
17
+ /**
18
+ * The Application Profile this ledger's canonical preimage uses (DEWP §4.5, reverse-DNS
19
+ * `vendor.profileName.vVersion`). Core fields 0..10 followed by six profile fields
20
+ * (isBillable, tenantId, actorNodeId, subjectNodeId, edgeId, challengeId), with `tenantSeq` last —
21
+ * 18 elements in total, versus the 12-element bare Core.
22
+ *
23
+ * Declaring it on the wire is what lets a verifier REFUSE a preimage layout it does not know, instead
24
+ * of hashing an unfamiliar array with this one's field ordering and reporting a leaf mismatch that
25
+ * looks like tampering.
26
+ */
27
+ export const AUDIT_PROFILE = "trust.intyga.audit.v1";
28
+ /** DEWP §6.1 algorithm registry — what this implementation commits to. */
29
+ export const ALGORITHM_REGISTRY = {
30
+ hashAlgorithm: "SHA-256",
31
+ serialization: "RFC8785-JCS",
32
+ merkleVersion: 1,
33
+ signatureAlgorithms: ["ES256", "WEBAUTHN", "AUTO_APPROVED"],
34
+ };
35
+ /**
36
+ * Verify the event's embedded DIV signature (ES256 over `signedPayload`) from the leaf alone. This legacy helper
37
+ * checks ES256 against the embedded key only. Use verifyAuditSignature for caller-trusted keys and
38
+ * WebAuthn assertions stored in canonical.metadata.webauthn. AUTO_APPROVED has no human signature.
39
+ */
40
+ export function verifyEmbeddedSignature(canonical) {
41
+ if (canonical.sigAlg !== "ES256")
42
+ return false;
43
+ if (!canonical.signerPublicKey || !canonical.signature || !canonical.signedPayload)
44
+ return false;
45
+ try {
46
+ const keyObject = crypto.createPublicKey({
47
+ key: Buffer.from(canonical.signerPublicKey, "base64"),
48
+ format: "der",
49
+ type: "spki",
50
+ });
51
+ if (keyObject.asymmetricKeyType !== "ec")
52
+ return false;
53
+ if (keyObject.asymmetricKeyDetails?.namedCurve !== "prime256v1")
54
+ return false;
55
+ const sig = Buffer.from(canonical.signature, "base64");
56
+ const data = Buffer.from(canonical.signedPayload, "utf8");
57
+ const tryEncoding = (dsaEncoding) => {
58
+ try {
59
+ return crypto.verify("sha256", data, { key: keyObject, dsaEncoding }, sig);
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ };
65
+ if (sig.length === 64 && tryEncoding("ieee-p1363"))
66
+ return true;
67
+ return tryEncoding("der");
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /** Derive the DEWP §7.1 summary level from the four properties and whether a signer was present. */
74
+ export function deriveVerificationLevel(p, hasSigner) {
75
+ if (!p.commitmentVerified)
76
+ return "INVALID";
77
+ if (!p.contentVerified)
78
+ return "COMMITMENT_VERIFIED";
79
+ // FULLY_VERIFIED needs anchorVerified plus, for a SIGNED event, signatureVerified. An unsigned
80
+ // event has no signature to require.
81
+ if (p.anchorVerified && (p.signatureVerified || !hasSigner))
82
+ return "FULLY_VERIFIED";
83
+ if (p.signatureVerified)
84
+ return "SIGNATURE_VERIFIED";
85
+ return "CONTENT_VERIFIED";
86
+ }
87
+ /**
88
+ * Why a proof's leaf counts cannot belong to a checkpoint committing `entryCount` events, or null.
89
+ *
90
+ * `blockLeafCount`/`checkpointLeafCount` arrive inside the proof, so a prover can shrink them: an
91
+ * interior node of a 4-leaf block then verifies as "leaf 0 of a 2-leaf block" — a redacted event that
92
+ * never existed. A checkpoint's entry count is the sum of its blocks' leaf counts, which bounds both.
93
+ */
94
+ export function leafCountMismatch(proof, entryCount) {
95
+ if (typeof entryCount !== "number" || !Number.isSafeInteger(entryCount) || entryCount < 0)
96
+ return null;
97
+ const { blockLeafCount, checkpointLeafCount } = proof;
98
+ if (!Number.isSafeInteger(blockLeafCount) || !Number.isSafeInteger(checkpointLeafCount))
99
+ return null;
100
+ if (checkpointLeafCount > entryCount ||
101
+ blockLeafCount + checkpointLeafCount - 1 > entryCount ||
102
+ (checkpointLeafCount === 1 && blockLeafCount !== entryCount)) {
103
+ return (`proof claims ${blockLeafCount} leaves in its block and ${checkpointLeafCount} block(s) under the ` +
104
+ `checkpoint, which cannot sum to the checkpoint's ${entryCount} committed events`);
105
+ }
106
+ return null;
107
+ }
108
+ /** DEWP §7 check 1 / §12: never interpret a future protocol under today's algorithms.
109
+ * Numeric revisions 1 and 2 without a protocol is the explicitly supported legacy export format.
110
+ */
111
+ export function supportedEnvelope(bundle) {
112
+ if (bundle.protocol != null && bundle.protocol !== "DEWP")
113
+ return false;
114
+ if (bundle.version !== "1.0" &&
115
+ !(bundle.protocol == null && (bundle.version === 1 || bundle.version === 2)))
116
+ return false;
117
+ const a = bundle.algorithmRegistry;
118
+ return (a === undefined ||
119
+ (a !== null &&
120
+ a.hashAlgorithm === "SHA-256" &&
121
+ a.serialization === "RFC8785-JCS" &&
122
+ a.merkleVersion === 1));
123
+ }
124
+ export function verifyBundle(bundle, opts = {}) {
125
+ const notes = [];
126
+ // DEWP §6.5: "a compliant producer MUST emit these forms and a compliant verifier MUST reject any
127
+ // other value." A note let a container of one type be fed to the verifier for another and still
128
+ // come back ok — the caller would be reading a verdict produced under semantics the artifact was
129
+ // never built for. `verifyEvidenceBundle` has always rejected; this is the same rule.
130
+ const kindRejected = bundle.kind !== BUNDLE_KIND || !supportedEnvelope(bundle);
131
+ if (kindRejected) {
132
+ notes.push("Refusing bundle kind, protocol, version or algorithm registry (DEWP §7/§12).");
133
+ }
134
+ // An unknown Application Profile means an unknown canonical-array layout (DEWP §4.5). Recomputing
135
+ // the leaf hash under THIS profile's field order would produce a mismatch indistinguishable from
136
+ // tampering, so refuse to attempt leaf binding rather than report a misleading failure.
137
+ const unknownProfile = bundle.profile !== undefined && bundle.profile !== AUDIT_PROFILE;
138
+ // The root the bundle asserts about itself. Read from the signed anchor when there is one, then the
139
+ // legacy pre-§6.2 `anchor` object, then the proof's own checkpoint root — all three are equally
140
+ // self-asserted, which is exactly why none of them can produce a trustworthy verdict on their own.
141
+ const selfAssertedRoot = bundle.anchor?.dailyRoot ?? bundle.legacyAnchor?.dailyRoot ?? bundle.proof.checkpointRoot ?? null;
142
+ const anchorRef = bundle.anchorRef ?? bundle.legacyAnchor?.anchorRef ?? bundle.proof.anchorRef ?? null;
143
+ const anchoredFlag = bundle.anchored ?? bundle.legacyAnchor?.anchored ?? bundle.proof.anchored;
144
+ // Which root do we verify against, and how much do we trust its provenance?
145
+ let dailyRoot;
146
+ let rootSource;
147
+ const trustedCheckpoint = opts.trustedCheckpoint;
148
+ const callerRoot = opts.trustedRoot || trustedCheckpoint?.root;
149
+ // Two caller inputs naming different roots: which one the caller meant is unknowable, so neither wins.
150
+ const checkpointConflict = Boolean(opts.trustedRoot && trustedCheckpoint && trustedCheckpoint.root !== opts.trustedRoot);
151
+ if (checkpointConflict) {
152
+ notes.push("The supplied trustedCheckpoint names a different root than trustedRoot — refusing to pick one.");
153
+ }
154
+ if (callerRoot) {
155
+ dailyRoot = callerRoot;
156
+ rootSource = "caller-supplied";
157
+ }
158
+ else if (selfAssertedRoot) {
159
+ dailyRoot = selfAssertedRoot;
160
+ rootSource = "self-asserted";
161
+ notes.push("No root supplied — verifying against the root inside the bundle. This proves the bundle is " +
162
+ "internally consistent, NOT that it matches the producer's anchored log. Re-run with a root " +
163
+ "you obtained earlier or from the published roots file for a real verdict.");
164
+ }
165
+ else {
166
+ dailyRoot = null;
167
+ rootSource = "none";
168
+ notes.push("No daily root available (event not yet committed to an anchored checkpoint).");
169
+ }
170
+ // DEWP §17.3: the proof's own leaf counts are bound to the trusted checkpoint's entry count.
171
+ const countMismatch = trustedCheckpoint && dailyRoot === trustedCheckpoint.root
172
+ ? leafCountMismatch(bundle.proof, trustedCheckpoint.entryCount)
173
+ : null;
174
+ const inclusion = dailyRoot === null
175
+ ? { pass: null, detail: "No daily root to verify against." }
176
+ : countMismatch
177
+ ? { pass: false, detail: `${countMismatch} — the proof is not for this checkpoint's tree.` }
178
+ : verifyInclusionProof(bundle.proof, dailyRoot)
179
+ ? {
180
+ pass: true,
181
+ detail: "Event leaf recomputes to the daily root through block and checkpoint.",
182
+ }
183
+ : {
184
+ pass: false,
185
+ detail: "Recomputed root does not match — proof is invalid for this root.",
186
+ };
187
+ const rootConsistency = dailyRoot === null
188
+ ? { pass: null, detail: "No daily root to compare." }
189
+ : bundle.proof.checkpointRoot === dailyRoot
190
+ ? {
191
+ pass: true,
192
+ detail: "Proof's checkpoint root equals the verified daily root.",
193
+ }
194
+ : {
195
+ pass: false,
196
+ detail: "Proof's checkpoint root differs from the root being verified against.",
197
+ };
198
+ // The displayed header must BE the committed data, not a caption over it. ProofBundle.event
199
+ // duplicates seq/createdAt/type/outcome/detail/signerDid/signature/sigAlg alongside `canonical`,
200
+ // and only `canonical` is hashed into the leaf. Unchecked, a bundle could show
201
+ // outcome "SUCCESS" over a committed "FAILURE" and still return FULLY_VERIFIED.
202
+ const headerBinding = bundle.event.canonical
203
+ ? (() => {
204
+ const c = bundle.event.canonical;
205
+ const e = bundle.event;
206
+ const differs = (label, shown, committed) => shown != null && String(shown) !== String(committed)
207
+ ? `displayed ${label} ("${String(shown)}") does not match the committed value ("${String(committed)}")`
208
+ : null;
209
+ const bad = differs("seq", e.seq, c.seq) ??
210
+ differs("proof.seq", bundle.proof.seq, c.seq) ??
211
+ differs("createdAt", e.createdAt, c.createdAt) ??
212
+ differs("type", e.type, c.event) ??
213
+ differs("outcome", e.outcome, c.outcome) ??
214
+ differs("detail", e.detail, c.detail) ??
215
+ differs("signerDid", e.signerDid, c.signerDid) ??
216
+ differs("signature", e.signature, c.signature) ??
217
+ differs("sigAlg", e.sigAlg, c.sigAlg);
218
+ return bad === null
219
+ ? { pass: true, detail: "Displayed fields match the committed preimage." }
220
+ : {
221
+ pass: false,
222
+ detail: `${bad} — the bundle displays something other than what was committed.`,
223
+ };
224
+ })()
225
+ : {
226
+ pass: null,
227
+ detail: "No canonical preimage, so the displayed fields cannot be bound to the commitment.",
228
+ };
229
+ const leafBinding = unknownProfile
230
+ ? {
231
+ pass: null,
232
+ detail: `Canonical preimage uses an unknown Application Profile "${bundle.profile}" — this verifier ` +
233
+ `implements "${AUDIT_PROFILE}" and cannot reproduce that layout, so leaf binding was not attempted.`,
234
+ }
235
+ : bundle.event.canonical
236
+ ? leafHash(bundle.event.canonical) === bundle.proof.leaf
237
+ ? { pass: true, detail: "Leaf hash matches the canonical event content." }
238
+ : {
239
+ pass: false,
240
+ detail: "Leaf hash does NOT match the event content — the bundle is inconsistent.",
241
+ }
242
+ : {
243
+ pass: null,
244
+ detail: "No canonical event preimage in this bundle, so the leaf can be located in the tree but not " +
245
+ "bound to the displayed fields. (Redacted events omit it by design.)",
246
+ };
247
+ if (unknownProfile) {
248
+ notes.push(`Unknown canonical profile "${bundle.profile}" — commitment can still be verified, but the event ` +
249
+ "content cannot be bound to the leaf.");
250
+ }
251
+ // The producer's independence CLAIM. The historic `anchored` flag is commitment-only (the producer
252
+ // writes a publication receipt for every checkpoint, so it is structurally always true) and MUST
253
+ // NOT read as external anchoring — that wiring previously made the CLI print "[PASS] anchored" for
254
+ // roots no third party had ever seen. Only the explicit quorum-derived `externallyAnchored` claim
255
+ // can pass this check, and even then it is reported as a claim: `anchorVerified` is the check.
256
+ const externallyAnchoredClaim = bundle.externallyAnchored ?? bundle.proof.externallyAnchored;
257
+ // The quorum size behind the claim. Without it "true" is unreadable — a 1-of-1 deployment and a
258
+ // 2-of-N deployment publish the same boolean — so state it whenever the producer supplies it.
259
+ const claimedRequired = bundle.externallyAnchoredRequired ?? bundle.proof.externallyAnchoredRequired;
260
+ const anchored = externallyAnchoredClaim === true
261
+ ? {
262
+ pass: true,
263
+ detail: `Producer claims a §5.3 external anchor quorum of ${claimedRequired ?? "an unstated number of"} distinct independent issuer(s) for this root` +
264
+ `${anchorRef ? ` (${anchorRef})` : ""}. Claim only — anchorVerified is the check.`,
265
+ }
266
+ : externallyAnchoredClaim === false
267
+ ? {
268
+ pass: false,
269
+ detail: "Root is committed and self-signed only — the producer claims no external anchor quorum.",
270
+ }
271
+ : {
272
+ pass: null,
273
+ detail: "Bundle predates the externallyAnchored claim — external anchoring is unknown from the " +
274
+ `bundle${anchoredFlag ? " (its legacy `anchored` flag means only that a publication receipt exists)" : ""}. ` +
275
+ "Evaluate the signed anchors under your own policy (anchorVerified).",
276
+ };
277
+ // `ok` is the flag callers branch on (`if (!ok) throw`), so it must mean what its doc comment says:
278
+ // every APPLICABLE check passed. Two traps, both previously open:
279
+ //
280
+ // - `headerBinding` was computed and then left out of this expression, so a bundle displaying
281
+ // outcome "SUCCESS" over a committed "FAILURE" returned ok:true with headerBinding.pass:false.
282
+ // - `leafBinding.pass === null` is legitimate ONLY when there is no canonical preimage to bind
283
+ // (a redacted, commitment-only entry). It must never be accepted because an attacker-supplied
284
+ // `profile` made this verifier skip the check on content the bundle did ship — that is a check
285
+ // the prover can switch off. DEWP §4.5 requires reporting contentVerified:false and continuing
286
+ // to evaluate commitmentVerified, which we do; it does not make the bundle ok.
287
+ const contentBoundWhenPresent = bundle.event.canonical ? leafBinding.pass === true : true;
288
+ const ok = !kindRejected &&
289
+ !checkpointConflict &&
290
+ rootSource === "caller-supplied" &&
291
+ inclusion.pass === true &&
292
+ rootConsistency.pass === true &&
293
+ leafBinding.pass !== false &&
294
+ headerBinding.pass !== false &&
295
+ contentBoundWhenPresent;
296
+ // DEWP §7.1 independent properties.
297
+ const commitmentVerified = inclusion.pass === true && rootConsistency.pass === true;
298
+ const contentVerified = commitmentVerified && leafBinding.pass === true && headerBinding.pass !== false;
299
+ const signature = contentVerified && bundle.event.canonical
300
+ ? verifyAuditSignature(bundle.event.canonical, opts.signaturePolicy)
301
+ : uncheckedSignature();
302
+ const signatureVerified = signature.status === "verified";
303
+ const hasSigner = signature.status !== "not_applicable";
304
+ // Anchor verification (DEWP §5.3). If signed anchors + a policy are supplied, require a real quorum
305
+ // over the daily root. Otherwise fall back to the weaker signal: the checkpoint root was handed to
306
+ // us by the caller (not the bundle's own self-asserted flag).
307
+ //
308
+ // Anchors carried INSIDE the bundle are used only when the caller supplied a policy and a key
309
+ // resolver — the signatures are then checked against keys the VERIFIER trusts, so a bundle cannot
310
+ // vouch for itself by shipping anchors it signed with its own key.
311
+ const candidateAnchors = opts.anchors ?? [
312
+ ...(bundle.anchors ?? []),
313
+ ...(bundle.anchor ? [bundle.anchor] : []),
314
+ ];
315
+ // Bundle-carried anchors may COUNT toward quorum — they still have to verify under a key the
316
+ // caller trusts, so a bundle cannot vouch for itself. They may not, however, trigger the fatal
317
+ // DIVERGENCE verdict. A single proof carries no checkpoint position of its own to hold an anchor's
318
+ // signed seq range against, so a genuine anchor from another day is indistinguishable here from a
319
+ // conflicting one, and appending a real, publicly available anchor would be enough to make a valid
320
+ // proof read as tampering. Only anchors the caller fetched itself, per checkpoint, can establish
321
+ // divergence.
322
+ const divergenceAnchors = opts.anchors ?? [];
323
+ let anchorVerified;
324
+ let witnessTimes = {};
325
+ // Gated on the CALLER having asked (policy + resolver), never on candidates existing: a bundle
326
+ // shipped with its anchors stripped must evaluate to "quorum not met (0/N)" under a supplied
327
+ // policy — falling back to the weaker caller-supplied-root signal there would let the prover switch
328
+ // off the very check the caller configured.
329
+ if (opts.anchorPolicy && (opts.resolveAnchorKey || opts.externalKeys) && dailyRoot) {
330
+ const q = verifyAnchorQuorum(candidateAnchors, dailyRoot, opts.anchorPolicy, opts.resolveAnchorKey ?? (() => null), {
331
+ divergenceAnchors,
332
+ externalKeys: opts.externalKeys,
333
+ // A single proof carries no checkpoint, so the only position and time an anchor can be held
334
+ // to is the caller's own record. Naming an EMPTY checkpoint when there is none is deliberate:
335
+ // it keeps an external witness from being bounded by the anchor's producer-chosen timestamp.
336
+ checkpoint: trustedCheckpoint
337
+ ? {
338
+ seqStart: trustedCheckpoint.seqStart,
339
+ seqEnd: trustedCheckpoint.seqEnd,
340
+ chainHash: trustedCheckpoint.chainHash,
341
+ anchoredAt: trustedCheckpoint.anchoredAt,
342
+ }
343
+ : {},
344
+ });
345
+ anchorVerified = commitmentVerified && q.ok;
346
+ witnessTimes = q.witnessTimes;
347
+ if (q.divergence) {
348
+ notes.push(`ANCHOR DIVERGENCE — ${q.reason}. Treating as INVALID.`);
349
+ }
350
+ else if (!q.ok && q.reason) {
351
+ notes.push(q.reason);
352
+ }
353
+ else if (q.ok) {
354
+ notes.push(`Anchor quorum met: ${q.verifiedIssuers.length} independent issuer(s) signed this root.`);
355
+ }
356
+ // Report TSA evidence that could not be verified under the caller's configuration.
357
+ if (q.note)
358
+ notes.push(q.note);
359
+ // A divergent anchor is fatal regardless of inclusion.
360
+ if (q.divergence) {
361
+ return {
362
+ ok: false,
363
+ signature,
364
+ dailyRoot,
365
+ rootSource,
366
+ witnessTimes,
367
+ properties: {
368
+ commitmentVerified,
369
+ contentVerified,
370
+ signatureVerified,
371
+ anchorVerified: false,
372
+ },
373
+ verificationLevel: "INVALID",
374
+ checks: { inclusion, rootConsistency, leafBinding, headerBinding, anchored },
375
+ notes,
376
+ };
377
+ }
378
+ }
379
+ else {
380
+ // DEWP §3 Invariant 7 / §7.1: anchorVerified holds IF AND ONLY IF the root was verified against
381
+ // an anchor quorum. Being handed a root out of band is not that check — no anchor signature was
382
+ // examined — so it cannot make this property true, and FULLY_VERIFIED must stay out of reach.
383
+ // `rootSource: "caller-supplied"` already reports the weaker provenance signal on its own.
384
+ anchorVerified = false;
385
+ if (opts.anchorPolicy) {
386
+ notes.push("Anchor quorum could not be evaluated: a daily root and resolveAnchorKey or externalKeys are required.");
387
+ }
388
+ else if (commitmentVerified && rootSource === "caller-supplied") {
389
+ notes.push("A caller-supplied root was used, but no anchor policy was given, so no anchor " +
390
+ "signature or quorum was evaluated (DEWP §5.2/§5.3): anchorVerified stays false and " +
391
+ "FULLY_VERIFIED is not reachable. Supply anchors + anchorPolicy + resolveAnchorKey for a " +
392
+ "real quorum verdict.");
393
+ }
394
+ }
395
+ const properties = {
396
+ commitmentVerified,
397
+ contentVerified,
398
+ signatureVerified,
399
+ anchorVerified,
400
+ };
401
+ // A refused `kind` is not a partially-verified bundle: the container was never the one these
402
+ // semantics apply to, so it reports INVALID rather than a level derived from checks we should not
403
+ // have run at all.
404
+ const verificationLevel = kindRejected ? "INVALID" : deriveVerificationLevel(properties, hasSigner);
405
+ return {
406
+ // A configured policy is an applicable check even when its anchors or resolver are missing.
407
+ ok: ok &&
408
+ (!opts.anchorPolicy || anchorVerified) &&
409
+ (!opts.requireSignatures || (signatureVerified && signature.trusted)),
410
+ signature,
411
+ dailyRoot,
412
+ rootSource,
413
+ witnessTimes,
414
+ properties,
415
+ verificationLevel,
416
+ checks: { inclusion, rootConsistency, leafBinding, headerBinding, anchored },
417
+ notes,
418
+ };
419
+ }