@sanning/proof 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/case-file.js +6 -2
- package/dist/case.d.ts +51 -7
- package/dist/case.js +81 -16
- package/dist/cli.d.ts +8 -27
- package/dist/cli.js +184 -103
- package/dist/compose.js +6 -1
- package/dist/evidence.d.ts +36 -0
- package/dist/evidence.js +104 -3
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +24 -2
- package/dist/message-parts.d.ts +12 -6
- package/dist/message-parts.js +18 -10
- package/dist/verifier.d.ts +54 -2
- package/dist/verifier.js +195 -3
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- package/dist/witness-lookup.d.ts +112 -0
- package/dist/witness-lookup.js +300 -0
- package/package.json +1 -1
- package/src/case-file.ts +5 -2
- package/src/case.ts +101 -15
- package/src/cli.ts +191 -123
- package/src/compose.ts +8 -1
- package/src/evidence.ts +115 -2
- package/src/file-match.ts +461 -0
- package/src/index.ts +68 -0
- package/src/message-parts.ts +18 -10
- package/src/verifier.ts +190 -3
- package/src/witness-cache.ts +306 -0
- package/src/witness-lookup.ts +428 -0
- package/vectors/README.md +43 -3
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/stamp-on-chain.json +18 -3
- package/vectors/witness-cache.json +1304 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The witnessed-time cache: what a pack remembers about its witness, unverified
|
|
2
|
+
// (evidence-bundle.md 5.6.6, v1.25, A30, proof#24).
|
|
3
|
+
//
|
|
4
|
+
// The TypeScript half of `src/sanning_proof/witness_cache.py`. Both kernels are
|
|
5
|
+
// held to one table, `ts/vectors/witness-cache.json`, so neither can pass by
|
|
6
|
+
// agreeing with the other.
|
|
7
|
+
//
|
|
8
|
+
// WHAT THE CACHE IS. A file beside the pack's `bundle.json`,
|
|
9
|
+
// `witness-cache.json`, and never a member of the bundle. Its one list today,
|
|
10
|
+
// `witnessed`, gives for each stamp the block height and block time of the
|
|
11
|
+
// witness transaction, as the builder of the pack fetched them. Nobody signs
|
|
12
|
+
// it, and anyone holding the pack can edit it. EVERY MEMBER OF THE FILE IS
|
|
13
|
+
// UNVERIFIED BY CONSTRUCTION, and the name carries that warning to every field
|
|
14
|
+
// added to it later.
|
|
15
|
+
//
|
|
16
|
+
// Because it is a separate file, a reader before v1.25 never opens it: it lists
|
|
17
|
+
// it as a file outside every pack and reads the pack exactly as it would
|
|
18
|
+
// without it. A `cache` member written INSIDE `bundle.json` is an unknown
|
|
19
|
+
// wrapper field, counted in the signed scope by every reader, this one
|
|
20
|
+
// included.
|
|
21
|
+
//
|
|
22
|
+
// WHAT THIS MODULE DOES, AND WHAT IT MUST NEVER DO.
|
|
23
|
+
//
|
|
24
|
+
// - `readWitnessCache` reads the file's bytes against the signed bundle beside
|
|
25
|
+
// it. It is pure, and it is never an input to the bundle verdict, the pack
|
|
26
|
+
// grade or the case reading: neither `verifyEvidenceBundle` nor `readCase` is
|
|
27
|
+
// ever handed the file.
|
|
28
|
+
// - `checkWitnessCache` looks each cached time up on the public record, where a
|
|
29
|
+
// caller asked for a lookup. A lookup that answers with a different block is
|
|
30
|
+
// a finding, reported on the witness line as `differs`. A lookup that does
|
|
31
|
+
// not settle leaves the cached value labelled not checked.
|
|
32
|
+
// - Nothing here returns a time a reader may rely on. The only time this module
|
|
33
|
+
// ever reports as the public record's is one a gateway returned, and a
|
|
34
|
+
// gateway is named, never trusted (6).
|
|
35
|
+
//
|
|
36
|
+
// A file that is absent, that is not JSON under the reader's rule, whose
|
|
37
|
+
// `cache_version` is not `WITNESS_CACHE_VERSION`, whose `witnessed` is not a
|
|
38
|
+
// list, or whose entry is not well formed, reads as no cache for what it could
|
|
39
|
+
// not say. It is never a finding and never moves anything.
|
|
40
|
+
import { jsonDepthExceeds } from "./case-file.js";
|
|
41
|
+
import { jcs, jcsParse } from "./verifier.js";
|
|
42
|
+
/**
|
|
43
|
+
* The last second a block time can name and still be printed the same way by
|
|
44
|
+
* both kernels: 9999-12-31T23:59:59Z. A larger value is a cache entry this
|
|
45
|
+
* reader cannot print, and reads as none.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_BLOCK_TIME = 253402300799;
|
|
48
|
+
/** The file's name, beside `bundle.json` in the pack's folder (5.6.1). */
|
|
49
|
+
export const WITNESS_CACHE_NAME = "witness-cache.json";
|
|
50
|
+
/**
|
|
51
|
+
* The dispatch key: a file whose `cache_version` is absent or any other value
|
|
52
|
+
* reads as no cache.
|
|
53
|
+
*/
|
|
54
|
+
export const WITNESS_CACHE_VERSION = "sanning.witness-cache/v1";
|
|
55
|
+
/**
|
|
56
|
+
* How deep `[` and `{` may nest in the file before it is refused unread: the
|
|
57
|
+
* limit `bundle.json` and `contents.json` are read under.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_WITNESS_CACHE_DEPTH = 512;
|
|
60
|
+
// The bodies that carry `stamps[]`, the only witnesses the cache speaks for.
|
|
61
|
+
const STAMP_BODIES = new Set(["sanning.stamp.trace/v1", "sanning.stamp.evidence/v2"]);
|
|
62
|
+
const STAMP_REF = /^[0-9a-f]{64}$/;
|
|
63
|
+
// The one witness kind a time is looked up for, and the only ids the lookup
|
|
64
|
+
// sends: the same rule the byte lookup applies (evidence-bundle 5.1.1, v1.6).
|
|
65
|
+
const ADAPTER_KIND = "arweave";
|
|
66
|
+
const ARWEAVE_ID = /^[A-Za-z0-9_-]{43}$/;
|
|
67
|
+
// A GraphQL answer to one query for one transaction is a few hundred bytes. A
|
|
68
|
+
// deeper answer is not one, and refusing it before any parser recurses keeps
|
|
69
|
+
// the two kernels on one reading of it.
|
|
70
|
+
const MAX_ANSWER_DEPTH = 16;
|
|
71
|
+
function isObject(v) {
|
|
72
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
73
|
+
}
|
|
74
|
+
// A member the object itself holds, never one inherited from its prototype.
|
|
75
|
+
function own(o, key) {
|
|
76
|
+
return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
|
|
77
|
+
}
|
|
78
|
+
function blockNumber(value, upper) {
|
|
79
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value))
|
|
80
|
+
return null;
|
|
81
|
+
if (value < 0 || value > upper)
|
|
82
|
+
return null;
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
// `stamp_ref` to `[kind, id]` for every stamp in the signed body that names a
|
|
86
|
+
// witness. A `stamp_ref` spelled on two stamps names neither.
|
|
87
|
+
function stampWitnesses(bundle) {
|
|
88
|
+
const out = new Map();
|
|
89
|
+
if (typeof bundle.body_type !== "string" || !STAMP_BODIES.has(bundle.body_type))
|
|
90
|
+
return out;
|
|
91
|
+
const body = bundle.body;
|
|
92
|
+
const stamps = isObject(body) ? body.stamps : undefined;
|
|
93
|
+
if (!Array.isArray(stamps))
|
|
94
|
+
return out;
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const stamp of stamps) {
|
|
97
|
+
if (!isObject(stamp))
|
|
98
|
+
continue;
|
|
99
|
+
const ref = stamp.stamp_ref;
|
|
100
|
+
if (typeof ref !== "string" || !STAMP_REF.test(ref))
|
|
101
|
+
continue;
|
|
102
|
+
if (seen.has(ref)) {
|
|
103
|
+
out.delete(ref);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
seen.add(ref);
|
|
107
|
+
const anchor = stamp.anchor_ref;
|
|
108
|
+
if (!isObject(anchor))
|
|
109
|
+
continue;
|
|
110
|
+
if (typeof anchor.kind === "string" && typeof anchor.id === "string") {
|
|
111
|
+
out.set(ref, [anchor.kind, anchor.id]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
// The file's JSON, read under the rule `contents.json` is read under (5.6.2):
|
|
117
|
+
// strict UTF-8, no byte-order mark, no `NaN` or `Infinity`, no member named
|
|
118
|
+
// twice, and nothing nested past `MAX_WITNESS_CACHE_DEPTH`. `undefined` for
|
|
119
|
+
// anything else.
|
|
120
|
+
function readFile(raw) {
|
|
121
|
+
if (!(raw instanceof Uint8Array))
|
|
122
|
+
return undefined;
|
|
123
|
+
if (jsonDepthExceeds(raw, MAX_WITNESS_CACHE_DEPTH))
|
|
124
|
+
return undefined;
|
|
125
|
+
try {
|
|
126
|
+
return jcsParse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The cached witness times the `witness-cache.json` bytes `raw` give for the
|
|
134
|
+
* signed `bundle` beside them, in the order the file lists them. `raw` is
|
|
135
|
+
* `null` where the pack has no such file. Pure, and never throws. The file
|
|
136
|
+
* reads only where it is a JSON object whose `cache_version` is
|
|
137
|
+
* `WITNESS_CACHE_VERSION` and whose `witnessed` is a list. An entry reads only
|
|
138
|
+
* where it is an object whose `stamp_ref` is 64 lowercase hex characters naming
|
|
139
|
+
* exactly one stamp of the signed body that has an `anchor_ref`, whose
|
|
140
|
+
* `block_height` is a non-negative safe integer, and whose `block_time` is a
|
|
141
|
+
* whole number of seconds since 1970-01-01T00:00:00Z from 0 to
|
|
142
|
+
* `MAX_BLOCK_TIME`. A `stamp_ref` two entries name reads for neither.
|
|
143
|
+
* Everything else reads as no cache for the stamp it would have spoken for.
|
|
144
|
+
*/
|
|
145
|
+
export function readWitnessCache(bundle, raw) {
|
|
146
|
+
if (!isObject(bundle))
|
|
147
|
+
return [];
|
|
148
|
+
const cache = readFile(raw);
|
|
149
|
+
if (!isObject(cache))
|
|
150
|
+
return [];
|
|
151
|
+
if (own(cache, "cache_version") !== WITNESS_CACHE_VERSION)
|
|
152
|
+
return [];
|
|
153
|
+
const entries = own(cache, "witnessed");
|
|
154
|
+
if (!Array.isArray(entries))
|
|
155
|
+
return [];
|
|
156
|
+
const witnesses = stampWitnesses(bundle);
|
|
157
|
+
const read = [];
|
|
158
|
+
const named = new Map();
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (!isObject(entry))
|
|
161
|
+
continue;
|
|
162
|
+
const ref = own(entry, "stamp_ref");
|
|
163
|
+
if (typeof ref !== "string" || !witnesses.has(ref))
|
|
164
|
+
continue;
|
|
165
|
+
named.set(ref, (named.get(ref) ?? 0) + 1);
|
|
166
|
+
const height = blockNumber(own(entry, "block_height"), Number.MAX_SAFE_INTEGER);
|
|
167
|
+
const time = blockNumber(own(entry, "block_time"), MAX_BLOCK_TIME);
|
|
168
|
+
if (height === null || time === null)
|
|
169
|
+
continue;
|
|
170
|
+
const [kind, id] = witnesses.get(ref);
|
|
171
|
+
read.push({ stampRef: ref, blockHeight: height, blockTime: time, witnessKind: kind, witnessId: id });
|
|
172
|
+
}
|
|
173
|
+
return read.filter((c) => named.get(c.stampRef) === 1);
|
|
174
|
+
}
|
|
175
|
+
/** A block time as RFC 3339 UTC to the second, the way both kernels print it: `2026-09-08T12:10:07Z`. */
|
|
176
|
+
export function formatBlockTime(seconds) {
|
|
177
|
+
return new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
178
|
+
}
|
|
179
|
+
/** `block 1996701 at 2026-09-08T12:10:07Z`. */
|
|
180
|
+
export function renderWitnessTime(blockHeight, blockTime) {
|
|
181
|
+
return `block ${blockHeight} at ${formatBlockTime(blockTime)}`;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The request body the lookup sends: one GraphQL query for one transaction's
|
|
185
|
+
* block, as JCS, so both kernels send the same bytes.
|
|
186
|
+
*/
|
|
187
|
+
export function witnessTimeQuery(witnessId) {
|
|
188
|
+
return jcs({ query: `query{transaction(id:"${witnessId}"){block{height timestamp}}}` });
|
|
189
|
+
}
|
|
190
|
+
function readAnswer(raw) {
|
|
191
|
+
if (jsonDepthExceeds(raw, MAX_ANSWER_DEPTH))
|
|
192
|
+
return null;
|
|
193
|
+
let node;
|
|
194
|
+
try {
|
|
195
|
+
node = jcsParse(raw);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
for (const key of ["data", "transaction", "block"]) {
|
|
201
|
+
if (!isObject(node))
|
|
202
|
+
return null;
|
|
203
|
+
node = node[key];
|
|
204
|
+
}
|
|
205
|
+
if (!isObject(node))
|
|
206
|
+
return null;
|
|
207
|
+
const height = blockNumber(node.height, Number.MAX_SAFE_INTEGER);
|
|
208
|
+
const time = blockNumber(node.timestamp, Number.MAX_SAFE_INTEGER);
|
|
209
|
+
if (height === null || time === null)
|
|
210
|
+
return null;
|
|
211
|
+
return [height, time];
|
|
212
|
+
}
|
|
213
|
+
async function ask(cached, gateway, fetchImpl) {
|
|
214
|
+
const unreachable = { gateway, outcome: "unreachable", blockHeight: null, blockTime: null };
|
|
215
|
+
const url = `${gateway.replace(/\/+$/, "")}/graphql`;
|
|
216
|
+
let raw;
|
|
217
|
+
try {
|
|
218
|
+
const resp = await fetchImpl(url, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "content-type": "application/json" },
|
|
221
|
+
body: witnessTimeQuery(cached.witnessId),
|
|
222
|
+
});
|
|
223
|
+
if (!resp.ok)
|
|
224
|
+
return unreachable;
|
|
225
|
+
raw = new Uint8Array(await resp.arrayBuffer());
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return unreachable;
|
|
229
|
+
}
|
|
230
|
+
const block = readAnswer(raw);
|
|
231
|
+
if (block === null)
|
|
232
|
+
return unreachable;
|
|
233
|
+
const [height, time] = block;
|
|
234
|
+
const same = height === cached.blockHeight && time === cached.blockTime;
|
|
235
|
+
return { gateway, outcome: same ? "matches" : "differs", blockHeight: height, blockTime: time };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Look each cached time up on every gateway, in order. With no gateways or no
|
|
239
|
+
* `fetchImpl`, nothing is looked up and every time is `not_checked`. A witness
|
|
240
|
+
* that is not `arweave`, or whose id is not a 43-character base64url
|
|
241
|
+
* transaction id, is never looked up.
|
|
242
|
+
*/
|
|
243
|
+
export async function checkWitnessCache(cached, gateways, fetchImpl) {
|
|
244
|
+
const out = [];
|
|
245
|
+
for (const c of cached) {
|
|
246
|
+
if (gateways.length === 0 ||
|
|
247
|
+
fetchImpl === undefined ||
|
|
248
|
+
c.witnessKind !== ADAPTER_KIND ||
|
|
249
|
+
!ARWEAVE_ID.test(c.witnessId)) {
|
|
250
|
+
out.push({ cached: c, outcome: "not_checked", answers: [] });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const answers = [];
|
|
254
|
+
for (const gw of gateways)
|
|
255
|
+
answers.push(await ask(c, gw, fetchImpl));
|
|
256
|
+
const outcome = answers.some((a) => a.outcome === "differs")
|
|
257
|
+
? "differs"
|
|
258
|
+
: answers.some((a) => a.outcome === "matches")
|
|
259
|
+
? "matches"
|
|
260
|
+
: "not_checked";
|
|
261
|
+
out.push({ cached: c, outcome, answers });
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { WitnessLine, WitnessState } from "./case.js";
|
|
2
|
+
import { type CachedWitnessTime, type WitnessTimeAnswer, type WitnessTimeCheck } from "./witness-cache.js";
|
|
3
|
+
/** The seam every network call in this module goes through: used where given, else the ambient global `fetch`. */
|
|
4
|
+
export interface WitnessLookupOptions {
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
}
|
|
7
|
+
export interface OnChainRow {
|
|
8
|
+
ref: string;
|
|
9
|
+
witnesses: number;
|
|
10
|
+
confirmed: number;
|
|
11
|
+
mismatched: number;
|
|
12
|
+
unreachable: number;
|
|
13
|
+
not_checked: number;
|
|
14
|
+
/** Present only where the pack caches a witness time (5.6.6, v1.25, A30). */
|
|
15
|
+
witness_times?: WitnessTimeRow[];
|
|
16
|
+
}
|
|
17
|
+
export interface WitnessTimeRow {
|
|
18
|
+
stamp_ref: string;
|
|
19
|
+
cached: {
|
|
20
|
+
block_height: number;
|
|
21
|
+
block_time: number;
|
|
22
|
+
};
|
|
23
|
+
outcome: WitnessTimeCheck["outcome"];
|
|
24
|
+
answers: {
|
|
25
|
+
gateway: string;
|
|
26
|
+
outcome: WitnessTimeAnswer["outcome"];
|
|
27
|
+
block_height: number | null;
|
|
28
|
+
block_time: number | null;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
export interface OnChainHalf {
|
|
32
|
+
/** Present only where a lookup ran: each gateway that answered, in the order given. */
|
|
33
|
+
answered?: string[];
|
|
34
|
+
checked: boolean;
|
|
35
|
+
gateways: string[];
|
|
36
|
+
packs: OnChainRow[];
|
|
37
|
+
verdict: "not_checked" | "confirmed" | "failed" | "undetermined";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside
|
|
41
|
+
* the case reading on purpose: `readCase` makes no network call, and neither
|
|
42
|
+
* does the verify page's case reading, so the case verdict stays the one both
|
|
43
|
+
* surfaces compute. This adds the half the reading names as not checked.
|
|
44
|
+
*
|
|
45
|
+
* Moved from `cli.ts` unchanged, apart from taking `fetchImpl` directly
|
|
46
|
+
* instead of a `Cli` stream (`io.fetchImpl ?? fetch`, the same fallback the
|
|
47
|
+
* CLI already applied at its own call site).
|
|
48
|
+
*/
|
|
49
|
+
export declare function onChainHalf(packs: readonly (readonly [string, unknown, readonly CachedWitnessTime[]])[], gateways: readonly string[], options?: WitnessLookupOptions): Promise<OnChainHalf>;
|
|
50
|
+
/**
|
|
51
|
+
* `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
|
|
52
|
+
* lookup ran, each is printed labelled not checked. Where one ran, each time
|
|
53
|
+
* the public record matched is printed as the record's, each time no lookup
|
|
54
|
+
* settled stays labelled not checked, and a time the record contradicts is the
|
|
55
|
+
* reason the line reads `differs`.
|
|
56
|
+
*
|
|
57
|
+
* Moved from `cli.ts` unchanged: same inputs, same three states
|
|
58
|
+
* (`WitnessState`), same rendering. The CLI's `case` command still calls this
|
|
59
|
+
* exact function, so its witness line is unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export declare function witnessLine(onChain: OnChainHalf, cached?: readonly CachedWitnessTime[]): WitnessLine;
|
|
62
|
+
/** The four states this export's result can carry. The CLI's own `WitnessState` plus `no_public_record`. */
|
|
63
|
+
export type PublicRecordState = WitnessState | "no_public_record";
|
|
64
|
+
/**
|
|
65
|
+
* One pack, as this lookup needs it: the parsed `bundle.json` and, where the
|
|
66
|
+
* pack carries one, the RAW BYTES of its `witness-cache.json`, read as part
|
|
67
|
+
* of the pack, per proof#200's acceptance criteria, rather than as a
|
|
68
|
+
* separately-loaded side file. `null` and `undefined` both mean "this pack has
|
|
69
|
+
* no witness-cache.json", the same reading `readWitnessCache` gives a missing
|
|
70
|
+
* file.
|
|
71
|
+
*/
|
|
72
|
+
export interface PublicRecordPack {
|
|
73
|
+
/** How this pack is named in the result; carries no meaning beyond that. */
|
|
74
|
+
ref: string;
|
|
75
|
+
bundle: unknown;
|
|
76
|
+
witnessCache?: Uint8Array | null;
|
|
77
|
+
}
|
|
78
|
+
export interface PublicRecordResult {
|
|
79
|
+
state: PublicRecordState;
|
|
80
|
+
reasons: string[];
|
|
81
|
+
services: string[];
|
|
82
|
+
/** Witness times a pack caches that no lookup settled (5.6.6, v1.25, A30). */
|
|
83
|
+
cached?: [number, number][];
|
|
84
|
+
/** Witness times the public record returned that matched a pack's cache. */
|
|
85
|
+
recorded?: [number, number][];
|
|
86
|
+
/** The line as both the CLI and a page print it, one string, kernel words. */
|
|
87
|
+
rendered: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The public-record lookup the CLI runs with `--gateways`, exported so a
|
|
91
|
+
* consumer that is not the CLI can run the SAME lookup rather than copy it
|
|
92
|
+
* (proof#200; the copy this closes is verify-page#70's `src/lib/verify.ts`).
|
|
93
|
+
*
|
|
94
|
+
* `packs` is every pack in the drop being read; `gateways` is empty for an
|
|
95
|
+
* offline reading, which never makes a network call and always answers
|
|
96
|
+
* `not_checked` or `no_public_record`. `options.fetchImpl` is the one seam a
|
|
97
|
+
* network call goes through, a browser page supplies its own `fetchImpl`;
|
|
98
|
+
* with none given this falls back to the ambient global `fetch`, so a
|
|
99
|
+
* browser page and a test can each supply their own and Node's `fetch` is
|
|
100
|
+
* never assumed where a caller passed something else.
|
|
101
|
+
*
|
|
102
|
+
* `state` is `confirmed` only about the BYTES: the public record holds what a
|
|
103
|
+
* pack names. It says nothing about whether the pack itself verifies (its
|
|
104
|
+
* wrapper signature, its `body_hash`), and a caller must never show
|
|
105
|
+
* `confirmed` apart from the pack's own verdict (proof#201).
|
|
106
|
+
*
|
|
107
|
+
* Never throws. Every input, including a malformed pack, a non-string
|
|
108
|
+
* gateway, or a `fetchImpl` that itself misbehaves, is read as untrusted, and
|
|
109
|
+
* anything this function or what it calls cannot make sense of settles as
|
|
110
|
+
* `not_checked` rather than escape as an exception.
|
|
111
|
+
*/
|
|
112
|
+
export declare function lookupPublicRecord(packs: readonly PublicRecordPack[], gateways: readonly string[], options?: WitnessLookupOptions): Promise<PublicRecordResult>;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// The public-record (witness) lookup the CLI runs with `--gateways`
|
|
2
|
+
// (evidence-bundle.md §5.5.6, v1.20, A24, D7): re-fetch each pack's witness on
|
|
3
|
+
// the public record and report what it said. Moved here from `cli.ts`
|
|
4
|
+
// (proof#200) so the CLI and a consumer that is not the CLI, the verify page,
|
|
5
|
+
// a browser app, call the SAME implementation rather than each keeping its
|
|
6
|
+
// own copy that can drift.
|
|
7
|
+
//
|
|
8
|
+
// Pure by the kernel's own rule (specs/architecture.md §3): the network call
|
|
9
|
+
// is the one exception this module exists to hold, and it never runs unless a
|
|
10
|
+
// caller hands in a gateway to ask. `options.fetchImpl` is the seam: where a
|
|
11
|
+
// caller supplies one (a browser page's own `fetch`, or a test's fake), that
|
|
12
|
+
// is the ONE this module calls; where none is given, it falls back to the
|
|
13
|
+
// ambient global `fetch`, the same fallback `cli.ts` already applied at its
|
|
14
|
+
// own call site before this module existed.
|
|
15
|
+
//
|
|
16
|
+
// `onChainHalf` and `witnessLine` are the CLI's own building blocks,
|
|
17
|
+
// UNCHANGED (proof#200 is additive: the CLI's `case` output and exit codes are
|
|
18
|
+
// byte-for-byte what they were). `lookupPublicRecord` is the new export:
|
|
19
|
+
// one pack (or several) in, one four-state result out, with a pack's
|
|
20
|
+
// `witness-cache.json` read as part of the pack rather than as a side file a
|
|
21
|
+
// caller must remember to also pass in.
|
|
22
|
+
import { verifyEvidenceBundle } from "./evidence.js";
|
|
23
|
+
import { WITNESS_NOT_LOOKED_UP, renderWitnessLine } from "./case.js";
|
|
24
|
+
import { checkWitnessCache, readWitnessCache, renderWitnessTime, } from "./witness-cache.js";
|
|
25
|
+
function witnessTimeRow(t) {
|
|
26
|
+
return {
|
|
27
|
+
stamp_ref: t.cached.stampRef,
|
|
28
|
+
cached: { block_height: t.cached.blockHeight, block_time: t.cached.blockTime },
|
|
29
|
+
outcome: t.outcome,
|
|
30
|
+
answers: t.answers.map((a) => ({
|
|
31
|
+
gateway: a.gateway,
|
|
32
|
+
outcome: a.outcome,
|
|
33
|
+
block_height: a.blockHeight,
|
|
34
|
+
block_time: a.blockTime,
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside
|
|
40
|
+
* the case reading on purpose: `readCase` makes no network call, and neither
|
|
41
|
+
* does the verify page's case reading, so the case verdict stays the one both
|
|
42
|
+
* surfaces compute. This adds the half the reading names as not checked.
|
|
43
|
+
*
|
|
44
|
+
* Moved from `cli.ts` unchanged, apart from taking `fetchImpl` directly
|
|
45
|
+
* instead of a `Cli` stream (`io.fetchImpl ?? fetch`, the same fallback the
|
|
46
|
+
* CLI already applied at its own call site).
|
|
47
|
+
*/
|
|
48
|
+
export async function onChainHalf(packs, gateways, options = {}) {
|
|
49
|
+
if (gateways.length === 0)
|
|
50
|
+
return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
51
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
52
|
+
const rows = [];
|
|
53
|
+
const answered = new Set();
|
|
54
|
+
for (const [ref, bundle, cached] of packs) {
|
|
55
|
+
const result = await verifyEvidenceBundle(bundle, { gateways: [...gateways], fetchImpl });
|
|
56
|
+
const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
|
|
57
|
+
const row = {
|
|
58
|
+
ref,
|
|
59
|
+
witnesses: witnesses.length,
|
|
60
|
+
confirmed: 0,
|
|
61
|
+
mismatched: 0,
|
|
62
|
+
unreachable: 0,
|
|
63
|
+
not_checked: 0,
|
|
64
|
+
};
|
|
65
|
+
for (const w of witnesses) {
|
|
66
|
+
const rollup = w.onChain?.rollup ?? null;
|
|
67
|
+
if (rollup === "confirm")
|
|
68
|
+
row.confirmed++;
|
|
69
|
+
else if (rollup === "mismatch")
|
|
70
|
+
row.mismatched++;
|
|
71
|
+
else if (rollup === "unreachable")
|
|
72
|
+
row.unreachable++;
|
|
73
|
+
else
|
|
74
|
+
row.not_checked++;
|
|
75
|
+
}
|
|
76
|
+
// The witness times the pack's witness-cache.json gives, looked up where
|
|
77
|
+
// it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
|
|
78
|
+
// no request.
|
|
79
|
+
const times = await checkWitnessCache(cached, gateways, fetchImpl);
|
|
80
|
+
if (times.length > 0)
|
|
81
|
+
row.witness_times = times.map(witnessTimeRow);
|
|
82
|
+
rows.push(row);
|
|
83
|
+
for (const w of witnesses) {
|
|
84
|
+
for (const outcome of w.onChain?.perGateway ?? []) {
|
|
85
|
+
if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
|
|
86
|
+
answered.add(outcome.gateway);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const t of times) {
|
|
91
|
+
for (const a of t.answers) {
|
|
92
|
+
if (a.outcome === "matches" || a.outcome === "differs")
|
|
93
|
+
answered.add(a.gateway);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const verdict = rows.some((r) => r.mismatched > 0 || (r.witness_times ?? []).some((t) => t.outcome === "differs"))
|
|
98
|
+
? "failed"
|
|
99
|
+
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
100
|
+
? "undetermined"
|
|
101
|
+
: "confirmed";
|
|
102
|
+
return {
|
|
103
|
+
answered: gateways.filter((g) => answered.has(g)),
|
|
104
|
+
checked: true,
|
|
105
|
+
gateways: [...gateways],
|
|
106
|
+
packs: rows,
|
|
107
|
+
verdict,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// Distinct `[blockHeight, blockTime]` pairs, in time order.
|
|
111
|
+
function distinctTimes(times) {
|
|
112
|
+
const seen = new Map();
|
|
113
|
+
for (const [h, t] of times)
|
|
114
|
+
seen.set(`${h}:${t}`, [h, t]);
|
|
115
|
+
return [...seen.values()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
|
116
|
+
}
|
|
117
|
+
// Only the keys that carry something: a line with no cached or recorded time
|
|
118
|
+
// is the line 0.13.0 printed, field for field.
|
|
119
|
+
function withTimes(line, cached, recorded) {
|
|
120
|
+
return {
|
|
121
|
+
...line,
|
|
122
|
+
...(cached.length > 0 ? { cached } : {}),
|
|
123
|
+
...(recorded.length > 0 ? { recorded } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
|
|
128
|
+
* lookup ran, each is printed labelled not checked. Where one ran, each time
|
|
129
|
+
* the public record matched is printed as the record's, each time no lookup
|
|
130
|
+
* settled stays labelled not checked, and a time the record contradicts is the
|
|
131
|
+
* reason the line reads `differs`.
|
|
132
|
+
*
|
|
133
|
+
* Moved from `cli.ts` unchanged: same inputs, same three states
|
|
134
|
+
* (`WitnessState`), same rendering. The CLI's `case` command still calls this
|
|
135
|
+
* exact function, so its witness line is unchanged.
|
|
136
|
+
*/
|
|
137
|
+
export function witnessLine(onChain, cached = []) {
|
|
138
|
+
if (!onChain.checked) {
|
|
139
|
+
return withTimes({
|
|
140
|
+
state: WITNESS_NOT_LOOKED_UP.state,
|
|
141
|
+
reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
|
|
142
|
+
services: [...WITNESS_NOT_LOOKED_UP.services],
|
|
143
|
+
}, distinctTimes(cached.map((c) => [c.blockHeight, c.blockTime])), []);
|
|
144
|
+
}
|
|
145
|
+
const services = [...(onChain.answered ?? [])];
|
|
146
|
+
const rows = onChain.packs;
|
|
147
|
+
const times = rows.flatMap((r) => r.witness_times ?? []);
|
|
148
|
+
const unsettled = distinctTimes(times
|
|
149
|
+
.filter((t) => t.outcome === "not_checked")
|
|
150
|
+
.map((t) => [t.cached.block_height, t.cached.block_time]));
|
|
151
|
+
const recorded = distinctTimes(times
|
|
152
|
+
.filter((t) => t.outcome === "matches")
|
|
153
|
+
.flatMap((t) => t.answers.filter((a) => a.outcome === "matches"))
|
|
154
|
+
.map((a) => [a.block_height, a.block_time]));
|
|
155
|
+
const contradicted = [];
|
|
156
|
+
for (const t of times) {
|
|
157
|
+
if (t.outcome !== "differs")
|
|
158
|
+
continue;
|
|
159
|
+
const a = t.answers.find((x) => x.outcome === "differs");
|
|
160
|
+
const reason = `a pack dates the witness ${renderWitnessTime(t.cached.block_height, t.cached.block_time)}` +
|
|
161
|
+
`, and the public record dates it ${renderWitnessTime(a.block_height, a.block_time)}`;
|
|
162
|
+
if (!contradicted.includes(reason))
|
|
163
|
+
contradicted.push(reason);
|
|
164
|
+
}
|
|
165
|
+
if (onChain.verdict === "failed") {
|
|
166
|
+
return withTimes({ state: "differs", reasons: contradicted, services }, unsettled, recorded);
|
|
167
|
+
}
|
|
168
|
+
if (onChain.verdict === "confirmed") {
|
|
169
|
+
return withTimes({ state: "confirmed", reasons: [], services }, unsettled, recorded);
|
|
170
|
+
}
|
|
171
|
+
const reasons = [];
|
|
172
|
+
if (rows.some((r) => r.witnesses === 0))
|
|
173
|
+
reasons.push("a pack names no witness");
|
|
174
|
+
if (rows.some((r) => r.unreachable > 0)) {
|
|
175
|
+
reasons.push("a lookup did not settle: the service could not be reached, or it " + "holds no such witness yet");
|
|
176
|
+
}
|
|
177
|
+
if (rows.some((r) => r.not_checked > 0)) {
|
|
178
|
+
reasons.push("no lookup is made for a witness kind a pack names");
|
|
179
|
+
}
|
|
180
|
+
return withTimes({ state: "not_checked", reasons, services }, unsettled, recorded);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The public-record lookup the CLI runs with `--gateways`, exported so a
|
|
184
|
+
* consumer that is not the CLI can run the SAME lookup rather than copy it
|
|
185
|
+
* (proof#200; the copy this closes is verify-page#70's `src/lib/verify.ts`).
|
|
186
|
+
*
|
|
187
|
+
* `packs` is every pack in the drop being read; `gateways` is empty for an
|
|
188
|
+
* offline reading, which never makes a network call and always answers
|
|
189
|
+
* `not_checked` or `no_public_record`. `options.fetchImpl` is the one seam a
|
|
190
|
+
* network call goes through, a browser page supplies its own `fetchImpl`;
|
|
191
|
+
* with none given this falls back to the ambient global `fetch`, so a
|
|
192
|
+
* browser page and a test can each supply their own and Node's `fetch` is
|
|
193
|
+
* never assumed where a caller passed something else.
|
|
194
|
+
*
|
|
195
|
+
* `state` is `confirmed` only about the BYTES: the public record holds what a
|
|
196
|
+
* pack names. It says nothing about whether the pack itself verifies (its
|
|
197
|
+
* wrapper signature, its `body_hash`), and a caller must never show
|
|
198
|
+
* `confirmed` apart from the pack's own verdict (proof#201).
|
|
199
|
+
*
|
|
200
|
+
* Never throws. Every input, including a malformed pack, a non-string
|
|
201
|
+
* gateway, or a `fetchImpl` that itself misbehaves, is read as untrusted, and
|
|
202
|
+
* anything this function or what it calls cannot make sense of settles as
|
|
203
|
+
* `not_checked` rather than escape as an exception.
|
|
204
|
+
*/
|
|
205
|
+
export async function lookupPublicRecord(packs, gateways, options = {}) {
|
|
206
|
+
try {
|
|
207
|
+
// Nothing to look up: zero packs means the CALLER handed in nothing to
|
|
208
|
+
// check, which is not the same claim as "the packs it handed in name no
|
|
209
|
+
// witness" (`no_public_record`, below). Checked first, and unconditionally,
|
|
210
|
+
// so a caller than mishandles an empty drop never reads it as confirmed.
|
|
211
|
+
if (packs.length === 0) {
|
|
212
|
+
const reasons = [...WITNESS_NOT_LOOKED_UP.reasons];
|
|
213
|
+
return {
|
|
214
|
+
state: "not_checked",
|
|
215
|
+
reasons,
|
|
216
|
+
services: [],
|
|
217
|
+
rendered: renderWitnessLine({ state: "not_checked", reasons, services: [] }),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const caches = packs.map((p) => readWitnessCache(p.bundle, p.witnessCache ?? null));
|
|
221
|
+
const rows = packs.map((p, i) => [
|
|
222
|
+
p.ref,
|
|
223
|
+
p.bundle,
|
|
224
|
+
caches[i],
|
|
225
|
+
]);
|
|
226
|
+
const onChain = await onChainHalfForWitnessCount(rows, gateways, options);
|
|
227
|
+
const line = witnessLine(onChain.half, caches.flat());
|
|
228
|
+
// "No public record yet": every pack this lookup was handed names ZERO
|
|
229
|
+
// witnesses, so there is nothing the public record could confirm or
|
|
230
|
+
// contradict. This holds independently of whether a lookup ran, an
|
|
231
|
+
// offline reading of a fresh, unstamped pack is exactly as much "nothing to
|
|
232
|
+
// check yet" as an online one, and it is computed from the SAME witness
|
|
233
|
+
// count `onChainHalf` derives from `verifyEvidenceBundle`'s own parse, never
|
|
234
|
+
// a second reading of the bundle.
|
|
235
|
+
//
|
|
236
|
+
// A pack that does NOT verify (a broken wrapper signature, `bundle: null`,
|
|
237
|
+
// garbage, an unrecognised body type) also names zero witnesses, by
|
|
238
|
+
// construction: `verifyEvidenceBundle` never reports a checkpoint or a
|
|
239
|
+
// stamp for a bundle it could not authenticate. That is not "nothing to
|
|
240
|
+
// check yet", it is a pack this lookup could not read, so `no_public_record`
|
|
241
|
+
// is reserved for a pack whose wrapper genuinely verifies
|
|
242
|
+
// (`signatureOk && bodyHashOk && status !== "failed"`) and that carries no
|
|
243
|
+
// cached witness time either (a cache entry names something to check).
|
|
244
|
+
// Anything else that would otherwise qualify reads `not_checked` instead.
|
|
245
|
+
const noWitnesses = onChain.witnessCounts.every((n) => n === 0);
|
|
246
|
+
const everyPackVerifies = onChain.verifies.every((v) => v);
|
|
247
|
+
const noCacheRead = caches.every((c) => c.length === 0);
|
|
248
|
+
const canBeNoPublicRecord = noWitnesses && everyPackVerifies && noCacheRead;
|
|
249
|
+
const state = line.state === "not_checked" && canBeNoPublicRecord ? "no_public_record" : line.state;
|
|
250
|
+
return {
|
|
251
|
+
state,
|
|
252
|
+
reasons: line.reasons,
|
|
253
|
+
services: line.services,
|
|
254
|
+
...(line.cached ? { cached: line.cached } : {}),
|
|
255
|
+
// `recorded` is a claim about the public record confirming a cached
|
|
256
|
+
// time; it never rides alongside anything but a `confirmed` verdict, so
|
|
257
|
+
// a pack whose wrapper does not verify can never surface a cached time
|
|
258
|
+
// as though the record had confirmed it.
|
|
259
|
+
...(state === "confirmed" && line.recorded ? { recorded: line.recorded } : {}),
|
|
260
|
+
rendered: renderWitnessLine({ ...line, state: state === "no_public_record" ? "not_checked" : state }),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
// Untrusted input all the way down: a gateway's answer, a pack's shape, a
|
|
265
|
+
// caller's `fetchImpl`. Anything this function or what it calls cannot
|
|
266
|
+
// make sense of settles `not_checked` rather than throw out of an export a
|
|
267
|
+
// browser page calls directly.
|
|
268
|
+
const reason = e instanceof Error ? `the lookup did not settle: ${e.message}` : "the lookup did not settle";
|
|
269
|
+
const reasons = [reason];
|
|
270
|
+
return {
|
|
271
|
+
state: "not_checked",
|
|
272
|
+
reasons,
|
|
273
|
+
services: [],
|
|
274
|
+
rendered: renderWitnessLine({ state: "not_checked", reasons, services: [] }),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// `onChainHalf` short-circuits to `{ checked: false, packs: [] }` with no
|
|
279
|
+
// gateways, which is right for the CLI (nothing to print per pack) but leaves
|
|
280
|
+
// `lookupPublicRecord` unable to tell "no gateways" from "no witnesses" for an
|
|
281
|
+
// OFFLINE reading. This wrapper reads each pack's witness count, and whether
|
|
282
|
+
// its wrapper verifies, the same way `onChainHalf` does, via
|
|
283
|
+
// `verifyEvidenceBundle`: an OFFLINE parse (`gateways: []`), so it costs no
|
|
284
|
+
// extra request whether or not a lookup runs, and `verifies` is never itself
|
|
285
|
+
// a statement about the public record, only about the pack's own signature
|
|
286
|
+
// and `body_hash`.
|
|
287
|
+
async function onChainHalfForWitnessCount(packs, gateways, options) {
|
|
288
|
+
const witnessCounts = [];
|
|
289
|
+
const verifies = [];
|
|
290
|
+
for (const [, bundle] of packs) {
|
|
291
|
+
const result = await verifyEvidenceBundle(bundle, { gateways: [] });
|
|
292
|
+
witnessCounts.push(result.checkpoints.length + (result.stamps?.length ?? 0));
|
|
293
|
+
verifies.push(result.signatureOk && result.bodyHashOk && result.status !== "failed");
|
|
294
|
+
}
|
|
295
|
+
if (gateways.length > 0) {
|
|
296
|
+
const half = await onChainHalf(packs, gateways, options);
|
|
297
|
+
return { half, witnessCounts, verifies };
|
|
298
|
+
}
|
|
299
|
+
return { half: { checked: false, gateways: [], packs: [], verdict: "not_checked" }, witnessCounts, verifies };
|
|
300
|
+
}
|