@sanning/proof 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/case-file.js +6 -2
- package/dist/case.d.ts +51 -7
- package/dist/case.js +81 -16
- package/dist/cli.d.ts +8 -27
- package/dist/cli.js +184 -103
- package/dist/compose.js +6 -1
- package/dist/evidence.d.ts +36 -0
- package/dist/evidence.js +104 -3
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +24 -2
- package/dist/message-parts.d.ts +12 -6
- package/dist/message-parts.js +18 -10
- package/dist/verifier.d.ts +54 -2
- package/dist/verifier.js +195 -3
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- package/dist/witness-lookup.d.ts +112 -0
- package/dist/witness-lookup.js +300 -0
- package/package.json +1 -1
- package/src/case-file.ts +5 -2
- package/src/case.ts +101 -15
- package/src/cli.ts +191 -123
- package/src/compose.ts +8 -1
- package/src/evidence.ts +115 -2
- package/src/file-match.ts +461 -0
- package/src/index.ts +68 -0
- package/src/message-parts.ts +18 -10
- package/src/verifier.ts +190 -3
- package/src/witness-cache.ts +306 -0
- package/src/witness-lookup.ts +428 -0
- package/vectors/README.md +43 -3
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/stamp-on-chain.json +18 -3
- package/vectors/witness-cache.json +1304 -0
package/src/evidence.ts
CHANGED
|
@@ -32,9 +32,11 @@ import {
|
|
|
32
32
|
import { isConformantHandoffRecord, isHandoffNamespace, type HeldRecord } from "./handoff.js";
|
|
33
33
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
34
34
|
import {
|
|
35
|
+
DuplicateKeyError,
|
|
35
36
|
MAX_CANONICAL_DEPTH,
|
|
36
37
|
contentHashes,
|
|
37
38
|
exceedsDepth,
|
|
39
|
+
isRfc3339UtcInstant,
|
|
38
40
|
jcs,
|
|
39
41
|
jcsParse,
|
|
40
42
|
verifyEnvelope,
|
|
@@ -516,6 +518,12 @@ export interface EvidenceBundleResult {
|
|
|
516
518
|
// Absent for every other body — additive, backward-compatible.
|
|
517
519
|
stamps?: StampResult[];
|
|
518
520
|
errors: string[];
|
|
521
|
+
// Informational only: never read, never gated on, never moves `status` or
|
|
522
|
+
// the CLI exit code. Carries `CO_SIGNATURES_NOTE` where the wrapper's
|
|
523
|
+
// reserved `co_signatures` field is present with at least one entry
|
|
524
|
+
// (evidence-bundle.md §2, §7.1 decision proof#148). Absent for every
|
|
525
|
+
// bundle without it, additive and backward-compatible.
|
|
526
|
+
notes?: string[];
|
|
519
527
|
}
|
|
520
528
|
|
|
521
529
|
// ---- Attested-evidence-export wire + result types (evidence-export.md) -------
|
|
@@ -671,6 +679,43 @@ export interface VerifyEvidenceOptions {
|
|
|
671
679
|
fetchImpl?: typeof fetch;
|
|
672
680
|
}
|
|
673
681
|
|
|
682
|
+
/**
|
|
683
|
+
* A surface MUST NOT present a wrapper's `co_signatures` as evidence
|
|
684
|
+
* (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
|
|
685
|
+
* in either kernel verifies the field, and it sits outside the wrapper's
|
|
686
|
+
* signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
|
|
687
|
+
* append it with no key at all. This is the one sentence both kernels attach
|
|
688
|
+
* as a note, never a verdict, never an exit code.
|
|
689
|
+
*/
|
|
690
|
+
export const CO_SIGNATURES_NOTE =
|
|
691
|
+
"unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* True iff the wrapper's reserved `co_signatures` field carries at least one
|
|
695
|
+
* entry.
|
|
696
|
+
*
|
|
697
|
+
* Absence and an empty array read the same way here: the field asserts
|
|
698
|
+
* nothing about a second signer until it holds one, and evidence-bundle.md's
|
|
699
|
+
* own carve-out language for the analogous envelope field treats "absent"
|
|
700
|
+
* and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
|
|
701
|
+
* value (malformed shape, but the field is reserved, not yet specified)
|
|
702
|
+
* still counts as present: it is not the well-formed absence either rule
|
|
703
|
+
* describes.
|
|
704
|
+
*/
|
|
705
|
+
export function coSignaturesPresent(b: unknown): boolean {
|
|
706
|
+
if (b === null || typeof b !== "object" || Array.isArray(b)) return false;
|
|
707
|
+
const value = (b as Record<string, unknown>).co_signatures;
|
|
708
|
+
if (!("co_signatures" in (b as Record<string, unknown>)) || value === null || value === undefined) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function coSignaturesNote(bundle: unknown): string | undefined {
|
|
716
|
+
return coSignaturesPresent(bundle) ? CO_SIGNATURES_NOTE : undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
674
719
|
function isEvidenceMajorAccepted(specVersion: unknown): boolean {
|
|
675
720
|
if (typeof specVersion !== "string" || specVersion === "") return false;
|
|
676
721
|
return ACCEPTED_EVIDENCE_MAJORS.some((m) => {
|
|
@@ -692,6 +737,10 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
|
|
|
692
737
|
* (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
|
|
693
738
|
* broke its own body shape read as a forger.
|
|
694
739
|
*/
|
|
740
|
+
/** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
|
|
741
|
+
export const GENERATED_AT_MALFORMED =
|
|
742
|
+
"malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
|
|
743
|
+
|
|
695
744
|
function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
|
|
696
745
|
return {
|
|
697
746
|
status: "malformed",
|
|
@@ -712,7 +761,9 @@ function malformed(error: string, specVersionOk = false, wrapperOk = false): Evi
|
|
|
712
761
|
* the Python kernel writes too. Anything outside this set comes from the host:
|
|
713
762
|
* a JSON parser, a codec, a crypto library. See `thrownDetail`.
|
|
714
763
|
*/
|
|
715
|
-
|
|
764
|
+
// `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
|
|
765
|
+
// `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
|
|
766
|
+
const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
|
|
716
767
|
|
|
717
768
|
/**
|
|
718
769
|
* The detail a finding may append about a caught exception.
|
|
@@ -730,6 +781,27 @@ const KERNEL_WORDING = ["hexToBytes: "];
|
|
|
730
781
|
* is the host's parse offset, which no reader can act on and which was never
|
|
731
782
|
* the same in the two kernels.
|
|
732
783
|
*/
|
|
784
|
+
/**
|
|
785
|
+
* The kernel's `duplicate key "<name>"` when a record repeats a member name,
|
|
786
|
+
* else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
|
|
787
|
+
*
|
|
788
|
+
* ONLY A DUPLICATE, and not every unreadable record. Every reader below this
|
|
789
|
+
* one that parses a record treats an unreadable one as absent, so the content
|
|
790
|
+
* binding reads undetermined and the pack said "1 record discloses no bytes"
|
|
791
|
+
* about a record whose bytes it had read. A record that repeats a name is
|
|
792
|
+
* a defect in the artifact, and a defect this kernel can see fails, the rule
|
|
793
|
+
* `event_bytes` that are not hex already follow.
|
|
794
|
+
*/
|
|
795
|
+
function recordDuplicateKey(eventBytes: Uint8Array | undefined): string | undefined {
|
|
796
|
+
if (eventBytes === undefined) return undefined;
|
|
797
|
+
try {
|
|
798
|
+
jcsParse(eventBytes);
|
|
799
|
+
} catch (e) {
|
|
800
|
+
if (e instanceof DuplicateKeyError) return e.message;
|
|
801
|
+
}
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
|
|
733
805
|
function thrownDetail(e: unknown): string {
|
|
734
806
|
const text = e instanceof Error ? e.message : String(e);
|
|
735
807
|
return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
|
|
@@ -806,7 +878,17 @@ export async function verifyEvidenceBundle(
|
|
|
806
878
|
bundle: unknown,
|
|
807
879
|
options: VerifyEvidenceOptions = {},
|
|
808
880
|
): Promise<EvidenceBundleResult> {
|
|
809
|
-
|
|
881
|
+
// The ONE place a present `co_signatures` becomes a note, so every caller
|
|
882
|
+
// (including `case.ts`, which calls this function directly per pack) gets
|
|
883
|
+
// it, however the wrapper otherwise verifies. Never touches `status` or
|
|
884
|
+
// the exit code: a bundle that would otherwise verify still verifies, note
|
|
885
|
+
// or not (proof#148).
|
|
886
|
+
const result = await verifyBundle(bundle, options, false);
|
|
887
|
+
const note = coSignaturesNote(bundle);
|
|
888
|
+
if (note !== undefined) {
|
|
889
|
+
result.notes = [...(result.notes ?? []), note];
|
|
890
|
+
}
|
|
891
|
+
return result;
|
|
810
892
|
}
|
|
811
893
|
|
|
812
894
|
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
@@ -917,6 +999,19 @@ async function verifyBundle(
|
|
|
917
999
|
};
|
|
918
1000
|
}
|
|
919
1001
|
|
|
1002
|
+
// --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
|
|
1003
|
+
// `generated_at` is copied into a recomputed verdict's `as_of` (the export
|
|
1004
|
+
// reading) and printed beside every verdict, so a present value that is not
|
|
1005
|
+
// the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
|
|
1006
|
+
// render, whatever its signature. It is read only once the wrapper holds: a
|
|
1007
|
+
// broken wrapper is `failed` above, as it was. Absence is unchanged.
|
|
1008
|
+
if (
|
|
1009
|
+
Object.prototype.hasOwnProperty.call(b, "generated_at") &&
|
|
1010
|
+
!isRfc3339UtcInstant((b as { generated_at?: unknown }).generated_at)
|
|
1011
|
+
) {
|
|
1012
|
+
return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
920
1015
|
// --- Body dispatch ---------------------------------------------------------
|
|
921
1016
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
922
1017
|
|
|
@@ -1390,6 +1485,11 @@ async function verifyAnchorTrace(
|
|
|
1390
1485
|
eventBytesMalformed = true;
|
|
1391
1486
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1392
1487
|
}
|
|
1488
|
+
const duplicate = recordDuplicateKey(eventBytes);
|
|
1489
|
+
if (duplicate !== undefined) {
|
|
1490
|
+
eventBytesMalformed = true;
|
|
1491
|
+
evErrors.push(`event record is not JSON: ${duplicate}`);
|
|
1492
|
+
}
|
|
1393
1493
|
try {
|
|
1394
1494
|
const res = await verifyEnvelope(
|
|
1395
1495
|
ev.envelope,
|
|
@@ -1945,6 +2045,10 @@ async function verifyStampEntry(
|
|
|
1945
2045
|
if (typeof body[f] !== "string" || body[f] === "") {
|
|
1946
2046
|
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
1947
2047
|
base.bodyOk = false;
|
|
2048
|
+
} else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
|
|
2049
|
+
// §5.2 (A31): the promise window is the form `signed_at` pins.
|
|
2050
|
+
errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
|
|
2051
|
+
base.bodyOk = false;
|
|
1948
2052
|
}
|
|
1949
2053
|
}
|
|
1950
2054
|
base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
|
|
@@ -2255,6 +2359,11 @@ async function verifyStampEvidence(
|
|
|
2255
2359
|
eventBytesMalformed = true;
|
|
2256
2360
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
2257
2361
|
}
|
|
2362
|
+
const duplicate = recordDuplicateKey(eventBytes);
|
|
2363
|
+
if (duplicate !== undefined) {
|
|
2364
|
+
eventBytesMalformed = true;
|
|
2365
|
+
evErrors.push(`event record is not JSON: ${duplicate}`);
|
|
2366
|
+
}
|
|
2258
2367
|
try {
|
|
2259
2368
|
const res = await verifyEnvelope(
|
|
2260
2369
|
ev.envelope,
|
|
@@ -3084,6 +3193,10 @@ function payloadFieldError(payload: Record<string, unknown>): string | null {
|
|
|
3084
3193
|
want = "1, 2 or 3";
|
|
3085
3194
|
}
|
|
3086
3195
|
if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
3196
|
+
if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
|
|
3197
|
+
// evidence-export §3.1 (v3.4): the form `signed_at` pins.
|
|
3198
|
+
return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
|
|
3199
|
+
}
|
|
3087
3200
|
}
|
|
3088
3201
|
return null;
|
|
3089
3202
|
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files handed in beside a pack, matched against what its records signed (A29).
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript half of `src/sanning_proof/file_match.py`. Both kernels are
|
|
5
|
+
* held to one table, `vectors/file-match.json`, so neither can pass by agreeing
|
|
6
|
+
* with the other, and `cross-kernel/run_file_match.sh` holds the two readings,
|
|
7
|
+
* and the two commands' `--files` output, to the same bytes.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THIS ANSWERS (evidence-bundle.md 5.7 v1.24, proof#92, decided by Phil
|
|
10
|
+
* 2026-09-23 as option 2). A reader holds a pack and some files beside it and
|
|
11
|
+
* asks whether the pack vouches for them. Each offered file is hashed, SHA-256
|
|
12
|
+
* over its exact bytes, and the digest is looked for among the commitments the
|
|
13
|
+
* pack's SIGNED records make. The reading says which offered files a record
|
|
14
|
+
* commits to (naming the record, its signer and its `signed_at`), which match
|
|
15
|
+
* no record, and which commitments had no file offered.
|
|
16
|
+
*
|
|
17
|
+
* THE RULES THAT CARRY THE WEIGHT:
|
|
18
|
+
*
|
|
19
|
+
* - By hash, and by nothing else. A file's name is carried through as the
|
|
20
|
+
* caller's label and is never compared with an `event_id`, a part's name or
|
|
21
|
+
* a `logs/` path. There is no layout convention.
|
|
22
|
+
* - Unmatched is reported, never guessed.
|
|
23
|
+
* - Only what a record's own key signed: a verified envelope whose
|
|
24
|
+
* `event_bytes` bind to its `payload_hash`, or a hand-off message whose
|
|
25
|
+
* bytes bind to that record's `message_hash`. No wrapper field is read.
|
|
26
|
+
* - The verdict does not move. A match proves that a signed record in the
|
|
27
|
+
* pack committed to those exact bytes, and nothing more (`FILE_MATCH_SCOPE`).
|
|
28
|
+
*
|
|
29
|
+
* WHAT COUNTS AS A COMMITMENT, in record order and then part order:
|
|
30
|
+
*
|
|
31
|
+
* 1. `record`: a record outside envelope-spec.md 4.1 commits through
|
|
32
|
+
* `event.content_hash`, 64 hex characters in either case.
|
|
33
|
+
* 2. `file_part`: a record 4.1 governs commits to its MESSAGE through
|
|
34
|
+
* `event.message_hash` (5.1, A20), never through a `content_hash` it also
|
|
35
|
+
* writes. Where the message's JCS bytes are in hand (in the body, or in
|
|
36
|
+
* the side content) and bind to it, each file part of that message (A18)
|
|
37
|
+
* commits by its `content_hash`.
|
|
38
|
+
*
|
|
39
|
+
* Side content is read for ONE thing, to find a hand-off message that binds to
|
|
40
|
+
* a signed `message_hash`. It is never an offered file.
|
|
41
|
+
*
|
|
42
|
+
* Pure: a bundle, the files and the optional side content in, a reading out.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { hexToBytes, isHexField, sha256Hex } from "./crypto.js";
|
|
46
|
+
import { resolveTraceEvents } from "./evidence.js";
|
|
47
|
+
import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
|
|
48
|
+
import type { HeldRecord } from "./handoff.js";
|
|
49
|
+
import { isFilePart } from "./message-parts.js";
|
|
50
|
+
import { sideContentEntry } from "./side-content.js";
|
|
51
|
+
import type { SideContentMap } from "./side-content.js";
|
|
52
|
+
import type { Envelope } from "./types.js";
|
|
53
|
+
import { jcsParse, verifyEnvelope } from "./verifier.js";
|
|
54
|
+
|
|
55
|
+
/** The one sentence every surface prints with the reading (5.7). */
|
|
56
|
+
export const FILE_MATCH_SCOPE =
|
|
57
|
+
"A match proves that a signed record in the pack committed to these exact " +
|
|
58
|
+
"bytes, and nothing more.";
|
|
59
|
+
|
|
60
|
+
/** Why a record's commitments could not be read, in the order they are tested. */
|
|
61
|
+
export const NOT_READ_REASONS = [
|
|
62
|
+
"no_records",
|
|
63
|
+
"record_unreadable",
|
|
64
|
+
"record_not_verified",
|
|
65
|
+
"record_withheld",
|
|
66
|
+
"no_content_hash",
|
|
67
|
+
"handoff_record_malformed",
|
|
68
|
+
"message_withheld",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
export type NotReadReason = (typeof NOT_READ_REASONS)[number];
|
|
72
|
+
|
|
73
|
+
const REASON_TEXT: Record<NotReadReason, string> = {
|
|
74
|
+
no_records: "this bundle carries no records this reading can read",
|
|
75
|
+
record_unreadable: "its record bytes cannot be read",
|
|
76
|
+
record_not_verified: "its envelope does not verify",
|
|
77
|
+
record_withheld: "its record bytes are withheld",
|
|
78
|
+
no_content_hash: "it commits to no content_hash",
|
|
79
|
+
handoff_record_malformed:
|
|
80
|
+
"it is a hand-off record that commits to no message a reader can bind to",
|
|
81
|
+
message_withheld: "its message is not in hand, so its file parts cannot be read",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// The accept-set minus the quarantined `sanning.mlflow/v1` (DL-63), the set
|
|
85
|
+
// both kernels read a case under.
|
|
86
|
+
const RECORD_PROFILES = ["sanning.agent/v1", "sanning.events/v2"] as const;
|
|
87
|
+
|
|
88
|
+
/** One commitment a signed record makes to a file's bytes. */
|
|
89
|
+
export interface FileCommitment {
|
|
90
|
+
packRef: string | null;
|
|
91
|
+
eventId: string | null;
|
|
92
|
+
kind: "record" | "file_part";
|
|
93
|
+
partIndex: number | null;
|
|
94
|
+
contentHash: string;
|
|
95
|
+
signer: string;
|
|
96
|
+
signedAt: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface MatchedFile {
|
|
100
|
+
name: string;
|
|
101
|
+
sha256: string;
|
|
102
|
+
records: FileCommitment[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface UnmatchedFile {
|
|
106
|
+
name: string;
|
|
107
|
+
sha256: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface NotRead {
|
|
111
|
+
packRef: string | null;
|
|
112
|
+
eventId: string | null;
|
|
113
|
+
reason: NotReadReason;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface FileMatchReading {
|
|
117
|
+
offered: number;
|
|
118
|
+
matched: MatchedFile[];
|
|
119
|
+
unmatched: UnmatchedFile[];
|
|
120
|
+
notOffered: FileCommitment[];
|
|
121
|
+
notRead: NotRead[];
|
|
122
|
+
scope: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A file handed in beside a pack. `name` is a label and is never compared. */
|
|
126
|
+
export interface OfferedFile {
|
|
127
|
+
name: string;
|
|
128
|
+
bytes: Uint8Array;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One pack of a case, for `matchCaseFiles`. */
|
|
132
|
+
export interface FileMatchPack {
|
|
133
|
+
ref: string;
|
|
134
|
+
bundle: unknown;
|
|
135
|
+
content?: SideContentMap | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
139
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function strOrNull(v: unknown): string | null {
|
|
143
|
+
return typeof v === "string" ? v : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function own(o: Record<string, unknown>, key: string): unknown {
|
|
147
|
+
return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The envelope's key and signature in the hex charset at their lengths, the
|
|
151
|
+
// reading every body verifier applies inside a bundle (proof#142).
|
|
152
|
+
function envelopeSpelled(envelope: Record<string, unknown>): boolean {
|
|
153
|
+
for (const [name, nibbles] of [
|
|
154
|
+
["public_key", 64],
|
|
155
|
+
["signature", 128],
|
|
156
|
+
] as const) {
|
|
157
|
+
const value = own(envelope, name);
|
|
158
|
+
if (typeof value === "string" && !isHexField(value, nibbles)) return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function decode(value: unknown): Uint8Array | undefined {
|
|
164
|
+
if (value instanceof Uint8Array) return value;
|
|
165
|
+
if (typeof value === "string") {
|
|
166
|
+
try {
|
|
167
|
+
return hexToBytes(value);
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ABSENT = Symbol("absent");
|
|
176
|
+
|
|
177
|
+
async function messageOf(
|
|
178
|
+
ev: Record<string, unknown>,
|
|
179
|
+
eventId: string | null,
|
|
180
|
+
messageHash: string,
|
|
181
|
+
content: SideContentMap | null | undefined,
|
|
182
|
+
): Promise<unknown> {
|
|
183
|
+
const candidates = [decode(own(ev, "content"))];
|
|
184
|
+
if (eventId !== null) candidates.push(decode(sideContentEntry(content, eventId).value));
|
|
185
|
+
for (const raw of candidates) {
|
|
186
|
+
if (raw === undefined || (await sha256Hex(raw)) !== messageHash) continue;
|
|
187
|
+
try {
|
|
188
|
+
return jcsParse(raw);
|
|
189
|
+
} catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return ABSENT;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function readPack(
|
|
197
|
+
bundle: unknown,
|
|
198
|
+
content: SideContentMap | null | undefined,
|
|
199
|
+
packRef: string | null,
|
|
200
|
+
): Promise<{ commitments: FileCommitment[]; notRead: NotRead[] }> {
|
|
201
|
+
const commitments: FileCommitment[] = [];
|
|
202
|
+
const notRead: NotRead[] = [];
|
|
203
|
+
const events = resolveTraceEvents(bundle) as unknown[] | undefined;
|
|
204
|
+
if (events === undefined) {
|
|
205
|
+
notRead.push({ packRef, eventId: null, reason: "no_records" });
|
|
206
|
+
return { commitments, notRead };
|
|
207
|
+
}
|
|
208
|
+
for (const ev of events) {
|
|
209
|
+
if (!isObject(ev)) {
|
|
210
|
+
notRead.push({ packRef, eventId: null, reason: "record_unreadable" });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const envelope = own(ev, "envelope");
|
|
214
|
+
const eventId = isObject(envelope) ? strOrNull(own(envelope, "event_id")) : null;
|
|
215
|
+
|
|
216
|
+
const rawBytes = own(ev, "event_bytes");
|
|
217
|
+
let eventBytes: Uint8Array | undefined;
|
|
218
|
+
if (rawBytes !== undefined && rawBytes !== null) {
|
|
219
|
+
eventBytes = typeof rawBytes === "string" ? decode(rawBytes) : undefined;
|
|
220
|
+
if (eventBytes === undefined) {
|
|
221
|
+
notRead.push({ packRef, eventId, reason: "record_unreadable" });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = await verifyEnvelope(envelope as Envelope, {
|
|
227
|
+
...(eventBytes !== undefined ? { payloadBytes: eventBytes } : {}),
|
|
228
|
+
acceptedSpecMajors: RECORD_PROFILES,
|
|
229
|
+
});
|
|
230
|
+
if (!result.ok || !isObject(envelope) || !envelopeSpelled(envelope)) {
|
|
231
|
+
notRead.push({ packRef, eventId, reason: "record_not_verified" });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (eventBytes === undefined) {
|
|
235
|
+
notRead.push({ packRef, eventId, reason: "record_withheld" });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (result.payloadHashOk !== true) {
|
|
239
|
+
notRead.push({ packRef, eventId, reason: "record_not_verified" });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
let record: unknown;
|
|
243
|
+
try {
|
|
244
|
+
record = jcsParse(eventBytes);
|
|
245
|
+
} catch {
|
|
246
|
+
record = undefined;
|
|
247
|
+
}
|
|
248
|
+
if (!isObject(record)) {
|
|
249
|
+
notRead.push({ packRef, eventId, reason: "record_unreadable" });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const key = own(envelope, "public_key");
|
|
254
|
+
const signer = typeof key === "string" ? key : "";
|
|
255
|
+
const signedAt = strOrNull(own(envelope, "signed_at"));
|
|
256
|
+
const section = own(record, "event");
|
|
257
|
+
const event = isObject(section) ? section : {};
|
|
258
|
+
|
|
259
|
+
const held: HeldRecord = {
|
|
260
|
+
publicKey: signer,
|
|
261
|
+
record: record as HeldRecord["record"],
|
|
262
|
+
ref: "",
|
|
263
|
+
};
|
|
264
|
+
if (isHandoffNamespace(held)) {
|
|
265
|
+
const messageHash = own(event, "message_hash");
|
|
266
|
+
if (!isConformantHandoffRecord(held) || !isHexField(messageHash, 64, true)) {
|
|
267
|
+
notRead.push({ packRef, eventId, reason: "handoff_record_malformed" });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const message = await messageOf(ev, eventId, messageHash, content);
|
|
271
|
+
if (message === ABSENT) {
|
|
272
|
+
notRead.push({ packRef, eventId, reason: "message_withheld" });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const parts = isObject(message) ? own(message, "parts") : undefined;
|
|
276
|
+
if (Array.isArray(parts)) {
|
|
277
|
+
parts.forEach((part, index) => {
|
|
278
|
+
if (!isFilePart(part)) return;
|
|
279
|
+
commitments.push({
|
|
280
|
+
packRef,
|
|
281
|
+
eventId,
|
|
282
|
+
kind: "file_part",
|
|
283
|
+
partIndex: index,
|
|
284
|
+
contentHash: ((part as { content_hash: string }).content_hash).toLowerCase(),
|
|
285
|
+
signer,
|
|
286
|
+
signedAt,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const contentHash = own(event, "content_hash");
|
|
294
|
+
if (!isHexField(contentHash, 64)) {
|
|
295
|
+
notRead.push({ packRef, eventId, reason: "no_content_hash" });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
commitments.push({
|
|
299
|
+
packRef,
|
|
300
|
+
eventId,
|
|
301
|
+
kind: "record",
|
|
302
|
+
partIndex: null,
|
|
303
|
+
contentHash: contentHash.toLowerCase(),
|
|
304
|
+
signer,
|
|
305
|
+
signedAt,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return { commitments, notRead };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A malformed file entry is the CALLER's mistake, not the evidence's, so it
|
|
312
|
+
// throws rather than reading as a file that matched nothing.
|
|
313
|
+
async function offeredDigests(files: readonly OfferedFile[]): Promise<[string, string][]> {
|
|
314
|
+
const out: [string, string][] = [];
|
|
315
|
+
for (const entry of files) {
|
|
316
|
+
if (!isObject(entry)) throw new TypeError("matchFiles: each file is an object {name, bytes}");
|
|
317
|
+
const { name, bytes } = entry as { name?: unknown; bytes?: unknown };
|
|
318
|
+
if (typeof name !== "string") throw new TypeError("matchFiles: a file's name is a string");
|
|
319
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
320
|
+
throw new TypeError("matchFiles: a file's bytes are a Uint8Array");
|
|
321
|
+
}
|
|
322
|
+
out.push([name, await sha256Hex(bytes)]);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function match(
|
|
328
|
+
commitments: FileCommitment[],
|
|
329
|
+
notRead: NotRead[],
|
|
330
|
+
files: readonly OfferedFile[],
|
|
331
|
+
): Promise<FileMatchReading> {
|
|
332
|
+
const offered = await offeredDigests(files);
|
|
333
|
+
const matched: MatchedFile[] = [];
|
|
334
|
+
const unmatched: UnmatchedFile[] = [];
|
|
335
|
+
const used = new Set<number>();
|
|
336
|
+
for (const [name, digest] of offered) {
|
|
337
|
+
const hits: number[] = [];
|
|
338
|
+
commitments.forEach((c, i) => {
|
|
339
|
+
if (c.contentHash === digest) hits.push(i);
|
|
340
|
+
});
|
|
341
|
+
if (hits.length > 0) {
|
|
342
|
+
for (const i of hits) used.add(i);
|
|
343
|
+
matched.push({ name, sha256: digest, records: hits.map((i) => commitments[i]!) });
|
|
344
|
+
} else {
|
|
345
|
+
unmatched.push({ name, sha256: digest });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
offered: offered.length,
|
|
350
|
+
matched,
|
|
351
|
+
unmatched,
|
|
352
|
+
notOffered: commitments.filter((_, i) => !used.has(i)),
|
|
353
|
+
notRead,
|
|
354
|
+
scope: FILE_MATCH_SCOPE,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Match each offered file against the commitments one pack's signed records
|
|
360
|
+
* make. `content` is the side content handed in beside the pack, read only to
|
|
361
|
+
* find a hand-off message that binds to its record's `message_hash`; it is
|
|
362
|
+
* never an offered file.
|
|
363
|
+
*/
|
|
364
|
+
export async function matchFiles(
|
|
365
|
+
bundle: unknown,
|
|
366
|
+
files: readonly OfferedFile[],
|
|
367
|
+
content?: SideContentMap | null,
|
|
368
|
+
): Promise<FileMatchReading> {
|
|
369
|
+
const { commitments, notRead } = await readPack(bundle, content, null);
|
|
370
|
+
return match(commitments, notRead, files);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
|
|
374
|
+
export async function matchCaseFiles(
|
|
375
|
+
packs: readonly FileMatchPack[],
|
|
376
|
+
files: readonly OfferedFile[],
|
|
377
|
+
): Promise<FileMatchReading> {
|
|
378
|
+
const commitments: FileCommitment[] = [];
|
|
379
|
+
const notRead: NotRead[] = [];
|
|
380
|
+
for (const pack of packs) {
|
|
381
|
+
if (!isObject(pack)) throw new TypeError("matchCaseFiles: each pack is an object {ref, bundle}");
|
|
382
|
+
if (typeof pack.ref !== "string") throw new TypeError("matchCaseFiles: a pack's ref is a string");
|
|
383
|
+
const r = await readPack(pack.bundle, pack.content, pack.ref);
|
|
384
|
+
commitments.push(...r.commitments);
|
|
385
|
+
notRead.push(...r.notRead);
|
|
386
|
+
}
|
|
387
|
+
return match(commitments, notRead, files);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function commitmentJson(c: FileCommitment): Record<string, unknown> {
|
|
391
|
+
return {
|
|
392
|
+
pack_ref: c.packRef,
|
|
393
|
+
event_id: c.eventId,
|
|
394
|
+
kind: c.kind,
|
|
395
|
+
part_index: c.partIndex,
|
|
396
|
+
content_hash: c.contentHash,
|
|
397
|
+
signer: c.signer,
|
|
398
|
+
signed_at: c.signedAt,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The reading as the `files` member of `--json`, identical in both kernels. */
|
|
403
|
+
export function fileMatchToJson(reading: FileMatchReading): Record<string, unknown> {
|
|
404
|
+
return {
|
|
405
|
+
offered: reading.offered,
|
|
406
|
+
matched: reading.matched.map((m) => ({
|
|
407
|
+
name: m.name,
|
|
408
|
+
sha256: m.sha256,
|
|
409
|
+
records: m.records.map(commitmentJson),
|
|
410
|
+
})),
|
|
411
|
+
unmatched: reading.unmatched.map((u) => ({ name: u.name, sha256: u.sha256 })),
|
|
412
|
+
not_offered: reading.notOffered.map(commitmentJson),
|
|
413
|
+
not_read: reading.notRead.map((n) => ({
|
|
414
|
+
pack_ref: n.packRef,
|
|
415
|
+
event_id: n.eventId,
|
|
416
|
+
reason: n.reason,
|
|
417
|
+
rendered: renderNotRead(n),
|
|
418
|
+
})),
|
|
419
|
+
scope: reading.scope,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function recordName(eventId: string | null): string {
|
|
424
|
+
return eventId !== null ? `record ${eventId}` : "a record with no event_id";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function renderCommitment(c: FileCommitment): string {
|
|
428
|
+
const what =
|
|
429
|
+
c.kind === "record"
|
|
430
|
+
? recordName(c.eventId)
|
|
431
|
+
: `file part ${c.partIndex} of the message in ${recordName(c.eventId)}`;
|
|
432
|
+
const where = c.packRef !== null ? ` in pack ${c.packRef}` : "";
|
|
433
|
+
const when = c.signedAt !== null ? c.signedAt : "no stated time";
|
|
434
|
+
return `${what}${where}, signed by ${c.signer}, signed_at ${when}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function renderNotRead(n: NotRead): string {
|
|
438
|
+
const where = n.packRef !== null ? ` in pack ${n.packRef}` : "";
|
|
439
|
+
if (n.reason === "no_records") return `no record read${where}: ${REASON_TEXT[n.reason]}`;
|
|
440
|
+
return `${recordName(n.eventId)}${where} not read: ${REASON_TEXT[n.reason]}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The text report's lines for the reading, identical in both kernels. */
|
|
444
|
+
export function renderFileMatch(reading: FileMatchReading): string[] {
|
|
445
|
+
const lines = [
|
|
446
|
+
`files ${reading.offered} offered: ${reading.matched.length} vouched for, ` +
|
|
447
|
+
`${reading.unmatched.length} match no record; ` +
|
|
448
|
+
`${reading.notOffered.length} commitment(s) had no file offered`,
|
|
449
|
+
];
|
|
450
|
+
for (const m of reading.matched) {
|
|
451
|
+
lines.push(` vouched for ${m.name} sha256 ${m.sha256}`);
|
|
452
|
+
for (const c of m.records) lines.push(` by ${renderCommitment(c)}`);
|
|
453
|
+
}
|
|
454
|
+
for (const u of reading.unmatched) lines.push(` no match ${u.name} sha256 ${u.sha256}`);
|
|
455
|
+
for (const c of reading.notOffered) {
|
|
456
|
+
lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
|
|
457
|
+
}
|
|
458
|
+
for (const n of reading.notRead) lines.push(` not read ${renderNotRead(n)}`);
|
|
459
|
+
lines.push(` ${reading.scope}`);
|
|
460
|
+
return lines;
|
|
461
|
+
}
|