@sanning/proof 0.9.1 → 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 +1 -1
- package/dist/agent-proof.js +5 -0
- package/dist/evidence.js +15 -0
- package/dist/verifier.d.ts +1 -0
- package/dist/verifier.js +59 -1
- package/package.json +1 -1
- package/src/agent-proof.ts +3 -0
- package/src/evidence.ts +9 -0
- package/src/verifier.ts +60 -1
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.
|
|
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
|
|
package/dist/agent-proof.js
CHANGED
|
@@ -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)}`);
|
package/dist/verifier.d.ts
CHANGED
|
@@ -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
|
@@ -228,6 +228,57 @@ export function specVersionSupported(specVersion) {
|
|
|
228
228
|
return minor.length > 0 && /^[0-9]+$/.test(minor);
|
|
229
229
|
});
|
|
230
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
|
+
}
|
|
231
282
|
// The content hash(es) an envelope commits to, by event type — the values a
|
|
232
283
|
// reverse lookup can match the user's in-browser file hash against.
|
|
233
284
|
// asset_registered -> payload.hash (the registered, known-good bytes)
|
|
@@ -339,6 +390,13 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
339
390
|
const specVersionOk = specVersionSupported(env.spec_version);
|
|
340
391
|
if (!specVersionOk)
|
|
341
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;
|
|
342
400
|
// Check 1 — Payload binding (both modes; see header).
|
|
343
401
|
//
|
|
344
402
|
// payload_hash MUST be present and a string in every mode — envelope-spec
|
|
@@ -439,7 +497,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
439
497
|
//
|
|
440
498
|
// Absence stays undetermined: `contentHashOk` is null when no hash was
|
|
441
499
|
// supplied, and null must not fail (P-10 — absence is not failure).
|
|
442
|
-
ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
|
|
500
|
+
ok: specVersionOk && fieldsOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
|
|
443
501
|
specVersionOk,
|
|
444
502
|
payloadHashOk,
|
|
445
503
|
signatureOk,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.9.
|
|
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",
|
package/src/agent-proof.ts
CHANGED
|
@@ -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
|
@@ -241,6 +241,57 @@ export function specVersionSupported(specVersion: string): boolean {
|
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
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
|
+
|
|
244
295
|
// The content hash(es) an envelope commits to, by event type — the values a
|
|
245
296
|
// reverse lookup can match the user's in-browser file hash against.
|
|
246
297
|
// asset_registered -> payload.hash (the registered, known-good bytes)
|
|
@@ -362,6 +413,14 @@ export async function verifyEnvelope(
|
|
|
362
413
|
const specVersionOk = specVersionSupported(env.spec_version);
|
|
363
414
|
if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
|
|
364
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
|
+
|
|
365
424
|
// Check 1 — Payload binding (both modes; see header).
|
|
366
425
|
//
|
|
367
426
|
// payload_hash MUST be present and a string in every mode — envelope-spec
|
|
@@ -466,7 +525,7 @@ export async function verifyEnvelope(
|
|
|
466
525
|
//
|
|
467
526
|
// Absence stays undetermined: `contentHashOk` is null when no hash was
|
|
468
527
|
// supplied, and null must not fail (P-10 — absence is not failure).
|
|
469
|
-
ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
|
|
528
|
+
ok: specVersionOk && fieldsOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
|
|
470
529
|
specVersionOk,
|
|
471
530
|
payloadHashOk,
|
|
472
531
|
signatureOk,
|