@sanning/proof 0.6.0 → 0.8.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 +1 -1
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +389 -0
- package/dist/cli.js +42 -2
- package/dist/evidence.d.ts +148 -0
- package/dist/evidence.js +603 -70
- package/dist/index.d.ts +4 -2
- package/dist/index.js +22 -1
- package/dist/verifier.js +46 -7
- package/package.json +1 -1
- package/src/chain.ts +500 -0
- package/src/cli.ts +50 -1
- package/src/evidence.ts +813 -74
- package/src/index.ts +46 -0
- package/src/verifier.ts +49 -7
- package/vectors/chain-reading.json +293 -0
package/dist/index.d.ts
CHANGED
|
@@ -2,10 +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, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, 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, AnchorRef, TraceCheckpoint, TraceCheckpointV1, 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, STAMP_EVIDENCE_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, StampEvidenceBody, StampEvidenceEvent, StampEvidenceInclusion, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
7
7
|
export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
|
|
8
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";
|
|
9
11
|
export { attestationSignatureToHex, composeExport } from "./compose.js";
|
|
10
12
|
export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
|
|
11
13
|
export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyI
|
|
|
34
34
|
// files that failed verification on genuine evidence. This hands back DECODED
|
|
35
35
|
// bytes, and only for events whose verification bound them to the committed
|
|
36
36
|
// content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
|
|
37
|
-
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
37
|
+
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
38
38
|
// Pack grading (src/grading.ts): what a verification SURFACE may claim about a
|
|
39
39
|
// pack — the signed bundle PLUS whatever disclosed bytes travelled beside it.
|
|
40
40
|
//
|
|
@@ -51,6 +51,27 @@ export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TY
|
|
|
51
51
|
// derivation to it, and diff its copy against ours. Additive — no existing
|
|
52
52
|
// export changes.
|
|
53
53
|
export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
|
|
54
|
+
// Chain reading (src/chain.ts): following the pointer every sanning.events/v1
|
|
55
|
+
// record carries to the record before it, over the records ONE bundle presents.
|
|
56
|
+
//
|
|
57
|
+
// Every anchored event has carried that pointer since the profile was ratified
|
|
58
|
+
// and nothing we shipped read it, so a bundle with an event removed — and a
|
|
59
|
+
// chain that restarted after a crash and now asserts two beginnings — passed
|
|
60
|
+
// every check we had (proof#43). The verifier was right not to do this: it
|
|
61
|
+
// verifies a single envelope and says so. Nothing above it did it either.
|
|
62
|
+
//
|
|
63
|
+
// Pure — data in, data out, no I/O — so the kernel's scope boundary
|
|
64
|
+
// (specs/architecture.md §3) is unchanged, and the rule ships as
|
|
65
|
+
// `vectors/chain-reading.json` beside the pack-grading table for the same
|
|
66
|
+
// reason: a rule consumers must agree with needs a statement they can fetch.
|
|
67
|
+
//
|
|
68
|
+
// It returns FINDINGS, never a verdict, and it feeds no status. Chain links are
|
|
69
|
+
// deletion/reorder-evidence for the events present and NOT a completeness proof
|
|
70
|
+
// (`anchor/docs/profile-sanning.events-v1.md` §5) — drop the last N events of a
|
|
71
|
+
// chain and nothing dangles. `CHAIN_READING_SCOPE` is that sentence, exported so
|
|
72
|
+
// every surface prints the same limit rather than paraphrasing it into a claim.
|
|
73
|
+
// Additive — no existing export changes.
|
|
74
|
+
export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
|
|
54
75
|
// The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
|
|
55
76
|
// signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
|
|
56
77
|
// anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
|
package/dist/verifier.js
CHANGED
|
@@ -215,6 +215,18 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
215
215
|
const opts = typeof optionsOrContentHash === "string"
|
|
216
216
|
? { expectedContentHash: optionsOrContentHash }
|
|
217
217
|
: (optionsOrContentHash ?? {});
|
|
218
|
+
// REFUSE an unknown option key rather than ignore it. `expected_content_hash`
|
|
219
|
+
// — the Python kernel's spelling — previously fell through as `undefined`, so
|
|
220
|
+
// the content bind silently never ran and the caller got a clean pass with no
|
|
221
|
+
// errors at all. A check that quietly does not happen is worse than one that
|
|
222
|
+
// fails: the caller believes they verified an artifact they never checked.
|
|
223
|
+
const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
|
|
224
|
+
const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
|
|
225
|
+
if (unknownOptions.length > 0) {
|
|
226
|
+
throw new TypeError(`verifyEnvelope: unknown option(s) ${unknownOptions.join(", ")}. ` +
|
|
227
|
+
`Did you mean ${[...KNOWN_VERIFY_OPTIONS].join(" / ")}? ` +
|
|
228
|
+
"Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.");
|
|
229
|
+
}
|
|
218
230
|
const expectedContentHash = opts.expectedContentHash;
|
|
219
231
|
const errors = [];
|
|
220
232
|
// Guard a malformed input (null / non-object / array) up front — every field
|
|
@@ -296,18 +308,45 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
296
308
|
let contentHashOk = null;
|
|
297
309
|
let contentRole = null;
|
|
298
310
|
if (expectedContentHash !== undefined) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
311
|
+
const committed = contentHashes(env);
|
|
312
|
+
if (committed.length === 0) {
|
|
313
|
+
// UNDETERMINED, not failed. An external-commitment envelope
|
|
314
|
+
// (`sanning.events/v1`) has no inline payload, so it commits to no
|
|
315
|
+
// envelope-readable content hash and there is nothing here to compare
|
|
316
|
+
// against. Reporting that as a failure would render "we cannot check
|
|
317
|
+
// this" as "it did not match" — what P-10 and I-5 exist to prevent — and
|
|
318
|
+
// would fail every envelope on the DL-52 path.
|
|
319
|
+
//
|
|
320
|
+
// The caller is still told the check did not happen; silence would be
|
|
321
|
+
// worse than either verdict.
|
|
322
|
+
contentHashOk = null;
|
|
323
|
+
errors.push("this envelope commits to no readable content hash (external-commitment " +
|
|
324
|
+
"profile), so the provided content hash could not be checked — " +
|
|
325
|
+
"undetermined, not failed");
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const want = expectedContentHash.toLowerCase();
|
|
329
|
+
const match = committed.find((c) => c.hash.toLowerCase() === want);
|
|
330
|
+
contentHashOk = match !== undefined;
|
|
331
|
+
contentRole = match ? match.role : null;
|
|
332
|
+
if (!contentHashOk) {
|
|
333
|
+
errors.push("provided content hash does not match any hash this envelope commits to");
|
|
334
|
+
}
|
|
305
335
|
}
|
|
306
336
|
}
|
|
307
337
|
return {
|
|
308
338
|
// null (binding not checkable) does not fail the verdict — Python-kernel
|
|
309
339
|
// parity: ok = spec && sig && payloadHashOk is not False.
|
|
310
|
-
|
|
340
|
+
// A supplied `expectedContentHash` that matches nothing this envelope
|
|
341
|
+
// commits to is a FAILING verdict, not a side note (proof#42). This is the
|
|
342
|
+
// check an external auditor runs — "does this file match what was
|
|
343
|
+
// anchored" — and reporting it only in `contentHashOk` meant the top-level
|
|
344
|
+
// verdict, the field a caller naturally reads, said pass over a file that
|
|
345
|
+
// does not match.
|
|
346
|
+
//
|
|
347
|
+
// Absence stays undetermined: `contentHashOk` is null when no hash was
|
|
348
|
+
// supplied, and null must not fail (P-10 — absence is not failure).
|
|
349
|
+
ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
|
|
311
350
|
specVersionOk,
|
|
312
351
|
payloadHashOk,
|
|
313
352
|
signatureOk,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/chain.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// Chain reading — following the pointer every anchored event carries to the
|
|
2
|
+
// event before it, and reporting what does not line up.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS LIVES IN THE KERNEL, BESIDE PACK GRADING.
|
|
5
|
+
// `sanning.events/v1` records carry `previous_hash`, and until now nothing we
|
|
6
|
+
// shipped read it. The envelope verifier hashes each event's `record_bytes`
|
|
7
|
+
// and stops — `evidence.ts` says following the link is "consumer-layer logic
|
|
8
|
+
// above it", and it was right that a SINGLE-envelope verifier should not do
|
|
9
|
+
// it. But the consumer layer never did it either, so a bundle with an event
|
|
10
|
+
// removed, and a chain that restarted after a crash and now asserts two
|
|
11
|
+
// beginnings, both passed every shipped check (proof#43).
|
|
12
|
+
//
|
|
13
|
+
// Reading the link is not envelope verification and it is not pack grading: it
|
|
14
|
+
// is a third, pure rule over the records a bundle presents. Same seam as
|
|
15
|
+
// grading — data in, data out, no `fs`, no `fetch` — so the kernel's scope
|
|
16
|
+
// boundary (`specs/architecture.md` §3) is unchanged.
|
|
17
|
+
//
|
|
18
|
+
// ---- WHAT THIS IS NOT ------------------------------------------------------
|
|
19
|
+
//
|
|
20
|
+
// It is not a completeness statement and it must never be read as one. The
|
|
21
|
+
// profile says so in its own words (`anchor/docs/profile-sanning.events-v1.md`
|
|
22
|
+
// §5): chain semantics are "deletion/reorder-evidence for the events present
|
|
23
|
+
// ... not a completeness proof." Drop the last N events of a chain and nothing
|
|
24
|
+
// here dangles; the record that would have pointed at them was never written.
|
|
25
|
+
// Completeness is the checkpoint `leaf_count` mechanism, which lives in
|
|
26
|
+
// `grading.ts` and answers a different question.
|
|
27
|
+
//
|
|
28
|
+
// So nothing in this module returns a verdict, a status, an `ok`, or the word
|
|
29
|
+
// "complete". It returns FINDINGS — each one a specific link that does not
|
|
30
|
+
// resolve within the bundle in front of us — and a count of the chains it read,
|
|
31
|
+
// so a surface can tell "nothing to report" apart from "read nothing".
|
|
32
|
+
//
|
|
33
|
+
// ---- HOW A CHAIN IS IDENTIFIED ---------------------------------------------
|
|
34
|
+
//
|
|
35
|
+
// A chain is one head per `(public_key, chain_key)` — profile §3.2, and the
|
|
36
|
+
// reason `public_key` is part of every group key below. Two producers using the
|
|
37
|
+
// chain name "session-1" are two chains; merging them would manufacture a
|
|
38
|
+
// second root out of a naming coincidence.
|
|
39
|
+
//
|
|
40
|
+
// The linkage is written two ways today, and this reads both:
|
|
41
|
+
//
|
|
42
|
+
// 1. THE RECORD'S OWN CHAIN (`previous_hash` + `context.chain_key`). The
|
|
43
|
+
// profile's mechanism, and the one a third party can walk from the spec
|
|
44
|
+
// alone. Links resolve in PAYLOAD-HASH space.
|
|
45
|
+
// 2. THE ADAPTER'S METADATA (`metadata.<namespace>.prev_event_id` + `seq`).
|
|
46
|
+
// The batched paths write the linkage only here; their records say
|
|
47
|
+
// GENESIS. Links resolve in EVENT-ID space. Read as a FALLBACK, only when
|
|
48
|
+
// the record carries no chain of its own.
|
|
49
|
+
//
|
|
50
|
+
// (2) is a transitional accommodation, not the design. `metadata` is
|
|
51
|
+
// caller-owned and the profile says a verifier MUST NOT read it for
|
|
52
|
+
// verification — which is exactly why nothing here feeds a verdict. The fix is
|
|
53
|
+
// for every adapter to set the record's own `previous_hash` (filed separately
|
|
54
|
+
// against `anchor`); when it lands, the namespace table below stops mattering
|
|
55
|
+
// and can go.
|
|
56
|
+
|
|
57
|
+
import { hexToBytes, sha256Hex } from "./crypto.js";
|
|
58
|
+
import { resolveTraceEvents } from "./evidence.js";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The one sentence a surface should print beside these findings, so the reader
|
|
62
|
+
* is never left to infer that "no findings" means "nothing is missing".
|
|
63
|
+
*
|
|
64
|
+
* Lifted from the profile (§5) rather than reworded, because a paraphrase of a
|
|
65
|
+
* scope limit is how a scope limit turns into a claim.
|
|
66
|
+
*/
|
|
67
|
+
export const CHAIN_READING_SCOPE =
|
|
68
|
+
"Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
|
|
69
|
+
|
|
70
|
+
/** Where the linkage was read from. */
|
|
71
|
+
export type ChainSource = "record" | "adapter";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The adapter metadata namespaces that carry a chain when the record does not.
|
|
75
|
+
*
|
|
76
|
+
* VERIFIED AGAINST `sanning-io/anchor` @ main, 2026-09-07. `run` is NOT a typo
|
|
77
|
+
* for `langchain`: anchor#68 promoted the LangChain linkage out of a
|
|
78
|
+
* framework-named namespace into a shared one, so the same fact has one path in
|
|
79
|
+
* every SDK. A table pinned on the old name would find no chains, report
|
|
80
|
+
* nothing, and look exactly like a clean bundle — the rename trap, in the
|
|
81
|
+
* direction that fails silently.
|
|
82
|
+
*
|
|
83
|
+
* Exported so a test can assert the table itself, and so a consumer can see
|
|
84
|
+
* what this reader knows about rather than guessing.
|
|
85
|
+
*/
|
|
86
|
+
export const ADAPTER_CHAIN_NAMESPACES: readonly { namespace: string; keyField: string }[] = [
|
|
87
|
+
// @sanning/anchor LangChain callbacks, TS + Python (anchor#68). One chain per
|
|
88
|
+
// root run, so the root run id is the chain.
|
|
89
|
+
{ namespace: "run", keyField: "root_run_id" },
|
|
90
|
+
// @sanning/anchor Vercel AI SDK middleware. A flat sequence per correlation key.
|
|
91
|
+
{ namespace: "vercel_ai", keyField: "chain_key" },
|
|
92
|
+
// @sanning/anchor Interchange adapter. One chain per session; `seq` is
|
|
93
|
+
// Interchange's own reactor-owned sequence, passed through.
|
|
94
|
+
{ namespace: "interchange", keyField: "session_id" },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export type ChainFindingKind =
|
|
98
|
+
/** A record points at a predecessor no record in this bundle presents. */
|
|
99
|
+
| "chain-link-dangling"
|
|
100
|
+
/** A `seq` slot between two records this bundle presents has no record. */
|
|
101
|
+
| "chain-gap"
|
|
102
|
+
/** More than one record claims to begin the chain. */
|
|
103
|
+
| "chain-second-root";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One link that does not resolve, as data.
|
|
107
|
+
*
|
|
108
|
+
* Structured for the same reason `PackGradeCause` is: prose is the part that
|
|
109
|
+
* drifts hardest between surfaces and matters least. `renderChainFinding`
|
|
110
|
+
* produces the house wording; a surface with its own voice reads the fields.
|
|
111
|
+
*/
|
|
112
|
+
export interface ChainFinding {
|
|
113
|
+
kind: ChainFindingKind;
|
|
114
|
+
/** The chain this is about, as the records spell it. */
|
|
115
|
+
chainKey: string;
|
|
116
|
+
/** Whether the linkage came from the record core or an adapter namespace. */
|
|
117
|
+
source: ChainSource;
|
|
118
|
+
/** The adapter metadata namespace, when `source` is `"adapter"`. */
|
|
119
|
+
namespace?: string;
|
|
120
|
+
/** The producing key, since a chain is one head per (public_key, chain_key). */
|
|
121
|
+
publicKey: string;
|
|
122
|
+
/** How many links, slots or roots the finding is about. */
|
|
123
|
+
count: number;
|
|
124
|
+
/** How many records this bundle presents for the chain, framing `count`. */
|
|
125
|
+
of: number;
|
|
126
|
+
/** The affected `event_id`s, so a reader has somewhere to look. */
|
|
127
|
+
eventIds: string[];
|
|
128
|
+
/** The pointer that resolved to no record here (`chain-link-dangling`). */
|
|
129
|
+
unresolvedPointer?: string;
|
|
130
|
+
/** The first sequence slot with no record here (`chain-gap`). */
|
|
131
|
+
firstMissingSeq?: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One chain this bundle presents records for.
|
|
136
|
+
*
|
|
137
|
+
* `events` is HOW MANY RECORDS ARE HERE — never how many the chain has. The
|
|
138
|
+
* summary exists so a surface can say "read 3 chains, nothing to report"
|
|
139
|
+
* instead of "nothing to report", which is also what a reader that silently
|
|
140
|
+
* matched no records would say.
|
|
141
|
+
*/
|
|
142
|
+
export interface ChainSummary {
|
|
143
|
+
chainKey: string;
|
|
144
|
+
source: ChainSource;
|
|
145
|
+
namespace?: string;
|
|
146
|
+
publicKey: string;
|
|
147
|
+
events: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ChainReading {
|
|
151
|
+
chains: ChainSummary[];
|
|
152
|
+
findings: ChainFinding[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- Reading the records -----------------------------------------------------
|
|
156
|
+
|
|
157
|
+
interface EventRecord {
|
|
158
|
+
previous_hash?: unknown;
|
|
159
|
+
context?: { chain_key?: unknown };
|
|
160
|
+
metadata?: Record<string, unknown>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One record's place in one chain, normalised across the two linkage forms. */
|
|
164
|
+
interface Member {
|
|
165
|
+
eventId: string;
|
|
166
|
+
/** What the NEXT record's pointer must equal: payload hash, or event id. */
|
|
167
|
+
selfId: string;
|
|
168
|
+
/** The pointer this record carries, or `null` when it claims to begin the chain. */
|
|
169
|
+
prev: string | null;
|
|
170
|
+
seq: number | null;
|
|
171
|
+
/** Position in `body.events`, the tiebreak when there is no `seq`. */
|
|
172
|
+
order: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface Group {
|
|
176
|
+
chainKey: string;
|
|
177
|
+
source: ChainSource;
|
|
178
|
+
namespace?: string;
|
|
179
|
+
publicKey: string;
|
|
180
|
+
members: Member[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The events this bundle is about, unwrapping any evidence-export wrapper to
|
|
185
|
+
* the inline source bundle. DEFERRED to `evidence.ts`, which is the same
|
|
186
|
+
* descent `verifyEvidenceBundle` and `disclosedContent` already use — a second
|
|
187
|
+
* answer to "which events" inside one kernel is a second implementation of one
|
|
188
|
+
* rule, and it is how a reader ends up walking a different set of events than
|
|
189
|
+
* the verifier reported on.
|
|
190
|
+
*/
|
|
191
|
+
const eventsOf = (bundle: unknown) => resolveTraceEvents(bundle) ?? [];
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Decode one event record from its committed bytes.
|
|
195
|
+
*
|
|
196
|
+
* Same discipline as `signedLeafCount`: hex, UTF-8, JSON, and an undecodable
|
|
197
|
+
* record is `null` rather than a throw or a guess. A withheld or unreadable
|
|
198
|
+
* record simply takes no part in a chain — `verifyEvidenceBundle` already
|
|
199
|
+
* reports its binding as undetermined, and inventing a second accusation here
|
|
200
|
+
* would say the same thing twice in a harsher voice.
|
|
201
|
+
*/
|
|
202
|
+
function decodeRecord(hex: unknown): { record: EventRecord; bytes: Uint8Array } | null {
|
|
203
|
+
if (typeof hex !== "string" || hex.length === 0) return null;
|
|
204
|
+
try {
|
|
205
|
+
const bytes = hexToBytes(hex);
|
|
206
|
+
const record = JSON.parse(new TextDecoder().decode(bytes)) as EventRecord;
|
|
207
|
+
if (record === null || typeof record !== "object") return null;
|
|
208
|
+
return { record, bytes };
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The adapter linkage, if this record carries one this reader knows about.
|
|
218
|
+
*
|
|
219
|
+
* Order is the table's order and the first match wins; a record carrying two
|
|
220
|
+
* adapter namespaces is not a shape any SDK emits.
|
|
221
|
+
*/
|
|
222
|
+
function adapterLinkage(
|
|
223
|
+
record: EventRecord,
|
|
224
|
+
): { namespace: string; chainKey: string; prev: string | null; seq: number | null } | null {
|
|
225
|
+
const metadata = record.metadata;
|
|
226
|
+
if (metadata === null || typeof metadata !== "object") return null;
|
|
227
|
+
for (const { namespace, keyField } of ADAPTER_CHAIN_NAMESPACES) {
|
|
228
|
+
const ns = (metadata as Record<string, unknown>)[namespace];
|
|
229
|
+
if (ns === null || typeof ns !== "object") continue;
|
|
230
|
+
const fields = ns as Record<string, unknown>;
|
|
231
|
+
const chainKey = str(fields[keyField]);
|
|
232
|
+
if (chainKey === null) continue;
|
|
233
|
+
// `prev_event_id` is explicitly null at the head of an adapter chain — the
|
|
234
|
+
// adapters write the null rather than omitting the key, because absent and
|
|
235
|
+
// null mean different things to a reader. Absent is treated the same way
|
|
236
|
+
// here: nothing to resolve is a beginning.
|
|
237
|
+
const prev = str(fields.prev_event_id);
|
|
238
|
+
const seqRaw = fields.seq;
|
|
239
|
+
// SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
|
|
240
|
+
// survive `JSON.parse` intact — `9007199254740993` arrives as
|
|
241
|
+
// `...992` — while Python's parser keeps it exactly, so the two kernels
|
|
242
|
+
// read DIFFERENT sequence numbers off identical bytes and report gaps of
|
|
243
|
+
// different widths. Measured at 2^53+1: a `chain-gap` count of
|
|
244
|
+
// 9007199254740990 here against 9007199254740992 there (proof#49). A value
|
|
245
|
+
// this kernel cannot represent is one it cannot read, which is the same
|
|
246
|
+
// answer it gives every other unreadable field: no sequence, take no part.
|
|
247
|
+
const seq = Number.isSafeInteger(seqRaw) ? (seqRaw as number) : null;
|
|
248
|
+
return { namespace, chainKey, prev, seq };
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Group the bundle's records into chains.
|
|
255
|
+
*
|
|
256
|
+
* Self-ids are DERIVED, never read off the envelope. A record chain's pointer
|
|
257
|
+
* is the previous record's `payload_hash`, and `payload_hash` is an asserted
|
|
258
|
+
* label sitting next to the bytes it claims to be about; matching one asserted
|
|
259
|
+
* label against another is how a relabelled checkpoint once verified cleanly
|
|
260
|
+
* (`evidence.ts`, `checkpoint_ref`). Hashing the bytes we already hold costs
|
|
261
|
+
* nothing and means a relabelled record DANGLES instead of quietly resolving.
|
|
262
|
+
* That is why this function is async.
|
|
263
|
+
*/
|
|
264
|
+
async function groupChains(bundle: unknown): Promise<Group[]> {
|
|
265
|
+
const groups = new Map<string, Group>();
|
|
266
|
+
const events = eventsOf(bundle);
|
|
267
|
+
|
|
268
|
+
for (let order = 0; order < events.length; order++) {
|
|
269
|
+
const entry = events[order];
|
|
270
|
+
const eventId = str(entry?.envelope?.event_id);
|
|
271
|
+
const publicKey = str(entry?.envelope?.public_key);
|
|
272
|
+
// Without a producing key there is no chain identity (§3.2), and without an
|
|
273
|
+
// event id an adapter pointer has nothing to resolve against.
|
|
274
|
+
if (eventId === null || publicKey === null) continue;
|
|
275
|
+
|
|
276
|
+
const decoded = decodeRecord(entry?.record_bytes);
|
|
277
|
+
if (decoded === null) continue;
|
|
278
|
+
const { record, bytes } = decoded;
|
|
279
|
+
|
|
280
|
+
// 1. The record's own chain. `context.chain_key` is present IFF the record
|
|
281
|
+
// is chained (profile §5) — which is what keeps every unchained
|
|
282
|
+
// singleton, all of them carrying `previous_hash: "GENESIS"`, out of one
|
|
283
|
+
// enormous fictional chain that would report a second root per pair.
|
|
284
|
+
const recordChainKey = str(record.context?.chain_key);
|
|
285
|
+
let member: Member;
|
|
286
|
+
let key: string;
|
|
287
|
+
let group: Omit<Group, "members">;
|
|
288
|
+
|
|
289
|
+
if (recordChainKey !== null) {
|
|
290
|
+
const previous = str(record.previous_hash);
|
|
291
|
+
member = {
|
|
292
|
+
eventId,
|
|
293
|
+
selfId: await sha256Hex(bytes),
|
|
294
|
+
prev: previous === null || previous === "GENESIS" ? null : previous,
|
|
295
|
+
seq: null,
|
|
296
|
+
order,
|
|
297
|
+
};
|
|
298
|
+
group = { chainKey: recordChainKey, source: "record", publicKey };
|
|
299
|
+
key = `record ${publicKey} ${recordChainKey}`;
|
|
300
|
+
} else {
|
|
301
|
+
// 2. Fallback only. A record that carries its own chain is never read
|
|
302
|
+
// through its adapter metadata: one record must not land in two
|
|
303
|
+
// chains, and the profile's own field is the one a third party can
|
|
304
|
+
// walk without knowing which SDK wrote it.
|
|
305
|
+
const linkage = adapterLinkage(record);
|
|
306
|
+
if (linkage === null) continue;
|
|
307
|
+
member = { eventId, selfId: eventId, prev: linkage.prev, seq: linkage.seq, order };
|
|
308
|
+
group = {
|
|
309
|
+
chainKey: linkage.chainKey,
|
|
310
|
+
source: "adapter",
|
|
311
|
+
namespace: linkage.namespace,
|
|
312
|
+
publicKey,
|
|
313
|
+
};
|
|
314
|
+
key = `adapter ${linkage.namespace} ${publicKey} ${linkage.chainKey}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const existing = groups.get(key);
|
|
318
|
+
if (existing === undefined) groups.set(key, { ...group, members: [member] });
|
|
319
|
+
else existing.members.push(member);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return [...groups.values()];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- The rule ----------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/** By `seq` where both have one, else by position in the bundle. */
|
|
328
|
+
function byChainOrder(a: Member, b: Member): number {
|
|
329
|
+
if (a.seq !== null && b.seq !== null && a.seq !== b.seq) return a.seq - b.seq;
|
|
330
|
+
return a.order - b.order;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The smallest slot between the lowest and highest occupied ones that no record
|
|
335
|
+
* occupies, found by walking the SORTED occupants rather than the range they
|
|
336
|
+
* span. See the call site: the range's width is chosen by whoever wrote the
|
|
337
|
+
* bundle.
|
|
338
|
+
*/
|
|
339
|
+
function firstMissingSlot(present: Set<number>): number | null {
|
|
340
|
+
const ordered = [...present].sort((a, b) => a - b);
|
|
341
|
+
for (let i = 0; i < ordered.length - 1; i++) {
|
|
342
|
+
if (ordered[i + 1]! > ordered[i]! + 1) return ordered[i]! + 1;
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findingsFor(group: Group): ChainFinding[] {
|
|
348
|
+
const { members, chainKey, source, publicKey } = group;
|
|
349
|
+
const of = members.length;
|
|
350
|
+
const base = {
|
|
351
|
+
chainKey,
|
|
352
|
+
source,
|
|
353
|
+
publicKey,
|
|
354
|
+
of,
|
|
355
|
+
...(group.namespace === undefined ? {} : { namespace: group.namespace }),
|
|
356
|
+
};
|
|
357
|
+
const findings: ChainFinding[] = [];
|
|
358
|
+
|
|
359
|
+
const selfIds = new Set(members.map((m) => m.selfId));
|
|
360
|
+
const roots = members.filter((m) => m.prev === null).sort(byChainOrder);
|
|
361
|
+
const dangling = members
|
|
362
|
+
.filter((m): m is Member & { prev: string } => m.prev !== null && !selfIds.has(m.prev))
|
|
363
|
+
.sort(byChainOrder);
|
|
364
|
+
|
|
365
|
+
// A SECOND BEGINNING. One chain has one head per (public_key, chain_key), so
|
|
366
|
+
// two records claiming GENESIS under one key is a fork — the case a link-walk
|
|
367
|
+
// alone reports as CLEAN, because both halves walk back to a legitimate
|
|
368
|
+
// beginning and neither dangles. It is what a crash looks like when the SDK
|
|
369
|
+
// lost its chain head and started over under the same name.
|
|
370
|
+
if (roots.length > 1) {
|
|
371
|
+
findings.push({
|
|
372
|
+
kind: "chain-second-root",
|
|
373
|
+
...base,
|
|
374
|
+
count: roots.length,
|
|
375
|
+
eventIds: roots.map((m) => m.eventId),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// A POINTER THAT LEAVES THIS BUNDLE. Exactly one such pointer is expected and
|
|
380
|
+
// is NOT a finding: a bundle is a window onto a chain that may have begun
|
|
381
|
+
// before this export, so the window's earliest record necessarily points at a
|
|
382
|
+
// predecessor nobody handed us. The allowance is spent only when the chain
|
|
383
|
+
// shows no GENESIS root — a chain that already has its beginning here has no
|
|
384
|
+
// window edge left to explain the next one.
|
|
385
|
+
const unexplained = roots.length === 0 ? dangling.slice(1) : dangling;
|
|
386
|
+
if (unexplained.length > 0) {
|
|
387
|
+
const first = unexplained[0]!;
|
|
388
|
+
findings.push({
|
|
389
|
+
kind: "chain-link-dangling",
|
|
390
|
+
...base,
|
|
391
|
+
count: unexplained.length,
|
|
392
|
+
eventIds: unexplained.map((m) => m.eventId),
|
|
393
|
+
unresolvedPointer: first.prev,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// A HOLE IN THE SEQUENCE, where an adapter numbers its events. Only INTERIOR
|
|
398
|
+
// holes count: a window that starts at seq 7 is a window, not a deletion, and
|
|
399
|
+
// reporting its first six absent slots would bury the one that matters. A
|
|
400
|
+
// repeated seq is a real defect but a different one, and the present-set below
|
|
401
|
+
// is deliberately duplicate-safe rather than count-based so it cannot be
|
|
402
|
+
// mistaken for a gap.
|
|
403
|
+
const sequenced = members.filter((m) => m.seq !== null);
|
|
404
|
+
if (sequenced.length === of && of > 0) {
|
|
405
|
+
const present = new Set(sequenced.map((m) => m.seq as number));
|
|
406
|
+
const low = Math.min(...present);
|
|
407
|
+
const high = Math.max(...present);
|
|
408
|
+
// Counted, never walked. `seq` is attacker-chosen, so a bundle presenting
|
|
409
|
+
// two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
|
|
410
|
+
// high; s++)` spin for days — a verifier that HANGS on adversarial input is
|
|
411
|
+
// worse than one that raises, and this reader promises neither (proof#49).
|
|
412
|
+
// The present set is a subset of [low, high], so the absent slots are the
|
|
413
|
+
// width minus the occupants, and the first of them is found by walking the
|
|
414
|
+
// occupants in order.
|
|
415
|
+
const missingCount = high - low + 1 - present.size;
|
|
416
|
+
const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
|
|
417
|
+
if (firstMissing !== null) {
|
|
418
|
+
const after = sequenced.filter((m) => (m.seq as number) > firstMissing).sort(byChainOrder)[0];
|
|
419
|
+
findings.push({
|
|
420
|
+
kind: "chain-gap",
|
|
421
|
+
...base,
|
|
422
|
+
count: missingCount,
|
|
423
|
+
eventIds: after === undefined ? [] : [after.eventId],
|
|
424
|
+
firstMissingSeq: firstMissing,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return findings;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Follow every chain the bundle's records describe, and report the links that
|
|
434
|
+
* do not resolve within it.
|
|
435
|
+
*
|
|
436
|
+
* Reports NOTHING for an intact chain, and nothing for a chain whose records
|
|
437
|
+
* are withheld — a link we could not read is unknown, never an accusation.
|
|
438
|
+
* `chains` is what makes that distinguishable from having read nothing at all.
|
|
439
|
+
*/
|
|
440
|
+
export async function readChains(bundle: unknown): Promise<ChainReading> {
|
|
441
|
+
const groups = await groupChains(bundle);
|
|
442
|
+
const order = (g: Group) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
|
|
443
|
+
// Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
|
|
444
|
+
// text, and `localeCompare` with no locale argument answers from the host's
|
|
445
|
+
// ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
|
|
446
|
+
// code-wise comparison puts it before, and it can differ between two
|
|
447
|
+
// TypeScript installs, let alone between kernels. The Python reader sorts the
|
|
448
|
+
// same key encoded as `utf-16-be`, which is exactly this comparison — so the
|
|
449
|
+
// two kernels emit findings in one order (proof#49).
|
|
450
|
+
groups.sort((a, b) => {
|
|
451
|
+
const x = order(a);
|
|
452
|
+
const y = order(b);
|
|
453
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
chains: groups.map((g) => ({
|
|
458
|
+
chainKey: g.chainKey,
|
|
459
|
+
source: g.source,
|
|
460
|
+
...(g.namespace === undefined ? {} : { namespace: g.namespace }),
|
|
461
|
+
publicKey: g.publicKey,
|
|
462
|
+
events: g.members.length,
|
|
463
|
+
})),
|
|
464
|
+
findings: groups.flatMap(findingsFor),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The house wording for one finding.
|
|
470
|
+
*
|
|
471
|
+
* Deliberately free of verdict vocabulary. Every sentence says what does not
|
|
472
|
+
* resolve IN THIS BUNDLE, because that is the whole of what was checked; none
|
|
473
|
+
* of them says failed, invalid, tampered or complete. A surface with its own
|
|
474
|
+
* voice should read the fields instead — but it must not upgrade the claim.
|
|
475
|
+
*/
|
|
476
|
+
export function renderChainFinding(finding: ChainFinding, subject = "this bundle"): string {
|
|
477
|
+
const where =
|
|
478
|
+
finding.source === "adapter"
|
|
479
|
+
? `chain ${finding.chainKey} (${finding.namespace} metadata)`
|
|
480
|
+
: `chain ${finding.chainKey}`;
|
|
481
|
+
switch (finding.kind) {
|
|
482
|
+
case "chain-link-dangling":
|
|
483
|
+
return (
|
|
484
|
+
`${where}: ${finding.count} of ${finding.of} record(s) point at a predecessor ` +
|
|
485
|
+
`${subject} does not present (first: ${finding.eventIds[0]}, pointer ` +
|
|
486
|
+
`${finding.unresolvedPointer})`
|
|
487
|
+
);
|
|
488
|
+
case "chain-gap":
|
|
489
|
+
return (
|
|
490
|
+
`${where}: ${finding.count} sequence slot(s) between the records ${subject} presents ` +
|
|
491
|
+
`carry no record (first missing seq ${finding.firstMissingSeq}` +
|
|
492
|
+
`${finding.eventIds[0] === undefined ? "" : `, next present: ${finding.eventIds[0]}`})`
|
|
493
|
+
);
|
|
494
|
+
case "chain-second-root":
|
|
495
|
+
return (
|
|
496
|
+
`${where}: ${finding.count} records begin the chain where one chain has one beginning ` +
|
|
497
|
+
`(${finding.eventIds.join(", ")})`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|