@sanning/proof 0.9.0 → 0.9.2

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 CHANGED
@@ -203,7 +203,7 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
203
203
 
204
204
  ## Why you can trust it (without trusting us)
205
205
 
206
- Three **independent implementations** of this kernel exist: this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and and the MIT-licensed Go reference. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against one shared corpus, pinned per file by SHA-256 at cut tag `test-vectors-v3.4`, and a cross-kernel agreement gate re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
206
+ Three **independent implementations** of this kernel exist: this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and and the MIT-licensed Go reference. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against one shared corpus, pinned per file by SHA-256 at cut tag `test-vectors-v3.6`, and a cross-kernel agreement gate re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
207
207
 
208
208
  That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path**, write your own verifier against the specs that ship inside the [`sanning-proof`](https://pypi.org/project/sanning-proof/) sdist, and the corpus will tell you if it is conformant.
209
209
 
@@ -77,6 +77,11 @@ export async function verifyAgentProofBundle(bundle, options = {}) {
77
77
  errors.push("checkpoint envelope signature failed");
78
78
  if (res.payloadHashOk !== true)
79
79
  errors.push("checkpoint envelope payload binding failed");
80
+ // A malformed skeleton field (proof#95) fails the envelope with a valid
81
+ // signature and binding; name it rather than fail with no reason.
82
+ for (const m of res.errors)
83
+ if (m.startsWith("malformed "))
84
+ errors.push(`checkpoint envelope ${m}`);
80
85
  const payload = (b.checkpoint_envelope.payload ?? {});
81
86
  if (typeof payload.merkle_root === "string")
82
87
  merkleRoot = payload.merkle_root;
package/dist/evidence.js CHANGED
@@ -466,6 +466,11 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
466
466
  }
467
467
  if (res.payloadHashOk === false)
468
468
  cpErrors.push("checkpoint record does not bind to payload_hash");
469
+ // proof#95: a malformed skeleton field fails the envelope with a valid
470
+ // signature and binding; name it rather than fail with no reason.
471
+ for (const m of res.errors)
472
+ if (m.startsWith("malformed "))
473
+ cpErrors.push(`checkpoint envelope ${m}`);
469
474
  // WHY the binding is undetermined decides which sentence is true.
470
475
  // record_bytes really absent is one thing; record_bytes present and
471
476
  // unbindable (a degenerate envelope, unreadable hex) is another, and
@@ -564,6 +569,11 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
564
569
  }
565
570
  if (res.payloadHashOk === false)
566
571
  evErrors.push("event record does not bind to payload_hash");
572
+ // A malformed skeleton field fails the envelope with a valid signature
573
+ // and binding (proof#95), so without this the event fails with no reason.
574
+ for (const m of res.errors)
575
+ if (m.startsWith("malformed "))
576
+ evErrors.push(`event envelope ${m}`);
567
577
  }
568
578
  catch (e) {
569
579
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -1234,6 +1244,11 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1234
1244
  }
1235
1245
  if (res.payloadHashOk === false)
1236
1246
  evErrors.push("event record does not bind to payload_hash");
1247
+ // A malformed skeleton field fails the envelope with a valid signature
1248
+ // and binding (proof#95), so without this the event fails with no reason.
1249
+ for (const m of res.errors)
1250
+ if (m.startsWith("malformed "))
1251
+ evErrors.push(`event envelope ${m}`);
1237
1252
  }
