@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/index.d.ts CHANGED
@@ -2,16 +2,22 @@ export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyE
2
2
  export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
3
3
  export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
4
4
  export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
5
- export { ACCEPTED_EVIDENCE_MAJORS, ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
5
+ export { ACCEPTED_EVIDENCE_MAJORS, ANCHOR_TRACE_BODY_TYPE, CO_SIGNATURES_NOTE, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, coSignaturesPresent, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
6
6
  export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, StampEvidenceBody, StampEvidenceEvent, StampEvidenceInclusion, TraceCheckpoint, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
7
7
  export { PACK_GRADE_CAUSE_KINDS, PACK_VERDICTS, accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
8
8
  export type { CompletenessNote, DisclosureAccount, GradeInput, PackGrade, PackGradeCause, PackGradeCauseKind, PackVerdict, } from "./grading.js";
9
9
  export { DECLARATION_CONTENT_KINDS, DECLARATION_DEVIATION_KINDS, DECLARATION_NOTE_KINDS, DECLARATION_ROLES, DECLARATION_VERDICTS, DECLARATION_VERSION, DECLARATION_VERSION_V2, DECLARATION_VERSIONS, checkDeclaration, declarationClaims, packRecords, renderContentFinding, renderDeclarationSummary, renderDeviation, renderNote, } from "./declaration.js";
10
10
  export type { DeclarationCheck, DeclarationClaims, DeclarationContentFinding, DeclarationContentKind, DeclarationDeviation, DeclarationDeviationKind, DeclarationNote, DeclarationNoteKind, DeclarationRole, DeclarationVerdict, PackRecord, } from "./declaration.js";
11
- export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
11
+ export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_RELIANCE_HANDOFF_CLAUSE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, CO_SIGNATURES_PRESENT, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, WITNESS_TIME_CACHED, WITNESS_TIME_RECORDED, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
12
+ export { MAX_BLOCK_TIME, MAX_WITNESS_CACHE_DEPTH, WITNESS_CACHE_NAME, WITNESS_CACHE_VERSION, checkWitnessCache, formatBlockTime, readWitnessCache, renderWitnessTime, witnessTimeQuery, } from "./witness-cache.js";
13
+ export type { CachedWitnessTime, WitnessTimeAnswer, WitnessTimeCheck } from "./witness-cache.js";
14
+ export { lookupPublicRecord, onChainHalf, witnessLine } from "./witness-lookup.js";
15
+ export type { OnChainHalf, OnChainRow, PublicRecordPack, PublicRecordResult, PublicRecordState, WitnessLookupOptions, WitnessTimeRow, } from "./witness-lookup.js";
12
16
  export type { CaseException, CaseExceptionKind, CaseNote, CaseNoteKind, CasePack, CasePackReading, CaseReading, CaseRecordReading, CaseVerdict, WitnessLine, WitnessState, } from "./case.js";
13
17
  export { CASE_FILE_FINDING_KINDS, CASE_FILE_INDEX_NAME, CASE_FILE_VERSION, HOW_TO_VERIFY_NAME, REPORT_README_FIRST_LINE, howToVerifyText, isPackRef, readCaseFileIndex, renderCaseFileFinding, } from "./case-file.js";
14
18
  export type { CaseFileFinding, CaseFileFindingKind, CaseFileIndexReading, } from "./case-file.js";
19
+ export { FILE_MATCH_SCOPE, NOT_READ_REASONS, fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch, } from "./file-match.js";
20
+ export type { FileCommitment, FileMatchPack, FileMatchReading, MatchedFile, NotRead, NotReadReason, OfferedFile, UnmatchedFile, } from "./file-match.js";
15
21
  export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
16
22
  export type { ChainFinding, ChainFindingKind, ChainReading, ChainSource, ChainSummary, } from "./chain.js";
17
23
  export { EXIT_CODES, EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_MIXED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyI
34
34
  // files that failed verification on genuine evidence. This hands back DECODED
35
35
  // bytes, and only for events whose verification bound them to the committed
36
36
  // content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
37
- export { ACCEPTED_EVIDENCE_MAJORS, ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
37
+ export { ACCEPTED_EVIDENCE_MAJORS, ANCHOR_TRACE_BODY_TYPE, CO_SIGNATURES_NOTE, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, coSignaturesPresent, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
38
38
  // Pack grading (src/grading.ts): what a verification SURFACE may claim about a
39
39
  // pack — the signed bundle PLUS whatever disclosed bytes travelled beside it.
40
40
  //
@@ -85,11 +85,33 @@ export { DECLARATION_CONTENT_KINDS, DECLARATION_DEVIATION_KINDS, DECLARATION_NOT
85
85
  //
86
86
  // Pure: data in, data out, no I/O. Additive: a new module, no existing export
87
87
  // changes, and no new verdict word anywhere.
88
- export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
88
+ export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_RELIANCE_HANDOFF_CLAUSE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, CO_SIGNATURES_PRESENT, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, WITNESS_TIME_CACHED, WITNESS_TIME_RECORDED, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
89
+ // The witnessed-time cache (evidence-bundle 5.6.6, v1.25, A30): read it, look it
90
+ // up, print it. Never an input to a verdict.
91
+ export { MAX_BLOCK_TIME, MAX_WITNESS_CACHE_DEPTH, WITNESS_CACHE_NAME, WITNESS_CACHE_VERSION, checkWitnessCache, formatBlockTime, readWitnessCache, renderWitnessTime, witnessTimeQuery, } from "./witness-cache.js";
92
+ // The public-record (witness) lookup the CLI runs with `--gateways`
93
+ // (src/witness-lookup.ts, proof#200): moved out of the CLI so a consumer
94
+ // that is not the CLI, the verify page, calls the SAME implementation
95
+ // rather than keeping its own copy (verify-page#70). Pure kernel scope: the
96
+ // one network call lives in `lookupPublicRecord` and `onChainHalf`, runs
97
+ // only when a caller hands in a gateway, and goes through `options.fetchImpl`
98
+ // where a caller supplies one (a browser page's own `fetch`) or the ambient
99
+ // global `fetch` otherwise, so this export runs in a browser exactly as it
100
+ // runs in Node. `onChainHalf` and `witnessLine` are the CLI's own unchanged
101
+ // building blocks; `lookupPublicRecord` is the new one-call, four-state entry
102
+ // point a page wants, additive over both. `confirmed` from
103
+ // `lookupPublicRecord` is a claim about the BYTES only, never about whether
104
+ // the pack itself verifies, and a caller must never show it apart from the
105
+ // pack's own verdict.
106
+ export { lookupPublicRecord, onChainHalf, witnessLine } from "./witness-lookup.js";
89
107
  // The case file's index (src/case-file.ts, evidence-bundle.md §5.6, A25):
90
108
  // compared with the drop it describes, with NO verdict contribution. The index
91
109
  // is never an input to `readCase`. Pure: bytes and a file list in.
92
110
  export { CASE_FILE_FINDING_KINDS, CASE_FILE_INDEX_NAME, CASE_FILE_VERSION, HOW_TO_VERIFY_NAME, REPORT_README_FIRST_LINE, howToVerifyText, isPackRef, readCaseFileIndex, renderCaseFileFinding, } from "./case-file.js";
111
+ // Files handed in beside a pack (src/file-match.ts, evidence-bundle.md §5.7,
112
+ // A29, proof#92): each file's SHA-256 matched against the commitments the
113
+ // pack's SIGNED records make, by hash alone. Never moves a verdict.
114
+ export { FILE_MATCH_SCOPE, NOT_READ_REASONS, fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch, } from "./file-match.js";
93
115
  // Chain reading (src/chain.ts): following the pointer every sanning.events/v2
94
116
  // record carries to the record before it, over the records ONE bundle presents.
95
117
  //
@@ -118,12 +118,18 @@ export declare const REFUSAL_ORDER: readonly ["bytes_for_unknown_part", "conflic
118
118
  export declare const MESSAGE_RESULT_ORDER: readonly ["parts_not_an_array"];
119
119
  /**
120
120
  * The keys that make a part carry its content INLINE, in the order they are
121
- * read. Named rather than inferred: `bytes`, `data` and `content` are the
122
- * spellings in the wild. None is A2A 1.0.0's: its `Part` has no `kind` and
121
+ * read. Settled at `bytes` alone (envelope-spec §4.1, A31, proof#163): that
122
+ * is A2A's spelling, the one the wedge actually emits, and it is the only
123
+ * inline spelling either reference kernel has read from a real producer.
124
+ * `content` and `data` were added on inference rather than on an observed
125
+ * wire shape, and sanning-io/anchor's `filePart()` and `file_part()` SDK
126
+ * helpers, the only producer this plane ships, confirm the inference was
127
+ * wrong: both emit a descriptor part, `kind: "file"` plus `content_hash` and
128
+ * `content_length`, and neither ever writes a `content` or `data` wire key
129
+ * for inline bytes. None is A2A 1.0.0's either: its `Part` has no `kind` and
123
130
  * carries a file as `raw` or `url`, so a literal A2A part reads
124
- * `part_not_a_file` (envelope-spec §4.1, A19). The FIRST of them present with
125
- * a value that is not `null` is the inline content, so a part spelling it
126
- * twice reads one way in both kernels.
131
+ * `part_not_a_file` (envelope-spec §4.1, A19). The one key present with a
132
+ * value that is not `null` is the inline content.
127
133
  *
128
134
  * A key present with `null` is an ABSENT inline value, on A14's ruling that an
129
135
  * absent value and one written as `null` name one thing. That matters: a
@@ -132,7 +138,7 @@ export declare const MESSAGE_RESULT_ORDER: readonly ["parts_not_an_array"];
132
138
  * (proof#156 gate). Such a part falls through to the descriptor rule, which is
133
139
  * what it is.
134
140
  */
135
- export declare const INLINE_BYTES_KEYS: readonly ["bytes", "content", "data"];
141
+ export declare const INLINE_BYTES_KEYS: readonly ["bytes"];
136
142
  /**
137
143
  * The largest integer a double carries exactly, the same bound section 4.1 puts
138
144
  * on `event.sequence`: a `content_length` outside it is a value the two kernels
@@ -262,12 +262,18 @@ const HELD_UNCHECKED_RESULTS = [
262
262
  ];
263
263
  /**
264
264
  * The keys that make a part carry its content INLINE, in the order they are
265
- * read. Named rather than inferred: `bytes`, `data` and `content` are the
266
- * spellings in the wild. None is A2A 1.0.0's: its `Part` has no `kind` and
265
+ * read. Settled at `bytes` alone (envelope-spec §4.1, A31, proof#163): that
266
+ * is A2A's spelling, the one the wedge actually emits, and it is the only
267
+ * inline spelling either reference kernel has read from a real producer.
268
+ * `content` and `data` were added on inference rather than on an observed
269
+ * wire shape, and sanning-io/anchor's `filePart()` and `file_part()` SDK
270
+ * helpers, the only producer this plane ships, confirm the inference was
271
+ * wrong: both emit a descriptor part, `kind: "file"` plus `content_hash` and
272
+ * `content_length`, and neither ever writes a `content` or `data` wire key
273
+ * for inline bytes. None is A2A 1.0.0's either: its `Part` has no `kind` and
267
274
  * carries a file as `raw` or `url`, so a literal A2A part reads
268
- * `part_not_a_file` (envelope-spec §4.1, A19). The FIRST of them present with
269
- * a value that is not `null` is the inline content, so a part spelling it
270
- * twice reads one way in both kernels.
275
+ * `part_not_a_file` (envelope-spec §4.1, A19). The one key present with a
276
+ * value that is not `null` is the inline content.
271
277
  *
272
278
  * A key present with `null` is an ABSENT inline value, on A14's ruling that an
273
279
  * absent value and one written as `null` name one thing. That matters: a
@@ -276,11 +282,13 @@ const HELD_UNCHECKED_RESULTS = [
276
282
  * (proof#156 gate). Such a part falls through to the descriptor rule, which is
277
283
  * what it is.
278
284
  */
279
- // A conformant DESCRIPTOR part that also carries a `content` or `data` key
280
- // meaning something else (a filename, a metadata blob) is reclassified by this
281
- // list and never checked against supplied bytes. That is inside section 4.1's
282
- // stated rule, and whether these parts ought to fall into it is proof#163.
283
- export const INLINE_BYTES_KEYS = ["bytes", "content", "data"];
285
+ // Before A31 this list also carried `content` and `data`, and a conformant
286
+ // DESCRIPTOR part that happened to carry either key meaning something else (a
287
+ // filename, a caption) was silently reclassified as inline and never checked
288
+ // against supplied bytes: the swapped-bytes check A18 exists to run
289
+ // (proof#145) never ran on it. proof#163 is resolved by narrowing to `bytes`
290
+ // alone, closing that gap.
291
+ export const INLINE_BYTES_KEYS = ["bytes"];
284
292
  const HEX64 = /^[0-9a-fA-F]{64}$/;
285
293
  const HEX_CHARSET = /^[0-9a-fA-F]+$/;
286
294
  const LOWER_HEX = "0123456789abcdef";
@@ -8,11 +8,13 @@ export declare function jcs(value: unknown): string;
8
8
  * twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
9
9
  * either kernel that reads bytes a producer signed goes through one of the two.
10
10
  *
11
- * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Four checks
11
+ * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Five checks
12
12
  * carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
13
13
  * byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
14
14
  * `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
15
- * `parse_constant` hook), and no unpaired surrogate in the parsed value.
15
+ * `parse_constant` hook), no object that repeats a member name
16
+ * (`DuplicateKeyError`, proof#144), and no unpaired surrogate in the parsed
17
+ * value.
16
18
  *
17
19
  * THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
18
20
  * surrogate written as raw bytes; it cannot see one written as the ASCII
@@ -46,6 +48,56 @@ export declare function jcs(value: unknown): string;
46
48
  */
47
49
  export declare function utf8Strict(bytes: Uint8Array): string;
48
50
  export declare function jcsParse(bytes: Uint8Array): unknown;
51
+ /**
52
+ * A JSON object that names one member twice (I-JSON, RFC 7493 section 2.3).
53
+ * The Python twin is `DuplicateKey` in `canonicalize.py`, and the message is
54
+ * the same kernel wording in both: `duplicate key ` and the name as
55
+ * `renderKey` spells it.
56
+ *
57
+ * JCS (RFC 8785) is defined over I-JSON, and I-JSON forbids duplicate member
58
+ * names, so bytes that repeat a name are MALFORMED rather than another spelling
59
+ * of the last value. `JSON.parse` and Python's `json.loads` both keep the last
60
+ * value and say nothing, which let a bundle carrying `"spec_version":"junk"`
61
+ * before its real `spec_version` read `verified` in both kernels (proof#144).
62
+ */
63
+ export declare class DuplicateKeyError extends Error {
64
+ readonly key: string;
65
+ constructor(key: string);
66
+ }
67
+ /**
68
+ * A member name as a quoted, pure-ASCII string, identically in both kernels
69
+ * (`render_key` in the Python twin). Printable ASCII other than `"` and `\` is
70
+ * kept; `"` and `\` are backslash-escaped; every other UTF-16 code unit is
71
+ * `\uXXXX` in lower-case hex. A rendering longer than 64 characters is cut
72
+ * there and `...` follows the closing quote.
73
+ */
74
+ export declare function renderKey(key: string): string;
75
+ /**
76
+ * `JSON.parse(text)`, refusing an object that repeats a member name. The Python
77
+ * twin is `loads_refusing_duplicates` in `canonicalize.py`.
78
+ *
79
+ * `JSON.parse` cannot see a repeat, because it keeps the last value, so the
80
+ * text is scanned once more AFTER it parsed. The parse runs first, so text
81
+ * that is not JSON at all fails as JSON and never as a duplicate, as it does in
82
+ * the Python twin. Throws `DuplicateKeyError`.
83
+ */
84
+ export declare function parseRefusingDuplicates(text: string): unknown;
85
+ /**
86
+ * The repeated member name to report for text `JSON.parse` has already
87
+ * accepted, or `undefined` when no object repeats one.
88
+ *
89
+ * WHICH ONE, because both kernels must name the same one. Objects are checked
90
+ * as they CLOSE, innermost first, which is the order Python's
91
+ * `object_pairs_hook` sees them in; inside one object the name reported is the
92
+ * first whose repeat comes earliest. Names compare after unescaping, so `"a"`
93
+ * and `"\u0061"` are one name, as they are to both parsers.
94
+ *
95
+ * ITERATIVE, with an explicit stack, for the reason `rejectUnpairedSurrogates`
96
+ * is: this walks bytes a producer wrote. It relies on the text being valid
97
+ * JSON, which is why it runs only after `JSON.parse` succeeded: outside a
98
+ * string, only `{`, `[`, `}`, `]`, `,` and `"` change what comes next.
99
+ */
100
+ export declare function firstDuplicateKey(text: string): string | undefined;
49
101
  export declare function specVersionSupported(specVersion: string, accepted?: readonly string[]): boolean;
50
102
  export declare function isRfc3339UtcInstant(value: unknown): boolean;
51
103
  export declare function contentHashes(env: Envelope): {
package/dist/verifier.js CHANGED
@@ -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. Four checks
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), and no unpaired surrogate in the parsed value.
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
@@ -230,10 +232,139 @@ export function jcsParse(bytes) {
230
232
  const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
231
233
  if (text.startsWith(JCS_BOM))
232
234
  throw new Error("jcsParse: leading byte-order mark");
233
- const value = JSON.parse(text);
235
+ const value = parseRefusingDuplicates(text);
234
236
  rejectUnpairedSurrogates(value);
235
237
  return value;
236
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
+ }
237
368
  /**
238
369
  * Refuse a parsed value carrying an unpaired surrogate, in a key or a value, at
239
370
  * any depth. The Python twin is `_reject_unpaired_surrogates`, and the two name
@@ -396,8 +527,69 @@ function skeletonFieldErrors(env) {
396
527
  if (environment !== undefined && environment !== "dev" && environment !== "production") {
397
528
  out.push('malformed environment: not "dev" or "production" (envelope-spec §2)');
398
529
  }
530
+ out.push(...identityFieldErrors(own));
531
+ return out;
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
+ }
399
581
  return out;
400
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
+ }
401
593
  // The content hash(es) an envelope commits to, by event type — the values a
402
594
  // reverse lookup can match the user's in-browser file hash against.
403
595
  // asset_registered -> payload.hash (the registered, known-good bytes)
@@ -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[]>;