@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 +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 +77 -3
- package/package.json +1 -1
- package/src/agent-proof.ts +3 -0
- package/src/evidence.ts +9 -0
- package/src/verifier.ts +79 -3
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
|
@@ -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.
|
|
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
|
@@ -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,
|