1238
1253
  catch (e) {
1239
1254
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -4,6 +4,7 @@ export declare const MAX_CANONICAL_DEPTH = 128;
4
4
  export declare function exceedsDepth(value: unknown, max: number): boolean;
5
5
  export declare function jcs(value: unknown): string;
6
6
  export declare function specVersionSupported(specVersion: string): boolean;
7
+ export declare function isRfc3339UtcInstant(value: unknown): boolean;
7
8
  export declare function contentHashes(env: Envelope): {
8
9
  role: ContentRole;
9
10
  hash: string;
package/dist/verifier.js CHANGED
@@ -93,11 +93,27 @@ function resolveToJSON(node, max) {
93
93
  // bound exists to prevent, reached through the bound (proof#71 review). Copying
94
94
  // what was measured is what makes check and use the same value. It also settles
95
95
  // a getter, which is read once here and never re-read.
96
+ //
97
+ // Every object copy has a NULL prototype, and that is load-bearing too. RFC
98
+ // 8785 has no reserved member names, and `JSON.parse` keeps `__proto__` as an
99
+ // ordinary own key. On a plain `{}`, `copy["__proto__"] = x` runs the setter
100
+ // inherited from `Object.prototype`: it replaces the copy's prototype and
101
+ // creates no key, so the member and everything under it vanished from the
102
+ // canonical bytes. Content there was covered by no hash and no signature, and
103
+ // this kernel disagreed with the Python and Go kernels on the same bytes
104
+ // (0.9.0; the same bug class as proof#31 and proof#34). With no prototype
105
+ // there is no setter, so every key, `__proto__` included, lands as an own data
106
+ // property. `canonicalize` reads the copy with `Object.keys` and `object[key]`
107
+ // and tests `toJSON instanceof Function`, none of which a prototype affects
108
+ // for JSON data, so no other input's output changes.
109
+ function emptyObject() {
110
+ return Object.create(null);
111
+ }
96
112
  function boundedInput(value, max) {
97
113
  const root = resolveToJSON(value, max);
98
114
  if (root === OVER_DEEP || root === null || typeof root !== "object")
99
115
  return root;
100
- const copy = Array.isArray(root) ? [] : {};
116
+ const copy = Array.isArray(root) ? [] : emptyObject();
101
117
  const stack = [[root, copy, 1]];
102
118
  while (stack.length > 0) {
103
119
  const [src, dst, depth] = stack.pop();
@@ -113,7 +129,7 @@ function boundedInput(value, max) {
113
129
  }
114
130
  if (depth + 1 > max)
115
131
  return OVER_DEEP; // a container nested past the bound
116
- const childCopy = Array.isArray(child) ? [] : {};
132
+ const childCopy = Array.isArray(child) ? [] : emptyObject();
117
133
  dst[key] = childCopy;
118
134
  stack.push([child, childCopy, depth + 1]);
119
135
  }
@@ -212,6 +228,57 @@ export function specVersionSupported(specVersion) {
212
228
  return minor.length > 0 && /^[0-9]+$/.test(minor);
213
229
  });
214
230
  }
231
+ // `signed_at` format (envelope-spec §2: "RFC 3339 UTC, `Z`"), proof#95. The
232
+ // accepted form is RFC 3339 `date-time` narrowed three ways, identically in
233
+ // every kernel:
234
+ // - the zone is an uppercase `Z`: never a numeric offset (not even
235
+ // `+00:00`), never a lowercase `z` or `t`;
236
+ // - the date is a real proleptic Gregorian date from 0001-01-01 to
237
+ // 9999-12-31, so February 29 only in a leap year;
238
+ // - second 60 is refused. RFC 3339 allows it at a real leap second, but
239
+ // which instants those were needs a table, and JavaScript's `Date`,
240
+ // Python's `datetime` and Go's `time` cannot hold one, so a verified `:60`
241
+ // would fail the first consumer that parses it.
242
+ // Fractional seconds of any length are allowed. Digits are ASCII `[0-9]`
243
+ // (Python's `\d` matches any Unicode digit, so the Python kernel spells it the
244
+ // same way).
245
+ const RFC3339_UTC = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?Z$/;
246
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
247
+ export function isRfc3339UtcInstant(value) {
248
+ if (typeof value !== "string")
249
+ return false;
250
+ const m = RFC3339_UTC.exec(value);
251
+ if (m === null)
252
+ return false;
253
+ const [year, month, day, hour, minute, second] = m.slice(1).map(Number);
254
+ if (year < 1 || month < 1 || month > 12 || day < 1)
255
+ return false;
256
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
257
+ const days = month === 2 && leap ? 29 : DAYS_IN_MONTH[month - 1];
258
+ return day <= days && hour <= 23 && minute <= 59 && second <= 59;
259
+ }
260
+ // The skeleton fields whose FORMAT §2 fixes and no other check reaches: a
261
+ // correctly signed envelope with `signed_at: "2026-02-30T00:00:00Z"` or
262
+ // `environment: "prod\u0000uction"` passes the signature and the payload
263
+ // binding, so without this it verified in full. Each is checked only when
264
+ // present: §2 flags an absent `signed_at` as non-conformant rather than
265
+ // rejecting it, and `environment` is optional in the skeleton.
266
+ //
267
+ // Own properties only, because those are the members `jcs` signs over.
268
+ function skeletonFieldErrors(env) {
269
+ const fields = env;
270
+ const own = (k) => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
271
+ const out = [];
272
+ const signedAt = own("signed_at");
273
+ if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
274
+ out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
275
+ }
276
+ const environment = own("environment");
277
+ if (environment !== undefined && environment !== "dev" && environment !== "production") {
278
+ out.push('malformed environment: not "dev" or "production" (envelope-spec §2)');
279
+ }
280
+ return out;
281
+ }
215
282
  // The content hash(es) an envelope commits to, by event type — the values a
216
283
  // reverse lookup can match the user's in-browser file hash against.
217
284
  // asset_registered -> payload.hash (the registered, known-good bytes)
@@ -323,6 +390,13 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
323
390
  const specVersionOk = specVersionSupported(env.spec_version);
324
391
  if (!specVersionOk)
325
392
  errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
393
+ // Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
394
+ // or `environment` that is not what §2 says fails the verdict. There is no
395
+ // result field for it, so the verdict shape is unchanged for every envelope
396
+ // the spec allows; the reason is in `errors`.
397
+ const fieldErrors = skeletonFieldErrors(env);
398
+ errors.push(...fieldErrors);
399
+ const fieldsOk = fieldErrors.length === 0;
326
400
  // Check 1 — Payload binding (both modes; see header).
327
401
  //
328
402
  // payload_hash MUST be present and a string in every mode — envelope-spec
@@ -423,7 +497,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
423
497
  //
424
498
  // Absence stays undetermined: `contentHashOk` is null when no hash was
425
499
  // supplied, and null must not fail (P-10 — absence is not failure).
426
- ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
500
+ ok: specVersionOk && fieldsOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
427
501
  specVersionOk,
428
502
  payloadHashOk,
429
503
  signatureOk,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -118,6 +118,9 @@ export async function verifyAgentProofBundle(
118
118
  checkpointOk = res.ok && res.payloadHashOk === true;
119
119
  if (!res.signatureOk) errors.push("checkpoint envelope signature failed");
120
120
  if (res.payloadHashOk !== true) errors.push("checkpoint envelope payload binding failed");
121
+ // A malformed skeleton field (proof#95) fails the envelope with a valid
122
+ // signature and binding; name it rather than fail with no reason.
123
+ for (const m of res.errors) if (m.startsWith("malformed ")) errors.push(`checkpoint envelope ${m}`);
121
124
  const payload = (b.checkpoint_envelope.payload ?? {}) as {
122
125
  merkle_root?: unknown;
123
126
  leaf_count?: unknown;
package/src/evidence.ts CHANGED
@@ -1082,6 +1082,9 @@ async function verifyAnchorTrace(
1082
1082
  cpErrors.push("checkpoint envelope signature failed");
1083
1083
  }
1084
1084
  if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
1085
+ // proof#95: a malformed skeleton field fails the envelope with a valid
1086
+ // signature and binding; name it rather than fail with no reason.
1087
+ for (const m of res.errors) if (m.startsWith("malformed ")) cpErrors.push(`checkpoint envelope ${m}`);
1085
1088
  // WHY the binding is undetermined decides which sentence is true.
1086
1089
  // record_bytes really absent is one thing; record_bytes present and
1087
1090
  // unbindable (a degenerate envelope, unreadable hex) is another, and
@@ -1187,6 +1190,9 @@ async function verifyAnchorTrace(
1187
1190
  evErrors.push("event envelope signature failed");
1188
1191
  }
1189
1192
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1193
+ // A malformed skeleton field fails the envelope with a valid signature
1194
+ // and binding (proof#95), so without this the event fails with no reason.
1195
+ for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
1190
1196
  } catch (e) {
1191
1197
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1192
1198
  }
@@ -1938,6 +1944,9 @@ async function verifyStampEvidence(
1938
1944
  evErrors.push("event envelope signature failed");
1939
1945
  }
1940
1946
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1947
+ // A malformed skeleton field fails the envelope with a valid signature
1948
+ // and binding (proof#95), so without this the event fails with no reason.
1949
+ for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
1941
1950
  } catch (e) {
1942
1951
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1943
1952
  }
package/src/verifier.ts CHANGED
@@ -103,10 +103,27 @@ function resolveToJSON(node: unknown, max: number): unknown {
103
103
  // bound exists to prevent, reached through the bound (proof#71 review). Copying
104
104
  // what was measured is what makes check and use the same value. It also settles
105
105
  // a getter, which is read once here and never re-read.
106
+ //
107
+ // Every object copy has a NULL prototype, and that is load-bearing too. RFC
108
+ // 8785 has no reserved member names, and `JSON.parse` keeps `__proto__` as an
109
+ // ordinary own key. On a plain `{}`, `copy["__proto__"] = x` runs the setter
110
+ // inherited from `Object.prototype`: it replaces the copy's prototype and
111
+ // creates no key, so the member and everything under it vanished from the
112
+ // canonical bytes. Content there was covered by no hash and no signature, and
113
+ // this kernel disagreed with the Python and Go kernels on the same bytes
114
+ // (0.9.0; the same bug class as proof#31 and proof#34). With no prototype
115
+ // there is no setter, so every key, `__proto__` included, lands as an own data
116
+ // property. `canonicalize` reads the copy with `Object.keys` and `object[key]`
117
+ // and tests `toJSON instanceof Function`, none of which a prototype affects
118
+ // for JSON data, so no other input's output changes.
119
+ function emptyObject(): Record<string, unknown> {
120
+ return Object.create(null) as Record<string, unknown>;
121
+ }
122
+
106
123
  function boundedInput(value: unknown, max: number): unknown {
107
124
  const root = resolveToJSON(value, max);
108
125
  if (root === OVER_DEEP || root === null || typeof root !== "object") return root;
109
- const copy: Record<string, unknown> | unknown[] = Array.isArray(root) ? [] : {};
126
+ const copy: Record<string, unknown> | unknown[] = Array.isArray(root) ? [] : emptyObject();
110
127
  const stack: Array<[object, Record<string, unknown> | unknown[], number]> = [[root, copy, 1]];
111
128
  while (stack.length > 0) {
112
129
  const [src, dst, depth] = stack.pop()!;
@@ -120,7 +137,7 @@ function boundedInput(value: unknown, max: number): unknown {
120
137
  continue;
121
138
  }
122
139
  if (depth + 1 > max) return OVER_DEEP; // a container nested past the bound
123
- const childCopy: Record<string, unknown> | unknown[] = Array.isArray(child) ? [] : {};
140
+ const childCopy: Record<string, unknown> | unknown[] = Array.isArray(child) ? [] : emptyObject();
124
141
  (dst as Record<string, unknown>)[key] = childCopy;
125
142
  stack.push([child, childCopy, depth + 1]);
126
143
  }
@@ -224,6 +241,57 @@ export function specVersionSupported(specVersion: string): boolean {
224
241
  });
225
242
  }
226
243
 
244
+ // `signed_at` format (envelope-spec §2: "RFC 3339 UTC, `Z`"), proof#95. The
245
+ // accepted form is RFC 3339 `date-time` narrowed three ways, identically in
246
+ // every kernel:
247
+ // - the zone is an uppercase `Z`: never a numeric offset (not even
248
+ // `+00:00`), never a lowercase `z` or `t`;
249
+ // - the date is a real proleptic Gregorian date from 0001-01-01 to
250
+ // 9999-12-31, so February 29 only in a leap year;
251
+ // - second 60 is refused. RFC 3339 allows it at a real leap second, but
252
+ // which instants those were needs a table, and JavaScript's `Date`,
253
+ // Python's `datetime` and Go's `time` cannot hold one, so a verified `:60`
254
+ // would fail the first consumer that parses it.
255
+ // Fractional seconds of any length are allowed. Digits are ASCII `[0-9]`
256
+ // (Python's `\d` matches any Unicode digit, so the Python kernel spells it the
257
+ // same way).
258
+ const RFC3339_UTC = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?Z$/;
259
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
260
+
261
+ export function isRfc3339UtcInstant(value: unknown): boolean {
262
+ if (typeof value !== "string") return false;
263
+ const m = RFC3339_UTC.exec(value);
264
+ if (m === null) return false;
265
+ const [year, month, day, hour, minute, second] = m.slice(1).map(Number);
266
+ if (year < 1 || month < 1 || month > 12 || day < 1) return false;
267
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
268
+ const days = month === 2 && leap ? 29 : DAYS_IN_MONTH[month - 1];
269
+ return day <= days && hour <= 23 && minute <= 59 && second <= 59;
270
+ }
271
+
272
+ // The skeleton fields whose FORMAT §2 fixes and no other check reaches: a
273
+ // correctly signed envelope with `signed_at: "2026-02-30T00:00:00Z"` or
274
+ // `environment: "prod\u0000uction"` passes the signature and the payload
275
+ // binding, so without this it verified in full. Each is checked only when
276
+ // present: §2 flags an absent `signed_at` as non-conformant rather than
277
+ // rejecting it, and `environment` is optional in the skeleton.
278
+ //
279
+ // Own properties only, because those are the members `jcs` signs over.
280
+ function skeletonFieldErrors(env: Envelope): string[] {
281
+ const fields = env as unknown as Record<string, unknown>;
282
+ const own = (k: string): unknown => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
283
+ const out: string[] = [];
284
+ const signedAt = own("signed_at");
285
+ if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
286
+ out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
287
+ }
288
+ const environment = own("environment");
289
+ if (environment !== undefined && environment !== "dev" && environment !== "production") {
290
+ out.push('malformed environment: not "dev" or "production" (envelope-spec §2)');
291
+ }
292
+ return out;
293
+ }
294
+
227
295
  // The content hash(es) an envelope commits to, by event type — the values a
