@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.
- package/CHANGELOG.md +162 -0
- package/LICENSE +201 -0
- package/README.md +296 -2
- package/dist/approval-policy.d.ts +37 -0
- package/dist/approval-policy.js +149 -0
- package/dist/index.d.ts +803 -0
- package/dist/index.js +2236 -0
- package/dist/ledger-anchor.d.ts +195 -0
- package/dist/ledger-anchor.js +314 -0
- package/dist/ledger-bundle.d.ts +235 -0
- package/dist/ledger-bundle.js +419 -0
- package/dist/ledger-chain.d.ts +58 -0
- package/dist/ledger-chain.js +121 -0
- package/dist/ledger-evidence.d.ts +193 -0
- package/dist/ledger-evidence.js +613 -0
- package/dist/ledger-leaf.d.ts +25 -0
- package/dist/ledger-leaf.js +44 -0
- package/dist/ledger-merkle.d.ts +46 -0
- package/dist/ledger-merkle.js +183 -0
- package/dist/ledger-proof.d.ts +40 -0
- package/dist/ledger-proof.js +29 -0
- package/dist/ledger-rekor.d.ts +49 -0
- package/dist/ledger-rekor.js +187 -0
- package/dist/ledger-rfc3161.d.ts +25 -0
- package/dist/ledger-rfc3161.js +345 -0
- package/dist/ledger-signature.d.ts +16 -0
- package/dist/ledger-signature.js +61 -0
- package/package.json +57 -4
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { verifyAnchorQuorum, } from "./ledger-anchor.js";
|
|
2
|
+
import { AUDIT_PROFILE, leafCountMismatch, supportedEnvelope, } from "./ledger-bundle.js";
|
|
3
|
+
import { verifyAuditSignature, uncheckedSignature, } from "./ledger-signature.js";
|
|
4
|
+
import { chainHash } from "./ledger-chain.js";
|
|
5
|
+
import { leafHash } from "./ledger-leaf.js";
|
|
6
|
+
import { verifyInclusionProof } from "./ledger-proof.js";
|
|
7
|
+
// Multi-entry auditor evidence bundle (date-range export from the console's Audit page). Each entry
|
|
8
|
+
// carries its own two-hop inclusion proof. REDACTED entries (content removed by the retention
|
|
9
|
+
// ladder) carry only the leaf digest: they verify as COMMITMENT-ONLY — "an event with this digest
|
|
10
|
+
// existed at this position under this anchored root" — while unredacted entries additionally bind
|
|
11
|
+
// the displayed content to the leaf (CONTENT-VERIFIED).
|
|
12
|
+
// DEWP canonical kind (docs/DEWP.md §6.3). Producers emit this form and verifiers require it; there
|
|
13
|
+
// is no vendor-prefixed alias, since no bundle has ever been exported under one.
|
|
14
|
+
export const EVIDENCE_BUNDLE_KIND = "dewp.audit.evidence-bundle";
|
|
15
|
+
/**
|
|
16
|
+
* Compare an entry's DISPLAYED fields against the committed preimage.
|
|
17
|
+
*
|
|
18
|
+
* The leaf commits to `canonical`. Every field duplicated next to it — seq, createdAt, type,
|
|
19
|
+
* outcome, signerDid, sigAlg, tenantSeq — is unsigned, and this is the artifact a human, a console
|
|
20
|
+
* or a SIEM actually reads. Without this check a bundle could carry genuine proofs under a real
|
|
21
|
+
* anchored root while displaying `outcome: "SUCCESS"` over a committed `FAILURE`, and still come
|
|
22
|
+
* back fully verified. Returns a reason on mismatch, or null.
|
|
23
|
+
*/
|
|
24
|
+
function displayMismatch(event) {
|
|
25
|
+
const c = event.canonical;
|
|
26
|
+
if (!c)
|
|
27
|
+
return null;
|
|
28
|
+
const differs = (label, shown, committed) => shown != null && String(shown) !== String(committed)
|
|
29
|
+
? `displayed ${label} ("${String(shown)}") does not match the committed value ("${String(committed)}")`
|
|
30
|
+
: null;
|
|
31
|
+
return (differs("seq", event.seq, c.seq) ??
|
|
32
|
+
differs("createdAt", event.createdAt, c.createdAt) ??
|
|
33
|
+
differs("type", event.type, c.event) ??
|
|
34
|
+
differs("outcome", event.outcome, c.outcome) ??
|
|
35
|
+
differs("signerDid", event.signerDid, c.signerDid) ??
|
|
36
|
+
differs("sigAlg", event.sigAlg, c.sigAlg) ??
|
|
37
|
+
differs("tenantSeq", event.tenantSeq, c.tenantSeq));
|
|
38
|
+
}
|
|
39
|
+
export function verifyEvidenceBundle(bundle, opts = {}) {
|
|
40
|
+
const notes = [];
|
|
41
|
+
const failed = [];
|
|
42
|
+
let contentVerified = 0;
|
|
43
|
+
let commitmentOnly = 0;
|
|
44
|
+
/** Entries whose displayed type/outcome rest on the producer's redaction record, not on the log. */
|
|
45
|
+
let redactedDisplayed = 0;
|
|
46
|
+
const signatureChecks = new Map();
|
|
47
|
+
let signaturesVerified = 0;
|
|
48
|
+
let signaturesNotCheckable = 0;
|
|
49
|
+
const signaturesInvalid = [];
|
|
50
|
+
// DEWP §6.5: "a compliant verifier MUST reject any other value." A note let a container of one
|
|
51
|
+
// type be fed to the verifier for another and still come back ok — the caller would be reading a
|
|
52
|
+
// verdict produced under semantics the artifact was never built for.
|
|
53
|
+
if (bundle.kind !== EVIDENCE_BUNDLE_KIND || !supportedEnvelope(bundle)) {
|
|
54
|
+
failed.push({
|
|
55
|
+
seq: "-",
|
|
56
|
+
reason: `refusing bundle kind "${bundle.kind}", protocol, version or algorithm registry (DEWP §7/§12)`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Unknown canonical layout ⇒ leaf binding is not attempted (see verifyBundle for the rationale). That
|
|
60
|
+
// is a check the PRODUCER can switch off, so an entry that ships a preimage under such a profile
|
|
61
|
+
// fails below rather than passing as commitment-only: its content, tenant and tenantSeq would all be
|
|
62
|
+
// unbound, and the contiguity verdict would be computed over counters nothing commits to.
|
|
63
|
+
const unknownProfile = bundle.profile !== undefined && bundle.profile !== AUDIT_PROFILE;
|
|
64
|
+
if (unknownProfile) {
|
|
65
|
+
notes.push(`Unknown canonical profile "${bundle.profile}" — this verifier implements "${AUDIT_PROFILE}", so ` +
|
|
66
|
+
"entry content cannot be bound to its leaf. Entries without a preimage are still checked for " +
|
|
67
|
+
"commitment; an entry that carries one cannot be verified and fails.");
|
|
68
|
+
}
|
|
69
|
+
const bundleTenantId = bundle.tenant?.id ?? null;
|
|
70
|
+
/** DEWP §6.3 redaction, reading the `redaction` object and falling back to the legacy boolean. */
|
|
71
|
+
const isRedacted = (event) => event.redaction ? event.redaction.mode === "COMMITMENT_ONLY" : event.redacted === true;
|
|
72
|
+
/**
|
|
73
|
+
* `tenantSeq` arrives as an untrusted string. Bare `BigInt(x)` THROWS on anything non-numeric, so a
|
|
74
|
+
* malformed counter propagated a SyntaxError out of this function instead of returning a verdict —
|
|
75
|
+
* fail-closed only by accident of the CLI exiting non-zero, and an exception for any library
|
|
76
|
+
* consumer. Oversized digit strings are also superlinear to parse, hence the length bound: a 64-bit
|
|
77
|
+
* counter needs 20 digits.
|
|
78
|
+
*/
|
|
79
|
+
const parseCounter = (raw) => {
|
|
80
|
+
if (typeof raw !== "string" || !/^-?\d{1,20}$/.test(raw))
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
return BigInt(raw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const trustedRecords = opts.trustedCheckpoints ?? [];
|
|
90
|
+
const trusted = opts.trustedRoots || opts.trustedCheckpoints
|
|
91
|
+
? new Set([...(opts.trustedRoots ?? []), ...trustedRecords.map((r) => r.root)])
|
|
92
|
+
: null;
|
|
93
|
+
/** Root → the caller's own record for it (first wins). */
|
|
94
|
+
const trustedRecordByRoot = new Map();
|
|
95
|
+
for (const r of trustedRecords)
|
|
96
|
+
if (!trustedRecordByRoot.has(r.root))
|
|
97
|
+
trustedRecordByRoot.set(r.root, r);
|
|
98
|
+
if (!trusted) {
|
|
99
|
+
notes.push("No roots supplied — verifying against the roots inside the bundle. This proves internal " +
|
|
100
|
+
"consistency, NOT that the bundle matches the producer's anchored log. Re-run with roots you " +
|
|
101
|
+
"obtained earlier or from the published roots file for a real verdict.");
|
|
102
|
+
}
|
|
103
|
+
// §5.4 chain fields, where the export carries them: the chain hash must recompute from the
|
|
104
|
+
// checkpoint's own fields, or the anchors that bind it would be binding something the bundle does
|
|
105
|
+
// not actually describe.
|
|
106
|
+
for (const cp of bundle.checkpoints) {
|
|
107
|
+
if (cp.chainHash == null)
|
|
108
|
+
continue;
|
|
109
|
+
const complete = typeof cp.prevChainHash === "string" &&
|
|
110
|
+
typeof cp.anchoredAt === "string" &&
|
|
111
|
+
typeof cp.entryCount === "number" &&
|
|
112
|
+
Number.isSafeInteger(cp.entryCount);
|
|
113
|
+
const recomputed = complete
|
|
114
|
+
? chainHash({
|
|
115
|
+
prevChainHash: cp.prevChainHash,
|
|
116
|
+
root: cp.root,
|
|
117
|
+
seqStart: cp.seqStart,
|
|
118
|
+
seqEnd: cp.seqEnd,
|
|
119
|
+
entryCount: cp.entryCount,
|
|
120
|
+
anchoredAt: cp.anchoredAt,
|
|
121
|
+
})
|
|
122
|
+
: null;
|
|
123
|
+
if (recomputed !== cp.chainHash) {
|
|
124
|
+
failed.push({
|
|
125
|
+
seq: "-",
|
|
126
|
+
reason: complete
|
|
127
|
+
? `checkpoint ${cp.id} chainHash does not recompute from its root, range, entry count and anchoredAt`
|
|
128
|
+
: `checkpoint ${cp.id} carries a chainHash without the prevChainHash/entryCount/anchoredAt it commits to`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// A checkpoint the caller holds its own record for must agree with that record on every field both
|
|
133
|
+
// state. The bundle's copy is the producer's; the caller's is what the roots-file chain verified. A
|
|
134
|
+
// re-dated `anchoredAt` with a self-consistent chainHash over a made-up predecessor recomputes fine
|
|
135
|
+
// above, and is caught only here.
|
|
136
|
+
for (const cp of bundle.checkpoints) {
|
|
137
|
+
const record = trustedRecordByRoot.get(cp.root);
|
|
138
|
+
if (!record)
|
|
139
|
+
continue;
|
|
140
|
+
const pairs = [
|
|
141
|
+
["seqStart", cp.seqStart, record.seqStart],
|
|
142
|
+
["seqEnd", cp.seqEnd, record.seqEnd],
|
|
143
|
+
["entryCount", cp.entryCount, record.entryCount],
|
|
144
|
+
["anchoredAt", cp.anchoredAt, record.anchoredAt],
|
|
145
|
+
["chainHash", cp.chainHash, record.chainHash],
|
|
146
|
+
];
|
|
147
|
+
for (const [field, shown, held] of pairs) {
|
|
148
|
+
if (shown != null && held != null && shown !== held) {
|
|
149
|
+
failed.push({
|
|
150
|
+
seq: "-",
|
|
151
|
+
reason: `checkpoint ${cp.id} ${field} contradicts your trusted checkpoint record for its root`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The position and time anchors over `root` are held to: the caller's record where it states a
|
|
158
|
+
* field, the bundle's checkpoint otherwise.
|
|
159
|
+
*/
|
|
160
|
+
const effectiveCheckpoint = (root, cp) => {
|
|
161
|
+
const record = trustedRecordByRoot.get(root);
|
|
162
|
+
return {
|
|
163
|
+
seqStart: record?.seqStart ?? cp?.seqStart ?? null,
|
|
164
|
+
seqEnd: record?.seqEnd ?? cp?.seqEnd ?? null,
|
|
165
|
+
entryCount: record?.entryCount ?? cp?.entryCount ?? null,
|
|
166
|
+
chainHash: record?.chainHash ?? cp?.chainHash ?? null,
|
|
167
|
+
anchoredAt: record?.anchoredAt ?? cp?.anchoredAt ?? null,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
const knownRoots = new Map();
|
|
171
|
+
/** Root → the checkpoint that states it (first wins), for the anchors' expected position. */
|
|
172
|
+
const checkpointByRoot = new Map();
|
|
173
|
+
const bundleAnchorsByRoot = new Map();
|
|
174
|
+
/** Checkpoint id OR root → root, so the caller may key its anchors by either. */
|
|
175
|
+
const checkpointKeyToRoot = new Map();
|
|
176
|
+
for (const cp of bundle.checkpoints) {
|
|
177
|
+
knownRoots.set(cp.root, cp.anchorRef);
|
|
178
|
+
if (!checkpointByRoot.has(cp.root))
|
|
179
|
+
checkpointByRoot.set(cp.root, cp);
|
|
180
|
+
if (cp.anchors?.length)
|
|
181
|
+
bundleAnchorsByRoot.set(cp.root, cp.anchors);
|
|
182
|
+
if (cp.id)
|
|
183
|
+
checkpointKeyToRoot.set(cp.id, cp.root);
|
|
184
|
+
}
|
|
185
|
+
// Roots last: a checkpoint whose id happens to equal another checkpoint's root must not shadow it.
|
|
186
|
+
for (const cp of bundle.checkpoints)
|
|
187
|
+
checkpointKeyToRoot.set(cp.root, cp.root);
|
|
188
|
+
// Caller-fetched anchors in two views: the flat union is the quorum candidate pool (as before —
|
|
189
|
+
// verifyAnchorQuorum only counts anchors whose dailyRoot IS the root under evaluation), while
|
|
190
|
+
// `callerAnchorsByRoot` records which checkpoint the caller said each one was fetched for. Only the
|
|
191
|
+
// second may feed divergence; see the quorum loop.
|
|
192
|
+
const callerAnchors = [];
|
|
193
|
+
const callerAnchorsByRoot = new Map();
|
|
194
|
+
const callerKeyed = Boolean(opts.anchors) && !Array.isArray(opts.anchors);
|
|
195
|
+
let unattributedKeys = false;
|
|
196
|
+
if (Array.isArray(opts.anchors)) {
|
|
197
|
+
callerAnchors.push(...opts.anchors);
|
|
198
|
+
}
|
|
199
|
+
else if (opts.anchors) {
|
|
200
|
+
for (const [key, list] of Object.entries(opts.anchors)) {
|
|
201
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
202
|
+
continue;
|
|
203
|
+
callerAnchors.push(...list);
|
|
204
|
+
const root = checkpointKeyToRoot.get(key);
|
|
205
|
+
if (root === undefined) {
|
|
206
|
+
// Keyed to a checkpoint this bundle does not contain. Still a quorum candidate, but it
|
|
207
|
+
// cannot be divergence evidence for a checkpoint nobody can identify.
|
|
208
|
+
unattributedKeys = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
callerAnchorsByRoot.set(root, [...(callerAnchorsByRoot.get(root) ?? []), ...list]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// One committed event appears once. A genuine leaf used twice (or two entries claiming one seq) can
|
|
215
|
+
// otherwise fill two holes in the tenant sequence from a single real event.
|
|
216
|
+
const seenLeaves = new Set();
|
|
217
|
+
const seenSeqs = new Set();
|
|
218
|
+
/** Leaf counts are properties of a TREE: every proof into one block/checkpoint must agree on them. */
|
|
219
|
+
const blockLeafCounts = new Map();
|
|
220
|
+
const checkpointLeafCounts = new Map();
|
|
221
|
+
for (const entry of bundle.entries) {
|
|
222
|
+
const seq = entry.event.seq;
|
|
223
|
+
const root = entry.proof.checkpointRoot;
|
|
224
|
+
if (seenLeaves.has(entry.proof.leaf) || seenSeqs.has(seq)) {
|
|
225
|
+
failed.push({ seq, reason: "duplicate entry: this leaf or seq already appears in the bundle" });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
seenLeaves.add(entry.proof.leaf);
|
|
229
|
+
seenSeqs.add(seq);
|
|
230
|
+
if (!root) {
|
|
231
|
+
failed.push({
|
|
232
|
+
seq,
|
|
233
|
+
reason: "no checkpoint root (event not committed at export time)",
|
|
234
|
+
});
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!knownRoots.has(root)) {
|
|
238
|
+
failed.push({
|
|
239
|
+
seq,
|
|
240
|
+
reason: "proof's checkpoint root is not in the bundle's checkpoint list",
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (trusted && !trusted.has(root)) {
|
|
245
|
+
failed.push({
|
|
246
|
+
seq,
|
|
247
|
+
reason: "proof's checkpoint root is not among the supplied trusted roots",
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!verifyInclusionProof(entry.proof, root)) {
|
|
252
|
+
failed.push({
|
|
253
|
+
seq,
|
|
254
|
+
reason: "inclusion proof does not recompute to the daily root",
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// DEWP §17.3: the prover supplies the leaf counts, so an interior node of a larger block can
|
|
259
|
+
// otherwise pass as a leaf of a smaller one. Bind them to each other and to the entry count.
|
|
260
|
+
const priorBlock = blockLeafCounts.get(entry.proof.blockRoot);
|
|
261
|
+
const priorCheckpoint = checkpointLeafCounts.get(root);
|
|
262
|
+
const countBad = (priorBlock !== undefined && priorBlock !== entry.proof.blockLeafCount) ||
|
|
263
|
+
(priorCheckpoint !== undefined && priorCheckpoint !== entry.proof.checkpointLeafCount)
|
|
264
|
+
? "proofs into the same block or checkpoint disagree on its leaf count"
|
|
265
|
+
: leafCountMismatch(entry.proof, effectiveCheckpoint(root, checkpointByRoot.get(root)).entryCount);
|
|
266
|
+
if (countBad) {
|
|
267
|
+
failed.push({ seq, reason: countBad });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
blockLeafCounts.set(entry.proof.blockRoot, entry.proof.blockLeafCount);
|
|
271
|
+
checkpointLeafCounts.set(root, entry.proof.checkpointLeafCount);
|
|
272
|
+
// A redaction marker is read from the bundle, so it must not be able to switch off a check the
|
|
273
|
+
// entry's own data would otherwise satisfy. DEWP §15 says a verifier MUST NOT attempt
|
|
274
|
+
// contentVerified for a COMMITMENT_ONLY entry *because the preimage is intentionally absent* —
|
|
275
|
+
// an entry that ships a preimage AND claims redaction is self-contradictory, and the preimage is
|
|
276
|
+
// the thing that can actually be checked. Bind it. Otherwise a fabricated `canonical` plus
|
|
277
|
+
// `redaction: {mode: "COMMITMENT_ONLY"}` relabels any real committed leaf to anything at all.
|
|
278
|
+
if (isRedacted(entry.event) && !entry.event.canonical) {
|
|
279
|
+
// §15: a COMMITMENT_ONLY entry MUST keep the ORIGINAL leaf hash — it is what still verifies
|
|
280
|
+
// against the anchored root. If the redaction record names a different one, the commitment was
|
|
281
|
+
// rewritten during redaction, which is exactly what redaction must not be able to do.
|
|
282
|
+
const retained = entry.event.redaction?.commitment?.leaf;
|
|
283
|
+
if (retained !== undefined && retained !== entry.proof.leaf) {
|
|
284
|
+
failed.push({
|
|
285
|
+
seq,
|
|
286
|
+
reason: "redaction commitment leaf does not match the proof leaf (commitment was altered)",
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
redactedDisplayed++;
|
|
291
|
+
commitmentOnly++;
|
|
292
|
+
}
|
|
293
|
+
else if (unknownProfile && entry.event.canonical) {
|
|
294
|
+
// The preimage cannot be bound under a layout this verifier does not implement, and a pass here
|
|
295
|
+
// would let the producer switch leaf binding off (DEWP §4.5/§7.2 rule 1) — mirrors verifyBundle.
|
|
296
|
+
failed.push({
|
|
297
|
+
seq,
|
|
298
|
+
reason: `canonical preimage under unknown profile "${bundle.profile}" cannot be bound to its leaf`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else if (unknownProfile) {
|
|
302
|
+
// Commitment verified above; there is no content to bind.
|
|
303
|
+
commitmentOnly++;
|
|
304
|
+
}
|
|
305
|
+
else if (entry.event.canonical) {
|
|
306
|
+
if (leafHash(entry.event.canonical) !== entry.proof.leaf) {
|
|
307
|
+
failed.push({
|
|
308
|
+
seq,
|
|
309
|
+
reason: "leaf hash does not match the event content (leaf binding failed)",
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
// The leaf commits to `canonical`. Everything ALONGSIDE it on the entry is a display copy that
|
|
314
|
+
// nothing signs, so it has to be checked against the committed value or it is just a caption.
|
|
315
|
+
const bad = displayMismatch(entry.event);
|
|
316
|
+
if (bad) {
|
|
317
|
+
failed.push({ seq, reason: bad });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
// The redaction record is unsigned. Where a preimage exists it is not a counter source (§7.2),
|
|
321
|
+
// so one that disagrees with the committed counter is a caption contradicting the evidence.
|
|
322
|
+
const redactionSeq = entry.event.redaction?.commitment?.tenantSeq;
|
|
323
|
+
if (redactionSeq != null && redactionSeq !== entry.event.canonical.tenantSeq) {
|
|
324
|
+
failed.push({
|
|
325
|
+
seq,
|
|
326
|
+
reason: `redaction record tenantSeq ("${redactionSeq}") does not match the committed value ("${String(entry.event.canonical.tenantSeq)}")`,
|
|
327
|
+
});
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// The entry belongs to THIS bundle's tenant. canonical.tenantId is committed; without this an
|
|
331
|
+
// entry from another tenant, with a genuine proof under a root the auditor trusts, counts as
|
|
332
|
+
// one of this tenant's own records. A bundle that declares no tenant has none to belong to:
|
|
333
|
+
// skipping the check there let entries of several tenants be spliced into one "contiguous"
|
|
334
|
+
// sequence, since tenantSeq is a per-tenant counter.
|
|
335
|
+
if (entry.event.canonical.tenantId != null && entry.event.canonical.tenantId !== bundleTenantId) {
|
|
336
|
+
failed.push({
|
|
337
|
+
seq,
|
|
338
|
+
reason: bundleTenantId == null
|
|
339
|
+
? `entry belongs to tenant ${entry.event.canonical.tenantId}, but the bundle declares no tenant`
|
|
340
|
+
: `entry belongs to tenant ${entry.event.canonical.tenantId}, not ${bundleTenantId}`,
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
// §7.1 signatureVerified. Runs only once the leaf binding above passed, so the signature we
|
|
345
|
+
// check is provably the committed one rather than something the bundle attached.
|
|
346
|
+
const canonical = entry.event.canonical;
|
|
347
|
+
const signature = verifyAuditSignature(canonical, opts.signaturePolicy);
|
|
348
|
+
signatureChecks.set(seq, signature);
|
|
349
|
+
if (signature.status === "verified")
|
|
350
|
+
signaturesVerified++;
|
|
351
|
+
else if (signature.status === "invalid")
|
|
352
|
+
signaturesInvalid.push({ seq });
|
|
353
|
+
else
|
|
354
|
+
signaturesNotCheckable++;
|
|
355
|
+
contentVerified++;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
failed.push({
|
|
359
|
+
seq,
|
|
360
|
+
reason: "unredacted entry is missing its canonical preimage",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Check per-tenant sequence contiguity (completeness / omission detection). DEWP §3 Invariant 4
|
|
365
|
+
// requires rejecting gaps, duplicates AND out-of-order values, so compare against tenantSeq_N + 1
|
|
366
|
+
// exactly rather than merely checking for forward movement.
|
|
367
|
+
//
|
|
368
|
+
// The counter is read from the COMMITTED preimage first. Reading `entry.tenantSeq` first was the
|
|
369
|
+
// bug: that field is a sibling of `canonical` and is covered by nothing — not the leaf, not the
|
|
370
|
+
// root, not any anchor. A producer could omit the incriminating events, keep the honest entries
|
|
371
|
+
// with their genuine proofs, renumber the display counters to close the hole, and the contiguity
|
|
372
|
+
// check would report no gap (verified). A redacted entry has no preimage, so it falls back to the
|
|
373
|
+
// redaction commitment and then to the entry, or the hole lawful redaction leaves would read as an
|
|
374
|
+
// omission — but those values are NOT leaf-bound, which the note below now says plainly.
|
|
375
|
+
//
|
|
376
|
+
// The fallback is for entries WITHOUT a preimage only (§7.2 rule 2). An entry with a preimage whose
|
|
377
|
+
// committed tenantSeq is null has no counter (rule 5): falling through to the unsigned redaction
|
|
378
|
+
// record let a genuine tenantless system leaf, tagged `redaction: {mode: "NONE", commitment:
|
|
379
|
+
// {tenantSeq: "N"}}`, fill tenant T's hole at N while counting as fully content-verified.
|
|
380
|
+
let lastTenantSeq = null;
|
|
381
|
+
let firstTenantSeq = null;
|
|
382
|
+
let sawUncountedEntry = false;
|
|
383
|
+
let sawUnboundCounter = false;
|
|
384
|
+
for (const entry of bundle.entries) {
|
|
385
|
+
const canonical = entry.event.canonical;
|
|
386
|
+
// Under an unknown profile a preimage is not leaf-bound; that entry has already failed above.
|
|
387
|
+
if (canonical && unknownProfile)
|
|
388
|
+
continue;
|
|
389
|
+
const tenantSeqStr = canonical
|
|
390
|
+
? canonical.tenantSeq
|
|
391
|
+
: (entry.event.redaction?.commitment?.tenantSeq ?? entry.event.tenantSeq);
|
|
392
|
+
if (!canonical && tenantSeqStr != null)
|
|
393
|
+
sawUnboundCounter = true;
|
|
394
|
+
if (tenantSeqStr == null) {
|
|
395
|
+
sawUncountedEntry = true;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const currentTenantSeq = parseCounter(tenantSeqStr);
|
|
399
|
+
if (currentTenantSeq === null) {
|
|
400
|
+
failed.push({
|
|
401
|
+
seq: entry.event.seq,
|
|
402
|
+
reason: `tenantSeq ${JSON.stringify(tenantSeqStr)} is not a valid integer counter`,
|
|
403
|
+
});
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (lastTenantSeq !== null && currentTenantSeq !== lastTenantSeq + 1n) {
|
|
407
|
+
failed.push({
|
|
408
|
+
seq: entry.event.seq,
|
|
409
|
+
reason: currentTenantSeq <= lastTenantSeq
|
|
410
|
+
? `per-tenant sequence is not strictly increasing: tenantSeq ${currentTenantSeq.toString()} follows ${lastTenantSeq.toString()}`
|
|
411
|
+
: `per-tenant omission detected: sequence gap between tenantSeq ${lastTenantSeq.toString()} and ${currentTenantSeq.toString()}`,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (firstTenantSeq === null)
|
|
415
|
+
firstTenantSeq = currentTenantSeq;
|
|
416
|
+
lastTenantSeq = currentTenantSeq;
|
|
417
|
+
}
|
|
418
|
+
if (redactedDisplayed > 0) {
|
|
419
|
+
notes.push(`${redactedDisplayed} entr${redactedDisplayed === 1 ? "y is" : "ies are"} COMMITMENT_ONLY: inclusion ` +
|
|
420
|
+
"is proven against the retained leaf, but the displayed type/outcome/detail are NOT covered by " +
|
|
421
|
+
"it — the preimage they would be checked against is gone. They rest on the producer's redaction " +
|
|
422
|
+
"record (DEWP §15), not on the anchored log.");
|
|
423
|
+
}
|
|
424
|
+
if (sawUncountedEntry) {
|
|
425
|
+
notes.push("Some entries carry no tenantSeq, so gapless completeness could not be checked across them. " +
|
|
426
|
+
"(Bundles exported before redacted entries carried tenantSeq — re-export for a complete check.)");
|
|
427
|
+
}
|
|
428
|
+
if (sawUnboundCounter) {
|
|
429
|
+
notes.push("Some entries carry no canonical preimage (COMMITMENT_ONLY redaction), so their tenantSeq was read " +
|
|
430
|
+
"from the redaction record or the display copy and is NOT covered by the Merkle leaf. " +
|
|
431
|
+
"Gaplessness across those rests on the producer's redaction record, not on the anchored log.");
|
|
432
|
+
}
|
|
433
|
+
// DEWP §6.3: the bundle asserts WHICH contiguous tenantSeq range it covers. Confirm the entries
|
|
434
|
+
// actually span it — contiguity alone only proves the entries present are consecutive, not that the
|
|
435
|
+
// producer did not quietly truncate either end of the range it claimed to export.
|
|
436
|
+
const commitment = bundle.tenantSequenceCommitment;
|
|
437
|
+
if (commitment) {
|
|
438
|
+
const firstSeen = firstTenantSeq;
|
|
439
|
+
const lastSeen = lastTenantSeq;
|
|
440
|
+
if (firstSeen === null || lastSeen === null) {
|
|
441
|
+
notes.push("Bundle declares a tenantSequenceCommitment but no entry carries a tenantSeq to check it against.");
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
const claimedFirst = parseCounter(commitment.firstTenantSeq);
|
|
445
|
+
const claimedLast = parseCounter(commitment.lastTenantSeq);
|
|
446
|
+
if (claimedFirst === null || claimedLast === null) {
|
|
447
|
+
failed.push({
|
|
448
|
+
seq: bundle.entries[0]?.event.seq ?? "?",
|
|
449
|
+
reason: "tenantSequenceCommitment carries a non-numeric firstTenantSeq/lastTenantSeq",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
if (firstSeen !== claimedFirst)
|
|
454
|
+
failed.push({
|
|
455
|
+
seq: bundle.entries[0]?.event.seq ?? "?",
|
|
456
|
+
reason: `bundle claims it starts at tenantSeq ${commitment.firstTenantSeq} but the first entry is ${firstSeen.toString()}`,
|
|
457
|
+
});
|
|
458
|
+
if (lastSeen !== claimedLast)
|
|
459
|
+
failed.push({
|
|
460
|
+
seq: bundle.entries.at(-1)?.event.seq ?? "?",
|
|
461
|
+
reason: `bundle claims it ends at tenantSeq ${commitment.lastTenantSeq} but the last entry is ${lastSeen.toString()}`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (commitment.tenantId !== bundleTenantId)
|
|
465
|
+
notes.push(`tenantSequenceCommitment names tenant ${commitment.tenantId}, which differs from the bundle's tenant ${String(bundleTenantId)}.`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// DEWP §5.3 anchor quorum, per distinct root. Candidate anchors come from the caller when
|
|
469
|
+
// supplied, otherwise from the bundle's own per-checkpoint `anchors` — the §6.3 set the quorum
|
|
470
|
+
// rule is defined over. Either way the check only runs when the caller supplied a policy AND a key
|
|
471
|
+
// resolver: signatures are checked against keys the VERIFIER trusts, so a bundle cannot vouch for
|
|
472
|
+
// itself by shipping anchors it signed with its own key.
|
|
473
|
+
//
|
|
474
|
+
// Bundle-carried anchors may COUNT toward quorum but may never trigger the fatal DIVERGENCE
|
|
475
|
+
// verdict (mirrors verifyBundle): the producer chooses what the bundle carries, so its anchors are
|
|
476
|
+
// no evidence that nothing conflicting exists, and appending a real, publicly available anchor
|
|
477
|
+
// from another checkpoint must never make a valid bundle read as tampering. Only anchors the caller
|
|
478
|
+
// fetched itself, per checkpoint, can establish divergence — and one whose signed seq range names
|
|
479
|
+
// another checkpoint cannot.
|
|
480
|
+
//
|
|
481
|
+
// The same misbinding applies to the caller's own anchors once a bundle spans more than one
|
|
482
|
+
// checkpoint, which a date-range export routinely does: passing the whole flat list to EVERY root
|
|
483
|
+
// read a genuine anchor for checkpoint A as divergence while evaluating checkpoint B, and turned a
|
|
484
|
+
// sound multi-day export into a tamper alarm. So caller anchors are attributed to a checkpoint
|
|
485
|
+
// first. The keyed form is exact. A flat list can only be attributed by elimination — an anchor
|
|
486
|
+
// over ANOTHER checkpoint's root in this same bundle is explainable and is therefore not
|
|
487
|
+
// divergence evidence — which keeps the single-checkpoint behaviour identical and leaves one gap
|
|
488
|
+
// stated in the note below: a producer who appends a decoy checkpoint carrying the real root can
|
|
489
|
+
// absorb the conflicting anchor that way. That downgrades the verdict from DIVERGENCE to "quorum
|
|
490
|
+
// not met" for the checkpoint it forged, never to ok.
|
|
491
|
+
// Gated on the CALLER having asked (policy + resolver), never on candidates existing: a producer
|
|
492
|
+
// who strips `checkpoints[].anchors` must get "quorum not met (0/N)", not a skipped check. Gating
|
|
493
|
+
// on candidates was the same shape as the trap verifyBundle documents — a check the prover can
|
|
494
|
+
// switch off — with the CLI then printing VERIFIED under a policy nobody evaluated.
|
|
495
|
+
const canCheckAnchors = Boolean(opts.anchorPolicy && (opts.resolveAnchorKey || opts.externalKeys));
|
|
496
|
+
const usedBundleAnchors = canCheckAnchors && callerAnchors.length === 0 && bundleAnchorsByRoot.size > 0;
|
|
497
|
+
const roots = [...knownRoots.entries()].map(([root, anchorRef]) => {
|
|
498
|
+
if (!canCheckAnchors || !opts.anchorPolicy) {
|
|
499
|
+
return {
|
|
500
|
+
root,
|
|
501
|
+
anchorRef,
|
|
502
|
+
anchorVerified: null,
|
|
503
|
+
verifiedIssuers: [],
|
|
504
|
+
witnessTimes: {},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
// Anchors bind a POSITION: they count for this root only if their signed range, chain hash and
|
|
508
|
+
// time are this checkpoint's own.
|
|
509
|
+
const expected = effectiveCheckpoint(root, checkpointByRoot.get(root));
|
|
510
|
+
// §5.3/§6.3: an anchor is held to its checkpoint's chain hash and claimed time. A checkpoint that
|
|
511
|
+
// states neither (and has no caller record to supply them) cannot hold anything to anything, and
|
|
512
|
+
// skipping it silently is what let a producer strip `anchoredAt`/`chainHash` to turn off the time
|
|
513
|
+
// bound. So it never counts as anchored — divergence is still evaluated, so stripping the fields
|
|
514
|
+
// cannot also turn a tamper alarm into a mere "not anchored".
|
|
515
|
+
const positionUnknown = expected.chainHash == null || expected.anchoredAt == null;
|
|
516
|
+
if (positionUnknown) {
|
|
517
|
+
notes.push(`Root ${root.slice(0, 16)}…: its checkpoint carries no chainHash/anchoredAt and no trusted checkpoint ` +
|
|
518
|
+
"record supplies them, so its anchors cannot be held to a position and time (DEWP §5.3) — not anchored.");
|
|
519
|
+
}
|
|
520
|
+
const candidates = callerAnchors.length ? callerAnchors : (bundleAnchorsByRoot.get(root) ?? []);
|
|
521
|
+
const divergenceAnchors = callerKeyed
|
|
522
|
+
? (callerAnchorsByRoot.get(root) ?? [])
|
|
523
|
+
: callerAnchors.filter((a) => a.dailyRoot === root || !knownRoots.has(a.dailyRoot));
|
|
524
|
+
const q = verifyAnchorQuorum(candidates, root, opts.anchorPolicy, opts.resolveAnchorKey ?? (() => null), {
|
|
525
|
+
divergenceAnchors,
|
|
526
|
+
externalKeys: opts.externalKeys,
|
|
527
|
+
checkpoint: {
|
|
528
|
+
seqStart: expected.seqStart,
|
|
529
|
+
seqEnd: expected.seqEnd,
|
|
530
|
+
chainHash: expected.chainHash,
|
|
531
|
+
anchoredAt: expected.anchoredAt,
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
if (q.divergence) {
|
|
535
|
+
failed.push({ seq: "-", reason: `ANCHOR DIVERGENCE for root ${root.slice(0, 16)}…: ${q.reason}` });
|
|
536
|
+
}
|
|
537
|
+
else if (!q.ok && q.reason) {
|
|
538
|
+
notes.push(`Root ${root.slice(0, 16)}…: ${q.reason}`);
|
|
539
|
+
}
|
|
540
|
+
// Report TSA evidence that could not be verified under the caller's configuration.
|
|
541
|
+
if (q.note)
|
|
542
|
+
notes.push(`Root ${root.slice(0, 16)}…: ${q.note}`);
|
|
543
|
+
return {
|
|
544
|
+
root,
|
|
545
|
+
anchorRef,
|
|
546
|
+
anchorVerified: q.ok && !positionUnknown,
|
|
547
|
+
verifiedIssuers: positionUnknown ? [] : q.verifiedIssuers,
|
|
548
|
+
witnessTimes: q.witnessTimes,
|
|
549
|
+
};
|
|
550
|
+
});
|
|
551
|
+
if (usedBundleAnchors) {
|
|
552
|
+
notes.push("Anchor quorum was evaluated over the anchors carried in the bundle's checkpoints. They verify " +
|
|
553
|
+
"only under keys YOU trust, so the result is sound — but divergence detection needs anchors " +
|
|
554
|
+
"you fetched per checkpoint yourself (pass `anchors`).");
|
|
555
|
+
}
|
|
556
|
+
if (canCheckAnchors && callerAnchors.length > 0 && !callerKeyed && knownRoots.size > 1) {
|
|
557
|
+
notes.push(`The anchors you supplied came as one flat list while this bundle spans ${knownRoots.size} checkpoints, ` +
|
|
558
|
+
"so each one could only be attributed by its own root. Divergence is therefore reported only for " +
|
|
559
|
+
"anchors over a root this bundle does not claim at all. Key them by checkpoint id or root " +
|
|
560
|
+
"(`anchors: { [checkpointId]: [...] }`) for a per-checkpoint divergence verdict.");
|
|
561
|
+
}
|
|
562
|
+
if (unattributedKeys) {
|
|
563
|
+
notes.push("Some supplied anchors are keyed to a checkpoint id/root this bundle does not contain. They still " +
|
|
564
|
+
"count toward quorum for a root they sign, but they cannot establish divergence — check the keys " +
|
|
565
|
+
"against `checkpoints[]`, because a producer renaming a checkpoint would look exactly like this.");
|
|
566
|
+
}
|
|
567
|
+
if (!canCheckAnchors && !(opts.anchorPolicy || opts.resolveAnchorKey)) {
|
|
568
|
+
notes.push("No anchor policy supplied — the roots above were compared, but no independent signature over " +
|
|
569
|
+
"them was checked. Pass anchorPolicy + resolveAnchorKey (and optionally your own anchors) " +
|
|
570
|
+
"for a DEWP §5.3 verdict.");
|
|
571
|
+
}
|
|
572
|
+
else if (!canCheckAnchors) {
|
|
573
|
+
notes.push("Anchor policy incomplete — anchorPolicy and either resolveAnchorKey or externalKeys are required for a DEWP " +
|
|
574
|
+
"§5.3 verdict; neither alone can check a signature.");
|
|
575
|
+
}
|
|
576
|
+
// When a policy WAS supplied, every root must reach quorum; a bundle resting on an unanchored root
|
|
577
|
+
// is not independently attested no matter how well its proofs verify.
|
|
578
|
+
const allAnchored = !opts.anchorPolicy || (canCheckAnchors && roots.every((r) => r.anchorVerified === true));
|
|
579
|
+
const ok = failed.length === 0 && bundle.entries.length > 0 && !!trusted && allAnchored;
|
|
580
|
+
if (!trusted && failed.length === 0 && bundle.entries.length > 0) {
|
|
581
|
+
notes.push("All entries internally consistent; supply --roots for an independent verdict.");
|
|
582
|
+
}
|
|
583
|
+
if (signaturesInvalid.length > 0) {
|
|
584
|
+
notes.push(`${signaturesInvalid.length} entr${signaturesInvalid.length === 1 ? "y" : "ies"} carr${signaturesInvalid.length === 1 ? "ies" : "y"} ES256 proof material that does NOT verify (seq ${signaturesInvalid
|
|
585
|
+
.map((s) => s.seq)
|
|
586
|
+
.join(", ")}). The signature bytes are themselves committed, so this is not bundle tampering — ` +
|
|
587
|
+
"it means the producer anchored a signature that does not check out.");
|
|
588
|
+
}
|
|
589
|
+
const checks = bundle.entries.map((e) => ({
|
|
590
|
+
seq: e.event.seq,
|
|
591
|
+
...(signatureChecks.get(e.event.seq) ?? uncheckedSignature()),
|
|
592
|
+
}));
|
|
593
|
+
if (opts.requireSignatures)
|
|
594
|
+
for (const check of checks) {
|
|
595
|
+
if (check.status !== "verified" || !check.trusted)
|
|
596
|
+
failed.push({ seq: check.seq, reason: `Required trusted signature: ${check.reason}` });
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
ok: ok && failed.length === 0,
|
|
600
|
+
total: bundle.entries.length,
|
|
601
|
+
contentVerified,
|
|
602
|
+
commitmentOnly,
|
|
603
|
+
failed,
|
|
604
|
+
roots,
|
|
605
|
+
signatures: {
|
|
606
|
+
checks,
|
|
607
|
+
verified: signaturesVerified,
|
|
608
|
+
invalid: signaturesInvalid,
|
|
609
|
+
notCheckable: signaturesNotCheckable,
|
|
610
|
+
},
|
|
611
|
+
notes,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface AuditLeaf {
|
|
2
|
+
seq: string;
|
|
3
|
+
tenantSeq?: string | null;
|
|
4
|
+
createdAt: string;
|
|
5
|
+
event: string;
|
|
6
|
+
outcome: string;
|
|
7
|
+
detail: string | null;
|
|
8
|
+
metadata: unknown;
|
|
9
|
+
signerDid: string | null;
|
|
10
|
+
signerPublicKey: string | null;
|
|
11
|
+
signedPayload: string | null;
|
|
12
|
+
signature: string | null;
|
|
13
|
+
sigAlg: string | null;
|
|
14
|
+
isBillable: boolean;
|
|
15
|
+
tenantId: string | null;
|
|
16
|
+
actorNodeId: string | null;
|
|
17
|
+
subjectNodeId: string | null;
|
|
18
|
+
edgeId: string | null;
|
|
19
|
+
challengeId: string | null;
|
|
20
|
+
}
|
|
21
|
+
/** The 18-field ordered array that gets JSON-stringified into the leaf preimage. Keep in lockstep
|
|
22
|
+
* with the producer's `leafHash` (packages/db/src/checkpoint.ts). */
|
|
23
|
+
export declare function canonicalPreimage(row: AuditLeaf): string;
|
|
24
|
+
/** Domain-separated leaf digest over the full event content. */
|
|
25
|
+
export declare function leafHash(row: AuditLeaf): string;
|