@sanning/proof 0.12.0 → 0.14.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 +25 -12
- 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 +353 -0
- package/dist/case.d.ts +217 -26
- package/dist/case.js +535 -124
- package/dist/cli.d.ts +64 -8
- package/dist/cli.js +799 -219
- package/dist/compose.js +6 -1
- 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 +38 -0
- package/dist/evidence.js +238 -22
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/index.d.ts +14 -7
- package/dist/index.js +20 -6
- package/dist/message-parts.d.ts +18 -10
- package/dist/message-parts.js +25 -15
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +63 -4
- package/dist/verifier.js +223 -15
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- 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 +422 -0
- package/src/case.ts +671 -131
- package/src/cli.ts +932 -217
- package/src/compose.ts +8 -1
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +257 -23
- package/src/exit-codes.ts +54 -0
- package/src/file-match.ts +461 -0
- package/src/grading.ts +109 -52
- package/src/index.ts +101 -2
- package/src/message-parts.ts +25 -15
- package/src/types.ts +5 -0
- package/src/verifier.ts +225 -16
- package/src/witness-cache.ts +306 -0
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +66 -8
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +20 -5
- package/vectors/witness-cache.json +1304 -0
package/src/evidence.ts
CHANGED
|
@@ -29,11 +29,14 @@ 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 {
|
|
35
|
+
DuplicateKeyError,
|
|
34
36
|
MAX_CANONICAL_DEPTH,
|
|
35
37
|
contentHashes,
|
|
36
38
|
exceedsDepth,
|
|
39
|
+
isRfc3339UtcInstant,
|
|
37
40
|
jcs,
|
|
38
41
|
jcsParse,
|
|
39
42
|
verifyEnvelope,
|
|
@@ -48,7 +51,8 @@ import type { Envelope } from "./types.js";
|
|
|
48
51
|
// Only the evidence-bundle major(s) this kernel verifies. A new major is a
|
|
49
52
|
// deliberate one-entry addition HERE — same fail-closed discipline as the
|
|
50
53
|
// envelope verifier's ACCEPTED_SPEC_MAJORS.
|
|
51
|
-
|
|
54
|
+
/** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
|
|
55
|
+
export const ACCEPTED_EVIDENCE_MAJORS: readonly string[] = Object.freeze(["sanning.evidence/v1"]);
|
|
52
56
|
|
|
53
57
|
// The body_type this module knows how to verify structurally. An unknown
|
|
54
58
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
@@ -514,6 +518,12 @@ export interface EvidenceBundleResult {
|
|
|
514
518
|
// Absent for every other body — additive, backward-compatible.
|
|
515
519
|
stamps?: StampResult[];
|
|
516
520
|
errors: string[];
|
|
521
|
+
// Informational only: never read, never gated on, never moves `status` or
|
|
522
|
+
// the CLI exit code. Carries `CO_SIGNATURES_NOTE` where the wrapper's
|
|
523
|
+
// reserved `co_signatures` field is present with at least one entry
|
|
524
|
+
// (evidence-bundle.md §2, §7.1 decision proof#148). Absent for every
|
|
525
|
+
// bundle without it, additive and backward-compatible.
|
|
526
|
+
notes?: string[];
|
|
517
527
|
}
|
|
518
528
|
|
|
519
529
|
// ---- Attested-evidence-export wire + result types (evidence-export.md) -------
|
|
@@ -669,6 +679,43 @@ export interface VerifyEvidenceOptions {
|
|
|
669
679
|
fetchImpl?: typeof fetch;
|
|
670
680
|
}
|
|
671
681
|
|
|
682
|
+
/**
|
|
683
|
+
* A surface MUST NOT present a wrapper's `co_signatures` as evidence
|
|
684
|
+
* (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
|
|
685
|
+
* in either kernel verifies the field, and it sits outside the wrapper's
|
|
686
|
+
* signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
|
|
687
|
+
* append it with no key at all. This is the one sentence both kernels attach
|
|
688
|
+
* as a note, never a verdict, never an exit code.
|
|
689
|
+
*/
|
|
690
|
+
export const CO_SIGNATURES_NOTE =
|
|
691
|
+
"unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* True iff the wrapper's reserved `co_signatures` field carries at least one
|
|
695
|
+
* entry.
|
|
696
|
+
*
|
|
697
|
+
* Absence and an empty array read the same way here: the field asserts
|
|
698
|
+
* nothing about a second signer until it holds one, and evidence-bundle.md's
|
|
699
|
+
* own carve-out language for the analogous envelope field treats "absent"
|
|
700
|
+
* and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
|
|
701
|
+
* value (malformed shape, but the field is reserved, not yet specified)
|
|
702
|
+
* still counts as present: it is not the well-formed absence either rule
|
|
703
|
+
* describes.
|
|
704
|
+
*/
|
|
705
|
+
export function coSignaturesPresent(b: unknown): boolean {
|
|
706
|
+
if (b === null || typeof b !== "object" || Array.isArray(b)) return false;
|
|
707
|
+
const value = (b as Record<string, unknown>).co_signatures;
|
|
708
|
+
if (!("co_signatures" in (b as Record<string, unknown>)) || value === null || value === undefined) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function coSignaturesNote(bundle: unknown): string | undefined {
|
|
716
|
+
return coSignaturesPresent(bundle) ? CO_SIGNATURES_NOTE : undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
672
719
|
function isEvidenceMajorAccepted(specVersion: unknown): boolean {
|
|
673
720
|
if (typeof specVersion !== "string" || specVersion === "") return false;
|
|
674
721
|
return ACCEPTED_EVIDENCE_MAJORS.some((m) => {
|
|
@@ -690,6 +737,10 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
|
|
|
690
737
|
* (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
|
|
691
738
|
* broke its own body shape read as a forger.
|
|
692
739
|
*/
|
|
740
|
+
/** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
|
|
741
|
+
export const GENERATED_AT_MALFORMED =
|
|
742
|
+
"malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
|
|
743
|
+
|
|
693
744
|
function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
|
|
694
745
|
return {
|
|
695
746
|
status: "malformed",
|
|
@@ -710,7 +761,9 @@ function malformed(error: string, specVersionOk = false, wrapperOk = false): Evi
|
|
|
710
761
|
* the Python kernel writes too. Anything outside this set comes from the host:
|
|
711
762
|
* a JSON parser, a codec, a crypto library. See `thrownDetail`.
|
|
712
763
|
*/
|
|
713
|
-
|
|
764
|
+
// `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
|
|
765
|
+
// `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
|
|
766
|
+
const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
|
|
714
767
|
|
|
715
768
|
/**
|
|
716
769
|
* The detail a finding may append about a caught exception.
|
|
@@ -728,6 +781,27 @@ const KERNEL_WORDING = ["hexToBytes: "];
|
|
|
728
781
|
* is the host's parse offset, which no reader can act on and which was never
|
|
729
782
|
* the same in the two kernels.
|
|
730
783
|
*/
|
|
784
|
+
/**
|
|
785
|
+
* The kernel's `duplicate key "<name>"` when a record repeats a member name,
|
|
786
|
+
* else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
|
|
787
|
+
*
|
|
788
|
+
* ONLY A DUPLICATE, and not every unreadable record. Every reader below this
|
|
789
|
+
* one that parses a record treats an unreadable one as absent, so the content
|
|
790
|
+
* binding reads undetermined and the pack said "1 record discloses no bytes"
|
|
791
|
+
* about a record whose bytes it had read. A record that repeats a name is
|
|
792
|
+
* a defect in the artifact, and a defect this kernel can see fails, the rule
|
|
793
|
+
* `event_bytes` that are not hex already follow.
|
|
794
|
+
*/
|
|
795
|
+
function recordDuplicateKey(eventBytes: Uint8Array | undefined): string | undefined {
|
|
796
|
+
if (eventBytes === undefined) return undefined;
|
|
797
|
+
try {
|
|
798
|
+
jcsParse(eventBytes);
|
|
799
|
+
} catch (e) {
|
|
800
|
+
if (e instanceof DuplicateKeyError) return e.message;
|
|
801
|
+
}
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
|
|
731
805
|
function thrownDetail(e: unknown): string {
|
|
732
806
|
const text = e instanceof Error ? e.message : String(e);
|
|
733
807
|
return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
|
|
@@ -804,7 +878,17 @@ export async function verifyEvidenceBundle(
|
|
|
804
878
|
bundle: unknown,
|
|
805
879
|
options: VerifyEvidenceOptions = {},
|
|
806
880
|
): Promise<EvidenceBundleResult> {
|
|
807
|
-
|
|
881
|
+
// The ONE place a present `co_signatures` becomes a note, so every caller
|
|
882
|
+
// (including `case.ts`, which calls this function directly per pack) gets
|
|
883
|
+
// it, however the wrapper otherwise verifies. Never touches `status` or
|
|
884
|
+
// the exit code: a bundle that would otherwise verify still verifies, note
|
|
885
|
+
// or not (proof#148).
|
|
886
|
+
const result = await verifyBundle(bundle, options, false);
|
|
887
|
+
const note = coSignaturesNote(bundle);
|
|
888
|
+
if (note !== undefined) {
|
|
889
|
+
result.notes = [...(result.notes ?? []), note];
|
|
890
|
+
}
|
|
891
|
+
return result;
|
|
808
892
|
}
|
|
809
893
|
|
|
810
894
|
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
@@ -830,12 +914,16 @@ async function verifyBundle(
|
|
|
830
914
|
const b = bundle as EvidenceBundle;
|
|
831
915
|
const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
|
|
832
916
|
if (!specVersionOk) {
|
|
833
|
-
|
|
917
|
+
// `?? null`: an ABSENT field is spelled `null`, as the Python twin's
|
|
918
|
+
// `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
|
|
919
|
+
// the two kernels printed two different sentences for one bundle until
|
|
920
|
+
// `verify --json` compared them byte for byte (0.13.0).
|
|
921
|
+
return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
|
|
834
922
|
}
|
|
835
923
|
if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
|
|
836
924
|
// The reference verifier implements ed25519 only (evidence-bundle §2 +
|
|
837
925
|
// decision #2); other algs are a deliberate future addition.
|
|
838
|
-
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
|
|
926
|
+
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
|
|
839
927
|
}
|
|
840
928
|
if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
|
|
841
929
|
return malformed("evidence bundle is missing public_key/signature", true);
|
|
@@ -885,7 +973,7 @@ async function verifyBundle(
|
|
|
885
973
|
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
|
|
886
974
|
co_signatures?: unknown;
|
|
887
975
|
};
|
|
888
|
-
signatureOk = await ed25519Verify(
|
|
976
|
+
signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
|
|
889
977
|
if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
|
|
890
978
|
} catch (e) {
|
|
891
979
|
errors.push(`wrapper signature verification error${thrownDetail(e)}`);
|
|
@@ -911,6 +999,19 @@ async function verifyBundle(
|
|
|
911
999
|
};
|
|
912
1000
|
}
|
|
913
1001
|
|
|
1002
|
+
// --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
|
|
1003
|
+
// `generated_at` is copied into a recomputed verdict's `as_of` (the export
|
|
1004
|
+
// reading) and printed beside every verdict, so a present value that is not
|
|
1005
|
+
// the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
|
|
1006
|
+
// render, whatever its signature. It is read only once the wrapper holds: a
|
|
1007
|
+
// broken wrapper is `failed` above, as it was. Absence is unchanged.
|
|
1008
|
+
if (
|
|
1009
|
+
Object.prototype.hasOwnProperty.call(b, "generated_at") &&
|
|
1010
|
+
!isRfc3339UtcInstant((b as { generated_at?: unknown }).generated_at)
|
|
1011
|
+
) {
|
|
1012
|
+
return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
914
1015
|
// --- Body dispatch ---------------------------------------------------------
|
|
915
1016
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
916
1017
|
|
|
@@ -1135,7 +1236,7 @@ function parseAnchorRef(
|
|
|
1135
1236
|
}
|
|
1136
1237
|
const o = raw as Record<string, unknown>;
|
|
1137
1238
|
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)}`);
|
|
1239
|
+
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind ?? null)}`);
|
|
1139
1240
|
return { ref: null, malformed: true };
|
|
1140
1241
|
}
|
|
1141
1242
|
if (typeof o.id !== "string" || o.id === "") {
|
|
@@ -1384,6 +1485,11 @@ async function verifyAnchorTrace(
|
|
|
1384
1485
|
eventBytesMalformed = true;
|
|
1385
1486
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1386
1487
|
}
|
|
1488
|
+
const duplicate = recordDuplicateKey(eventBytes);
|
|
1489
|
+
if (duplicate !== undefined) {
|
|
1490
|
+
eventBytesMalformed = true;
|
|
1491
|
+
evErrors.push(`event record is not JSON: ${duplicate}`);
|
|
1492
|
+
}
|
|
1387
1493
|
try {
|
|
1388
1494
|
const res = await verifyEnvelope(
|
|
1389
1495
|
ev.envelope,
|
|
@@ -1711,21 +1817,61 @@ async function verifyDisclosedContent(
|
|
|
1711
1817
|
const evErrors: string[] = [];
|
|
1712
1818
|
let contentOk: boolean | null = null;
|
|
1713
1819
|
|
|
1820
|
+
// WHICH hash in the record is decided by the record's STRUCTURE
|
|
1821
|
+
// (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
|
|
1822
|
+
// governs (`isHandoffNamespace`) commits to its message through
|
|
1823
|
+
// `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
|
|
1824
|
+
// `content_hash`, so until A20 its disclosure bound to nothing and an honest
|
|
1825
|
+
// hand-off pack could never grade `verified`. For such a record the committed
|
|
1826
|
+
// hash is `event.message_hash` and nothing else: not a `content_hash` it also
|
|
1827
|
+
// carries, and not the envelope's. It binds only when the record is a
|
|
1828
|
+
// CONFORMANT hand-off record under the key that signed it
|
|
1829
|
+
// (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
|
|
1830
|
+
// hex. Anything short of that commits nothing this reader will bind to, so
|
|
1831
|
+
// the reading is undetermined and never true: a record dressed as a hand-off
|
|
1832
|
+
// record gains no binding from the dress, and a record outside §4.1 keeps its
|
|
1833
|
+
// `content_hash` exactly as before, whatever `message_hash` field it writes.
|
|
1834
|
+
// The Python twin is `evidence._evaluate_content`.
|
|
1714
1835
|
const committed: string[] = [];
|
|
1715
1836
|
let recordContentHash: string | undefined;
|
|
1837
|
+
let governed = false;
|
|
1838
|
+
let handoffHash: string | undefined;
|
|
1716
1839
|
if (eventBytes !== undefined) {
|
|
1717
1840
|
try {
|
|
1718
|
-
const
|
|
1841
|
+
const parsed = jcsParse(eventBytes);
|
|
1842
|
+
const record = parsed as {
|
|
1719
1843
|
event?: { content_hash?: unknown };
|
|
1720
|
-
};
|
|
1721
|
-
const ch = record
|
|
1844
|
+
} | null;
|
|
1845
|
+
const ch = record?.event?.content_hash;
|
|
1722
1846
|
if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
|
|
1847
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1848
|
+
const envelope = ev.envelope;
|
|
1849
|
+
const held: HeldRecord = {
|
|
1850
|
+
publicKey:
|
|
1851
|
+
envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
|
|
1852
|
+
? ((envelope as { public_key?: unknown }).public_key as string)
|
|
1853
|
+
: (undefined as unknown as string),
|
|
1854
|
+
record: parsed as HeldRecord["record"],
|
|
1855
|
+
ref: "",
|
|
1856
|
+
};
|
|
1857
|
+
if (isHandoffNamespace(held)) {
|
|
1858
|
+
governed = true;
|
|
1859
|
+
const event = (parsed as { event?: unknown }).event;
|
|
1860
|
+
const mh =
|
|
1861
|
+
event !== null && typeof event === "object" && !Array.isArray(event)
|
|
1862
|
+
? (event as { message_hash?: unknown }).message_hash
|
|
1863
|
+
: undefined;
|
|
1864
|
+
if (isConformantHandoffRecord(held) && isLowerHex(mh, 64)) handoffHash = mh;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1723
1867
|
} catch {
|
|
1724
1868
|
// record not JSON — the payload binding already reflects that; content
|
|
1725
1869
|
// stays undetermined rather than throwing.
|
|
1726
1870
|
}
|
|
1727
1871
|
}
|
|
1728
|
-
if (
|
|
1872
|
+
if (governed) {
|
|
1873
|
+
if (handoffHash !== undefined) committed.push(handoffHash);
|
|
1874
|
+
} else if (recordContentHash !== undefined) {
|
|
1729
1875
|
committed.push(recordContentHash);
|
|
1730
1876
|
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1731
1877
|
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
@@ -1749,16 +1895,21 @@ async function verifyDisclosedContent(
|
|
|
1749
1895
|
contentOk = null;
|
|
1750
1896
|
if (disclosed !== undefined && committed.length === 0) {
|
|
1751
1897
|
evErrors.push(
|
|
1752
|
-
|
|
1753
|
-
"
|
|
1898
|
+
governed
|
|
1899
|
+
? "disclosed content present but this hand-off record commits no " +
|
|
1900
|
+
"message_hash a reader can bind to (not a conformant hand-off " +
|
|
1901
|
+
"record, or message_hash is not 64 lowercase hex) -- undetermined"
|
|
1902
|
+
: "disclosed content present but no committed content_hash to bind to " +
|
|
1903
|
+
"(record withheld or record has no event.content_hash) -- undetermined",
|
|
1754
1904
|
);
|
|
1755
1905
|
}
|
|
1756
1906
|
} else {
|
|
1757
1907
|
const got = await sha256Hex(disclosed);
|
|
1758
1908
|
contentOk = committed.includes(got);
|
|
1759
1909
|
if (!contentOk) {
|
|
1910
|
+
const fieldName = governed ? "message_hash" : "content_hash";
|
|
1760
1911
|
evErrors.push(
|
|
1761
|
-
|
|
1912
|
+
`disclosed content does not match the committed ${fieldName}: ` +
|
|
1762
1913
|
`sha256(disclosed)=${got} committed=${committed.join("|")}`,
|
|
1763
1914
|
);
|
|
1764
1915
|
}
|
|
@@ -1882,7 +2033,7 @@ async function verifyStampEntry(
|
|
|
1882
2033
|
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
1883
2034
|
// how a reader half-parses a shape it does not know.
|
|
1884
2035
|
errors.push(
|
|
1885
|
-
`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
2036
|
+
`unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
|
|
1886
2037
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
|
|
1887
2038
|
);
|
|
1888
2039
|
return { result: base, witnessedHex };
|
|
@@ -1894,6 +2045,10 @@ async function verifyStampEntry(
|
|
|
1894
2045
|
if (typeof body[f] !== "string" || body[f] === "") {
|
|
1895
2046
|
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
1896
2047
|
base.bodyOk = false;
|
|
2048
|
+
} else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
|
|
2049
|
+
// §5.2 (A31): the promise window is the form `signed_at` pins.
|
|
2050
|
+
errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
|
|
2051
|
+
base.bodyOk = false;
|
|
1897
2052
|
}
|
|
1898
2053
|
}
|
|
1899
2054
|
base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
|
|
@@ -2204,6 +2359,11 @@ async function verifyStampEvidence(
|
|
|
2204
2359
|
eventBytesMalformed = true;
|
|
2205
2360
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
2206
2361
|
}
|
|
2362
|
+
const duplicate = recordDuplicateKey(eventBytes);
|
|
2363
|
+
if (duplicate !== undefined) {
|
|
2364
|
+
eventBytesMalformed = true;
|
|
2365
|
+
evErrors.push(`event record is not JSON: ${duplicate}`);
|
|
2366
|
+
}
|
|
2207
2367
|
try {
|
|
2208
2368
|
const res = await verifyEnvelope(
|
|
2209
2369
|
ev.envelope,
|
|
@@ -2429,8 +2589,8 @@ async function verifyStampEvidence(
|
|
|
2429
2589
|
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
2430
2590
|
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
2431
2591
|
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
2432
|
-
* nothing to the status
|
|
2433
|
-
*
|
|
2592
|
+
* nothing to the status, which is `partial` either way, and would add an
|
|
2593
|
+
* unreachable-gateway note for a network event that never happened, based on
|
|
2434
2594
|
* nothing but whether the caller happened to pass a gateway list.
|
|
2435
2595
|
*/
|
|
2436
2596
|
async function refetchStamps(
|
|
@@ -2840,8 +3000,11 @@ async function verifyExportBody(
|
|
|
2840
3000
|
// step-5 erratum flagged in the slice report.
|
|
2841
3001
|
let verdictAgreementOk = false;
|
|
2842
3002
|
try {
|
|
2843
|
-
const
|
|
2844
|
-
const
|
|
3003
|
+
const cached = projectVerdictForAgreement(body.kernel_verdict);
|
|
3004
|
+
const fresh = projectVerdictForAgreement(verdict);
|
|
3005
|
+
toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
|
|
3006
|
+
const cachedProjection = jcs(cached);
|
|
3007
|
+
const freshProjection = jcs(fresh);
|
|
2845
3008
|
verdictAgreementOk = cachedProjection === freshProjection;
|
|
2846
3009
|
if (!verdictAgreementOk) {
|
|
2847
3010
|
errors.push(
|
|
@@ -2874,8 +3037,8 @@ async function verifyExportBody(
|
|
|
2874
3037
|
) {
|
|
2875
3038
|
status = "failed";
|
|
2876
3039
|
} else if (sourceResult.status === "partial") {
|
|
2877
|
-
// Undetermined source (withheld record
|
|
2878
|
-
// exit 3
|
|
3040
|
+
// Undetermined source (a withheld record, or an unreachable gateway):
|
|
3041
|
+
// exit 3 either way since 0.13.0. Never a failure.
|
|
2879
3042
|
status = "partial";
|
|
2880
3043
|
} else {
|
|
2881
3044
|
status = "verified";
|
|
@@ -3030,6 +3193,10 @@ function payloadFieldError(payload: Record<string, unknown>): string | null {
|
|
|
3030
3193
|
want = "1, 2 or 3";
|
|
3031
3194
|
}
|
|
3032
3195
|
if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
3196
|
+
if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
|
|
3197
|
+
// evidence-export §3.1 (v3.4): the form `signed_at` pins.
|
|
3198
|
+
return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
|
|
3199
|
+
}
|
|
3033
3200
|
}
|
|
3034
3201
|
return null;
|
|
3035
3202
|
}
|
|
@@ -3064,7 +3231,7 @@ async function verifyAttestation(
|
|
|
3064
3231
|
if (rec.signature_alg !== "rsa-pss-sha256") {
|
|
3065
3232
|
return {
|
|
3066
3233
|
result,
|
|
3067
|
-
malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
|
|
3234
|
+
malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg ?? null)}`,
|
|
3068
3235
|
};
|
|
3069
3236
|
}
|
|
3070
3237
|
const pub = rec.public_key as { kty?: unknown; n?: unknown; e?: unknown } | null;
|
|
@@ -3266,7 +3433,11 @@ function eventVerdictStatus(e: EventResult): string {
|
|
|
3266
3433
|
// nullish normalization) — a structural difference surfaces as disagreement,
|
|
3267
3434
|
// which is the correct tamper signal. Works on both the cached snake_case JSON
|
|
3268
3435
|
// and the freshly built VerdictObject.
|
|
3269
|
-
function projectVerdictForAgreement(v: unknown):
|
|
3436
|
+
function projectVerdictForAgreement(v: unknown): {
|
|
3437
|
+
schema_version: string | null;
|
|
3438
|
+
events: { content_ok: boolean | null }[];
|
|
3439
|
+
checkpoints: unknown[];
|
|
3440
|
+
} {
|
|
3270
3441
|
const o = (v ?? {}) as VerdictObject;
|
|
3271
3442
|
const events = Array.isArray(o.events) ? o.events : [];
|
|
3272
3443
|
const checkpoints = Array.isArray(o.checkpoints) ? o.checkpoints : [];
|
|
@@ -3304,6 +3475,69 @@ function projectVerdictForAgreement(v: unknown): unknown {
|
|
|
3304
3475
|
};
|
|
3305
3476
|
}
|
|
3306
3477
|
|
|
3478
|
+
// Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
|
|
3479
|
+
// whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
|
|
3480
|
+
// content binding decodes it, so an event whose record is withheld, not hex or
|
|
3481
|
+
// not JCS is not governed here either. The Python twin is
|
|
3482
|
+
// `evidence._source_events_governed`.
|
|
3483
|
+
function sourceEventsGoverned(source: unknown): boolean[] {
|
|
3484
|
+
if (source === null || typeof source !== "object" || Array.isArray(source)) return [];
|
|
3485
|
+
const s = source as { body_type?: unknown; body?: unknown };
|
|
3486
|
+
if (s.body_type !== ANCHOR_TRACE_BODY_TYPE) return [];
|
|
3487
|
+
const body = s.body as { events?: unknown } | null | undefined;
|
|
3488
|
+
const events =
|
|
3489
|
+
body !== null && typeof body === "object" && !Array.isArray(body) ? body.events : undefined;
|
|
3490
|
+
if (!Array.isArray(events)) return [];
|
|
3491
|
+
return events.map((raw: unknown) => {
|
|
3492
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return false;
|
|
3493
|
+
const ev = raw as { event_bytes?: unknown; envelope?: unknown };
|
|
3494
|
+
if (typeof ev.event_bytes !== "string") return false;
|
|
3495
|
+
try {
|
|
3496
|
+
const parsed = jcsParse(hexToBytes(ev.event_bytes));
|
|
3497
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
3498
|
+
const envelope = ev.envelope;
|
|
3499
|
+
const held: HeldRecord = {
|
|
3500
|
+
publicKey:
|
|
3501
|
+
envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
|
|
3502
|
+
? ((envelope as { public_key?: unknown }).public_key as string)
|
|
3503
|
+
: (undefined as unknown as string),
|
|
3504
|
+
record: parsed as HeldRecord["record"],
|
|
3505
|
+
ref: "",
|
|
3506
|
+
};
|
|
3507
|
+
return isHandoffNamespace(held);
|
|
3508
|
+
} catch {
|
|
3509
|
+
return false;
|
|
3510
|
+
}
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
// The one difference §5 step 5 does not read as a disagreement
|
|
3515
|
+
// (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
|
|
3516
|
+
// `null` against a recomputed `true`, on an event whose record §4.1 governs. A
|
|
3517
|
+
// kernel before A20 could not bind a hand-off record's disclosed message, so an
|
|
3518
|
+
// export it composed claims less than the recompute proves, and reading that
|
|
3519
|
+
// as tamper failed an honest export. Only that direction: a cached `true`
|
|
3520
|
+
// against a recomputed `false` or `null`, `null` against `false`, and any
|
|
3521
|
+
// difference on a record outside §4.1 still disagree. Edits `fresh` in place
|
|
3522
|
+
// to match `cached` on those events. The Python twin is
|
|
3523
|
+
// `evidence._tolerate_pre_a20_content`.
|
|
3524
|
+
function toleratePreA20Content(
|
|
3525
|
+
cached: { events: { content_ok: boolean | null }[] },
|
|
3526
|
+
fresh: { events: { content_ok: boolean | null }[] },
|
|
3527
|
+
governed: boolean[],
|
|
3528
|
+
): void {
|
|
3529
|
+
const n = Math.min(cached.events.length, fresh.events.length);
|
|
3530
|
+
for (let i = 0; i < n; i++) {
|
|
3531
|
+
if (
|
|
3532
|
+
governed[i] === true &&
|
|
3533
|
+
cached.events[i]!.content_ok === null &&
|
|
3534
|
+
fresh.events[i]!.content_ok === true
|
|
3535
|
+
) {
|
|
3536
|
+
fresh.events[i]!.content_ok = null;
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3307
3541
|
function summarizeExport(
|
|
3308
3542
|
status: EvidenceStatus,
|
|
3309
3543
|
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
|
+
}
|