228
296
  // reverse lookup can match the user's in-browser file hash against.
229
297
  // asset_registered -> payload.hash (the registered, known-good bytes)
@@ -345,6 +413,14 @@ export async function verifyEnvelope(
345
413
  const specVersionOk = specVersionSupported(env.spec_version);
346
414
  if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
347
415
 
416
+ // Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
417
+ // or `environment` that is not what §2 says fails the verdict. There is no
418
+ // result field for it, so the verdict shape is unchanged for every envelope
419
+ // the spec allows; the reason is in `errors`.
420
+ const fieldErrors = skeletonFieldErrors(env);
421
+ errors.push(...fieldErrors);
422
+ const fieldsOk = fieldErrors.length === 0;
423
+
348
424
  // Check 1 — Payload binding (both modes; see header).
349
425
  //
350
426
  // payload_hash MUST be present and a string in every mode — envelope-spec
@@ -449,7 +525,7 @@ export async function verifyEnvelope(
449
525
  //
450
526
  // Absence stays undetermined: `contentHashOk` is null when no hash was
451
527
  // supplied, and null must not fail (P-10 — absence is not failure).
452
- ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
528
+ ok: specVersionOk && fieldsOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
453
529
  specVersionOk,
454
530
  payloadHashOk,
455
531
  signatureOk,