@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/src/index.ts CHANGED
@@ -62,10 +62,12 @@ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions
62
62
  export {
63
63
  ACCEPTED_EVIDENCE_MAJORS,
64
64
  ANCHOR_TRACE_BODY_TYPE,
65
+ CO_SIGNATURES_NOTE,
65
66
  EXPORT_BODY_TYPE,
66
67
  STAMP_BODY_VERSION,
67
68
  STAMP_TRACE_BODY_TYPE,
68
69
  STAMP_EVIDENCE_BODY_TYPE,
70
+ coSignaturesPresent,
69
71
  disclosedContent,
70
72
  recomputeExportVerdict,
71
73
  verifyEvidenceBundle,
@@ -219,17 +221,61 @@ export {
219
221
  CASE_RECORD_PROFILES,
220
222
  CASE_NOTE_KINDS,
221
223
  CASE_RELIANCE,
224
+ CASE_RELIANCE_HANDOFF_CLAUSE,
222
225
  CASE_VERDICTS,
223
226
  CONTENT_WITHHELD_BY_DECLARATION,
227
+ CO_SIGNATURES_PRESENT,
224
228
  VERDICT_DISPLAY_TITLES,
225
229
  WITNESS_NOT_LOOKED_UP,
226
230
  WITNESS_STATES,
231
+ WITNESS_TIME_CACHED,
232
+ WITNESS_TIME_RECORDED,
233
+ caseReliance,
227
234
  readCase,
228
235
  renderCaseNote,
229
236
  renderCaseSummary,
230
237
  renderException,
231
238
  renderWitnessLine,
232
239
  } from "./case.js";
240
+ // The witnessed-time cache (evidence-bundle 5.6.6, v1.25, A30): read it, look it
241
+ // up, print it. Never an input to a verdict.
242
+ export {
243
+ MAX_BLOCK_TIME,
244
+ MAX_WITNESS_CACHE_DEPTH,
245
+ WITNESS_CACHE_NAME,
246
+ WITNESS_CACHE_VERSION,
247
+ checkWitnessCache,
248
+ formatBlockTime,
249
+ readWitnessCache,
250
+ renderWitnessTime,
251
+ witnessTimeQuery,
252
+ } from "./witness-cache.js";
253
+ export type { CachedWitnessTime, WitnessTimeAnswer, WitnessTimeCheck } from "./witness-cache.js";
254
+
255
+ // The public-record (witness) lookup the CLI runs with `--gateways`
256
+ // (src/witness-lookup.ts, proof#200): moved out of the CLI so a consumer
257
+ // that is not the CLI, the verify page, calls the SAME implementation
258
+ // rather than keeping its own copy (verify-page#70). Pure kernel scope: the
259
+ // one network call lives in `lookupPublicRecord` and `onChainHalf`, runs
260
+ // only when a caller hands in a gateway, and goes through `options.fetchImpl`
261
+ // where a caller supplies one (a browser page's own `fetch`) or the ambient
262
+ // global `fetch` otherwise, so this export runs in a browser exactly as it
263
+ // runs in Node. `onChainHalf` and `witnessLine` are the CLI's own unchanged
264
+ // building blocks; `lookupPublicRecord` is the new one-call, four-state entry
265
+ // point a page wants, additive over both. `confirmed` from
266
+ // `lookupPublicRecord` is a claim about the BYTES only, never about whether
267
+ // the pack itself verifies, and a caller must never show it apart from the
268
+ // pack's own verdict.
269
+ export { lookupPublicRecord, onChainHalf, witnessLine } from "./witness-lookup.js";
270
+ export type {
271
+ OnChainHalf,
272
+ OnChainRow,
273
+ PublicRecordPack,
274
+ PublicRecordResult,
275
+ PublicRecordState,
276
+ WitnessLookupOptions,
277
+ WitnessTimeRow,
278
+ } from "./witness-lookup.js";
233
279
  export type {
234
280
  CaseException,
235
281
  CaseExceptionKind,
@@ -264,6 +310,28 @@ export type {
264
310
  CaseFileIndexReading,
265
311
  } from "./case-file.js";
266
312
 
313
+ // Files handed in beside a pack (src/file-match.ts, evidence-bundle.md §5.7,
314
+ // A29, proof#92): each file's SHA-256 matched against the commitments the
315
+ // pack's SIGNED records make, by hash alone. Never moves a verdict.
316
+ export {
317
+ FILE_MATCH_SCOPE,
318
+ NOT_READ_REASONS,
319
+ fileMatchToJson,
320
+ matchCaseFiles,
321
+ matchFiles,
322
+ renderFileMatch,
323
+ } from "./file-match.js";
324
+ export type {
325
+ FileCommitment,
326
+ FileMatchPack,
327
+ FileMatchReading,
328
+ MatchedFile,
329
+ NotRead,
330
+ NotReadReason,
331
+ OfferedFile,
332
+ UnmatchedFile,
333
+ } from "./file-match.js";
334
+
267
335
  // Chain reading (src/chain.ts): following the pointer every sanning.events/v2
268
336
  // record carries to the record before it, over the records ONE bundle presents.
269
337
  //
@@ -278,12 +278,18 @@ const HELD_UNCHECKED_RESULTS: readonly string[] = [
278
278
 
279
279
  /**
280
280
  * The keys that make a part carry its content INLINE, in the order they are
281
- * read. Named rather than inferred: `bytes`, `data` and `content` are the
282
- * spellings in the wild. None is A2A 1.0.0's: its `Part` has no `kind` and
281
+ * read. Settled at `bytes` alone (envelope-spec §4.1, A31, proof#163): that
282
+ * is A2A's spelling, the one the wedge actually emits, and it is the only
283
+ * inline spelling either reference kernel has read from a real producer.
284
+ * `content` and `data` were added on inference rather than on an observed
285
+ * wire shape, and sanning-io/anchor's `filePart()` and `file_part()` SDK
286
+ * helpers, the only producer this plane ships, confirm the inference was
287
+ * wrong: both emit a descriptor part, `kind: "file"` plus `content_hash` and
288
+ * `content_length`, and neither ever writes a `content` or `data` wire key
289
+ * for inline bytes. None is A2A 1.0.0's either: its `Part` has no `kind` and
283
290
  * carries a file as `raw` or `url`, so a literal A2A part reads
284
- * `part_not_a_file` (envelope-spec §4.1, A19). The FIRST of them present with
285
- * a value that is not `null` is the inline content, so a part spelling it
286
- * twice reads one way in both kernels.
291
+ * `part_not_a_file` (envelope-spec §4.1, A19). The one key present with a
292
+ * value that is not `null` is the inline content.
287
293
  *
288
294
  * A key present with `null` is an ABSENT inline value, on A14's ruling that an
289
295
  * absent value and one written as `null` name one thing. That matters: a
@@ -292,11 +298,13 @@ const HELD_UNCHECKED_RESULTS: readonly string[] = [
292
298
  * (proof#156 gate). Such a part falls through to the descriptor rule, which is
293
299
  * what it is.
294
300
  */
295
- // A conformant DESCRIPTOR part that also carries a `content` or `data` key
296
- // meaning something else (a filename, a metadata blob) is reclassified by this
297
- // list and never checked against supplied bytes. That is inside section 4.1's
298
- // stated rule, and whether these parts ought to fall into it is proof#163.
299
- export const INLINE_BYTES_KEYS = ["bytes", "content", "data"] as const;
301
+ // Before A31 this list also carried `content` and `data`, and a conformant
302
+ // DESCRIPTOR part that happened to carry either key meaning something else (a
303
+ // filename, a caption) was silently reclassified as inline and never checked
304
+ // against supplied bytes: the swapped-bytes check A18 exists to run
305
+ // (proof#145) never ran on it. proof#163 is resolved by narrowing to `bytes`
306
+ // alone, closing that gap.
307
+ export const INLINE_BYTES_KEYS = ["bytes"] as const;
300
308
 
301
309
  const HEX64 = /^[0-9a-fA-F]{64}$/;
302
310
  const HEX_CHARSET = /^[0-9a-fA-F]+$/;
package/src/verifier.ts CHANGED
@@ -201,11 +201,13 @@ const JCS_BOM = "";
201
201
  * twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
202
202
  * either kernel that reads bytes a producer signed goes through one of the two.
203
203
  *
204
- * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Four checks
204
+ * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Five checks
205
205
  * carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
206
206
  * byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
207
207
  * `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
208
- * `parse_constant` hook), and no unpaired surrogate in the parsed value.
208
+ * `parse_constant` hook), no object that repeats a member name
209
+ * (`DuplicateKeyError`, proof#144), and no unpaired surrogate in the parsed
210
+ * value.
209
211
  *
210
212
  * THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
211
213
  * surrogate written as raw bytes; it cannot see one written as the ASCII
@@ -244,11 +246,130 @@ export function utf8Strict(bytes: Uint8Array): string {
244
246
  export function jcsParse(bytes: Uint8Array): unknown {
245
247
  const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
246
248
  if (text.startsWith(JCS_BOM)) throw new Error("jcsParse: leading byte-order mark");
247
- const value: unknown = JSON.parse(text);
249
+ const value: unknown = parseRefusingDuplicates(text);
248
250
  rejectUnpairedSurrogates(value);
249
251
  return value;
250
252
  }
251
253
 
254
+ /**
255
+ * A JSON object that names one member twice (I-JSON, RFC 7493 section 2.3).
256
+ * The Python twin is `DuplicateKey` in `canonicalize.py`, and the message is
257
+ * the same kernel wording in both: `duplicate key ` and the name as
258
+ * `renderKey` spells it.
259
+ *
260
+ * JCS (RFC 8785) is defined over I-JSON, and I-JSON forbids duplicate member
261
+ * names, so bytes that repeat a name are MALFORMED rather than another spelling
262
+ * of the last value. `JSON.parse` and Python's `json.loads` both keep the last
263
+ * value and say nothing, which let a bundle carrying `"spec_version":"junk"`
264
+ * before its real `spec_version` read `verified` in both kernels (proof#144).
265
+ */
266
+ export class DuplicateKeyError extends Error {
267
+ readonly key: string;
268
+ constructor(key: string) {
269
+ super(`duplicate key ${renderKey(key)}`);
270
+ this.key = key;
271
+ }
272
+ }
273
+
274
+ // The longest rendered name a message carries, in characters, before it is
275
+ // cut. The rendering is pure ASCII, so the cut lands on the same character in
276
+ // both kernels.
277
+ const RENDERED_KEY_MAX = 64;
278
+
279
+ /**
280
+ * A member name as a quoted, pure-ASCII string, identically in both kernels
281
+ * (`render_key` in the Python twin). Printable ASCII other than `"` and `\` is
282
+ * kept; `"` and `\` are backslash-escaped; every other UTF-16 code unit is
283
+ * `\uXXXX` in lower-case hex. A rendering longer than 64 characters is cut
284
+ * there and `...` follows the closing quote.
285
+ */
286
+ export function renderKey(key: string): string {
287
+ let body = "";
288
+ for (let i = 0; i < key.length; i++) {
289
+ const c = key.charCodeAt(i);
290
+ const ch = key[i]!;
291
+ if (ch === '"' || ch === "\\") body += "\\" + ch;
292
+ else if (c >= 0x20 && c <= 0x7e) body += ch;
293
+ else body += "\\u" + c.toString(16).padStart(4, "0");
294
+ }
295
+ if (body.length > RENDERED_KEY_MAX) return `"${body.slice(0, RENDERED_KEY_MAX)}"...`;
296
+ return `"${body}"`;
297
+ }
298
+
299
+ /**
300
+ * `JSON.parse(text)`, refusing an object that repeats a member name. The Python
301
+ * twin is `loads_refusing_duplicates` in `canonicalize.py`.
302
+ *
303
+ * `JSON.parse` cannot see a repeat, because it keeps the last value, so the
304
+ * text is scanned once more AFTER it parsed. The parse runs first, so text
305
+ * that is not JSON at all fails as JSON and never as a duplicate, as it does in
306
+ * the Python twin. Throws `DuplicateKeyError`.
307
+ */
308
+ export function parseRefusingDuplicates(text: string): unknown {
309
+ const value: unknown = JSON.parse(text);
310
+ const dup = firstDuplicateKey(text);
311
+ if (dup !== undefined) throw new DuplicateKeyError(dup);
312
+ return value;
313
+ }
314
+
315
+ /**
316
+ * The repeated member name to report for text `JSON.parse` has already
317
+ * accepted, or `undefined` when no object repeats one.
318
+ *
319
+ * WHICH ONE, because both kernels must name the same one. Objects are checked
320
+ * as they CLOSE, innermost first, which is the order Python's
321
+ * `object_pairs_hook` sees them in; inside one object the name reported is the
322
+ * first whose repeat comes earliest. Names compare after unescaping, so `"a"`
323
+ * and `"\u0061"` are one name, as they are to both parsers.
324
+ *
325
+ * ITERATIVE, with an explicit stack, for the reason `rejectUnpairedSurrogates`
326
+ * is: this walks bytes a producer wrote. It relies on the text being valid
327
+ * JSON, which is why it runs only after `JSON.parse` succeeded: outside a
328
+ * string, only `{`, `[`, `}`, `]`, `,` and `"` change what comes next.
329
+ */
330
+ export function firstDuplicateKey(text: string): string | undefined {
331
+ // `names` is null for an array. `expectName` is true where the next string
332
+ // is a member name rather than a value.
333
+ const stack: Array<{ names: Set<string> | null; expectName: boolean; dup?: string }> = [];
334
+ const n = text.length;
335
+ let i = 0;
336
+ while (i < n) {
337
+ const c = text.charCodeAt(i);
338
+ if (c === 0x22) {
339
+ let j = i + 1;
340
+ for (;;) {
341
+ const d = text.charCodeAt(j);
342
+ if (d === 0x5c) j += 2;
343
+ else if (d === 0x22) break;
344
+ else j++;
345
+ }
346
+ const top = stack[stack.length - 1];
347
+ if (top !== undefined && top.names !== null && top.expectName) {
348
+ const name = JSON.parse(text.slice(i, j + 1)) as string;
349
+ if (top.names.has(name)) {
350
+ if (top.dup === undefined) top.dup = name;
351
+ } else {
352
+ top.names.add(name);
353
+ }
354
+ top.expectName = false;
355
+ }
356
+ i = j + 1;
357
+ continue;
358
+ }
359
+ if (c === 0x7b) stack.push({ names: new Set(), expectName: true });
360
+ else if (c === 0x5b) stack.push({ names: null, expectName: false });
361
+ else if (c === 0x7d || c === 0x5d) {
362
+ const closed = stack.pop();
363
+ if (closed?.dup !== undefined) return closed.dup;
364
+ } else if (c === 0x2c) {
365
+ const top = stack[stack.length - 1];
366
+ if (top !== undefined && top.names !== null) top.expectName = true;
367
+ }
368
+ i++;
369
+ }
370
+ return undefined;
371
+ }
372
+
252
373
  /**
253
374
  * Refuse a parsed value carrying an unpaired surrogate, in a key or a value, at
254
375
  * any depth. The Python twin is `_reject_unpaired_surrogates`, and the two name
@@ -410,9 +531,75 @@ function skeletonFieldErrors(env: Envelope): string[] {
410
531
  if (environment !== undefined && environment !== "dev" && environment !== "production") {
411
532
  out.push('malformed environment: not "dev" or "production" (envelope-spec §2)');
412
533
  }
534
+ out.push(...identityFieldErrors(own));
535
+ return out;
536
+ }
537
+
538
+ // The four fields envelope-spec §2 types and A23 pins (proof#97, proof#155).
539
+ // Every pattern is anchored at both ends with no `m` flag, so `$` never matches
540
+ // before a trailing newline, and uses ASCII classes only.
541
+ //
542
+ // `event_id`: "UUID v4, lowercase", the pattern both SDKs already refuse to
543
+ // sign anything else under (`UUID_V4_RE` in `@sanning/anchor`, `_UUID_V4_RE` in
544
+ // `sanning-anchor`); the Go agent's `uuid.NewString` emits the same.
545
+ 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}$/;
546
+ // `payload_hash`: sha256-hex, which invariant 2 defines as lowercase hex with no
547
+ // `0x`. Lower case is pinned here, unlike `public_key`, because every kernel
548
+ // compares the value against a digest it recomputes in lower case: an
549
+ // upper-case `payload_hash` already failed wherever there was material.
550
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
551
+ // `payload_ref`: §2 says "string (URI)" and names no grammar, so this pins what
552
+ // both SDKs emit: an ASCII scheme, a colon, then RFC 3986 unreserved, reserved
553
+ // and percent-encoded characters, at most 2048 of them in all (`REF_RE` and
554
+ // `MAX_REF_LEN` in `@sanning/anchor`'s `ref.ts`).
555
+ const URI_REF = /^[A-Za-z][A-Za-z0-9+.-]*:[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]*$/;
556
+ const MAX_URI_REF = 2048;
557
+ // `subject`: "string fields <=128 chars matching `^[A-Za-z0-9_.:-]+$`".
558
+ const SUBJECT_FIELD = /^[A-Za-z0-9_.:-]{1,128}$/;
559
+
560
+ // `event_id`, `payload_hash`, `payload_ref` and `subject` (A23). Each is checked
561
+ // only when present, and present includes a JSON null, as for `signed_at`.
562
+ // `payload_hash` is the exception: only a STRING is checked here, because a
563
+ // missing or non-string one is already `missing payload_hash` below, and naming
564
+ // it twice would state one fact in two sets of words. `subject` fixes only its
565
+ // STRING members: §2 types no other, so a member of another type is left as it
566
+ // was. Own members only, as everywhere in this file. The messages are
567
+ // byte-identical to the Python and Go kernels'.
568
+ function identityFieldErrors(own: (k: string) => unknown): string[] {
569
+ const out: string[] = [];
570
+ const eventId = own("event_id");
571
+ if (eventId !== undefined && !(typeof eventId === "string" && UUID_V4.test(eventId))) {
572
+ out.push("malformed event_id: not a lowercase UUID v4 (envelope-spec §2)");
573
+ }
574
+ const payloadHash = own("payload_hash");
575
+ if (typeof payloadHash === "string" && !SHA256_HEX.test(payloadHash)) {
576
+ out.push("malformed payload_hash: not 64 lowercase hex characters (envelope-spec §2)");
577
+ }
578
+ const payloadRef = own("payload_ref");
579
+ if (
580
+ payloadRef !== undefined &&
581
+ !(typeof payloadRef === "string" && payloadRef.length <= MAX_URI_REF && URI_REF.test(payloadRef))
582
+ ) {
583
+ out.push("malformed payload_ref: not a URI of at most 2048 RFC 3986 characters (envelope-spec §2)");
584
+ }
585
+ const subject = own("subject");
586
+ if (subject !== undefined && !isWellFormedSubject(subject)) {
587
+ out.push(
588
+ "malformed subject: not an object whose string fields are 1 to 128 characters of [A-Za-z0-9_.:-] (envelope-spec §2)",
589
+ );
590
+ }
413
591
  return out;
414
592
  }
415
593
 
594
+ function isWellFormedSubject(subject: unknown): boolean {
595
+ if (typeof subject !== "object" || subject === null || Array.isArray(subject)) return false;
596
+ for (const key of Object.keys(subject)) {
597
+ const v = (subject as Record<string, unknown>)[key];
598
+ if (typeof v === "string" && !SUBJECT_FIELD.test(v)) return false;
599
+ }
600
+ return true;
601
+ }
602
+
416
603
  // The content hash(es) an envelope commits to, by event type — the values a
417
604
  // reverse lookup can match the user's in-browser file hash against.
418
605
  // asset_registered -> payload.hash (the registered, known-good bytes)
@@ -0,0 +1,306 @@
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
+
41
+ import { jsonDepthExceeds } from "./case-file.js";
42
+ import { jcs, jcsParse } from "./verifier.js";
43
+
44
+ /**
45
+ * The last second a block time can name and still be printed the same way by
46
+ * both kernels: 9999-12-31T23:59:59Z. A larger value is a cache entry this
47
+ * reader cannot print, and reads as none.
48
+ */
49
+ export const MAX_BLOCK_TIME = 253402300799;
50
+
51
+ /** The file's name, beside `bundle.json` in the pack's folder (5.6.1). */
52
+ export const WITNESS_CACHE_NAME = "witness-cache.json";
53
+
54
+ /**
55
+ * The dispatch key: a file whose `cache_version` is absent or any other value
56
+ * reads as no cache.
57
+ */
58
+ export const WITNESS_CACHE_VERSION = "sanning.witness-cache/v1";
59
+
60
+ /**
61
+ * How deep `[` and `{` may nest in the file before it is refused unread: the
62
+ * limit `bundle.json` and `contents.json` are read under.
63
+ */
64
+ export const MAX_WITNESS_CACHE_DEPTH = 512;
65
+
66
+ // The bodies that carry `stamps[]`, the only witnesses the cache speaks for.
67
+ const STAMP_BODIES = new Set(["sanning.stamp.trace/v1", "sanning.stamp.evidence/v2"]);
68
+ const STAMP_REF = /^[0-9a-f]{64}$/;
69
+ // The one witness kind a time is looked up for, and the only ids the lookup
70
+ // sends: the same rule the byte lookup applies (evidence-bundle 5.1.1, v1.6).
71
+ const ADAPTER_KIND = "arweave";
72
+ const ARWEAVE_ID = /^[A-Za-z0-9_-]{43}$/;
73
+ // A GraphQL answer to one query for one transaction is a few hundred bytes. A
74
+ // deeper answer is not one, and refusing it before any parser recurses keeps
75
+ // the two kernels on one reading of it.
76
+ const MAX_ANSWER_DEPTH = 16;
77
+
78
+ /**
79
+ * One `cache.witnessed[]` entry that reads, joined to the stamp it names.
80
+ * `witnessKind` and `witnessId` are the stamp's own `anchor_ref`, read from the
81
+ * signed body, never from the cache: the cache says WHEN, and the body alone
82
+ * says WHICH witness it is about.
83
+ */
84
+ export interface CachedWitnessTime {
85
+ stampRef: string;
86
+ blockHeight: number;
87
+ blockTime: number;
88
+ witnessKind: string;
89
+ witnessId: string;
90
+ }
91
+
92
+ /**
93
+ * One gateway's answer for one cached time. `matches`: the gateway named the
94
+ * cached block height and block time. `differs`: it named a block, and the
95
+ * block is not the cached one. `unreachable`: it named no block (a transport
96
+ * failure, a refusal, an answer that is not one, or a witness not yet in a
97
+ * block).
98
+ */
99
+ export interface WitnessTimeAnswer {
100
+ gateway: string;
101
+ outcome: "matches" | "differs" | "unreachable";
102
+ blockHeight: number | null;
103
+ blockTime: number | null;
104
+ }
105
+
106
+ /**
107
+ * One cached time, and what the public record said about it: `differs` when
108
+ * any gateway named a different block, otherwise `matches` when any gateway
109
+ * named the cached one, otherwise `not_checked`. The same worst-answer-wins
110
+ * order as the byte lookup.
111
+ */
112
+ export interface WitnessTimeCheck {
113
+ cached: CachedWitnessTime;
114
+ outcome: "matches" | "differs" | "not_checked";
115
+ answers: WitnessTimeAnswer[];
116
+ }
117
+
118
+ function isObject(v: unknown): v is Record<string, unknown> {
119
+ return v !== null && typeof v === "object" && !Array.isArray(v);
120
+ }
121
+
122
+ // A member the object itself holds, never one inherited from its prototype.
123
+ function own(o: Record<string, unknown>, key: string): unknown {
124
+ return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
125
+ }
126
+
127
+ function blockNumber(value: unknown, upper: number): number | null {
128
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) return null;
129
+ if (value < 0 || value > upper) return null;
130
+ return value;
131
+ }
132
+
133
+ // `stamp_ref` to `[kind, id]` for every stamp in the signed body that names a
134
+ // witness. A `stamp_ref` spelled on two stamps names neither.
135
+ function stampWitnesses(bundle: Record<string, unknown>): Map<string, [string, string]> {
136
+ const out = new Map<string, [string, string]>();
137
+ if (typeof bundle.body_type !== "string" || !STAMP_BODIES.has(bundle.body_type)) return out;
138
+ const body = bundle.body;
139
+ const stamps = isObject(body) ? body.stamps : undefined;
140
+ if (!Array.isArray(stamps)) return out;
141
+ const seen = new Set<string>();
142
+ for (const stamp of stamps) {
143
+ if (!isObject(stamp)) continue;
144
+ const ref = stamp.stamp_ref;
145
+ if (typeof ref !== "string" || !STAMP_REF.test(ref)) continue;
146
+ if (seen.has(ref)) {
147
+ out.delete(ref);
148
+ continue;
149
+ }
150
+ seen.add(ref);
151
+ const anchor = stamp.anchor_ref;
152
+ if (!isObject(anchor)) continue;
153
+ if (typeof anchor.kind === "string" && typeof anchor.id === "string") {
154
+ out.set(ref, [anchor.kind, anchor.id]);
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ // The file's JSON, read under the rule `contents.json` is read under (5.6.2):
161
+ // strict UTF-8, no byte-order mark, no `NaN` or `Infinity`, no member named
162
+ // twice, and nothing nested past `MAX_WITNESS_CACHE_DEPTH`. `undefined` for
163
+ // anything else.
164
+ function readFile(raw: Uint8Array | null | undefined): unknown {
165
+ if (!(raw instanceof Uint8Array)) return undefined;
166
+ if (jsonDepthExceeds(raw, MAX_WITNESS_CACHE_DEPTH)) return undefined;
167
+ try {
168
+ return jcsParse(raw);
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The cached witness times the `witness-cache.json` bytes `raw` give for the
176
+ * signed `bundle` beside them, in the order the file lists them. `raw` is
177
+ * `null` where the pack has no such file. Pure, and never throws. The file
178
+ * reads only where it is a JSON object whose `cache_version` is
179
+ * `WITNESS_CACHE_VERSION` and whose `witnessed` is a list. An entry reads only
180
+ * where it is an object whose `stamp_ref` is 64 lowercase hex characters naming
181
+ * exactly one stamp of the signed body that has an `anchor_ref`, whose
182
+ * `block_height` is a non-negative safe integer, and whose `block_time` is a
183
+ * whole number of seconds since 1970-01-01T00:00:00Z from 0 to
184
+ * `MAX_BLOCK_TIME`. A `stamp_ref` two entries name reads for neither.
185
+ * Everything else reads as no cache for the stamp it would have spoken for.
186
+ */
187
+ export function readWitnessCache(bundle: unknown, raw: Uint8Array | null | undefined): CachedWitnessTime[] {
188
+ if (!isObject(bundle)) return [];
189
+ const cache = readFile(raw);
190
+ if (!isObject(cache)) return [];
191
+ if (own(cache, "cache_version") !== WITNESS_CACHE_VERSION) return [];
192
+ const entries = own(cache, "witnessed");
193
+ if (!Array.isArray(entries)) return [];
194
+ const witnesses = stampWitnesses(bundle);
195
+ const read: CachedWitnessTime[] = [];
196
+ const named = new Map<string, number>();
197
+ for (const entry of entries) {
198
+ if (!isObject(entry)) continue;
199
+ const ref = own(entry, "stamp_ref");
200
+ if (typeof ref !== "string" || !witnesses.has(ref)) continue;
201
+ named.set(ref, (named.get(ref) ?? 0) + 1);
202
+ const height = blockNumber(own(entry, "block_height"), Number.MAX_SAFE_INTEGER);
203
+ const time = blockNumber(own(entry, "block_time"), MAX_BLOCK_TIME);
204
+ if (height === null || time === null) continue;
205
+ const [kind, id] = witnesses.get(ref)!;
206
+ read.push({ stampRef: ref, blockHeight: height, blockTime: time, witnessKind: kind, witnessId: id });
207
+ }
208
+ return read.filter((c) => named.get(c.stampRef) === 1);
209
+ }
210
+
211
+ /** A block time as RFC 3339 UTC to the second, the way both kernels print it: `2026-09-08T12:10:07Z`. */
212
+ export function formatBlockTime(seconds: number): string {
213
+ return new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
214
+ }
215
+
216
+ /** `block 1996701 at 2026-09-08T12:10:07Z`. */
217
+ export function renderWitnessTime(blockHeight: number, blockTime: number): string {
218
+ return `block ${blockHeight} at ${formatBlockTime(blockTime)}`;
219
+ }
220
+
221
+ /**
222
+ * The request body the lookup sends: one GraphQL query for one transaction's
223
+ * block, as JCS, so both kernels send the same bytes.
224
+ */
225
+ export function witnessTimeQuery(witnessId: string): string {
226
+ return jcs({ query: `query{transaction(id:"${witnessId}"){block{height timestamp}}}` });
227
+ }
228
+
229
+ function readAnswer(raw: Uint8Array): [number, number] | null {
230
+ if (jsonDepthExceeds(raw, MAX_ANSWER_DEPTH)) return null;
231
+ let node: unknown;
232
+ try {
233
+ node = jcsParse(raw);
234
+ } catch {
235
+ return null;
236
+ }
237
+ for (const key of ["data", "transaction", "block"]) {
238
+ if (!isObject(node)) return null;
239
+ node = node[key];
240
+ }
241
+ if (!isObject(node)) return null;
242
+ const height = blockNumber(node.height, Number.MAX_SAFE_INTEGER);
243
+ const time = blockNumber(node.timestamp, Number.MAX_SAFE_INTEGER);
244
+ if (height === null || time === null) return null;
245
+ return [height, time];
246
+ }
247
+
248
+ async function ask(
249
+ cached: CachedWitnessTime,
250
+ gateway: string,
251
+ fetchImpl: typeof fetch,
252
+ ): Promise<WitnessTimeAnswer> {
253
+ const unreachable: WitnessTimeAnswer = { gateway, outcome: "unreachable", blockHeight: null, blockTime: null };
254
+ const url = `${gateway.replace(/\/+$/, "")}/graphql`;
255
+ let raw: Uint8Array;
256
+ try {
257
+ const resp = await fetchImpl(url, {
258
+ method: "POST",
259
+ headers: { "content-type": "application/json" },
260
+ body: witnessTimeQuery(cached.witnessId),
261
+ });
262
+ if (!resp.ok) return unreachable;
263
+ raw = new Uint8Array(await resp.arrayBuffer());
264
+ } catch {
265
+ return unreachable;
266
+ }
267
+ const block = readAnswer(raw);
268
+ if (block === null) return unreachable;
269
+ const [height, time] = block;
270
+ const same = height === cached.blockHeight && time === cached.blockTime;
271
+ return { gateway, outcome: same ? "matches" : "differs", blockHeight: height, blockTime: time };
272
+ }
273
+
274
+ /**
275
+ * Look each cached time up on every gateway, in order. With no gateways or no
276
+ * `fetchImpl`, nothing is looked up and every time is `not_checked`. A witness
277
+ * that is not `arweave`, or whose id is not a 43-character base64url
278
+ * transaction id, is never looked up.
279
+ */
280
+ export async function checkWitnessCache(
281
+ cached: readonly CachedWitnessTime[],
282
+ gateways: readonly string[],
283
+ fetchImpl: typeof fetch | undefined,
284
+ ): Promise<WitnessTimeCheck[]> {
285
+ const out: WitnessTimeCheck[] = [];
286
+ for (const c of cached) {
287
+ if (
288
+ gateways.length === 0 ||
289
+ fetchImpl === undefined ||
290
+ c.witnessKind !== ADAPTER_KIND ||
291
+ !ARWEAVE_ID.test(c.witnessId)
292
+ ) {
293
+ out.push({ cached: c, outcome: "not_checked", answers: [] });
294
+ continue;
295
+ }
296
+ const answers: WitnessTimeAnswer[] = [];
297
+ for (const gw of gateways) answers.push(await ask(c, gw, fetchImpl));
298
+ const outcome = answers.some((a) => a.outcome === "differs")
299
+ ? "differs"
300
+ : answers.some((a) => a.outcome === "matches")
301
+ ? "matches"
302
+ : "not_checked";
303
+ out.push({ cached: c, outcome, answers });
304
+ }
305
+ return out;
306
+ }