@sanning/proof 0.11.1 → 0.13.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/README.md +43 -8
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +349 -0
- package/dist/case.d.ts +199 -43
- package/dist/case.js +522 -146
- package/dist/cli.d.ts +132 -1
- package/dist/cli.js +1140 -96
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.js +134 -19
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/handoff.d.ts +16 -1
- package/dist/handoff.js +91 -16
- package/dist/index.d.ts +10 -7
- package/dist/index.js +15 -7
- package/dist/message-parts.d.ts +11 -9
- package/dist/message-parts.js +11 -9
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +9 -2
- package/dist/verifier.js +28 -12
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +419 -0
- package/src/case.ts +651 -156
- package/src/cli.ts +1325 -100
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +142 -21
- package/src/exit-codes.ts +54 -0
- package/src/grading.ts +109 -52
- package/src/handoff.ts +108 -17
- package/src/index.ts +60 -3
- package/src/message-parts.ts +11 -9
- package/src/types.ts +5 -0
- package/src/verifier.ts +35 -13
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +56 -3
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +1102 -0
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +3 -3
package/src/evidence.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
utf8,
|
|
30
30
|
verifyRsaPssSha256,
|
|
31
31
|
} from "./crypto.js";
|
|
32
|
+
import { isConformantHandoffRecord, isHandoffNamespace, type HeldRecord } from "./handoff.js";
|
|
32
33
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
33
34
|
import {
|
|
34
35
|
MAX_CANONICAL_DEPTH,
|
|
@@ -48,7 +49,8 @@ import type { Envelope } from "./types.js";
|
|
|
48
49
|
// Only the evidence-bundle major(s) this kernel verifies. A new major is a
|
|
49
50
|
// deliberate one-entry addition HERE — same fail-closed discipline as the
|
|
50
51
|
// envelope verifier's ACCEPTED_SPEC_MAJORS.
|
|
51
|
-
|
|
52
|
+
/** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
|
|
53
|
+
export const ACCEPTED_EVIDENCE_MAJORS: readonly string[] = Object.freeze(["sanning.evidence/v1"]);
|
|
52
54
|
|
|
53
55
|
// The body_type this module knows how to verify structurally. An unknown
|
|
54
56
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
@@ -830,12 +832,16 @@ async function verifyBundle(
|
|
|
830
832
|
const b = bundle as EvidenceBundle;
|
|
831
833
|
const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
|
|
832
834
|
if (!specVersionOk) {
|
|
833
|
-
|
|
835
|
+
// `?? null`: an ABSENT field is spelled `null`, as the Python twin's
|
|
836
|
+
// `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
|
|
837
|
+
// the two kernels printed two different sentences for one bundle until
|
|
838
|
+
// `verify --json` compared them byte for byte (0.13.0).
|
|
839
|
+
return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
|
|
834
840
|
}
|
|
835
841
|
if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
|
|
836
842
|
// The reference verifier implements ed25519 only (evidence-bundle §2 +
|
|
837
843
|
// decision #2); other algs are a deliberate future addition.
|
|
838
|
-
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
|
|
844
|
+
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
|
|
839
845
|
}
|
|
840
846
|
if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
|
|
841
847
|
return malformed("evidence bundle is missing public_key/signature", true);
|
|
@@ -885,7 +891,7 @@ async function verifyBundle(
|
|
|
885
891
|
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
|
|
886
892
|
co_signatures?: unknown;
|
|
887
893
|
};
|
|
888
|
-
signatureOk = await ed25519Verify(
|
|
894
|
+
signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
|
|
889
895
|
if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
|
|
890
896
|
} catch (e) {
|
|
891
897
|
errors.push(`wrapper signature verification error${thrownDetail(e)}`);
|
|
@@ -1135,7 +1141,7 @@ function parseAnchorRef(
|
|
|
1135
1141
|
}
|
|
1136
1142
|
const o = raw as Record<string, unknown>;
|
|
1137
1143
|
if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
|
|
1138
|
-
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
|
|
1144
|
+
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind ?? null)}`);
|
|
1139
1145
|
return { ref: null, malformed: true };
|
|
1140
1146
|
}
|
|
1141
1147
|
if (typeof o.id !== "string" || o.id === "") {
|
|
@@ -1711,21 +1717,61 @@ async function verifyDisclosedContent(
|
|
|
1711
1717
|
const evErrors: string[] = [];
|
|
1712
1718
|
let contentOk: boolean | null = null;
|
|
1713
1719
|
|
|
1720
|
+
// WHICH hash in the record is decided by the record's STRUCTURE
|
|
1721
|
+
// (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
|
|
1722
|
+
// governs (`isHandoffNamespace`) commits to its message through
|
|
1723
|
+
// `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
|
|
1724
|
+
// `content_hash`, so until A20 its disclosure bound to nothing and an honest
|
|
1725
|
+
// hand-off pack could never grade `verified`. For such a record the committed
|
|
1726
|
+
// hash is `event.message_hash` and nothing else: not a `content_hash` it also
|
|
1727
|
+
// carries, and not the envelope's. It binds only when the record is a
|
|
1728
|
+
// CONFORMANT hand-off record under the key that signed it
|
|
1729
|
+
// (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
|
|
1730
|
+
// hex. Anything short of that commits nothing this reader will bind to, so
|
|
1731
|
+
// the reading is undetermined and never true: a record dressed as a hand-off
|
|
1732
|
+
// record gains no binding from the dress, and a record outside §4.1 keeps its
|
|
1733
|
+
// `content_hash` exactly as before, whatever `message_hash` field it writes.
|
|
1734
|
+
// The Python twin is `evidence._evaluate_content`.
|
|
1714
1735
|
const committed: string[] = [];
|
|
1715
1736
|
let recordContentHash: string | undefined;
|
|
1737
|
+
let governed = false;
|
|
1738
|
+
let handoffHash: string | undefined;
|
|
1716
1739
|
if (eventBytes !== undefined) {
|
|
1717
1740
|
try {
|
|
1718
|
-
const
|
|
1741
|
+
const parsed = jcsParse(eventBytes);
|
|
1742
|
+
const record = parsed as {
|
|
1719
1743
|
event?: { content_hash?: unknown };
|
|
1720
|
-
};
|
|
1721
|
-
const ch = record
|
|
1744
|
+
} | null;
|
|
1745
|
+
const ch = record?.event?.content_hash;
|
|
1722
1746
|
if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
|
|
1747
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1748
|
+
const envelope = ev.envelope;
|
|
1749
|
+
const held: HeldRecord = {
|
|
1750
|
+
publicKey:
|
|
1751
|
+
envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
|
|
1752
|
+
? ((envelope as { public_key?: unknown }).public_key as string)
|
|
1753
|
+
: (undefined as unknown as string),
|
|
1754
|
+
record: parsed as HeldRecord["record"],
|
|
1755
|
+
ref: "",
|
|
1756
|
+
};
|
|
1757
|
+
if (isHandoffNamespace(held)) {
|
|
1758
|
+
governed = true;
|
|
1759
|
+
const event = (parsed as { event?: unknown }).event;
|
|
1760
|
+
const mh =
|
|
1761
|
+
event !== null && typeof event === "object" && !Array.isArray(event)
|
|
1762
|
+
? (event as { message_hash?: unknown }).message_hash
|
|
1763
|
+
: undefined;
|
|
1764
|
+
if (isConformantHandoffRecord(held) && isLowerHex(mh, 64)) handoffHash = mh;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1723
1767
|
} catch {
|
|
1724
1768
|
// record not JSON — the payload binding already reflects that; content
|
|
1725
1769
|
// stays undetermined rather than throwing.
|
|
1726
1770
|
}
|
|
1727
1771
|
}
|
|
1728
|
-
if (
|
|
1772
|
+
if (governed) {
|
|
1773
|
+
if (handoffHash !== undefined) committed.push(handoffHash);
|
|
1774
|
+
} else if (recordContentHash !== undefined) {
|
|
1729
1775
|
committed.push(recordContentHash);
|
|
1730
1776
|
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1731
1777
|
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
@@ -1749,16 +1795,21 @@ async function verifyDisclosedContent(
|
|
|
1749
1795
|
contentOk = null;
|
|
1750
1796
|
if (disclosed !== undefined && committed.length === 0) {
|
|
1751
1797
|
evErrors.push(
|
|
1752
|
-
|
|
1753
|
-
"
|
|
1798
|
+
governed
|
|
1799
|
+
? "disclosed content present but this hand-off record commits no " +
|
|
1800
|
+
"message_hash a reader can bind to (not a conformant hand-off " +
|
|
1801
|
+
"record, or message_hash is not 64 lowercase hex) -- undetermined"
|
|
1802
|
+
: "disclosed content present but no committed content_hash to bind to " +
|
|
1803
|
+
"(record withheld or record has no event.content_hash) -- undetermined",
|
|
1754
1804
|
);
|
|
1755
1805
|
}
|
|
1756
1806
|
} else {
|
|
1757
1807
|
const got = await sha256Hex(disclosed);
|
|
1758
1808
|
contentOk = committed.includes(got);
|
|
1759
1809
|
if (!contentOk) {
|
|
1810
|
+
const fieldName = governed ? "message_hash" : "content_hash";
|
|
1760
1811
|
evErrors.push(
|
|
1761
|
-
|
|
1812
|
+
`disclosed content does not match the committed ${fieldName}: ` +
|
|
1762
1813
|
`sha256(disclosed)=${got} committed=${committed.join("|")}`,
|
|
1763
1814
|
);
|
|
1764
1815
|
}
|
|
@@ -1882,7 +1933,7 @@ async function verifyStampEntry(
|
|
|
1882
1933
|
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
1883
1934
|
// how a reader half-parses a shape it does not know.
|
|
1884
1935
|
errors.push(
|
|
1885
|
-
`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
1936
|
+
`unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
|
|
1886
1937
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
|
|
1887
1938
|
);
|
|
1888
1939
|
return { result: base, witnessedHex };
|
|
@@ -2429,8 +2480,8 @@ async function verifyStampEvidence(
|
|
|
2429
2480
|
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
2430
2481
|
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
2431
2482
|
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
2432
|
-
* nothing to the status
|
|
2433
|
-
*
|
|
2483
|
+
* nothing to the status, which is `partial` either way, and would add an
|
|
2484
|
+
* unreachable-gateway note for a network event that never happened, based on
|
|
2434
2485
|
* nothing but whether the caller happened to pass a gateway list.
|
|
2435
2486
|
*/
|
|
2436
2487
|
async function refetchStamps(
|
|
@@ -2840,8 +2891,11 @@ async function verifyExportBody(
|
|
|
2840
2891
|
// step-5 erratum flagged in the slice report.
|
|
2841
2892
|
let verdictAgreementOk = false;
|
|
2842
2893
|
try {
|
|
2843
|
-
const
|
|
2844
|
-
const
|
|
2894
|
+
const cached = projectVerdictForAgreement(body.kernel_verdict);
|
|
2895
|
+
const fresh = projectVerdictForAgreement(verdict);
|
|
2896
|
+
toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
|
|
2897
|
+
const cachedProjection = jcs(cached);
|
|
2898
|
+
const freshProjection = jcs(fresh);
|
|
2845
2899
|
verdictAgreementOk = cachedProjection === freshProjection;
|
|
2846
2900
|
if (!verdictAgreementOk) {
|
|
2847
2901
|
errors.push(
|
|
@@ -2874,8 +2928,8 @@ async function verifyExportBody(
|
|
|
2874
2928
|
) {
|
|
2875
2929
|
status = "failed";
|
|
2876
2930
|
} else if (sourceResult.status === "partial") {
|
|
2877
|
-
// Undetermined source (withheld record
|
|
2878
|
-
// exit 3
|
|
2931
|
+
// Undetermined source (a withheld record, or an unreachable gateway):
|
|
2932
|
+
// exit 3 either way since 0.13.0. Never a failure.
|
|
2879
2933
|
status = "partial";
|
|
2880
2934
|
} else {
|
|
2881
2935
|
status = "verified";
|
|
@@ -3064,7 +3118,7 @@ async function verifyAttestation(
|
|
|
3064
3118
|
if (rec.signature_alg !== "rsa-pss-sha256") {
|
|
3065
3119
|
return {
|
|
3066
3120
|
result,
|
|
3067
|
-
malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
|
|
3121
|
+
malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg ?? null)}`,
|
|
3068
3122
|
};
|
|
3069
3123
|
}
|
|
3070
3124
|
const pub = rec.public_key as { kty?: unknown; n?: unknown; e?: unknown } | null;
|
|
@@ -3266,7 +3320,11 @@ function eventVerdictStatus(e: EventResult): string {
|
|
|
3266
3320
|
// nullish normalization) — a structural difference surfaces as disagreement,
|
|
3267
3321
|
// which is the correct tamper signal. Works on both the cached snake_case JSON
|
|
3268
3322
|
// and the freshly built VerdictObject.
|
|
3269
|
-
function projectVerdictForAgreement(v: unknown):
|
|
3323
|
+
function projectVerdictForAgreement(v: unknown): {
|
|
3324
|
+
schema_version: string | null;
|
|
3325
|
+
events: { content_ok: boolean | null }[];
|
|
3326
|
+
checkpoints: unknown[];
|
|
3327
|
+
} {
|
|
3270
3328
|
const o = (v ?? {}) as VerdictObject;
|
|
3271
3329
|
const events = Array.isArray(o.events) ? o.events : [];
|
|
3272
3330
|
const checkpoints = Array.isArray(o.checkpoints) ? o.checkpoints : [];
|
|
@@ -3304,6 +3362,69 @@ function projectVerdictForAgreement(v: unknown): unknown {
|
|
|
3304
3362
|
};
|
|
3305
3363
|
}
|
|
3306
3364
|
|
|
3365
|
+
// Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
|
|
3366
|
+
// whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
|
|
3367
|
+
// content binding decodes it, so an event whose record is withheld, not hex or
|
|
3368
|
+
// not JCS is not governed here either. The Python twin is
|
|
3369
|
+
// `evidence._source_events_governed`.
|
|
3370
|
+
function sourceEventsGoverned(source: unknown): boolean[] {
|
|
3371
|
+
if (source === null || typeof source !== "object" || Array.isArray(source)) return [];
|
|
3372
|
+
const s = source as { body_type?: unknown; body?: unknown };
|
|
3373
|
+
if (s.body_type !== ANCHOR_TRACE_BODY_TYPE) return [];
|
|
3374
|
+
const body = s.body as { events?: unknown } | null | undefined;
|
|
3375
|
+
const events =
|
|
3376
|
+
body !== null && typeof body === "object" && !Array.isArray(body) ? body.events : undefined;
|
|
3377
|
+
if (!Array.isArray(events)) return [];
|
|
3378
|
+
return events.map((raw: unknown) => {
|
|
3379
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return false;
|
|
3380
|
+
const ev = raw as { event_bytes?: unknown; envelope?: unknown };
|
|
3381
|
+
if (typeof ev.event_bytes !== "string") return false;
|
|
3382
|
+
try {
|
|
3383
|
+
const parsed = jcsParse(hexToBytes(ev.event_bytes));
|
|
3384
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
3385
|
+
const envelope = ev.envelope;
|
|
3386
|
+
const held: HeldRecord = {
|
|
3387
|
+
publicKey:
|
|
3388
|
+
envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
|
|
3389
|
+
? ((envelope as { public_key?: unknown }).public_key as string)
|
|
3390
|
+
: (undefined as unknown as string),
|
|
3391
|
+
record: parsed as HeldRecord["record"],
|
|
3392
|
+
ref: "",
|
|
3393
|
+
};
|
|
3394
|
+
return isHandoffNamespace(held);
|
|
3395
|
+
} catch {
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
// The one difference §5 step 5 does not read as a disagreement
|
|
3402
|
+
// (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
|
|
3403
|
+
// `null` against a recomputed `true`, on an event whose record §4.1 governs. A
|
|
3404
|
+
// kernel before A20 could not bind a hand-off record's disclosed message, so an
|
|
3405
|
+
// export it composed claims less than the recompute proves, and reading that
|
|
3406
|
+
// as tamper failed an honest export. Only that direction: a cached `true`
|
|
3407
|
+
// against a recomputed `false` or `null`, `null` against `false`, and any
|
|
3408
|
+
// difference on a record outside §4.1 still disagree. Edits `fresh` in place
|
|
3409
|
+
// to match `cached` on those events. The Python twin is
|
|
3410
|
+
// `evidence._tolerate_pre_a20_content`.
|
|
3411
|
+
function toleratePreA20Content(
|
|
3412
|
+
cached: { events: { content_ok: boolean | null }[] },
|
|
3413
|
+
fresh: { events: { content_ok: boolean | null }[] },
|
|
3414
|
+
governed: boolean[],
|
|
3415
|
+
): void {
|
|
3416
|
+
const n = Math.min(cached.events.length, fresh.events.length);
|
|
3417
|
+
for (let i = 0; i < n; i++) {
|
|
3418
|
+
if (
|
|
3419
|
+
governed[i] === true &&
|
|
3420
|
+
cached.events[i]!.content_ok === null &&
|
|
3421
|
+
fresh.events[i]!.content_ok === true
|
|
3422
|
+
) {
|
|
3423
|
+
fresh.events[i]!.content_ok = null;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3307
3428
|
function summarizeExport(
|
|
3308
3429
|
status: EvidenceStatus,
|
|
3309
3430
|
source: EvidenceBundleResult,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// One exit-code table for every verb (0.13.0).
|
|
2
|
+
//
|
|
3
|
+
// Both commands, `npx @sanning/proof` and `sanning-proof`, map a verdict word to
|
|
4
|
+
// an exit code through this one table, for `verify`, `case` and `export` alike,
|
|
5
|
+
// and every verb's `--help` prints `EXIT_CODES_HELP` byte for byte. The twin is
|
|
6
|
+
// `src/sanning_proof/exit_codes.py`; the specification is `evidence-export.md`
|
|
7
|
+
// §5.
|
|
8
|
+
//
|
|
9
|
+
// Until 0.13.0 `verify` exited 0 on `partial` unless a gateway was unreachable,
|
|
10
|
+
// while `case` exited 3 on it, so a CI gate reading `verify`'s exit code read a
|
|
11
|
+
// stamp witnessed by nobody as verified. Every word that means "could not be
|
|
12
|
+
// settled" now exits 3, which is the code's name: undetermined.
|
|
13
|
+
|
|
14
|
+
export const EXIT_VERIFIED = 0;
|
|
15
|
+
export const EXIT_FAILED = 1;
|
|
16
|
+
export const EXIT_MALFORMED = 2;
|
|
17
|
+
export const EXIT_UNDETERMINED = 3;
|
|
18
|
+
export const EXIT_MIXED = 4;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The exit code of each of the eight verdict words (`evidence-bundle.md` §3).
|
|
22
|
+
* `undetermined` is the name of 3, and never a word here.
|
|
23
|
+
*/
|
|
24
|
+
export const EXIT_CODES: Readonly<Record<string, number>> = Object.freeze({
|
|
25
|
+
verified: EXIT_VERIFIED,
|
|
26
|
+
failed: EXIT_FAILED,
|
|
27
|
+
malformed: EXIT_MALFORMED,
|
|
28
|
+
partial: EXIT_UNDETERMINED,
|
|
29
|
+
pending: EXIT_UNDETERMINED,
|
|
30
|
+
incomplete: EXIT_UNDETERMINED,
|
|
31
|
+
not_found: EXIT_UNDETERMINED,
|
|
32
|
+
mixed: EXIT_MIXED,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** What every verb's help prints, identical in both commands. */
|
|
36
|
+
export const EXIT_CODES_HELP = [
|
|
37
|
+
"Exit codes, one table for every verb:",
|
|
38
|
+
" 0 verified, or the export was written",
|
|
39
|
+
" 1 failed",
|
|
40
|
+
" 2 usage or malformed",
|
|
41
|
+
" 3 undetermined: partial, pending, incomplete or not_found",
|
|
42
|
+
" 4 mixed: more than one outcome, none failed; read the exceptions",
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The exit code of a verdict word. A word outside the eight is a verdict
|
|
47
|
+
* nothing can render, so it exits 2, never 0.
|
|
48
|
+
*/
|
|
49
|
+
export function exitCodeFor(verdict: unknown): number {
|
|
50
|
+
if (typeof verdict === "string" && Object.prototype.hasOwnProperty.call(EXIT_CODES, verdict)) {
|
|
51
|
+
return EXIT_CODES[verdict]!;
|
|
52
|
+
}
|
|
53
|
+
return EXIT_MALFORMED;
|
|
54
|
+
}
|
package/src/grading.ts
CHANGED
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
// presents a different number of records than a checkpoint
|
|
42
42
|
// signed for — UNKNOWN
|
|
43
43
|
//
|
|
44
|
-
// `incomplete` is
|
|
45
|
-
//
|
|
46
|
-
// bundle; this
|
|
47
|
-
// whatever bytes travelled beside it. A pack can be incomplete
|
|
48
|
-
// it carries is impeccable, which is exactly the case that
|
|
49
|
-
// over zero hashed bytes.
|
|
50
|
-
//
|
|
44
|
+
// `incomplete` is one of the eight words of `evidence-bundle.md` §3 (v1.17,
|
|
45
|
+
// A18), and it is a PACK verdict, never a bundle status. The bundle status
|
|
46
|
+
// grades a signed bundle; this grades a PACK — a strictly larger object, the
|
|
47
|
+
// bundle plus whatever bytes travelled beside it. A pack can be incomplete
|
|
48
|
+
// while the bundle it carries is impeccable, which is exactly the case that
|
|
49
|
+
// used to render green over zero hashed bytes. One vocabulary, two layers,
|
|
50
|
+
// each issuing the subset it can reach.
|
|
51
51
|
//
|
|
52
52
|
// `recordsVerified` counts CONTENT-BOUND records only. The kernel's own
|
|
53
53
|
// `event.ok` tolerates undisclosed content by design — a bundle may legitimately
|
|
@@ -86,15 +86,17 @@ import { jcsParse } from "./verifier.js";
|
|
|
86
86
|
// A superset of `EvidenceStatus`: the four the kernel can return, plus the
|
|
87
87
|
// pack-level `incomplete`, plus `pending` from the shared §3 vocabulary so a
|
|
88
88
|
// kernel headline this module does not downgrade can pass through unchanged.
|
|
89
|
-
export
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
export const PACK_VERDICTS = [
|
|
90
|
+
"verified",
|
|
91
|
+
"partial",
|
|
92
|
+
"pending",
|
|
93
|
+
"not_found",
|
|
94
|
+
"mixed",
|
|
95
|
+
"failed",
|
|
96
|
+
"malformed",
|
|
97
|
+
"incomplete",
|
|
98
|
+
] as const;
|
|
99
|
+
export type PackVerdict = (typeof PACK_VERDICTS)[number];
|
|
98
100
|
|
|
99
101
|
// ---- Inputs ------------------------------------------------------------------
|
|
100
102
|
|
|
@@ -109,7 +111,17 @@ export type PackVerdict =
|
|
|
109
111
|
*/
|
|
110
112
|
export interface GradeInput {
|
|
111
113
|
status: string | null | undefined;
|
|
112
|
-
|
|
114
|
+
/**
|
|
115
|
+
* `eventId` and `payloadBindingOk` are read by rule version 6 alone (A22):
|
|
116
|
+
* an input that does not carry both is never withheld by its declaration,
|
|
117
|
+
* so an adapter written for rule version 5 keeps the stricter reading.
|
|
118
|
+
*/
|
|
119
|
+
events: {
|
|
120
|
+
ok: boolean;
|
|
121
|
+
contentOk: boolean | null;
|
|
122
|
+
eventId?: string;
|
|
123
|
+
payloadBindingOk?: boolean | null;
|
|
124
|
+
}[];
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
/**
|
|
@@ -144,21 +156,23 @@ export type CompletenessNote =
|
|
|
144
156
|
|
|
145
157
|
// ---- Output ------------------------------------------------------------------
|
|
146
158
|
|
|
147
|
-
export
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
export const PACK_GRADE_CAUSE_KINDS = [
|
|
160
|
+
"records_omitted",
|
|
161
|
+
"records_surplus",
|
|
162
|
+
"leaf_count_unreadable",
|
|
163
|
+
"no_disclosure",
|
|
164
|
+
"missing_disclosure",
|
|
165
|
+
"unreadable_disclosure",
|
|
166
|
+
"unaccounted_disclosure",
|
|
167
|
+
"declaration_deviation",
|
|
156
168
|
/**
|
|
157
169
|
* The pack and the UNSIGNED content handed in beside it do not line up
|
|
158
170
|
* (proof#147). A cause, so the pack cannot read clean over it and a reader
|
|
159
171
|
* is told which record; NOT a deviation, so it never reaches `failed`.
|
|
160
172
|
*/
|
|
161
|
-
|
|
173
|
+
"declaration_content_dependent",
|
|
174
|
+
] as const;
|
|
175
|
+
export type PackGradeCauseKind = (typeof PACK_GRADE_CAUSE_KINDS)[number];
|
|
162
176
|
|
|
163
177
|
/**
|
|
164
178
|
* One reason a pack is not `verified`, as data.
|
|
@@ -186,8 +200,8 @@ export interface PackGradeCause {
|
|
|
186
200
|
reason?: string;
|
|
187
201
|
/**
|
|
188
202
|
* Which deviation from the pack's own declaration, for
|
|
189
|
-
* `
|
|
190
|
-
* `
|
|
203
|
+
* `declaration_deviation`, or which content-dependent finding, for
|
|
204
|
+
* `declaration_content_dependent`.
|
|
191
205
|
*/
|
|
192
206
|
deviation?: DeclarationDeviationKind | DeclarationContentKind;
|
|
193
207
|
}
|
|
@@ -205,6 +219,17 @@ export interface PackGrade {
|
|
|
205
219
|
* check was passed, which is the pre-§5.4 call.
|
|
206
220
|
*/
|
|
207
221
|
declaration?: DeclarationVerdict;
|
|
222
|
+
/**
|
|
223
|
+
* Records WITHHELD BY THEIR DECLARATION (rule version 6, evidence-bundle
|
|
224
|
+
* 5.4.5 v1.20, A22): a readable declaration lists them in
|
|
225
|
+
* `declaredWithheld`, their `ok` is true, their `contentOk` is `null` and
|
|
226
|
+
* their `payloadBindingOk` is true. Never counted in `recordsVerified`: no
|
|
227
|
+
* byte of their content was hashed, and a surface MUST NOT print them as
|
|
228
|
+
* verified records.
|
|
229
|
+
*/
|
|
230
|
+
recordsWithheld: number;
|
|
231
|
+
/** Their `event_id`s, in bundle order. */
|
|
232
|
+
withheldEventIds: string[];
|
|
208
233
|
}
|
|
209
234
|
|
|
210
235
|
// ---- Reading a bundle --------------------------------------------------------
|
|
@@ -522,7 +547,20 @@ export function gradeCauses(
|
|
|
522
547
|
account: DisclosureAccount,
|
|
523
548
|
notes: readonly CompletenessNote[],
|
|
524
549
|
declaration?: DeclarationCheck | null,
|
|
550
|
+
withheld: readonly string[] = [],
|
|
525
551
|
): PackGradeCause[] {
|
|
552
|
+
// The `event_id`s WITHHELD BY THEIR DECLARATION (rule version 6) are taken
|
|
553
|
+
// out of `expected` and `missing` before the disclosure causes are computed,
|
|
554
|
+
// so a pack whose every record its declaration withholds earns neither
|
|
555
|
+
// `no_disclosure` nor `missing_disclosure`.
|
|
556
|
+
if (withheld.length > 0) {
|
|
557
|
+
const gone = new Set(withheld);
|
|
558
|
+
account = {
|
|
559
|
+
...account,
|
|
560
|
+
expected: account.expected.filter((e) => !gone.has(e)),
|
|
561
|
+
missing: account.missing.filter((e) => !gone.has(e)),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
526
564
|
const causes: PackGradeCause[] = [];
|
|
527
565
|
|
|
528
566
|
// What the pack CLAIMED to hold comes first, because it frames everything
|
|
@@ -532,7 +570,7 @@ export function gradeCauses(
|
|
|
532
570
|
// four ways can never be reported as one vague shortfall.
|
|
533
571
|
for (const deviation of declaration?.deviations ?? []) {
|
|
534
572
|
causes.push({
|
|
535
|
-
kind: "
|
|
573
|
+
kind: "declaration_deviation",
|
|
536
574
|
deviation: deviation.kind,
|
|
537
575
|
...(deviation.eventId === undefined ? {} : { firstEventId: deviation.eventId }),
|
|
538
576
|
reason: renderDeviation(deviation),
|
|
@@ -544,7 +582,7 @@ export function gradeCauses(
|
|
|
544
582
|
// deviation, so it never reaches `failed`.
|
|
545
583
|
for (const finding of declaration?.contentFindings ?? []) {
|
|
546
584
|
causes.push({
|
|
547
|
-
kind: "
|
|
585
|
+
kind: "declaration_content_dependent",
|
|
548
586
|
deviation: finding.kind,
|
|
549
587
|
...(finding.eventId === undefined ? {} : { firstEventId: finding.eventId }),
|
|
550
588
|
reason: renderContentFinding(finding),
|
|
@@ -554,7 +592,7 @@ export function gradeCauses(
|
|
|
554
592
|
for (const note of notes) {
|
|
555
593
|
if (note.kind === "omitted") {
|
|
556
594
|
causes.push({
|
|
557
|
-
kind: "
|
|
595
|
+
kind: "records_omitted",
|
|
558
596
|
checkpointRef: note.checkpointRef,
|
|
559
597
|
signed: note.signed,
|
|
560
598
|
presented: note.presented,
|
|
@@ -562,14 +600,14 @@ export function gradeCauses(
|
|
|
562
600
|
});
|
|
563
601
|
} else if (note.kind === "surplus") {
|
|
564
602
|
causes.push({
|
|
565
|
-
kind: "
|
|
603
|
+
kind: "records_surplus",
|
|
566
604
|
checkpointRef: note.checkpointRef,
|
|
567
605
|
signed: note.signed,
|
|
568
606
|
presented: note.presented,
|
|
569
607
|
count: note.presented - note.signed,
|
|
570
608
|
});
|
|
571
609
|
} else {
|
|
572
|
-
causes.push({ kind: "
|
|
610
|
+
causes.push({ kind: "leaf_count_unreadable", checkpointRef: note.checkpointRef });
|
|
573
611
|
}
|
|
574
612
|
}
|
|
575
613
|
|
|
@@ -578,10 +616,10 @@ export function gradeCauses(
|
|
|
578
616
|
// reader deserves to be told the pack disclosed nothing rather than counting
|
|
579
617
|
// N absences.
|
|
580
618
|
if (account.expected.length > 0 && account.present.length === 0 && account.unreadable.length === 0) {
|
|
581
|
-
causes.push({ kind: "
|
|
619
|
+
causes.push({ kind: "no_disclosure", count: account.expected.length, of: account.expected.length });
|
|
582
620
|
} else if (account.missing.length > 0) {
|
|
583
621
|
causes.push({
|
|
584
|
-
kind: "
|
|
622
|
+
kind: "missing_disclosure",
|
|
585
623
|
count: account.missing.length,
|
|
586
624
|
of: account.expected.length,
|
|
587
625
|
...(account.missing[0] === undefined ? {} : { firstEventId: account.missing[0] }),
|
|
@@ -589,12 +627,12 @@ export function gradeCauses(
|
|
|
589
627
|
}
|
|
590
628
|
|
|
591
629
|
for (const u of account.unreadable) {
|
|
592
|
-
causes.push({ kind: "
|
|
630
|
+
causes.push({ kind: "unreadable_disclosure", firstEventId: u.eventId, reason: u.reason });
|
|
593
631
|
}
|
|
594
632
|
|
|
595
633
|
if (account.extra.length > 0) {
|
|
596
634
|
causes.push({
|
|
597
|
-
kind: "
|
|
635
|
+
kind: "unaccounted_disclosure",
|
|
598
636
|
count: account.extra.length,
|
|
599
637
|
...(account.extra[0] === undefined ? {} : { firstEventId: account.extra[0] }),
|
|
600
638
|
});
|
|
@@ -614,7 +652,8 @@ export function gradeCauses(
|
|
|
614
652
|
* the type and renders the verdict verbatim would then print the one word
|
|
615
653
|
* `evidence-bundle.md` §6 forbids, over a pack this rule never graded.
|
|
616
654
|
*
|
|
617
|
-
* So: the
|
|
655
|
+
* So: the five words of the §3 vocabulary that a bundle status can pass up to
|
|
656
|
+
* a pack pass through, and ANYTHING ELSE is
|
|
618
657
|
* `incomplete`. A verdict we cannot interpret is a check we could not run, and
|
|
619
658
|
* the answer to that is UNKNOWN — never a pass, and never an accusation.
|
|
620
659
|
* (`failed` and `malformed` are handled before this is reached.)
|
|
@@ -637,8 +676,9 @@ function passThrough(status: string | null | undefined): PackVerdict {
|
|
|
637
676
|
* The verdict a surface may print, and the counts it may print beside it.
|
|
638
677
|
*
|
|
639
678
|
* The invariant, stated so a mutation test can kill it: `verified` is reachable
|
|
640
|
-
* ONLY when every record's supplied bytes bound to its own commitment
|
|
641
|
-
*
|
|
679
|
+
* ONLY when every record's supplied bytes bound to its own commitment, or
|
|
680
|
+
* (rule version 6) the record is withheld by its declaration, and nothing is
|
|
681
|
+
* missing, unreadable, surplus or omitted.
|
|
642
682
|
*
|
|
643
683
|
* Precedence: malformed > failed > incomplete > the kernel's own headline.
|
|
644
684
|
* A hard verdict wins — "the bytes are absent" is never reported as failure
|
|
@@ -696,8 +736,23 @@ export function gradePack(
|
|
|
696
736
|
const events = Array.isArray(result?.events) ? result.events : [];
|
|
697
737
|
const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
|
|
698
738
|
const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
|
|
699
|
-
|
|
700
|
-
|
|
739
|
+
// WITHHELD BY ITS DECLARATION (rule version 6, 5.4.5 v1.20, A22, D5). All
|
|
740
|
+
// four conditions, each on its own. `declaredWithheld` is empty for an
|
|
741
|
+
// absent, unreadable or malformed declaration.
|
|
742
|
+
const declaredWithheld = new Set(declaration?.declaredWithheld ?? []);
|
|
743
|
+
const withheldEventIds = events
|
|
744
|
+
.filter(
|
|
745
|
+
(e) =>
|
|
746
|
+
typeof e.eventId === "string" &&
|
|
747
|
+
declaredWithheld.has(e.eventId) &&
|
|
748
|
+
e.ok === true &&
|
|
749
|
+
e.contentOk === null &&
|
|
750
|
+
e.payloadBindingOk === true,
|
|
751
|
+
)
|
|
752
|
+
.map((e) => e.eventId as string);
|
|
753
|
+
const recordsWithheld = withheldEventIds.length;
|
|
754
|
+
const recordsUndetermined = events.length - recordsVerified - recordsFailed - recordsWithheld;
|
|
755
|
+
const causes = gradeCauses(account, notes, declaration, withheldEventIds);
|
|
701
756
|
|
|
702
757
|
let verdict: PackVerdict;
|
|
703
758
|
if (result?.status === "malformed") verdict = "malformed";
|
|
@@ -714,6 +769,8 @@ export function gradePack(
|
|
|
714
769
|
recordsFailed,
|
|
715
770
|
recordsUndetermined,
|
|
716
771
|
causes,
|
|
772
|
+
recordsWithheld,
|
|
773
|
+
withheldEventIds,
|
|
717
774
|
};
|
|
718
775
|
if (declaration != null) grade.declaration = declaration.verdict;
|
|
719
776
|
return grade;
|
|
@@ -733,31 +790,31 @@ export function gradePack(
|
|
|
733
790
|
*/
|
|
734
791
|
export function renderCause(cause: PackGradeCause, subject = "this pack"): string {
|
|
735
792
|
switch (cause.kind) {
|
|
736
|
-
case "
|
|
793
|
+
case "records_omitted":
|
|
737
794
|
return (
|
|
738
795
|
`checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
|
|
739
796
|
`${cause.presented} — ${cause.count} record(s) OMITTED`
|
|
740
797
|
);
|
|
741
|
-
case "
|
|
798
|
+
case "records_surplus":
|
|
742
799
|
return (
|
|
743
800
|
`checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
|
|
744
801
|
`${cause.presented} — more records than the signed tree accounts for`
|
|
745
802
|
);
|
|
746
|
-
case "
|
|
803
|
+
case "leaf_count_unreadable":
|
|
747
804
|
return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count — completeness is UNKNOWN`;
|
|
748
|
-
case "
|
|
805
|
+
case "no_disclosure":
|
|
749
806
|
return (
|
|
750
807
|
`no event bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
|
|
751
808
|
`record(s), not verified`
|
|
752
809
|
);
|
|
753
|
-
case "
|
|
810
|
+
case "missing_disclosure":
|
|
754
811
|
return (
|
|
755
812
|
`${cause.count} of ${cause.of} record(s) disclose no bytes (first: ${cause.firstEventId}) — ` +
|
|
756
813
|
`content binding is UNKNOWN, not verified`
|
|
757
814
|
);
|
|
758
|
-
case "
|
|
815
|
+
case "unreadable_disclosure":
|
|
759
816
|
return `the disclosure for ${cause.firstEventId} is unreadable: ${cause.reason}`;
|
|
760
|
-
case "
|
|
817
|
+
case "unaccounted_disclosure":
|
|
761
818
|
return (
|
|
762
819
|
`${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
|
|
763
820
|
`(first: ${cause.firstEventId})`
|
|
@@ -766,9 +823,9 @@ export function renderCause(cause: PackGradeCause, subject = "this pack"): strin
|
|
|
766
823
|
// here on `reason`. Re-deriving the sentence from the cause fields would
|
|
767
824
|
// put the declaration's wording in two places, and one of the two would be
|
|
768
825
|
// edited alone.
|
|
769
|
-
case "
|
|
826
|
+
case "declaration_deviation":
|
|
770
827
|
return `this pack deviates from its own declaration: ${cause.reason}`;
|
|
771
|
-
case "
|
|
828
|
+
case "declaration_content_dependent":
|
|
772
829
|
// Named as what the reader could not establish, never as what the
|
|
773
830
|
// holder did: the bytes it turns on are unsigned and whoever assembled
|
|
774
831
|
// the case chose them (proof#147).
|