@sanning/proof 0.7.0 → 0.8.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/dist/chain.js +48 -10
- package/dist/cli.js +25 -0
- package/dist/evidence.d.ts +50 -0
- package/dist/evidence.js +442 -91
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/chain.ts +48 -8
- package/src/cli.ts +29 -0
- package/src/evidence.ts +590 -97
- package/src/index.ts +4 -0
- package/vectors/README.md +18 -0
- package/vectors/stamp-on-chain.json +229 -0
package/src/evidence.ts
CHANGED
|
@@ -86,6 +86,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
|
86
86
|
* unrecoverable once witnessed.
|
|
87
87
|
*/
|
|
88
88
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
89
|
+
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
92
|
* The version string INSIDE a witnessed stamp body — a different string from
|
|
@@ -245,6 +246,37 @@ export interface StampTraceBody {
|
|
|
245
246
|
stamps: StampEntry[];
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// ---- `sanning.stamp.evidence/v1` wire types (evidence-bundle.md §5.3) -----
|
|
250
|
+
//
|
|
251
|
+
// Both halves of a DL-52 record, bound: the producer signs the envelope, and
|
|
252
|
+
// Sanning signs and witnesses the stamp whose root commits to it. Neither
|
|
253
|
+
// earlier body joins them, and a field added to §5.2 could not — a conformant
|
|
254
|
+
// reader MUST ignore fields it does not know (envelope-spec.md §2), so the
|
|
255
|
+
// join has to live in a body_type readers DISPATCH on. See §5.3.1.
|
|
256
|
+
|
|
257
|
+
/** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
|
|
258
|
+
export interface StampEvidenceInclusion {
|
|
259
|
+
leaf_hash: string; // hex
|
|
260
|
+
leaf_index: number;
|
|
261
|
+
leaf_count: number;
|
|
262
|
+
audit_path: string[]; // hex[]
|
|
263
|
+
/** Resolves against `stamps[].stamp_ref` in the SAME body. */
|
|
264
|
+
stamp_ref: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface StampEvidenceEvent {
|
|
268
|
+
envelope: Envelope;
|
|
269
|
+
record_bytes?: string; // hex of JCS(event record); omitted == withheld
|
|
270
|
+
content?: string; // opt-in disclosed raw bytes, lowercase hex
|
|
271
|
+
inclusion: StampEvidenceInclusion;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface StampEvidenceBody {
|
|
275
|
+
/** Byte-identical to §5.2.2 — one definition, one place to change. */
|
|
276
|
+
stamps: StampEntry[];
|
|
277
|
+
events: StampEvidenceEvent[];
|
|
278
|
+
}
|
|
279
|
+
|
|
248
280
|
export interface EvidenceBundle {
|
|
249
281
|
spec_version: string;
|
|
250
282
|
body_type: string;
|
|
@@ -396,6 +428,35 @@ export interface StampResult {
|
|
|
396
428
|
* carries it.
|
|
397
429
|
*/
|
|
398
430
|
anchorRef: AnchorRef | null;
|
|
431
|
+
/**
|
|
432
|
+
* Per-gateway on-chain outcomes for this stamp's `anchor_ref` (§4.2's shape,
|
|
433
|
+
* `checkpoints[]`'s vocabulary), or `null` when NOTHING WAS CHECKED.
|
|
434
|
+
*
|
|
435
|
+
* ── The four states, and why none of them collapses ──
|
|
436
|
+
*
|
|
437
|
+
* `null` means not checked — no gateways were supplied, or this stamp names
|
|
438
|
+
* no witness to check. It is a statement about the RUN, never about the
|
|
439
|
+
* stamp. The other three are statements about the stamp and live in
|
|
440
|
+
* `onChain.rollup`: `confirm` (a gateway returned the witnessed bytes),
|
|
441
|
+
* `mismatch` (a gateway returned DIFFERENT bytes — a finding, and the only
|
|
442
|
+
* one of the four that fails anything), `unreachable` (we asked and nobody
|
|
443
|
+
* answered — an availability gap, never an accusation).
|
|
444
|
+
*
|
|
445
|
+
* Keeping "not checked" distinct from "unreachable" is the whole point. A
|
|
446
|
+
* surface handed a collapsed boolean cannot tell "no gateway could be
|
|
447
|
+
* reached" from "no gateway was tried", and reported the first when the
|
|
448
|
+
* second was true — a false accusation manufactured by a missing state.
|
|
449
|
+
*/
|
|
450
|
+
onChain: OnChainResult | null;
|
|
451
|
+
/**
|
|
452
|
+
* Derived collapse of `onChain`, mirroring `CheckpointResult.onChainOk` so a
|
|
453
|
+
* consumer reads one field the same way on both: `false` on mismatch, `true`
|
|
454
|
+
* on confirm, `null` on unreachable AND on not-checked.
|
|
455
|
+
*
|
|
456
|
+
* It is deliberately the LOSSY view — `null` is three-quarters of the state
|
|
457
|
+
* space here. Anything that needs to tell those apart reads `onChain`.
|
|
458
|
+
*/
|
|
459
|
+
onChainOk: boolean | null;
|
|
399
460
|
ok: boolean;
|
|
400
461
|
errors: string[];
|
|
401
462
|
}
|
|
@@ -717,7 +778,12 @@ export async function verifyEvidenceBundle(
|
|
|
717
778
|
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
718
779
|
// a stamp carries no second signature of its own inside the bundle.
|
|
719
780
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
720
|
-
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
781
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
|
|
785
|
+
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
786
|
+
return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
|
|
721
787
|
}
|
|
722
788
|
|
|
723
789
|
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
@@ -1091,79 +1157,10 @@ async function verifyAnchorTrace(
|
|
|
1091
1157
|
}
|
|
1092
1158
|
}
|
|
1093
1159
|
|
|
1094
|
-
// Content (raw-log) binding —
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
// envelope it falls back to the envelope's own committed hashes
|
|
1099
|
-
// (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
|
|
1100
|
-
// and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
|
|
1101
|
-
// from the signed in-body `ev.content` FIRST, then the out-of-band
|
|
1102
|
-
// options.content side input. Undetermined (null) when nothing is disclosed
|
|
1103
|
-
// or nothing is committed; false ONLY on a real mismatch or an in-body vs
|
|
1104
|
-
// side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
|
|
1105
|
-
// discipline.
|
|
1106
|
-
let contentOk: boolean | null = null;
|
|
1107
|
-
{
|
|
1108
|
-
const committed: string[] = [];
|
|
1109
|
-
let recordContentHash: string | undefined;
|
|
1110
|
-
if (recordBytes !== undefined) {
|
|
1111
|
-
try {
|
|
1112
|
-
const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
|
|
1113
|
-
event?: { content_hash?: unknown };
|
|
1114
|
-
};
|
|
1115
|
-
const ch = record.event?.content_hash;
|
|
1116
|
-
if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
|
|
1117
|
-
} catch {
|
|
1118
|
-
// record not JSON — the payload binding already reflects that; content
|
|
1119
|
-
// stays undetermined rather than throwing.
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
if (recordContentHash !== undefined) {
|
|
1123
|
-
committed.push(recordContentHash);
|
|
1124
|
-
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1125
|
-
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
1126
|
-
// which throws on a null/non-object envelope — a verifier must never
|
|
1127
|
-
// raise on adversarial input (such an event already fails via envelopeOk).
|
|
1128
|
-
for (const c of contentHashes(ev.envelope)) committed.push(c.hash.toLowerCase());
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
1132
|
-
// side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
|
|
1133
|
-
// selector so `disclosedContent` hands a consumer the very bytes bound
|
|
1134
|
-
// here — one decode, not two that can drift apart.
|
|
1135
|
-
const selection = selectDisclosedBytes(ev, eventId, options.content);
|
|
1136
|
-
const { inBody, side } = selection;
|
|
1137
|
-
evErrors.push(...selection.errors);
|
|
1138
|
-
|
|
1139
|
-
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
1140
|
-
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
1141
|
-
contentOk = false;
|
|
1142
|
-
evErrors.push(
|
|
1143
|
-
"disclosed content disagreement: in-body events[].content and the supplied content differ",
|
|
1144
|
-
);
|
|
1145
|
-
} else {
|
|
1146
|
-
const disclosed = inBody ?? side;
|
|
1147
|
-
if (disclosed === undefined || committed.length === 0) {
|
|
1148
|
-
contentOk = null;
|
|
1149
|
-
if (disclosed !== undefined && committed.length === 0) {
|
|
1150
|
-
evErrors.push(
|
|
1151
|
-
"disclosed content present but no committed content_hash to bind to " +
|
|
1152
|
-
"(record withheld or record has no event.content_hash) — undetermined",
|
|
1153
|
-
);
|
|
1154
|
-
}
|
|
1155
|
-
} else {
|
|
1156
|
-
const got = await sha256Hex(disclosed);
|
|
1157
|
-
contentOk = committed.includes(got);
|
|
1158
|
-
if (!contentOk) {
|
|
1159
|
-
evErrors.push(
|
|
1160
|
-
"disclosed content does not match the committed content_hash: " +
|
|
1161
|
-
`sha256(disclosed)=${got} committed=${committed.join("|")}`,
|
|
1162
|
-
);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1160
|
+
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
1161
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
|
|
1162
|
+
const contentOk = contentRes.contentOk;
|
|
1163
|
+
evErrors.push(...contentRes.errors);
|
|
1167
1164
|
|
|
1168
1165
|
eventResults.push({
|
|
1169
1166
|
eventId,
|
|
@@ -1320,10 +1317,106 @@ function isNonNegInt(n: unknown): n is number {
|
|
|
1320
1317
|
* verifier that stops at the first one turns a second fix into a second
|
|
1321
1318
|
* round-trip.
|
|
1322
1319
|
*/
|
|
1320
|
+
// ===========================================================================
|
|
1321
|
+
// Disclosed-content binding, shared by every body that carries events.
|
|
1322
|
+
//
|
|
1323
|
+
// Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
|
|
1324
|
+
// rawLog -> content_hash link is the one step the chain
|
|
1325
|
+
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
1326
|
+
// not otherwise cover, and a second copy of it is a second place for the
|
|
1327
|
+
// never-fail-on-absent discipline to drift. The rules are unchanged from the
|
|
1328
|
+
// anchor-trace path this came from: undetermined (null) when nothing is
|
|
1329
|
+
// disclosed or nothing is committed; false ONLY on a real mismatch or an
|
|
1330
|
+
// in-body vs side-input disagreement.
|
|
1331
|
+
// ===========================================================================
|
|
1332
|
+
async function verifyDisclosedContent(
|
|
1333
|
+
ev: { envelope?: unknown; content?: unknown },
|
|
1334
|
+
eventId: string,
|
|
1335
|
+
recordBytes: Uint8Array | undefined,
|
|
1336
|
+
sideContent: VerifyEvidenceOptions["content"],
|
|
1337
|
+
): Promise<{ contentOk: boolean | null; errors: string[] }> {
|
|
1338
|
+
const evErrors: string[] = [];
|
|
1339
|
+
let contentOk: boolean | null = null;
|
|
1340
|
+
|
|
1341
|
+
const committed: string[] = [];
|
|
1342
|
+
let recordContentHash: string | undefined;
|
|
1343
|
+
if (recordBytes !== undefined) {
|
|
1344
|
+
try {
|
|
1345
|
+
const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
|
|
1346
|
+
event?: { content_hash?: unknown };
|
|
1347
|
+
};
|
|
1348
|
+
const ch = record.event?.content_hash;
|
|
1349
|
+
if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
|
|
1350
|
+
} catch {
|
|
1351
|
+
// record not JSON — the payload binding already reflects that; content
|
|
1352
|
+
// stays undetermined rather than throwing.
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (recordContentHash !== undefined) {
|
|
1356
|
+
committed.push(recordContentHash);
|
|
1357
|
+
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1358
|
+
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
1359
|
+
// which throws on a null/non-object envelope — a verifier must never raise
|
|
1360
|
+
// on adversarial input.
|
|
1361
|
+
for (const c of contentHashes(ev.envelope as Envelope)) committed.push(c.hash.toLowerCase());
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const selection = selectDisclosedBytes(ev as TraceEvent, eventId, sideContent);
|
|
1365
|
+
const { inBody, side } = selection;
|
|
1366
|
+
evErrors.push(...selection.errors);
|
|
1367
|
+
|
|
1368
|
+
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
1369
|
+
contentOk = false;
|
|
1370
|
+
evErrors.push(
|
|
1371
|
+
"disclosed content disagreement: in-body events[].content and the supplied content differ",
|
|
1372
|
+
);
|
|
1373
|
+
} else {
|
|
1374
|
+
const disclosed = inBody ?? side;
|
|
1375
|
+
if (disclosed === undefined || committed.length === 0) {
|
|
1376
|
+
contentOk = null;
|
|
1377
|
+
if (disclosed !== undefined && committed.length === 0) {
|
|
1378
|
+
evErrors.push(
|
|
1379
|
+
"disclosed content present but no committed content_hash to bind to " +
|
|
1380
|
+
"(record withheld or record has no event.content_hash) — undetermined",
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
} else {
|
|
1384
|
+
const got = await sha256Hex(disclosed);
|
|
1385
|
+
contentOk = committed.includes(got);
|
|
1386
|
+
if (!contentOk) {
|
|
1387
|
+
evErrors.push(
|
|
1388
|
+
"disclosed content does not match the committed content_hash: " +
|
|
1389
|
+
`sha256(disclosed)=${got} committed=${committed.join("|")}`,
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return { contentOk, errors: evErrors };
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* One `stamps[]` entry's result, plus the witnessed bytes it was graded on.
|
|
1399
|
+
*
|
|
1400
|
+
* `witnessedHex` is internal and never surfaces on `StampResult`: it is the
|
|
1401
|
+
* whole stamp body again, and putting it back on the result would double every
|
|
1402
|
+
* serialized verdict to carry a copy of what the bundle already holds. It
|
|
1403
|
+
* exists so the optional on-chain re-fetch compares the chain against THE SAME
|
|
1404
|
+
* BYTES this function hashed to derive `stamp_ref`, rather than re-reading
|
|
1405
|
+
* `stamp_bytes` out of the body a second time — one value, so the graded bytes
|
|
1406
|
+
* and the compared bytes cannot come apart.
|
|
1407
|
+
*
|
|
1408
|
+
* Undefined exactly when `stamp_bytes` was unusable, in which case the entry
|
|
1409
|
+
* has already failed and there is nothing a gateway could confirm.
|
|
1410
|
+
*/
|
|
1411
|
+
interface StampEntryOutcome {
|
|
1412
|
+
result: StampResult;
|
|
1413
|
+
witnessedHex?: string;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1323
1416
|
async function verifyStampEntry(
|
|
1324
1417
|
raw: unknown,
|
|
1325
1418
|
seen: Set<string>,
|
|
1326
|
-
): Promise<
|
|
1419
|
+
): Promise<StampEntryOutcome> {
|
|
1327
1420
|
const errors: string[] = [];
|
|
1328
1421
|
const base: StampResult = {
|
|
1329
1422
|
stampRef: "",
|
|
@@ -1337,28 +1430,33 @@ async function verifyStampEntry(
|
|
|
1337
1430
|
subtreesOk: false,
|
|
1338
1431
|
subtrees: [],
|
|
1339
1432
|
anchorRef: null,
|
|
1433
|
+
onChain: null,
|
|
1434
|
+
onChainOk: null,
|
|
1340
1435
|
ok: false,
|
|
1341
1436
|
errors,
|
|
1342
1437
|
};
|
|
1343
1438
|
|
|
1344
1439
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1345
1440
|
errors.push("stamp entry is not an object");
|
|
1346
|
-
return base;
|
|
1441
|
+
return { result: base };
|
|
1347
1442
|
}
|
|
1348
1443
|
const e = raw as Partial<StampEntry> & Record<string, unknown>;
|
|
1349
1444
|
|
|
1350
1445
|
// --- the witnessed bytes, which are the only authority in this body -------
|
|
1351
1446
|
if (!isLowerHex(e.stamp_bytes)) {
|
|
1352
1447
|
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
1353
|
-
return base;
|
|
1448
|
+
return { result: base };
|
|
1354
1449
|
}
|
|
1355
1450
|
let bytes: Uint8Array;
|
|
1356
1451
|
try {
|
|
1357
1452
|
bytes = hexToBytes(e.stamp_bytes);
|
|
1358
1453
|
} catch (err) {
|
|
1359
1454
|
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
1360
|
-
return base;
|
|
1455
|
+
return { result: base };
|
|
1361
1456
|
}
|
|
1457
|
+
// Gated by `isLowerHex` above, so this is already the exact lowercase hex of
|
|
1458
|
+
// the exact witnessed bytes — §5.2.2's "one authority in this body".
|
|
1459
|
+
const witnessedHex: string = e.stamp_bytes;
|
|
1362
1460
|
|
|
1363
1461
|
// --- the derived identity -------------------------------------------------
|
|
1364
1462
|
const derived = await sha256Hex(bytes);
|
|
@@ -1389,7 +1487,7 @@ async function verifyStampEntry(
|
|
|
1389
1487
|
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
1390
1488
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1391
1489
|
errors.push("stamp body is not a JSON object");
|
|
1392
|
-
return base;
|
|
1490
|
+
return { result: base, witnessedHex };
|
|
1393
1491
|
}
|
|
1394
1492
|
body = parsed as StampBody;
|
|
1395
1493
|
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
@@ -1398,11 +1496,11 @@ async function verifyStampEntry(
|
|
|
1398
1496
|
// of the stamp disagree with the one that was witnessed.
|
|
1399
1497
|
if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
|
|
1400
1498
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
1401
|
-
return base;
|
|
1499
|
+
return { result: base, witnessedHex };
|
|
1402
1500
|
}
|
|
1403
1501
|
} catch (err) {
|
|
1404
1502
|
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
1405
|
-
return base;
|
|
1503
|
+
return { result: base, witnessedHex };
|
|
1406
1504
|
}
|
|
1407
1505
|
|
|
1408
1506
|
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
@@ -1414,7 +1512,7 @@ async function verifyStampEntry(
|
|
|
1414
1512
|
`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
1415
1513
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
|
|
1416
1514
|
);
|
|
1417
|
-
return base;
|
|
1515
|
+
return { result: base, witnessedHex };
|
|
1418
1516
|
}
|
|
1419
1517
|
base.stampVersion = body.stamp_version;
|
|
1420
1518
|
base.bodyOk = true;
|
|
@@ -1448,7 +1546,7 @@ async function verifyStampEntry(
|
|
|
1448
1546
|
|
|
1449
1547
|
base.ok =
|
|
1450
1548
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
1451
|
-
return base;
|
|
1549
|
+
return { result: base, witnessedHex };
|
|
1452
1550
|
}
|
|
1453
1551
|
|
|
1454
1552
|
/**
|
|
@@ -1536,6 +1634,7 @@ async function verifyStampTrace(
|
|
|
1536
1634
|
specVersionOk: boolean,
|
|
1537
1635
|
assertedStatus: string | null,
|
|
1538
1636
|
errors: string[],
|
|
1637
|
+
options: VerifyEvidenceOptions,
|
|
1539
1638
|
): Promise<EvidenceBundleResult> {
|
|
1540
1639
|
const body = b.body as StampTraceBody;
|
|
1541
1640
|
if (!Array.isArray(body?.stamps)) {
|
|
@@ -1544,10 +1643,27 @@ async function verifyStampTrace(
|
|
|
1544
1643
|
|
|
1545
1644
|
const seen = new Set<string>();
|
|
1546
1645
|
const stamps: StampResult[] = [];
|
|
1646
|
+
const witnessed: (string | undefined)[] = [];
|
|
1547
1647
|
for (const entry of body.stamps) {
|
|
1548
|
-
|
|
1648
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
1649
|
+
stamps.push(outcome.result);
|
|
1650
|
+
witnessed.push(outcome.witnessedHex);
|
|
1549
1651
|
}
|
|
1550
1652
|
|
|
1653
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
1654
|
+
//
|
|
1655
|
+
// The SAME stamps[] array §5.3 carries, so it gets the same capability. The
|
|
1656
|
+
// alternative — a stamp that can be re-fetched inside one body and not the
|
|
1657
|
+
// other — asks every consumer to remember which wrapper it is holding before
|
|
1658
|
+
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
1659
|
+
// exception a reader eventually gets wrong.
|
|
1660
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(
|
|
1661
|
+
stamps,
|
|
1662
|
+
witnessed,
|
|
1663
|
+
options,
|
|
1664
|
+
errors,
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1551
1667
|
// --- Rollup ----------------------------------------------------------------
|
|
1552
1668
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
1553
1669
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
@@ -1561,17 +1677,25 @@ async function verifyStampTrace(
|
|
|
1561
1677
|
// does not have.
|
|
1562
1678
|
status = "partial";
|
|
1563
1679
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1564
|
-
} else if (anyUnwitnessed) {
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
// our coverage and never an accusation about their evidence. It is also not
|
|
1568
|
-
// `verified`, because for a stamp the witness is the entire independent
|
|
1569
|
-
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
1570
|
-
// signature and an inclusion proof that stand on their own offline.
|
|
1680
|
+
} else if (gatewayUnavailable || anyUnwitnessed) {
|
|
1681
|
+
// Both hold the bundle at `partial`, and both get said — see §5.3's rollup
|
|
1682
|
+
// for why one note is not enough when there are two different gaps.
|
|
1571
1683
|
status = "partial";
|
|
1572
|
-
|
|
1573
|
-
"one or more stamps
|
|
1574
|
-
|
|
1684
|
+
if (gatewayUnavailable) {
|
|
1685
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
1686
|
+
}
|
|
1687
|
+
if (anyUnwitnessed) {
|
|
1688
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1689
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
1690
|
+
// about our coverage and never an accusation about their evidence. It is
|
|
1691
|
+
// also not `verified`, because for a stamp the witness is the entire
|
|
1692
|
+
// independent part — unlike an anchor-trace checkpoint, which still
|
|
1693
|
+
// carries a producer signature and an inclusion proof that stand on
|
|
1694
|
+
// their own offline.
|
|
1695
|
+
errors.push(
|
|
1696
|
+
"one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1575
1699
|
} else {
|
|
1576
1700
|
status = "verified";
|
|
1577
1701
|
}
|
|
@@ -1582,7 +1706,7 @@ async function verifyStampTrace(
|
|
|
1582
1706
|
bodyHashOk: true,
|
|
1583
1707
|
signatureOk: true,
|
|
1584
1708
|
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
1585
|
-
onChainChecked
|
|
1709
|
+
onChainChecked,
|
|
1586
1710
|
checkpoints: [],
|
|
1587
1711
|
events: [],
|
|
1588
1712
|
assertedStatus,
|
|
@@ -1591,6 +1715,320 @@ async function verifyStampTrace(
|
|
|
1591
1715
|
};
|
|
1592
1716
|
}
|
|
1593
1717
|
|
|
1718
|
+
// ===========================================================================
|
|
1719
|
+
// `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
|
|
1720
|
+
//
|
|
1721
|
+
// The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
|
|
1722
|
+
// the sub-tree partition check). The event half reuses the same primitives the
|
|
1723
|
+
// anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
|
|
1724
|
+
// `verifyDisclosedContent` — because a second copy of Merkle verification is a
|
|
1725
|
+
// second place for it to drift.
|
|
1726
|
+
//
|
|
1727
|
+
// What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
|
|
1728
|
+
// to a stamps[] entry and walks its audit path to the root committed INSIDE
|
|
1729
|
+
// that stamp's witnessed bytes. There is no `merkle_root` field on the event to
|
|
1730
|
+
// read instead — repeating the root beside its own authoritative bytes would be
|
|
1731
|
+
// two answers to one question inside one signature (§5.1.2).
|
|
1732
|
+
// ===========================================================================
|
|
1733
|
+
async function verifyStampEvidence(
|
|
1734
|
+
b: EvidenceBundle,
|
|
1735
|
+
specVersionOk: boolean,
|
|
1736
|
+
assertedStatus: string | null,
|
|
1737
|
+
errors: string[],
|
|
1738
|
+
options: VerifyEvidenceOptions,
|
|
1739
|
+
): Promise<EvidenceBundleResult> {
|
|
1740
|
+
const body = b.body as StampEvidenceBody;
|
|
1741
|
+
if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
|
|
1742
|
+
return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
1746
|
+
const seen = new Set<string>();
|
|
1747
|
+
const stamps: StampResult[] = [];
|
|
1748
|
+
const witnessed: (string | undefined)[] = [];
|
|
1749
|
+
for (const entry of body.stamps) {
|
|
1750
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
1751
|
+
stamps.push(outcome.result);
|
|
1752
|
+
witnessed.push(outcome.witnessedHex);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// The join table. ONLY a stamp whose derived ref and witnessed bytes both
|
|
1756
|
+
// checked out is joinable: a root read out of bytes that failed their own
|
|
1757
|
+
// verification is not a root anyone should walk a proof to, and admitting one
|
|
1758
|
+
// would let a broken stamp lend its authority to an event.
|
|
1759
|
+
const byRef = new Map<string, StampResult>();
|
|
1760
|
+
for (const st of stamps) {
|
|
1761
|
+
if (st.stampRef && st.stampRefOk && st.bodyOk) byRef.set(st.stampRef, st);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// --- The event half -------------------------------------------------------
|
|
1765
|
+
const eventResults: EventResult[] = [];
|
|
1766
|
+
for (const rawEv of body.events) {
|
|
1767
|
+
const evErrors: string[] = [];
|
|
1768
|
+
// A verifier must never RAISE on adversarial input — a caller handed a
|
|
1769
|
+
// stack trace instead of a verdict cannot tell a malformed pack from a
|
|
1770
|
+
// broken tool. `events[]` is attacker-shaped, so a null/non-object entry
|
|
1771
|
+
// is coerced to an empty event and fails through the normal path, exactly
|
|
1772
|
+
// as the Python kernel does. Found by adversarial review, not by the
|
|
1773
|
+
// cross-kernel leg — whose cases did not include one, and now do.
|
|
1774
|
+
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
1775
|
+
// NOT an event. Graded and reported as such rather than coerced to an
|
|
1776
|
+
// empty one: probing verifyEnvelope with nothing asks the envelope layer
|
|
1777
|
+
// a question about an object that was never an envelope, and the two
|
|
1778
|
+
// kernels answer it differently (undetermined vs failed) — a real
|
|
1779
|
+
// divergence, but not this body's to resolve. Reported separately.
|
|
1780
|
+
eventResults.push({
|
|
1781
|
+
eventId: "<unknown>",
|
|
1782
|
+
envelopeOk: false,
|
|
1783
|
+
payloadBindingOk: null,
|
|
1784
|
+
inclusionOk: false,
|
|
1785
|
+
checkpointBound: false,
|
|
1786
|
+
contentOk: null,
|
|
1787
|
+
ok: false,
|
|
1788
|
+
errors: ["events[] entry is not an object"],
|
|
1789
|
+
});
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
const ev = rawEv as StampEvidenceEvent;
|
|
1793
|
+
const eventId = getEventId(ev.envelope);
|
|
1794
|
+
let envelopeOk = false;
|
|
1795
|
+
let payloadBindingOk: boolean | null = null;
|
|
1796
|
+
|
|
1797
|
+
let recordBytes: Uint8Array | undefined;
|
|
1798
|
+
try {
|
|
1799
|
+
if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
|
|
1800
|
+
} catch (e) {
|
|
1801
|
+
evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
|
|
1802
|
+
}
|
|
1803
|
+
try {
|
|
1804
|
+
const res = await verifyEnvelope(
|
|
1805
|
+
ev.envelope,
|
|
1806
|
+
recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
|
|
1807
|
+
);
|
|
1808
|
+
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1809
|
+
payloadBindingOk = res.payloadHashOk;
|
|
1810
|
+
if (!res.signatureOk) evErrors.push("event envelope signature failed");
|
|
1811
|
+
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// --- The join (§5.3.3 rule 1) -------------------------------------------
|
|
1817
|
+
const incl = ev.inclusion as (StampEvidenceInclusion & Record<string, unknown>) | undefined;
|
|
1818
|
+
let st: StampResult | undefined;
|
|
1819
|
+
if (!incl || typeof incl !== "object") {
|
|
1820
|
+
evErrors.push("event missing inclusion proof");
|
|
1821
|
+
} else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
|
|
1822
|
+
// One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
|
|
1823
|
+
// join key inside this body is a mixed bundle, and resolving it anyway is
|
|
1824
|
+
// how an event silently binds to something nobody meant (§5.1.2).
|
|
1825
|
+
evErrors.push(
|
|
1826
|
+
`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`,
|
|
1827
|
+
);
|
|
1828
|
+
} else if (typeof incl.stamp_ref !== "string") {
|
|
1829
|
+
evErrors.push("event inclusion missing stamp_ref");
|
|
1830
|
+
} else {
|
|
1831
|
+
st = byRef.get(incl.stamp_ref);
|
|
1832
|
+
if (st === undefined) {
|
|
1833
|
+
// Hard reject, never undetermined: without a resolved stamp the
|
|
1834
|
+
// inclusion proof has no root to check against at all.
|
|
1835
|
+
evErrors.push(
|
|
1836
|
+
`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`,
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const stampBound = st !== undefined;
|
|
1841
|
+
|
|
1842
|
+
let inclusionOk = false;
|
|
1843
|
+
if (incl && stampBound && st) {
|
|
1844
|
+
// Rule 3: the tree walked must be the tree the stamp committed to. A
|
|
1845
|
+
// proof walked against a different leaf_count is a proof about a
|
|
1846
|
+
// different tree, and RFC 9162's path shape depends on the size.
|
|
1847
|
+
if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
|
|
1848
|
+
evErrors.push(
|
|
1849
|
+
`inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
|
|
1850
|
+
`leaf_count ${st.leafCount}`,
|
|
1851
|
+
);
|
|
1852
|
+
} else {
|
|
1853
|
+
try {
|
|
1854
|
+
inclusionOk = await verifyInclusion(
|
|
1855
|
+
hexToBytes(incl.leaf_hash),
|
|
1856
|
+
incl.leaf_index,
|
|
1857
|
+
incl.leaf_count,
|
|
1858
|
+
(incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
1859
|
+
hexToBytes(st.merkleRoot ?? ""),
|
|
1860
|
+
);
|
|
1861
|
+
if (!inclusionOk) {
|
|
1862
|
+
evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Defense in depth, as §5.1: the claimed leaf MUST be this event's
|
|
1866
|
+
// own signed envelope bytes, or a valid proof for an UNRELATED leaf
|
|
1867
|
+
// could be smuggled in.
|
|
1868
|
+
const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
|
|
1869
|
+
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
1870
|
+
inclusionOk = false;
|
|
1871
|
+
evErrors.push(
|
|
1872
|
+
"inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))",
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
} catch (e) {
|
|
1876
|
+
evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
|
|
1882
|
+
const contentOk = contentRes.contentOk;
|
|
1883
|
+
evErrors.push(...contentRes.errors);
|
|
1884
|
+
|
|
1885
|
+
eventResults.push({
|
|
1886
|
+
eventId,
|
|
1887
|
+
envelopeOk,
|
|
1888
|
+
payloadBindingOk,
|
|
1889
|
+
inclusionOk,
|
|
1890
|
+
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
1891
|
+
checkpointBound: stampBound,
|
|
1892
|
+
contentOk,
|
|
1893
|
+
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
|
|
1894
|
+
errors: evErrors,
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
1899
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(
|
|
1900
|
+
stamps,
|
|
1901
|
+
witnessed,
|
|
1902
|
+
options,
|
|
1903
|
+
errors,
|
|
1904
|
+
);
|
|
1905
|
+
|
|
1906
|
+
// --- Rollup ---------------------------------------------------------------
|
|
1907
|
+
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1908
|
+
// is the false green this body exists to prevent, so an event that did not
|
|
1909
|
+
// verify fails the bundle — it is never rolled up as verified.
|
|
1910
|
+
const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
|
|
1911
|
+
const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
|
|
1912
|
+
|
|
1913
|
+
let status: EvidenceStatus;
|
|
1914
|
+
if (anyHardFailure) {
|
|
1915
|
+
status = "failed";
|
|
1916
|
+
} else if (stamps.length === 0 && eventResults.length === 0) {
|
|
1917
|
+
status = "partial";
|
|
1918
|
+
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1919
|
+
} else if (gatewayUnavailable || anyUnwitnessed) {
|
|
1920
|
+
// Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
|
|
1921
|
+
// witnessed and a witness we could not reach are different gaps with
|
|
1922
|
+
// different fixes — one waits for the interval, the other for the network —
|
|
1923
|
+
// and an auditor told only about the second would read the first as fine.
|
|
1924
|
+
// The order is fixed so the two kernels emit the same list.
|
|
1925
|
+
status = "partial";
|
|
1926
|
+
if (gatewayUnavailable) {
|
|
1927
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
1928
|
+
}
|
|
1929
|
+
if (anyUnwitnessed) {
|
|
1930
|
+
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
1931
|
+
// the independent part, so the events resting on it inherit `partial`.
|
|
1932
|
+
errors.push(
|
|
1933
|
+
"one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
} else {
|
|
1937
|
+
status = "verified";
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
return {
|
|
1941
|
+
status,
|
|
1942
|
+
specVersionOk,
|
|
1943
|
+
bodyHashOk: true,
|
|
1944
|
+
signatureOk: true,
|
|
1945
|
+
bodyType: STAMP_EVIDENCE_BODY_TYPE,
|
|
1946
|
+
onChainChecked,
|
|
1947
|
+
checkpoints: [],
|
|
1948
|
+
events: eventResults,
|
|
1949
|
+
assertedStatus,
|
|
1950
|
+
stamps,
|
|
1951
|
+
errors,
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
|
|
1957
|
+
*
|
|
1958
|
+
* ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
|
|
1959
|
+
*
|
|
1960
|
+
* A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
|
|
1961
|
+
* carry these outcomes would make a stamp indistinguishable from a checkpoint
|
|
1962
|
+
* in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
|
|
1963
|
+
* and §5.3.1 refused twice, most sharply in "a Sanning attestation
|
|
1964
|
+
* indistinguishable on the wire from a producer's". They are different
|
|
1965
|
+
* artifacts with different signers and different meanings when absent, and a
|
|
1966
|
+
* reader must never have to guess which one it is holding. So the stamp
|
|
1967
|
+
* results go on the stamps, and `checkpoints: []` keeps saying the true thing:
|
|
1968
|
+
* this body has none.
|
|
1969
|
+
*
|
|
1970
|
+
* ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
|
|
1971
|
+
*
|
|
1972
|
+
* The anchor-trace path synthesises an all-`unreachable` result for a
|
|
1973
|
+
* checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
|
|
1974
|
+
*
|
|
1975
|
+
* It would be untrue: nothing was asked of any gateway, so reporting that
|
|
1976
|
+
* every gateway failed to answer invents a network event. "Not checked" and
|
|
1977
|
+
* "we asked and nobody answered" are different facts, and collapsing them is
|
|
1978
|
+
* precisely the defect that had a surface reporting an unreachable gateway
|
|
1979
|
+
* when it had contacted none.
|
|
1980
|
+
*
|
|
1981
|
+
* And it would be load-bearing in the wrong direction. Over there the
|
|
1982
|
+
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
1983
|
+
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
1984
|
+
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
1985
|
+
* nothing to the status and would silently move the CLI exit code from 0 to 3
|
|
1986
|
+
* for an unwitnessed stamp — changing what an auditor's script does, based on
|
|
1987
|
+
* nothing but whether the caller happened to pass a gateway list.
|
|
1988
|
+
*/
|
|
1989
|
+
async function refetchStamps(
|
|
1990
|
+
stamps: StampResult[],
|
|
1991
|
+
witnessed: (string | undefined)[],
|
|
1992
|
+
options: VerifyEvidenceOptions,
|
|
1993
|
+
errors: string[],
|
|
1994
|
+
): Promise<{ onChainChecked: boolean; gatewayUnavailable: boolean }> {
|
|
1995
|
+
const gateways = options.gateways ?? [];
|
|
1996
|
+
const onChainChecked = gateways.length > 0;
|
|
1997
|
+
if (!onChainChecked) return { onChainChecked, gatewayUnavailable: false };
|
|
1998
|
+
|
|
1999
|
+
const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
|
|
2000
|
+
if (typeof fetchImpl !== "function") {
|
|
2001
|
+
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
2002
|
+
return { onChainChecked, gatewayUnavailable: true };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
let gatewayUnavailable = false;
|
|
2006
|
+
for (let i = 0; i < stamps.length; i++) {
|
|
2007
|
+
const st = stamps[i]!;
|
|
2008
|
+
const witnessedHex = witnessed[i];
|
|
2009
|
+
// No witness named, or bytes we could not read: nothing to re-fetch, and
|
|
2010
|
+
// nothing a gateway could confirm or deny. Left as `onChain: null`.
|
|
2011
|
+
if (st.anchorRef === null || witnessedHex === undefined) continue;
|
|
2012
|
+
|
|
2013
|
+
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
2014
|
+
st.onChain = outcome.onChain;
|
|
2015
|
+
st.onChainOk = outcome.onChain.onChainOk;
|
|
2016
|
+
if (outcome.unavailable) gatewayUnavailable = true;
|
|
2017
|
+
if (outcome.error) st.errors.push(outcome.error);
|
|
2018
|
+
// Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
|
|
2019
|
+
// (false) fails the stamp — the chain does not hold what the bundle says
|
|
2020
|
+
// was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
|
|
2021
|
+
// downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
|
|
2022
|
+
// changes nothing that was not already true offline.
|
|
2023
|
+
//
|
|
2024
|
+
// `st.ok` is kept and narrowed rather than recomputed: its offline form
|
|
2025
|
+
// includes `errors.length === 0`, and re-evaluating that AFTER pushing an
|
|
2026
|
+
// unreachable note would fail a stamp for a gateway being down.
|
|
2027
|
+
st.ok = st.ok && st.onChainOk !== false;
|
|
2028
|
+
}
|
|
2029
|
+
return { onChainChecked, gatewayUnavailable };
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1594
2032
|
// ===========================================================================
|
|
1595
2033
|
// Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
|
|
1596
2034
|
//
|
|
@@ -2362,6 +2800,10 @@ async function refetchCheckpoint(
|
|
|
2362
2800
|
): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
|
|
2363
2801
|
let expectedHex: string | undefined;
|
|
2364
2802
|
try {
|
|
2803
|
+
// A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
|
|
2804
|
+
// bundle carries the envelope as a JSON object, not as bytes, so what was
|
|
2805
|
+
// uploaded has to be recomputed from it. See `refetchStamp` for the case
|
|
2806
|
+
// where this step is not merely unnecessary but WRONG.
|
|
2365
2807
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
2366
2808
|
} catch {
|
|
2367
2809
|
// Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
|
|
@@ -2369,6 +2811,54 @@ async function refetchCheckpoint(
|
|
|
2369
2811
|
expectedHex = undefined;
|
|
2370
2812
|
}
|
|
2371
2813
|
|
|
2814
|
+
return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
// Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
|
|
2818
|
+
//
|
|
2819
|
+
// ── The one step this path must NOT copy from the checkpoint path ──
|
|
2820
|
+
//
|
|
2821
|
+
// `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
|
|
2822
|
+
// witnessed bytes are not in the bundle — only the object they were derived
|
|
2823
|
+
// from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
|
|
2824
|
+
// "lowercase hex of the exact JCS bytes that were handed to the witness", and
|
|
2825
|
+
// §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
|
|
2826
|
+
// are already the bytes, already hex, already lowercase (`isLowerHex` gated
|
|
2827
|
+
// them), and `stamp_ref` is their SHA-256.
|
|
2828
|
+
//
|
|
2829
|
+
// So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
|
|
2830
|
+
// defect, not a redundancy: it would compare the chain against bytes NOBODY
|
|
2831
|
+
// SIGNED. On a canonical body the two agree and the bug hides; on a body that
|
|
2832
|
+
// is not canonical — the exact case §5.2.2 makes the kernel check for, because
|
|
2833
|
+
// the plane replays these bytes as text precisely so a normalizing serializer
|
|
2834
|
+
// cannot alter them — a re-canonicalizing verifier reports `mismatch` against
|
|
2835
|
+
// a chain that faithfully holds what it was given. A false accusation, from
|
|
2836
|
+
// the verifier, about untampered evidence.
|
|
2837
|
+
//
|
|
2838
|
+
// The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
|
|
2839
|
+
// `stamp_ref`, threaded through rather than re-read from the body, so the bytes
|
|
2840
|
+
// graded and the bytes compared cannot drift apart.
|
|
2841
|
+
async function refetchStamp(
|
|
2842
|
+
witnessedHex: string,
|
|
2843
|
+
anchorRef: AnchorRef,
|
|
2844
|
+
gateways: string[],
|
|
2845
|
+
fetchImpl: typeof fetch,
|
|
2846
|
+
): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
|
|
2847
|
+
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// The part both paths share: probe every gateway, roll the outcomes up under
|
|
2851
|
+
// the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
|
|
2852
|
+
// copied — a second rollup rule is a second place for "mismatch beats confirm
|
|
2853
|
+
// beats unreachable" to drift, and the drifted copy is the one nobody reads.
|
|
2854
|
+
// The subject only ever selects wording; it never changes a verdict.
|
|
2855
|
+
async function refetchAnchored(
|
|
2856
|
+
subject: "checkpoint" | "stamp",
|
|
2857
|
+
expectedHex: string | undefined,
|
|
2858
|
+
anchorRef: AnchorRef,
|
|
2859
|
+
gateways: string[],
|
|
2860
|
+
fetchImpl: typeof fetch,
|
|
2861
|
+
): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
|
|
2372
2862
|
const perGateway: PerGatewayOutcome[] = [];
|
|
2373
2863
|
for (const gw of gateways) {
|
|
2374
2864
|
perGateway.push({
|
|
@@ -2380,9 +2870,12 @@ async function refetchCheckpoint(
|
|
|
2380
2870
|
const onChain = rollupOnChain(perGateway);
|
|
2381
2871
|
let error: string | undefined;
|
|
2382
2872
|
if (onChain.rollup === "mismatch") {
|
|
2383
|
-
error =
|
|
2873
|
+
error =
|
|
2874
|
+
subject === "checkpoint"
|
|
2875
|
+
? `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`
|
|
2876
|
+
: `on-chain bytes at ${anchorRef.id} do not match the stamp's witnessed bytes`;
|
|
2384
2877
|
} else if (onChain.rollup === "unreachable") {
|
|
2385
|
-
error =
|
|
2878
|
+
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
2386
2879
|
}
|
|
2387
2880
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
2388
2881
|
}
|