@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/src/index.ts CHANGED
@@ -61,7 +61,11 @@ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions
61
61
  // content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
62
62
  export {
63
63
  ANCHOR_TRACE_BODY_TYPE,
64
+ ANCHOR_TRACE_BODY_TYPE_V1,
65
+ ANCHOR_TRACE_BODY_TYPES,
64
66
  EXPORT_BODY_TYPE,
67
+ STAMP_BODY_VERSION,
68
+ STAMP_TRACE_BODY_TYPE,
65
69
  disclosedContent,
66
70
  recomputeExportVerdict,
67
71
  verifyEvidenceBundle,
@@ -87,7 +91,15 @@ export type {
87
91
  OnChainOutcome,
88
92
  OnChainResult,
89
93
  PerGatewayOutcome,
94
+ AnchorRef,
95
+ StampBody,
96
+ StampEntry,
97
+ StampResult,
98
+ StampSubtree,
99
+ StampSubtreeResult,
100
+ StampTraceBody,
90
101
  TraceCheckpoint,
102
+ TraceCheckpointV1,
91
103
  TraceEvent,
92
104
  TraceInclusion,
93
105
  VerdictAttestation,
@@ -99,6 +111,73 @@ export type {
99
111
  VerifyEvidenceOptions,
100
112
  } from "./evidence.js";
101
113
 
114
+ // Pack grading (src/grading.ts): what a verification SURFACE may claim about a
115
+ // pack — the signed bundle PLUS whatever disclosed bytes travelled beside it.
116
+ //
117
+ // The rule is already normative in evidence-bundle.md §5.1 (absent disclosure is
118
+ // undetermined, not failed) and §6 (no status ever means "safe"); what the
119
+ // surfaces added on top was a sharper word for undetermined and an account of
120
+ // what the pack failed to disclose. It lived twice, in two repos and two
121
+ // languages, and the two derivations had already diverged once. It lives here.
122
+ //
123
+ // Pure — data in, data out, no I/O, so the kernel's scope boundary
124
+ // (specs/architecture.md §3) is unchanged. `vectors/pack-grading.json` is the
125
+ // conformance statement of the rule and ships in the published tarball, so a
126
+ // consumer that cannot yet import `gradePack` can vendor the table, hold its own
127
+ // derivation to it, and diff its copy against ours. Additive — no existing
128
+ // export changes.
129
+ export {
130
+ accountDisclosure,
131
+ completenessNotes,
132
+ gradeCauses,
133
+ gradePack,
134
+ renderCause,
135
+ signedLeafCount,
136
+ } from "./grading.js";
137
+ export type {
138
+ CompletenessNote,
139
+ DisclosureAccount,
140
+ GradeInput,
141
+ PackGrade,
142
+ PackGradeCause,
143
+ PackGradeCauseKind,
144
+ PackVerdict,
145
+ } from "./grading.js";
146
+
147
+ // Chain reading (src/chain.ts): following the pointer every sanning.events/v1
148
+ // record carries to the record before it, over the records ONE bundle presents.
149
+ //
150
+ // Every anchored event has carried that pointer since the profile was ratified
151
+ // and nothing we shipped read it, so a bundle with an event removed — and a
152
+ // chain that restarted after a crash and now asserts two beginnings — passed
153
+ // every check we had (proof#43). The verifier was right not to do this: it
154
+ // verifies a single envelope and says so. Nothing above it did it either.
155
+ //
156
+ // Pure — data in, data out, no I/O — so the kernel's scope boundary
157
+ // (specs/architecture.md §3) is unchanged, and the rule ships as
158
+ // `vectors/chain-reading.json` beside the pack-grading table for the same
159
+ // reason: a rule consumers must agree with needs a statement they can fetch.
160
+ //
161
+ // It returns FINDINGS, never a verdict, and it feeds no status. Chain links are
162
+ // deletion/reorder-evidence for the events present and NOT a completeness proof
163
+ // (`anchor/docs/profile-sanning.events-v1.md` §5) — drop the last N events of a
164
+ // chain and nothing dangles. `CHAIN_READING_SCOPE` is that sentence, exported so
165
+ // every surface prints the same limit rather than paraphrasing it into a claim.
166
+ // Additive — no existing export changes.
167
+ export {
168
+ ADAPTER_CHAIN_NAMESPACES,
169
+ CHAIN_READING_SCOPE,
170
+ readChains,
171
+ renderChainFinding,
172
+ } from "./chain.js";
173
+ export type {
174
+ ChainFinding,
175
+ ChainFindingKind,
176
+ ChainReading,
177
+ ChainSource,
178
+ ChainSummary,
179
+ } from "./chain.js";
180
+
102
181
  // The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
103
182
  // signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
104
183
  // anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
@@ -0,0 +1,119 @@
1
+ // ONE answer to "did the holder actually supply bytes for this event_id?"
2
+ //
3
+ // WHY THIS IS A MODULE AND NOT TWO PRIVATE HELPERS.
4
+ // The kernel asks that question in two places — the verifier, deciding whether
5
+ // an event's content was offered at all (`evidence.ts`), and the grader,
6
+ // accounting which records a pack disclosed (`grading.ts`). It had two answers.
7
+ // On one and the same content map the grader said PRESENT while the verifier
8
+ // said nothing was offered, so `gradePack` returned `incomplete` with ZERO
9
+ // causes: a surface saying "incomplete" and unable to say why. That is exactly
10
+ // the outcome `grading.ts`'s own comment claims was removed, reappearing
11
+ // because the fix landed on one of the two definitions.
12
+ //
13
+ // Two implementations of one verification rule is the defect `grading.ts` was
14
+ // extracted into the kernel to end. A rule stated once cannot drift; a rule
15
+ // stated twice already has.
16
+ //
17
+ // ── THE RULE ────────────────────────────────────────────────────────────────
18
+ //
19
+ // An entry exists for `eventId` when the map carries an OWN, ENUMERABLE, DATA
20
+ // property (or `Map` key) holding something other than `null`/`undefined`.
21
+ // This is the STRICTER of the two definitions that were in the kernel — the
22
+ // verifier's — and it is stricter on purpose. Every clause was paid for:
23
+ //
24
+ // own — an `event_id` is PRODUCER-CHOSEN, and every key on
25
+ // `Object.prototype` is a legal one: `constructor`,
26
+ // `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`,
27
+ // `propertyIsEnumerable`, `toLocaleString`, `__proto__`. An
28
+ // indexed lookup walks the prototype chain and resolves every
29
+ // one of them, so absent evidence read as good evidence.
30
+ // enumerable — what a caller means by "the map I built" is what
31
+ // `Object.keys` shows; nothing hidden behind it counts.
32
+ // data — an accessor is NEVER invoked. A getter can throw, and this
33
+ // kernel's standing rule is that a verifier does not raise on
34
+ // adversarial input. Not hypothetical: a plain-object map that
35
+ // has taken a `__proto__` write carries `Uint8Array.prototype`
36
+ // as its prototype, and reading `byteLength`, `length`,
37
+ // `buffer` or `byteOffset` off a plain object through it throws
38
+ // a TypeError. Reading a descriptor cannot.
39
+ // non-nullish — `{ [id]: undefined }` is how `Object.fromEntries` and friends
40
+ // spell "no entry", and `new Map([[id, undefined]])` is the
41
+ // same statement in the other shape. Anything else non-nullish
42
+ // — a number, an object, a string that is not hex — IS a real,
43
+ // unusable disclosure and stays a finding.
44
+ //
45
+ // `offered` is what separates "nothing was disclosed" (undetermined) from
46
+ // "something was disclosed and is unusable" (a finding), and absent disclosure
47
+ // is undetermined, not failed (evidence-bundle.md §5.1). So it is returned
48
+ // SEPARATELY from the value and is never inferred from it: `value !== undefined`
49
+ // is the conflation this module exists to remove, in both directions.
50
+
51
+ /** A caller-built map of disclosed bytes, in either shape it can take. */
52
+ export type SideContentMap =
53
+ | Readonly<Record<string, Uint8Array | string | undefined>>
54
+ | ReadonlyMap<string, Uint8Array | string | undefined>;
55
+
56
+ /** One event's entry, and — separately — whether an entry existed at all. */
57
+ export interface SideEntry {
58
+ /** An entry EXISTS for this id. Never inferred from the value. */
59
+ offered: boolean;
60
+ /** What it held, read without ever invoking an accessor. */
61
+ value: Uint8Array | string | undefined;
62
+ }
63
+
64
+ const ABSENT: SideEntry = { offered: false, value: undefined };
65
+
66
+ /** Look up one event's out-of-band disclosure. The rule, stated once. */
67
+ export function sideContentEntry(
68
+ content: SideContentMap | null | undefined,
69
+ eventId: string,
70
+ ): SideEntry {
71
+ if (content === undefined || content === null) return ABSENT;
72
+ if (content instanceof Map) {
73
+ const value = content.get(eventId);
74
+ return value === undefined || value === null ? ABSENT : { offered: true, value };
75
+ }
76
+ const desc = Object.getOwnPropertyDescriptor(content, eventId);
77
+ if (desc === undefined || !desc.enumerable || !("value" in desc)) return ABSENT;
78
+ const value = desc.value as Uint8Array | string | undefined;
79
+ return value === undefined || value === null ? ABSENT : { offered: true, value };
80
+ }
81
+
82
+ /** Whether the map carries a usable entry for this id. The same rule. */
83
+ export function hasSideContent(
84
+ content: SideContentMap | null | undefined,
85
+ eventId: string,
86
+ ): boolean {
87
+ return sideContentEntry(content, eventId).offered;
88
+ }
89
+
90
+ /**
91
+ * The ids the map actually carries — the same rule applied to every key, so a
92
+ * key that is not an entry cannot be reported as surplus disclosure either.
93
+ */
94
+ export function sideContentKeys(content: SideContentMap | null | undefined): string[] {
95
+ if (content === undefined || content === null) return [];
96
+ const keys = content instanceof Map ? [...content.keys()] : Object.keys(content);
97
+ return keys.filter((id) => hasSideContent(content, id));
98
+ }
99
+
100
+ /**
101
+ * Whether an event's IN-BODY `events[].content` field is a disclosure.
102
+ *
103
+ * The other half of the same question, and it broke the same way. `content`
104
+ * is an OPTIONAL field, so `null` is how a producer's serializer spells its
105
+ * absence — Go's `json.Marshal` on a nil pointer without `omitempty`, Python's
106
+ * `json.dumps(None)`. A bare `!== undefined` marks such an event OFFERED while
107
+ * no bytes are selected (only a `string` decodes), and the kernel then splits
108
+ * against itself: `verifyEvidenceBundle` reports `contentOk: null` and rolls up
109
+ * VERIFIED, while `disclosedContent` reports FAILED — "content was disclosed
110
+ * for this event but is not a decodable lowercase-hex string". One kernel, one
111
+ * bundle, two verdicts, reachable from any conforming producer.
112
+ *
113
+ * Non-nullish and not a string — a number, an object, a hex string that is not
114
+ * hex — remains a real, unusable disclosure and stays a finding, exactly as on
115
+ * the side-input path.
116
+ */
117
+ export function inBodyContentOffered(content: unknown): boolean {
118
+ return content !== undefined && content !== null;
119
+ }
package/src/verifier.ts CHANGED
@@ -228,6 +228,20 @@ export async function verifyEnvelope(
228
228
  typeof optionsOrContentHash === "string"
229
229
  ? { expectedContentHash: optionsOrContentHash }
230
230
  : (optionsOrContentHash ?? {});
231
+ // REFUSE an unknown option key rather than ignore it. `expected_content_hash`
232
+ // — the Python kernel's spelling — previously fell through as `undefined`, so
233
+ // the content bind silently never ran and the caller got a clean pass with no
234
+ // errors at all. A check that quietly does not happen is worse than one that
235
+ // fails: the caller believes they verified an artifact they never checked.
236
+ const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
237
+ const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
238
+ if (unknownOptions.length > 0) {
239
+ throw new TypeError(
240
+ `verifyEnvelope: unknown option(s) ${unknownOptions.join(", ")}. ` +
241
+ `Did you mean ${[...KNOWN_VERIFY_OPTIONS].join(" / ")}? ` +
242
+ "Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.",
243
+ );
244
+ }
231
245
  const expectedContentHash = opts.expectedContentHash;
232
246
  const errors: string[] = [];
233
247
 
@@ -313,19 +327,47 @@ export async function verifyEnvelope(
313
327
  let contentHashOk: boolean | null = null;
314
328
  let contentRole: ContentRole | null = null;
315
329
  if (expectedContentHash !== undefined) {
316
- const want = expectedContentHash.toLowerCase();
317
- const match = contentHashes(env).find((c) => c.hash.toLowerCase() === want);
318
- contentHashOk = match !== undefined;
319
- contentRole = match ? match.role : null;
320
- if (!contentHashOk) {
321
- errors.push("provided content hash does not match any hash this envelope commits to");
330
+ const committed = contentHashes(env);
331
+ if (committed.length === 0) {
332
+ // UNDETERMINED, not failed. An external-commitment envelope
333
+ // (`sanning.events/v1`) has no inline payload, so it commits to no
334
+ // envelope-readable content hash and there is nothing here to compare
335
+ // against. Reporting that as a failure would render "we cannot check
336
+ // this" as "it did not match" — what P-10 and I-5 exist to prevent — and
337
+ // would fail every envelope on the DL-52 path.
338
+ //
339
+ // The caller is still told the check did not happen; silence would be
340
+ // worse than either verdict.
341
+ contentHashOk = null;
342
+ errors.push(
343
+ "this envelope commits to no readable content hash (external-commitment " +
344
+ "profile), so the provided content hash could not be checked — " +
345
+ "undetermined, not failed",
346
+ );
347
+ } else {
348
+ const want = expectedContentHash.toLowerCase();
349
+ const match = committed.find((c) => c.hash.toLowerCase() === want);
350
+ contentHashOk = match !== undefined;
351
+ contentRole = match ? match.role : null;
352
+ if (!contentHashOk) {
353
+ errors.push("provided content hash does not match any hash this envelope commits to");
354
+ }
322
355
  }
323
356
  }
324
357
 
325
358
  return {
326
359
  // null (binding not checkable) does not fail the verdict — Python-kernel
327
360
  // parity: ok = spec && sig && payloadHashOk is not False.
328
- ok: specVersionOk && signatureOk && payloadHashOk !== false,
361
+ // A supplied `expectedContentHash` that matches nothing this envelope
362
+ // commits to is a FAILING verdict, not a side note (proof#42). This is the
363
+ // check an external auditor runs — "does this file match what was
364
+ // anchored" — and reporting it only in `contentHashOk` meant the top-level
365
+ // verdict, the field a caller naturally reads, said pass over a file that
366
+ // does not match.
367
+ //
368
+ // Absence stays undetermined: `contentHashOk` is null when no hash was
369
+ // supplied, and null must not fail (P-10 — absence is not failure).
370
+ ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
329
371
  specVersionOk,
330
372
  payloadHashOk,
331
373
  signatureOk,
@@ -0,0 +1,66 @@
1
+ # `vectors/` — conformance tables that ship to consumers
2
+
3
+ Unlike [`../../test-vectors/`](../../test-vectors/) — the tagged, vendored corpus
4
+ for the kernel primitives — everything here is **inside the published package**
5
+ (`files: ["vectors", …]`) and reaches downstream surfaces through npm.
6
+
7
+ That distinction is the whole point. A rule that consumers must agree with needs
8
+ a statement they can *fetch*, not one they have to be trusted to have copied
9
+ correctly.
10
+
11
+ ## `pack-grading.json`
12
+
13
+ The rule that decides whether a **pack** — a signed bundle plus whatever
14
+ disclosed bytes travelled beside it — is `verified`, `failed` or `incomplete`.
15
+ Implemented by [`../src/grading.ts`](../src/grading.ts) and asserted against this
16
+ table by [`../test/grading.test.ts`](../test/grading.test.ts).
17
+
18
+ **If an implementation disagrees with this table, the implementation is wrong.**
19
+
20
+ ### For a consumer that can import the kernel
21
+
22
+ Don't vendor anything. Call `gradePack`:
23
+
24
+ ```ts
25
+ import { accountDisclosure, completenessNotes, gradePack, verifyEvidenceBundle } from "@sanning/proof"
26
+
27
+ const result = await verifyEvidenceBundle(bundle, { content })
28
+ const account = accountDisclosure(bundle, content)
29
+ const grade = gradePack(result, account, completenessNotes(bundle))
30
+ ```
31
+
32
+ One implementation, no table to keep in step, no drift to guard against.
33
+
34
+ ### For a consumer that cannot yet
35
+
36
+ An older pinned kernel, another language, a surface that adapts a result from
37
+ somewhere else. Then, and only then:
38
+
39
+ 1. **Vendor this file byte-for-byte.** Not "equivalent" — identical. A vendored
40
+ copy that is not byte-identical to this one is a defect, not a variant.
41
+ 2. **Assert your derivation against it** in your own suite, so an implementation
42
+ that drifts from the rule goes red in your repo.
43
+ 3. **Run a drift check in CI** that compares your copy against the copy in
44
+ `node_modules/@sanning/proof/vectors/pack-grading.json` and fails on any
45
+ difference — *and fails when it cannot find a copy to compare against.* A
46
+ drift check that skips when its source is missing is not a check.
47
+
48
+ Step 3 is the one people skip, and it is the one that matters. Steps 1 and 2
49
+ catch an implementation drifting from its own table. Only step 3 catches the
50
+ table itself being edited on one side — which is the failure that actually
51
+ happened, and the reason this file moved here.
52
+
53
+ ### Shape
54
+
55
+ | Key | Meaning |
56
+ |---|---|
57
+ | `ruleVersion` | Bumped when a case's expectation changes **or a case is added**. An implementation asserts the version it was written against, so a table it does not understand fails loudly rather than passing vacuously. Currently **2**. |
58
+ | `kernelEventClasses` | The four per-event outcome classes a verification result can carry, named so a case can be written as counts rather than as a hand-built bundle. |
59
+ | `cases[].kernel` | The verification result to grade, as class counts. |
60
+ | `cases[].logs` | The disclosure account: how many records the bundle presents, and how many disclosed usable bytes. |
61
+ | `cases[].omissions` | Completeness notes — a checkpoint's signed `leaf_count` against the records the pack presents for it. |
62
+ | `cases[].expect` | The verdict, the three counts, and how many causes the grade must carry. |
63
+
64
+ Nothing in a case names a transport. A directory read and a browser drop produce
65
+ the same account and must produce the same verdict; a table that encoded one
66
+ surface's mechanism would let the other one drift while staying green.