@sanning/proof 0.10.0 → 0.11.1
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 +4 -4
- package/dist/agent-proof.js +8 -2
- package/dist/case.d.ts +745 -0
- package/dist/case.js +1318 -0
- package/dist/chain.js +20 -1
- package/dist/cli.js +181 -27
- package/dist/crypto.d.ts +1 -0
- package/dist/crypto.js +29 -0
- package/dist/declaration.d.ts +284 -0
- package/dist/declaration.js +674 -0
- package/dist/evidence.js +296 -73
- package/dist/grading.d.ts +65 -4
- package/dist/grading.js +112 -6
- package/dist/handoff.d.ts +209 -0
- package/dist/handoff.js +617 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +50 -0
- package/dist/message-parts.d.ts +283 -0
- package/dist/message-parts.js +903 -0
- package/dist/verifier.d.ts +36 -0
- package/dist/verifier.js +150 -3
- package/package.json +1 -1
- package/src/agent-proof.ts +8 -2
- package/src/case.ts +1573 -0
- package/src/chain.ts +20 -1
- package/src/cli.ts +213 -29
- package/src/crypto.ts +28 -0
- package/src/declaration.ts +1005 -0
- package/src/evidence.ts +306 -71
- package/src/grading.ts +140 -6
- package/src/handoff.ts +694 -0
- package/src/index.ts +127 -0
- package/src/message-parts.ts +998 -0
- package/src/verifier.ts +150 -3
- package/vectors/README.md +36 -1
- package/vectors/jcs-reader.json +166 -0
- package/vectors/pack-grading.json +131 -3
package/dist/verifier.d.ts
CHANGED
|
@@ -3,6 +3,42 @@ export declare const ACCEPTED_SPEC_MAJORS: readonly string[];
|
|
|
3
3
|
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
|
+
/**
|
|
7
|
+
* Read bytes that claim to be `jcs(value)` (RFC 8785), strictly. The Python
|
|
8
|
+
* twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
|
|
9
|
+
* either kernel that reads bytes a producer signed goes through one of the two.
|
|
10
|
+
*
|
|
11
|
+
* ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Four checks
|
|
12
|
+
* carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
|
|
13
|
+
* byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
|
|
14
|
+
* `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
|
|
15
|
+
* `parse_constant` hook), and no unpaired surrogate in the parsed value.
|
|
16
|
+
*
|
|
17
|
+
* THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
|
|
18
|
+
* surrogate written as raw bytes; it cannot see one written as the ASCII
|
|
19
|
+
* escape `\ud800`, which `JSON.parse` turns into a lone code unit AFTER the
|
|
20
|
+
* decode has passed. `"c\ud800x"` is pure ASCII on the wire, so this reader
|
|
21
|
+
* accepted it while its own text above said it does not, and one poisoned
|
|
22
|
+
* record then deleted the Python kernel's whole chain reading (the gate on
|
|
23
|
+
* proof#154). Both spellings are the same malformed record, and both are
|
|
24
|
+
* refused here.
|
|
25
|
+
*
|
|
26
|
+
* `new TextDecoder()` alone is none of that. It replaces every invalid sequence
|
|
27
|
+
* with U+FFFD and strips a BOM. Python's `json.loads(<bytes>)` is not it
|
|
28
|
+
* either, in the opposite direction: CPython sniffs the encoding and decodes
|
|
29
|
+
* with `errors="surrogatepass"`, so it strips a UTF-8 BOM, accepts a UTF-16 or
|
|
30
|
+
* UTF-32 body, and passes a lone surrogate and a CESU-8 pair through. Both
|
|
31
|
+
* defaults have shipped a split verdict on one pack: a raw `0x80` inside a
|
|
32
|
+
* stamp body read `verified` here and `failed` there (gate 4), and then a
|
|
33
|
+
* `BOM + JCS(record)` checkpoint read `verified` there and `mixed` here (gate
|
|
34
|
+
* 5), both on proof#140. JCS is defined over UTF-8 and I-JSON (RFC 7493)
|
|
35
|
+
* forbids a lone surrogate, so each of those inputs is MALFORMED rather than
|
|
36
|
+
* another spelling of the same record.
|
|
37
|
+
*
|
|
38
|
+
* Throws for anything that is not conformant; every call site treats a throw as
|
|
39
|
+
* "this reader cannot read these bytes" and names the check that failed.
|
|
40
|
+
*/
|
|
41
|
+
export declare function jcsParse(bytes: Uint8Array): unknown;
|
|
6
42
|
export declare function specVersionSupported(specVersion: string): boolean;
|
|
7
43
|
export declare function isRfc3339UtcInstant(value: unknown): boolean;
|
|
8
44
|
export declare function contentHashes(env: Envelope): {
|
package/dist/verifier.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Conformance is enforced by test/conformance.test.ts, which runs every
|
|
8
8
|
// corpus test vector through this code and asserts byte-for-byte agreement.
|
|
9
9
|
import canonicalize from "canonicalize";
|
|
10
|
-
import { ed25519Verify, sha256Hex, utf8 } from "./crypto.js";
|
|
10
|
+
import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
11
11
|
// Fail-closed accepted-profile registry (envelope-spec §2): exactly the
|
|
12
12
|
// accepted profile majors, nothing else. Minors within an accepted
|
|
13
13
|
// major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
|
|
@@ -178,6 +178,85 @@ export function jcs(value) {
|
|
|
178
178
|
}
|
|
179
179
|
return canonical;
|
|
180
180
|
}
|
|
181
|
+
// The byte-order mark, as a CHARACTER. RFC 8785 output never begins with
|
|
182
|
+
// U+FEFF, so bytes that do are not conformant whatever a JSON reader makes of
|
|
183
|
+
// them.
|
|
184
|
+
const JCS_BOM = "";
|
|
185
|
+
/**
|
|
186
|
+
* Read bytes that claim to be `jcs(value)` (RFC 8785), strictly. The Python
|
|
187
|
+
* twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
|
|
188
|
+
* either kernel that reads bytes a producer signed goes through one of the two.
|
|
189
|
+
*
|
|
190
|
+
* ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Four checks
|
|
191
|
+
* carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
|
|
192
|
+
* byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
|
|
193
|
+
* `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
|
|
194
|
+
* `parse_constant` hook), and no unpaired surrogate in the parsed value.
|
|
195
|
+
*
|
|
196
|
+
* THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
|
|
197
|
+
* surrogate written as raw bytes; it cannot see one written as the ASCII
|
|
198
|
+
* escape `\ud800`, which `JSON.parse` turns into a lone code unit AFTER the
|
|
199
|
+
* decode has passed. `"c\ud800x"` is pure ASCII on the wire, so this reader
|
|
200
|
+
* accepted it while its own text above said it does not, and one poisoned
|
|
201
|
+
* record then deleted the Python kernel's whole chain reading (the gate on
|
|
202
|
+
* proof#154). Both spellings are the same malformed record, and both are
|
|
203
|
+
* refused here.
|
|
204
|
+
*
|
|
205
|
+
* `new TextDecoder()` alone is none of that. It replaces every invalid sequence
|
|
206
|
+
* with U+FFFD and strips a BOM. Python's `json.loads(<bytes>)` is not it
|
|
207
|
+
* either, in the opposite direction: CPython sniffs the encoding and decodes
|
|
208
|
+
* with `errors="surrogatepass"`, so it strips a UTF-8 BOM, accepts a UTF-16 or
|
|
209
|
+
* UTF-32 body, and passes a lone surrogate and a CESU-8 pair through. Both
|
|
210
|
+
* defaults have shipped a split verdict on one pack: a raw `0x80` inside a
|
|
211
|
+
* stamp body read `verified` here and `failed` there (gate 4), and then a
|
|
212
|
+
* `BOM + JCS(record)` checkpoint read `verified` there and `mixed` here (gate
|
|
213
|
+
* 5), both on proof#140. JCS is defined over UTF-8 and I-JSON (RFC 7493)
|
|
214
|
+
* forbids a lone surrogate, so each of those inputs is MALFORMED rather than
|
|
215
|
+
* another spelling of the same record.
|
|
216
|
+
*
|
|
217
|
+
* Throws for anything that is not conformant; every call site treats a throw as
|
|
218
|
+
* "this reader cannot read these bytes" and names the check that failed.
|
|
219
|
+
*/
|
|
220
|
+
export function jcsParse(bytes) {
|
|
221
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
222
|
+
if (text.startsWith(JCS_BOM))
|
|
223
|
+
throw new Error("jcsParse: leading byte-order mark");
|
|
224
|
+
const value = JSON.parse(text);
|
|
225
|
+
rejectUnpairedSurrogates(value);
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Refuse a parsed value carrying an unpaired surrogate, in a key or a value, at
|
|
230
|
+
* any depth. The Python twin is `_reject_unpaired_surrogates`, and the two name
|
|
231
|
+
* the failure with the same words.
|
|
232
|
+
*
|
|
233
|
+
* Deliberately ITERATIVE. `rejectLoneSurrogates` below walks a value this
|
|
234
|
+
* kernel is about to serialize, which `boundedInput` has already depth-bounded.
|
|
235
|
+
* This one walks whatever `JSON.parse` returned for a producer's bytes, with no
|
|
236
|
+
* bound in front of it, so a recursive walk would turn a deeply-nested record
|
|
237
|
+
* into a `RangeError` out of the reader itself.
|
|
238
|
+
*/
|
|
239
|
+
function rejectUnpairedSurrogates(value) {
|
|
240
|
+
const stack = [value];
|
|
241
|
+
while (stack.length > 0) {
|
|
242
|
+
const node = stack.pop();
|
|
243
|
+
if (typeof node === "string") {
|
|
244
|
+
if (hasLoneSurrogate(node))
|
|
245
|
+
throw new Error("jcsParse: unpaired surrogate");
|
|
246
|
+
}
|
|
247
|
+
else if (Array.isArray(node)) {
|
|
248
|
+
for (const child of node)
|
|
249
|
+
stack.push(child);
|
|
250
|
+
}
|
|
251
|
+
else if (node !== null && typeof node === "object") {
|
|
252
|
+
for (const [key, child] of Object.entries(node)) {
|
|
253
|
+
if (hasLoneSurrogate(key))
|
|
254
|
+
throw new Error("jcsParse: unpaired surrogate");
|
|
255
|
+
stack.push(child);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
181
260
|
// Walk every string in the value (keys and values). The check must run on
|
|
182
261
|
// the INPUT: `canonicalize` escapes a lone surrogate as `\udXXX` text in its
|
|
183
262
|
// output, so the malformed code unit is invisible after serialization.
|
|
@@ -268,11 +347,34 @@ export function isRfc3339UtcInstant(value) {
|
|
|
268
347
|
// present: §2 flags an absent `signed_at` as non-conformant rather than
|
|
269
348
|
// rejecting it, and `environment` is optional in the skeleton.
|
|
270
349
|
//
|
|
350
|
+
// `public_key` AND `signature` ARE CHECKED HERE TOO (proof#146, under
|
|
351
|
+
// proof#135 option B). The strict `hexToBytes` inside `ed25519Verify` is what
|
|
352
|
+
// makes the three kernels agree on the VERDICT; naming the spelling is what
|
|
353
|
+
// makes them agree on the REASON. On its own a respelled key reads `Ed25519
|
|
354
|
+
// signature verification failed`, which sends an auditor looking for a forged
|
|
355
|
+
// signature that is not there. proof#135 was opened against this function's
|
|
356
|
+
// Python twin, for covering `signed_at` and `environment` and not `public_key`.
|
|
357
|
+
//
|
|
358
|
+
// ONLY A STRING IS CHECKED, the same rule `envelopeHexError` follows one layer
|
|
359
|
+
// up: an absent or non-string `public_key` is already a failed signature in all
|
|
360
|
+
// three kernels (Go names it "missing signature or public_key"), and naming it
|
|
361
|
+
// here as a spelling would state one fact twice and in the wrong words. CASE IS
|
|
362
|
+
// NOT CHECKED: proof#135 pinned the charset and left the case open.
|
|
363
|
+
//
|
|
271
364
|
// Own properties only, because those are the members `jcs` signs over.
|
|
272
365
|
function skeletonFieldErrors(env) {
|
|
273
366
|
const fields = env;
|
|
274
367
|
const own = (k) => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
|
|
275
368
|
const out = [];
|
|
369
|
+
for (const [name, nibbles] of [
|
|
370
|
+
["public_key", 64],
|
|
371
|
+
["signature", 128],
|
|
372
|
+
]) {
|
|
373
|
+
const value = own(name);
|
|
374
|
+
if (typeof value === "string" && !isHexField(value, nibbles)) {
|
|
375
|
+
out.push(`malformed ${name}: not ${nibbles} hex characters (envelope-spec §2)`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
276
378
|
const signedAt = own("signed_at");
|
|
277
379
|
if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
|
|
278
380
|
out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
|
|
@@ -360,6 +462,41 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
360
462
|
`Did you mean ${[...KNOWN_VERIFY_OPTIONS].join(" / ")}? ` +
|
|
361
463
|
"Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.");
|
|
362
464
|
}
|
|
465
|
+
// ── A verifier never raises on an input (proof#85 item 3) ──
|
|
466
|
+
//
|
|
467
|
+
// The option check above is the one exception and stays OUTSIDE this try: an
|
|
468
|
+
// unknown option key is the CALLER's mistake, not the envelope's, and turning
|
|
469
|
+
// a typo into a verdict is how a check silently never runs.
|
|
470
|
+
//
|
|
471
|
+
// Below, every field read is a read of attacker-shaped data. `env` is typed
|
|
472
|
+
// as an `Envelope`, but nothing enforces that at runtime, and an object whose
|
|
473
|
+
// property reads throw (a `get`-trap `Proxy`, a getter that raises, an
|
|
474
|
+
// exotic object from another realm) passed the null/non-object guard and then
|
|
475
|
+
// escaped as a raised exception. An auditor cannot tell a raised exception
|
|
476
|
+
// apart from a broken tool, which is the one thing this kernel exists not to
|
|
477
|
+
// hand them. CLAUDE.md states the rule flatly: malformed envelopes return a
|
|
478
|
+
// failed `VerificationResult`, they do not throw. This closes the class, not
|
|
479
|
+
// the one `Proxy` case: #77 hardened the anchor-trace path for the same
|
|
480
|
+
// reason, and a per-input patch would have to be written again for the next
|
|
481
|
+
// exotic object.
|
|
482
|
+
try {
|
|
483
|
+
return await verifyEnvelopeChecked(env, opts);
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
specVersionOk: false,
|
|
489
|
+
// `null`, never `false`: nothing was read, so nothing was determined.
|
|
490
|
+
// The same rule the not-a-JSON-object branch below follows.
|
|
491
|
+
payloadHashOk: null,
|
|
492
|
+
signatureOk: false,
|
|
493
|
+
contentHashOk: opts.expectedContentHash === undefined ? null : false,
|
|
494
|
+
contentRole: null,
|
|
495
|
+
errors: [`envelope could not be read: ${stringifyErr(e)}`],
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function verifyEnvelopeChecked(env, opts) {
|
|
363
500
|
const expectedContentHash = opts.expectedContentHash;
|
|
364
501
|
const errors = [];
|
|
365
502
|
// Guard a malformed input (null / non-object / array) up front — every field
|
|
@@ -413,6 +550,16 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
413
550
|
// inline payload, supplied committed bytes, or both. With neither, the
|
|
414
551
|
// binding is undetermined (null): "signature-valid, semantics-undetermined"
|
|
415
552
|
// (§3.1/§6.2). null does not fail the verdict; absence does.
|
|
553
|
+
//
|
|
554
|
+
// THE ENVELOPE'S OWN `payload_hash` IS NOT ECHOED INTO THESE REASONS, and
|
|
555
|
+
// that is a change (proof#154 F4). It is producer-controlled text of any
|
|
556
|
+
// length, and it went into an auditor-facing error list verbatim: a producer
|
|
557
|
+
// could put a newline and a forged `payload_hash: OK envelope verified`
|
|
558
|
+
// line, or an ANSI escape that paints `VERIFIED` green, into what the
|
|
559
|
+
// verifier prints. No other field in this file echoes its value, the Python
|
|
560
|
+
// twin names the same failure without one, and the value is in the envelope
|
|
561
|
+
// the auditor already holds. The RECOMPUTED digest stays: this kernel
|
|
562
|
+
// produced it, so it is 64 characters of lowercase hex by construction.
|
|
416
563
|
let payloadHashOk;
|
|
417
564
|
if (typeof env.payload_hash !== "string") {
|
|
418
565
|
payloadHashOk = false;
|
|
@@ -425,7 +572,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
425
572
|
const recomputed = await sha256Hex(utf8(jcs(env.payload)));
|
|
426
573
|
checks.push(recomputed === env.payload_hash);
|
|
427
574
|
if (recomputed !== env.payload_hash) {
|
|
428
|
-
errors.push(`payload_hash mismatch: envelope
|
|
575
|
+
errors.push(`payload_hash mismatch: the envelope's payload_hash is not SHA-256(JCS(payload)), recomputed=${recomputed}`);
|
|
429
576
|
}
|
|
430
577
|
}
|
|
431
578
|
catch (e) {
|
|
@@ -437,7 +584,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
437
584
|
const external = await sha256Hex(opts.payloadBytes);
|
|
438
585
|
checks.push(external === env.payload_hash);
|
|
439
586
|
if (external !== env.payload_hash) {
|
|
440
|
-
errors.push(`payload_hash does not match the committed bytes:
|
|
587
|
+
errors.push(`payload_hash does not match the committed bytes: bytes=${external}`);
|
|
441
588
|
}
|
|
442
589
|
}
|
|
443
590
|
payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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 and sanning.events/v2. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/agent-proof.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { hexToBytes, utf8 } from "./crypto.js";
|
|
18
18
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
19
|
-
import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, verifyEnvelope } from "./verifier.js";
|
|
19
|
+
import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, jcsParse, verifyEnvelope } from "./verifier.js";
|
|
20
20
|
import type { Envelope } from "./types.js";
|
|
21
21
|
|
|
22
22
|
export const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
|
|
@@ -213,7 +213,13 @@ async function refetch(
|
|
|
213
213
|
const onChain = new Uint8Array(await resp.arrayBuffer());
|
|
214
214
|
if (toHex(onChain) === expectedHex) return { ok: true, unavailable: false };
|
|
215
215
|
try {
|
|
216
|
-
|
|
216
|
+
// `jcsParse`, matching `_probe_gateway` in
|
|
217
|
+
// `src/sanning_proof/evidence.py`. These bytes arrive from a gateway or
|
|
218
|
+
// a CDN in front of one, so a lenient decoder is reachable without any
|
|
219
|
+
// party building a pack: a CDN serving the envelope with a byte-order
|
|
220
|
+
// mark split this probe from the one in `evidence.ts`, and two honest
|
|
221
|
+
// readers of ONE transaction disagreed (proof#140 gate 5).
|
|
222
|
+
const parsed = jcsParse(onChain);
|
|
217
223
|
if (toHex(utf8(jcs(parsed))) === expectedHex) return { ok: true, unavailable: false };
|
|
218
224
|
} catch {
|
|
219
225
|
// fall through
|