@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/src/evidence.ts
CHANGED
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
deriveOperatorAddress,
|
|
21
21
|
ed25519Verify,
|
|
22
22
|
hexToBytes,
|
|
23
|
+
// The one hex-field test, which lives in `crypto.ts` beside the one hex
|
|
24
|
+
// reader (proof#146). It was a second copy here until then, with the same
|
|
25
|
+
// body: two copies of one rule is how two kernels drift apart one edit at a
|
|
26
|
+
// time, which is the whole defect class this fix is about.
|
|
27
|
+
isHexField,
|
|
23
28
|
sha256Hex,
|
|
24
29
|
utf8,
|
|
25
30
|
verifyRsaPssSha256,
|
|
@@ -30,6 +35,7 @@ import {
|
|
|
30
35
|
contentHashes,
|
|
31
36
|
exceedsDepth,
|
|
32
37
|
jcs,
|
|
38
|
+
jcsParse,
|
|
33
39
|
verifyEnvelope,
|
|
34
40
|
} from "./verifier.js";
|
|
35
41
|
import {
|
|
@@ -673,12 +679,23 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
|
|
|
673
679
|
});
|
|
674
680
|
}
|
|
675
681
|
|
|
676
|
-
|
|
682
|
+
/**
|
|
683
|
+
* A bundle this kernel cannot read.
|
|
684
|
+
*
|
|
685
|
+
* `wrapperOk` is true only where the wrapper signature and `body_hash` were
|
|
686
|
+
* CHECKED and held before the body was found unreadable. It is then reported
|
|
687
|
+
* as held, because it did: `signatureOk` and `bodyHashOk` are statements about
|
|
688
|
+
* the wrapper, and a body this kernel cannot parse does not unsign it. The
|
|
689
|
+
* case reader decides whose pack a bundle is on exactly those two fields
|
|
690
|
+
* (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
|
|
691
|
+
* broke its own body shape read as a forger.
|
|
692
|
+
*/
|
|
693
|
+
function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
|
|
677
694
|
return {
|
|
678
695
|
status: "malformed",
|
|
679
696
|
specVersionOk,
|
|
680
|
-
bodyHashOk:
|
|
681
|
-
signatureOk:
|
|
697
|
+
bodyHashOk: wrapperOk,
|
|
698
|
+
signatureOk: wrapperOk,
|
|
682
699
|
bodyType: null,
|
|
683
700
|
onChainChecked: false,
|
|
684
701
|
checkpoints: [],
|
|
@@ -688,8 +705,79 @@ function malformed(error: string, specVersionOk = false): EvidenceBundleResult {
|
|
|
688
705
|
};
|
|
689
706
|
}
|
|
690
707
|
|
|
691
|
-
|
|
692
|
-
|
|
708
|
+
/**
|
|
709
|
+
* The prefixes of the exception text this kernel writes ITSELF, in the words
|
|
710
|
+
* the Python kernel writes too. Anything outside this set comes from the host:
|
|
711
|
+
* a JSON parser, a codec, a crypto library. See `thrownDetail`.
|
|
712
|
+
*/
|
|
713
|
+
const KERNEL_WORDING = ["hexToBytes: "];
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The detail a finding may append about a caught exception.
|
|
717
|
+
*
|
|
718
|
+
* THE RULE: a finding carries the text of a caught exception only where this
|
|
719
|
+
* kernel wrote that text. A finding travels into a case reading through `case`
|
|
720
|
+
* `reasons`, and both kernels promise one reading and one rendered sentence for
|
|
721
|
+
* one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
|
|
722
|
+
* not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
|
|
723
|
+
* for one pack anyone can build (gate 4 on proof#140, finding 1).
|
|
724
|
+
*
|
|
725
|
+
* Returns `": <text>"` for this kernel's own wording and the empty string for
|
|
726
|
+
* anything else. The label at the call site already names the check that
|
|
727
|
+
* failed and both kernels agree on it character for character; what is dropped
|
|
728
|
+
* is the host's parse offset, which no reader can act on and which was never
|
|
729
|
+
* the same in the two kernels.
|
|
730
|
+
*/
|
|
731
|
+
function thrownDetail(e: unknown): string {
|
|
732
|
+
const text = e instanceof Error ? e.message : String(e);
|
|
733
|
+
return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Decode `bytes` as UTF-8 the way every other kernel does: a byte sequence that
|
|
738
|
+
* is not valid UTF-8 THROWS, and a leading byte-order mark is kept rather than
|
|
739
|
+
* eaten.
|
|
740
|
+
*
|
|
741
|
+
* ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
|
|
742
|
+
* every set of signed bytes in this kernel now; this function survives only for
|
|
743
|
+
* the canonicality comparison on the stamp path, which needs the DECODED TEXT
|
|
744
|
+
* of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
|
|
745
|
+
* performs exactly the decode `jcsParse` performs, so the comparison cannot
|
|
746
|
+
* read the bytes differently from the parse that produced `body`.
|
|
747
|
+
*
|
|
748
|
+
* `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
|
|
749
|
+
* every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
|
|
750
|
+
* one raw `0x80` inside a string value read `verified` here and `failed` there
|
|
751
|
+
* because of it (gate 4 on proof#140). The symmetric claim the first version of
|
|
752
|
+
* this comment made, that "Python's `json.loads` raises on both", is FALSE and
|
|
753
|
+
* is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
|
|
754
|
+
* too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
|
|
755
|
+
* is stated once, in `jcsParse`.
|
|
756
|
+
*/
|
|
757
|
+
function decodeUtf8Strict(bytes: Uint8Array): string {
|
|
758
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Name an envelope `public_key` or `signature` spelled outside the hex charset
|
|
763
|
+
* or at the wrong length (proof#142). This kernel's envelope layer already
|
|
764
|
+
* fails such an envelope, and the Python one's decoder accepts ASCII
|
|
765
|
+
* whitespace (proof#135), so both bundle verifiers name it here, in the same
|
|
766
|
+
* words, and the two kernels give one reading. Only a STRING is checked: a
|
|
767
|
+
* missing or non-string field is the envelope layer's to fail, and it does.
|
|
768
|
+
*/
|
|
769
|
+
function envelopeHexError(what: string, envelope: unknown): string | null {
|
|
770
|
+
if (!isJsonObject(envelope)) return null;
|
|
771
|
+
for (const [name, nibbles] of [
|
|
772
|
+
["public_key", 64],
|
|
773
|
+
["signature", 128],
|
|
774
|
+
] as const) {
|
|
775
|
+
const value = envelope[name];
|
|
776
|
+
if (typeof value === "string" && !isHexField(value, nibbles)) {
|
|
777
|
+
return `${what} envelope ${name} is not ${nibbles} hex characters`;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return null;
|
|
693
781
|
}
|
|
694
782
|
|
|
695
783
|
/**
|
|
@@ -764,26 +852,44 @@ async function verifyBundle(
|
|
|
764
852
|
// --- Step 2: verify the wrapper signature + recompute body_hash ------------
|
|
765
853
|
const errors: string[] = [];
|
|
766
854
|
|
|
855
|
+
// Every wrapper hex field is read STRICTLY, before any decoder sees it
|
|
856
|
+
// (proof#142). The Python decoder skipped ASCII whitespace, and the wrapper
|
|
857
|
+
// `signature` sits outside the signed scope, so one space in it, added by
|
|
858
|
+
// anyone and with no key, made a pack verify there and fail here. The same
|
|
859
|
+
// named failure in both kernels, for each field: `public_key` and
|
|
860
|
+
// `signature` are 64 and 128 hex characters in either case (proof#135,
|
|
861
|
+
// option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
|
|
862
|
+
// recomputed in and compared against.
|
|
767
863
|
let bodyHashOk = false;
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
864
|
+
if (!isHexField(b.body_hash, 64, true)) {
|
|
865
|
+
errors.push("wrapper body_hash is not 64 lowercase hex characters");
|
|
866
|
+
} else {
|
|
867
|
+
try {
|
|
868
|
+
const recomputed = await sha256Hex(utf8(jcs(b.body)));
|
|
869
|
+
bodyHashOk = recomputed === b.body_hash;
|
|
870
|
+
if (!bodyHashOk) {
|
|
871
|
+
errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
|
|
872
|
+
}
|
|
873
|
+
} catch (e) {
|
|
874
|
+
errors.push(`body canonicalization failed${thrownDetail(e)}`);
|
|
773
875
|
}
|
|
774
|
-
} catch (e) {
|
|
775
|
-
errors.push(`body canonicalization failed: ${stringifyErr(e)}`);
|
|
776
876
|
}
|
|
777
877
|
|
|
778
878
|
let signatureOk = false;
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
879
|
+
const keyFormOk = isHexField(b.public_key, 64);
|
|
880
|
+
const signatureFormOk = isHexField(b.signature, 128);
|
|
881
|
+
if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
|
|
882
|
+
if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
|
|
883
|
+
if (keyFormOk && signatureFormOk) {
|
|
884
|
+
try {
|
|
885
|
+
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
|
|
886
|
+
co_signatures?: unknown;
|
|
887
|
+
};
|
|
888
|
+
signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
|
|
889
|
+
if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
|
|
890
|
+
} catch (e) {
|
|
891
|
+
errors.push(`wrapper signature verification error${thrownDetail(e)}`);
|
|
892
|
+
}
|
|
787
893
|
}
|
|
788
894
|
|
|
789
895
|
const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
|
|
@@ -939,39 +1045,52 @@ type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
|
|
|
939
1045
|
async function resolveCheckpointKey(
|
|
940
1046
|
cp: AnyTraceCheckpoint,
|
|
941
1047
|
eventBytes: Uint8Array | undefined,
|
|
942
|
-
): Promise<{
|
|
1048
|
+
): Promise<{
|
|
1049
|
+
key: string | null;
|
|
1050
|
+
anchorRef: AnchorRef | null;
|
|
1051
|
+
anchorRefMalformed: boolean;
|
|
1052
|
+
errors: string[];
|
|
1053
|
+
}> {
|
|
943
1054
|
const errors: string[] = [];
|
|
1055
|
+
// Every early return below leaves before `anchor_ref` is ever looked at, so
|
|
1056
|
+
// nothing there is "malformed": it is unexamined, which is not the same claim.
|
|
1057
|
+
const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
|
|
944
1058
|
|
|
945
1059
|
if (cp.tx_id !== undefined) {
|
|
946
1060
|
errors.push(
|
|
947
1061
|
`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`,
|
|
948
1062
|
);
|
|
949
|
-
return
|
|
1063
|
+
return unexamined;
|
|
950
1064
|
}
|
|
951
1065
|
const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
|
|
952
1066
|
if (legacy !== null) {
|
|
953
1067
|
errors.push(legacy);
|
|
954
|
-
return
|
|
1068
|
+
return unexamined;
|
|
955
1069
|
}
|
|
956
1070
|
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
957
1071
|
errors.push("checkpoint missing checkpoint_ref");
|
|
958
|
-
return
|
|
1072
|
+
return unexamined;
|
|
959
1073
|
}
|
|
960
1074
|
if (eventBytes === undefined) {
|
|
961
1075
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
962
1076
|
errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
|
|
963
|
-
return
|
|
1077
|
+
return unexamined;
|
|
964
1078
|
}
|
|
965
1079
|
const derived = await sha256Hex(eventBytes);
|
|
966
1080
|
if (derived !== cp.checkpoint_ref.toLowerCase()) {
|
|
967
1081
|
errors.push(
|
|
968
1082
|
`checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
|
|
969
1083
|
);
|
|
970
|
-
return
|
|
1084
|
+
return unexamined;
|
|
971
1085
|
}
|
|
972
1086
|
|
|
973
|
-
const
|
|
974
|
-
return {
|
|
1087
|
+
const parsed = parseAnchorRef(cp.anchor_ref, errors);
|
|
1088
|
+
return {
|
|
1089
|
+
key: derived,
|
|
1090
|
+
anchorRef: parsed.ref,
|
|
1091
|
+
anchorRefMalformed: parsed.malformed,
|
|
1092
|
+
errors,
|
|
1093
|
+
};
|
|
975
1094
|
}
|
|
976
1095
|
|
|
977
1096
|
/**
|
|
@@ -991,31 +1110,50 @@ async function resolveCheckpointKey(
|
|
|
991
1110
|
* So this validates the SHAPE and never the vocabulary. A malformed
|
|
992
1111
|
* `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
|
|
993
1112
|
* the reader, and only the first is the bundle's fault.
|
|
1113
|
+
*
|
|
1114
|
+
* ── Absent and malformed are not the same null (proof#120) ──
|
|
1115
|
+
*
|
|
1116
|
+
* Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
|
|
1117
|
+
* way downstream: an all-`unreachable` on-chain result, although no gateway was
|
|
1118
|
+
* asked in either case. For the ABSENT case that is at least the note
|
|
1119
|
+
* `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
|
|
1120
|
+
* For the MALFORMED case it is a manufactured outage: the bundle named a
|
|
1121
|
+
* witness, the name was unreadable, and the reader was told the public record
|
|
1122
|
+
* could not be reached. So the two come back as different facts, and `malformed`
|
|
1123
|
+
* carries the difference.
|
|
994
1124
|
*/
|
|
995
|
-
function parseAnchorRef(
|
|
996
|
-
|
|
1125
|
+
function parseAnchorRef(
|
|
1126
|
+
raw: unknown,
|
|
1127
|
+
errors: string[],
|
|
1128
|
+
): { ref: AnchorRef | null; malformed: boolean } {
|
|
1129
|
+
if (raw === undefined || raw === null) {
|
|
1130
|
+
return { ref: null, malformed: false }; // placed, not yet witnessed
|
|
1131
|
+
}
|
|
997
1132
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
998
1133
|
errors.push("anchor_ref must be an object");
|
|
999
|
-
return null;
|
|
1134
|
+
return { ref: null, malformed: true };
|
|
1000
1135
|
}
|
|
1001
1136
|
const o = raw as Record<string, unknown>;
|
|
1002
1137
|
if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
|
|
1003
1138
|
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
|
|
1004
|
-
return null;
|
|
1139
|
+
return { ref: null, malformed: true };
|
|
1005
1140
|
}
|
|
1006
1141
|
if (typeof o.id !== "string" || o.id === "") {
|
|
1007
1142
|
errors.push("anchor_ref.id is missing or empty");
|
|
1008
|
-
return null;
|
|
1143
|
+
return { ref: null, malformed: true };
|
|
1009
1144
|
}
|
|
1010
1145
|
const ref = o.ref;
|
|
1011
1146
|
if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
|
|
1012
1147
|
errors.push("anchor_ref.ref must be an object when present");
|
|
1013
|
-
return null;
|
|
1148
|
+
return { ref: null, malformed: true };
|
|
1014
1149
|
}
|
|
1015
1150
|
return {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1151
|
+
ref: {
|
|
1152
|
+
kind: o.kind,
|
|
1153
|
+
id: o.id,
|
|
1154
|
+
...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
|
|
1155
|
+
},
|
|
1156
|
+
malformed: false,
|
|
1019
1157
|
};
|
|
1020
1158
|
}
|
|
1021
1159
|
|
|
@@ -1029,7 +1167,7 @@ async function verifyAnchorTrace(
|
|
|
1029
1167
|
const body = b.body as AnchorTraceBody;
|
|
1030
1168
|
const bodyType = ANCHOR_TRACE_BODY_TYPE;
|
|
1031
1169
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
1032
|
-
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
|
|
1170
|
+
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true, true);
|
|
1033
1171
|
}
|
|
1034
1172
|
|
|
1035
1173
|
const gateways = options.gateways ?? [];
|
|
@@ -1046,6 +1184,11 @@ async function verifyAnchorTrace(
|
|
|
1046
1184
|
const byKey = new Map<string, AnyTraceCheckpoint>();
|
|
1047
1185
|
const anchorByKey = new Map<string, AnchorRef | null>();
|
|
1048
1186
|
const checkpointResults: CheckpointResult[] = [];
|
|
1187
|
+
// proof#120. Kept beside the results rather than on them: "the bundle named
|
|
1188
|
+
// a witness we could not read" is an input defect the checkpoint's own
|
|
1189
|
+
// `errors` already states, not a new dimension of the published verdict, and
|
|
1190
|
+
// `CheckpointResult` is a shape downstream surfaces render.
|
|
1191
|
+
const malformedAnchorRef = new Set<CheckpointResult>();
|
|
1049
1192
|
for (const rawCp of body.checkpoints as unknown[]) {
|
|
1050
1193
|
// `checkpoints[]` is attacker-shaped, and a verifier must never raise on
|
|
1051
1194
|
// adversarial input: a null entry used to reach `cp.tx_id` and hand the
|
|
@@ -1079,7 +1222,7 @@ async function verifyAnchorTrace(
|
|
|
1079
1222
|
try {
|
|
1080
1223
|
if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
|
|
1081
1224
|
} catch (e) {
|
|
1082
|
-
cpErrors.push(`checkpoint event_bytes is not hex
|
|
1225
|
+
cpErrors.push(`checkpoint event_bytes is not hex${thrownDetail(e)}`);
|
|
1083
1226
|
}
|
|
1084
1227
|
|
|
1085
1228
|
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
@@ -1114,11 +1257,14 @@ async function verifyAnchorTrace(
|
|
|
1114
1257
|
// A checkpoint must fully bind: signature + the committed record matching
|
|
1115
1258
|
// payload_hash. An undetermined binding (no record) is not enough to
|
|
1116
1259
|
// anchor the events' inclusion claims to a trustworthy root.
|
|
1117
|
-
|
|
1260
|
+
const hexError = envelopeHexError("checkpoint", cp.envelope);
|
|
1261
|
+
envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
|
|
1118
1262
|
// See the event loop below: a degenerate envelope is a malformed input,
|
|
1119
1263
|
// not a failed check (proof#76).
|
|
1120
1264
|
if (!isJsonObject(cp.envelope)) {
|
|
1121
1265
|
cpErrors.push("checkpoint envelope is not a JSON object");
|
|
1266
|
+
} else if (hexError !== null) {
|
|
1267
|
+
cpErrors.push(hexError);
|
|
1122
1268
|
} else if (!res.signatureOk) {
|
|
1123
1269
|
cpErrors.push("checkpoint envelope signature failed");
|
|
1124
1270
|
}
|
|
@@ -1141,14 +1287,14 @@ async function verifyAnchorTrace(
|
|
|
1141
1287
|
);
|
|
1142
1288
|
}
|
|
1143
1289
|
} catch (e) {
|
|
1144
|
-
cpErrors.push(`checkpoint envelope verify error
|
|
1290
|
+
cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
|
|
1145
1291
|
}
|
|
1146
1292
|
|
|
1147
1293
|
// The committed record's merkle_root must equal the checkpoint's claimed
|
|
1148
1294
|
// merkle_root (recompute, don't trust the bundle's top-level claim).
|
|
1149
1295
|
if (eventBytes !== undefined) {
|
|
1150
1296
|
try {
|
|
1151
|
-
const record =
|
|
1297
|
+
const record = jcsParse(eventBytes) as {
|
|
1152
1298
|
event?: { merkle_root?: unknown };
|
|
1153
1299
|
};
|
|
1154
1300
|
const committedRoot = record.event?.merkle_root;
|
|
@@ -1160,7 +1306,7 @@ async function verifyAnchorTrace(
|
|
|
1160
1306
|
);
|
|
1161
1307
|
}
|
|
1162
1308
|
} catch (e) {
|
|
1163
|
-
cpErrors.push(`checkpoint record is not JSON
|
|
1309
|
+
cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
|
|
1164
1310
|
}
|
|
1165
1311
|
}
|
|
1166
1312
|
|
|
@@ -1180,6 +1326,9 @@ async function verifyAnchorTrace(
|
|
|
1180
1326
|
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
|
|
1181
1327
|
errors: cpErrors,
|
|
1182
1328
|
});
|
|
1329
|
+
if (keyed.anchorRefMalformed) {
|
|
1330
|
+
malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
|
|
1331
|
+
}
|
|
1183
1332
|
}
|
|
1184
1333
|
|
|
1185
1334
|
// --- Step 4: per-event ------------------------------------------------------
|
|
@@ -1212,18 +1361,37 @@ async function verifyAnchorTrace(
|
|
|
1212
1361
|
const legacyError = legacyEventBytesError(ev, "event", bodyType);
|
|
1213
1362
|
if (legacyError !== null) evErrors.push(legacyError);
|
|
1214
1363
|
|
|
1364
|
+
// MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
|
|
1365
|
+
//
|
|
1366
|
+
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
1367
|
+
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
1368
|
+
// line down. So an event carrying garbage in place of its record used to
|
|
1369
|
+
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
1370
|
+
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
1371
|
+
// a record nobody could read. The only thing wrong with the pack was the
|
|
1372
|
+
// bytes, and it was the one thing the verdict did not say.
|
|
1373
|
+
//
|
|
1374
|
+
// A record the producer chose not to disclose is the customer exercising
|
|
1375
|
+
// minimum disclosure. A record that is present and unreadable is a defect
|
|
1376
|
+
// in the artifact, and this kernel's standing rule (the same one
|
|
1377
|
+
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
1378
|
+
// we can see fails.
|
|
1215
1379
|
let eventBytes: Uint8Array | undefined;
|
|
1380
|
+
let eventBytesMalformed = false;
|
|
1216
1381
|
try {
|
|
1217
1382
|
if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
|
|
1218
1383
|
} catch (e) {
|
|
1219
|
-
|
|
1384
|
+
eventBytesMalformed = true;
|
|
1385
|
+
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1220
1386
|
}
|
|
1221
1387
|
try {
|
|
1222
1388
|
const res = await verifyEnvelope(
|
|
1223
1389
|
ev.envelope,
|
|
1224
1390
|
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1225
1391
|
);
|
|
1226
|
-
|
|
1392
|
+
const hexError = envelopeHexError("event", ev.envelope);
|
|
1393
|
+
// ok tolerates an undetermined binding (withheld record)
|
|
1394
|
+
envelopeOk = res.ok && hexError === null;
|
|
1227
1395
|
payloadBindingOk = res.payloadHashOk;
|
|
1228
1396
|
// P-10: report what could not be determined, never a fault that was never
|
|
1229
1397
|
// observed. There is no envelope here, so no signature was checked and no
|
|
@@ -1234,6 +1402,8 @@ async function verifyAnchorTrace(
|
|
|
1234
1402
|
// the producer at all (proof#76).
|
|
1235
1403
|
if (!isJsonObject(ev.envelope)) {
|
|
1236
1404
|
evErrors.push("event envelope is not a JSON object");
|
|
1405
|
+
} else if (hexError !== null) {
|
|
1406
|
+
evErrors.push(hexError);
|
|
1237
1407
|
} else if (!res.signatureOk) {
|
|
1238
1408
|
evErrors.push("event envelope signature failed");
|
|
1239
1409
|
}
|
|
@@ -1244,7 +1414,7 @@ async function verifyAnchorTrace(
|
|
|
1244
1414
|
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
1245
1415
|
if (specFinding !== null) evErrors.push(specFinding);
|
|
1246
1416
|
} catch (e) {
|
|
1247
|
-
evErrors.push(`event envelope verify error
|
|
1417
|
+
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
1248
1418
|
}
|
|
1249
1419
|
|
|
1250
1420
|
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
@@ -1307,7 +1477,7 @@ async function verifyAnchorTrace(
|
|
|
1307
1477
|
);
|
|
1308
1478
|
}
|
|
1309
1479
|
} catch (e) {
|
|
1310
|
-
evErrors.push(`inclusion verify error
|
|
1480
|
+
evErrors.push(`inclusion verify error${thrownDetail(e)}`);
|
|
1311
1481
|
}
|
|
1312
1482
|
}
|
|
1313
1483
|
|
|
@@ -1328,7 +1498,15 @@ async function verifyAnchorTrace(
|
|
|
1328
1498
|
// present checkpoint, and its disclosed content did not MISMATCH. A
|
|
1329
1499
|
// withheld record or undisclosed content (binding/content null) does not
|
|
1330
1500
|
// fail it.
|
|
1331
|
-
|
|
1501
|
+
// `!eventBytesMalformed`: present-and-unreadable record bytes are a
|
|
1502
|
+
// defect in the artifact, never a disclosure choice (proof#85).
|
|
1503
|
+
ok:
|
|
1504
|
+
envelopeOk &&
|
|
1505
|
+
inclusionOk &&
|
|
1506
|
+
checkpointBound &&
|
|
1507
|
+
contentOk !== false &&
|
|
1508
|
+
legacyError === null &&
|
|
1509
|
+
!eventBytesMalformed,
|
|
1332
1510
|
errors: evErrors,
|
|
1333
1511
|
});
|
|
1334
1512
|
}
|
|
@@ -1352,6 +1530,20 @@ async function verifyAnchorTrace(
|
|
|
1352
1530
|
// the run to partial) and NEVER as a mismatch. Treating "we never
|
|
1353
1531
|
// anchored it yet" as "the chain disagrees" would be the same false
|
|
1354
1532
|
// accusation this whole change is removing.
|
|
1533
|
+
// proof#120. An `anchor_ref` that failed structural validation is not
|
|
1534
|
+
// an absent one. Nothing was fetched, so the witness dimension is NOT
|
|
1535
|
+
// CHECKED — `onChain: null`, exactly as when the caller supplied no
|
|
1536
|
+
// adapter — and it must not raise `gatewayUnavailable`, which is what
|
|
1537
|
+
// puts "could not be re-fetched from the supplied gateways" on the
|
|
1538
|
+
// bundle. Reporting an outage nobody observed is a claim about the
|
|
1539
|
+
// public record made out of a defect in the artifact, and verify-page
|
|
1540
|
+
// renders it word for word. The run still fails: the structural error
|
|
1541
|
+
// is already in `cpr.errors` and already in `ok` above.
|
|
1542
|
+
if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
|
|
1543
|
+
cpr.onChain = null;
|
|
1544
|
+
cpr.onChainOk = null;
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1355
1547
|
if (cpr.anchorRef === null) {
|
|
1356
1548
|
gatewayUnavailable = true;
|
|
1357
1549
|
cpr.onChain = {
|
|
@@ -1523,7 +1715,7 @@ async function verifyDisclosedContent(
|
|
|
1523
1715
|
let recordContentHash: string | undefined;
|
|
1524
1716
|
if (eventBytes !== undefined) {
|
|
1525
1717
|
try {
|
|
1526
|
-
const record =
|
|
1718
|
+
const record = jcsParse(eventBytes) as {
|
|
1527
1719
|
event?: { content_hash?: unknown };
|
|
1528
1720
|
};
|
|
1529
1721
|
const ch = record.event?.content_hash;
|
|
@@ -1632,7 +1824,7 @@ async function verifyStampEntry(
|
|
|
1632
1824
|
try {
|
|
1633
1825
|
bytes = hexToBytes(e.stamp_bytes);
|
|
1634
1826
|
} catch (err) {
|
|
1635
|
-
errors.push(`stamp_bytes is not hex
|
|
1827
|
+
errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
|
|
1636
1828
|
return { result: base };
|
|
1637
1829
|
}
|
|
1638
1830
|
// Gated by `isLowerHex` above, so this is already the exact lowercase hex of
|
|
@@ -1665,7 +1857,7 @@ async function verifyStampEntry(
|
|
|
1665
1857
|
// --- the body itself ------------------------------------------------------
|
|
1666
1858
|
let body: StampBody;
|
|
1667
1859
|
try {
|
|
1668
|
-
const parsed: unknown =
|
|
1860
|
+
const parsed: unknown = jcsParse(bytes);
|
|
1669
1861
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1670
1862
|
errors.push("stamp body is not a JSON object");
|
|
1671
1863
|
return { result: base, witnessedHex };
|
|
@@ -1675,12 +1867,12 @@ async function verifyStampEntry(
|
|
|
1675
1867
|
// every submit attempt for exactly this reason: a non-canonical body makes
|
|
1676
1868
|
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
1677
1869
|
// of the stamp disagree with the one that was witnessed.
|
|
1678
|
-
if (jcs(body as unknown as Record<string, unknown>) !==
|
|
1870
|
+
if (jcs(body as unknown as Record<string, unknown>) !== decodeUtf8Strict(bytes)) {
|
|
1679
1871
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
1680
1872
|
return { result: base, witnessedHex };
|
|
1681
1873
|
}
|
|
1682
1874
|
} catch (err) {
|
|
1683
|
-
errors.push(`stamp body is not JSON
|
|
1875
|
+
errors.push(`stamp body is not JSON${thrownDetail(err)}`);
|
|
1684
1876
|
return { result: base, witnessedHex };
|
|
1685
1877
|
}
|
|
1686
1878
|
|
|
@@ -1723,7 +1915,10 @@ async function verifyStampEntry(
|
|
|
1723
1915
|
// --- the sub-tree ranges --------------------------------------------------
|
|
1724
1916
|
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
1725
1917
|
|
|
1726
|
-
|
|
1918
|
+
// A stamp needs no malformed/absent split: the §5.2 re-fetch path already
|
|
1919
|
+
// leaves `onChain` null whenever there is no `AnchorRef`, so it never
|
|
1920
|
+
// synthesised the outage proof#120 is about.
|
|
1921
|
+
base.anchorRef = parseAnchorRef(e.anchor_ref, errors).ref;
|
|
1727
1922
|
|
|
1728
1923
|
base.ok =
|
|
1729
1924
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
@@ -1819,7 +2014,7 @@ async function verifyStampTrace(
|
|
|
1819
2014
|
): Promise<EvidenceBundleResult> {
|
|
1820
2015
|
const body = b.body as StampTraceBody;
|
|
1821
2016
|
if (!Array.isArray(body?.stamps)) {
|
|
1822
|
-
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
2017
|
+
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
|
|
1823
2018
|
}
|
|
1824
2019
|
|
|
1825
2020
|
const seen = new Set<string>();
|
|
@@ -1922,7 +2117,11 @@ async function verifyStampEvidence(
|
|
|
1922
2117
|
): Promise<EvidenceBundleResult> {
|
|
1923
2118
|
const body = b.body as StampEvidenceBody;
|
|
1924
2119
|
if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
|
|
1925
|
-
return malformed(
|
|
2120
|
+
return malformed(
|
|
2121
|
+
`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`,
|
|
2122
|
+
true,
|
|
2123
|
+
true,
|
|
2124
|
+
);
|
|
1926
2125
|
}
|
|
1927
2126
|
|
|
1928
2127
|
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
@@ -1982,18 +2181,37 @@ async function verifyStampEvidence(
|
|
|
1982
2181
|
const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
|
|
1983
2182
|
if (legacyError !== null) evErrors.push(legacyError);
|
|
1984
2183
|
|
|
2184
|
+
// MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
|
|
2185
|
+
//
|
|
2186
|
+
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
2187
|
+
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
2188
|
+
// line down. So an event carrying garbage in place of its record used to
|
|
2189
|
+
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
2190
|
+
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
2191
|
+
// a record nobody could read. The only thing wrong with the pack was the
|
|
2192
|
+
// bytes, and it was the one thing the verdict did not say.
|
|
2193
|
+
//
|
|
2194
|
+
// A record the producer chose not to disclose is the customer exercising
|
|
2195
|
+
// minimum disclosure. A record that is present and unreadable is a defect
|
|
2196
|
+
// in the artifact, and this kernel's standing rule (the same one
|
|
2197
|
+
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
2198
|
+
// we can see fails.
|
|
1985
2199
|
let eventBytes: Uint8Array | undefined;
|
|
2200
|
+
let eventBytesMalformed = false;
|
|
1986
2201
|
try {
|
|
1987
2202
|
if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
|
|
1988
2203
|
} catch (e) {
|
|
1989
|
-
|
|
2204
|
+
eventBytesMalformed = true;
|
|
2205
|
+
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1990
2206
|
}
|
|
1991
2207
|
try {
|
|
1992
2208
|
const res = await verifyEnvelope(
|
|
1993
2209
|
ev.envelope,
|
|
1994
2210
|
eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
|
|
1995
2211
|
);
|
|
1996
|
-
|
|
2212
|
+
const hexError = envelopeHexError("event", ev.envelope);
|
|
2213
|
+
// tolerates an undetermined binding (withheld record)
|
|
2214
|
+
envelopeOk = res.ok && hexError === null;
|
|
1997
2215
|
payloadBindingOk = res.payloadHashOk;
|
|
1998
2216
|
// P-10: report what could not be determined, never a fault that was never
|
|
1999
2217
|
// observed. There is no envelope here, so no signature was checked and no
|
|
@@ -2004,6 +2222,8 @@ async function verifyStampEvidence(
|
|
|
2004
2222
|
// the producer at all (proof#76).
|
|
2005
2223
|
if (!isJsonObject(ev.envelope)) {
|
|
2006
2224
|
evErrors.push("event envelope is not a JSON object");
|
|
2225
|
+
} else if (hexError !== null) {
|
|
2226
|
+
evErrors.push(hexError);
|
|
2007
2227
|
} else if (!res.signatureOk) {
|
|
2008
2228
|
evErrors.push("event envelope signature failed");
|
|
2009
2229
|
}
|
|
@@ -2014,7 +2234,7 @@ async function verifyStampEvidence(
|
|
|
2014
2234
|
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
2015
2235
|
if (specFinding !== null) evErrors.push(specFinding);
|
|
2016
2236
|
} catch (e) {
|
|
2017
|
-
evErrors.push(`event envelope verify error
|
|
2237
|
+
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
2018
2238
|
}
|
|
2019
2239
|
|
|
2020
2240
|
// --- The join (§5.3.3 rule 1) -------------------------------------------
|
|
@@ -2077,7 +2297,7 @@ async function verifyStampEvidence(
|
|
|
2077
2297
|
);
|
|
2078
2298
|
}
|
|
2079
2299
|
} catch (e) {
|
|
2080
|
-
evErrors.push(`inclusion verify error
|
|
2300
|
+
evErrors.push(`inclusion verify error${thrownDetail(e)}`);
|
|
2081
2301
|
}
|
|
2082
2302
|
}
|
|
2083
2303
|
}
|
|
@@ -2094,7 +2314,15 @@ async function verifyStampEvidence(
|
|
|
2094
2314
|
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
2095
2315
|
checkpointBound: stampBound,
|
|
2096
2316
|
contentOk,
|
|
2097
|
-
|
|
2317
|
+
// `!eventBytesMalformed`: present-and-unreadable record bytes are a
|
|
2318
|
+
// defect in the artifact, never a disclosure choice (proof#85).
|
|
2319
|
+
ok:
|
|
2320
|
+
envelopeOk &&
|
|
2321
|
+
inclusionOk &&
|
|
2322
|
+
stampBound &&
|
|
2323
|
+
contentOk !== false &&
|
|
2324
|
+
legacyError === null &&
|
|
2325
|
+
!eventBytesMalformed,
|
|
2098
2326
|
errors: evErrors,
|
|
2099
2327
|
});
|
|
2100
2328
|
}
|
|
@@ -2574,7 +2802,7 @@ async function verifyExportBody(
|
|
|
2574
2802
|
);
|
|
2575
2803
|
}
|
|
2576
2804
|
} catch (e) {
|
|
2577
|
-
errors.push(`source_bundle canonicalization failed
|
|
2805
|
+
errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
|
|
2578
2806
|
}
|
|
2579
2807
|
|
|
2580
2808
|
// --- Step 4: recompute the source verdict ---------------------------------
|
|
@@ -2622,7 +2850,7 @@ async function verifyExportBody(
|
|
|
2622
2850
|
);
|
|
2623
2851
|
}
|
|
2624
2852
|
} catch (e) {
|
|
2625
|
-
errors.push(`verdict agreement comparison failed
|
|
2853
|
+
errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
|
|
2626
2854
|
}
|
|
2627
2855
|
|
|
2628
2856
|
// --- Step 7 already folded in by the source recompute ---------------------
|
|
@@ -2867,13 +3095,20 @@ async function verifyAttestation(
|
|
|
2867
3095
|
try {
|
|
2868
3096
|
payloadBytes = utf8(jcs(payload));
|
|
2869
3097
|
} catch (e) {
|
|
2870
|
-
return { result, malformed: `attestation payload canonicalization failed
|
|
3098
|
+
return { result, malformed: `attestation payload canonicalization failed${thrownDetail(e)}` };
|
|
2871
3099
|
}
|
|
2872
3100
|
try {
|
|
2873
3101
|
result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
|
|
2874
3102
|
if (!result.signatureOk) errs.push("attestation RSA-PSS signature verification failed");
|
|
2875
|
-
} catch
|
|
2876
|
-
|
|
3103
|
+
} catch {
|
|
3104
|
+
// A FIXED sentence, not the library's. This kernel throws
|
|
3105
|
+
// `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
|
|
3106
|
+
// raises `malformed signature hex: ...`, so quoting either one split the
|
|
3107
|
+
// two kernels on a finding a holder can put in an export body it signs,
|
|
3108
|
+
// and an export pack's findings reach a case reading through `reasons`
|
|
3109
|
+
// (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
|
|
3110
|
+
// get this kernel's own words.
|
|
3111
|
+
return { result, malformed: "attestation signature or key is malformed" };
|
|
2877
3112
|
}
|
|
2878
3113
|
|
|
2879
3114
|
// (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
|
|
@@ -2886,7 +3121,7 @@ async function verifyAttestation(
|
|
|
2886
3121
|
);
|
|
2887
3122
|
}
|
|
2888
3123
|
} catch (e) {
|
|
2889
|
-
return { result, malformed: `attestation operator key
|
|
3124
|
+
return { result, malformed: `attestation operator key${thrownDetail(e)}` };
|
|
2890
3125
|
}
|
|
2891
3126
|
|
|
2892
3127
|
// (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
|
|
@@ -2911,7 +3146,7 @@ async function verifyAttestation(
|
|
|
2911
3146
|
);
|
|
2912
3147
|
}
|
|
2913
3148
|
} catch (e) {
|
|
2914
|
-
errs.push(`cannot recompute checkpoint content hash
|
|
3149
|
+
errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
|
|
2915
3150
|
}
|
|
2916
3151
|
}
|
|
2917
3152
|
|
|
@@ -3278,7 +3513,7 @@ async function probeGateway(
|
|
|
3278
3513
|
// the load-bearing check.
|
|
3279
3514
|
if (toHex(onChainBytes) === expectedHex) return "confirm";
|
|
3280
3515
|
try {
|
|
3281
|
-
const parsed =
|
|
3516
|
+
const parsed = jcsParse(onChainBytes);
|
|
3282
3517
|
if (toHex(utf8(jcs(parsed))) === expectedHex) return "confirm";
|
|
3283
3518
|
} catch {
|
|
3284
3519
|
// not JSON / not parseable — a genuine mismatch
|
|
@@ -3344,7 +3579,7 @@ function selectDisclosedBytes(
|
|
|
3344
3579
|
try {
|
|
3345
3580
|
inBody = hexToBytes(ev.content);
|
|
3346
3581
|
} catch (e) {
|
|
3347
|
-
errors.push(`event content is not hex
|
|
3582
|
+
errors.push(`event content is not hex${thrownDetail(e)}`);
|
|
3348
3583
|
}
|
|
3349
3584
|
}
|
|
3350
3585
|
let side: Uint8Array | undefined;
|
|
@@ -3355,7 +3590,7 @@ function selectDisclosedBytes(
|
|
|
3355
3590
|
try {
|
|
3356
3591
|
side = hexToBytes(sideEntry.value);
|
|
3357
3592
|
} catch (e) {
|
|
3358
|
-
errors.push(`supplied content for ${eventId} is not hex
|
|
3593
|
+
errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
|
|
3359
3594
|
}
|
|
3360
3595
|
}
|
|
3361
3596
|
// `offered` comes from the SAME test that produced the value, never from
|