@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/dist/evidence.js
CHANGED
|
@@ -21,13 +21,15 @@ import { deriveOperatorAddress, ed25519Verify, hexToBytes,
|
|
|
21
21
|
// body: two copies of one rule is how two kernels drift apart one edit at a
|
|
22
22
|
// time, which is the whole defect class this fix is about.
|
|
23
23
|
isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
24
|
+
import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
|
|
24
25
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
25
|
-
import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
|
|
26
|
+
import { DuplicateKeyError, MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, isRfc3339UtcInstant, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
|
|
26
27
|
import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
|
|
27
28
|
// Only the evidence-bundle major(s) this kernel verifies. A new major is a
|
|
28
29
|
// deliberate one-entry addition HERE — same fail-closed discipline as the
|
|
29
30
|
// envelope verifier's ACCEPTED_SPEC_MAJORS.
|
|
30
|
-
|
|
31
|
+
/** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
|
|
32
|
+
export const ACCEPTED_EVIDENCE_MAJORS = Object.freeze(["sanning.evidence/v1"]);
|
|
31
33
|
// The body_type this module knows how to verify structurally. An unknown
|
|
32
34
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
33
35
|
// still verify), but its body cannot be re-derived, so the per-body verdict is
|
|
@@ -106,6 +108,41 @@ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
|
|
|
106
108
|
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
107
109
|
*/
|
|
108
110
|
export const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
111
|
+
/**
|
|
112
|
+
* A surface MUST NOT present a wrapper's `co_signatures` as evidence
|
|
113
|
+
* (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
|
|
114
|
+
* in either kernel verifies the field, and it sits outside the wrapper's
|
|
115
|
+
* signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
|
|
116
|
+
* append it with no key at all. This is the one sentence both kernels attach
|
|
117
|
+
* as a note, never a verdict, never an exit code.
|
|
118
|
+
*/
|
|
119
|
+
export const CO_SIGNATURES_NOTE = "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
|
|
120
|
+
/**
|
|
121
|
+
* True iff the wrapper's reserved `co_signatures` field carries at least one
|
|
122
|
+
* entry.
|
|
123
|
+
*
|
|
124
|
+
* Absence and an empty array read the same way here: the field asserts
|
|
125
|
+
* nothing about a second signer until it holds one, and evidence-bundle.md's
|
|
126
|
+
* own carve-out language for the analogous envelope field treats "absent"
|
|
127
|
+
* and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
|
|
128
|
+
* value (malformed shape, but the field is reserved, not yet specified)
|
|
129
|
+
* still counts as present: it is not the well-formed absence either rule
|
|
130
|
+
* describes.
|
|
131
|
+
*/
|
|
132
|
+
export function coSignaturesPresent(b) {
|
|
133
|
+
if (b === null || typeof b !== "object" || Array.isArray(b))
|
|
134
|
+
return false;
|
|
135
|
+
const value = b.co_signatures;
|
|
136
|
+
if (!("co_signatures" in b) || value === null || value === undefined) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(value))
|
|
140
|
+
return value.length > 0;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function coSignaturesNote(bundle) {
|
|
144
|
+
return coSignaturesPresent(bundle) ? CO_SIGNATURES_NOTE : undefined;
|
|
145
|
+
}
|
|
109
146
|
function isEvidenceMajorAccepted(specVersion) {
|
|
110
147
|
if (typeof specVersion !== "string" || specVersion === "")
|
|
111
148
|
return false;
|
|
@@ -129,6 +166,8 @@ function isEvidenceMajorAccepted(specVersion) {
|
|
|
129
166
|
* (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
|
|
130
167
|
* broke its own body shape read as a forger.
|
|
131
168
|
*/
|
|
169
|
+
/** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
|
|
170
|
+
export const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
|
|
132
171
|
function malformed(error, specVersionOk = false, wrapperOk = false) {
|
|
133
172
|
return {
|
|
134
173
|
status: "malformed",
|
|
@@ -148,7 +187,9 @@ function malformed(error, specVersionOk = false, wrapperOk = false) {
|
|
|
148
187
|
* the Python kernel writes too. Anything outside this set comes from the host:
|
|
149
188
|
* a JSON parser, a codec, a crypto library. See `thrownDetail`.
|
|
150
189
|
*/
|
|
151
|
-
|
|
190
|
+
// `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
|
|
191
|
+
// `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
|
|
192
|
+
const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
|
|
152
193
|
/**
|
|
153
194
|
* The detail a finding may append about a caught exception.
|
|
154
195
|
*
|
|
@@ -165,6 +206,29 @@ const KERNEL_WORDING = ["hexToBytes: "];
|
|
|
165
206
|
* is the host's parse offset, which no reader can act on and which was never
|
|
166
207
|
* the same in the two kernels.
|
|
167
208
|
*/
|
|
209
|
+
/**
|
|
210
|
+
* The kernel's `duplicate key "<name>"` when a record repeats a member name,
|
|
211
|
+
* else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
|
|
212
|
+
*
|
|
213
|
+
* ONLY A DUPLICATE, and not every unreadable record. Every reader below this
|
|
214
|
+
* one that parses a record treats an unreadable one as absent, so the content
|
|
215
|
+
* binding reads undetermined and the pack said "1 record discloses no bytes"
|
|
216
|
+
* about a record whose bytes it had read. A record that repeats a name is
|
|
217
|
+
* a defect in the artifact, and a defect this kernel can see fails, the rule
|
|
218
|
+
* `event_bytes` that are not hex already follow.
|
|
219
|
+
*/
|
|
220
|
+
function recordDuplicateKey(eventBytes) {
|
|
221
|
+
if (eventBytes === undefined)
|
|
222
|
+
return undefined;
|
|
223
|
+
try {
|
|
224
|
+
jcsParse(eventBytes);
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
if (e instanceof DuplicateKeyError)
|
|
228
|
+
return e.message;
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
168
232
|
function thrownDetail(e) {
|
|
169
233
|
const text = e instanceof Error ? e.message : String(e);
|
|
170
234
|
return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
|
|
@@ -235,7 +299,17 @@ function jsonStr(value) {
|
|
|
235
299
|
// (`assertedStatus`) but NEVER trusted — the displayed status is the one this
|
|
236
300
|
// function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
|
|
237
301
|
export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
238
|
-
|
|
302
|
+
// The ONE place a present `co_signatures` becomes a note, so every caller
|
|
303
|
+
// (including `case.ts`, which calls this function directly per pack) gets
|
|
304
|
+
// it, however the wrapper otherwise verifies. Never touches `status` or
|
|
305
|
+
// the exit code: a bundle that would otherwise verify still verifies, note
|
|
306
|
+
// or not (proof#148).
|
|
307
|
+
const result = await verifyBundle(bundle, options, false);
|
|
308
|
+
const note = coSignaturesNote(bundle);
|
|
309
|
+
if (note !== undefined) {
|
|
310
|
+
result.notes = [...(result.notes ?? []), note];
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
239
313
|
}
|
|
240
314
|
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
241
315
|
// §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
|
|
@@ -254,12 +328,16 @@ async function verifyBundle(bundle, options, exportSource) {
|
|
|
254
328
|
const b = bundle;
|
|
255
329
|
const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
|
|
256
330
|
if (!specVersionOk) {
|
|
257
|
-
|
|
331
|
+
// `?? null`: an ABSENT field is spelled `null`, as the Python twin's
|
|
332
|
+
// `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
|
|
333
|
+
// the two kernels printed two different sentences for one bundle until
|
|
334
|
+
// `verify --json` compared them byte for byte (0.13.0).
|
|
335
|
+
return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
|
|
258
336
|
}
|
|
259
337
|
if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
|
|
260
338
|
// The reference verifier implements ed25519 only (evidence-bundle §2 +
|
|
261
339
|
// decision #2); other algs are a deliberate future addition.
|
|
262
|
-
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
|
|
340
|
+
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
|
|
263
341
|
}
|
|
264
342
|
if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
|
|
265
343
|
return malformed("evidence bundle is missing public_key/signature", true);
|
|
@@ -308,7 +386,7 @@ async function verifyBundle(bundle, options, exportSource) {
|
|
|
308
386
|
if (keyFormOk && signatureFormOk) {
|
|
309
387
|
try {
|
|
310
388
|
const { signature: _sig, co_signatures: _co, ...bundleForSig } = b;
|
|
311
|
-
signatureOk = await ed25519Verify(
|
|
389
|
+
signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
|
|
312
390
|
if (!signatureOk)
|
|
313
391
|
errors.push("wrapper Ed25519 signature verification failed");
|
|
314
392
|
}
|
|
@@ -333,6 +411,16 @@ async function verifyBundle(bundle, options, exportSource) {
|
|
|
333
411
|
errors,
|
|
334
412
|
};
|
|
335
413
|
}
|
|
414
|
+
// --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
|
|
415
|
+
// `generated_at` is copied into a recomputed verdict's `as_of` (the export
|
|
416
|
+
// reading) and printed beside every verdict, so a present value that is not
|
|
417
|
+
// the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
|
|
418
|
+
// render, whatever its signature. It is read only once the wrapper holds: a
|
|
419
|
+
// broken wrapper is `failed` above, as it was. Absence is unchanged.
|
|
420
|
+
if (Object.prototype.hasOwnProperty.call(b, "generated_at") &&
|
|
421
|
+
!isRfc3339UtcInstant(b.generated_at)) {
|
|
422
|
+
return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
|
|
423
|
+
}
|
|
336
424
|
// --- Body dispatch ---------------------------------------------------------
|
|
337
425
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
338
426
|
// Read as an export's source (evidence-export.md §2.4), the only body this
|
|
@@ -524,7 +612,7 @@ function parseAnchorRef(raw, errors) {
|
|
|
524
612
|
}
|
|
525
613
|
const o = raw;
|
|
526
614
|
if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
|
|
527
|
-
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
|
|
615
|
+
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind ?? null)}`);
|
|
528
616
|
return { ref: null, malformed: true };
|
|
529
617
|
}
|
|
530
618
|
if (typeof o.id !== "string" || o.id === "") {
|
|
@@ -761,6 +849,11 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
761
849
|
eventBytesMalformed = true;
|
|
762
850
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
763
851
|
}
|
|
852
|
+
const duplicate = recordDuplicateKey(eventBytes);
|
|
853
|
+
if (duplicate !== undefined) {
|
|
854
|
+
eventBytesMalformed = true;
|
|
855
|
+
evErrors.push(`event record is not JSON: ${duplicate}`);
|
|
856
|
+
}
|
|
764
857
|
try {
|
|
765
858
|
const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
766
859
|
const hexError = envelopeHexError("event", ev.envelope);
|
|
@@ -1076,21 +1169,62 @@ function isNonNegInt(n) {
|
|
|
1076
1169
|
async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
1077
1170
|
const evErrors = [];
|
|
1078
1171
|
let contentOk = null;
|
|
1172
|
+
// WHICH hash in the record is decided by the record's STRUCTURE
|
|
1173
|
+
// (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
|
|
1174
|
+
// governs (`isHandoffNamespace`) commits to its message through
|
|
1175
|
+
// `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
|
|
1176
|
+
// `content_hash`, so until A20 its disclosure bound to nothing and an honest
|
|
1177
|
+
// hand-off pack could never grade `verified`. For such a record the committed
|
|
1178
|
+
// hash is `event.message_hash` and nothing else: not a `content_hash` it also
|
|
1179
|
+
// carries, and not the envelope's. It binds only when the record is a
|
|
1180
|
+
// CONFORMANT hand-off record under the key that signed it
|
|
1181
|
+
// (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
|
|
1182
|
+
// hex. Anything short of that commits nothing this reader will bind to, so
|
|
1183
|
+
// the reading is undetermined and never true: a record dressed as a hand-off
|
|
1184
|
+
// record gains no binding from the dress, and a record outside §4.1 keeps its
|
|
1185
|
+
// `content_hash` exactly as before, whatever `message_hash` field it writes.
|
|
1186
|
+
// The Python twin is `evidence._evaluate_content`.
|
|
1079
1187
|
const committed = [];
|
|
1080
1188
|
let recordContentHash;
|
|
1189
|
+
let governed = false;
|
|
1190
|
+
let handoffHash;
|
|
1081
1191
|
if (eventBytes !== undefined) {
|
|
1082
1192
|
try {
|
|
1083
|
-
const
|
|
1084
|
-
const
|
|
1193
|
+
const parsed = jcsParse(eventBytes);
|
|
1194
|
+
const record = parsed;
|
|
1195
|
+
const ch = record?.event?.content_hash;
|
|
1085
1196
|
if (typeof ch === "string" && ch)
|
|
1086
1197
|
recordContentHash = ch.toLowerCase();
|
|
1198
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1199
|
+
const envelope = ev.envelope;
|
|
1200
|
+
const held = {
|
|
1201
|
+
publicKey: envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
|
|
1202
|
+
? envelope.public_key
|
|
1203
|
+
: undefined,
|
|
1204
|
+
record: parsed,
|
|
1205
|
+
ref: "",
|
|
1206
|
+
};
|
|
1207
|
+
if (isHandoffNamespace(held)) {
|
|
1208
|
+
governed = true;
|
|
1209
|
+
const event = parsed.event;
|
|
1210
|
+
const mh = event !== null && typeof event === "object" && !Array.isArray(event)
|
|
1211
|
+
? event.message_hash
|
|
1212
|
+
: undefined;
|
|
1213
|
+
if (isConformantHandoffRecord(held) && isLowerHex(mh, 64))
|
|
1214
|
+
handoffHash = mh;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1087
1217
|
}
|
|
1088
1218
|
catch {
|
|
1089
1219
|
// record not JSON — the payload binding already reflects that; content
|
|
1090
1220
|
// stays undetermined rather than throwing.
|
|
1091
1221
|
}
|
|
1092
1222
|
}
|
|
1093
|
-
if (
|
|
1223
|
+
if (governed) {
|
|
1224
|
+
if (handoffHash !== undefined)
|
|
1225
|
+
committed.push(handoffHash);
|
|
1226
|
+
}
|
|
1227
|
+
else if (recordContentHash !== undefined) {
|
|
1094
1228
|
committed.push(recordContentHash);
|
|
1095
1229
|
}
|
|
1096
1230
|
else if (ev.envelope && typeof ev.envelope === "object") {
|
|
@@ -1112,15 +1246,20 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
|
1112
1246
|
if (disclosed === undefined || committed.length === 0) {
|
|
1113
1247
|
contentOk = null;
|
|
1114
1248
|
if (disclosed !== undefined && committed.length === 0) {
|
|
1115
|
-
evErrors.push(
|
|
1116
|
-
"
|
|
1249
|
+
evErrors.push(governed
|
|
1250
|
+
? "disclosed content present but this hand-off record commits no " +
|
|
1251
|
+
"message_hash a reader can bind to (not a conformant hand-off " +
|
|
1252
|
+
"record, or message_hash is not 64 lowercase hex) -- undetermined"
|
|
1253
|
+
: "disclosed content present but no committed content_hash to bind to " +
|
|
1254
|
+
"(record withheld or record has no event.content_hash) -- undetermined");
|
|
1117
1255
|
}
|
|
1118
1256
|
}
|
|
1119
1257
|
else {
|
|
1120
1258
|
const got = await sha256Hex(disclosed);
|
|
1121
1259
|
contentOk = committed.includes(got);
|
|
1122
1260
|
if (!contentOk) {
|
|
1123
|
-
|
|
1261
|
+
const fieldName = governed ? "message_hash" : "content_hash";
|
|
1262
|
+
evErrors.push(`disclosed content does not match the committed ${fieldName}: ` +
|
|
1124
1263
|
`sha256(disclosed)=${got} committed=${committed.join("|")}`);
|
|
1125
1264
|
}
|
|
1126
1265
|
}
|
|
@@ -1217,7 +1356,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1217
1356
|
// two answers to one question inside one signature. The body_type is the
|
|
1218
1357
|
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
1219
1358
|
// how a reader half-parses a shape it does not know.
|
|
1220
|
-
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
1359
|
+
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
|
|
1221
1360
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
|
|
1222
1361
|
return { result: base, witnessedHex };
|
|
1223
1362
|
}
|
|
@@ -1228,6 +1367,11 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1228
1367
|
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
1229
1368
|
base.bodyOk = false;
|
|
1230
1369
|
}
|
|
1370
|
+
else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
|
|
1371
|
+
// §5.2 (A31): the promise window is the form `signed_at` pins.
|
|
1372
|
+
errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
|
|
1373
|
+
base.bodyOk = false;
|
|
1374
|
+
}
|
|
1231
1375
|
}
|
|
1232
1376
|
base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
|
|
1233
1377
|
base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
|
|
@@ -1501,6 +1645,11 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1501
1645
|
eventBytesMalformed = true;
|
|
1502
1646
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1503
1647
|
}
|
|
1648
|
+
const duplicate = recordDuplicateKey(eventBytes);
|
|
1649
|
+
if (duplicate !== undefined) {
|
|
1650
|
+
eventBytesMalformed = true;
|
|
1651
|
+
evErrors.push(`event record is not JSON: ${duplicate}`);
|
|
1652
|
+
}
|
|
1504
1653
|
try {
|
|
1505
1654
|
const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
1506
1655
|
const hexError = envelopeHexError("event", ev.envelope);
|
|
@@ -1702,8 +1851,8 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1702
1851
|
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
1703
1852
|
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
1704
1853
|
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
1705
|
-
* nothing to the status
|
|
1706
|
-
*
|
|
1854
|
+
* nothing to the status, which is `partial` either way, and would add an
|
|
1855
|
+
* unreachable-gateway note for a network event that never happened, based on
|
|
1707
1856
|
* nothing but whether the caller happened to pass a gateway list.
|
|
1708
1857
|
*/
|
|
1709
1858
|
async function refetchStamps(stamps, witnessed, options, errors) {
|
|
@@ -2016,8 +2165,11 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2016
2165
|
// step-5 erratum flagged in the slice report.
|
|
2017
2166
|
let verdictAgreementOk = false;
|
|
2018
2167
|
try {
|
|
2019
|
-
const
|
|
2020
|
-
const
|
|
2168
|
+
const cached = projectVerdictForAgreement(body.kernel_verdict);
|
|
2169
|
+
const fresh = projectVerdictForAgreement(verdict);
|
|
2170
|
+
toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
|
|
2171
|
+
const cachedProjection = jcs(cached);
|
|
2172
|
+
const freshProjection = jcs(fresh);
|
|
2021
2173
|
verdictAgreementOk = cachedProjection === freshProjection;
|
|
2022
2174
|
if (!verdictAgreementOk) {
|
|
2023
2175
|
errors.push("recomputed verdict disagrees with the cached kernel_verdict " +
|
|
@@ -2046,8 +2198,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2046
2198
|
status = "failed";
|
|
2047
2199
|
}
|
|
2048
2200
|
else if (sourceResult.status === "partial") {
|
|
2049
|
-
// Undetermined source (withheld record
|
|
2050
|
-
// exit 3
|
|
2201
|
+
// Undetermined source (a withheld record, or an unreachable gateway):
|
|
2202
|
+
// exit 3 either way since 0.13.0. Never a failure.
|
|
2051
2203
|
status = "partial";
|
|
2052
2204
|
}
|
|
2053
2205
|
else {
|
|
@@ -2165,6 +2317,10 @@ function payloadFieldError(payload) {
|
|
|
2165
2317
|
}
|
|
2166
2318
|
if (!ok)
|
|
2167
2319
|
return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
2320
|
+
if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
|
|
2321
|
+
// evidence-export §3.1 (v3.4): the form `signed_at` pins.
|
|
2322
|
+
return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
|
|
2323
|
+
}
|
|
2168
2324
|
}
|
|
2169
2325
|
return null;
|
|
2170
2326
|
}
|
|
@@ -2193,7 +2349,7 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
2193
2349
|
if (rec.signature_alg !== "rsa-pss-sha256") {
|
|
2194
2350
|
return {
|
|
2195
2351
|
result,
|
|
2196
|
-
malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
|
|
2352
|
+
malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg ?? null)}`,
|
|
2197
2353
|
};
|
|
2198
2354
|
}
|
|
2199
2355
|
const pub = rec.public_key;
|
|
@@ -2421,6 +2577,66 @@ function projectVerdictForAgreement(v) {
|
|
|
2421
2577
|
}),
|
|
2422
2578
|
};
|
|
2423
2579
|
}
|
|
2580
|
+
// Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
|
|
2581
|
+
// whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
|
|
2582
|
+
// content binding decodes it, so an event whose record is withheld, not hex or
|
|
2583
|
+
// not JCS is not governed here either. The Python twin is
|
|
2584
|
+
// `evidence._source_events_governed`.
|
|
2585
|
+
function sourceEventsGoverned(source) {
|
|
2586
|
+
if (source === null || typeof source !== "object" || Array.isArray(source))
|
|
2587
|
+
return [];
|
|
2588
|
+
const s = source;
|
|
2589
|
+
if (s.body_type !== ANCHOR_TRACE_BODY_TYPE)
|
|
2590
|
+
return [];
|
|
2591
|
+
const body = s.body;
|
|
2592
|
+
const events = body !== null && typeof body === "object" && !Array.isArray(body) ? body.events : undefined;
|
|
2593
|
+
if (!Array.isArray(events))
|
|
2594
|
+
return [];
|
|
2595
|
+
return events.map((raw) => {
|
|
2596
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
2597
|
+
return false;
|
|
2598
|
+
const ev = raw;
|
|
2599
|
+
if (typeof ev.event_bytes !== "string")
|
|
2600
|
+
return false;
|
|
2601
|
+
try {
|
|
2602
|
+
const parsed = jcsParse(hexToBytes(ev.event_bytes));
|
|
2603
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2604
|
+
return false;
|
|
2605
|
+
const envelope = ev.envelope;
|
|
2606
|
+
const held = {
|
|
2607
|
+
publicKey: envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
|
|
2608
|
+
? envelope.public_key
|
|
2609
|
+
: undefined,
|
|
2610
|
+
record: parsed,
|
|
2611
|
+
ref: "",
|
|
2612
|
+
};
|
|
2613
|
+
return isHandoffNamespace(held);
|
|
2614
|
+
}
|
|
2615
|
+
catch {
|
|
2616
|
+
return false;
|
|
2617
|
+
}
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
// The one difference §5 step 5 does not read as a disagreement
|
|
2621
|
+
// (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
|
|
2622
|
+
// `null` against a recomputed `true`, on an event whose record §4.1 governs. A
|
|
2623
|
+
// kernel before A20 could not bind a hand-off record's disclosed message, so an
|
|
2624
|
+
// export it composed claims less than the recompute proves, and reading that
|
|
2625
|
+
// as tamper failed an honest export. Only that direction: a cached `true`
|
|
2626
|
+
// against a recomputed `false` or `null`, `null` against `false`, and any
|
|
2627
|
+
// difference on a record outside §4.1 still disagree. Edits `fresh` in place
|
|
2628
|
+
// to match `cached` on those events. The Python twin is
|
|
2629
|
+
// `evidence._tolerate_pre_a20_content`.
|
|
2630
|
+
function toleratePreA20Content(cached, fresh, governed) {
|
|
2631
|
+
const n = Math.min(cached.events.length, fresh.events.length);
|
|
2632
|
+
for (let i = 0; i < n; i++) {
|
|
2633
|
+
if (governed[i] === true &&
|
|
2634
|
+
cached.events[i].content_ok === null &&
|
|
2635
|
+
fresh.events[i].content_ok === true) {
|
|
2636
|
+
fresh.events[i].content_ok = null;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2424
2640
|
function summarizeExport(status, source, attestations) {
|
|
2425
2641
|
const attOk = attestations.filter((a) => a.ok).length;
|
|
2426
2642
|
return (`Export ${status}: source bundle ${source.status} ` +
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const EXIT_VERIFIED = 0;
|
|
2
|
+
export declare const EXIT_FAILED = 1;
|
|
3
|
+
export declare const EXIT_MALFORMED = 2;
|
|
4
|
+
export declare const EXIT_UNDETERMINED = 3;
|
|
5
|
+
export declare const EXIT_MIXED = 4;
|
|
6
|
+
/**
|
|
7
|
+
* The exit code of each of the eight verdict words (`evidence-bundle.md` §3).
|
|
8
|
+
* `undetermined` is the name of 3, and never a word here.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EXIT_CODES: Readonly<Record<string, number>>;
|
|
11
|
+
/** What every verb's help prints, identical in both commands. */
|
|
12
|
+
export declare const EXIT_CODES_HELP: string;
|
|
13
|
+
/**
|
|
14
|
+
* The exit code of a verdict word. A word outside the eight is a verdict
|
|
15
|
+
* nothing can render, so it exits 2, never 0.
|
|
16
|
+
*/
|
|
17
|
+
export declare function exitCodeFor(verdict: unknown): number;
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
export const EXIT_VERIFIED = 0;
|
|
14
|
+
export const EXIT_FAILED = 1;
|
|
15
|
+
export const EXIT_MALFORMED = 2;
|
|
16
|
+
export const EXIT_UNDETERMINED = 3;
|
|
17
|
+
export const EXIT_MIXED = 4;
|
|
18
|
+
/**
|
|
19
|
+
* The exit code of each of the eight verdict words (`evidence-bundle.md` §3).
|
|
20
|
+
* `undetermined` is the name of 3, and never a word here.
|
|
21
|
+
*/
|
|
22
|
+
export const EXIT_CODES = Object.freeze({
|
|
23
|
+
verified: EXIT_VERIFIED,
|
|
24
|
+
failed: EXIT_FAILED,
|
|
25
|
+
malformed: EXIT_MALFORMED,
|
|
26
|
+
partial: EXIT_UNDETERMINED,
|
|
27
|
+
pending: EXIT_UNDETERMINED,
|
|
28
|
+
incomplete: EXIT_UNDETERMINED,
|
|
29
|
+
not_found: EXIT_UNDETERMINED,
|
|
30
|
+
mixed: EXIT_MIXED,
|
|
31
|
+
});
|
|
32
|
+
/** What every verb's help prints, identical in both commands. */
|
|
33
|
+
export const EXIT_CODES_HELP = [
|
|
34
|
+
"Exit codes, one table for every verb:",
|
|
35
|
+
" 0 verified, or the export was written",
|
|
36
|
+
" 1 failed",
|
|
37
|
+
" 2 usage or malformed",
|
|
38
|
+
" 3 undetermined: partial, pending, incomplete or not_found",
|
|
39
|
+
" 4 mixed: more than one outcome, none failed; read the exceptions",
|
|
40
|
+
].join("\n");
|
|
41
|
+
/**
|
|
42
|
+
* The exit code of a verdict word. A word outside the eight is a verdict
|
|
43
|
+
* nothing can render, so it exits 2, never 0.
|
|
44
|
+
*/
|
|
45
|
+
export function exitCodeFor(verdict) {
|
|
46
|
+
if (typeof verdict === "string" && Object.prototype.hasOwnProperty.call(EXIT_CODES, verdict)) {
|
|
47
|
+
return EXIT_CODES[verdict];
|
|
48
|
+
}
|
|
49
|
+
return EXIT_MALFORMED;
|
|
50
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files handed in beside a pack, matched against what its records signed (A29).
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript half of `src/sanning_proof/file_match.py`. Both kernels are
|
|
5
|
+
* held to one table, `vectors/file-match.json`, so neither can pass by agreeing
|
|
6
|
+
* with the other, and `cross-kernel/run_file_match.sh` holds the two readings,
|
|
7
|
+
* and the two commands' `--files` output, to the same bytes.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THIS ANSWERS (evidence-bundle.md 5.7 v1.24, proof#92, decided by Phil
|
|
10
|
+
* 2026-09-23 as option 2). A reader holds a pack and some files beside it and
|
|
11
|
+
* asks whether the pack vouches for them. Each offered file is hashed, SHA-256
|
|
12
|
+
* over its exact bytes, and the digest is looked for among the commitments the
|
|
13
|
+
* pack's SIGNED records make. The reading says which offered files a record
|
|
14
|
+
* commits to (naming the record, its signer and its `signed_at`), which match
|
|
15
|
+
* no record, and which commitments had no file offered.
|
|
16
|
+
*
|
|
17
|
+
* THE RULES THAT CARRY THE WEIGHT:
|
|
18
|
+
*
|
|
19
|
+
* - By hash, and by nothing else. A file's name is carried through as the
|
|
20
|
+
* caller's label and is never compared with an `event_id`, a part's name or
|
|
21
|
+
* a `logs/` path. There is no layout convention.
|
|
22
|
+
* - Unmatched is reported, never guessed.
|
|
23
|
+
* - Only what a record's own key signed: a verified envelope whose
|
|
24
|
+
* `event_bytes` bind to its `payload_hash`, or a hand-off message whose
|
|
25
|
+
* bytes bind to that record's `message_hash`. No wrapper field is read.
|
|
26
|
+
* - The verdict does not move. A match proves that a signed record in the
|
|
27
|
+
* pack committed to those exact bytes, and nothing more (`FILE_MATCH_SCOPE`).
|
|
28
|
+
*
|
|
29
|
+
* WHAT COUNTS AS A COMMITMENT, in record order and then part order:
|
|
30
|
+
*
|
|
31
|
+
* 1. `record`: a record outside envelope-spec.md 4.1 commits through
|
|
32
|
+
* `event.content_hash`, 64 hex characters in either case.
|
|
33
|
+
* 2. `file_part`: a record 4.1 governs commits to its MESSAGE through
|
|
34
|
+
* `event.message_hash` (5.1, A20), never through a `content_hash` it also
|
|
35
|
+
* writes. Where the message's JCS bytes are in hand (in the body, or in
|
|
36
|
+
* the side content) and bind to it, each file part of that message (A18)
|
|
37
|
+
* commits by its `content_hash`.
|
|
38
|
+
*
|
|
39
|
+
* Side content is read for ONE thing, to find a hand-off message that binds to
|
|
40
|
+
* a signed `message_hash`. It is never an offered file.
|
|
41
|
+
*
|
|
42
|
+
* Pure: a bundle, the files and the optional side content in, a reading out.
|
|
43
|
+
*/
|
|
44
|
+
import type { SideContentMap } from "./side-content.js";
|
|
45
|
+
/** The one sentence every surface prints with the reading (5.7). */
|
|
46
|
+
export declare const FILE_MATCH_SCOPE: string;
|
|
47
|
+
/** Why a record's commitments could not be read, in the order they are tested. */
|
|
48
|
+
export declare const NOT_READ_REASONS: readonly ["no_records", "record_unreadable", "record_not_verified", "record_withheld", "no_content_hash", "handoff_record_malformed", "message_withheld"];
|
|
49
|
+
export type NotReadReason = (typeof NOT_READ_REASONS)[number];
|
|
50
|
+
/** One commitment a signed record makes to a file's bytes. */
|
|
51
|
+
export interface FileCommitment {
|
|
52
|
+
packRef: string | null;
|
|
53
|
+
eventId: string | null;
|
|
54
|
+
kind: "record" | "file_part";
|
|
55
|
+
partIndex: number | null;
|
|
56
|
+
contentHash: string;
|
|
57
|
+
signer: string;
|
|
58
|
+
signedAt: string | null;
|
|
59
|
+
}
|
|
60
|
+
export interface MatchedFile {
|
|
61
|
+
name: string;
|
|
62
|
+
sha256: string;
|
|
63
|
+
records: FileCommitment[];
|
|
64
|
+
}
|
|
65
|
+
export interface UnmatchedFile {
|
|
66
|
+
name: string;
|
|
67
|
+
sha256: string;
|
|
68
|
+
}
|
|
69
|
+
export interface NotRead {
|
|
70
|
+
packRef: string | null;
|
|
71
|
+
eventId: string | null;
|
|
72
|
+
reason: NotReadReason;
|
|
73
|
+
}
|
|
74
|
+
export interface FileMatchReading {
|
|
75
|
+
offered: number;
|
|
76
|
+
matched: MatchedFile[];
|
|
77
|
+
unmatched: UnmatchedFile[];
|
|
78
|
+
notOffered: FileCommitment[];
|
|
79
|
+
notRead: NotRead[];
|
|
80
|
+
scope: string;
|
|
81
|
+
}
|
|
82
|
+
/** A file handed in beside a pack. `name` is a label and is never compared. */
|
|
83
|
+
export interface OfferedFile {
|
|
84
|
+
name: string;
|
|
85
|
+
bytes: Uint8Array;
|
|
86
|
+
}
|
|
87
|
+
/** One pack of a case, for `matchCaseFiles`. */
|
|
88
|
+
export interface FileMatchPack {
|
|
89
|
+
ref: string;
|
|
90
|
+
bundle: unknown;
|
|
91
|
+
content?: SideContentMap | null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Match each offered file against the commitments one pack's signed records
|
|
95
|
+
* make. `content` is the side content handed in beside the pack, read only to
|
|
96
|
+
* find a hand-off message that binds to its record's `message_hash`; it is
|
|
97
|
+
* never an offered file.
|
|
98
|
+
*/
|
|
99
|
+
export declare function matchFiles(bundle: unknown, files: readonly OfferedFile[], content?: SideContentMap | null): Promise<FileMatchReading>;
|
|
100
|
+
/** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
|
|
101
|
+
export declare function matchCaseFiles(packs: readonly FileMatchPack[], files: readonly OfferedFile[]): Promise<FileMatchReading>;
|
|
102
|
+
/** The reading as the `files` member of `--json`, identical in both kernels. */
|
|
103
|
+
export declare function fileMatchToJson(reading: FileMatchReading): Record<string, unknown>;
|
|
104
|
+
/** The text report's lines for the reading, identical in both kernels. */
|
|
105
|
+
export declare function renderFileMatch(reading: FileMatchReading): string[];
|