@sanning/proof 0.12.0 → 0.14.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 +25 -12
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +353 -0
- package/dist/case.d.ts +217 -26
- package/dist/case.js +535 -124
- package/dist/cli.d.ts +64 -8
- package/dist/cli.js +799 -219
- package/dist/compose.js +6 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +38 -0
- package/dist/evidence.js +238 -22
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/index.d.ts +14 -7
- package/dist/index.js +20 -6
- package/dist/message-parts.d.ts +18 -10
- package/dist/message-parts.js +25 -15
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +63 -4
- package/dist/verifier.js +223 -15
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +422 -0
- package/src/case.ts +671 -131
- package/src/cli.ts +932 -217
- package/src/compose.ts +8 -1
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +257 -23
- package/src/exit-codes.ts +54 -0
- package/src/file-match.ts +461 -0
- package/src/grading.ts +109 -52
- package/src/index.ts +101 -2
- package/src/message-parts.ts +25 -15
- package/src/types.ts +5 -0
- package/src/verifier.ts +225 -16
- package/src/witness-cache.ts +306 -0
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +66 -8
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +20 -5
- package/vectors/witness-cache.json +1304 -0
package/dist/verifier.js
CHANGED
|
@@ -19,7 +19,7 @@ import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
|
19
19
|
// profile-specific dialect (no underscore strip, that is mlflow-only).
|
|
20
20
|
//
|
|
21
21
|
// Exported for parity with the sibling kernels' registries — Python
|
|
22
|
-
// `
|
|
22
|
+
// `ACCEPTED_SPEC_MAJORS`, Go `SupportedSpecMajor` — so the accept-set is
|
|
23
23
|
// assertable rather than inferable: test/accept-set.test.ts holds it to the
|
|
24
24
|
// ratified §4/§5 set transcribed in cross-kernel/accept-set.json, and the
|
|
25
25
|
// cross-kernel gate probes one signed envelope per identifier. This entry list
|
|
@@ -187,11 +187,13 @@ const JCS_BOM = "";
|
|
|
187
187
|
* twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
|
|
188
188
|
* either kernel that reads bytes a producer signed goes through one of the two.
|
|
189
189
|
*
|
|
190
|
-
* ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE.
|
|
190
|
+
* ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Five checks
|
|
191
191
|
* carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
|
|
192
192
|
* byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
|
|
193
193
|
* `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
|
|
194
|
-
* `parse_constant` hook),
|
|
194
|
+
* `parse_constant` hook), no object that repeats a member name
|
|
195
|
+
* (`DuplicateKeyError`, proof#144), and no unpaired surrogate in the parsed
|
|
196
|
+
* value.
|
|
195
197
|
*
|
|
196
198
|
* THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
|
|
197
199
|
* surrogate written as raw bytes; it cannot see one written as the ASCII
|
|
@@ -217,14 +219,152 @@ const JCS_BOM = "";
|
|
|
217
219
|
* Throws for anything that is not conformant; every call site treats a throw as
|
|
218
220
|
* "this reader cannot read these bytes" and names the check that failed.
|
|
219
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Bytes as strict UTF-8, a leading byte-order mark KEPT as a character.
|
|
224
|
+
* `utf8_strict` in the Python twin's `canonicalize.py` is the same rule, and
|
|
225
|
+
* `jcsParse` below performs the identical decode. Throws on bytes that are not
|
|
226
|
+
* UTF-8.
|
|
227
|
+
*/
|
|
228
|
+
export function utf8Strict(bytes) {
|
|
229
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
230
|
+
}
|
|
220
231
|
export function jcsParse(bytes) {
|
|
221
232
|
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
222
233
|
if (text.startsWith(JCS_BOM))
|
|
223
234
|
throw new Error("jcsParse: leading byte-order mark");
|
|
224
|
-
const value =
|
|
235
|
+
const value = parseRefusingDuplicates(text);
|
|
225
236
|
rejectUnpairedSurrogates(value);
|
|
226
237
|
return value;
|
|
227
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* A JSON object that names one member twice (I-JSON, RFC 7493 section 2.3).
|
|
241
|
+
* The Python twin is `DuplicateKey` in `canonicalize.py`, and the message is
|
|
242
|
+
* the same kernel wording in both: `duplicate key ` and the name as
|
|
243
|
+
* `renderKey` spells it.
|
|
244
|
+
*
|
|
245
|
+
* JCS (RFC 8785) is defined over I-JSON, and I-JSON forbids duplicate member
|
|
246
|
+
* names, so bytes that repeat a name are MALFORMED rather than another spelling
|
|
247
|
+
* of the last value. `JSON.parse` and Python's `json.loads` both keep the last
|
|
248
|
+
* value and say nothing, which let a bundle carrying `"spec_version":"junk"`
|
|
249
|
+
* before its real `spec_version` read `verified` in both kernels (proof#144).
|
|
250
|
+
*/
|
|
251
|
+
export class DuplicateKeyError extends Error {
|
|
252
|
+
key;
|
|
253
|
+
constructor(key) {
|
|
254
|
+
super(`duplicate key ${renderKey(key)}`);
|
|
255
|
+
this.key = key;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// The longest rendered name a message carries, in characters, before it is
|
|
259
|
+
// cut. The rendering is pure ASCII, so the cut lands on the same character in
|
|
260
|
+
// both kernels.
|
|
261
|
+
const RENDERED_KEY_MAX = 64;
|
|
262
|
+
/**
|
|
263
|
+
* A member name as a quoted, pure-ASCII string, identically in both kernels
|
|
264
|
+
* (`render_key` in the Python twin). Printable ASCII other than `"` and `\` is
|
|
265
|
+
* kept; `"` and `\` are backslash-escaped; every other UTF-16 code unit is
|
|
266
|
+
* `\uXXXX` in lower-case hex. A rendering longer than 64 characters is cut
|
|
267
|
+
* there and `...` follows the closing quote.
|
|
268
|
+
*/
|
|
269
|
+
export function renderKey(key) {
|
|
270
|
+
let body = "";
|
|
271
|
+
for (let i = 0; i < key.length; i++) {
|
|
272
|
+
const c = key.charCodeAt(i);
|
|
273
|
+
const ch = key[i];
|
|
274
|
+
if (ch === '"' || ch === "\\")
|
|
275
|
+
body += "\\" + ch;
|
|
276
|
+
else if (c >= 0x20 && c <= 0x7e)
|
|
277
|
+
body += ch;
|
|
278
|
+
else
|
|
279
|
+
body += "\\u" + c.toString(16).padStart(4, "0");
|
|
280
|
+
}
|
|
281
|
+
if (body.length > RENDERED_KEY_MAX)
|
|
282
|
+
return `"${body.slice(0, RENDERED_KEY_MAX)}"...`;
|
|
283
|
+
return `"${body}"`;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* `JSON.parse(text)`, refusing an object that repeats a member name. The Python
|
|
287
|
+
* twin is `loads_refusing_duplicates` in `canonicalize.py`.
|
|
288
|
+
*
|
|
289
|
+
* `JSON.parse` cannot see a repeat, because it keeps the last value, so the
|
|
290
|
+
* text is scanned once more AFTER it parsed. The parse runs first, so text
|
|
291
|
+
* that is not JSON at all fails as JSON and never as a duplicate, as it does in
|
|
292
|
+
* the Python twin. Throws `DuplicateKeyError`.
|
|
293
|
+
*/
|
|
294
|
+
export function parseRefusingDuplicates(text) {
|
|
295
|
+
const value = JSON.parse(text);
|
|
296
|
+
const dup = firstDuplicateKey(text);
|
|
297
|
+
if (dup !== undefined)
|
|
298
|
+
throw new DuplicateKeyError(dup);
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The repeated member name to report for text `JSON.parse` has already
|
|
303
|
+
* accepted, or `undefined` when no object repeats one.
|
|
304
|
+
*
|
|
305
|
+
* WHICH ONE, because both kernels must name the same one. Objects are checked
|
|
306
|
+
* as they CLOSE, innermost first, which is the order Python's
|
|
307
|
+
* `object_pairs_hook` sees them in; inside one object the name reported is the
|
|
308
|
+
* first whose repeat comes earliest. Names compare after unescaping, so `"a"`
|
|
309
|
+
* and `"\u0061"` are one name, as they are to both parsers.
|
|
310
|
+
*
|
|
311
|
+
* ITERATIVE, with an explicit stack, for the reason `rejectUnpairedSurrogates`
|
|
312
|
+
* is: this walks bytes a producer wrote. It relies on the text being valid
|
|
313
|
+
* JSON, which is why it runs only after `JSON.parse` succeeded: outside a
|
|
314
|
+
* string, only `{`, `[`, `}`, `]`, `,` and `"` change what comes next.
|
|
315
|
+
*/
|
|
316
|
+
export function firstDuplicateKey(text) {
|
|
317
|
+
// `names` is null for an array. `expectName` is true where the next string
|
|
318
|
+
// is a member name rather than a value.
|
|
319
|
+
const stack = [];
|
|
320
|
+
const n = text.length;
|
|
321
|
+
let i = 0;
|
|
322
|
+
while (i < n) {
|
|
323
|
+
const c = text.charCodeAt(i);
|
|
324
|
+
if (c === 0x22) {
|
|
325
|
+
let j = i + 1;
|
|
326
|
+
for (;;) {
|
|
327
|
+
const d = text.charCodeAt(j);
|
|
328
|
+
if (d === 0x5c)
|
|
329
|
+
j += 2;
|
|
330
|
+
else if (d === 0x22)
|
|
331
|
+
break;
|
|
332
|
+
else
|
|
333
|
+
j++;
|
|
334
|
+
}
|
|
335
|
+
const top = stack[stack.length - 1];
|
|
336
|
+
if (top !== undefined && top.names !== null && top.expectName) {
|
|
337
|
+
const name = JSON.parse(text.slice(i, j + 1));
|
|
338
|
+
if (top.names.has(name)) {
|
|
339
|
+
if (top.dup === undefined)
|
|
340
|
+
top.dup = name;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
top.names.add(name);
|
|
344
|
+
}
|
|
345
|
+
top.expectName = false;
|
|
346
|
+
}
|
|
347
|
+
i = j + 1;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (c === 0x7b)
|
|
351
|
+
stack.push({ names: new Set(), expectName: true });
|
|
352
|
+
else if (c === 0x5b)
|
|
353
|
+
stack.push({ names: null, expectName: false });
|
|
354
|
+
else if (c === 0x7d || c === 0x5d) {
|
|
355
|
+
const closed = stack.pop();
|
|
356
|
+
if (closed?.dup !== undefined)
|
|
357
|
+
return closed.dup;
|
|
358
|
+
}
|
|
359
|
+
else if (c === 0x2c) {
|
|
360
|
+
const top = stack[stack.length - 1];
|
|
361
|
+
if (top !== undefined && top.names !== null)
|
|
362
|
+
top.expectName = true;
|
|
363
|
+
}
|
|
364
|
+
i++;
|
|
365
|
+
}
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
228
368
|
/**
|
|
229
369
|
* Refuse a parsed value carrying an unpaired surrogate, in a key or a value, at
|
|
230
370
|
* any depth. The Python twin is `_reject_unpaired_surrogates`, and the two name
|
|
@@ -299,10 +439,14 @@ function hasLoneSurrogate(s) {
|
|
|
299
439
|
// `<major>.<minor>` where minor is `[0-9]+`. A non-numeric minor suffix is
|
|
300
440
|
// malformed, not a tolerated future version — matching the Python kernel's
|
|
301
441
|
// 0.1.1 semantics (the TS/Go fail-open lenience was closed deliberately).
|
|
302
|
-
|
|
442
|
+
//
|
|
443
|
+
// `accepted` NARROWS the registry and never widens it, as the Python twin's
|
|
444
|
+
// `spec_version_supported(spec_version, accepted)` does (P-10, 0.13.0).
|
|
445
|
+
export function specVersionSupported(specVersion, accepted) {
|
|
303
446
|
if (typeof specVersion !== "string" || specVersion === "")
|
|
304
447
|
return false;
|
|
305
|
-
|
|
448
|
+
const registry = accepted === undefined ? ACCEPTED_SPEC_MAJORS : ACCEPTED_SPEC_MAJORS.filter((m) => accepted.includes(m));
|
|
449
|
+
return registry.some((m) => {
|
|
306
450
|
if (specVersion === m)
|
|
307
451
|
return true;
|
|
308
452
|
if (!specVersion.startsWith(`${m}.`))
|
|
@@ -383,8 +527,69 @@ function skeletonFieldErrors(env) {
|
|
|
383
527
|
if (environment !== undefined && environment !== "dev" && environment !== "production") {
|
|
384
528
|
out.push('malformed environment: not "dev" or "production" (envelope-spec §2)');
|
|
385
529
|
}
|
|
530
|
+
out.push(...identityFieldErrors(own));
|
|
386
531
|
return out;
|
|
387
532
|
}
|
|
533
|
+
// The four fields envelope-spec §2 types and A23 pins (proof#97, proof#155).
|
|
534
|
+
// Every pattern is anchored at both ends with no `m` flag, so `$` never matches
|
|
535
|
+
// before a trailing newline, and uses ASCII classes only.
|
|
536
|
+
//
|
|
537
|
+
// `event_id`: "UUID v4, lowercase", the pattern both SDKs already refuse to
|
|
538
|
+
// sign anything else under (`UUID_V4_RE` in `@sanning/anchor`, `_UUID_V4_RE` in
|
|
539
|
+
// `sanning-anchor`); the Go agent's `uuid.NewString` emits the same.
|
|
540
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
541
|
+
// `payload_hash`: sha256-hex, which invariant 2 defines as lowercase hex with no
|
|
542
|
+
// `0x`. Lower case is pinned here, unlike `public_key`, because every kernel
|
|
543
|
+
// compares the value against a digest it recomputes in lower case: an
|
|
544
|
+
// upper-case `payload_hash` already failed wherever there was material.
|
|
545
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
546
|
+
// `payload_ref`: §2 says "string (URI)" and names no grammar, so this pins what
|
|
547
|
+
// both SDKs emit: an ASCII scheme, a colon, then RFC 3986 unreserved, reserved
|
|
548
|
+
// and percent-encoded characters, at most 2048 of them in all (`REF_RE` and
|
|
549
|
+
// `MAX_REF_LEN` in `@sanning/anchor`'s `ref.ts`).
|
|
550
|
+
const URI_REF = /^[A-Za-z][A-Za-z0-9+.-]*:[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]*$/;
|
|
551
|
+
const MAX_URI_REF = 2048;
|
|
552
|
+
// `subject`: "string fields <=128 chars matching `^[A-Za-z0-9_.:-]+$`".
|
|
553
|
+
const SUBJECT_FIELD = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
554
|
+
// `event_id`, `payload_hash`, `payload_ref` and `subject` (A23). Each is checked
|
|
555
|
+
// only when present, and present includes a JSON null, as for `signed_at`.
|
|
556
|
+
// `payload_hash` is the exception: only a STRING is checked here, because a
|
|
557
|
+
// missing or non-string one is already `missing payload_hash` below, and naming
|
|
558
|
+
// it twice would state one fact in two sets of words. `subject` fixes only its
|
|
559
|
+
// STRING members: §2 types no other, so a member of another type is left as it
|
|
560
|
+
// was. Own members only, as everywhere in this file. The messages are
|
|
561
|
+
// byte-identical to the Python and Go kernels'.
|
|
562
|
+
function identityFieldErrors(own) {
|
|
563
|
+
const out = [];
|
|
564
|
+
const eventId = own("event_id");
|
|
565
|
+
if (eventId !== undefined && !(typeof eventId === "string" && UUID_V4.test(eventId))) {
|
|
566
|
+
out.push("malformed event_id: not a lowercase UUID v4 (envelope-spec §2)");
|
|
567
|
+
}
|
|
568
|
+
const payloadHash = own("payload_hash");
|
|
569
|
+
if (typeof payloadHash === "string" && !SHA256_HEX.test(payloadHash)) {
|
|
570
|
+
out.push("malformed payload_hash: not 64 lowercase hex characters (envelope-spec §2)");
|
|
571
|
+
}
|
|
572
|
+
const payloadRef = own("payload_ref");
|
|
573
|
+
if (payloadRef !== undefined &&
|
|
574
|
+
!(typeof payloadRef === "string" && payloadRef.length <= MAX_URI_REF && URI_REF.test(payloadRef))) {
|
|
575
|
+
out.push("malformed payload_ref: not a URI of at most 2048 RFC 3986 characters (envelope-spec §2)");
|
|
576
|
+
}
|
|
577
|
+
const subject = own("subject");
|
|
578
|
+
if (subject !== undefined && !isWellFormedSubject(subject)) {
|
|
579
|
+
out.push("malformed subject: not an object whose string fields are 1 to 128 characters of [A-Za-z0-9_.:-] (envelope-spec §2)");
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
function isWellFormedSubject(subject) {
|
|
584
|
+
if (typeof subject !== "object" || subject === null || Array.isArray(subject))
|
|
585
|
+
return false;
|
|
586
|
+
for (const key of Object.keys(subject)) {
|
|
587
|
+
const v = subject[key];
|
|
588
|
+
if (typeof v === "string" && !SUBJECT_FIELD.test(v))
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
388
593
|
// The content hash(es) an envelope commits to, by event type — the values a
|
|
389
594
|
// reverse lookup can match the user's in-browser file hash against.
|
|
390
595
|
// asset_registered -> payload.hash (the registered, known-good bytes)
|
|
@@ -444,18 +649,21 @@ export function contentHashes(env) {
|
|
|
444
649
|
// signature, so stripping it to fake "external" — or injecting one to fake
|
|
445
650
|
// "inline" — breaks the signature).
|
|
446
651
|
//
|
|
447
|
-
// The second argument
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
652
|
+
// The second argument is a VerifyOptions object ({ payloadBytes,
|
|
653
|
+
// expectedContentHash, acceptedSpecMajors }). Until 0.13.0 it could also be a
|
|
654
|
+
// bare `expectedContentHash` string; that overload is gone (P-10), so the two
|
|
655
|
+
// kernels take one options shape: write `{ expectedContentHash: hash }`.
|
|
656
|
+
export async function verifyEnvelope(env, options) {
|
|
657
|
+
if (typeof options === "string") {
|
|
658
|
+
throw new TypeError("verifyEnvelope: pass { expectedContentHash } rather than a bare string (0.13.0)");
|
|
659
|
+
}
|
|
660
|
+
const opts = options ?? {};
|
|
453
661
|
// REFUSE an unknown option key rather than ignore it. `expected_content_hash`
|
|
454
662
|
// — the Python kernel's spelling — previously fell through as `undefined`, so
|
|
455
663
|
// the content bind silently never ran and the caller got a clean pass with no
|
|
456
664
|
// errors at all. A check that quietly does not happen is worse than one that
|
|
457
665
|
// fails: the caller believes they verified an artifact they never checked.
|
|
458
|
-
const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
|
|
666
|
+
const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash", "acceptedSpecMajors"]);
|
|
459
667
|
const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
|
|
460
668
|
if (unknownOptions.length > 0) {
|
|
461
669
|
throw new TypeError(`verifyEnvelope: unknown option(s) ${unknownOptions.join(", ")}. ` +
|
|
@@ -528,7 +736,7 @@ async function verifyEnvelopeChecked(env, opts) {
|
|
|
528
736
|
errors: ["envelope is not a JSON object"],
|
|
529
737
|
};
|
|
530
738
|
}
|
|
531
|
-
const specVersionOk = specVersionSupported(env.spec_version);
|
|
739
|
+
const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
|
|
532
740
|
if (!specVersionOk)
|
|
533
741
|
errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
|
|
534
742
|
// Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
|
|
@@ -598,7 +806,7 @@ async function verifyEnvelopeChecked(env, opts) {
|
|
|
598
806
|
let signatureOk = false;
|
|
599
807
|
try {
|
|
600
808
|
const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
|
|
601
|
-
signatureOk = await ed25519Verify(
|
|
809
|
+
signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
|
|
602
810
|
if (!signatureOk)
|
|
603
811
|
errors.push("Ed25519 signature verification failed");
|
|
604
812
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last second a block time can name and still be printed the same way by
|
|
3
|
+
* both kernels: 9999-12-31T23:59:59Z. A larger value is a cache entry this
|
|
4
|
+
* reader cannot print, and reads as none.
|
|
5
|
+
*/
|
|
6
|
+
export declare const MAX_BLOCK_TIME = 253402300799;
|
|
7
|
+
/** The file's name, beside `bundle.json` in the pack's folder (5.6.1). */
|
|
8
|
+
export declare const WITNESS_CACHE_NAME = "witness-cache.json";
|
|
9
|
+
/**
|
|
10
|
+
* The dispatch key: a file whose `cache_version` is absent or any other value
|
|
11
|
+
* reads as no cache.
|
|
12
|
+
*/
|
|
13
|
+
export declare const WITNESS_CACHE_VERSION = "sanning.witness-cache/v1";
|
|
14
|
+
/**
|
|
15
|
+
* How deep `[` and `{` may nest in the file before it is refused unread: the
|
|
16
|
+
* limit `bundle.json` and `contents.json` are read under.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MAX_WITNESS_CACHE_DEPTH = 512;
|
|
19
|
+
/**
|
|
20
|
+
* One `cache.witnessed[]` entry that reads, joined to the stamp it names.
|
|
21
|
+
* `witnessKind` and `witnessId` are the stamp's own `anchor_ref`, read from the
|
|
22
|
+
* signed body, never from the cache: the cache says WHEN, and the body alone
|
|
23
|
+
* says WHICH witness it is about.
|
|
24
|
+
*/
|
|
25
|
+
export interface CachedWitnessTime {
|
|
26
|
+
stampRef: string;
|
|
27
|
+
blockHeight: number;
|
|
28
|
+
blockTime: number;
|
|
29
|
+
witnessKind: string;
|
|
30
|
+
witnessId: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One gateway's answer for one cached time. `matches`: the gateway named the
|
|
34
|
+
* cached block height and block time. `differs`: it named a block, and the
|
|
35
|
+
* block is not the cached one. `unreachable`: it named no block (a transport
|
|
36
|
+
* failure, a refusal, an answer that is not one, or a witness not yet in a
|
|
37
|
+
* block).
|
|
38
|
+
*/
|
|
39
|
+
export interface WitnessTimeAnswer {
|
|
40
|
+
gateway: string;
|
|
41
|
+
outcome: "matches" | "differs" | "unreachable";
|
|
42
|
+
blockHeight: number | null;
|
|
43
|
+
blockTime: number | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One cached time, and what the public record said about it: `differs` when
|
|
47
|
+
* any gateway named a different block, otherwise `matches` when any gateway
|
|
48
|
+
* named the cached one, otherwise `not_checked`. The same worst-answer-wins
|
|
49
|
+
* order as the byte lookup.
|
|
50
|
+
*/
|
|
51
|
+
export interface WitnessTimeCheck {
|
|
52
|
+
cached: CachedWitnessTime;
|
|
53
|
+
outcome: "matches" | "differs" | "not_checked";
|
|
54
|
+
answers: WitnessTimeAnswer[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The cached witness times the `witness-cache.json` bytes `raw` give for the
|
|
58
|
+
* signed `bundle` beside them, in the order the file lists them. `raw` is
|
|
59
|
+
* `null` where the pack has no such file. Pure, and never throws. The file
|
|
60
|
+
* reads only where it is a JSON object whose `cache_version` is
|
|
61
|
+
* `WITNESS_CACHE_VERSION` and whose `witnessed` is a list. An entry reads only
|
|
62
|
+
* where it is an object whose `stamp_ref` is 64 lowercase hex characters naming
|
|
63
|
+
* exactly one stamp of the signed body that has an `anchor_ref`, whose
|
|
64
|
+
* `block_height` is a non-negative safe integer, and whose `block_time` is a
|
|
65
|
+
* whole number of seconds since 1970-01-01T00:00:00Z from 0 to
|
|
66
|
+
* `MAX_BLOCK_TIME`. A `stamp_ref` two entries name reads for neither.
|
|
67
|
+
* Everything else reads as no cache for the stamp it would have spoken for.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readWitnessCache(bundle: unknown, raw: Uint8Array | null | undefined): CachedWitnessTime[];
|
|
70
|
+
/** A block time as RFC 3339 UTC to the second, the way both kernels print it: `2026-09-08T12:10:07Z`. */
|
|
71
|
+
export declare function formatBlockTime(seconds: number): string;
|
|
72
|
+
/** `block 1996701 at 2026-09-08T12:10:07Z`. */
|
|
73
|
+
export declare function renderWitnessTime(blockHeight: number, blockTime: number): string;
|
|
74
|
+
/**
|
|
75
|
+
* The request body the lookup sends: one GraphQL query for one transaction's
|
|
76
|
+
* block, as JCS, so both kernels send the same bytes.
|
|
77
|
+
*/
|
|
78
|
+
export declare function witnessTimeQuery(witnessId: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* Look each cached time up on every gateway, in order. With no gateways or no
|
|
81
|
+
* `fetchImpl`, nothing is looked up and every time is `not_checked`. A witness
|
|
82
|
+
* that is not `arweave`, or whose id is not a 43-character base64url
|
|
83
|
+
* transaction id, is never looked up.
|
|
84
|
+
*/
|
|
85
|
+
export declare function checkWitnessCache(cached: readonly CachedWitnessTime[], gateways: readonly string[], fetchImpl: typeof fetch | undefined): Promise<WitnessTimeCheck[]>;
|
|
@@ -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
|
+
}
|