@sanning/proof 0.8.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/cli.js +25 -0
- package/dist/evidence.d.ts +29 -0
- package/dist/evidence.js +183 -29
- package/package.json +1 -1
- package/src/cli.ts +29 -0
- package/src/evidence.ts +266 -32
- package/vectors/README.md +18 -0
- package/vectors/stamp-on-chain.json +229 -0
package/dist/cli.js
CHANGED
|
@@ -610,6 +610,31 @@ function reportEvidence(result, path, gateways, io, chains) {
|
|
|
610
610
|
io.out(paint(` - ${e}`, R));
|
|
611
611
|
}
|
|
612
612
|
}
|
|
613
|
+
// Stamps (evidence-bundle.md §5.2 / §5.3). Printed rather than summarised,
|
|
614
|
+
// because for a stamp body the WITNESS is the entire independent part
|
|
615
|
+
// (§5.2.4) — and until now this reporter showed nothing about it at all, so a
|
|
616
|
+
// §5.3 pack printed a rollup line claiming an on-chain scope with no visible
|
|
617
|
+
// on-chain result anywhere above it.
|
|
618
|
+
//
|
|
619
|
+
// The on-chain column distinguishes all four states in the same three
|
|
620
|
+
// characters the checkpoint block uses, plus its absence: `✓` confirmed,
|
|
621
|
+
// `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
|
|
622
|
+
// nothing was checked. A reader must never have to infer "not checked" from
|
|
623
|
+
// a symbol that also means "could not be reached".
|
|
624
|
+
if (result.stamps && result.stamps.length > 0) {
|
|
625
|
+
io.out("");
|
|
626
|
+
io.out(paint(" Stamps", DIM));
|
|
627
|
+
for (const st of result.stamps) {
|
|
628
|
+
const witness = st.anchorRef === null
|
|
629
|
+
? paint("~ no witness", Y)
|
|
630
|
+
: `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
|
|
631
|
+
const onChain = st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
|
|
632
|
+
io.out(` ${mark(st.ok)} ${shortTx(st.stampRef)} bytes ${mark(st.stampRefOk && st.bodyOk)}` +
|
|
633
|
+
` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`);
|
|
634
|
+
for (const e of st.errors)
|
|
635
|
+
io.out(paint(` - ${e}`, R));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
613
638
|
if (result.events.length > 0) {
|
|
614
639
|
io.out("");
|
|
615
640
|
io.out(paint(" Events", DIM));
|
package/dist/evidence.d.ts
CHANGED
|
@@ -292,6 +292,35 @@ export interface StampResult {
|
|
|
292
292
|
* carries it.
|
|
293
293
|
*/
|
|
294
294
|
anchorRef: AnchorRef | null;
|
|
295
|
+
/**
|
|
296
|
+
* Per-gateway on-chain outcomes for this stamp's `anchor_ref` (§4.2's shape,
|
|
297
|
+
* `checkpoints[]`'s vocabulary), or `null` when NOTHING WAS CHECKED.
|
|
298
|
+
*
|
|
299
|
+
* ── The four states, and why none of them collapses ──
|
|
300
|
+
*
|
|
301
|
+
* `null` means not checked — no gateways were supplied, or this stamp names
|
|
302
|
+
* no witness to check. It is a statement about the RUN, never about the
|
|
303
|
+
* stamp. The other three are statements about the stamp and live in
|
|
304
|
+
* `onChain.rollup`: `confirm` (a gateway returned the witnessed bytes),
|
|
305
|
+
* `mismatch` (a gateway returned DIFFERENT bytes — a finding, and the only
|
|
306
|
+
* one of the four that fails anything), `unreachable` (we asked and nobody
|
|
307
|
+
* answered — an availability gap, never an accusation).
|
|
308
|
+
*
|
|
309
|
+
* Keeping "not checked" distinct from "unreachable" is the whole point. A
|
|
310
|
+
* surface handed a collapsed boolean cannot tell "no gateway could be
|
|
311
|
+
* reached" from "no gateway was tried", and reported the first when the
|
|
312
|
+
* second was true — a false accusation manufactured by a missing state.
|
|
313
|
+
*/
|
|
314
|
+
onChain: OnChainResult | null;
|
|
315
|
+
/**
|
|
316
|
+
* Derived collapse of `onChain`, mirroring `CheckpointResult.onChainOk` so a
|
|
317
|
+
* consumer reads one field the same way on both: `false` on mismatch, `true`
|
|
318
|
+
* on confirm, `null` on unreachable AND on not-checked.
|
|
319
|
+
*
|
|
320
|
+
* It is deliberately the LOSSY view — `null` is three-quarters of the state
|
|
321
|
+
* space here. Anything that needs to tell those apart reads `onChain`.
|
|
322
|
+
*/
|
|
323
|
+
onChainOk: boolean | null;
|
|
295
324
|
ok: boolean;
|
|
296
325
|
errors: string[];
|
|
297
326
|
}
|
package/dist/evidence.js
CHANGED
|
@@ -194,7 +194,7 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
194
194
|
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
195
195
|
// a stamp carries no second signature of its own inside the bundle.
|
|
196
196
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
197
|
-
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
197
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
198
198
|
}
|
|
199
199
|
// The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
|
|
200
200
|
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
@@ -768,18 +768,20 @@ async function verifyStampEntry(raw, seen) {
|
|
|
768
768
|
subtreesOk: false,
|
|
769
769
|
subtrees: [],
|
|
770
770
|
anchorRef: null,
|
|
771
|
+
onChain: null,
|
|
772
|
+
onChainOk: null,
|
|
771
773
|
ok: false,
|
|
772
774
|
errors,
|
|
773
775
|
};
|
|
774
776
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
775
777
|
errors.push("stamp entry is not an object");
|
|
776
|
-
return base;
|
|
778
|
+
return { result: base };
|
|
777
779
|
}
|
|
778
780
|
const e = raw;
|
|
779
781
|
// --- the witnessed bytes, which are the only authority in this body -------
|
|
780
782
|
if (!isLowerHex(e.stamp_bytes)) {
|
|
781
783
|
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
782
|
-
return base;
|
|
784
|
+
return { result: base };
|
|
783
785
|
}
|
|
784
786
|
let bytes;
|
|
785
787
|
try {
|
|
@@ -787,8 +789,11 @@ async function verifyStampEntry(raw, seen) {
|
|
|
787
789
|
}
|
|
788
790
|
catch (err) {
|
|
789
791
|
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
790
|
-
return base;
|
|
792
|
+
return { result: base };
|
|
791
793
|
}
|
|
794
|
+
// Gated by `isLowerHex` above, so this is already the exact lowercase hex of
|
|
795
|
+
// the exact witnessed bytes — §5.2.2's "one authority in this body".
|
|
796
|
+
const witnessedHex = e.stamp_bytes;
|
|
792
797
|
// --- the derived identity -------------------------------------------------
|
|
793
798
|
const derived = await sha256Hex(bytes);
|
|
794
799
|
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
@@ -818,7 +823,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
818
823
|
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
819
824
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
820
825
|
errors.push("stamp body is not a JSON object");
|
|
821
|
-
return base;
|
|
826
|
+
return { result: base, witnessedHex };
|
|
822
827
|
}
|
|
823
828
|
body = parsed;
|
|
824
829
|
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
@@ -827,12 +832,12 @@ async function verifyStampEntry(raw, seen) {
|
|
|
827
832
|
// of the stamp disagree with the one that was witnessed.
|
|
828
833
|
if (jcs(body) !== new TextDecoder().decode(bytes)) {
|
|
829
834
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
830
|
-
return base;
|
|
835
|
+
return { result: base, witnessedHex };
|
|
831
836
|
}
|
|
832
837
|
}
|
|
833
838
|
catch (err) {
|
|
834
839
|
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
835
|
-
return base;
|
|
840
|
+
return { result: base, witnessedHex };
|
|
836
841
|
}
|
|
837
842
|
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
838
843
|
// A `sanning.stamp.trace/v1` body whose inner version is something else is
|
|
@@ -841,7 +846,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
841
846
|
// how a reader half-parses a shape it does not know.
|
|
842
847
|
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
843
848
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
|
|
844
|
-
return base;
|
|
849
|
+
return { result: base, witnessedHex };
|
|
845
850
|
}
|
|
846
851
|
base.stampVersion = body.stamp_version;
|
|
847
852
|
base.bodyOk = true;
|
|
@@ -872,7 +877,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
872
877
|
base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
|
|
873
878
|
base.ok =
|
|
874
879
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
875
|
-
return base;
|
|
880
|
+
return { result: base, witnessedHex };
|
|
876
881
|
}
|
|
877
882
|
/**
|
|
878
883
|
* The range arithmetic (§5.2), and the reason it is a hard check.
|
|
@@ -948,16 +953,27 @@ function checkSubtrees(body, out, errors) {
|
|
|
948
953
|
}
|
|
949
954
|
return true;
|
|
950
955
|
}
|
|
951
|
-
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
956
|
+
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, options) {
|
|
952
957
|
const body = b.body;
|
|
953
958
|
if (!Array.isArray(body?.stamps)) {
|
|
954
959
|
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
955
960
|
}
|
|
956
961
|
const seen = new Set();
|
|
957
962
|
const stamps = [];
|
|
963
|
+
const witnessed = [];
|
|
958
964
|
for (const entry of body.stamps) {
|
|
959
|
-
|
|
965
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
966
|
+
stamps.push(outcome.result);
|
|
967
|
+
witnessed.push(outcome.witnessedHex);
|
|
960
968
|
}
|
|
969
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
970
|
+
//
|
|
971
|
+
// The SAME stamps[] array §5.3 carries, so it gets the same capability. The
|
|
972
|
+
// alternative — a stamp that can be re-fetched inside one body and not the
|
|
973
|
+
// other — asks every consumer to remember which wrapper it is holding before
|
|
974
|
+
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
975
|
+
// exception a reader eventually gets wrong.
|
|
976
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
961
977
|
// --- Rollup ----------------------------------------------------------------
|
|
962
978
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
963
979
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
@@ -972,15 +988,23 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
972
988
|
status = "partial";
|
|
973
989
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
974
990
|
}
|
|
975
|
-
else if (anyUnwitnessed) {
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
// our coverage and never an accusation about their evidence. It is also not
|
|
979
|
-
// `verified`, because for a stamp the witness is the entire independent
|
|
980
|
-
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
981
|
-
// signature and an inclusion proof that stand on their own offline.
|
|
991
|
+
else if (gatewayUnavailable || anyUnwitnessed) {
|
|
992
|
+
// Both hold the bundle at `partial`, and both get said — see §5.3's rollup
|
|
993
|
+
// for why one note is not enough when there are two different gaps.
|
|
982
994
|
status = "partial";
|
|
983
|
-
|
|
995
|
+
if (gatewayUnavailable) {
|
|
996
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
997
|
+
}
|
|
998
|
+
if (anyUnwitnessed) {
|
|
999
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1000
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
1001
|
+
// about our coverage and never an accusation about their evidence. It is
|
|
1002
|
+
// also not `verified`, because for a stamp the witness is the entire
|
|
1003
|
+
// independent part — unlike an anchor-trace checkpoint, which still
|
|
1004
|
+
// carries a producer signature and an inclusion proof that stand on
|
|
1005
|
+
// their own offline.
|
|
1006
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1007
|
+
}
|
|
984
1008
|
}
|
|
985
1009
|
else {
|
|
986
1010
|
status = "verified";
|
|
@@ -991,7 +1015,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
991
1015
|
bodyHashOk: true,
|
|
992
1016
|
signatureOk: true,
|
|
993
1017
|
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
994
|
-
onChainChecked
|
|
1018
|
+
onChainChecked,
|
|
995
1019
|
checkpoints: [],
|
|
996
1020
|
events: [],
|
|
997
1021
|
assertedStatus,
|
|
@@ -1022,8 +1046,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1022
1046
|
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
1023
1047
|
const seen = new Set();
|
|
1024
1048
|
const stamps = [];
|
|
1025
|
-
|
|
1026
|
-
|
|
1049
|
+
const witnessed = [];
|
|
1050
|
+
for (const entry of body.stamps) {
|
|
1051
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
1052
|
+
stamps.push(outcome.result);
|
|
1053
|
+
witnessed.push(outcome.witnessedHex);
|
|
1054
|
+
}
|
|
1027
1055
|
// The join table. ONLY a stamp whose derived ref and witnessed bytes both
|
|
1028
1056
|
// checked out is joinable: a root read out of bytes that failed their own
|
|
1029
1057
|
// verification is not a root anyone should walk a proof to, and admitting one
|
|
@@ -1153,6 +1181,8 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1153
1181
|
errors: evErrors,
|
|
1154
1182
|
});
|
|
1155
1183
|
}
|
|
1184
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
1185
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1156
1186
|
// --- Rollup ---------------------------------------------------------------
|
|
1157
1187
|
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1158
1188
|
// is the false green this body exists to prevent, so an event that did not
|
|
@@ -1167,11 +1197,21 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1167
1197
|
status = "partial";
|
|
1168
1198
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1169
1199
|
}
|
|
1170
|
-
else if (anyUnwitnessed) {
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1200
|
+
else if (gatewayUnavailable || anyUnwitnessed) {
|
|
1201
|
+
// Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
|
|
1202
|
+
// witnessed and a witness we could not reach are different gaps with
|
|
1203
|
+
// different fixes — one waits for the interval, the other for the network —
|
|
1204
|
+
// and an auditor told only about the second would read the first as fine.
|
|
1205
|
+
// The order is fixed so the two kernels emit the same list.
|
|
1173
1206
|
status = "partial";
|
|
1174
|
-
|
|
1207
|
+
if (gatewayUnavailable) {
|
|
1208
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
1209
|
+
}
|
|
1210
|
+
if (anyUnwitnessed) {
|
|
1211
|
+
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
1212
|
+
// the independent part, so the events resting on it inherit `partial`.
|
|
1213
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1214
|
+
}
|
|
1175
1215
|
}
|
|
1176
1216
|
else {
|
|
1177
1217
|
status = "verified";
|
|
@@ -1182,7 +1222,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1182
1222
|
bodyHashOk: true,
|
|
1183
1223
|
signatureOk: true,
|
|
1184
1224
|
bodyType: STAMP_EVIDENCE_BODY_TYPE,
|
|
1185
|
-
onChainChecked
|
|
1225
|
+
onChainChecked,
|
|
1186
1226
|
checkpoints: [],
|
|
1187
1227
|
events: eventResults,
|
|
1188
1228
|
assertedStatus,
|
|
@@ -1190,6 +1230,78 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1190
1230
|
errors,
|
|
1191
1231
|
};
|
|
1192
1232
|
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
|
|
1235
|
+
*
|
|
1236
|
+
* ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
|
|
1237
|
+
*
|
|
1238
|
+
* A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
|
|
1239
|
+
* carry these outcomes would make a stamp indistinguishable from a checkpoint
|
|
1240
|
+
* in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
|
|
1241
|
+
* and §5.3.1 refused twice, most sharply in "a Sanning attestation
|
|
1242
|
+
* indistinguishable on the wire from a producer's". They are different
|
|
1243
|
+
* artifacts with different signers and different meanings when absent, and a
|
|
1244
|
+
* reader must never have to guess which one it is holding. So the stamp
|
|
1245
|
+
* results go on the stamps, and `checkpoints: []` keeps saying the true thing:
|
|
1246
|
+
* this body has none.
|
|
1247
|
+
*
|
|
1248
|
+
* ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
|
|
1249
|
+
*
|
|
1250
|
+
* The anchor-trace path synthesises an all-`unreachable` result for a
|
|
1251
|
+
* checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
|
|
1252
|
+
*
|
|
1253
|
+
* It would be untrue: nothing was asked of any gateway, so reporting that
|
|
1254
|
+
* every gateway failed to answer invents a network event. "Not checked" and
|
|
1255
|
+
* "we asked and nobody answered" are different facts, and collapsing them is
|
|
1256
|
+
* precisely the defect that had a surface reporting an unreachable gateway
|
|
1257
|
+
* when it had contacted none.
|
|
1258
|
+
*
|
|
1259
|
+
* And it would be load-bearing in the wrong direction. Over there the
|
|
1260
|
+
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
1261
|
+
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
1262
|
+
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
1263
|
+
* nothing to the status and would silently move the CLI exit code from 0 to 3
|
|
1264
|
+
* for an unwitnessed stamp — changing what an auditor's script does, based on
|
|
1265
|
+
* nothing but whether the caller happened to pass a gateway list.
|
|
1266
|
+
*/
|
|
1267
|
+
async function refetchStamps(stamps, witnessed, options, errors) {
|
|
1268
|
+
const gateways = options.gateways ?? [];
|
|
1269
|
+
const onChainChecked = gateways.length > 0;
|
|
1270
|
+
if (!onChainChecked)
|
|
1271
|
+
return { onChainChecked, gatewayUnavailable: false };
|
|
1272
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1273
|
+
if (typeof fetchImpl !== "function") {
|
|
1274
|
+
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
1275
|
+
return { onChainChecked, gatewayUnavailable: true };
|
|
1276
|
+
}
|
|
1277
|
+
let gatewayUnavailable = false;
|
|
1278
|
+
for (let i = 0; i < stamps.length; i++) {
|
|
1279
|
+
const st = stamps[i];
|
|
1280
|
+
const witnessedHex = witnessed[i];
|
|
1281
|
+
// No witness named, or bytes we could not read: nothing to re-fetch, and
|
|
1282
|
+
// nothing a gateway could confirm or deny. Left as `onChain: null`.
|
|
1283
|
+
if (st.anchorRef === null || witnessedHex === undefined)
|
|
1284
|
+
continue;
|
|
1285
|
+
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
1286
|
+
st.onChain = outcome.onChain;
|
|
1287
|
+
st.onChainOk = outcome.onChain.onChainOk;
|
|
1288
|
+
if (outcome.unavailable)
|
|
1289
|
+
gatewayUnavailable = true;
|
|
1290
|
+
if (outcome.error)
|
|
1291
|
+
st.errors.push(outcome.error);
|
|
1292
|
+
// Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
|
|
1293
|
+
// (false) fails the stamp — the chain does not hold what the bundle says
|
|
1294
|
+
// was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
|
|
1295
|
+
// downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
|
|
1296
|
+
// changes nothing that was not already true offline.
|
|
1297
|
+
//
|
|
1298
|
+
// `st.ok` is kept and narrowed rather than recomputed: its offline form
|
|
1299
|
+
// includes `errors.length === 0`, and re-evaluating that AFTER pushing an
|
|
1300
|
+
// unreachable note would fail a stamp for a gateway being down.
|
|
1301
|
+
st.ok = st.ok && st.onChainOk !== false;
|
|
1302
|
+
}
|
|
1303
|
+
return { onChainChecked, gatewayUnavailable };
|
|
1304
|
+
}
|
|
1193
1305
|
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
1194
1306
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
1195
1307
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
@@ -1802,6 +1914,10 @@ function summarizeExport(status, source, attestations) {
|
|
|
1802
1914
|
async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
1803
1915
|
let expectedHex;
|
|
1804
1916
|
try {
|
|
1917
|
+
// A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
|
|
1918
|
+
// bundle carries the envelope as a JSON object, not as bytes, so what was
|
|
1919
|
+
// uploaded has to be recomputed from it. See `refetchStamp` for the case
|
|
1920
|
+
// where this step is not merely unnecessary but WRONG.
|
|
1805
1921
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
1806
1922
|
}
|
|
1807
1923
|
catch {
|
|
@@ -1809,6 +1925,41 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
|
1809
1925
|
// (we can never confirm bytes we cannot compute).
|
|
1810
1926
|
expectedHex = undefined;
|
|
1811
1927
|
}
|
|
1928
|
+
return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
|
|
1929
|
+
}
|
|
1930
|
+
// Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
|
|
1931
|
+
//
|
|
1932
|
+
// ── The one step this path must NOT copy from the checkpoint path ──
|
|
1933
|
+
//
|
|
1934
|
+
// `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
|
|
1935
|
+
// witnessed bytes are not in the bundle — only the object they were derived
|
|
1936
|
+
// from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
|
|
1937
|
+
// "lowercase hex of the exact JCS bytes that were handed to the witness", and
|
|
1938
|
+
// §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
|
|
1939
|
+
// are already the bytes, already hex, already lowercase (`isLowerHex` gated
|
|
1940
|
+
// them), and `stamp_ref` is their SHA-256.
|
|
1941
|
+
//
|
|
1942
|
+
// So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
|
|
1943
|
+
// defect, not a redundancy: it would compare the chain against bytes NOBODY
|
|
1944
|
+
// SIGNED. On a canonical body the two agree and the bug hides; on a body that
|
|
1945
|
+
// is not canonical — the exact case §5.2.2 makes the kernel check for, because
|
|
1946
|
+
// the plane replays these bytes as text precisely so a normalizing serializer
|
|
1947
|
+
// cannot alter them — a re-canonicalizing verifier reports `mismatch` against
|
|
1948
|
+
// a chain that faithfully holds what it was given. A false accusation, from
|
|
1949
|
+
// the verifier, about untampered evidence.
|
|
1950
|
+
//
|
|
1951
|
+
// The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
|
|
1952
|
+
// `stamp_ref`, threaded through rather than re-read from the body, so the bytes
|
|
1953
|
+
// graded and the bytes compared cannot drift apart.
|
|
1954
|
+
async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
|
|
1955
|
+
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
1956
|
+
}
|
|
1957
|
+
// The part both paths share: probe every gateway, roll the outcomes up under
|
|
1958
|
+
// the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
|
|
1959
|
+
// copied — a second rollup rule is a second place for "mismatch beats confirm
|
|
1960
|
+
// beats unreachable" to drift, and the drifted copy is the one nobody reads.
|
|
1961
|
+
// The subject only ever selects wording; it never changes a verdict.
|
|
1962
|
+
async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
|
|
1812
1963
|
const perGateway = [];
|
|
1813
1964
|
for (const gw of gateways) {
|
|
1814
1965
|
perGateway.push({
|
|
@@ -1819,10 +1970,13 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
|
1819
1970
|
const onChain = rollupOnChain(perGateway);
|
|
1820
1971
|
let error;
|
|
1821
1972
|
if (onChain.rollup === "mismatch") {
|
|
1822
|
-
error =
|
|
1973
|
+
error =
|
|
1974
|
+
subject === "checkpoint"
|
|
1975
|
+
? `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`
|
|
1976
|
+
: `on-chain bytes at ${anchorRef.id} do not match the stamp's witnessed bytes`;
|
|
1823
1977
|
}
|
|
1824
1978
|
else if (onChain.rollup === "unreachable") {
|
|
1825
|
-
error =
|
|
1979
|
+
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
1826
1980
|
}
|
|
1827
1981
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
1828
1982
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -697,6 +697,35 @@ function reportEvidence(
|
|
|
697
697
|
}
|
|
698
698
|
}
|
|
699
699
|
|
|
700
|
+
// Stamps (evidence-bundle.md §5.2 / §5.3). Printed rather than summarised,
|
|
701
|
+
// because for a stamp body the WITNESS is the entire independent part
|
|
702
|
+
// (§5.2.4) — and until now this reporter showed nothing about it at all, so a
|
|
703
|
+
// §5.3 pack printed a rollup line claiming an on-chain scope with no visible
|
|
704
|
+
// on-chain result anywhere above it.
|
|
705
|
+
//
|
|
706
|
+
// The on-chain column distinguishes all four states in the same three
|
|
707
|
+
// characters the checkpoint block uses, plus its absence: `✓` confirmed,
|
|
708
|
+
// `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
|
|
709
|
+
// nothing was checked. A reader must never have to infer "not checked" from
|
|
710
|
+
// a symbol that also means "could not be reached".
|
|
711
|
+
if (result.stamps && result.stamps.length > 0) {
|
|
712
|
+
io.out("");
|
|
713
|
+
io.out(paint(" Stamps", DIM));
|
|
714
|
+
for (const st of result.stamps) {
|
|
715
|
+
const witness =
|
|
716
|
+
st.anchorRef === null
|
|
717
|
+
? paint("~ no witness", Y)
|
|
718
|
+
: `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
|
|
719
|
+
const onChain =
|
|
720
|
+
st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
|
|
721
|
+
io.out(
|
|
722
|
+
` ${mark(st.ok)} ${shortTx(st.stampRef)} bytes ${mark(st.stampRefOk && st.bodyOk)}` +
|
|
723
|
+
` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`,
|
|
724
|
+
);
|
|
725
|
+
for (const e of st.errors) io.out(paint(` - ${e}`, R));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
700
729
|
if (result.events.length > 0) {
|
|
701
730
|
io.out("");
|
|
702
731
|
io.out(paint(" Events", DIM));
|
package/src/evidence.ts
CHANGED
|
@@ -428,6 +428,35 @@ export interface StampResult {
|
|
|
428
428
|
* carries it.
|
|
429
429
|
*/
|
|
430
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;
|
|
431
460
|
ok: boolean;
|
|
432
461
|
errors: string[];
|
|
433
462
|
}
|
|
@@ -749,7 +778,7 @@ export async function verifyEvidenceBundle(
|
|
|
749
778
|
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
750
779
|
// a stamp carries no second signature of its own inside the bundle.
|
|
751
780
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
752
|
-
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
781
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
753
782
|
}
|
|
754
783
|
|
|
755
784
|
// The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
|
|
@@ -1365,10 +1394,29 @@ async function verifyDisclosedContent(
|
|
|
1365
1394
|
return { contentOk, errors: evErrors };
|
|
1366
1395
|
}
|
|
1367
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
|
+
|
|
1368
1416
|
async function verifyStampEntry(
|
|
1369
1417
|
raw: unknown,
|
|
1370
1418
|
seen: Set<string>,
|
|
1371
|
-
): Promise<
|
|
1419
|
+
): Promise<StampEntryOutcome> {
|
|
1372
1420
|
const errors: string[] = [];
|
|
1373
1421
|
const base: StampResult = {
|
|
1374
1422
|
stampRef: "",
|
|
@@ -1382,28 +1430,33 @@ async function verifyStampEntry(
|
|
|
1382
1430
|
subtreesOk: false,
|
|
1383
1431
|
subtrees: [],
|
|
1384
1432
|
anchorRef: null,
|
|
1433
|
+
onChain: null,
|
|
1434
|
+
onChainOk: null,
|
|
1385
1435
|
ok: false,
|
|
1386
1436
|
errors,
|
|
1387
1437
|
};
|
|
1388
1438
|
|
|
1389
1439
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1390
1440
|
errors.push("stamp entry is not an object");
|
|
1391
|
-
return base;
|
|
1441
|
+
return { result: base };
|
|
1392
1442
|
}
|
|
1393
1443
|
const e = raw as Partial<StampEntry> & Record<string, unknown>;
|
|
1394
1444
|
|
|
1395
1445
|
// --- the witnessed bytes, which are the only authority in this body -------
|
|
1396
1446
|
if (!isLowerHex(e.stamp_bytes)) {
|
|
1397
1447
|
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
1398
|
-
return base;
|
|
1448
|
+
return { result: base };
|
|
1399
1449
|
}
|
|
1400
1450
|
let bytes: Uint8Array;
|
|
1401
1451
|
try {
|
|
1402
1452
|
bytes = hexToBytes(e.stamp_bytes);
|
|
1403
1453
|
} catch (err) {
|
|
1404
1454
|
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
1405
|
-
return base;
|
|
1455
|
+
return { result: base };
|
|
1406
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;
|
|
1407
1460
|
|
|
1408
1461
|
// --- the derived identity -------------------------------------------------
|
|
1409
1462
|
const derived = await sha256Hex(bytes);
|
|
@@ -1434,7 +1487,7 @@ async function verifyStampEntry(
|
|
|
1434
1487
|
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
1435
1488
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1436
1489
|
errors.push("stamp body is not a JSON object");
|
|
1437
|
-
return base;
|
|
1490
|
+
return { result: base, witnessedHex };
|
|
1438
1491
|
}
|
|
1439
1492
|
body = parsed as StampBody;
|
|
1440
1493
|
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
@@ -1443,11 +1496,11 @@ async function verifyStampEntry(
|
|
|
1443
1496
|
// of the stamp disagree with the one that was witnessed.
|
|
1444
1497
|
if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
|
|
1445
1498
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
1446
|
-
return base;
|
|
1499
|
+
return { result: base, witnessedHex };
|
|
1447
1500
|
}
|
|
1448
1501
|
} catch (err) {
|
|
1449
1502
|
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
1450
|
-
return base;
|
|
1503
|
+
return { result: base, witnessedHex };
|
|
1451
1504
|
}
|
|
1452
1505
|
|
|
1453
1506
|
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
@@ -1459,7 +1512,7 @@ async function verifyStampEntry(
|
|
|
1459
1512
|
`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
1460
1513
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
|
|
1461
1514
|
);
|
|
1462
|
-
return base;
|
|
1515
|
+
return { result: base, witnessedHex };
|
|
1463
1516
|
}
|
|
1464
1517
|
base.stampVersion = body.stamp_version;
|
|
1465
1518
|
base.bodyOk = true;
|
|
@@ -1493,7 +1546,7 @@ async function verifyStampEntry(
|
|
|
1493
1546
|
|
|
1494
1547
|
base.ok =
|
|
1495
1548
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
1496
|
-
return base;
|
|
1549
|
+
return { result: base, witnessedHex };
|
|
1497
1550
|
}
|
|
1498
1551
|
|
|
1499
1552
|
/**
|
|
@@ -1581,6 +1634,7 @@ async function verifyStampTrace(
|
|
|
1581
1634
|
specVersionOk: boolean,
|
|
1582
1635
|
assertedStatus: string | null,
|
|
1583
1636
|
errors: string[],
|
|
1637
|
+
options: VerifyEvidenceOptions,
|
|
1584
1638
|
): Promise<EvidenceBundleResult> {
|
|
1585
1639
|
const body = b.body as StampTraceBody;
|
|
1586
1640
|
if (!Array.isArray(body?.stamps)) {
|
|
@@ -1589,10 +1643,27 @@ async function verifyStampTrace(
|
|
|
1589
1643
|
|
|
1590
1644
|
const seen = new Set<string>();
|
|
1591
1645
|
const stamps: StampResult[] = [];
|
|
1646
|
+
const witnessed: (string | undefined)[] = [];
|
|
1592
1647
|
for (const entry of body.stamps) {
|
|
1593
|
-
|
|
1648
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
1649
|
+
stamps.push(outcome.result);
|
|
1650
|
+
witnessed.push(outcome.witnessedHex);
|
|
1594
1651
|
}
|
|
1595
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
|
+
|
|
1596
1667
|
// --- Rollup ----------------------------------------------------------------
|
|
1597
1668
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
1598
1669
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
@@ -1606,17 +1677,25 @@ async function verifyStampTrace(
|
|
|
1606
1677
|
// does not have.
|
|
1607
1678
|
status = "partial";
|
|
1608
1679
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1609
|
-
} else if (anyUnwitnessed) {
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
// our coverage and never an accusation about their evidence. It is also not
|
|
1613
|
-
// `verified`, because for a stamp the witness is the entire independent
|
|
1614
|
-
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
1615
|
-
// 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.
|
|
1616
1683
|
status = "partial";
|
|
1617
|
-
|
|
1618
|
-
"one or more stamps
|
|
1619
|
-
|
|
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
|
+
}
|
|
1620
1699
|
} else {
|
|
1621
1700
|
status = "verified";
|
|
1622
1701
|
}
|
|
@@ -1627,7 +1706,7 @@ async function verifyStampTrace(
|
|
|
1627
1706
|
bodyHashOk: true,
|
|
1628
1707
|
signatureOk: true,
|
|
1629
1708
|
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
1630
|
-
onChainChecked
|
|
1709
|
+
onChainChecked,
|
|
1631
1710
|
checkpoints: [],
|
|
1632
1711
|
events: [],
|
|
1633
1712
|
assertedStatus,
|
|
@@ -1666,7 +1745,12 @@ async function verifyStampEvidence(
|
|
|
1666
1745
|
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
1667
1746
|
const seen = new Set<string>();
|
|
1668
1747
|
const stamps: StampResult[] = [];
|
|
1669
|
-
|
|
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
|
+
}
|
|
1670
1754
|
|
|
1671
1755
|
// The join table. ONLY a stamp whose derived ref and witnessed bytes both
|
|
1672
1756
|
// checked out is joinable: a root read out of bytes that failed their own
|
|
@@ -1811,6 +1895,14 @@ async function verifyStampEvidence(
|
|
|
1811
1895
|
});
|
|
1812
1896
|
}
|
|
1813
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
|
+
|
|
1814
1906
|
// --- Rollup ---------------------------------------------------------------
|
|
1815
1907
|
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1816
1908
|
// is the false green this body exists to prevent, so an event that did not
|
|
@@ -1824,13 +1916,23 @@ async function verifyStampEvidence(
|
|
|
1824
1916
|
} else if (stamps.length === 0 && eventResults.length === 0) {
|
|
1825
1917
|
status = "partial";
|
|
1826
1918
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1827
|
-
} else if (anyUnwitnessed) {
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
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.
|
|
1830
1925
|
status = "partial";
|
|
1831
|
-
|
|
1832
|
-
"one or more stamps
|
|
1833
|
-
|
|
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
|
+
}
|
|
1834
1936
|
} else {
|
|
1835
1937
|
status = "verified";
|
|
1836
1938
|
}
|
|
@@ -1841,7 +1943,7 @@ async function verifyStampEvidence(
|
|
|
1841
1943
|
bodyHashOk: true,
|
|
1842
1944
|
signatureOk: true,
|
|
1843
1945
|
bodyType: STAMP_EVIDENCE_BODY_TYPE,
|
|
1844
|
-
onChainChecked
|
|
1946
|
+
onChainChecked,
|
|
1845
1947
|
checkpoints: [],
|
|
1846
1948
|
events: eventResults,
|
|
1847
1949
|
assertedStatus,
|
|
@@ -1850,6 +1952,83 @@ async function verifyStampEvidence(
|
|
|
1850
1952
|
};
|
|
1851
1953
|
}
|
|
1852
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
|
+
|
|
1853
2032
|
// ===========================================================================
|
|
1854
2033
|
// Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
|
|
1855
2034
|
//
|
|
@@ -2621,6 +2800,10 @@ async function refetchCheckpoint(
|
|
|
2621
2800
|
): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
|
|
2622
2801
|
let expectedHex: string | undefined;
|
|
2623
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.
|
|
2624
2807
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
2625
2808
|
} catch {
|
|
2626
2809
|
// Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
|
|
@@ -2628,6 +2811,54 @@ async function refetchCheckpoint(
|
|
|
2628
2811
|
expectedHex = undefined;
|
|
2629
2812
|
}
|
|
2630
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 }> {
|
|
2631
2862
|
const perGateway: PerGatewayOutcome[] = [];
|
|
2632
2863
|
for (const gw of gateways) {
|
|
2633
2864
|
perGateway.push({
|
|
@@ -2639,9 +2870,12 @@ async function refetchCheckpoint(
|
|
|
2639
2870
|
const onChain = rollupOnChain(perGateway);
|
|
2640
2871
|
let error: string | undefined;
|
|
2641
2872
|
if (onChain.rollup === "mismatch") {
|
|
2642
|
-
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`;
|
|
2643
2877
|
} else if (onChain.rollup === "unreachable") {
|
|
2644
|
-
error =
|
|
2878
|
+
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
2645
2879
|
}
|
|
2646
2880
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
2647
2881
|
}
|
package/vectors/README.md
CHANGED
|
@@ -8,6 +8,24 @@ That distinction is the whole point. A rule that consumers must agree with needs
|
|
|
8
8
|
a statement they can *fetch*, not one they have to be trusted to have copied
|
|
9
9
|
correctly.
|
|
10
10
|
|
|
11
|
+
## `stamp-on-chain.json`
|
|
12
|
+
|
|
13
|
+
The rule that decides what re-fetching a **stamp's witness** from a gateway does
|
|
14
|
+
and does not settle — the optional, online half of `sanning.stamp.trace/v1` and
|
|
15
|
+
`sanning.stamp.evidence/v1` (`evidence-bundle.md` §5.2.4 / §5.3.4). Implemented
|
|
16
|
+
by [`../src/evidence.ts`](../src/evidence.ts) and asserted against this table by
|
|
17
|
+
[`../test/stamp-on-chain-vectors.test.ts`](../test/stamp-on-chain-vectors.test.ts)
|
|
18
|
+
— **and by the Python kernel against the same bytes**, in
|
|
19
|
+
`tests/test_stamp_on_chain_vectors.py`, so neither kernel can pass by agreeing
|
|
20
|
+
with the other.
|
|
21
|
+
|
|
22
|
+
**The one thing a consumer must not do with it: collapse the four states into a
|
|
23
|
+
boolean.** `not checked` · `confirm` · `mismatch` · `unreachable`. A reader that
|
|
24
|
+
cannot tell the first from the last will report that no gateway could be reached
|
|
25
|
+
on a run that contacted none — a false accusation manufactured by a missing
|
|
26
|
+
state, and the defect this table was written after. Vendoring rules are the same
|
|
27
|
+
three steps as below, against `vectors/stamp-on-chain.json`.
|
|
28
|
+
|
|
11
29
|
## `pack-grading.json`
|
|
12
30
|
|
|
13
31
|
The rule that decides whether a **pack** — a signed bundle plus whatever
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rule": "stamp-on-chain",
|
|
3
|
+
"ruleVersion": 1,
|
|
4
|
+
"statement": "What re-fetching a stamp's witness from a gateway DOES and DOES NOT settle. A stamp body carries no producer signature and no inclusion proof of its own, so the witness is the entire independent part of it (evidence-bundle.md §5.2.4): confirming that a gateway holds the exact bytes named by anchor_ref is the only check that turns Sanning's own assertion into evidence. It is an ONLINE act and always optional — an offline verdict is unchanged by it and never claims it happened. Four states, never three: not-checked (no gateway was asked, or the stamp names no witness), confirm (a gateway returned the witnessed bytes), mismatch (a gateway returned DIFFERENT bytes — the only outcome that fails anything), unreachable (we asked and nobody answered — an availability gap, never an accusation). The expected bytes are stamps[].stamp_bytes VERBATIM, because §5.2.2 defines that field as the exact bytes handed to the witness; re-canonicalizing them would compare the chain against bytes nobody signed.",
|
|
5
|
+
"home": "@sanning/proof — ts/vectors/stamp-on-chain.json. This file is the RULE, not a test fixture of one implementation. The TypeScript kernel implements it in ts/src/evidence.ts (refetchStamps / refetchStamp / probeGateway / rollupOnChain) and is held to it by ts/test/stamp-on-chain-vectors.test.ts; the Python kernel implements it in src/sanning_proof/evidence.py (_refetch_stamps / _refetch_stamp / _probe_gateway / _rollup_on_chain) and is held to the SAME file by tests/test_stamp_on_chain_vectors.py. Neither kernel can pass by agreeing with the other.",
|
|
6
|
+
"consumers": "A surface that re-fetches a stamp witness SHOULD call the kernel's verifyEvidenceBundle with `gateways` rather than derive the rule again. A surface that cannot yet (an older pinned kernel, another language) vendors this file byte-for-byte, asserts its own derivation against it, and runs a drift check against the copy shipped in the @sanning/proof tarball at vectors/stamp-on-chain.json — failing when it cannot find a copy to compare against. A vendored copy that is not byte-identical to this one is a defect, not a variant. In particular: a surface MUST NOT collapse the four states into a boolean. A reader that cannot distinguish not-checked from unreachable will report that no gateway could be reached when none was tried, which is a false accusation manufactured by a missing state, and is the defect this table was written after.",
|
|
7
|
+
"howToUse": "Each case describes one sanning.stamp.evidence/v1 pack and one set of scripted gateway responses. Build the pack from `packScaffold`: for each entry of `stamps` (index i, 0-based) sign ONE sanning.events/v1 envelope over the record {\"event\":{\"content_hash\":\"<64×a>\"},\"i\":i} with event_id `ev-<i>` and the fixed seed, take its leaf under RFC 9162 (SHA-256(0x00 ‖ JCS(envelope))), make a one-leaf tree, and build the stamp body from `stampBodyScaffold` with merkle_root = that root, leaf_count = 1 and one sub-tree covering [0,1). stamp_bytes is JCS(stamp body) hex-encoded; stamp_ref is SHA-256 of the DECODED bytes. When the entry's `witnessed` is true attach anchor_ref = {kind: \"arweave\", id: `<anchorIdPrefix><i>`}, otherwise omit anchor_ref entirely. Give each stamp one event whose inclusion is {stamp_ref, leaf_index: 0, leaf_count: 1, leaf_hash, audit_path: []}. Wrap the body per `wrapperScaffold`, set body_hash = SHA-256(JCS(body)) and sign the wrapper (minus `signature`) with the same seed. Then call the kernel with the case's `gateways` and a fetch seam that answers `{gateway}/{anchor id}` per the `returns` entry for that stamp at that gateway's index, and assert `expect`. A case with an empty `gateways` list is called with no gateway list at all — that is the not-checked state and it must be reachable without a fetch seam.",
|
|
8
|
+
"responseKinds": {
|
|
9
|
+
"witnessed": "The exact decoded stamp_bytes. The honest chain.",
|
|
10
|
+
"altered": "JCS of the same stamp body with interval_end changed to 2026-09-09T13:00:00.000Z — a well-formed, canonical, semantically different stamp. This is the forgery shape that matters: a pack built on it is fully self-consistent OFFLINE and lies only about what was witnessed.",
|
|
11
|
+
"reserialized": "The same JSON object re-serialized with 2-space indentation. Semantically identical, byte-different. MUST confirm: a gateway that pretty-prints is a delivery quirk, not a finding.",
|
|
12
|
+
"notJson": "The bytes `not json at all`. Neither byte-equal nor parseable, so there is nothing to compare structurally — a mismatch.",
|
|
13
|
+
"unreachable": "The fetch fails: a network error, a timeout, a non-ok response, or no such transaction. Never a mismatch."
|
|
14
|
+
},
|
|
15
|
+
"alsoAppliesTo": "sanning.stamp.trace/v1 — the same case, with body_type changed and events[] dropped. Every case's `expect` holds UNCHANGED for that body, and a grader MUST run all of them twice. §5.2 and §5.3 carry the same stamps[] array under the same §5.2.2 definition, so they must give the same on-chain answer: a stamp that can be re-fetched inside one wrapper and not the other forces every consumer to know which wrapper it is holding before it can read `onChain: null`, and that is the kind of per-body exception a reader eventually gets wrong. The one case-level difference is outside this rule: a §5.2 body with no stamps at all is `partial` for carrying nothing, which no case here exercises.",
|
|
16
|
+
"anchorIdPrefix": "stampOnChainFixtureAnchorId0000000000-",
|
|
17
|
+
"packScaffold": {
|
|
18
|
+
"seedHex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
19
|
+
"eventIdPrefix": "ev-",
|
|
20
|
+
"contentHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
21
|
+
"signedAt": "2026-09-09T12:00:00Z",
|
|
22
|
+
"environment": "production"
|
|
23
|
+
},
|
|
24
|
+
"stampBodyScaffold": {
|
|
25
|
+
"stamp_version": "sanning.stamp/v1",
|
|
26
|
+
"org_ref": "9999999999999999999999999999999999999999999999999999999999999999",
|
|
27
|
+
"interval_start": "2026-09-09T12:00:00.000Z",
|
|
28
|
+
"interval_end": "2026-09-09T12:10:00.000Z",
|
|
29
|
+
"subtreeRef": "1111111111111111111111111111111111111111111111111111111111111111"
|
|
30
|
+
},
|
|
31
|
+
"wrapperScaffold": {
|
|
32
|
+
"spec_version": "sanning.evidence/v1",
|
|
33
|
+
"body_type": "sanning.stamp.evidence/v1",
|
|
34
|
+
"issuer": { "kind": "sanning" },
|
|
35
|
+
"generated_at": "2026-09-09T12:20:00.000Z",
|
|
36
|
+
"gateway": null,
|
|
37
|
+
"previous_hash": "GENESIS",
|
|
38
|
+
"signature_alg": "ed25519"
|
|
39
|
+
},
|
|
40
|
+
"cases": [
|
|
41
|
+
{
|
|
42
|
+
"id": "not-checked-when-no-gateways",
|
|
43
|
+
"why": "The default, and the state the other three are defined against. No gateway list means no gateway was asked, so onChain is null — NOT an unreachable result over an empty list. An implementation that reported unreachable here would tell every offline auditor that the chain could not be reached, on a run that never intended to touch it.",
|
|
44
|
+
"gateways": [],
|
|
45
|
+
"stamps": [{ "witnessed": true, "returns": [] }],
|
|
46
|
+
"expect": {
|
|
47
|
+
"onChainChecked": false,
|
|
48
|
+
"status": "verified",
|
|
49
|
+
"exit": 0,
|
|
50
|
+
"stamps": [{ "rollup": null, "onChainOk": null, "ok": true }],
|
|
51
|
+
"bundleErrorsContain": []
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "confirm-single-gateway",
|
|
56
|
+
"why": "The gateway holds exactly the bytes the bundle says were witnessed. This is the only case in which a stamp bundle has been shown to be more than Sanning's own assertion, and it must still read `verified` — confirming a witness adds evidence, it does not change a verdict that was already sound offline.",
|
|
57
|
+
"gateways": ["https://g1.example"],
|
|
58
|
+
"stamps": [{ "witnessed": true, "returns": ["witnessed"] }],
|
|
59
|
+
"expect": {
|
|
60
|
+
"onChainChecked": true,
|
|
61
|
+
"status": "verified",
|
|
62
|
+
"exit": 0,
|
|
63
|
+
"stamps": [
|
|
64
|
+
{ "rollup": "confirm", "onChainOk": true, "ok": true, "perGateway": ["confirm"] }
|
|
65
|
+
],
|
|
66
|
+
"bundleErrorsContain": []
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "mismatch-fails-the-stamp",
|
|
71
|
+
"why": "THE case this capability exists for. The pack is internally perfect — wrapper signed, stamp_ref derived from its own bytes, those bytes JCS-canonical, the event's RFC 9162 path reconstructing the committed root — and verifies `verified` offline. The chain holds different bytes. Only a re-fetch can tell, and it must fail the stamp rather than note it: a stamp whose witness contradicts it is not partially witnessed, it is contradicted. An implementation whose byte comparison cannot fail passes every other case in this table and this one alone.",
|
|
72
|
+
"gateways": ["https://g1.example"],
|
|
73
|
+
"stamps": [{ "witnessed": true, "returns": ["altered"] }],
|
|
74
|
+
"expect": {
|
|
75
|
+
"onChainChecked": true,
|
|
76
|
+
"status": "failed",
|
|
77
|
+
"exit": 1,
|
|
78
|
+
"stamps": [
|
|
79
|
+
{
|
|
80
|
+
"rollup": "mismatch",
|
|
81
|
+
"onChainOk": false,
|
|
82
|
+
"ok": false,
|
|
83
|
+
"perGateway": ["mismatch"],
|
|
84
|
+
"errorContains": "do not match the stamp's witnessed bytes"
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"bundleErrorsContain": []
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"id": "unreachable-is-undetermined-not-failed",
|
|
92
|
+
"why": "A gateway that does not answer has told us NOTHING about the evidence, and a verifier that turns silence into an accusation is the exact failure the anchor_ref vocabulary was designed around. Undetermined: the offline-sound stamp stays ok, the run drops to `partial`, and the exit code is 3 — a retry, not a finding.",
|
|
93
|
+
"gateways": ["https://g1.example"],
|
|
94
|
+
"stamps": [{ "witnessed": true, "returns": ["unreachable"] }],
|
|
95
|
+
"expect": {
|
|
96
|
+
"onChainChecked": true,
|
|
97
|
+
"status": "partial",
|
|
98
|
+
"exit": 3,
|
|
99
|
+
"stamps": [
|
|
100
|
+
{
|
|
101
|
+
"rollup": "unreachable",
|
|
102
|
+
"onChainOk": null,
|
|
103
|
+
"ok": true,
|
|
104
|
+
"perGateway": ["unreachable"],
|
|
105
|
+
"errorContains": "unreachable on all gateways"
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
"bundleErrorsContain": ["could not be re-fetched from the supplied gateways"]
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"id": "mismatch-beats-confirm",
|
|
113
|
+
"why": "Worst-finding-wins. One gateway serving the right bytes does not answer the other one serving different bytes — a disagreement between gateways about a permanent record is itself the finding, and the honest reading is the accusing one. The per-gateway array is retained so a reader can see WHICH gateway disagreed rather than only that one did.",
|
|
114
|
+
"gateways": ["https://g1.example", "https://g2.example"],
|
|
115
|
+
"stamps": [{ "witnessed": true, "returns": ["altered", "witnessed"] }],
|
|
116
|
+
"expect": {
|
|
117
|
+
"onChainChecked": true,
|
|
118
|
+
"status": "failed",
|
|
119
|
+
"exit": 1,
|
|
120
|
+
"stamps": [
|
|
121
|
+
{
|
|
122
|
+
"rollup": "mismatch",
|
|
123
|
+
"onChainOk": false,
|
|
124
|
+
"ok": false,
|
|
125
|
+
"perGateway": ["mismatch", "confirm"]
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
"bundleErrorsContain": []
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"id": "confirm-beats-unreachable",
|
|
133
|
+
"why": "Best-evidence, once nothing has contradicted anything. One gateway being down is an availability fact about that gateway; another gateway returning the witnessed bytes is a fact about the record. The record wins, and the run is not downgraded for the outage — otherwise every auditor with one stale gateway in their list would read `partial` on fully witnessed evidence.",
|
|
134
|
+
"gateways": ["https://g1.example", "https://g2.example"],
|
|
135
|
+
"stamps": [{ "witnessed": true, "returns": ["unreachable", "witnessed"] }],
|
|
136
|
+
"expect": {
|
|
137
|
+
"onChainChecked": true,
|
|
138
|
+
"status": "verified",
|
|
139
|
+
"exit": 0,
|
|
140
|
+
"stamps": [
|
|
141
|
+
{
|
|
142
|
+
"rollup": "confirm",
|
|
143
|
+
"onChainOk": true,
|
|
144
|
+
"ok": true,
|
|
145
|
+
"perGateway": ["unreachable", "confirm"]
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
"bundleErrorsContain": []
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"id": "unwitnessed-stamp-is-not-checked-never-unreachable",
|
|
153
|
+
"why": "A stamp with no anchor_ref names no witness, so there is nothing to ask any gateway for. onChain stays null — not-checked — even though gateways were supplied and every one of them is reachable. Reporting all-unreachable here would invent a network event that did not occur, and would move this pack's exit code from 0 to 3 purely because the caller passed a gateway list. The bundle is already `partial` for the right reason, said in the right words, by §5.2.4.",
|
|
154
|
+
"gateways": ["https://g1.example"],
|
|
155
|
+
"stamps": [{ "witnessed": false, "returns": ["witnessed"] }],
|
|
156
|
+
"expect": {
|
|
157
|
+
"onChainChecked": true,
|
|
158
|
+
"status": "partial",
|
|
159
|
+
"exit": 0,
|
|
160
|
+
"stamps": [{ "rollup": null, "onChainOk": null, "ok": true }],
|
|
161
|
+
"bundleErrorsContain": ["sealed by Sanning, witnessed by nobody"]
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"id": "reserialized-body-still-confirms",
|
|
166
|
+
"why": "A gateway that re-serializes JSON on the way out has changed the delivery, not the record. The byte compare fails and the structural compare — JCS of what came back against the witnessed bytes — succeeds, so this is a confirm. Without the structural fallback every pretty-printing gateway would read as a tampered record, which is a false accusation with an operational cause.",
|
|
167
|
+
"gateways": ["https://g1.example"],
|
|
168
|
+
"stamps": [{ "witnessed": true, "returns": ["reserialized"] }],
|
|
169
|
+
"expect": {
|
|
170
|
+
"onChainChecked": true,
|
|
171
|
+
"status": "verified",
|
|
172
|
+
"exit": 0,
|
|
173
|
+
"stamps": [
|
|
174
|
+
{ "rollup": "confirm", "onChainOk": true, "ok": true, "perGateway": ["confirm"] }
|
|
175
|
+
],
|
|
176
|
+
"bundleErrorsContain": []
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"id": "not-json-is-mismatch-not-unreachable",
|
|
181
|
+
"why": "The gateway answered — so this is not an availability gap — and what it returned is neither the witnessed bytes nor anything that canonicalizes to them. The structural fallback must not swallow the failure to parse into a shrug: a witness id that resolves to something which is not this stamp is a finding, and filing it as unreachable would let a substituted record read as a temporary outage.",
|
|
182
|
+
"gateways": ["https://g1.example"],
|
|
183
|
+
"stamps": [{ "witnessed": true, "returns": ["notJson"] }],
|
|
184
|
+
"expect": {
|
|
185
|
+
"onChainChecked": true,
|
|
186
|
+
"status": "failed",
|
|
187
|
+
"exit": 1,
|
|
188
|
+
"stamps": [
|
|
189
|
+
{
|
|
190
|
+
"rollup": "mismatch",
|
|
191
|
+
"onChainOk": false,
|
|
192
|
+
"ok": false,
|
|
193
|
+
"perGateway": ["mismatch"],
|
|
194
|
+
"errorContains": "do not match the stamp's witnessed bytes"
|
|
195
|
+
}
|
|
196
|
+
],
|
|
197
|
+
"bundleErrorsContain": []
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"id": "two-gaps-are-two-notes",
|
|
202
|
+
"why": "One stamp witnessed but unreachable, one stamp never witnessed. Both hold the bundle at `partial` and both get SAID. They have different causes and different fixes — one waits for the network, the other for the interval to close — and an auditor told only about the gateway would read the unwitnessed stamp as fine. The per-stamp results keep them apart too: the first carries an unreachable rollup, the second carries null.",
|
|
203
|
+
"gateways": ["https://g1.example"],
|
|
204
|
+
"stamps": [
|
|
205
|
+
{ "witnessed": true, "returns": ["unreachable"] },
|
|
206
|
+
{ "witnessed": false, "returns": ["witnessed"] }
|
|
207
|
+
],
|
|
208
|
+
"expect": {
|
|
209
|
+
"onChainChecked": true,
|
|
210
|
+
"status": "partial",
|
|
211
|
+
"exit": 3,
|
|
212
|
+
"stamps": [
|
|
213
|
+
{
|
|
214
|
+
"rollup": "unreachable",
|
|
215
|
+
"onChainOk": null,
|
|
216
|
+
"ok": true,
|
|
217
|
+
"perGateway": ["unreachable"],
|
|
218
|
+
"errorContains": "unreachable on all gateways"
|
|
219
|
+
},
|
|
220
|
+
{ "rollup": null, "onChainOk": null, "ok": true }
|
|
221
|
+
],
|
|
222
|
+
"bundleErrorsContain": [
|
|
223
|
+
"could not be re-fetched from the supplied gateways",
|
|
224
|
+
"sealed by Sanning, witnessed by nobody"
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
}
|