@zanii/custody-verdicts 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +19 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +81 -45
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/index.ts +1412 -1357
package/src/index.ts
CHANGED
|
@@ -1,1357 +1,1412 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @zanii/custody-verdicts: the one verifier. One verifier, two surfaces: the subject
|
|
3
|
-
* page (zanii-id) and `@zanii/cli` both import this package and call `computeVerdicts`.
|
|
4
|
-
* Never two implementations of the verdict logic: two drift, and the drift always
|
|
5
|
-
* favours the vendor.
|
|
6
|
-
*
|
|
7
|
-
* Verdicts are **computed, never stored**. Input is a `SubjectHistory` from
|
|
8
|
-
* `@zanii/subject` (`fetchSubjectHistory`): the subject's receipt slice, each receipt
|
|
9
|
-
* already verified offline (signature + delegation + scope), plus the signed tree
|
|
10
|
-
* head. SpiritWave's custody receipts embed the `@zanii/data-custody` touch payload
|
|
11
|
-
* inside the receipt as a signature-covered `payload` field (the ledger has no
|
|
12
|
-
* payload store); row 1 re-checks every embedded payload with `verifyTouch`, and
|
|
13
|
-
* tolerates the other custody kind this package itself consumes: a `@zanii/retention`
|
|
14
|
-
* crypto-shredding payload (`verifyKeyDestruction`), which row 5 reads as the
|
|
15
|
-
* key-destruction evidence.
|
|
16
|
-
*
|
|
17
|
-
* Four states per line: `proven`, `not covered`, `failed`, `cannot determine`. The
|
|
18
|
-
* last three are as first-class as the first: a missing proof is NOT a clean result
|
|
19
|
-
* ("no declared training runs to check against", never "never"). A bad receipt is a
|
|
20
|
-
* red, not a grey, and a presented-but-broken proof is a red too.
|
|
21
|
-
*
|
|
22
|
-
* The function is pure: no network, no clock (`computeAnchorLag` takes `now` from the
|
|
23
|
-
* caller). It is async for exactly one reason: row 3's coverage attestation is checked
|
|
24
|
-
* through `@zanii/attest`'s `verifyAttestation`, whose injected quote verifier may be
|
|
25
|
-
* async. Verification paths never throw: malformed input yields `cannot determine` /
|
|
26
|
-
* `not covered` / `failed` rows with reasons, per the codebase convention (creation
|
|
27
|
-
* paths throw, verification paths return results).
|
|
28
|
-
*
|
|
29
|
-
* Rows 3–5 consume real producer evidence, checked here, narrowly and offline:
|
|
30
|
-
* row 3 a SIGNED coverage attestation (`@zanii/attest` verifies the embedded
|
|
31
|
-
* attestation with an injected quote verifier; the producer side ships with the
|
|
32
|
-
* enclave phase, so until then `proven` is unreachable, by design), row 4 a declared,
|
|
33
|
-
* anchor-ordered training manifest plus `@zanii/train`'s sorted-Merkle `AbsenceProof`
|
|
34
|
-
* (`verifyAbsence`), row 5 the `@zanii/retention` shred receipts carried by the slice
|
|
35
|
-
* itself (`verifyKeyDestruction`), optionally bound to a published image hash via
|
|
36
|
-
* `@zanii/attest`'s `attestationHash` convention.
|
|
37
|
-
*
|
|
38
|
-
* Rows 6–8 consume the slice the same narrow way: row 6 residency claims
|
|
39
|
-
* (`@zanii/residency`'s `buildResidencyClaim` shape) verified with an injected region
|
|
40
|
-
* verifier against the reader-pinned jurisdiction allow-set, plus `detectCrossings`
|
|
41
|
-
* over the verified touch sequence; row 7 onward transfers (`@zanii/subprocessor`'s
|
|
42
|
-
* `buildTransfer` shape) and custody-touch junctions checked against the
|
|
43
|
-
* reader-pinned declared subprocessor list via `verifyTransfers`; row 8 field-set
|
|
44
|
-
* commitments (`@zanii/minimization`'s `commitFieldSet` records) and access proofs
|
|
45
|
-
* (`buildAccessProof`) checked with `verifyMinimization`. The jurisdiction allow-set
|
|
46
|
-
* and the declared subprocessor list are READER configuration (ExtraEvidence), never
|
|
47
|
-
* producer-supplied: the same trust rule as `coverage.trustedSigners`.
|
|
48
|
-
*/
|
|
49
|
-
import { ed25519 } from '@noble/curves/ed25519';
|
|
50
|
-
import { hexToBytes } from '@noble/hashes/utils';
|
|
51
|
-
import {
|
|
52
|
-
canonicalBytes, publicKeyFromDid, verifySTH,
|
|
53
|
-
type Receipt, type SignedTreeHead,
|
|
54
|
-
} from '@zanii/core';
|
|
55
|
-
import { verifyTouch } from '@zanii/data-custody';
|
|
56
|
-
import { verifyKeyDestruction } from '@zanii/retention';
|
|
57
|
-
import { verifyAbsence, type AbsenceProof, type TrainingManifest } from '@zanii/train';
|
|
58
|
-
import {
|
|
59
|
-
attestationHash, verifyAttestation,
|
|
60
|
-
type Attestation, type AttestCheck, type QuoteVerifier,
|
|
61
|
-
} from '@zanii/attest';
|
|
62
|
-
import {
|
|
63
|
-
detectCrossings, verifyResidency,
|
|
64
|
-
type RegionVerifier, type ResidencyClaim,
|
|
65
|
-
} from '@zanii/residency';
|
|
66
|
-
import { verifyTransfers, type Agreement, type Transfer } from '@zanii/subprocessor';
|
|
67
|
-
import { verifyFieldSet, verifyMinimization, type AccessProof } from '@zanii/minimization';
|
|
68
|
-
import type { SubjectHistory, SubjectReceipt } from '@zanii/subject';
|
|
69
|
-
|
|
70
|
-
export type { AbsenceProof, TrainingManifest } from '@zanii/train';
|
|
71
|
-
export type { Attestation, QuoteVerifier } from '@zanii/attest';
|
|
72
|
-
export type { RegionVerifier, ResidencyClaim } from '@zanii/residency';
|
|
73
|
-
export type { Agreement, Transfer } from '@zanii/subprocessor';
|
|
74
|
-
export type { AccessProof } from '@zanii/minimization';
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The well-known ledger tag under which declared training manifests are published.
|
|
78
|
-
* The subject page and the CLI fetch that slice to build the row-4 training evidence
|
|
79
|
-
* bag (`ExtraEvidence.training`).
|
|
80
|
-
*/
|
|
81
|
-
export const TRAINING_MANIFEST_TAG = 'zanii:training-manifests';
|
|
82
|
-
|
|
83
|
-
/** The four verdict states. The last three are as loud as the first. */
|
|
84
|
-
export type VerdictState = 'proven' | 'not covered' | 'failed' | 'cannot determine';
|
|
85
|
-
|
|
86
|
-
export type VerdictId =
|
|
87
|
-
| 'purpose' | 'self_sovereign' | 'human_read' | 'trained_on' | 'copy_kept'
|
|
88
|
-
| 'residency' | 'subprocessor' | 'minimization';
|
|
89
|
-
|
|
90
|
-
export interface VerdictRow {
|
|
91
|
-
id: VerdictId;
|
|
92
|
-
label: string;
|
|
93
|
-
state: VerdictState;
|
|
94
|
-
/** Plain-language working: which receipts were computed over, why this state. Rendered verbatim. */
|
|
95
|
-
detail: string;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Coverage attestation for the "nobody read it" line: a SIGNED record asserting that
|
|
100
|
-
* every egress path to the data was receipt-gated (the `@zanii/broker` invariant) for
|
|
101
|
-
* the whole window, bound to the attested image whose code enforced it. The signature
|
|
102
|
-
* covers every field but `sig`; `imageHash` must equal `attestationHash(attestation)`
|
|
103
|
-
* (`@zanii/attest`'s convention)
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
attestation:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
*
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
const
|
|
283
|
-
if (!
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const
|
|
520
|
-
if (
|
|
521
|
-
return {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
if (
|
|
820
|
-
return {
|
|
821
|
-
id,
|
|
822
|
-
label: LABELS[id],
|
|
823
|
-
state: 'failed',
|
|
824
|
-
detail:
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
if (
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @zanii/custody-verdicts: the one verifier. One verifier, two surfaces: the subject
|
|
3
|
+
* page (zanii-id) and `@zanii/cli` both import this package and call `computeVerdicts`.
|
|
4
|
+
* Never two implementations of the verdict logic: two drift, and the drift always
|
|
5
|
+
* favours the vendor.
|
|
6
|
+
*
|
|
7
|
+
* Verdicts are **computed, never stored**. Input is a `SubjectHistory` from
|
|
8
|
+
* `@zanii/subject` (`fetchSubjectHistory`): the subject's receipt slice, each receipt
|
|
9
|
+
* already verified offline (signature + delegation + scope), plus the signed tree
|
|
10
|
+
* head. SpiritWave's custody receipts embed the `@zanii/data-custody` touch payload
|
|
11
|
+
* inside the receipt as a signature-covered `payload` field (the ledger has no
|
|
12
|
+
* payload store); row 1 re-checks every embedded payload with `verifyTouch`, and
|
|
13
|
+
* tolerates the other custody kind this package itself consumes: a `@zanii/retention`
|
|
14
|
+
* crypto-shredding payload (`verifyKeyDestruction`), which row 5 reads as the
|
|
15
|
+
* key-destruction evidence.
|
|
16
|
+
*
|
|
17
|
+
* Four states per line: `proven`, `not covered`, `failed`, `cannot determine`. The
|
|
18
|
+
* last three are as first-class as the first: a missing proof is NOT a clean result
|
|
19
|
+
* ("no declared training runs to check against", never "never"). A bad receipt is a
|
|
20
|
+
* red, not a grey, and a presented-but-broken proof is a red too.
|
|
21
|
+
*
|
|
22
|
+
* The function is pure: no network, no clock (`computeAnchorLag` takes `now` from the
|
|
23
|
+
* caller). It is async for exactly one reason: row 3's coverage attestation is checked
|
|
24
|
+
* through `@zanii/attest`'s `verifyAttestation`, whose injected quote verifier may be
|
|
25
|
+
* async. Verification paths never throw: malformed input yields `cannot determine` /
|
|
26
|
+
* `not covered` / `failed` rows with reasons, per the codebase convention (creation
|
|
27
|
+
* paths throw, verification paths return results).
|
|
28
|
+
*
|
|
29
|
+
* Rows 3–5 consume real producer evidence, checked here, narrowly and offline:
|
|
30
|
+
* row 3 a SIGNED coverage attestation (`@zanii/attest` verifies the embedded
|
|
31
|
+
* attestation with an injected quote verifier; the producer side ships with the
|
|
32
|
+
* enclave phase, so until then `proven` is unreachable, by design), row 4 a declared,
|
|
33
|
+
* anchor-ordered training manifest plus `@zanii/train`'s sorted-Merkle `AbsenceProof`
|
|
34
|
+
* (`verifyAbsence`), row 5 the `@zanii/retention` shred receipts carried by the slice
|
|
35
|
+
* itself (`verifyKeyDestruction`), optionally bound to a published image hash via
|
|
36
|
+
* `@zanii/attest`'s `attestationHash` convention.
|
|
37
|
+
*
|
|
38
|
+
* Rows 6–8 consume the slice the same narrow way: row 6 residency claims
|
|
39
|
+
* (`@zanii/residency`'s `buildResidencyClaim` shape) verified with an injected region
|
|
40
|
+
* verifier against the reader-pinned jurisdiction allow-set, plus `detectCrossings`
|
|
41
|
+
* over the verified touch sequence; row 7 onward transfers (`@zanii/subprocessor`'s
|
|
42
|
+
* `buildTransfer` shape) and custody-touch junctions checked against the
|
|
43
|
+
* reader-pinned declared subprocessor list via `verifyTransfers`; row 8 field-set
|
|
44
|
+
* commitments (`@zanii/minimization`'s `commitFieldSet` records) and access proofs
|
|
45
|
+
* (`buildAccessProof`) checked with `verifyMinimization`. The jurisdiction allow-set
|
|
46
|
+
* and the declared subprocessor list are READER configuration (ExtraEvidence), never
|
|
47
|
+
* producer-supplied: the same trust rule as `coverage.trustedSigners`.
|
|
48
|
+
*/
|
|
49
|
+
import { ed25519 } from '@noble/curves/ed25519';
|
|
50
|
+
import { hexToBytes } from '@noble/hashes/utils';
|
|
51
|
+
import {
|
|
52
|
+
canonicalBytes, publicKeyFromDid, verifySTH,
|
|
53
|
+
type Receipt, type SignedTreeHead,
|
|
54
|
+
} from '@zanii/core';
|
|
55
|
+
import { verifyTouch } from '@zanii/data-custody';
|
|
56
|
+
import { verifyKeyDestruction } from '@zanii/retention';
|
|
57
|
+
import { verifyAbsence, type AbsenceProof, type TrainingManifest } from '@zanii/train';
|
|
58
|
+
import {
|
|
59
|
+
attestationHash, verifyAttestation,
|
|
60
|
+
type Attestation, type AttestCheck, type QuoteVerifier,
|
|
61
|
+
} from '@zanii/attest';
|
|
62
|
+
import {
|
|
63
|
+
detectCrossings, verifyResidency,
|
|
64
|
+
type RegionVerifier, type ResidencyClaim,
|
|
65
|
+
} from '@zanii/residency';
|
|
66
|
+
import { verifyTransfers, type Agreement, type Transfer } from '@zanii/subprocessor';
|
|
67
|
+
import { verifyFieldSet, verifyMinimization, type AccessProof } from '@zanii/minimization';
|
|
68
|
+
import type { SubjectHistory, SubjectReceipt } from '@zanii/subject';
|
|
69
|
+
|
|
70
|
+
export type { AbsenceProof, TrainingManifest } from '@zanii/train';
|
|
71
|
+
export type { Attestation, QuoteVerifier } from '@zanii/attest';
|
|
72
|
+
export type { RegionVerifier, ResidencyClaim } from '@zanii/residency';
|
|
73
|
+
export type { Agreement, Transfer } from '@zanii/subprocessor';
|
|
74
|
+
export type { AccessProof } from '@zanii/minimization';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The well-known ledger tag under which declared training manifests are published.
|
|
78
|
+
* The subject page and the CLI fetch that slice to build the row-4 training evidence
|
|
79
|
+
* bag (`ExtraEvidence.training`).
|
|
80
|
+
*/
|
|
81
|
+
export const TRAINING_MANIFEST_TAG = 'zanii:training-manifests';
|
|
82
|
+
|
|
83
|
+
/** The four verdict states. The last three are as loud as the first. */
|
|
84
|
+
export type VerdictState = 'proven' | 'not covered' | 'failed' | 'cannot determine';
|
|
85
|
+
|
|
86
|
+
export type VerdictId =
|
|
87
|
+
| 'purpose' | 'self_sovereign' | 'human_read' | 'trained_on' | 'copy_kept'
|
|
88
|
+
| 'residency' | 'subprocessor' | 'minimization';
|
|
89
|
+
|
|
90
|
+
export interface VerdictRow {
|
|
91
|
+
id: VerdictId;
|
|
92
|
+
label: string;
|
|
93
|
+
state: VerdictState;
|
|
94
|
+
/** Plain-language working: which receipts were computed over, why this state. Rendered verbatim. */
|
|
95
|
+
detail: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Coverage attestation for the "nobody read it" line: a SIGNED record asserting that
|
|
100
|
+
* every egress path to the data was receipt-gated (the `@zanii/broker` invariant) for
|
|
101
|
+
* the whole window, bound to the attested image whose code enforced it. The signature
|
|
102
|
+
* covers every field but `sig`; `imageHash` must equal `attestationHash(attestation)`
|
|
103
|
+
* (`@zanii/attest`'s convention); the attestation's `nonce` must equal `signer`, which
|
|
104
|
+
* is what binds the SIGNING KEY to the attested image (without it a legitimately
|
|
105
|
+
* published attestation is replayable under an attacker's key, and `trustedSigners`
|
|
106
|
+
* is only an optional defence); and the embedded attestation is verified through
|
|
107
|
+
* `verifyAttestation` with the caller's injected quote verifier. Anything unsigned or
|
|
108
|
+
* malformed is never `proven`. The producer side ships with the enclave phase
|
|
109
|
+
* (`@zanii/attest` + `@zanii/broker`); until then this evidence does not exist and the
|
|
110
|
+
* row answers `not covered`, by design.
|
|
111
|
+
*/
|
|
112
|
+
export interface CoverageEvidence {
|
|
113
|
+
v: 1;
|
|
114
|
+
type: 'coverage.attestation';
|
|
115
|
+
/** Every egress path was receipt-gated; partial coverage is no coverage. */
|
|
116
|
+
allPathsGated: boolean;
|
|
117
|
+
/** ISO timestamp: start of the gated window. */
|
|
118
|
+
windowStart: string;
|
|
119
|
+
/** ISO timestamp: end of the gated window. */
|
|
120
|
+
windowEnd: string;
|
|
121
|
+
/** `attestationHash` of the embedded attestation: the image the claim is bound to. */
|
|
122
|
+
imageHash: string;
|
|
123
|
+
/** The full attestation record (`@zanii/attest`'s `Attestation`). */
|
|
124
|
+
attestation: Attestation;
|
|
125
|
+
/**
|
|
126
|
+
* did:key of the enclave key that signed this attestation. MUST equal
|
|
127
|
+
* `attestation.nonce`: the enclave generates this keypair inside the measured
|
|
128
|
+
* image and commits its DID to the quote, so the binding is covered by
|
|
129
|
+
* `attestationHash` and cannot be reassigned to another key.
|
|
130
|
+
*/
|
|
131
|
+
signer: string;
|
|
132
|
+
/** `ed25519:<hex>` over the canonical record minus `sig`. */
|
|
133
|
+
sig: string;
|
|
134
|
+
/**
|
|
135
|
+
* Optional trust root: the deployment's pinned coverage-signing DIDs. The reader
|
|
136
|
+
* (subject page / CLI) sets this from its OWN configuration, never from
|
|
137
|
+
* producer-supplied data; otherwise an attacker could attest with their own key and
|
|
138
|
+
* name it trusted. Not part of the signed record (excluded from the signature
|
|
139
|
+
* preimage). When present, a signer not in the list is `failed`; an empty list
|
|
140
|
+
* trusts no one. When absent, any well-formed did:key signer is accepted.
|
|
141
|
+
*/
|
|
142
|
+
trustedSigners?: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A declared, anchored training run plus `@zanii/train`'s absence proof. Ordering is
|
|
147
|
+
* the whole claim: the manifest anchor must PRECEDE the run attestation. A manifest
|
|
148
|
+
* anchored afterwards could have been written to exclude the subject.
|
|
149
|
+
*/
|
|
150
|
+
export interface TrainingEvidence {
|
|
151
|
+
/** The run's sorted-Merkle training manifest (`@zanii/train`'s `TrainingManifest`). */
|
|
152
|
+
manifest: TrainingManifest;
|
|
153
|
+
/** ISO timestamp when the manifest root was anchored. */
|
|
154
|
+
anchoredAt: string;
|
|
155
|
+
/** ISO timestamp of the run attestation. */
|
|
156
|
+
runAttestedAt: string;
|
|
157
|
+
/** `@zanii/train`'s `AbsenceProof` for this subject's tag, verified against the manifest. */
|
|
158
|
+
absenceProof: AbsenceProof;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Optional image evidence for the "no copy was kept" line: a CLAIMED destruction-image
|
|
163
|
+
* attestation plus the image hash the operator published for it. What is checked here
|
|
164
|
+
* is bag-internal consistency only, via `@zanii/attest`'s `attestationHash` convention
|
|
165
|
+
* (the published image hash is `attestationHash` of the published attestation record):
|
|
166
|
+
* a mismatch is `failed`. A match does NOT by itself prove the destruction ran under
|
|
167
|
+
* that image: nothing yet binds the shred receipt to the attestation, and the
|
|
168
|
+
* attestation's quote is not verified on this path. Full binding ships with the
|
|
169
|
+
* enclave phase.
|
|
170
|
+
*/
|
|
171
|
+
export interface DestructionImageEvidence {
|
|
172
|
+
/** The attestation (`@zanii/attest`'s `Attestation`) claimed for the destruction image. */
|
|
173
|
+
attestation: Attestation;
|
|
174
|
+
/** The published image hash: `attestationHash` of the published attestation record. */
|
|
175
|
+
publishedImageHash: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Optional evidence bag for rows 3–5. Row 5 needs no bag entry for the destruction
|
|
180
|
+
* record itself: the shred receipts are read from the subject's slice, each already
|
|
181
|
+
* signature-covered and offline-verified. `destructionImage` only checks a claimed
|
|
182
|
+
* destruction-image attestation against the published image hash.
|
|
183
|
+
*/
|
|
184
|
+
export interface ExtraEvidence {
|
|
185
|
+
/**
|
|
186
|
+
* One coverage attestation, or an ordered CHAIN of them. A real deployment
|
|
187
|
+
* restarts, redeploys and upgrades its image, and each of those ends a window,
|
|
188
|
+
* so coverage of a subject's whole history is a chain of contiguous windows —
|
|
189
|
+
* the `@zanii/succession` shape, applied to enclave lifetime instead of keys.
|
|
190
|
+
* Every window must authenticate on its own AND abut the previous one: a gap
|
|
191
|
+
* between windows is a gap in coverage, and a gap in coverage is not coverage.
|
|
192
|
+
*/
|
|
193
|
+
coverage?: CoverageEvidence | CoverageEvidence[];
|
|
194
|
+
training?: TrainingEvidence;
|
|
195
|
+
destructionImage?: DestructionImageEvidence;
|
|
196
|
+
/**
|
|
197
|
+
* Row 6 trust root: the declared jurisdiction as an allow-set of region codes
|
|
198
|
+
* (`@zanii/residency`'s `allowedRegions`, e.g. `['AE', 'me-central-1']`). READER
|
|
199
|
+
* configuration (deployment-side), never producer-supplied: the same trust rule as
|
|
200
|
+
* `coverage.trustedSigners`, or a producer could declare the whole world in bounds.
|
|
201
|
+
* When absent, residency claims can reach `cannot determine`, never `proven`. An
|
|
202
|
+
* empty array allows no region: every verified claim is a crossing.
|
|
203
|
+
*/
|
|
204
|
+
jurisdiction?: string[];
|
|
205
|
+
/**
|
|
206
|
+
* Row 7 trust root: the declared subprocessor list (the parties onward transfers
|
|
207
|
+
* may name). READER configuration, never producer-supplied, same trust rule as
|
|
208
|
+
* `coverage.trustedSigners`. When absent, transfers or junctions on record reach
|
|
209
|
+
* `cannot determine`, never `proven`. An empty array declares no one.
|
|
210
|
+
*/
|
|
211
|
+
declaredSubprocessors?: string[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Caller-injected verifiers: the enclave quote verifier for row 3, the region
|
|
215
|
+
* signal verifier for row 6. */
|
|
216
|
+
export interface VerdictOptions {
|
|
217
|
+
/**
|
|
218
|
+
* Platform-specific enclave quote verifier (`@zanii/attest`'s `QuoteVerifier`).
|
|
219
|
+
* Without it a signed coverage attestation can reach `cannot determine`, never
|
|
220
|
+
* `proven`: the quote stays unknown, and unknown is not verified.
|
|
221
|
+
*/
|
|
222
|
+
verifyQuote?: QuoteVerifier;
|
|
223
|
+
/**
|
|
224
|
+
* Region signal verifier (`@zanii/residency`'s `RegionVerifier`): authenticates a
|
|
225
|
+
* claim's region attestation (a cloud provider identity document, a TEE geo
|
|
226
|
+
* signal) and returns the trusted region. Without it residency claims can reach
|
|
227
|
+
* `cannot determine`, never `proven`.
|
|
228
|
+
*/
|
|
229
|
+
verifyRegion?: RegionVerifier;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const LABELS: Record<VerdictId, string> = {
|
|
233
|
+
purpose: 'Used only for its declared purpose',
|
|
234
|
+
self_sovereign: 'You can check this yourself',
|
|
235
|
+
human_read: 'Nobody read it',
|
|
236
|
+
trained_on: 'Never used to train a model',
|
|
237
|
+
copy_kept: 'No copy was kept',
|
|
238
|
+
residency: 'Data stayed in its declared region',
|
|
239
|
+
subprocessor: 'Only declared third parties touched it',
|
|
240
|
+
minimization: 'Only what was needed was taken',
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** A receipt as served by the ledger: core fields plus the operator-revealed,
|
|
244
|
+
* signature-covered embedded payload (the ledger has no payload store). */
|
|
245
|
+
type ReceiptWithPayload = Receipt & { payload?: unknown };
|
|
246
|
+
|
|
247
|
+
function touchPayloadOf(sr: SubjectReceipt): unknown {
|
|
248
|
+
return (sr?.receipt as ReceiptWithPayload | undefined)?.payload;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function asPayload(p: unknown): Record<string, unknown> | undefined {
|
|
252
|
+
return p !== null && typeof p === 'object' && !Array.isArray(p)
|
|
253
|
+
? (p as Record<string, unknown>)
|
|
254
|
+
: undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const plural = (n: number, word: string, pluralForm?: string) => `${n} ${n === 1 ? word : (pluralForm ?? `${word}s`)}`;
|
|
258
|
+
|
|
259
|
+
function parseTime(ts: unknown): number | null {
|
|
260
|
+
if (typeof ts !== 'string') return null;
|
|
261
|
+
const ms = Date.parse(ts);
|
|
262
|
+
return Number.isNaN(ms) ? null : ms;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `sha256:<64 hex>` → bytes, null on any other shape. */
|
|
266
|
+
function parseHash(s: unknown): Uint8Array | null {
|
|
267
|
+
if (typeof s !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(s)) return null;
|
|
268
|
+
return hexToBytes(s.slice('sha256:'.length));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Row 1 (purpose): "Used only for its declared purpose"
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
// Structural validators for the evidence payloads rows 6-8 consume, shared with row
|
|
276
|
+
// 1's closed world. Creation paths throw, verification paths return reasons.
|
|
277
|
+
|
|
278
|
+
/** Structural check of a `@zanii/residency` claim (`buildResidencyClaim`'s shape). */
|
|
279
|
+
function residencyClaimReasons(p: Record<string, unknown>): string[] {
|
|
280
|
+
const reasons: string[] = [];
|
|
281
|
+
if (p.v !== 1 || p.type !== 'residency.claim') reasons.push('not a residency claim (v1)');
|
|
282
|
+
const ra = asPayload(p.region_attestation);
|
|
283
|
+
if (!ra) reasons.push('missing region attestation');
|
|
284
|
+
else {
|
|
285
|
+
if (typeof ra.region !== 'string' || !ra.region) reasons.push('missing/invalid region');
|
|
286
|
+
if (typeof ra.source !== 'string' || !ra.source) reasons.push('missing region source');
|
|
287
|
+
}
|
|
288
|
+
if (parseTime(p.ts) === null) reasons.push('missing/invalid ts');
|
|
289
|
+
return reasons;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Structural check of a `@zanii/subprocessor` transfer (`buildTransfer`'s shape). */
|
|
293
|
+
function transferReasons(p: Record<string, unknown>): string[] {
|
|
294
|
+
const reasons: string[] = [];
|
|
295
|
+
if (p.v !== 1 || p.type !== 'subprocessor.transfer') reasons.push('not a subprocessor transfer (v1)');
|
|
296
|
+
if (typeof p.from !== 'string' || !p.from) reasons.push('missing from');
|
|
297
|
+
if (typeof p.to !== 'string' || !p.to) reasons.push('missing to');
|
|
298
|
+
const agreement = asPayload(p.agreement);
|
|
299
|
+
if (!agreement || typeof agreement.type !== 'string' || !agreement.type) reasons.push('missing agreement');
|
|
300
|
+
if (parseTime(p.ts) === null) reasons.push('missing/invalid ts');
|
|
301
|
+
return reasons;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Structural check of a `@zanii/minimization` field-set commitment record: the
|
|
305
|
+
* `commitFieldSet` output (`{ commitment, fields }`) carried as a payload of type
|
|
306
|
+
* `minimization.commit`. */
|
|
307
|
+
function commitReasons(p: Record<string, unknown>): string[] {
|
|
308
|
+
const reasons: string[] = [];
|
|
309
|
+
if (p.type !== 'minimization.commit') reasons.push('not a field-set commitment record');
|
|
310
|
+
if (typeof p.commitment !== 'string' || !p.commitment.startsWith('minfields:')) reasons.push('missing/invalid commitment');
|
|
311
|
+
if (!Array.isArray(p.fields) || p.fields.some((f) => typeof f !== 'string')) reasons.push('missing/invalid fields');
|
|
312
|
+
return reasons;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Structural check of a `@zanii/minimization` access proof (`buildAccessProof`'s shape). */
|
|
316
|
+
function accessReasons(p: Record<string, unknown>): string[] {
|
|
317
|
+
const reasons: string[] = [];
|
|
318
|
+
if (p.v !== 1 || p.type !== 'minimization.access') reasons.push('not a minimization access proof (v1)');
|
|
319
|
+
if (typeof p.field_set !== 'string' || !p.field_set.startsWith('minfields:')) reasons.push('missing/invalid field_set');
|
|
320
|
+
if (!Array.isArray(p.accessed) || p.accessed.some((f) => typeof f !== 'string')) reasons.push('missing/invalid accessed');
|
|
321
|
+
return reasons;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The evidence payload kinds rows 6-8 consume, identified by their `type` tag. */
|
|
325
|
+
function evidenceKindOf(p: Record<string, unknown>): 'residency' | 'transfer' | 'commit' | 'access' | null {
|
|
326
|
+
switch (p.type) {
|
|
327
|
+
case 'residency.claim': return 'residency';
|
|
328
|
+
case 'subprocessor.transfer': return 'transfer';
|
|
329
|
+
case 'minimization.commit': return 'commit';
|
|
330
|
+
case 'minimization.access': return 'access';
|
|
331
|
+
default: return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A slice receipt is well-formed custody evidence when its embedded payload is a
|
|
337
|
+
* well-formed custody touch (`@zanii/data-custody`) OR a well-formed retention shred
|
|
338
|
+
* (`@zanii/retention`) OR one of the evidence payloads rows 6-8 consume (a residency
|
|
339
|
+
* claim, a subprocessor transfer, a field-set commitment, a minimization access
|
|
340
|
+
* proof). Anything else in a custody slice is a red. Closed world on purpose: new
|
|
341
|
+
* custody kinds stay red until wired here. The returned kind lets the row say
|
|
342
|
+
* honestly what passed or what failed: a retention payload never gets touch-schema
|
|
343
|
+
* reasons.
|
|
344
|
+
*/
|
|
345
|
+
function wellFormedCustodyPayload(
|
|
346
|
+
p: unknown,
|
|
347
|
+
): { ok: true; kind: 'touch' | 'shred' | 'residency' | 'transfer' | 'commit' | 'access' } | { ok: false; kind: 'touch' | 'retention' | 'residency' | 'transfer' | 'commit' | 'access'; reasons: string[] } {
|
|
348
|
+
const payload = asPayload(p);
|
|
349
|
+
// A retention-claimed payload is checked as one, pass or fail.
|
|
350
|
+
if (payload && payload['_zr_kind'] === 'retention') {
|
|
351
|
+
let shred: { ok: boolean; reasons: string[] };
|
|
352
|
+
try {
|
|
353
|
+
shred = verifyKeyDestruction(payload);
|
|
354
|
+
} catch {
|
|
355
|
+
shred = { ok: false, reasons: ['unreadable payload'] };
|
|
356
|
+
}
|
|
357
|
+
return shred.ok ? { ok: true, kind: 'shred' } : { ok: false, kind: 'retention', reasons: shred.reasons };
|
|
358
|
+
}
|
|
359
|
+
const kind = payload ? evidenceKindOf(payload) : null;
|
|
360
|
+
if (kind !== null && payload) {
|
|
361
|
+
const reasons = kind === 'residency' ? residencyClaimReasons(payload)
|
|
362
|
+
: kind === 'transfer' ? transferReasons(payload)
|
|
363
|
+
: kind === 'commit' ? commitReasons(payload)
|
|
364
|
+
: accessReasons(payload);
|
|
365
|
+
return reasons.length === 0 ? { ok: true, kind } : { ok: false, kind, reasons };
|
|
366
|
+
}
|
|
367
|
+
let touch: { ok: boolean; reasons: string[] };
|
|
368
|
+
try {
|
|
369
|
+
touch = verifyTouch(payload);
|
|
370
|
+
} catch {
|
|
371
|
+
touch = { ok: false, reasons: ['unreadable payload'] };
|
|
372
|
+
}
|
|
373
|
+
return touch.ok ? { ok: true, kind: 'touch' } : { ok: false, kind: 'touch', reasons: touch.reasons };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const EVIDENCE_NOUNS: Record<'residency' | 'transfer' | 'commit' | 'access', [string, string?]> = {
|
|
377
|
+
residency: ['residency claim'],
|
|
378
|
+
transfer: ['transfer record'],
|
|
379
|
+
commit: ['field-set commitment'],
|
|
380
|
+
access: ['access proof'],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
function purposeRow(history: SubjectHistory, receipts: SubjectReceipt[]): VerdictRow {
|
|
384
|
+
const id = 'purpose' as const;
|
|
385
|
+
if (receipts.length === 0) {
|
|
386
|
+
return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
|
|
387
|
+
}
|
|
388
|
+
if (history.verified !== true) {
|
|
389
|
+
return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
|
|
390
|
+
}
|
|
391
|
+
let touches = 0;
|
|
392
|
+
let shreds = 0;
|
|
393
|
+
const evidence: Array<'residency' | 'transfer' | 'commit' | 'access'> = [];
|
|
394
|
+
for (const sr of receipts) {
|
|
395
|
+
const check = wellFormedCustodyPayload(touchPayloadOf(sr));
|
|
396
|
+
if (!check.ok) {
|
|
397
|
+
const what = check.kind === 'touch' ? 'custody touch'
|
|
398
|
+
: check.kind === 'retention' ? 'retention'
|
|
399
|
+
: `${EVIDENCE_NOUNS[check.kind][0]}`;
|
|
400
|
+
return {
|
|
401
|
+
id,
|
|
402
|
+
label: LABELS[id],
|
|
403
|
+
state: 'failed',
|
|
404
|
+
detail: `Receipt ${sr?.hash} (index ${sr?.index}) has a malformed ${what} payload: ${check.reasons.join(', ')}.`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (check.kind === 'shred') shreds += 1;
|
|
408
|
+
else if (check.kind === 'touch') touches += 1;
|
|
409
|
+
else evidence.push(check.kind);
|
|
410
|
+
}
|
|
411
|
+
if (evidence.length === 0) {
|
|
412
|
+
if (shreds === 0) {
|
|
413
|
+
return {
|
|
414
|
+
id,
|
|
415
|
+
label: LABELS[id],
|
|
416
|
+
state: 'proven',
|
|
417
|
+
detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope) and each carries a well-formed custody touch payload naming its data class and operation.`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
id,
|
|
422
|
+
label: LABELS[id],
|
|
423
|
+
state: 'proven',
|
|
424
|
+
detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope): ${plural(touches, 'touch', 'touches')} carried a well-formed custody touch payload naming its data class and operation, and ${plural(shreds, 'retention shred')} carried a well-formed key-destruction payload.`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// Slices that also carry the rows 6-8 evidence payloads: enumerate them honestly.
|
|
428
|
+
const counts = new Map<string, number>();
|
|
429
|
+
for (const k of evidence) counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
430
|
+
const named = [...counts.entries()].map(([k, n]) => {
|
|
431
|
+
const [noun, pluralForm] = EVIDENCE_NOUNS[k as keyof typeof EVIDENCE_NOUNS];
|
|
432
|
+
return plural(n, noun, pluralForm);
|
|
433
|
+
});
|
|
434
|
+
const parts = [`${plural(touches, 'touch', 'touches')} carried a well-formed custody touch payload naming its data class and operation`];
|
|
435
|
+
if (shreds > 0) parts.push(`${plural(shreds, 'retention shred')} carried a well-formed key-destruction payload`);
|
|
436
|
+
parts.push(`${named.join(', ')} passed structural checks`);
|
|
437
|
+
return {
|
|
438
|
+
id,
|
|
439
|
+
label: LABELS[id],
|
|
440
|
+
state: 'proven',
|
|
441
|
+
detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope): ${parts.join(', and ')}.`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function offlineFailureDetail(history: SubjectHistory): string {
|
|
446
|
+
const failures = Array.isArray(history.failures) ? history.failures : [];
|
|
447
|
+
if (failures.length === 0) return 'The slice did not verify offline.';
|
|
448
|
+
return `${plural(failures.length, 'receipt')} failed offline verification: ${failures.join('; ')}. A receipt that does not verify is a failure, not an unknown.`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// Row 2 (self-sovereign): "You can check this yourself"
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
function selfSovereignRow(history: SubjectHistory, receipts: SubjectReceipt[]): VerdictRow {
|
|
456
|
+
const id = 'self_sovereign' as const;
|
|
457
|
+
if (receipts.length === 0) {
|
|
458
|
+
return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
|
|
459
|
+
}
|
|
460
|
+
if (history.verified !== true) {
|
|
461
|
+
return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
|
|
462
|
+
}
|
|
463
|
+
if (history.sth === undefined || history.sth === null) {
|
|
464
|
+
return {
|
|
465
|
+
id,
|
|
466
|
+
label: LABELS[id],
|
|
467
|
+
state: 'cannot determine',
|
|
468
|
+
detail: 'The slice came with no signed tree head, so the anchor commitment could not be verified.',
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
let sthOk = false;
|
|
472
|
+
try {
|
|
473
|
+
sthOk = verifySTH(history.sth as unknown as SignedTreeHead);
|
|
474
|
+
} catch {
|
|
475
|
+
sthOk = false;
|
|
476
|
+
}
|
|
477
|
+
if (!sthOk) {
|
|
478
|
+
return {
|
|
479
|
+
id,
|
|
480
|
+
label: LABELS[id],
|
|
481
|
+
state: 'failed',
|
|
482
|
+
detail: "The signed tree head failed verification: the log's commitment to this slice does not check out.",
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const size = (history.sth as { size?: unknown }).size;
|
|
486
|
+
return {
|
|
487
|
+
id,
|
|
488
|
+
label: LABELS[id],
|
|
489
|
+
state: 'proven',
|
|
490
|
+
detail: `All ${plural(receipts.length, 'receipt')} verified offline in this process (signature, delegation, scope) and the signed tree head (size ${typeof size === 'number' ? size : '?'}) verifies: zero network calls, nothing taken on trust.`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Row 3 (human_read): "Nobody read it"
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
async function humanReadRow(
|
|
499
|
+
receipts: SubjectReceipt[],
|
|
500
|
+
evidence: ExtraEvidence,
|
|
501
|
+
verifyQuote: QuoteVerifier | undefined,
|
|
502
|
+
): Promise<VerdictRow> {
|
|
503
|
+
const id = 'human_read' as const;
|
|
504
|
+
// A receipt shows a human actor when its embedded touch payload declares one
|
|
505
|
+
// (`actor: "human"`). The check is cheap and always runs, evidence or not, and a
|
|
506
|
+
// human actor beats any coverage attestation: failed stays failed.
|
|
507
|
+
for (const sr of receipts) {
|
|
508
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
509
|
+
if (p && p['actor'] === 'human') {
|
|
510
|
+
return {
|
|
511
|
+
id,
|
|
512
|
+
label: LABELS[id],
|
|
513
|
+
state: 'failed',
|
|
514
|
+
detail: `Receipt ${sr?.hash} (index ${sr?.index}) records a human actor: a person, not only an agent, touched this data.`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const bag = evidence.coverage;
|
|
519
|
+
const windows: CoverageEvidence[] = Array.isArray(bag) ? bag : bag ? [bag] : [];
|
|
520
|
+
if (windows.length === 0) {
|
|
521
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'No coverage attestation for this window' };
|
|
522
|
+
}
|
|
523
|
+
for (const c of windows) {
|
|
524
|
+
if (typeof c !== 'object' || c === null || Array.isArray(c) || c.v !== 1 || c.type !== 'coverage.attestation') {
|
|
525
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'The coverage evidence is not a signed coverage attestation.' };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (receipts.length === 0) {
|
|
529
|
+
// Coverage over nothing proves nothing about the subject's data.
|
|
530
|
+
return {
|
|
531
|
+
id,
|
|
532
|
+
label: LABELS[id],
|
|
533
|
+
state: 'cannot determine',
|
|
534
|
+
detail: "This slice has no receipts, so there is nothing for the coverage attestation to cover: coverage over nothing proves nothing about this subject's data.",
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// Every window authenticates on its own before any of them counts: one bad link
|
|
538
|
+
// fails the chain, because a chain is only coverage if every part of it holds.
|
|
539
|
+
const spans: Array<{ start: number; end: number; startIso: string; endIso: string }> = [];
|
|
540
|
+
for (const coverage of windows) {
|
|
541
|
+
const checked = await checkCoverageWindow(coverage, verifyQuote);
|
|
542
|
+
if ('state' in checked) return { id, label: LABELS[id], state: checked.state, detail: checked.detail };
|
|
543
|
+
spans.push({ ...checked, startIso: coverage.windowStart, endIso: coverage.windowEnd });
|
|
544
|
+
}
|
|
545
|
+
// Contiguity: sorted by start, each window must begin no later than the running end
|
|
546
|
+
// of the ones before it. Abutting and overlapping are both fine; a gap is not.
|
|
547
|
+
spans.sort((a, b) => a.start - b.start);
|
|
548
|
+
let unionEnd = spans[0]!.end;
|
|
549
|
+
let unionEndIso = spans[0]!.endIso;
|
|
550
|
+
for (let k = 1; k < spans.length; k++) {
|
|
551
|
+
const w = spans[k]!;
|
|
552
|
+
if (w.start > unionEnd) {
|
|
553
|
+
return {
|
|
554
|
+
id,
|
|
555
|
+
label: LABELS[id],
|
|
556
|
+
state: 'not covered',
|
|
557
|
+
detail: `The coverage chain has a gap between ${new Date(unionEnd).toISOString()} and ${new Date(w.start).toISOString()}: egress was unattested in that interval, and an unattested interval is not coverage.`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (w.end > unionEnd) {
|
|
561
|
+
unionEnd = w.end;
|
|
562
|
+
unionEndIso = w.endIso;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const unionStart = spans[0]!.start;
|
|
566
|
+
const unionStartIso = spans[0]!.startIso;
|
|
567
|
+
const times = receipts.map((sr) => parseTime(sr?.receipt?.ts));
|
|
568
|
+
if (times.some((t) => t === null || t < unionStart || t > unionEnd)) {
|
|
569
|
+
const valid = times.filter((t): t is number => t !== null);
|
|
570
|
+
const span = valid.length > 0
|
|
571
|
+
? ` (this slice spans ${new Date(Math.min(...valid)).toISOString()} to ${new Date(Math.max(...valid)).toISOString()})`
|
|
572
|
+
: '';
|
|
573
|
+
return {
|
|
574
|
+
id,
|
|
575
|
+
label: LABELS[id],
|
|
576
|
+
state: 'not covered',
|
|
577
|
+
detail: `The coverage attestation (${unionStartIso} to ${unionEndIso}) does not cover every receipt in this slice${span}.`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const images = [...new Set(windows.map((w) => w.imageHash))];
|
|
581
|
+
const under = images.length === 1 ? `attested image ${images[0]}` : `${images.length} attested images (${images.join(', ')})`;
|
|
582
|
+
const chain = windows.length === 1
|
|
583
|
+
? `the whole window (${unionStartIso} to ${unionEndIso})`
|
|
584
|
+
: `an unbroken chain of ${windows.length} windows (${unionStartIso} to ${unionEndIso})`;
|
|
585
|
+
return {
|
|
586
|
+
id,
|
|
587
|
+
label: LABELS[id],
|
|
588
|
+
state: 'proven',
|
|
589
|
+
detail: `Signed coverage attestation verified (signature, signer-to-image binding and enclave quote): all egress paths were receipt-gated for ${chain} under ${under}, and no receipt in this slice shows a human actor.`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Authenticate ONE coverage window. Returns its time span, or the verdict state that
|
|
595
|
+
* window fails with. Authenticity failures are `failed` (a claim that does not verify
|
|
596
|
+
* is a red, not an unknown); a missing quote verifier is `cannot determine`; a claim
|
|
597
|
+
* that simply is not asserted is `not covered`.
|
|
598
|
+
*/
|
|
599
|
+
async function checkCoverageWindow(
|
|
600
|
+
coverage: CoverageEvidence,
|
|
601
|
+
verifyQuote: QuoteVerifier | undefined,
|
|
602
|
+
): Promise<{ start: number; end: number } | { state: VerdictState; detail: string }> {
|
|
603
|
+
// Authenticate before evaluating: an unsigned or badly-signed claim is not evidence.
|
|
604
|
+
const signerPub = publicKeyFromDid(typeof coverage.signer === 'string' ? coverage.signer : '');
|
|
605
|
+
if (!signerPub || typeof coverage.sig !== 'string' || !/^ed25519:[0-9a-f]{128}$/.test(coverage.sig)) {
|
|
606
|
+
return {
|
|
607
|
+
state: 'failed',
|
|
608
|
+
detail: 'The coverage attestation is unsigned or carries a malformed signature: an unsigned coverage claim is not evidence.',
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
// The trust root: when the deployment pins coverage-signing DIDs, a signer outside
|
|
612
|
+
// the list is never proven, however good its signature.
|
|
613
|
+
const trustedSigners = coverage.trustedSigners;
|
|
614
|
+
if (Array.isArray(trustedSigners) && !trustedSigners.includes(coverage.signer)) {
|
|
615
|
+
return {
|
|
616
|
+
state: 'failed',
|
|
617
|
+
detail: `The coverage attestation is from an unrecognized signer (${coverage.signer}): it is not in this deployment's trusted signers list, so the claim is not evidence.`,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// trustedSigners is reader-side config merged in after signing: it is NOT part of
|
|
621
|
+
// the signed record, so it is excluded from the canonical preimage here.
|
|
622
|
+
const { sig: _omitted, trustedSigners: _ts, ...unsigned } = coverage;
|
|
623
|
+
let sigOk = false;
|
|
624
|
+
try {
|
|
625
|
+
sigOk = ed25519.verify(hexToBytes(coverage.sig.slice('ed25519:'.length)), canonicalBytes(unsigned as Record<string, unknown>), signerPub);
|
|
626
|
+
} catch {
|
|
627
|
+
sigOk = false;
|
|
628
|
+
}
|
|
629
|
+
if (!sigOk) {
|
|
630
|
+
return {
|
|
631
|
+
state: 'failed',
|
|
632
|
+
detail: "The coverage attestation's signature does not verify: a coverage claim that does not verify is a failure, not an unknown.",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
// The claim must be bound to an attested image: attestationHash(embedded) === imageHash.
|
|
636
|
+
const attestation = asPayload(coverage.attestation) as Attestation | undefined;
|
|
637
|
+
let boundHash: string | null = null;
|
|
638
|
+
if (attestation) {
|
|
639
|
+
try {
|
|
640
|
+
boundHash = attestationHash(attestation);
|
|
641
|
+
} catch {
|
|
642
|
+
boundHash = null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (!attestation || boundHash === null || coverage.imageHash !== boundHash) {
|
|
646
|
+
return {
|
|
647
|
+
state: 'failed',
|
|
648
|
+
detail: "The coverage attestation's image hash does not match its embedded attestation (attestationHash convention): the binding to the attested image is broken.",
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
// The embedded attestation verifies through @zanii/attest with the injected quote
|
|
652
|
+
// verifier. A disproven quote is a red; no verifier injected is an honest grey.
|
|
653
|
+
let check: AttestCheck;
|
|
654
|
+
try {
|
|
655
|
+
check = await verifyAttestation(attestation, verifyQuote ? { verifyQuote } : {});
|
|
656
|
+
} catch {
|
|
657
|
+
check = { ok: false, structural: false, quoteVerified: false, reasons: ['attestation verification failed'] };
|
|
658
|
+
}
|
|
659
|
+
if (!check.structural) {
|
|
660
|
+
return { state: 'failed', detail: `The coverage attestation's embedded attestation is malformed: ${check.reasons.join(', ')}.` };
|
|
661
|
+
}
|
|
662
|
+
// And the SIGNING KEY must be bound to that image too. The enclave commits its
|
|
663
|
+
// signing DID to the quote as the attestation nonce, which attestationHash covers.
|
|
664
|
+
// Without this check a real published attestation can be lifted and re-signed by
|
|
665
|
+
// anyone, and only an optional trustedSigners pin would catch it.
|
|
666
|
+
if (attestation.nonce !== coverage.signer) {
|
|
667
|
+
return {
|
|
668
|
+
state: 'failed',
|
|
669
|
+
detail: `The coverage attestation's signing key is not bound to the attested image: the quote commits to ${attestation.nonce ?? '(no nonce)'}, but the record is signed by ${coverage.signer}. A key the enclave never vouched for is not evidence.`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (check.quoteVerified === false) {
|
|
673
|
+
return {
|
|
674
|
+
state: 'failed',
|
|
675
|
+
detail: "The coverage attestation's enclave quote failed verification: the code that claimed coverage is not confirmed to be the attested image.",
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
if (check.quoteVerified === null) {
|
|
679
|
+
return {
|
|
680
|
+
state: 'cannot determine',
|
|
681
|
+
detail: 'The coverage attestation is signed and well-formed, but no enclave quote verifier was injected, so the attested image could not be confirmed.',
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
if (coverage.allPathsGated !== true) {
|
|
685
|
+
return { state: 'not covered', detail: 'The coverage attestation does not assert that all egress paths were receipt-gated.' };
|
|
686
|
+
}
|
|
687
|
+
const start = parseTime(coverage.windowStart);
|
|
688
|
+
const end = parseTime(coverage.windowEnd);
|
|
689
|
+
if (start === null || end === null || start > end) {
|
|
690
|
+
return { state: 'not covered', detail: 'The coverage attestation window is missing or invalid.' };
|
|
691
|
+
}
|
|
692
|
+
return { start, end };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
// Row 4 (trained_on): "Never used to train a model"
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
|
|
699
|
+
function trainedOnRow(history: SubjectHistory, receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
|
|
700
|
+
const id = 'trained_on' as const;
|
|
701
|
+
const training = evidence.training;
|
|
702
|
+
if (!training) {
|
|
703
|
+
// Exact wording matters: a missing proof is NOT a clean result. Never "never".
|
|
704
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'No declared training runs to check against' };
|
|
705
|
+
}
|
|
706
|
+
const anchoredAt = parseTime(training.anchoredAt);
|
|
707
|
+
const runAttestedAt = parseTime(training.runAttestedAt);
|
|
708
|
+
if (anchoredAt === null || runAttestedAt === null) {
|
|
709
|
+
return {
|
|
710
|
+
id,
|
|
711
|
+
label: LABELS[id],
|
|
712
|
+
state: 'cannot determine',
|
|
713
|
+
detail: 'The training evidence is missing a valid manifest anchor time or run attestation time, so the ordering cannot be checked.',
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (anchoredAt > runAttestedAt) {
|
|
717
|
+
return {
|
|
718
|
+
id,
|
|
719
|
+
label: LABELS[id],
|
|
720
|
+
state: 'cannot determine',
|
|
721
|
+
detail: `The training manifest's anchor (${training.anchoredAt}) does not precede the run attestation (${training.runAttestedAt}). A manifest anchored after the run could have been written to exclude this record.`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (receipts.length === 0) {
|
|
725
|
+
return {
|
|
726
|
+
id,
|
|
727
|
+
label: LABELS[id],
|
|
728
|
+
state: 'not covered',
|
|
729
|
+
detail: "This slice has no receipts: a training-absence proof over an empty slice proves nothing about this subject's data.",
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
const manifest = training.manifest;
|
|
733
|
+
if (
|
|
734
|
+
!manifest || typeof manifest !== 'object' || manifest.v !== 1 || manifest.type !== 'training.manifest'
|
|
735
|
+
|| typeof manifest.run_id !== 'string' || typeof manifest.root !== 'string' || parseHash(manifest.root) === null
|
|
736
|
+
|| !Number.isInteger(manifest.size) || manifest.size < 0
|
|
737
|
+
) {
|
|
738
|
+
return {
|
|
739
|
+
id,
|
|
740
|
+
label: LABELS[id],
|
|
741
|
+
state: 'failed',
|
|
742
|
+
detail: 'The training manifest is missing or malformed, so the absence proof cannot be checked against it.',
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
// The proof must speak to THIS subject, not someone else.
|
|
746
|
+
const proof = training.absenceProof;
|
|
747
|
+
if (!proof || typeof proof !== 'object' || proof.ref !== history.tag) {
|
|
748
|
+
return {
|
|
749
|
+
id,
|
|
750
|
+
label: LABELS[id],
|
|
751
|
+
state: 'failed',
|
|
752
|
+
detail: "The non-membership proof is missing or names a different ref than this subject's tag, so it says nothing about this slice.",
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
let ok = false;
|
|
756
|
+
try {
|
|
757
|
+
ok = verifyAbsence(proof, manifest);
|
|
758
|
+
} catch {
|
|
759
|
+
ok = false;
|
|
760
|
+
}
|
|
761
|
+
if (!ok) {
|
|
762
|
+
return {
|
|
763
|
+
id,
|
|
764
|
+
label: LABELS[id],
|
|
765
|
+
state: 'failed',
|
|
766
|
+
detail: `The non-membership proof does not verify against the declared training manifest (root ${manifest.root}).`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
id,
|
|
771
|
+
label: LABELS[id],
|
|
772
|
+
state: 'proven',
|
|
773
|
+
detail: `Non-membership proof verified against the declared training manifest (${manifest.size} entries, root ${manifest.root}), anchored at ${training.anchoredAt} before the run was attested at ${training.runAttestedAt}. This subject's tag is absent from the training set.`,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// Row 5 (copy_kept): "No copy was kept"
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The destruction evidence lives IN the slice: a receipt stamped with this subject's
|
|
783
|
+
* tag whose embedded, signature-covered payload is a `@zanii/retention` shred
|
|
784
|
+
* (`buildKeyDestruction`'s shape). Slice membership binds the receipt to this subject
|
|
785
|
+
* (the ledger serves the receipt only under this tag, and the receipt's own offline
|
|
786
|
+
* check re-verifies here); the payload's salted subject commitment can later be
|
|
787
|
+
* proven to name the subject by revealing the salt. Binding to the OBJECT is by ref:
|
|
788
|
+
* a shred counts only for an object whose custody touches carry the same payload
|
|
789
|
+
* `ref`, so a shred for an unrelated object never turns this row green.
|
|
790
|
+
*/
|
|
791
|
+
function shredReceiptsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
|
|
792
|
+
const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
|
|
793
|
+
for (const sr of receipts) {
|
|
794
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
795
|
+
if (p && p['_zr_kind'] === 'retention' && p['action'] === 'shred') out.push({ sr, payload: p });
|
|
796
|
+
}
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The refs of the custody touches in the slice: the objects destruction must bind to. */
|
|
801
|
+
function touchRefsOf(receipts: SubjectReceipt[]): string[] {
|
|
802
|
+
const refs = new Set<string>();
|
|
803
|
+
for (const sr of receipts) {
|
|
804
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
805
|
+
if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string') refs.add(p['ref']);
|
|
806
|
+
}
|
|
807
|
+
return [...refs].sort();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function copyKeptRow(receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
|
|
811
|
+
const id = 'copy_kept' as const;
|
|
812
|
+
const shreds = shredReceiptsOf(receipts);
|
|
813
|
+
if (shreds.length === 0) {
|
|
814
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
|
|
815
|
+
}
|
|
816
|
+
// A presented-but-broken shred is a red, not a grey: any failure anywhere in the
|
|
817
|
+
// slice's destruction records fails the row, however many others verify.
|
|
818
|
+
for (const { sr, payload } of shreds) {
|
|
819
|
+
if (sr?.check?.ok !== true) {
|
|
820
|
+
return {
|
|
821
|
+
id,
|
|
822
|
+
label: LABELS[id],
|
|
823
|
+
state: 'failed',
|
|
824
|
+
detail: `Receipt ${sr?.hash} (index ${sr?.index}) carries a key-destruction payload, but the receipt itself failed offline verification.`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
let check: { ok: boolean; reasons: string[] };
|
|
828
|
+
try {
|
|
829
|
+
check = verifyKeyDestruction(payload);
|
|
830
|
+
} catch {
|
|
831
|
+
check = { ok: false, reasons: ['unreadable payload'] };
|
|
832
|
+
}
|
|
833
|
+
if (!check.ok) {
|
|
834
|
+
return {
|
|
835
|
+
id,
|
|
836
|
+
label: LABELS[id],
|
|
837
|
+
state: 'failed',
|
|
838
|
+
detail: `The key-destruction payload on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// Ref binding: a shred is evidence only for the objects this slice touches. A
|
|
843
|
+
// shred carrying no ref, or a ref no touch in this slice carries, does not count.
|
|
844
|
+
const touchRefs = touchRefsOf(receipts);
|
|
845
|
+
const bound = shreds.filter(({ payload }) => typeof payload['ref'] === 'string' && touchRefs.includes(payload['ref'] as string));
|
|
846
|
+
if (bound.length === 0) {
|
|
847
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
|
|
848
|
+
}
|
|
849
|
+
const coveredRefs = new Set(bound.map(({ payload }) => payload['ref'] as string));
|
|
850
|
+
const uncoveredRefs = touchRefs.filter((r) => !coveredRefs.has(r));
|
|
851
|
+
if (uncoveredRefs.length > 0) {
|
|
852
|
+
return {
|
|
853
|
+
id,
|
|
854
|
+
label: LABELS[id],
|
|
855
|
+
state: 'cannot determine',
|
|
856
|
+
detail: `Verified key-destruction records cover ${coveredRefs.size} of ${plural(touchRefs.length, 'object')} touched in this slice: no key-destruction record binds to ${plural(uncoveredRefs.length, 'object')} (${uncoveredRefs.join(', ')}), so ${uncoveredRefs.length === 1 ? 'its destruction' : 'their destruction'} is unknown.`,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
// Optional image evidence: check the claimed attestation hashes to the published
|
|
860
|
+
// image hash (bag-internal consistency; it does not bind the shred to the image).
|
|
861
|
+
const image = evidence.destructionImage;
|
|
862
|
+
let imagePart = '';
|
|
863
|
+
if (image !== undefined && image !== null) {
|
|
864
|
+
const attestation = asPayload((image as DestructionImageEvidence).attestation) as Attestation | undefined;
|
|
865
|
+
const published = (image as DestructionImageEvidence).publishedImageHash;
|
|
866
|
+
let attested: string | null = null;
|
|
867
|
+
if (attestation) {
|
|
868
|
+
try {
|
|
869
|
+
attested = attestationHash(attestation);
|
|
870
|
+
} catch {
|
|
871
|
+
attested = null;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (!attestation || attested === null || typeof published !== 'string' || published.length === 0) {
|
|
875
|
+
return {
|
|
876
|
+
id,
|
|
877
|
+
label: LABELS[id],
|
|
878
|
+
state: 'failed',
|
|
879
|
+
detail: 'The destruction image evidence is incomplete: it must carry the attestation record and the published image hash.',
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
if (attested !== published) {
|
|
883
|
+
return {
|
|
884
|
+
id,
|
|
885
|
+
label: LABELS[id],
|
|
886
|
+
state: 'failed',
|
|
887
|
+
detail: `The attested image hash (${attested}) does not match the published image hash (${published}).`,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
imagePart = ` The attested image hash matches the published one (${published}).`;
|
|
891
|
+
}
|
|
892
|
+
const first = bound[0]!;
|
|
893
|
+
return {
|
|
894
|
+
id,
|
|
895
|
+
label: LABELS[id],
|
|
896
|
+
state: 'proven',
|
|
897
|
+
detail: `Key-destruction record verified: receipt ${first.sr?.hash} (index ${first.sr?.index}) carries a crypto-shredding attestation signed by ${first.sr?.receipt?.agent_id}, key commitment ${first.payload['key_commitment']}, destroyed at ${first.payload['executed_at']}, bound to object ${first.payload['ref']}. ${plural(bound.length, 'verified key-destruction record')} ${bound.length === 1 ? 'covers' : 'cover'} every object touched in this slice (${plural(touchRefs.length, 'object')}).${imagePart}`,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ---------------------------------------------------------------------------
|
|
902
|
+
// Row 6 (residency): "Data stayed in its declared region"
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
|
|
905
|
+
/** Residency claims carried by the slice (`buildResidencyClaim`'s shape, embedded
|
|
906
|
+
* signature-covered exactly like the row-5 shred payloads). */
|
|
907
|
+
function residencyClaimsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
|
|
908
|
+
const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
|
|
909
|
+
for (const sr of receipts) {
|
|
910
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
911
|
+
if (p && p['type'] === 'residency.claim') out.push({ sr, payload: p });
|
|
912
|
+
}
|
|
913
|
+
return out;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** A claim whose region signal is the operator asserting its own region: not an
|
|
917
|
+
* attestation, whatever it claims. */
|
|
918
|
+
function isSelfDeclared(p: Record<string, unknown>): boolean {
|
|
919
|
+
const source = asPayload(p.region_attestation)?.source;
|
|
920
|
+
return typeof source === 'string' && source.toLowerCase().startsWith('self');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function receiptDetailPrefix(sr: SubjectReceipt): string {
|
|
924
|
+
return `Receipt ${sr?.hash} (index ${sr?.index})`;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function residencyRow(
|
|
928
|
+
receipts: SubjectReceipt[],
|
|
929
|
+
evidence: ExtraEvidence,
|
|
930
|
+
verifyRegion: RegionVerifier | undefined,
|
|
931
|
+
): Promise<VerdictRow> {
|
|
932
|
+
const id = 'residency' as const;
|
|
933
|
+
if (receipts.length === 0) {
|
|
934
|
+
return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
|
|
935
|
+
}
|
|
936
|
+
const claims = residencyClaimsOf(receipts);
|
|
937
|
+
if (claims.length === 0) {
|
|
938
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'No residency attestations on record' };
|
|
939
|
+
}
|
|
940
|
+
// A self-declared claim is no attestation: it does not count as evidence, here or
|
|
941
|
+
// in the coverage and crossing checks below.
|
|
942
|
+
const attested = claims.filter(({ payload }) => !isSelfDeclared(payload));
|
|
943
|
+
const selfDeclared = claims.length - attested.length;
|
|
944
|
+
if (attested.length === 0) {
|
|
945
|
+
return {
|
|
946
|
+
id,
|
|
947
|
+
label: LABELS[id],
|
|
948
|
+
state: 'not covered',
|
|
949
|
+
detail: `Every residency claim on record is self-declared: the region source is self-declared (the operator asserted its own region), not an independently verified signal.`,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
// A presented-but-broken claim is a red, not a grey: structural check first, then
|
|
953
|
+
// the receipt's own offline verification (an unverified receipt's payload is not
|
|
954
|
+
// signature-covered evidence).
|
|
955
|
+
for (const { sr, payload } of attested) {
|
|
956
|
+
const reasons = residencyClaimReasons(payload);
|
|
957
|
+
if (reasons.length > 0) {
|
|
958
|
+
return {
|
|
959
|
+
id,
|
|
960
|
+
label: LABELS[id],
|
|
961
|
+
state: 'failed',
|
|
962
|
+
detail: `${receiptDetailPrefix(sr)} carries a malformed residency claim: ${reasons.join(', ')}.`,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
if (sr?.check?.ok !== true) {
|
|
966
|
+
return {
|
|
967
|
+
id,
|
|
968
|
+
label: LABELS[id],
|
|
969
|
+
state: 'failed',
|
|
970
|
+
detail: `${receiptDetailPrefix(sr)} carries a residency claim payload, but the receipt itself failed offline verification.`,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// The jurisdiction allow-set is READER config, never producer-supplied (same trust
|
|
975
|
+
// rule as trustedSigners). Absent: unknown, never proven. Empty: allows no region,
|
|
976
|
+
// so every verified claim below is a crossing.
|
|
977
|
+
const jurisdiction = evidence.jurisdiction;
|
|
978
|
+
if (!Array.isArray(jurisdiction)) {
|
|
979
|
+
return {
|
|
980
|
+
id,
|
|
981
|
+
label: LABELS[id],
|
|
982
|
+
state: 'cannot determine',
|
|
983
|
+
detail: 'Residency claims are on record, but the reader provided no declared jurisdiction allow-set, so there is nothing to check them against.',
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
if (verifyRegion === undefined) {
|
|
987
|
+
return {
|
|
988
|
+
id,
|
|
989
|
+
label: LABELS[id],
|
|
990
|
+
state: 'cannot determine',
|
|
991
|
+
detail: 'Residency claims are on record, but no region verifier was injected, so the region signals could not be authenticated.',
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const verified: Array<{ ts: string; region: string; ref: string | null }> = [];
|
|
995
|
+
for (const { sr, payload } of attested) {
|
|
996
|
+
let check: { ok: boolean; reasons: string[]; region: string | null; crossed: boolean };
|
|
997
|
+
try {
|
|
998
|
+
check = await verifyResidency(payload as unknown as ResidencyClaim, { allowedRegions: jurisdiction, verifyRegion });
|
|
999
|
+
} catch {
|
|
1000
|
+
check = { ok: false, reasons: ['the residency check failed'], region: null, crossed: false };
|
|
1001
|
+
}
|
|
1002
|
+
if (check.crossed) {
|
|
1003
|
+
// Do not echo the producer reason: its wording is not reader-facing.
|
|
1004
|
+
return {
|
|
1005
|
+
id,
|
|
1006
|
+
label: LABELS[id],
|
|
1007
|
+
state: 'failed',
|
|
1008
|
+
detail: `The residency claim on receipt ${sr?.hash} (index ${sr?.index}) verifies, but its region ${check.region} is outside the declared jurisdiction: a border crossing is on record.`,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
if (!check.ok || check.region === null) {
|
|
1012
|
+
return {
|
|
1013
|
+
id,
|
|
1014
|
+
label: LABELS[id],
|
|
1015
|
+
state: 'failed',
|
|
1016
|
+
detail: `The residency claim on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
verified.push({
|
|
1020
|
+
ts: payload.ts as string,
|
|
1021
|
+
region: check.region,
|
|
1022
|
+
ref: typeof payload.ref === 'string' && payload.ref ? payload.ref : null,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
// The sequence check over the verified regions: any exit from the allow-set is red.
|
|
1026
|
+
// (Per-claim verification already rejects an outside region, so this is belt and
|
|
1027
|
+
// suspenders; it also catches a sequence that STARTS outside, which per-claim
|
|
1028
|
+
// checks phrase differently.)
|
|
1029
|
+
const crossings = detectCrossings(verified, jurisdiction);
|
|
1030
|
+
if (crossings.length > 0) {
|
|
1031
|
+
const c = crossings[0]!;
|
|
1032
|
+
return {
|
|
1033
|
+
id,
|
|
1034
|
+
label: LABELS[id],
|
|
1035
|
+
state: 'failed',
|
|
1036
|
+
detail: `Verified residency claims show the data left the declared jurisdiction at ${c.ts} (moved to region ${c.region}).`,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
// Coverage is per touch, never window-span: EVERY custody touch in the slice must
|
|
1040
|
+
// be covered by a verified claim. A claim carrying a ref binds to every touch of
|
|
1041
|
+
// that object; a ref-less claim is time-scoped and covers the touches made at its
|
|
1042
|
+
// own timestamp. Anything looser would let two claims at the window's ends prove
|
|
1043
|
+
// green over an uncovered middle.
|
|
1044
|
+
const touches: Array<{ ref: string; ts: string }> = [];
|
|
1045
|
+
for (const sr of receipts) {
|
|
1046
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
1047
|
+
if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string' && parseTime(p['ts']) !== null) {
|
|
1048
|
+
touches.push({ ref: p['ref'] as string, ts: p['ts'] as string });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (touches.length === 0) {
|
|
1052
|
+
return {
|
|
1053
|
+
id,
|
|
1054
|
+
label: LABELS[id],
|
|
1055
|
+
state: 'cannot determine',
|
|
1056
|
+
detail: 'This slice carries no custody touches, so per-touch residency coverage cannot be established.',
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
const isCovered = (t: { ref: string; ts: string }): boolean =>
|
|
1060
|
+
verified.some((c) => (c.ref !== null ? c.ref === t.ref : c.ts === t.ts));
|
|
1061
|
+
const uncovered = touches.filter((t) => !isCovered(t));
|
|
1062
|
+
if (uncovered.length > 0) {
|
|
1063
|
+
const covered = touches.length - uncovered.length;
|
|
1064
|
+
return {
|
|
1065
|
+
id,
|
|
1066
|
+
label: LABELS[id],
|
|
1067
|
+
state: 'cannot determine',
|
|
1068
|
+
detail: `Verified residency claims cover ${covered} of ${plural(touches.length, 'touch', 'touches')} in this slice: ${plural(uncovered.length, 'touch', 'touches')} ${uncovered.length === 1 ? 'has' : 'have'} no valid in-jurisdiction claim, so ${uncovered.length === 1 ? 'its' : 'their'} residency is unknown.`,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
const selfNote = selfDeclared > 0
|
|
1072
|
+
? ` ${plural(selfDeclared, 'self-declared claim')} ${selfDeclared === 1 ? 'was' : 'were'} not counted as evidence: the region source is self-declared.`
|
|
1073
|
+
: '';
|
|
1074
|
+
return {
|
|
1075
|
+
id,
|
|
1076
|
+
label: LABELS[id],
|
|
1077
|
+
state: 'proven',
|
|
1078
|
+
detail: `Verified residency claims cover every touch in this slice (${plural(touches.length, 'touch', 'touches')}): every region signal authenticated against the declared jurisdiction allow-set, and no border crossings were detected.${selfNote}`,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ---------------------------------------------------------------------------
|
|
1083
|
+
// Row 7 (subprocessor): "Only declared third parties touched it"
|
|
1084
|
+
// ---------------------------------------------------------------------------
|
|
1085
|
+
|
|
1086
|
+
/** Onward transfers carried by the slice (`buildTransfer`'s shape). */
|
|
1087
|
+
function transfersOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
|
|
1088
|
+
const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
|
|
1089
|
+
for (const sr of receipts) {
|
|
1090
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
1091
|
+
if (p && p['type'] === 'subprocessor.transfer') out.push({ sr, payload: p });
|
|
1092
|
+
}
|
|
1093
|
+
return out;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** Custody-touch junctions: the destinations the touch payloads say data crossed to. */
|
|
1097
|
+
function junctionsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; to: string }> {
|
|
1098
|
+
const out: Array<{ sr: SubjectReceipt; to: string }> = [];
|
|
1099
|
+
for (const sr of receipts) {
|
|
1100
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
1101
|
+
const j = p ? asPayload(p['junction']) : undefined;
|
|
1102
|
+
// Only share/export are onward disclosures to a third party. Store/read/transform
|
|
1103
|
+
// may carry a junction for topology (source/destination of the data), but they
|
|
1104
|
+
// are not subprocessor transfers.
|
|
1105
|
+
const op = typeof p?.['op'] === 'string' ? p['op'] : undefined;
|
|
1106
|
+
if (j && typeof j.to === 'string' && j.to && (op === 'share' || op === 'export')) out.push({ sr, to: j.to });
|
|
1107
|
+
}
|
|
1108
|
+
return out;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function subprocessorRow(receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
|
|
1112
|
+
const id = 'subprocessor' as const;
|
|
1113
|
+
const transfers = transfersOf(receipts);
|
|
1114
|
+
const junctions = junctionsOf(receipts);
|
|
1115
|
+
if (transfers.length === 0 && junctions.length === 0) {
|
|
1116
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'No onward transfers declared or recorded' };
|
|
1117
|
+
}
|
|
1118
|
+
// Presented-but-broken is a red before any trust-root question.
|
|
1119
|
+
for (const { sr, payload } of transfers) {
|
|
1120
|
+
const reasons = transferReasons(payload);
|
|
1121
|
+
if (reasons.length > 0) {
|
|
1122
|
+
return {
|
|
1123
|
+
id,
|
|
1124
|
+
label: LABELS[id],
|
|
1125
|
+
state: 'failed',
|
|
1126
|
+
detail: `${receiptDetailPrefix(sr)} carries a malformed transfer record: ${reasons.join(', ')}.`,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
if (sr?.check?.ok !== true) {
|
|
1130
|
+
return {
|
|
1131
|
+
id,
|
|
1132
|
+
label: LABELS[id],
|
|
1133
|
+
state: 'failed',
|
|
1134
|
+
detail: `${receiptDetailPrefix(sr)} carries a transfer record payload, but the receipt itself failed offline verification.`,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
// Stricter than verifyTransfers (which requires an id only for dpa/scc): any
|
|
1138
|
+
// onward transfer without an agreement id is a red here.
|
|
1139
|
+
const agreement = asPayload(payload.agreement);
|
|
1140
|
+
const agreementId = agreement?.id;
|
|
1141
|
+
if (typeof agreementId !== 'string' || !agreementId) {
|
|
1142
|
+
return {
|
|
1143
|
+
id,
|
|
1144
|
+
label: LABELS[id],
|
|
1145
|
+
state: 'failed',
|
|
1146
|
+
detail: `The transfer to '${payload.to}' at ${payload.ts} carries no agreement id: an onward transfer without an agreement reference is a failure, not an unknown.`,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
// The declared subprocessor list is READER config, never producer-supplied (same
|
|
1151
|
+
// trust rule as trustedSigners). Without it there is nothing to check against.
|
|
1152
|
+
const declared = evidence.declaredSubprocessors;
|
|
1153
|
+
if (!Array.isArray(declared)) {
|
|
1154
|
+
return {
|
|
1155
|
+
id,
|
|
1156
|
+
label: LABELS[id],
|
|
1157
|
+
state: 'cannot determine',
|
|
1158
|
+
detail: 'Custody touches or transfer records name onward destinations, but the reader provided no declared subprocessor list, so there is nothing to check them against.',
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
let check: { ok: boolean; reasons: string[] };
|
|
1162
|
+
try {
|
|
1163
|
+
check = verifyTransfers(transfers.map(({ payload }) => payload as unknown as Transfer), declared);
|
|
1164
|
+
} catch {
|
|
1165
|
+
check = { ok: false, reasons: ['the transfer check failed'] };
|
|
1166
|
+
}
|
|
1167
|
+
if (!check.ok) {
|
|
1168
|
+
return {
|
|
1169
|
+
id,
|
|
1170
|
+
label: LABELS[id],
|
|
1171
|
+
state: 'failed',
|
|
1172
|
+
detail: `The declared-subprocessor check failed: ${check.reasons.join('; ')}.`,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
// Divergence is red, never silent: a junction naming a destination with no matching
|
|
1176
|
+
// declared transfer means the custody trail and the transfer record disagree.
|
|
1177
|
+
for (const { sr, to } of junctions) {
|
|
1178
|
+
const matched = transfers.some(({ payload }) => payload.to === to && declared.includes(payload.to as string));
|
|
1179
|
+
if (!matched) {
|
|
1180
|
+
return {
|
|
1181
|
+
id,
|
|
1182
|
+
label: LABELS[id],
|
|
1183
|
+
state: 'failed',
|
|
1184
|
+
detail: `${receiptDetailPrefix(sr)} records a custody junction to '${to}', but no declared transfer to that party is on record: a divergence between the custody trail and the transfer record is a failure, never silent.`,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return {
|
|
1189
|
+
id,
|
|
1190
|
+
label: LABELS[id],
|
|
1191
|
+
state: 'proven',
|
|
1192
|
+
detail: `Every onward destination in this slice resolves to a declared subprocessor under a named agreement: ${plural(junctions.length, 'custody junction')} and ${plural(transfers.length, 'recorded transfer')} checked against the declared list (${declared.length} ${declared.length === 1 ? 'party' : 'parties'}), with no undeclared party, no missing agreement id, and no divergence.`,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// ---------------------------------------------------------------------------
|
|
1197
|
+
// Row 8 (minimization): "Only what was needed was taken"
|
|
1198
|
+
// ---------------------------------------------------------------------------
|
|
1199
|
+
|
|
1200
|
+
/** Field-set commitments carried by the slice (`commitFieldSet` records under type
|
|
1201
|
+
* `minimization.commit`). */
|
|
1202
|
+
function commitsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
|
|
1203
|
+
const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
|
|
1204
|
+
for (const sr of receipts) {
|
|
1205
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
1206
|
+
if (p && p['type'] === 'minimization.commit') out.push({ sr, payload: p });
|
|
1207
|
+
}
|
|
1208
|
+
return out;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** Access proofs carried by the slice (`buildAccessProof`'s shape). */
|
|
1212
|
+
function accessProofsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
|
|
1213
|
+
const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
|
|
1214
|
+
for (const sr of receipts) {
|
|
1215
|
+
const p = asPayload(touchPayloadOf(sr));
|
|
1216
|
+
if (p && p['type'] === 'minimization.access') out.push({ sr, payload: p });
|
|
1217
|
+
}
|
|
1218
|
+
return out;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function minimizationRow(receipts: SubjectReceipt[]): VerdictRow {
|
|
1222
|
+
const id = 'minimization' as const;
|
|
1223
|
+
if (receipts.length === 0) {
|
|
1224
|
+
return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
|
|
1225
|
+
}
|
|
1226
|
+
const commits = commitsOf(receipts);
|
|
1227
|
+
const accesses = accessProofsOf(receipts);
|
|
1228
|
+
for (const { sr, payload } of commits) {
|
|
1229
|
+
const reasons = commitReasons(payload);
|
|
1230
|
+
if (reasons.length > 0) {
|
|
1231
|
+
return {
|
|
1232
|
+
id,
|
|
1233
|
+
label: LABELS[id],
|
|
1234
|
+
state: 'failed',
|
|
1235
|
+
detail: `${receiptDetailPrefix(sr)} carries a malformed field-set commitment record: ${reasons.join(', ')}.`,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
if (sr?.check?.ok !== true) {
|
|
1239
|
+
return {
|
|
1240
|
+
id,
|
|
1241
|
+
label: LABELS[id],
|
|
1242
|
+
state: 'failed',
|
|
1243
|
+
detail: `${receiptDetailPrefix(sr)} carries a field-set commitment payload, but the receipt itself failed offline verification.`,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
// Recompute the commitment from the claimed fields: a mismatch is a set widened
|
|
1247
|
+
// (or swapped) after the commitment was recorded.
|
|
1248
|
+
let matches = false;
|
|
1249
|
+
try {
|
|
1250
|
+
matches = verifyFieldSet(payload.fields as string[], payload.commitment as string);
|
|
1251
|
+
} catch {
|
|
1252
|
+
matches = false;
|
|
1253
|
+
}
|
|
1254
|
+
if (!matches) {
|
|
1255
|
+
return {
|
|
1256
|
+
id,
|
|
1257
|
+
label: LABELS[id],
|
|
1258
|
+
state: 'failed',
|
|
1259
|
+
detail: `${receiptDetailPrefix(sr)} carries a field-set commitment whose fields do not recompute to the commitment: the declared minimum may have been widened after the fact.`,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
// Access proofs are validated before the commits-empty branch: a presented but
|
|
1264
|
+
// broken proof is a red even when no commitments are on record.
|
|
1265
|
+
for (const { sr, payload } of accesses) {
|
|
1266
|
+
const reasons = accessReasons(payload);
|
|
1267
|
+
if (reasons.length > 0) {
|
|
1268
|
+
return {
|
|
1269
|
+
id,
|
|
1270
|
+
label: LABELS[id],
|
|
1271
|
+
state: 'failed',
|
|
1272
|
+
detail: `${receiptDetailPrefix(sr)} carries a malformed minimization access proof: ${reasons.join(', ')}.`,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
if (sr?.check?.ok !== true) {
|
|
1276
|
+
return {
|
|
1277
|
+
id,
|
|
1278
|
+
label: LABELS[id],
|
|
1279
|
+
state: 'failed',
|
|
1280
|
+
detail: `${receiptDetailPrefix(sr)} carries an access proof payload, but the receipt itself failed offline verification.`,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (commits.length === 0) {
|
|
1285
|
+
if (accesses.length > 0) {
|
|
1286
|
+
// Symmetric with commits-without-accesses: an access proof whose declared
|
|
1287
|
+
// minimum is not on record cannot be checked, and unchecked is unknown.
|
|
1288
|
+
return {
|
|
1289
|
+
id,
|
|
1290
|
+
label: LABELS[id],
|
|
1291
|
+
state: 'cannot determine',
|
|
1292
|
+
detail: 'Access proofs are on record, but no minimum-field commitments: the declared minimum they claim cannot be checked, and an unchecked access is unknown, not clean.',
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
return { id, label: LABELS[id], state: 'not covered', detail: 'No minimum-field commitments on record' };
|
|
1296
|
+
}
|
|
1297
|
+
if (accesses.length === 0) {
|
|
1298
|
+
return {
|
|
1299
|
+
id,
|
|
1300
|
+
label: LABELS[id],
|
|
1301
|
+
state: 'cannot determine',
|
|
1302
|
+
detail: 'Field-set commitments are on record, but no access proofs: no recorded access was checked against the declared minimum.',
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
for (const { sr, payload } of accesses) {
|
|
1306
|
+
const commit = commits.find(({ payload: c }) => c.commitment === payload.field_set);
|
|
1307
|
+
if (!commit) {
|
|
1308
|
+
return {
|
|
1309
|
+
id,
|
|
1310
|
+
label: LABELS[id],
|
|
1311
|
+
state: 'cannot determine',
|
|
1312
|
+
detail: `${receiptDetailPrefix(sr)} carries an access proof against field-set commitment ${payload.field_set}, but that commitment is not on record in this slice: the declared minimum it claims cannot be checked.`,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
let check: { ok: boolean; reasons: string[]; excess: string[] };
|
|
1316
|
+
try {
|
|
1317
|
+
check = verifyMinimization(payload as unknown as AccessProof, commit.payload.fields as string[]);
|
|
1318
|
+
} catch {
|
|
1319
|
+
check = { ok: false, reasons: ['the minimization check failed'], excess: [] };
|
|
1320
|
+
}
|
|
1321
|
+
if (!check.ok) {
|
|
1322
|
+
return {
|
|
1323
|
+
id,
|
|
1324
|
+
label: LABELS[id],
|
|
1325
|
+
state: 'failed',
|
|
1326
|
+
detail: `The minimization check failed for receipt ${sr?.hash} (index ${sr?.index}): ${check.reasons.join(', ')}.`,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return {
|
|
1331
|
+
id,
|
|
1332
|
+
label: LABELS[id],
|
|
1333
|
+
state: 'proven',
|
|
1334
|
+
detail: `Every recorded access (${plural(accesses.length, 'proof')}) in this slice stayed within its committed minimum field set: all ${plural(commits.length, 'field-set commitment')} recomputed to their recorded commitments, and no accessed field fell outside the declared minimum.`,
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ---------------------------------------------------------------------------
|
|
1339
|
+
// The one function both surfaces call.
|
|
1340
|
+
// ---------------------------------------------------------------------------
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Compute the eight verdict rows from an offline-verified subject history. Pure: no
|
|
1344
|
+
* network, no clock. Async only because row 3's coverage attestation is checked
|
|
1345
|
+
* through `@zanii/attest`'s `verifyAttestation` and row 6's residency claims through
|
|
1346
|
+
* `@zanii/residency`'s `verifyResidency`, whose injected verifiers may be async;
|
|
1347
|
+
* pass them via `options.verifyQuote` / `options.verifyRegion`. Never throws:
|
|
1348
|
+
* malformed input yields honest `cannot determine` / `not covered` / `failed` rows
|
|
1349
|
+
* with reasons. Rows are always returned in this fixed order: purpose,
|
|
1350
|
+
* self_sovereign, human_read, trained_on, copy_kept, residency, subprocessor,
|
|
1351
|
+
* minimization.
|
|
1352
|
+
*/
|
|
1353
|
+
export async function computeVerdicts(
|
|
1354
|
+
history: SubjectHistory,
|
|
1355
|
+
evidence: ExtraEvidence = {},
|
|
1356
|
+
options: VerdictOptions = {},
|
|
1357
|
+
): Promise<VerdictRow[]> {
|
|
1358
|
+
const h = (history && typeof history === 'object' ? history : {}) as SubjectHistory;
|
|
1359
|
+
const receipts = Array.isArray(h.receipts) ? h.receipts : [];
|
|
1360
|
+
const ev = (evidence && typeof evidence === 'object' ? evidence : {}) as ExtraEvidence;
|
|
1361
|
+
const verifyQuote = options && typeof options === 'object' ? options.verifyQuote : undefined;
|
|
1362
|
+
const verifyRegion = options && typeof options === 'object' ? options.verifyRegion : undefined;
|
|
1363
|
+
const rows: Array<() => VerdictRow | Promise<VerdictRow>> = [
|
|
1364
|
+
() => purposeRow(h, receipts),
|
|
1365
|
+
() => selfSovereignRow(h, receipts),
|
|
1366
|
+
() => humanReadRow(receipts, ev, verifyQuote),
|
|
1367
|
+
() => trainedOnRow(h, receipts, ev),
|
|
1368
|
+
() => copyKeptRow(receipts, ev),
|
|
1369
|
+
() => residencyRow(receipts, ev, verifyRegion),
|
|
1370
|
+
() => subprocessorRow(receipts, ev),
|
|
1371
|
+
() => minimizationRow(receipts),
|
|
1372
|
+
];
|
|
1373
|
+
const ids: VerdictId[] = ['purpose', 'self_sovereign', 'human_read', 'trained_on', 'copy_kept', 'residency', 'subprocessor', 'minimization'];
|
|
1374
|
+
const out: VerdictRow[] = [];
|
|
1375
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1376
|
+
try {
|
|
1377
|
+
out.push(await rows[i]!());
|
|
1378
|
+
} catch (err) {
|
|
1379
|
+
// Verification paths never crash: an unexpected error is an honest grey.
|
|
1380
|
+
const id = ids[i]!;
|
|
1381
|
+
out.push({
|
|
1382
|
+
id,
|
|
1383
|
+
label: LABELS[id],
|
|
1384
|
+
state: 'cannot determine',
|
|
1385
|
+
detail: `This line could not be computed: ${err instanceof Error ? err.message : String(err)}.`,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return out;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Seconds between the signed tree head's timestamp and `now` (the caller supplies
|
|
1394
|
+
* `now`; this package has no clock). The page shows anchor lag: four minutes vs
|
|
1395
|
+
* four hours is a property the reader is entitled to see. Null when unknown (no sth,
|
|
1396
|
+
* no timestamp, unparseable). Negative means the sth is newer than the caller's
|
|
1397
|
+
* clock: possible skew, shown as-is.
|
|
1398
|
+
*/
|
|
1399
|
+
export function computeAnchorLag(
|
|
1400
|
+
sth: Record<string, unknown> | SignedTreeHead | undefined | null,
|
|
1401
|
+
now: Date | string | number,
|
|
1402
|
+
): number | null {
|
|
1403
|
+
try {
|
|
1404
|
+
const ts = parseTime((sth as Record<string, unknown> | undefined | null)?.ts);
|
|
1405
|
+
if (ts === null) return null;
|
|
1406
|
+
const nowMs = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : parseTime(now);
|
|
1407
|
+
if (nowMs === null || Number.isNaN(nowMs)) return null;
|
|
1408
|
+
return Math.round((nowMs - ts) / 1000);
|
|
1409
|
+
} catch {
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
}
|