@sanning/proof 0.19.1 → 0.20.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 +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/dist/chain.d.ts
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Lifted from the profile (§5) rather than reworded, because a paraphrase of a
|
|
6
|
-
* scope limit is how a scope limit turns into a claim.
|
|
2
|
+
* The sentence a surface prints beside these findings, so "no findings" is
|
|
3
|
+
* never read as "nothing is missing". Quoted from the profile (§5), because a
|
|
4
|
+
* paraphrased scope limit turns into a claim.
|
|
7
5
|
*/
|
|
8
6
|
export declare const CHAIN_READING_SCOPE = "Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
|
|
9
7
|
/** Where the linkage was read from. */
|
|
10
8
|
export type ChainSource = "record" | "adapter";
|
|
11
9
|
/**
|
|
12
10
|
* The adapter metadata namespaces that carry a chain when the record does not.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* for `langchain`: anchor#68 promoted the LangChain linkage out of a
|
|
16
|
-
* framework-named namespace into a shared one, so the same fact has one path in
|
|
17
|
-
* every SDK. A table pinned on the old name would find no chains, report
|
|
18
|
-
* nothing, and look exactly like a clean bundle — the rename trap, in the
|
|
19
|
-
* direction that fails silently.
|
|
20
|
-
*
|
|
21
|
-
* Exported so a test can assert the table itself, and so a consumer can see
|
|
22
|
-
* what this reader knows about rather than guessing.
|
|
11
|
+
* The names must match the `@sanning/anchor` SDKs exactly: a stale name finds no
|
|
12
|
+
* chains and reads like a clean bundle.
|
|
23
13
|
*/
|
|
24
14
|
export declare const ADAPTER_CHAIN_NAMESPACES: readonly {
|
|
25
15
|
namespace: string;
|
|
@@ -33,11 +23,8 @@ export type ChainFindingKind =
|
|
|
33
23
|
/** More than one record claims to begin the chain. */
|
|
34
24
|
| "chain-second-root";
|
|
35
25
|
/**
|
|
36
|
-
* One link that does not resolve, as data.
|
|
37
|
-
*
|
|
38
|
-
* Structured for the same reason `PackGradeCause` is: prose is the part that
|
|
39
|
-
* drifts hardest between surfaces and matters least. `renderChainFinding`
|
|
40
|
-
* produces the house wording; a surface with its own voice reads the fields.
|
|
26
|
+
* One link that does not resolve, as data. `renderChainFinding` gives the house
|
|
27
|
+
* wording.
|
|
41
28
|
*/
|
|
42
29
|
export interface ChainFinding {
|
|
43
30
|
kind: ChainFindingKind;
|
|
@@ -61,12 +48,8 @@ export interface ChainFinding {
|
|
|
61
48
|
firstMissingSeq?: number;
|
|
62
49
|
}
|
|
63
50
|
/**
|
|
64
|
-
* One chain this bundle presents records for.
|
|
65
|
-
*
|
|
66
|
-
* `events` is HOW MANY RECORDS ARE HERE — never how many the chain has. The
|
|
67
|
-
* summary exists so a surface can say "read 3 chains, nothing to report"
|
|
68
|
-
* instead of "nothing to report", which is also what a reader that silently
|
|
69
|
-
* matched no records would say.
|
|
51
|
+
* One chain this bundle presents records for. `events` counts the records
|
|
52
|
+
* here, never the chain's length.
|
|
70
53
|
*/
|
|
71
54
|
export interface ChainSummary {
|
|
72
55
|
chainKey: string;
|
|
@@ -80,10 +63,8 @@ export interface ChainReading {
|
|
|
80
63
|
findings: ChainFinding[];
|
|
81
64
|
}
|
|
82
65
|
/**
|
|
83
|
-
* One record's place in a chain,
|
|
84
|
-
*
|
|
85
|
-
* evidence-bundle.md 5.4.2, which holds a claimed chain to every link. The
|
|
86
|
-
* twin of Python's `ChainLink`.
|
|
66
|
+
* One record's place in a chain, for rule 11 of evidence-bundle.md §5.4.2.
|
|
67
|
+
* Python's twin is `ChainLink`.
|
|
87
68
|
*/
|
|
88
69
|
export interface ChainLink {
|
|
89
70
|
eventId: string;
|
|
@@ -100,28 +81,19 @@ export interface ChainLink {
|
|
|
100
81
|
order: number;
|
|
101
82
|
}
|
|
102
83
|
/**
|
|
103
|
-
* Every record
|
|
104
|
-
*
|
|
105
|
-
* record an envelope binds, `event_bytes` or the inline `payload` where no
|
|
106
|
-
* `event_bytes` travel, with the identity the SHA-256 of those bytes, exactly
|
|
107
|
-
* as the envelope verifier binds `payload_hash`.
|
|
84
|
+
* Every record placed in a chain, in bundle order, with its identity and
|
|
85
|
+
* pointer. Reads `event_bytes`, or the inline `payload` where none travel.
|
|
108
86
|
*/
|
|
109
87
|
export declare function readChainLinks(bundle: unknown): Promise<ChainLink[]>;
|
|
110
88
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* Reports NOTHING for an intact chain, and nothing for a chain whose records
|
|
115
|
-
* are withheld — a link we could not read is unknown, never an accusation.
|
|
116
|
-
* `chains` is what makes that distinguishable from having read nothing at all.
|
|
89
|
+
* Report the links that do not resolve within the bundle. An intact chain, and
|
|
90
|
+
* one whose records are withheld, report nothing: an unreadable link is
|
|
91
|
+
* unknown, never an accusation.
|
|
117
92
|
*/
|
|
118
93
|
export declare function readChains(bundle: unknown): Promise<ChainReading>;
|
|
119
94
|
/**
|
|
120
|
-
* The house wording for one finding.
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* resolve IN THIS BUNDLE, because that is the whole of what was checked; none
|
|
124
|
-
* of them says failed, invalid, tampered or complete. A surface with its own
|
|
125
|
-
* voice should read the fields instead — but it must not upgrade the claim.
|
|
95
|
+
* The house wording for one finding. Says only what does not resolve in this
|
|
96
|
+
* bundle, never failed, tampered or complete; a surface rewording it must not
|
|
97
|
+
* upgrade the claim.
|
|
126
98
|
*/
|
|
127
99
|
export declare function renderChainFinding(finding: ChainFinding, subject?: string): string;
|
package/dist/chain.js
CHANGED
|
@@ -1,134 +1,64 @@
|
|
|
1
|
-
// Chain reading
|
|
2
|
-
//
|
|
1
|
+
// Chain reading: follow the pointer each anchored event carries to the one
|
|
2
|
+
// before it, and report what does not resolve. A pure rule over the records a
|
|
3
|
+
// bundle presents, separate from envelope verification and pack grading.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
// `sanning.events
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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).
|
|
5
|
+
// This is not a completeness statement. Drop the last N events of a chain and
|
|
6
|
+
// nothing here dangles (`anchor/docs/profile-sanning.events-v2.md` §5);
|
|
7
|
+
// completeness is the checkpoint `leaf_count` in `grading.ts`. So nothing here
|
|
8
|
+
// returns a verdict: only findings, and a count of the chains read so a surface
|
|
9
|
+
// can tell "nothing to report" from "read nothing".
|
|
12
10
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// boundary (`specs/architecture.md` §3) is unchanged.
|
|
11
|
+
// A chain is one head per `(public_key, chain_key)` (profile §3.2): two
|
|
12
|
+
// producers that share a chain name are two chains. The linkage is read two
|
|
13
|
+
// ways:
|
|
17
14
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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 read through it feeds a
|
|
53
|
-
// verdict, a claimed chain included (rule 11 reads linkage (1) only). The fix is
|
|
54
|
-
// for every adapter to set the record's own `previous_hash` (filed separately
|
|
55
|
-
// against `anchor`); when it lands, the namespace table below stops mattering
|
|
56
|
-
// and can go.
|
|
15
|
+
// 1. The record's own chain (`previous_hash` + `context.chain_key`), resolved
|
|
16
|
+
// in payload-hash space. The profile's mechanism.
|
|
17
|
+
// 2. Adapter metadata (`metadata.<namespace>.prev_event_id` + `seq`),
|
|
18
|
+
// resolved in event-id space, read only when the record carries no chain
|
|
19
|
+
// of its own. `metadata` is caller-owned and the profile forbids
|
|
20
|
+
// verifying from it, so nothing read through it feeds a verdict, a claimed
|
|
21
|
+
// chain included (rule 11 reads linkage 1 only).
|
|
57
22
|
import { hexToBytes, sha256Hex } from "./crypto.js";
|
|
58
23
|
import { inlinePayloadBytes, jcsParse } from "./verifier.js";
|
|
59
24
|
import { resolveTraceEvents } from "./evidence.js";
|
|
60
25
|
/**
|
|
61
|
-
* The
|
|
62
|
-
*
|
|
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.
|
|
26
|
+
* The sentence a surface prints beside these findings, so "no findings" is
|
|
27
|
+
* never read as "nothing is missing". Quoted from the profile (§5), because a
|
|
28
|
+
* paraphrased scope limit turns into a claim.
|
|
66
29
|
*/
|
|
67
30
|
export const CHAIN_READING_SCOPE = "Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
|
|
68
31
|
/**
|
|
69
32
|
* The adapter metadata namespaces that carry a chain when the record does not.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* for `langchain`: anchor#68 promoted the LangChain linkage out of a
|
|
73
|
-
* framework-named namespace into a shared one, so the same fact has one path in
|
|
74
|
-
* every SDK. A table pinned on the old name would find no chains, report
|
|
75
|
-
* nothing, and look exactly like a clean bundle — the rename trap, in the
|
|
76
|
-
* direction that fails silently.
|
|
77
|
-
*
|
|
78
|
-
* Exported so a test can assert the table itself, and so a consumer can see
|
|
79
|
-
* what this reader knows about rather than guessing.
|
|
33
|
+
* The names must match the `@sanning/anchor` SDKs exactly: a stale name finds no
|
|
34
|
+
* chains and reads like a clean bundle.
|
|
80
35
|
*/
|
|
81
36
|
export const ADAPTER_CHAIN_NAMESPACES = [
|
|
82
|
-
//
|
|
83
|
-
// root run, so the root run id is the chain.
|
|
37
|
+
// LangChain callbacks: one chain per root run.
|
|
84
38
|
{ namespace: "run", keyField: "root_run_id" },
|
|
85
|
-
//
|
|
39
|
+
// Vercel AI SDK middleware: a flat sequence per correlation key.
|
|
86
40
|
{ namespace: "vercel_ai", keyField: "chain_key" },
|
|
87
|
-
//
|
|
88
|
-
// Interchange's own reactor-owned sequence, passed through.
|
|
41
|
+
// Interchange: one chain per session; `seq` is Interchange's own.
|
|
89
42
|
{ namespace: "interchange", keyField: "session_id" },
|
|
90
43
|
];
|
|
91
44
|
/**
|
|
92
|
-
* The events this bundle is about,
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* answer to "which events" inside one kernel is a second implementation of one
|
|
96
|
-
* rule, and it is how a reader ends up walking a different set of events than
|
|
97
|
-
* the verifier reported on.
|
|
45
|
+
* The events this bundle is about, through the same descent
|
|
46
|
+
* `verifyEvidenceBundle` uses, so the reader walks the events the verifier
|
|
47
|
+
* reported on.
|
|
98
48
|
*/
|
|
99
49
|
const eventsOf = (bundle) => resolveTraceEvents(bundle) ?? [];
|
|
100
50
|
/**
|
|
101
|
-
* Decode one event record from its committed bytes
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* record is `null` rather than a throw or a guess. A withheld or unreadable
|
|
105
|
-
* record simply takes no part in a chain — `verifyEvidenceBundle` already
|
|
106
|
-
* reports its binding as undetermined, and inventing a second accusation here
|
|
107
|
-
* would say the same thing twice in a harsher voice.
|
|
51
|
+
* Decode one event record from its committed bytes, or `null`. A withheld or
|
|
52
|
+
* unreadable record takes no part in a chain: the bundle verifier already
|
|
53
|
+
* reports its binding as undetermined.
|
|
108
54
|
*/
|
|
109
55
|
function decodeRecord(hex) {
|
|
110
56
|
if (typeof hex !== "string" || hex.length === 0)
|
|
111
57
|
return null;
|
|
112
58
|
try {
|
|
113
59
|
const bytes = hexToBytes(hex);
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// LAST lenient decode pair in either kernel, and it was lenient on BOTH
|
|
117
|
-
// sides in different directions, so it agreed nowhere while looking like
|
|
118
|
-
// it agreed everywhere:
|
|
119
|
-
//
|
|
120
|
-
// * A bare `new TextDecoder()` here eats a leading byte-order mark and
|
|
121
|
-
// substitutes U+FFFD for every invalid sequence, so `BOM + JCS(r)`, a
|
|
122
|
-
// raw `0x80`, a CESU-8 surrogate pair and a truncated UTF-8 sequence
|
|
123
|
-
// each joined a chain HERE and joined none there.
|
|
124
|
-
// * Python's `json.loads` accepted `NaN`, `Infinity` and `-Infinity`,
|
|
125
|
-
// which RFC 8785 cannot emit and this kernel's `JSON.parse` rejects,
|
|
126
|
-
// so a record carrying one joined a chain THERE and none here.
|
|
127
|
-
//
|
|
128
|
-
// Five split readings on one pair, in two directions, and no test in
|
|
129
|
-
// either kernel could see them: each kernel agreed with itself. That is
|
|
130
|
-
// why agreement between two lenient readers is not evidence of anything,
|
|
131
|
-
// and why the comparison has to run on the same bytes.
|
|
60
|
+
// Strict, matching Python's `jcs_loads`: a lenient decoder on either side
|
|
61
|
+
// (BOM, invalid UTF-8, non-finite literals) splits the two kernels.
|
|
132
62
|
const record = jcsParse(bytes);
|
|
133
63
|
if (record === null || typeof record !== "object")
|
|
134
64
|
return null;
|
|
@@ -140,10 +70,8 @@ function decodeRecord(hex) {
|
|
|
140
70
|
}
|
|
141
71
|
const str = (v) => (typeof v === "string" && v.length > 0 ? v : null);
|
|
142
72
|
/**
|
|
143
|
-
* The adapter linkage, if
|
|
144
|
-
*
|
|
145
|
-
* Order is the table's order and the first match wins; a record carrying two
|
|
146
|
-
* adapter namespaces is not a shape any SDK emits.
|
|
73
|
+
* The adapter linkage, if the record carries a known one. The first namespace
|
|
74
|
+
* in table order wins.
|
|
147
75
|
*/
|
|
148
76
|
function adapterLinkage(record) {
|
|
149
77
|
const metadata = record.metadata;
|
|
@@ -157,41 +85,20 @@ function adapterLinkage(record) {
|
|
|
157
85
|
const chainKey = str(fields[keyField]);
|
|
158
86
|
if (chainKey === null)
|
|
159
87
|
continue;
|
|
160
|
-
//
|
|
161
|
-
// adapters write the null rather than omitting the key, because absent and
|
|
162
|
-
// null mean different things to a reader. Absent is treated the same way
|
|
163
|
-
// here: nothing to resolve is a beginning.
|
|
88
|
+
// Adapters write `null` at the head; absent reads the same: a beginning.
|
|
164
89
|
const prev = str(fields.prev_event_id);
|
|
165
90
|
const seqRaw = fields.seq;
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// `...992` — while Python's parser keeps it exactly, so the two kernels
|
|
169
|
-
// read DIFFERENT sequence numbers off identical bytes and report gaps of
|
|
170
|
-
// different widths. Measured at 2^53+1: a `chain-gap` count of
|
|
171
|
-
// 9007199254740990 here against 9007199254740992 there (proof#49). A value
|
|
172
|
-
// this kernel cannot represent is one it cannot read, which is the same
|
|
173
|
-
// answer it gives every other unreadable field: no sequence, take no part.
|
|
91
|
+
// Safe integers only: beyond 2^53 `JSON.parse` loses precision where Python
|
|
92
|
+
// does not, and the kernels would report different gaps on the same bytes.
|
|
174
93
|
const seq = Number.isSafeInteger(seqRaw) ? seqRaw : null;
|
|
175
94
|
return { namespace, chainKey, prev, seq };
|
|
176
95
|
}
|
|
177
96
|
return null;
|
|
178
97
|
}
|
|
179
98
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* is the previous record's `payload_hash`, and `payload_hash` is an asserted
|
|
184
|
-
* label sitting next to the bytes it claims to be about; matching one asserted
|
|
185
|
-
* label against another is how a relabelled checkpoint once verified cleanly
|
|
186
|
-
* (`evidence.ts`, `checkpoint_ref`). Hashing the bytes we already hold costs
|
|
187
|
-
* nothing and means a relabelled record DANGLES instead of quietly resolving.
|
|
188
|
-
* That is why this function is async.
|
|
189
|
-
*/
|
|
190
|
-
/**
|
|
191
|
-
* The record an envelope binds, for rule 11's links: `event_bytes` where the
|
|
192
|
-
* entry carries them, else the inline `payload` read through
|
|
193
|
-
* `inlinePayloadBytes`, the bytes `payload_hash` binds. Its identity is the
|
|
194
|
-
* SHA-256 of whichever bytes it is read from. Python's `_bound_record`.
|
|
99
|
+
* The record an envelope binds, for rule 11: `event_bytes` where present, else
|
|
100
|
+
* the inline `payload`. Its identity is the SHA-256 of those bytes. Python's
|
|
101
|
+
* twin is `_bound_record`.
|
|
195
102
|
*/
|
|
196
103
|
function boundRecord(entry) {
|
|
197
104
|
const eventBytes = entry?.event_bytes;
|
|
@@ -213,6 +120,11 @@ function boundRecord(entry) {
|
|
|
213
120
|
return null;
|
|
214
121
|
}
|
|
215
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Group the bundle's records into chains. Self-ids are derived by hashing the
|
|
125
|
+
* bytes, never read off the envelope, so a relabelled record dangles instead of
|
|
126
|
+
* resolving.
|
|
127
|
+
*/
|
|
216
128
|
async function groupChains(bundle, inline = false) {
|
|
217
129
|
const groups = new Map();
|
|
218
130
|
const events = eventsOf(bundle);
|
|
@@ -220,22 +132,17 @@ async function groupChains(bundle, inline = false) {
|
|
|
220
132
|
const entry = events[order];
|
|
221
133
|
const eventId = str(entry?.envelope?.event_id);
|
|
222
134
|
const publicKey = str(entry?.envelope?.public_key);
|
|
223
|
-
//
|
|
224
|
-
// event id an adapter pointer has nothing to resolve against.
|
|
135
|
+
// No producing key, no chain identity; no event id, nothing to resolve to.
|
|
225
136
|
if (eventId === null || publicKey === null)
|
|
226
137
|
continue;
|
|
227
|
-
// `inline`
|
|
228
|
-
//
|
|
229
|
-
// itself keeps reading `event_bytes` alone, so its findings and its rule
|
|
230
|
-
// table do not move.
|
|
138
|
+
// `inline` is for rule 11 only; the chain reading itself reads
|
|
139
|
+
// `event_bytes` alone.
|
|
231
140
|
const decoded = inline ? boundRecord(entry) : decodeRecord(entry?.event_bytes);
|
|
232
141
|
if (decoded === null)
|
|
233
142
|
continue;
|
|
234
143
|
const { record, bytes } = decoded;
|
|
235
|
-
// 1.
|
|
236
|
-
//
|
|
237
|
-
// singleton, all of them carrying `previous_hash: "GENESIS"`, out of one
|
|
238
|
-
// enormous fictional chain that would report a second root per pair.
|
|
144
|
+
// 1. `context.chain_key` is present only on chained records, which keeps
|
|
145
|
+
// unchained `GENESIS` singletons out of one fictional chain.
|
|
239
146
|
const recordChainKey = str(record.context?.chain_key);
|
|
240
147
|
let member;
|
|
241
148
|
let key;
|
|
@@ -253,10 +160,7 @@ async function groupChains(bundle, inline = false) {
|
|
|
253
160
|
key = `record ${publicKey} ${recordChainKey}`;
|
|
254
161
|
}
|
|
255
162
|
else {
|
|
256
|
-
// 2. Fallback only
|
|
257
|
-
// through its adapter metadata: one record must not land in two
|
|
258
|
-
// chains, and the profile's own field is the one a third party can
|
|
259
|
-
// walk without knowing which SDK wrote it.
|
|
163
|
+
// 2. Fallback only: one record must not land in two chains.
|
|
260
164
|
const linkage = adapterLinkage(record);
|
|
261
165
|
if (linkage === null)
|
|
262
166
|
continue;
|
|
@@ -277,7 +181,6 @@ async function groupChains(bundle, inline = false) {
|
|
|
277
181
|
}
|
|
278
182
|
return [...groups.values()];
|
|
279
183
|
}
|
|
280
|
-
// ---- The rule ----------------------------------------------------------------
|
|
281
184
|
/** By `seq` where both have one, else by position in the bundle. */
|
|
282
185
|
function byChainOrder(a, b) {
|
|
283
186
|
if (a.seq !== null && b.seq !== null && a.seq !== b.seq)
|
|
@@ -285,10 +188,8 @@ function byChainOrder(a, b) {
|
|
|
285
188
|
return a.order - b.order;
|
|
286
189
|
}
|
|
287
190
|
/**
|
|
288
|
-
* The smallest slot between the lowest and highest occupied ones
|
|
289
|
-
*
|
|
290
|
-
* span. See the call site: the range's width is chosen by whoever wrote the
|
|
291
|
-
* bundle.
|
|
191
|
+
* The smallest unoccupied slot between the lowest and highest occupied ones,
|
|
192
|
+
* found by walking the sorted occupants: the range width is attacker-chosen.
|
|
292
193
|
*/
|
|
293
194
|
function firstMissingSlot(present) {
|
|
294
195
|
const ordered = [...present].sort((a, b) => a - b);
|
|
@@ -314,11 +215,8 @@ function findingsFor(group) {
|
|
|
314
215
|
const dangling = members
|
|
315
216
|
.filter((m) => m.prev !== null && !selfIds.has(m.prev))
|
|
316
217
|
.sort(byChainOrder);
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// alone reports as CLEAN, because both halves walk back to a legitimate
|
|
320
|
-
// beginning and neither dangles. It is what a crash looks like when the SDK
|
|
321
|
-
// lost its chain head and started over under the same name.
|
|
218
|
+
// Two GENESIS records under one key: a fork, which a link-walk alone reports
|
|
219
|
+
// as clean. Typically a crash that lost the chain head.
|
|
322
220
|
if (roots.length > 1) {
|
|
323
221
|
findings.push({
|
|
324
222
|
kind: "chain-second-root",
|
|
@@ -327,12 +225,8 @@ function findingsFor(group) {
|
|
|
327
225
|
eventIds: roots.map((m) => m.eventId),
|
|
328
226
|
});
|
|
329
227
|
}
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
// before this export, so the window's earliest record necessarily points at a
|
|
333
|
-
// predecessor nobody handed us. The allowance is spent only when the chain
|
|
334
|
-
// shows no GENESIS root — a chain that already has its beginning here has no
|
|
335
|
-
// window edge left to explain the next one.
|
|
228
|
+
// One pointer out of the bundle is the window's edge, not a finding, unless
|
|
229
|
+
// the chain already has its GENESIS root here.
|
|
336
230
|
const unexplained = roots.length === 0 ? dangling.slice(1) : dangling;
|
|
337
231
|
if (unexplained.length > 0) {
|
|
338
232
|
const first = unexplained[0];
|
|
@@ -344,24 +238,15 @@ function findingsFor(group) {
|
|
|
344
238
|
unresolvedPointer: first.prev,
|
|
345
239
|
});
|
|
346
240
|
}
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// reporting its first six absent slots would bury the one that matters. A
|
|
350
|
-
// repeated seq is a real defect but a different one, and the present-set below
|
|
351
|
-
// is deliberately duplicate-safe rather than count-based so it cannot be
|
|
352
|
-
// mistaken for a gap.
|
|
241
|
+
// Interior holes only: a window starting at seq 7 is not a deletion. The
|
|
242
|
+
// present-set is duplicate-safe so a repeated seq is not mistaken for a gap.
|
|
353
243
|
const sequenced = members.filter((m) => m.seq !== null);
|
|
354
244
|
if (sequenced.length === of && of > 0) {
|
|
355
245
|
const present = new Set(sequenced.map((m) => m.seq));
|
|
356
246
|
const low = Math.min(...present);
|
|
357
247
|
const high = Math.max(...present);
|
|
358
|
-
// Counted, never walked
|
|
359
|
-
//
|
|
360
|
-
// high; s++)` spin for days — a verifier that HANGS on adversarial input is
|
|
361
|
-
// worse than one that raises, and this reader promises neither (proof#49).
|
|
362
|
-
// The present set is a subset of [low, high], so the absent slots are the
|
|
363
|
-
// width minus the occupants, and the first of them is found by walking the
|
|
364
|
-
// occupants in order.
|
|
248
|
+
// Counted, never walked: `seq` is attacker-chosen, and a loop over
|
|
249
|
+
// [low, high] hangs on 0 and 1e15.
|
|
365
250
|
const missingCount = high - low + 1 - present.size;
|
|
366
251
|
const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
|
|
367
252
|
if (firstMissing !== null) {
|
|
@@ -378,11 +263,8 @@ function findingsFor(group) {
|
|
|
378
263
|
return findings;
|
|
379
264
|
}
|
|
380
265
|
/**
|
|
381
|
-
* Every record
|
|
382
|
-
*
|
|
383
|
-
* record an envelope binds, `event_bytes` or the inline `payload` where no
|
|
384
|
-
* `event_bytes` travel, with the identity the SHA-256 of those bytes, exactly
|
|
385
|
-
* as the envelope verifier binds `payload_hash`.
|
|
266
|
+
* Every record placed in a chain, in bundle order, with its identity and
|
|
267
|
+
* pointer. Reads `event_bytes`, or the inline `payload` where none travel.
|
|
386
268
|
*/
|
|
387
269
|
export async function readChainLinks(bundle) {
|
|
388
270
|
const links = [];
|
|
@@ -404,23 +286,15 @@ export async function readChainLinks(bundle) {
|
|
|
404
286
|
return links.sort((a, b) => a.order - b.order);
|
|
405
287
|
}
|
|
406
288
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* Reports NOTHING for an intact chain, and nothing for a chain whose records
|
|
411
|
-
* are withheld — a link we could not read is unknown, never an accusation.
|
|
412
|
-
* `chains` is what makes that distinguishable from having read nothing at all.
|
|
289
|
+
* Report the links that do not resolve within the bundle. An intact chain, and
|
|
290
|
+
* one whose records are withheld, report nothing: an unreadable link is
|
|
291
|
+
* unknown, never an accusation.
|
|
413
292
|
*/
|
|
414
293
|
export async function readChains(bundle) {
|
|
415
294
|
const groups = await groupChains(bundle);
|
|
416
295
|
const order = (g) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
// ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
|
|
420
|
-
// code-wise comparison puts it before, and it can differ between two
|
|
421
|
-
// TypeScript installs, let alone between kernels. The Python reader sorts the
|
|
422
|
-
// same key encoded as `utf-16-be`, which is exactly this comparison — so the
|
|
423
|
-
// two kernels emit findings in one order (proof#49).
|
|
296
|
+
// By UTF-16 code unit, not `localeCompare` (host-dependent ICU collation).
|
|
297
|
+
// Python sorts the key as `utf-16-be`, so both kernels emit one order.
|
|
424
298
|
groups.sort((a, b) => {
|
|
425
299
|
const x = order(a);
|
|
426
300
|
const y = order(b);
|
|
@@ -438,12 +312,9 @@ export async function readChains(bundle) {
|
|
|
438
312
|
};
|
|
439
313
|
}
|
|
440
314
|
/**
|
|
441
|
-
* The house wording for one finding.
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* resolve IN THIS BUNDLE, because that is the whole of what was checked; none
|
|
445
|
-
* of them says failed, invalid, tampered or complete. A surface with its own
|
|
446
|
-
* voice should read the fields instead — but it must not upgrade the claim.
|
|
315
|
+
* The house wording for one finding. Says only what does not resolve in this
|
|
316
|
+
* bundle, never failed, tampered or complete; a surface rewording it must not
|
|
317
|
+
* upgrade the claim.
|
|
447
318
|
*/
|
|
448
319
|
export function renderChainFinding(finding, subject = "this bundle") {
|
|
449
320
|
const where = finding.source === "adapter"
|
|
@@ -451,12 +322,14 @@ export function renderChainFinding(finding, subject = "this bundle") {
|
|
|
451
322
|
: `chain ${finding.chainKey}`;
|
|
452
323
|
switch (finding.kind) {
|
|
453
324
|
case "chain-link-dangling":
|
|
454
|
-
return (`${where}: ${finding.count} of ${finding.of}
|
|
325
|
+
return (`${where}: ${finding.count} of ${finding.of} ` +
|
|
326
|
+
`${finding.of === 1 ? "record points" : "records point"} at a predecessor ` +
|
|
455
327
|
`${subject} does not present (first: ${finding.eventIds[0]}, pointer ` +
|
|
456
328
|
`${finding.unresolvedPointer})`);
|
|
457
329
|
case "chain-gap":
|
|
458
|
-
return (`${where}: ${finding.count} sequence
|
|
459
|
-
|
|
330
|
+
return (`${where}: ${finding.count} sequence ` +
|
|
331
|
+
`${finding.count === 1 ? "slot" : "slots"} between the records ${subject} presents ` +
|
|
332
|
+
`${finding.count === 1 ? "carries" : "carry"} no record (first missing seq ${finding.firstMissingSeq}` +
|
|
460
333
|
`${finding.eventIds[0] === undefined ? "" : `, next present: ${finding.eventIds[0]}`})`);
|
|
461
334
|
case "chain-second-root":
|
|
462
335
|
return (`${where}: ${finding.count} records begin the chain where one chain has one beginning ` +
|