@sanning/proof 0.5.0 → 0.7.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/README.md +31 -4
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +351 -0
- package/dist/cli.js +253 -38
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +204 -7
- package/dist/evidence.js +621 -67
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +38 -1
- package/dist/side-content.d.ts +36 -0
- package/dist/side-content.js +98 -0
- package/dist/verifier.js +46 -7
- package/package.json +2 -1
- package/src/chain.ts +460 -0
- package/src/cli.ts +324 -35
- package/src/compose.ts +1 -1
- package/src/evidence.ts +875 -81
- package/src/grading.ts +623 -0
- package/src/index.ts +79 -0
- package/src/side-content.ts +119 -0
- package/src/verifier.ts +49 -7
- package/vectors/README.md +66 -0
- package/vectors/chain-reading.json +293 -0
- package/vectors/pack-grading.json +136 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export type PackVerdict = "verified" | "partial" | "pending" | "not_found" | "mixed" | "failed" | "malformed" | "incomplete";
|
|
2
|
+
/**
|
|
3
|
+
* The minimum this module reads out of a verification result.
|
|
4
|
+
*
|
|
5
|
+
* `EvidenceBundleResult` satisfies it structurally, so a consumer normally
|
|
6
|
+
* passes the kernel's own result straight through. It is stated as a minimum
|
|
7
|
+
* rather than an import so a surface running an OLDER pinned kernel — or one
|
|
8
|
+
* grading a result it adapted from another verifier — can still be held to
|
|
9
|
+
* this rule.
|
|
10
|
+
*/
|
|
11
|
+
export interface GradeInput {
|
|
12
|
+
status: string | null | undefined;
|
|
13
|
+
events: {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
contentOk: boolean | null;
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The account of which records disclosed bytes, and what could not be used.
|
|
20
|
+
*
|
|
21
|
+
* An empty account is NOT a clean bill of health: callers gate on this, never
|
|
22
|
+
* on the absence of mismatches. The old readers wrapped the disclosure read in
|
|
23
|
+
* `try {} catch {}` and fell back to an empty map, so deleting every log turned
|
|
24
|
+
* every content check undetermined, left every event `ok`, and printed a
|
|
25
|
+
* verified verdict over zero hashed bytes.
|
|
26
|
+
*/
|
|
27
|
+
export interface DisclosureAccount {
|
|
28
|
+
/** `event_id`s the bundle presents, in bundle order. */
|
|
29
|
+
expected: string[];
|
|
30
|
+
/** `event_id`s whose bytes were supplied. */
|
|
31
|
+
present: string[];
|
|
32
|
+
/** expected, but no bytes were supplied. */
|
|
33
|
+
missing: string[];
|
|
34
|
+
/** supplied, but not usable as evidence for the record it names. */
|
|
35
|
+
unreadable: {
|
|
36
|
+
eventId: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
}[];
|
|
39
|
+
/** supplied bytes no record in the bundle accounts for. */
|
|
40
|
+
extra: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A completeness note: the pack presents a different number of records for a
|
|
44
|
+
* checkpoint than that checkpoint's SIGNED record commits to.
|
|
45
|
+
*/
|
|
46
|
+
export type CompletenessNote = {
|
|
47
|
+
checkpointRef: string;
|
|
48
|
+
signed: number;
|
|
49
|
+
presented: number;
|
|
50
|
+
kind: "omitted" | "surplus";
|
|
51
|
+
} | {
|
|
52
|
+
checkpointRef: string;
|
|
53
|
+
signed: null;
|
|
54
|
+
presented: null;
|
|
55
|
+
kind: "unreadable";
|
|
56
|
+
};
|
|
57
|
+
export type PackGradeCauseKind = "records-omitted" | "records-surplus" | "leaf-count-unreadable" | "no-disclosure" | "missing-disclosure" | "unreadable-disclosure" | "unaccounted-disclosure";
|
|
58
|
+
/**
|
|
59
|
+
* One reason a pack is not `verified`, as data.
|
|
60
|
+
*
|
|
61
|
+
* Structured because the two surfaces that used to derive this rule also
|
|
62
|
+
* derived its prose, and prose is the part that drifts hardest and matters
|
|
63
|
+
* least. `renderCause` produces the house wording; a surface with its own
|
|
64
|
+
* voice reads the fields.
|
|
65
|
+
*/
|
|
66
|
+
export interface PackGradeCause {
|
|
67
|
+
kind: PackGradeCauseKind;
|
|
68
|
+
/** The checkpoint this is about, for the completeness kinds. */
|
|
69
|
+
checkpointRef?: string;
|
|
70
|
+
/** The count a checkpoint's signed record commits to. */
|
|
71
|
+
signed?: number;
|
|
72
|
+
/** The count the pack actually presents for that checkpoint. */
|
|
73
|
+
presented?: number;
|
|
74
|
+
/** How many records the cause is about (missing, unaccounted, expected). */
|
|
75
|
+
count?: number;
|
|
76
|
+
/** How many records the bundle presents in total, where that frames `count`. */
|
|
77
|
+
of?: number;
|
|
78
|
+
/** The first affected `event_id`, so a reader has somewhere to look. */
|
|
79
|
+
firstEventId?: string;
|
|
80
|
+
/** Why one supplied disclosure could not be used. */
|
|
81
|
+
reason?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface PackGrade {
|
|
84
|
+
verdict: PackVerdict;
|
|
85
|
+
/** Records whose OWN bytes bound to their OWN commitment. Nothing else. */
|
|
86
|
+
recordsVerified: number;
|
|
87
|
+
recordsFailed: number;
|
|
88
|
+
recordsUndetermined: number;
|
|
89
|
+
causes: PackGradeCause[];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Account for the disclosed bytes a pack handed us against the records its
|
|
93
|
+
* bundle presents.
|
|
94
|
+
*
|
|
95
|
+
* `content` is the same map `verifyEvidenceBundle` takes — a plain object or a
|
|
96
|
+
* `Map` — so a caller builds it once and uses it for both. Prefer the `Map`:
|
|
97
|
+
* an `event_id` is producer-chosen, and on a plain object `__proto__` is not a
|
|
98
|
+
* key but a prototype setter, so bytes written under that id never land. A
|
|
99
|
+
* ZERO-BYTE entry is present, not missing: bytes were supplied, the check ran,
|
|
100
|
+
* and the mismatch the verifier reports stands. A truncated log must not be
|
|
101
|
+
* able to launder itself into "unknown".
|
|
102
|
+
*
|
|
103
|
+
* A CONVENIENCE, NOT THE ONLY WAY TO BUILD AN ACCOUNT — and the difference
|
|
104
|
+
* matters for one field. `extra` here can only ever name keys the content map
|
|
105
|
+
* carries, so a transport whose surplus is not representable as a map entry
|
|
106
|
+
* cannot report it through this function: a pack directory holding
|
|
107
|
+
* `logs/notes.txt`, or a second file for a record already accounted for, is
|
|
108
|
+
* invisible to a map built by iterating the bundle's own events. A consumer
|
|
109
|
+
* reading a DIRECTORY should build `DisclosureAccount` itself from the real
|
|
110
|
+
* listing and pass that to `gradePack` — the rule takes an account, never a
|
|
111
|
+
* bundle, precisely so the transport can say things the map cannot.
|
|
112
|
+
*/
|
|
113
|
+
export declare function accountDisclosure(bundle: unknown, content: Readonly<Record<string, Uint8Array>> | ReadonlyMap<string, Uint8Array>): DisclosureAccount;
|
|
114
|
+
/** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
|
|
115
|
+
export declare function signedLeafCount(checkpoint: {
|
|
116
|
+
record_bytes?: unknown;
|
|
117
|
+
} | null | undefined): number | null;
|
|
118
|
+
/**
|
|
119
|
+
* Omission — the failure a per-record check structurally cannot see.
|
|
120
|
+
*
|
|
121
|
+
* Drop records from a pack and every SURVIVOR still verifies perfectly:
|
|
122
|
+
* signatures, inclusion proofs, content hashes, all green. The number that
|
|
123
|
+
* exposes it is already in the pack and already signed — each checkpoint's
|
|
124
|
+
* record commits to the `leaf_count` of the tree it anchored. This compares
|
|
125
|
+
* that against how many records the pack presents for that checkpoint.
|
|
126
|
+
*
|
|
127
|
+
* A checkpoint whose signed count cannot be read is `unreadable`: a
|
|
128
|
+
* completeness check we could not run is UNKNOWN, never a pass.
|
|
129
|
+
*/
|
|
130
|
+
export declare function completenessNotes(bundle: unknown): CompletenessNote[];
|
|
131
|
+
/**
|
|
132
|
+
* Every reason this pack is not `verified`, in the order a reader should hear
|
|
133
|
+
* them: what is missing from the whole, then what is missing from the parts.
|
|
134
|
+
*/
|
|
135
|
+
export declare function gradeCauses(account: DisclosureAccount, notes: readonly CompletenessNote[]): PackGradeCause[];
|
|
136
|
+
/**
|
|
137
|
+
* The verdict a surface may print, and the counts it may print beside it.
|
|
138
|
+
*
|
|
139
|
+
* The invariant, stated so a mutation test can kill it: `verified` is reachable
|
|
140
|
+
* ONLY when every record's supplied bytes bound to its own commitment and
|
|
141
|
+
* nothing is missing, unreadable, surplus or omitted.
|
|
142
|
+
*
|
|
143
|
+
* Precedence: malformed > failed > incomplete > the kernel's own headline.
|
|
144
|
+
* A hard verdict wins — "the bytes are absent" is never reported as failure
|
|
145
|
+
* (§5.1: undetermined, not failed), and a proven mismatch is never softened
|
|
146
|
+
* into absence. The last branch passes the kernel's headline through unchanged:
|
|
147
|
+
* this rule DOWNGRADES a verdict, it never invents one.
|
|
148
|
+
*/
|
|
149
|
+
export declare function gradePack(result: GradeInput, account: DisclosureAccount, notes: readonly CompletenessNote[]): PackGrade;
|
|
150
|
+
/**
|
|
151
|
+
* The house wording for one cause.
|
|
152
|
+
*
|
|
153
|
+
* Offered, not imposed: a surface with its own voice reads the fields on
|
|
154
|
+
* `PackGradeCause` instead. What must not vary between surfaces is the verdict
|
|
155
|
+
* and the counts, and those come from `gradePack`.
|
|
156
|
+
*
|
|
157
|
+
* The `subject` names what the pack is from the reader's position — "this pack"
|
|
158
|
+
* on a page someone dropped a file onto, "the pack" in a CLI report.
|
|
159
|
+
*/
|
|
160
|
+
export declare function renderCause(cause: PackGradeCause, subject?: string): string;
|
package/dist/grading.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// Pack grading — what a verification SURFACE may claim about a pack it was
|
|
2
|
+
// handed, as distinct from what the wrapper verdict says about a bundle.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS LIVES IN THE KERNEL.
|
|
5
|
+
// This rule was, until now, derived twice: once in a CLI trust close and once
|
|
6
|
+
// in a browser verify page, in two repos and two languages, held together by a
|
|
7
|
+
// conformance table copied byte-for-byte into both. The table caught an
|
|
8
|
+
// implementation drifting from the rule. Nothing caught the table itself being
|
|
9
|
+
// edited on one side — and the two surfaces had already diverged once, one
|
|
10
|
+
// binding disclosed bytes per record and the other believed to bind by set
|
|
11
|
+
// membership. Two implementations of one verification rule is the defect; a
|
|
12
|
+
// shared table is a mitigation of it.
|
|
13
|
+
//
|
|
14
|
+
// Grading is kernel semantics that leaked into consumers. The rule it encodes
|
|
15
|
+
// is already normative in `specs/evidence-bundle.md`: absent disclosure — and
|
|
16
|
+
// an absent committed `content_hash` — is **undetermined, not failed** (§5.1),
|
|
17
|
+
// a genuine mismatch fails the event and the rollup (§5.1), and no status ever
|
|
18
|
+
// means "safe" (§6). What the surfaces added was a sharper word for the
|
|
19
|
+
// undetermined case and an account of what the pack failed to disclose. Both
|
|
20
|
+
// belong next to the verifier that produced the result.
|
|
21
|
+
//
|
|
22
|
+
// ── the seam ────────────────────────────────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// Everything here is PURE: data in, data out. No `fs`, no `fetch`, no key
|
|
25
|
+
// lifecycle — the kernel's standing boundary (`specs/architecture.md` §3) is
|
|
26
|
+
// unchanged. How disclosed bytes were transported is a consumer concern; a
|
|
27
|
+
// directory read and a browser drop both arrive here as a content map and MUST
|
|
28
|
+
// produce the same verdict. `causes` come back STRUCTURED, so a surface can
|
|
29
|
+
// render its own prose (or call `renderCause` and get the house wording)
|
|
30
|
+
// without the kernel taking on presentation.
|
|
31
|
+
//
|
|
32
|
+
// ── the rule ────────────────────────────────────────────────────────────────
|
|
33
|
+
//
|
|
34
|
+
// Three outcomes where the old code had two:
|
|
35
|
+
//
|
|
36
|
+
// verified every record's own bytes hashed to its OWN committed
|
|
37
|
+
// content_hash, and nothing is missing, unreadable, surplus or
|
|
38
|
+
// omitted
|
|
39
|
+
// failed some record's bytes DISAGREE with what was committed — tamper
|
|
40
|
+
// incomplete some record's bytes are absent or unreadable, or the pack
|
|
41
|
+
// presents a different number of records than a checkpoint
|
|
42
|
+
// signed for — UNKNOWN
|
|
43
|
+
//
|
|
44
|
+
// `incomplete` is the surface's word for undetermined, and it is deliberately
|
|
45
|
+
// NOT `verdict.status` from `evidence-bundle.md` §3. That enum grades a signed
|
|
46
|
+
// bundle; this one grades a PACK — a strictly larger object, the bundle plus
|
|
47
|
+
// whatever bytes travelled beside it. A pack can be incomplete while the bundle
|
|
48
|
+
// it carries is impeccable, which is exactly the case that used to render green
|
|
49
|
+
// over zero hashed bytes. Widening §3 to carry `incomplete` would be a spec
|
|
50
|
+
// change; keeping the two enums distinct is not.
|
|
51
|
+
//
|
|
52
|
+
// `recordsVerified` counts CONTENT-BOUND records only. The kernel's own
|
|
53
|
+
// `event.ok` tolerates undisclosed content by design — a bundle may legitimately
|
|
54
|
+
// withhold a record (minimal disclosure, §5.1) — which is right for the kernel
|
|
55
|
+
// and wrong for a surface whose headline is that the logs were checked.
|
|
56
|
+
// Counting `ok` events is how "26 records verified" got printed over zero
|
|
57
|
+
// hashed bytes.
|
|
58
|
+
//
|
|
59
|
+
// Binding is PER RECORD, never per set. The bytes disclosed for `event_id` are
|
|
60
|
+
// hashed against the commitment inside THAT event's signed record; the verifier
|
|
61
|
+
// does this and this module never re-implements it. A set-membership check
|
|
62
|
+
// ("is this digest anywhere in the bundle?") passes a swap of two records'
|
|
63
|
+
// bytes, and that is the shape of the divergence this module exists to end.
|
|
64
|
+
//
|
|
65
|
+
// The conformance statement of all of the above is `../vectors/pack-grading.json`,
|
|
66
|
+
// which ships in the published tarball so a consumer can diff its own vendored
|
|
67
|
+
// copy against it. If an implementation disagrees with the table, the
|
|
68
|
+
// implementation is wrong.
|
|
69
|
+
import { hexToBytes } from "./crypto.js";
|
|
70
|
+
import { hasSideContent, sideContentKeys } from "./side-content.js";
|
|
71
|
+
/**
|
|
72
|
+
* The checkpoint's join key, in whichever vocabulary the bundle speaks.
|
|
73
|
+
*
|
|
74
|
+
* ── Why this is read from the entry and not from the body_type ──
|
|
75
|
+
*
|
|
76
|
+
* Grading runs over a PACK, which a consumer may hand us in pieces, so the
|
|
77
|
+
* wrapper is not always in scope here. Reading the key off the entry keeps
|
|
78
|
+
* grading honest for both forms with no wrapper lookup. It is deliberately
|
|
79
|
+
* NOT a fallback chain that would accept either spelling on the same entry:
|
|
80
|
+
* an entry carrying both is a mixed bundle, and `verifyEvidenceBundle`
|
|
81
|
+
* rejects it — grading must not quietly grade what the verifier refuses.
|
|
82
|
+
*
|
|
83
|
+
* Returning `null` (rather than a "(no tx_id)" placeholder that compares
|
|
84
|
+
* equal to itself) matters: before this was dual-form, a v2 pack read every
|
|
85
|
+
* checkpoint key as absent, every `presented` count as zero, and reported
|
|
86
|
+
* EVERY checkpoint as "omitted" — a completeness accusation manufactured by
|
|
87
|
+
* a field rename. A key we cannot read is unreadable, never zero.
|
|
88
|
+
*/
|
|
89
|
+
function joinKeyOf(o) {
|
|
90
|
+
if (!o)
|
|
91
|
+
return null;
|
|
92
|
+
const hasV2 = typeof o.checkpoint_ref === "string";
|
|
93
|
+
const hasV1 = typeof o.tx_id === "string";
|
|
94
|
+
if (hasV2 === hasV1)
|
|
95
|
+
return null; // neither, or both (a mixed entry)
|
|
96
|
+
return (hasV2 ? o.checkpoint_ref : o.tx_id);
|
|
97
|
+
}
|
|
98
|
+
function inclusionKeyOf(incl) {
|
|
99
|
+
if (!incl)
|
|
100
|
+
return null;
|
|
101
|
+
const hasV2 = typeof incl.checkpoint_ref === "string";
|
|
102
|
+
const hasV1 = typeof incl.checkpoint_tx_id === "string";
|
|
103
|
+
if (hasV2 === hasV1)
|
|
104
|
+
return null;
|
|
105
|
+
return (hasV2 ? incl.checkpoint_ref : incl.checkpoint_tx_id);
|
|
106
|
+
}
|
|
107
|
+
const eventsOf = (bundle) => {
|
|
108
|
+
const e = bundle?.body?.events;
|
|
109
|
+
return Array.isArray(e) ? e : [];
|
|
110
|
+
};
|
|
111
|
+
const checkpointsOf = (bundle) => {
|
|
112
|
+
const c = bundle?.body?.checkpoints;
|
|
113
|
+
return Array.isArray(c) ? c : [];
|
|
114
|
+
};
|
|
115
|
+
// Presence in a caller-built content map — DEFERRED to `side-content.ts`, which
|
|
116
|
+
// is the same rule the VERIFIER applies, because two answers to "is there an
|
|
117
|
+
// entry" inside one kernel is two implementations of one verification rule, and
|
|
118
|
+
// ending that is why this module was brought into the kernel at all.
|
|
119
|
+
//
|
|
120
|
+
// This module used to answer it laxly: own + enumerable, nothing more. The
|
|
121
|
+
// verifier additionally requires a DATA property (an accessor can throw, and a
|
|
122
|
+
// verifier must never raise on adversarial input) holding something other than
|
|
123
|
+
// null/undefined (`{ [id]: undefined }` and `new Map([[id, undefined]])` are
|
|
124
|
+
// how "no entry" is spelled in the two shapes). So on one map this module said
|
|
125
|
+
// PRESENT and the verifier said nothing was offered — and `gradePack` returned
|
|
126
|
+
// `incomplete` with ZERO causes, the very outcome the comment in
|
|
127
|
+
// `accountDisclosure` below says was removed. Now there is one definition and
|
|
128
|
+
// two call sites.
|
|
129
|
+
/**
|
|
130
|
+
* `event_id`s name files on every transport this rule has met. An id that
|
|
131
|
+
* cannot safely name one cannot be bound to disclosed bytes, so it is refused
|
|
132
|
+
* rather than joined into a path.
|
|
133
|
+
*
|
|
134
|
+
* ── WHAT THIS IS AND IS NOT ─────────────────────────────────────────────────
|
|
135
|
+
*
|
|
136
|
+
* **Not a traversal fix, and it must not be described as one.** Nothing in
|
|
137
|
+
* this kernel or in the `proof` CLI joins an `event_id` into a path: the CLI's
|
|
138
|
+
* only join is `join(dirPath, name)` where `name` came from `readdir`, so the
|
|
139
|
+
* id is DERIVED from a real directory entry rather than used to reach one.
|
|
140
|
+
* Claiming otherwise would misrepresent a hardening as a vulnerability fix.
|
|
141
|
+
*
|
|
142
|
+
* It is an **honesty guard**, and it points one way only. The documented pack
|
|
143
|
+
* layout is `logs/<event_id>.json`, so a downstream consumer WILL join these,
|
|
144
|
+
* and an id that cannot name a file on the holder's filesystem is an id whose
|
|
145
|
+
* bytes will never arrive. Saying so — `unreadable`, and therefore
|
|
146
|
+
* `incomplete` — is the truth about that record. Refusing an id can only move
|
|
147
|
+
* a verdict toward UNKNOWN, never toward verified, which is what makes
|
|
148
|
+
* tightening this safe: the failure mode of a rule that is too strict is a
|
|
149
|
+
* conservative verdict, and the failure mode of one that is too lax is a
|
|
150
|
+
* record silently graded on bytes that were never there.
|
|
151
|
+
*
|
|
152
|
+
* ── WHAT IS REFUSED, AND WHY EACH ONE ───────────────────────────────────────
|
|
153
|
+
*
|
|
154
|
+
* charset — anything outside letters, digits, dot, underscore, dash.
|
|
155
|
+
* No separator of any platform survives it.
|
|
156
|
+
* `.` and `..` — not names but path entries. Under the documented `.json`
|
|
157
|
+
* convention they are harmless (`logs/..json` is a file);
|
|
158
|
+
* under a consumer that writes `logs/<event_id>` or uses
|
|
159
|
+
* the id as a directory, `..` IS the parent. The kernel
|
|
160
|
+
* does not know the convention, so it refuses the two ids
|
|
161
|
+
* that are hazardous under any of them.
|
|
162
|
+
* trailing dot — Windows strips it, so `a.` and `a` are one file. Two
|
|
163
|
+
* distinct records collapsing onto one path means one
|
|
164
|
+
* record's bytes overwrite another's, and the verifier then
|
|
165
|
+
* reports a MISMATCH — an accusation manufactured by a
|
|
166
|
+
* filesystem rather than by a tamper.
|
|
167
|
+
* device names — `CON`, `PRN`, `AUX`, `NUL`, `COM0-9`, `LPT0-9`, with or
|
|
168
|
+
* without an extension. On Windows `CON.json` is still the
|
|
169
|
+
* console device: the write appears to succeed and nothing
|
|
170
|
+
* is on disk.
|
|
171
|
+
* length — a POSIX path component is 255 bytes. The charset is
|
|
172
|
+
* ASCII, so characters are bytes; 200 leaves the documented
|
|
173
|
+
* `.json` and room for a prefix a consumer adds, and sits
|
|
174
|
+
* far above any real id (a UUID is 36).
|
|
175
|
+
*
|
|
176
|
+
* ── WHAT IS DELIBERATELY *NOT* REFUSED ──────────────────────────────────────
|
|
177
|
+
*
|
|
178
|
+
* **Case collisions.** `A` and `a` are distinct ids that are one file on
|
|
179
|
+
* macOS and Windows. They stay legal, because banning uppercase would refuse
|
|
180
|
+
* ordinary ids for a hazard the kernel cannot see: this module takes a content
|
|
181
|
+
* MAP, which is case-sensitive and has already resolved the question. The
|
|
182
|
+
* collision happens in the transport, and `accountDisclosure` is explicitly a
|
|
183
|
+
* convenience rather than the only way to build an account — a consumer
|
|
184
|
+
* reading a DIRECTORY builds its own and is the one that can detect it.
|
|
185
|
+
* Pinned as a decision, not left to be rediscovered.
|
|
186
|
+
*/
|
|
187
|
+
const SAFE_EVENT_ID = /^[A-Za-z0-9._-]+$/;
|
|
188
|
+
const MAX_EVENT_ID_LENGTH = 200;
|
|
189
|
+
const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
|
|
190
|
+
/** Why this id cannot name a file, or null if it can. Prose, for a surface. */
|
|
191
|
+
function unsafeEventIdReason(eventId) {
|
|
192
|
+
if (!SAFE_EVENT_ID.test(eventId)) {
|
|
193
|
+
return "only letters, digits, dot, underscore and dash are admitted";
|
|
194
|
+
}
|
|
195
|
+
if (eventId === "." || eventId === "..") {
|
|
196
|
+
return "a relative path entry, not a name";
|
|
197
|
+
}
|
|
198
|
+
if (eventId.endsWith(".")) {
|
|
199
|
+
return "a trailing dot is stripped on Windows, so two ids collide on one file";
|
|
200
|
+
}
|
|
201
|
+
if (WINDOWS_DEVICE_NAME.test(eventId)) {
|
|
202
|
+
return "a reserved Windows device name, which swallows a write with no file on disk";
|
|
203
|
+
}
|
|
204
|
+
if (eventId.length > MAX_EVENT_ID_LENGTH) {
|
|
205
|
+
return `longer than ${MAX_EVENT_ID_LENGTH} characters, which overruns a path component`;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Account for the disclosed bytes a pack handed us against the records its
|
|
211
|
+
* bundle presents.
|
|
212
|
+
*
|
|
213
|
+
* `content` is the same map `verifyEvidenceBundle` takes — a plain object or a
|
|
214
|
+
* `Map` — so a caller builds it once and uses it for both. Prefer the `Map`:
|
|
215
|
+
* an `event_id` is producer-chosen, and on a plain object `__proto__` is not a
|
|
216
|
+
* key but a prototype setter, so bytes written under that id never land. A
|
|
217
|
+
* ZERO-BYTE entry is present, not missing: bytes were supplied, the check ran,
|
|
218
|
+
* and the mismatch the verifier reports stands. A truncated log must not be
|
|
219
|
+
* able to launder itself into "unknown".
|
|
220
|
+
*
|
|
221
|
+
* A CONVENIENCE, NOT THE ONLY WAY TO BUILD AN ACCOUNT — and the difference
|
|
222
|
+
* matters for one field. `extra` here can only ever name keys the content map
|
|
223
|
+
* carries, so a transport whose surplus is not representable as a map entry
|
|
224
|
+
* cannot report it through this function: a pack directory holding
|
|
225
|
+
* `logs/notes.txt`, or a second file for a record already accounted for, is
|
|
226
|
+
* invisible to a map built by iterating the bundle's own events. A consumer
|
|
227
|
+
* reading a DIRECTORY should build `DisclosureAccount` itself from the real
|
|
228
|
+
* listing and pass that to `gradePack` — the rule takes an account, never a
|
|
229
|
+
* bundle, precisely so the transport can say things the map cannot.
|
|
230
|
+
*/
|
|
231
|
+
export function accountDisclosure(bundle, content) {
|
|
232
|
+
const expected = [];
|
|
233
|
+
const present = [];
|
|
234
|
+
const missing = [];
|
|
235
|
+
const unreadable = [];
|
|
236
|
+
for (const [i, ev] of eventsOf(bundle).entries()) {
|
|
237
|
+
const eventId = ev?.envelope?.event_id;
|
|
238
|
+
if (typeof eventId !== "string" || eventId.length === 0) {
|
|
239
|
+
unreadable.push({ eventId: `#${i + 1}`, reason: "the bundle event carries no event_id" });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
expected.push(eventId);
|
|
243
|
+
const unsafe = unsafeEventIdReason(eventId);
|
|
244
|
+
if (unsafe !== null) {
|
|
245
|
+
// The prefix is stable so a surface can group these; the suffix says
|
|
246
|
+
// which rule refused it, because "not a safe file name" alone leaves a
|
|
247
|
+
// holder with no idea what to change.
|
|
248
|
+
unreadable.push({ eventId, reason: `event_id is not a safe file name: ${unsafe}` });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// An OWN entry, NOT `content[eventId] === undefined`. Every key on
|
|
252
|
+
// Object.prototype — `constructor`, `toString`, `valueOf`, `__proto__` —
|
|
253
|
+
// passes SAFE_EVENT_ID (see the regex above: it admits letters, digits,
|
|
254
|
+
// dot, underscore and dash, so every one of them is a legal id here) and
|
|
255
|
+
// resolves to an inherited value, so an indexed lookup reported a record as
|
|
256
|
+
// PRESENT when nothing at all was disclosed for it. That is the module's
|
|
257
|
+
// own founding defect (absent evidence read as good evidence) reappearing
|
|
258
|
+
// one layer down: the verdict stayed `incomplete` because the verifier
|
|
259
|
+
// still had no bytes to bind, but with ZERO causes — a surface saying
|
|
260
|
+
// "incomplete" and unable to say why. `__proto__` is worse still: assigning
|
|
261
|
+
// it on a plain object either silently drops the write or replaces the
|
|
262
|
+
// object's prototype outright, so bytes a holder genuinely supplied never
|
|
263
|
+
// reach the map, and the indexed read then claimed they had.
|
|
264
|
+
if (!hasSideContent(content, eventId))
|
|
265
|
+
missing.push(eventId);
|
|
266
|
+
else
|
|
267
|
+
present.push(eventId);
|
|
268
|
+
}
|
|
269
|
+
const accounted = new Set(expected);
|
|
270
|
+
const extra = sideContentKeys(content).filter((id) => !accounted.has(id));
|
|
271
|
+
return { expected, present, missing, unreadable, extra };
|
|
272
|
+
}
|
|
273
|
+
/** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
|
|
274
|
+
export function signedLeafCount(checkpoint) {
|
|
275
|
+
const hex = checkpoint?.record_bytes;
|
|
276
|
+
if (typeof hex !== "string" || hex.length === 0)
|
|
277
|
+
return null;
|
|
278
|
+
try {
|
|
279
|
+
const record = JSON.parse(new TextDecoder().decode(hexToBytes(hex)));
|
|
280
|
+
const n = record?.event?.leaf_count;
|
|
281
|
+
return Number.isInteger(n) ? n : null;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Omission — the failure a per-record check structurally cannot see.
|
|
289
|
+
*
|
|
290
|
+
* Drop records from a pack and every SURVIVOR still verifies perfectly:
|
|
291
|
+
* signatures, inclusion proofs, content hashes, all green. The number that
|
|
292
|
+
* exposes it is already in the pack and already signed — each checkpoint's
|
|
293
|
+
* record commits to the `leaf_count` of the tree it anchored. This compares
|
|
294
|
+
* that against how many records the pack presents for that checkpoint.
|
|
295
|
+
*
|
|
296
|
+
* A checkpoint whose signed count cannot be read is `unreadable`: a
|
|
297
|
+
* completeness check we could not run is UNKNOWN, never a pass.
|
|
298
|
+
*/
|
|
299
|
+
export function completenessNotes(bundle) {
|
|
300
|
+
const events = eventsOf(bundle);
|
|
301
|
+
const notes = [];
|
|
302
|
+
for (const cp of checkpointsOf(bundle)) {
|
|
303
|
+
const key = joinKeyOf(cp);
|
|
304
|
+
const checkpointRef = key ?? "(no checkpoint_ref)";
|
|
305
|
+
const signed = signedLeafCount(cp);
|
|
306
|
+
// No readable key means we cannot count what was presented against it, so
|
|
307
|
+
// completeness is UNKNOWN — the same answer as an unreadable leaf_count,
|
|
308
|
+
// and never a shortfall.
|
|
309
|
+
if (signed === null || key === null) {
|
|
310
|
+
notes.push({ checkpointRef, signed: null, presented: null, kind: "unreadable" });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const presented = events.filter((e) => inclusionKeyOf(e?.inclusion) === key).length;
|
|
314
|
+
if (presented < signed)
|
|
315
|
+
notes.push({ checkpointRef, signed, presented, kind: "omitted" });
|
|
316
|
+
else if (presented > signed)
|
|
317
|
+
notes.push({ checkpointRef, signed, presented, kind: "surplus" });
|
|
318
|
+
}
|
|
319
|
+
return notes;
|
|
320
|
+
}
|
|
321
|
+
// ---- The rule ----------------------------------------------------------------
|
|
322
|
+
/**
|
|
323
|
+
* Every reason this pack is not `verified`, in the order a reader should hear
|
|
324
|
+
* them: what is missing from the whole, then what is missing from the parts.
|
|
325
|
+
*/
|
|
326
|
+
export function gradeCauses(account, notes) {
|
|
327
|
+
const causes = [];
|
|
328
|
+
for (const note of notes) {
|
|
329
|
+
if (note.kind === "omitted") {
|
|
330
|
+
causes.push({
|
|
331
|
+
kind: "records-omitted",
|
|
332
|
+
checkpointRef: note.checkpointRef,
|
|
333
|
+
signed: note.signed,
|
|
334
|
+
presented: note.presented,
|
|
335
|
+
count: note.signed - note.presented,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
else if (note.kind === "surplus") {
|
|
339
|
+
causes.push({
|
|
340
|
+
kind: "records-surplus",
|
|
341
|
+
checkpointRef: note.checkpointRef,
|
|
342
|
+
signed: note.signed,
|
|
343
|
+
presented: note.presented,
|
|
344
|
+
count: note.presented - note.signed,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
causes.push({ kind: "leaf-count-unreadable", checkpointRef: note.checkpointRef });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// "Nothing at all was disclosed" is reported once, as itself. It is the
|
|
352
|
+
// regression that rendered a green banner over zero hashed bytes, and a
|
|
353
|
+
// reader deserves to be told the pack disclosed nothing rather than counting
|
|
354
|
+
// N absences.
|
|
355
|
+
if (account.expected.length > 0 && account.present.length === 0 && account.unreadable.length === 0) {
|
|
356
|
+
causes.push({ kind: "no-disclosure", count: account.expected.length, of: account.expected.length });
|
|
357
|
+
}
|
|
358
|
+
else if (account.missing.length > 0) {
|
|
359
|
+
causes.push({
|
|
360
|
+
kind: "missing-disclosure",
|
|
361
|
+
count: account.missing.length,
|
|
362
|
+
of: account.expected.length,
|
|
363
|
+
...(account.missing[0] === undefined ? {} : { firstEventId: account.missing[0] }),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
for (const u of account.unreadable) {
|
|
367
|
+
causes.push({ kind: "unreadable-disclosure", firstEventId: u.eventId, reason: u.reason });
|
|
368
|
+
}
|
|
369
|
+
if (account.extra.length > 0) {
|
|
370
|
+
causes.push({
|
|
371
|
+
kind: "unaccounted-disclosure",
|
|
372
|
+
count: account.extra.length,
|
|
373
|
+
...(account.extra[0] === undefined ? {} : { firstEventId: account.extra[0] }),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return causes;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The kernel headline this rule is willing to repeat, as an ALLOW-LIST.
|
|
380
|
+
*
|
|
381
|
+
* `GradeInput.status` is deliberately `string` so a surface can grade a result
|
|
382
|
+
* from a verifier this kernel did not write. The cost of that was a hole: the
|
|
383
|
+
* status used to be cast straight to `PackVerdict`, so `gradePack` would hand
|
|
384
|
+
* back whatever it was given — `"mixed"` and `"not_found"` (legal, if
|
|
385
|
+
* unexpected), but equally `"banana"`, `""` and `"SAFE"`. A surface that trusts
|
|
386
|
+
* the type and renders the verdict verbatim would then print the one word
|
|
387
|
+
* `evidence-bundle.md` §6 forbids, over a pack this rule never graded.
|
|
388
|
+
*
|
|
389
|
+
* So: the six states of the §3 vocabulary pass through, and ANYTHING ELSE is
|
|
390
|
+
* `incomplete`. A verdict we cannot interpret is a check we could not run, and
|
|
391
|
+
* the answer to that is UNKNOWN — never a pass, and never an accusation.
|
|
392
|
+
* (`failed` and `malformed` are handled before this is reached.)
|
|
393
|
+
*/
|
|
394
|
+
const PASS_THROUGH = new Set([
|
|
395
|
+
"verified",
|
|
396
|
+
"partial",
|
|
397
|
+
"pending",
|
|
398
|
+
"not_found",
|
|
399
|
+
"mixed",
|
|
400
|
+
]);
|
|
401
|
+
function passThrough(status) {
|
|
402
|
+
return typeof status === "string" && PASS_THROUGH.has(status)
|
|
403
|
+
? status
|
|
404
|
+
: "incomplete";
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The verdict a surface may print, and the counts it may print beside it.
|
|
408
|
+
*
|
|
409
|
+
* The invariant, stated so a mutation test can kill it: `verified` is reachable
|
|
410
|
+
* ONLY when every record's supplied bytes bound to its own commitment and
|
|
411
|
+
* nothing is missing, unreadable, surplus or omitted.
|
|
412
|
+
*
|
|
413
|
+
* Precedence: malformed > failed > incomplete > the kernel's own headline.
|
|
414
|
+
* A hard verdict wins — "the bytes are absent" is never reported as failure
|
|
415
|
+
* (§5.1: undetermined, not failed), and a proven mismatch is never softened
|
|
416
|
+
* into absence. The last branch passes the kernel's headline through unchanged:
|
|
417
|
+
* this rule DOWNGRADES a verdict, it never invents one.
|
|
418
|
+
*/
|
|
419
|
+
export function gradePack(result, account, notes) {
|
|
420
|
+
const events = Array.isArray(result?.events) ? result.events : [];
|
|
421
|
+
const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
|
|
422
|
+
const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
|
|
423
|
+
const recordsUndetermined = events.length - recordsVerified - recordsFailed;
|
|
424
|
+
const causes = gradeCauses(account, notes);
|
|
425
|
+
let verdict;
|
|
426
|
+
if (result?.status === "malformed")
|
|
427
|
+
verdict = "malformed";
|
|
428
|
+
else if (result?.status === "failed" || recordsFailed > 0)
|
|
429
|
+
verdict = "failed";
|
|
430
|
+
else if (recordsUndetermined > 0 || causes.length > 0)
|
|
431
|
+
verdict = "incomplete";
|
|
432
|
+
else
|
|
433
|
+
verdict = passThrough(result?.status);
|
|
434
|
+
return { verdict, recordsVerified, recordsFailed, recordsUndetermined, causes };
|
|
435
|
+
}
|
|
436
|
+
// ---- Presentation (optional) -------------------------------------------------
|
|
437
|
+
/**
|
|
438
|
+
* The house wording for one cause.
|
|
439
|
+
*
|
|
440
|
+
* Offered, not imposed: a surface with its own voice reads the fields on
|
|
441
|
+
* `PackGradeCause` instead. What must not vary between surfaces is the verdict
|
|
442
|
+
* and the counts, and those come from `gradePack`.
|
|
443
|
+
*
|
|
444
|
+
* The `subject` names what the pack is from the reader's position — "this pack"
|
|
445
|
+
* on a page someone dropped a file onto, "the pack" in a CLI report.
|
|
446
|
+
*/
|
|
447
|
+
export function renderCause(cause, subject = "this pack") {
|
|
448
|
+
switch (cause.kind) {
|
|
449
|
+
case "records-omitted":
|
|
450
|
+
return (`checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
|
|
451
|
+
`${cause.presented} — ${cause.count} record(s) OMITTED`);
|
|
452
|
+
case "records-surplus":
|
|
453
|
+
return (`checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
|
|
454
|
+
`${cause.presented} — more records than the signed tree accounts for`);
|
|
455
|
+
case "leaf-count-unreadable":
|
|
456
|
+
return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count — completeness is UNKNOWN`;
|
|
457
|
+
case "no-disclosure":
|
|
458
|
+
return (`no record bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
|
|
459
|
+
`record(s), not verified`);
|
|
460
|
+
case "missing-disclosure":
|
|
461
|
+
return (`${cause.count} of ${cause.of} record(s) disclose no bytes (first: ${cause.firstEventId}) — ` +
|
|
462
|
+
`content binding is UNKNOWN, not verified`);
|
|
463
|
+
case "unreadable-disclosure":
|
|
464
|
+
return `the disclosure for ${cause.firstEventId} is unreadable: ${cause.reason}`;
|
|
465
|
+
case "unaccounted-disclosure":
|
|
466
|
+
return (`${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
|
|
467
|
+
`(first: ${cause.firstEventId})`);
|
|
468
|
+
}
|
|
469
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,12 @@ export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyE
|
|
|
2
2
|
export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
3
3
|
export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
|
|
4
4
|
export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
|
|
5
|
-
export { ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
6
|
-
export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, TraceCheckpoint, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
5
|
+
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
6
|
+
export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
7
|
+
export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
|
|
8
|
+
export type { CompletenessNote, DisclosureAccount, GradeInput, PackGrade, PackGradeCause, PackGradeCauseKind, PackVerdict, } from "./grading.js";
|
|
9
|
+
export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
|
|
10
|
+
export type { ChainFinding, ChainFindingKind, ChainReading, ChainSource, ChainSummary, } from "./chain.js";
|
|
7
11
|
export { attestationSignatureToHex, composeExport } from "./compose.js";
|
|
8
12
|
export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
|
|
9
13
|
export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|