@sanning/proof 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/case-file.js +2 -1
- package/dist/case.js +11 -4
- package/dist/chain.d.ts +28 -0
- package/dist/chain.js +61 -4
- package/dist/cli.js +226 -33
- package/dist/declaration.d.ts +43 -4
- package/dist/declaration.js +198 -14
- package/dist/environment.d.ts +42 -0
- package/dist/environment.js +136 -0
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +32 -1
- package/dist/exit-codes.d.ts +18 -0
- package/dist/exit-codes.js +25 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +20 -4
- package/dist/run-adapters.d.ts +10 -0
- package/dist/run-adapters.js +35 -0
- package/dist/run.d.ts +209 -0
- package/dist/run.js +1405 -0
- package/dist/verifier.d.ts +43 -0
- package/dist/verifier.js +104 -1
- package/dist/witness-lookup.d.ts +13 -1
- package/dist/witness-lookup.js +12 -2
- package/package.json +1 -1
- package/src/case-file.ts +2 -1
- package/src/case.ts +11 -4
- package/src/chain.ts +81 -4
- package/src/cli.ts +259 -28
- package/src/declaration.ts +242 -12
- package/src/environment.ts +145 -0
- package/src/evidence.ts +29 -1
- package/src/exit-codes.ts +28 -0
- package/src/index.ts +30 -1
- package/src/run-adapters.ts +44 -0
- package/src/run.ts +1598 -0
- package/src/verifier.ts +94 -1
- package/src/witness-lookup.ts +18 -1
- package/vectors/README.md +37 -0
- package/vectors/sandbox-evidence.json +3161 -0
package/src/declaration.ts
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
// The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
|
|
132
132
|
// byte-order mark, no non-finite literal. `jcs_loads` in
|
|
133
133
|
// `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
|
|
134
|
+
import type { ChainLink } from "./chain.js";
|
|
134
135
|
import { hexToBytes, utf8 } from "./crypto.js";
|
|
135
136
|
import { jcs, jcsParse } from "./verifier.js";
|
|
136
137
|
import { isConformantHandoffRecord, isHandoffNamespace, type HandoffRecord } from "./handoff.js";
|
|
@@ -151,8 +152,23 @@ export const DECLARATION_VERSION = "sanning.declaration/v1";
|
|
|
151
152
|
*/
|
|
152
153
|
export const DECLARATION_VERSION_V2 = "sanning.declaration/v2";
|
|
153
154
|
|
|
155
|
+
/**
|
|
156
|
+
* v2 plus a signed `chains[]`: the `chain_key`s the holder claims are complete
|
|
157
|
+
* and in order (evidence-bundle.md 5.4.1 v1.36, A45). A reader that knows only
|
|
158
|
+
* v1 and v2 reads a v3 declaration as one it cannot read, which is the safe
|
|
159
|
+
* direction.
|
|
160
|
+
*/
|
|
161
|
+
export const DECLARATION_VERSION_V3 = "sanning.declaration/v3";
|
|
162
|
+
|
|
154
163
|
/** Every declaration schema this reader knows, in the order they were added. */
|
|
155
|
-
export const DECLARATION_VERSIONS = [
|
|
164
|
+
export const DECLARATION_VERSIONS = [
|
|
165
|
+
DECLARATION_VERSION,
|
|
166
|
+
DECLARATION_VERSION_V2,
|
|
167
|
+
DECLARATION_VERSION_V3,
|
|
168
|
+
] as const;
|
|
169
|
+
|
|
170
|
+
/** The versions whose every `records[]` entry carries a signed `role`. */
|
|
171
|
+
const ROLE_VERSIONS: readonly string[] = [DECLARATION_VERSION_V2, DECLARATION_VERSION_V3];
|
|
156
172
|
|
|
157
173
|
/**
|
|
158
174
|
* What a v2 declaration may sign a record IS, compared exactly: `handoff`, a
|
|
@@ -205,6 +221,18 @@ export const DECLARATION_DEVIATION_KINDS = [
|
|
|
205
221
|
* read under rule 7's parser rule.
|
|
206
222
|
*/
|
|
207
223
|
"role_step_unreadable",
|
|
224
|
+
/**
|
|
225
|
+
* Rule 11 (v1.36, A45), `sanning.declaration/v3` only. The holder signed
|
|
226
|
+
* that a chain is complete and in order, and a second record under that key,
|
|
227
|
+
* under any producing key, begins it.
|
|
228
|
+
*/
|
|
229
|
+
"chain_second_root",
|
|
230
|
+
/** A record of a claimed chain is the second successor of one predecessor. */
|
|
231
|
+
"chain_fork",
|
|
232
|
+
/** A record of a claimed chain points at a predecessor its chain does not present in the pack. */
|
|
233
|
+
"chain_link_dangling",
|
|
234
|
+
/** A claimed chain of which the pack presents no record at all. */
|
|
235
|
+
"chain_claimed_absent",
|
|
208
236
|
] as const;
|
|
209
237
|
export type DeclarationDeviationKind = (typeof DECLARATION_DEVIATION_KINDS)[number];
|
|
210
238
|
|
|
@@ -300,6 +328,14 @@ export const DECLARATION_NOTE_KINDS = [
|
|
|
300
328
|
* declaration other than this pack's own is UNKNOWN.
|
|
301
329
|
*/
|
|
302
330
|
"other_declaration_withheld",
|
|
331
|
+
/**
|
|
332
|
+
* Rule 11 (v1.36, A45). A claimed chain has a dangling link, and a record
|
|
333
|
+
* signed by the same producing key has an undetermined payload binding. That
|
|
334
|
+
* record takes no part in a chain, so whether the missing link is it is
|
|
335
|
+
* UNKNOWN. Moves nothing: the undetermined binding holds the pack at
|
|
336
|
+
* `incomplete`.
|
|
337
|
+
*/
|
|
338
|
+
"chain_claim_withheld",
|
|
303
339
|
] as const;
|
|
304
340
|
export type DeclarationNoteKind = (typeof DECLARATION_NOTE_KINDS)[number];
|
|
305
341
|
|
|
@@ -320,6 +356,8 @@ export interface DeclarationNote {
|
|
|
320
356
|
side?: "sent" | "received";
|
|
321
357
|
/** Which claimed hand-off, by position, for `pair_record_withheld`. */
|
|
322
358
|
pairIndex?: number;
|
|
359
|
+
/** The claimed chain, for `chain_claim_withheld`. */
|
|
360
|
+
chainKey?: string;
|
|
323
361
|
}
|
|
324
362
|
|
|
325
363
|
/** One deviation, as data. A surface renders its own prose or calls `renderDeviation`. */
|
|
@@ -339,6 +377,8 @@ export interface DeclarationDeviation {
|
|
|
339
377
|
* the first one this reader learned.
|
|
340
378
|
*/
|
|
341
379
|
version?: string;
|
|
380
|
+
/** The claimed chain, for the four rule 11 `chain_` deviations. */
|
|
381
|
+
chainKey?: string;
|
|
342
382
|
}
|
|
343
383
|
|
|
344
384
|
export interface DeclarationCheck {
|
|
@@ -377,6 +417,12 @@ export interface DeclarationCheck {
|
|
|
377
417
|
* Empty for v1, and for anything this reader could not read.
|
|
378
418
|
*/
|
|
379
419
|
roles?: { eventId: string; role: DeclarationRole }[];
|
|
420
|
+
/**
|
|
421
|
+
* Every `chain_key` a v3 declaration claims is complete and in order, in
|
|
422
|
+
* declaration order. Empty for v1 and v2, and for anything this reader could
|
|
423
|
+
* not read.
|
|
424
|
+
*/
|
|
425
|
+
chains?: string[];
|
|
380
426
|
}
|
|
381
427
|
|
|
382
428
|
// ---- Reading the pack --------------------------------------------------------
|
|
@@ -385,7 +431,7 @@ interface DeclarationBundleLike {
|
|
|
385
431
|
body?: {
|
|
386
432
|
declaration?: unknown;
|
|
387
433
|
events?: {
|
|
388
|
-
envelope?: { event_id?: unknown; public_key?: unknown };
|
|
434
|
+
envelope?: { event_id?: unknown; public_key?: unknown; payload_hash?: unknown };
|
|
389
435
|
content?: unknown;
|
|
390
436
|
event_bytes?: unknown;
|
|
391
437
|
}[];
|
|
@@ -504,6 +550,19 @@ export interface PackRecord {
|
|
|
504
550
|
* two different readings and never one.
|
|
505
551
|
*/
|
|
506
552
|
bytesSupplied: boolean;
|
|
553
|
+
/**
|
|
554
|
+
* Whether this record's payload binding is UNDETERMINED: no `event_bytes`
|
|
555
|
+
* string and no inline envelope `payload`, the two things the envelope
|
|
556
|
+
* verifier binds `payload_hash` against. Rule 11 excuses a dangling link
|
|
557
|
+
* only for such a record's producing key.
|
|
558
|
+
*/
|
|
559
|
+
bindingUndetermined: boolean;
|
|
560
|
+
/**
|
|
561
|
+
* The envelope's signed `payload_hash` as written, or `""`. For a record
|
|
562
|
+
* whose binding is undetermined it is the identity a successor's pointer
|
|
563
|
+
* names: rule 11 excuses a dangling link only where the pointer equals it.
|
|
564
|
+
*/
|
|
565
|
+
payloadHash: string;
|
|
507
566
|
}
|
|
508
567
|
|
|
509
568
|
/**
|
|
@@ -545,6 +604,8 @@ export function packRecords(
|
|
|
545
604
|
publicKey: typeof key === "string" ? key : "",
|
|
546
605
|
record: committedRecord(rawBytes),
|
|
547
606
|
bytesSupplied: typeof rawBytes === "string" && rawBytes.length > 0,
|
|
607
|
+
bindingUndetermined: typeof rawBytes !== "string" && !(isObject(ev?.envelope) && "payload" in ev.envelope),
|
|
608
|
+
payloadHash: typeof ev?.envelope?.payload_hash === "string" ? ev.envelope.payload_hash : "",
|
|
548
609
|
};
|
|
549
610
|
});
|
|
550
611
|
}
|
|
@@ -568,6 +629,7 @@ interface ReadDeclaration {
|
|
|
568
629
|
records: { eventId: string; withheld: boolean }[];
|
|
569
630
|
pairs: { sent: string | null; received: string | null }[];
|
|
570
631
|
roles: { eventId: string; role: DeclarationRole }[];
|
|
632
|
+
chains: string[];
|
|
571
633
|
}
|
|
572
634
|
|
|
573
635
|
/**
|
|
@@ -587,7 +649,7 @@ function readDeclaration(
|
|
|
587
649
|
// or `step` (5.4.1 v1.20, A21); under v1 the member is not read at all, so a
|
|
588
650
|
// v1 declaration carrying `role` reads exactly as the same object without it.
|
|
589
651
|
const roles: { eventId: string; role: DeclarationRole }[] = [];
|
|
590
|
-
const v2 = version
|
|
652
|
+
const v2 = ROLE_VERSIONS.includes(version);
|
|
591
653
|
const note = (r: string) => reasons.push(r);
|
|
592
654
|
|
|
593
655
|
if (!isId(declaration["case_id"])) note("case_id is not a non-empty string");
|
|
@@ -665,7 +727,31 @@ function readDeclaration(
|
|
|
665
727
|
}
|
|
666
728
|
}
|
|
667
729
|
|
|
668
|
-
|
|
730
|
+
// v3 is v2 plus a required `chains[]` (5.4.1 v1.36, A45); v1 and v2 do not
|
|
731
|
+
// read the member.
|
|
732
|
+
const chains: string[] = [];
|
|
733
|
+
if (version === DECLARATION_VERSION_V3) {
|
|
734
|
+
const rawChains = declaration["chains"];
|
|
735
|
+
if (!Array.isArray(rawChains)) note("chains is not an array");
|
|
736
|
+
else {
|
|
737
|
+
for (const [i, c] of rawChains.entries()) {
|
|
738
|
+
if (!isObject(c) || !isId(c["chain_key"])) {
|
|
739
|
+
note(`chains[${i}] carries no chain_key`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const chainKey = c["chain_key"] as string;
|
|
743
|
+
if (chains.includes(chainKey)) {
|
|
744
|
+
note(`chains[${i}] claims ${chainKey} a second time`);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
chains.push(chainKey);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return reasons.length > 0
|
|
753
|
+
? { ok: false, reasons }
|
|
754
|
+
: { ok: true, read: { records, pairs, roles, chains } };
|
|
669
755
|
}
|
|
670
756
|
|
|
671
757
|
// ---- The check ---------------------------------------------------------------
|
|
@@ -680,6 +766,7 @@ const noDeclaration = (): DeclarationCheck => ({
|
|
|
680
766
|
declaredWithheld: [],
|
|
681
767
|
version: null,
|
|
682
768
|
roles: [],
|
|
769
|
+
chains: [],
|
|
683
770
|
});
|
|
684
771
|
|
|
685
772
|
/**
|
|
@@ -690,7 +777,9 @@ const noDeclaration = (): DeclarationCheck => ({
|
|
|
690
777
|
function knownVersion(declaration: unknown): string | null {
|
|
691
778
|
if (!isObject(declaration)) return null;
|
|
692
779
|
const version = declaration["declaration_version"];
|
|
693
|
-
return version ===
|
|
780
|
+
return typeof version === "string" && (DECLARATION_VERSIONS as readonly string[]).includes(version)
|
|
781
|
+
? version
|
|
782
|
+
: null;
|
|
694
783
|
}
|
|
695
784
|
|
|
696
785
|
/**
|
|
@@ -752,16 +841,19 @@ function hasExactKeys(value: unknown, keys: string[]): value is Record<string, u
|
|
|
752
841
|
function isDeclarationShape(obj: unknown): boolean {
|
|
753
842
|
if (!isObject(obj)) return false;
|
|
754
843
|
const version = obj["declaration_version"];
|
|
755
|
-
if (version !==
|
|
756
|
-
|
|
844
|
+
if (typeof version !== "string" || !(DECLARATION_VERSIONS as readonly string[]).includes(version))
|
|
845
|
+
return false;
|
|
846
|
+
const v3 = version === DECLARATION_VERSION_V3;
|
|
847
|
+
if (!hasExactKeys(obj, v3 ? [...DECLARATION_KEYS, "chains"] : DECLARATION_KEYS)) return false;
|
|
757
848
|
if (!isId(obj["case_id"])) return false;
|
|
758
849
|
const agents = obj["agents"];
|
|
759
850
|
if (!Array.isArray(agents) || !agents.every((a) => typeof a === "string")) return false;
|
|
760
851
|
const period = obj["period"];
|
|
761
852
|
if (!hasExactKeys(period, ["start", "end"])) return false;
|
|
762
853
|
if (typeof period["start"] !== "string" || typeof period["end"] !== "string") return false;
|
|
763
|
-
const recordKeys =
|
|
764
|
-
|
|
854
|
+
const recordKeys = ROLE_VERSIONS.includes(version)
|
|
855
|
+
? ["event_id", "disclosure", "role"]
|
|
856
|
+
: ["event_id", "disclosure"];
|
|
765
857
|
const records = obj["records"];
|
|
766
858
|
if (!Array.isArray(records)) return false;
|
|
767
859
|
for (const r of records) {
|
|
@@ -769,7 +861,13 @@ function isDeclarationShape(obj: unknown): boolean {
|
|
|
769
861
|
}
|
|
770
862
|
const handoffs = obj["handoffs"];
|
|
771
863
|
if (!Array.isArray(handoffs)) return false;
|
|
772
|
-
|
|
864
|
+
if (!handoffs.every((h) => hasExactKeys(h, ["sent_event_id", "received_event_id"]))) return false;
|
|
865
|
+
if (!v3) return true;
|
|
866
|
+
const chains = obj["chains"];
|
|
867
|
+
return (
|
|
868
|
+
Array.isArray(chains) &&
|
|
869
|
+
chains.every((c) => hasExactKeys(c, ["chain_key"]) && typeof c["chain_key"] === "string")
|
|
870
|
+
);
|
|
773
871
|
}
|
|
774
872
|
|
|
775
873
|
/**
|
|
@@ -871,6 +969,14 @@ function otherDeclarationNotes(
|
|
|
871
969
|
* and a pack that discloses them beside itself passes the map a consumer
|
|
872
970
|
* already built.
|
|
873
971
|
*
|
|
972
|
+
* `chains` is `readChains` over the SAME bundle. Only a v3 declaration that
|
|
973
|
+
* claims a chain reads it (rule 11). It is an argument rather than computed
|
|
974
|
+
* here because `readChains` is asynchronous and this check is not, and Python's
|
|
975
|
+
* `check_declaration` takes it the same way so the two kernels share one
|
|
976
|
+
* contract. A v3 declaration that claims a chain, checked without it, reads as
|
|
977
|
+
* a reader that does not know v3 reads it: `partial`, nothing named, and the
|
|
978
|
+
* pack `incomplete`. Leaving it out can never read `verified`.
|
|
979
|
+
*
|
|
874
980
|
* The verdict is this check's own. It does not restate the bundle verdict and
|
|
875
981
|
* it does not read it: a declaration can match perfectly over a pack whose
|
|
876
982
|
* signatures are forged, and saying so is `gradePack`'s job, which takes both.
|
|
@@ -878,6 +984,7 @@ function otherDeclarationNotes(
|
|
|
878
984
|
export function checkDeclaration(
|
|
879
985
|
bundle: unknown,
|
|
880
986
|
content?: SideContentMap | null,
|
|
987
|
+
links?: readonly ChainLink[] | null,
|
|
881
988
|
): DeclarationCheck {
|
|
882
989
|
// `body.declaration` as WRITTEN, not as hoped. The key being absent and the
|
|
883
990
|
// key holding a string, an array or `null` are graded differently and must
|
|
@@ -922,10 +1029,12 @@ export function checkDeclaration(
|
|
|
922
1029
|
declaredWithheld: [],
|
|
923
1030
|
version,
|
|
924
1031
|
roles: [],
|
|
1032
|
+
chains: [],
|
|
925
1033
|
};
|
|
926
1034
|
}
|
|
927
1035
|
|
|
928
|
-
const { records, pairs, roles } = read.read;
|
|
1036
|
+
const { records, pairs, roles, chains: claimedChains } = read.read;
|
|
1037
|
+
|
|
929
1038
|
const declared = records.map((r) => r.eventId);
|
|
930
1039
|
const declaredSet = new Set(declared);
|
|
931
1040
|
const pack = packRecords(bundle, content);
|
|
@@ -1080,6 +1189,19 @@ export function checkDeclaration(
|
|
|
1080
1189
|
}
|
|
1081
1190
|
}
|
|
1082
1191
|
|
|
1192
|
+
// Rule 11 (5.4.2 v1.36, A45), `sanning.declaration/v3` only: a chain the
|
|
1193
|
+
// holder signed as complete and in order, against the chain reading of the
|
|
1194
|
+
// same pack. Its deviations follow rule 9's, and its notes follow rule 9's
|
|
1195
|
+
// and precede rule 10's, both in `chains[]` order.
|
|
1196
|
+
// A caller that passed no links leaves the claim UNREAD: rules 1 to 10 still
|
|
1197
|
+
// run, and the verdict below is never `verified`.
|
|
1198
|
+
const unread = claimedChains.length > 0 && (links === undefined || links === null);
|
|
1199
|
+
if (claimedChains.length > 0 && links !== undefined && links !== null) {
|
|
1200
|
+
const found = chainClaimFindings(claimedChains, links, pack);
|
|
1201
|
+
deviations.push(...found.deviations);
|
|
1202
|
+
notes.push(...found.notes);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1083
1205
|
// Rule 10 (5.4.2 v1.29, A36): content handed in for a record signed
|
|
1084
1206
|
// `declaration` that is a declaration other than this pack's own. Notes only, in bundle order, after every other note, and
|
|
1085
1207
|
// never a deviation: the pack holds what its own declaration says whatever
|
|
@@ -1093,7 +1215,11 @@ export function checkDeclaration(
|
|
|
1093
1215
|
// only unsigned bytes could settle, which is UNDETERMINED: `partial` is
|
|
1094
1216
|
// 3's word for that, and it adds none.
|
|
1095
1217
|
const verdict: DeclarationVerdict =
|
|
1096
|
-
deviations.length > 0
|
|
1218
|
+
deviations.length > 0
|
|
1219
|
+
? "failed"
|
|
1220
|
+
: contentFindings.length > 0 || unread
|
|
1221
|
+
? "partial"
|
|
1222
|
+
: "verified";
|
|
1097
1223
|
|
|
1098
1224
|
return {
|
|
1099
1225
|
verdict,
|
|
@@ -1105,9 +1231,75 @@ export function checkDeclaration(
|
|
|
1105
1231
|
declaredWithheld: records.filter((r) => r.withheld).map((r) => r.eventId),
|
|
1106
1232
|
version,
|
|
1107
1233
|
roles,
|
|
1234
|
+
chains: claimedChains,
|
|
1108
1235
|
};
|
|
1109
1236
|
}
|
|
1110
1237
|
|
|
1238
|
+
/**
|
|
1239
|
+
* Rule 11 over every claimed `chain_key`, in `chains[]` order. The twin of
|
|
1240
|
+
* Python's `_chain_claim_findings`, which states the rule in full.
|
|
1241
|
+
*
|
|
1242
|
+
* A claim covers every record whose OWN signed linkage names the key
|
|
1243
|
+
* (`source === "record"`), under every producing key; an adapter's metadata
|
|
1244
|
+
* linkage is never read for a claim. Under a claim, in bundle order: every
|
|
1245
|
+
* record after the first that begins the chain is a `chain_second_root`; a
|
|
1246
|
+
* record whose predecessor already has an earlier successor in its chain is a
|
|
1247
|
+
* `chain_fork`; a record whose pointer resolves to no record of its own chain
|
|
1248
|
+
* (one producing key, one `chain_key`) is a `chain_link_dangling`, with no
|
|
1249
|
+
* window allowance; a claimed key with no such record is
|
|
1250
|
+
* `chain_claimed_absent`. Links are read from the record each envelope binds,
|
|
1251
|
+
* `event_bytes` or an inline `payload`. A dangling link is the note
|
|
1252
|
+
* `chain_claim_withheld` instead, once per key, only where its pointer equals
|
|
1253
|
+
* the signed `payload_hash` of a same-key record whose binding is
|
|
1254
|
+
* undetermined: exactly the missing link. Nothing else is excused.
|
|
1255
|
+
*/
|
|
1256
|
+
function chainClaimFindings(
|
|
1257
|
+
claimed: string[],
|
|
1258
|
+
links: readonly ChainLink[],
|
|
1259
|
+
pack: { publicKey: string; bindingUndetermined: boolean; payloadHash: string }[],
|
|
1260
|
+
): { deviations: DeclarationDeviation[]; notes: DeclarationNote[] } {
|
|
1261
|
+
const deviations: DeclarationDeviation[] = [];
|
|
1262
|
+
const notes: DeclarationNote[] = [];
|
|
1263
|
+
const withheldLinks = new Set(
|
|
1264
|
+
pack
|
|
1265
|
+
.filter((p) => p.bindingUndetermined && p.publicKey !== "" && p.payloadHash !== "")
|
|
1266
|
+
.map((p) => JSON.stringify([p.publicKey, p.payloadHash])),
|
|
1267
|
+
);
|
|
1268
|
+
for (const chainKey of claimed) {
|
|
1269
|
+
const members = links
|
|
1270
|
+
.filter((l) => l.source === "record" && l.chainKey === chainKey)
|
|
1271
|
+
.sort((a, b) => a.order - b.order);
|
|
1272
|
+
if (members.length === 0) {
|
|
1273
|
+
deviations.push({ kind: "chain_claimed_absent", chainKey });
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
const idsByKey = new Map<string, Set<string>>();
|
|
1277
|
+
for (const m of members) {
|
|
1278
|
+
if (!idsByKey.has(m.publicKey)) idsByKey.set(m.publicKey, new Set());
|
|
1279
|
+
idsByKey.get(m.publicKey)!.add(m.selfId);
|
|
1280
|
+
}
|
|
1281
|
+
let roots = 0;
|
|
1282
|
+
const successors = new Set<string>();
|
|
1283
|
+
let excused = false;
|
|
1284
|
+
for (const m of members) {
|
|
1285
|
+
if (m.prev === null) {
|
|
1286
|
+
roots++;
|
|
1287
|
+
if (roots > 1) deviations.push({ kind: "chain_second_root", eventId: m.eventId, chainKey });
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
const edge = JSON.stringify([m.publicKey, m.prev]);
|
|
1291
|
+
if (successors.has(edge)) deviations.push({ kind: "chain_fork", eventId: m.eventId, chainKey });
|
|
1292
|
+
successors.add(edge);
|
|
1293
|
+
if (!idsByKey.get(m.publicKey)!.has(m.prev)) {
|
|
1294
|
+
if (withheldLinks.has(JSON.stringify([m.publicKey, m.prev]))) excused = true;
|
|
1295
|
+
else deviations.push({ kind: "chain_link_dangling", eventId: m.eventId, chainKey });
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (excused) notes.push({ kind: "chain_claim_withheld", chainKey });
|
|
1299
|
+
}
|
|
1300
|
+
return { deviations, notes };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1111
1303
|
// ---- Presentation (optional) -------------------------------------------------
|
|
1112
1304
|
|
|
1113
1305
|
/**
|
|
@@ -1257,6 +1449,15 @@ export function renderDeclarationSummary(check: DeclarationCheck): string {
|
|
|
1257
1449
|
"unsigned content handed in beside it, which anyone could have supplied"
|
|
1258
1450
|
);
|
|
1259
1451
|
}
|
|
1452
|
+
if (check.verdict === "partial" && (check.chains ?? []).length > 0) {
|
|
1453
|
+
// Read before the branch below: the declaration WAS read, and its chain
|
|
1454
|
+
// claim was not checked because the caller gave no reading.
|
|
1455
|
+
return (
|
|
1456
|
+
"this pack holds what it declares, and says its chains are complete " +
|
|
1457
|
+
"and in order, which this verifier did not check, so whether they " +
|
|
1458
|
+
"are is UNKNOWN"
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1260
1461
|
if (check.verdict === "partial") {
|
|
1261
1462
|
return (
|
|
1262
1463
|
"this pack says what it holds in a declaration this verifier cannot read, " +
|
|
@@ -1303,6 +1504,13 @@ export function renderNote(note: DeclarationNote): string {
|
|
|
1303
1504
|
"reached this verifier, so whether it is a declaration other than " +
|
|
1304
1505
|
"this pack's own is UNKNOWN"
|
|
1305
1506
|
);
|
|
1507
|
+
case "chain_claim_withheld":
|
|
1508
|
+
return (
|
|
1509
|
+
`this pack declares chain ${note.chainKey} complete and in order, ` +
|
|
1510
|
+
"and a link in it points at a record its producer signed and this " +
|
|
1511
|
+
"pack withholds: the pointer equals that record's signed " +
|
|
1512
|
+
"payload_hash, and the record's bytes are not here to read"
|
|
1513
|
+
);
|
|
1306
1514
|
}
|
|
1307
1515
|
}
|
|
1308
1516
|
|
|
@@ -1380,5 +1588,27 @@ export function renderDeviation(deviation: DeclarationDeviation): string {
|
|
|
1380
1588
|
`${deviation.eventId} is signed as a step and this pack carries ` +
|
|
1381
1589
|
"record bytes for it that this verifier cannot read"
|
|
1382
1590
|
);
|
|
1591
|
+
case "chain_second_root":
|
|
1592
|
+
return (
|
|
1593
|
+
`${deviation.eventId} begins chain ${deviation.chainKey} a second ` +
|
|
1594
|
+
"time, and the declaration says that chain is complete and in order"
|
|
1595
|
+
);
|
|
1596
|
+
case "chain_fork":
|
|
1597
|
+
return (
|
|
1598
|
+
`${deviation.eventId} in chain ${deviation.chainKey} points at a ` +
|
|
1599
|
+
"record another record of that chain already points at, and the " +
|
|
1600
|
+
"declaration says that chain is complete and in order"
|
|
1601
|
+
);
|
|
1602
|
+
case "chain_link_dangling":
|
|
1603
|
+
return (
|
|
1604
|
+
`${deviation.eventId} in chain ${deviation.chainKey} points at a ` +
|
|
1605
|
+
"record of that chain this pack does not present, and the " +
|
|
1606
|
+
"declaration says that chain is complete and in order"
|
|
1607
|
+
);
|
|
1608
|
+
case "chain_claimed_absent":
|
|
1609
|
+
return (
|
|
1610
|
+
`the declaration says chain ${deviation.chainKey} is complete and ` +
|
|
1611
|
+
"in order, and this pack presents no record of it"
|
|
1612
|
+
);
|
|
1383
1613
|
}
|
|
1384
1614
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The environment marker: is this sandbox evidence?
|
|
2
|
+
//
|
|
3
|
+
// `environment` is an OPTIONAL skeleton field inside every envelope's signed
|
|
4
|
+
// scope, `"dev"` or `"production"` (envelope-spec §2, A1 and A9). The spec
|
|
5
|
+
// says a verifier MAY surface the value as `environment_marker` in its result
|
|
6
|
+
// and a profile MAY refuse to count dev-marked envelopes toward production
|
|
7
|
+
// evidence. Both commands now do the first, and print a pack or case that
|
|
8
|
+
// holds a `dev` envelope as sandbox evidence rather than as `VERIFIED`.
|
|
9
|
+
//
|
|
10
|
+
// The marker is read from the signed envelopes the pack holds, never from a
|
|
11
|
+
// wrapper field, a pack name or anything else the holder could write without a
|
|
12
|
+
// key. It never moves the verdict word: a sandbox pack that verifies is still
|
|
13
|
+
// `verified`, and the commands map that to the headline `SANDBOX` and exit 5
|
|
14
|
+
// (`sandboxExit` in `./exit-codes.ts`). The Python twin is
|
|
15
|
+
// `src/sanning_proof/environment.py`.
|
|
16
|
+
|
|
17
|
+
import { isAgentProofSpec } from "./agent-proof.js";
|
|
18
|
+
import {
|
|
19
|
+
ANCHOR_TRACE_BODY_TYPE,
|
|
20
|
+
EXPORT_BODY_TYPE,
|
|
21
|
+
type EvidenceBundleResult,
|
|
22
|
+
SIMULATED_WITNESS_KIND,
|
|
23
|
+
STAMP_EVIDENCE_BODY_TYPE,
|
|
24
|
+
} from "./evidence.js";
|
|
25
|
+
|
|
26
|
+
/** `"dev"` when every envelope is signed `dev`, `"mixed"` when only some are. */
|
|
27
|
+
export type EnvironmentMarker = "dev" | "mixed";
|
|
28
|
+
|
|
29
|
+
/** The headline `verify` prints for sandbox evidence in place of `VERIFIED`. */
|
|
30
|
+
export const SANDBOX_HEADLINE = "SANDBOX";
|
|
31
|
+
|
|
32
|
+
/** The title `case` prints for sandbox evidence in place of `Verified` (case titles are title case). */
|
|
33
|
+
export const SANDBOX_TITLE = "Sandbox";
|
|
34
|
+
|
|
35
|
+
/** The one line printed with it, and with any other headline, wherever the marker is present. */
|
|
36
|
+
export const SANDBOX_LINE = "Sandbox evidence (environment dev): not production evidence";
|
|
37
|
+
|
|
38
|
+
// The same bound `resolveTraceEvents` puts on unwrapping export wrappers.
|
|
39
|
+
const MAX_EXPORT_UNWRAP = 4;
|
|
40
|
+
|
|
41
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
42
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function own(o: Record<string, unknown>, key: string): unknown {
|
|
46
|
+
return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function entryEnvelopes(list: unknown, out: Record<string, unknown>[]): void {
|
|
50
|
+
if (!Array.isArray(list)) return;
|
|
51
|
+
for (const entry of list) {
|
|
52
|
+
if (!isObject(entry)) continue;
|
|
53
|
+
const env = own(entry, "envelope");
|
|
54
|
+
if (isObject(env)) out.push(env);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The signed envelopes a pack holds: every checkpoint and event envelope of an
|
|
60
|
+
* anchor-trace body, every event envelope of a stamp-evidence body, the same
|
|
61
|
+
* inside an export's inline source bundle, and an agent proof's checkpoint
|
|
62
|
+
* envelope. A stamp-trace body holds none. A value that is not an object is
|
|
63
|
+
* not an envelope and is not counted.
|
|
64
|
+
*/
|
|
65
|
+
export function packEnvelopes(bundle: unknown): Record<string, unknown>[] {
|
|
66
|
+
const out: Record<string, unknown>[] = [];
|
|
67
|
+
if (isObject(bundle) && isAgentProofSpec(own(bundle, "spec_version"))) {
|
|
68
|
+
const env = own(bundle, "checkpoint_envelope");
|
|
69
|
+
if (isObject(env)) out.push(env);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
let cur: unknown = bundle;
|
|
73
|
+
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
74
|
+
if (!isObject(cur)) return out;
|
|
75
|
+
const bodyType = own(cur, "body_type");
|
|
76
|
+
const body = own(cur, "body");
|
|
77
|
+
if (!isObject(body)) return out;
|
|
78
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
|
|
79
|
+
entryEnvelopes(own(body, "checkpoints"), out);
|
|
80
|
+
entryEnvelopes(own(body, "events"), out);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
84
|
+
entryEnvelopes(own(body, "events"), out);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
if (bodyType !== EXPORT_BODY_TYPE) return out;
|
|
88
|
+
cur = own(body, "source_bundle");
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The environment marker over one or more packs: `"dev"` where every envelope
|
|
95
|
+
* they hold is signed `environment: "dev"`, `"mixed"` where some are and some
|
|
96
|
+
* are not (`production`, or no `environment` at all), and `null` where none
|
|
97
|
+
* is, which is every production pack and every pack that holds no envelope.
|
|
98
|
+
*/
|
|
99
|
+
export function environmentMarker(bundles: readonly unknown[]): EnvironmentMarker | null {
|
|
100
|
+
let total = 0;
|
|
101
|
+
let dev = 0;
|
|
102
|
+
for (const bundle of bundles) {
|
|
103
|
+
for (const env of packEnvelopes(bundle)) {
|
|
104
|
+
total++;
|
|
105
|
+
if (own(env, "environment") === "dev") dev++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (dev === 0) return null;
|
|
109
|
+
return dev === total ? "dev" : "mixed";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a body's only unsettled witnesses are simulated: the body has events
|
|
114
|
+
* and witnesses (checkpoints, stamps or both), every witness is named, at
|
|
115
|
+
* least one is simulated, and every other one a gateway confirmed. Then the
|
|
116
|
+
* one thing that keeps the bundle from `verified` is a simulated witness
|
|
117
|
+
* nobody can look up: no mismatch, no unreachable gateway, no witness of
|
|
118
|
+
* another kind, no checkpoint or stamp witnessed by nobody.
|
|
119
|
+
*/
|
|
120
|
+
export function onlySimulatedUnsettled(result: EvidenceBundleResult): boolean {
|
|
121
|
+
const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
|
|
122
|
+
if (witnesses.length === 0 || result.events.length === 0) return false;
|
|
123
|
+
let simulated = 0;
|
|
124
|
+
for (const w of witnesses) {
|
|
125
|
+
if (w.anchorRef === null) return false;
|
|
126
|
+
if (w.anchorRef.kind === SIMULATED_WITNESS_KIND) simulated++;
|
|
127
|
+
else if (w.onChain?.rollup !== "confirm") return false;
|
|
128
|
+
}
|
|
129
|
+
return simulated > 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a run with gateways counted only simulated witnesses, so nothing
|
|
134
|
+
* was looked up on a chain: gateways were passed, the run is not counted as
|
|
135
|
+
* checked on a chain, and it holds a simulated witness. A run that stopped
|
|
136
|
+
* before its witnesses were read (a bad wrapper signature or `body_hash`, an
|
|
137
|
+
* unknown `body_type`, an export whose source is not inline) holds none, so it
|
|
138
|
+
* is not one, and prints what it printed before simulated witnesses existed.
|
|
139
|
+
*/
|
|
140
|
+
export function simulatedOnlyRun(result: EvidenceBundleResult, gateways: readonly string[]): boolean {
|
|
141
|
+
if (gateways.length === 0 || result.onChainChecked) return false;
|
|
142
|
+
return [...result.checkpoints, ...(result.stamps ?? [])].some(
|
|
143
|
+
(w) => w.anchorRef?.kind === SIMULATED_WITNESS_KIND,
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/evidence.ts
CHANGED
|
@@ -1662,6 +1662,10 @@ async function verifyAnchorTrace(
|
|
|
1662
1662
|
let gatewayUnavailable = false;
|
|
1663
1663
|
let uncheckedKind = false;
|
|
1664
1664
|
let uncheckedId = false;
|
|
1665
|
+
// Simulated witnesses against witnesses of any other kind, counted over the
|
|
1666
|
+
// checkpoints that name one, as `refetchStamps` counts stamps.
|
|
1667
|
+
let simulated = 0;
|
|
1668
|
+
let onChainKinds = 0;
|
|
1665
1669
|
if (onChainChecked) {
|
|
1666
1670
|
if (typeof fetchImpl !== "function") {
|
|
1667
1671
|
gatewayUnavailable = true;
|
|
@@ -1701,6 +1705,8 @@ async function verifyAnchorTrace(
|
|
|
1701
1705
|
cpr.errors.push("checkpoint has no anchor_ref -- not yet witnessed, nothing to re-fetch");
|
|
1702
1706
|
continue;
|
|
1703
1707
|
}
|
|
1708
|
+
if (cpr.anchorRef.kind === SIMULATED_WITNESS_KIND) simulated++;
|
|
1709
|
+
else onChainKinds++;
|
|
1704
1710
|
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
1705
1711
|
cpr.onChain = outcome.onChain;
|
|
1706
1712
|
cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
|
|
@@ -1764,7 +1770,10 @@ async function verifyAnchorTrace(
|
|
|
1764
1770
|
bodyHashOk: true,
|
|
1765
1771
|
signatureOk: true,
|
|
1766
1772
|
bodyType,
|
|
1767
|
-
|
|
1773
|
+
// A run whose only witnessed checkpoints are simulated looked nothing up
|
|
1774
|
+
// on a chain, so it is not counted as checked on one (the stamp rule in
|
|
1775
|
+
// `refetchStamps`). The checkpoints stay not checked and the run partial.
|
|
1776
|
+
onChainChecked: onChainChecked && !(simulated > 0 && onChainKinds === 0),
|
|
1768
1777
|
checkpoints: checkpointResults,
|
|
1769
1778
|
events: eventResults,
|
|
1770
1779
|
assertedStatus,
|
|
@@ -2679,12 +2688,16 @@ async function refetchStamps(
|
|
|
2679
2688
|
let gatewayUnavailable = false;
|
|
2680
2689
|
let uncheckedKind = false;
|
|
2681
2690
|
let uncheckedId = false;
|
|
2691
|
+
let simulated = 0;
|
|
2692
|
+
let onChainKinds = 0;
|
|
2682
2693
|
for (let i = 0; i < stamps.length; i++) {
|
|
2683
2694
|
const st = stamps[i]!;
|
|
2684
2695
|
const witnessedHex = witnessed[i];
|
|
2685
2696
|
// No witness named, or bytes we could not read: nothing to re-fetch, and
|
|
2686
2697
|
// nothing a gateway could confirm or deny. Left as `onChain: null`.
|
|
2687
2698
|
if (st.anchorRef === null || witnessedHex === undefined) continue;
|
|
2699
|
+
if (st.anchorRef.kind === SIMULATED_WITNESS_KIND) simulated++;
|
|
2700
|
+
else onChainKinds++;
|
|
2688
2701
|
|
|
2689
2702
|
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
2690
2703
|
st.onChain = outcome.onChain;
|
|
@@ -2704,9 +2717,24 @@ async function refetchStamps(
|
|
|
2704
2717
|
// unreachable note would fail a stamp for a gateway being down.
|
|
2705
2718
|
st.ok = st.ok && st.onChainOk !== false;
|
|
2706
2719
|
}
|
|
2720
|
+
// A simulated witness is a sandbox plane's stand-in for a chain: there is no
|
|
2721
|
+
// chain to look it up on, so it is never counted as checked on one.
|
|
2722
|
+
// It stays not checked like any kind this verifier has no adapter for, and
|
|
2723
|
+
// the run stays undetermined; what changes is that a run whose only
|
|
2724
|
+
// witnesses are simulated no longer says it checked anything on a chain.
|
|
2725
|
+
if (simulated > 0 && onChainKinds === 0) {
|
|
2726
|
+
return { onChainChecked: false, gatewayUnavailable, uncheckedKind, uncheckedId };
|
|
2727
|
+
}
|
|
2707
2728
|
return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
|
|
2708
2729
|
}
|
|
2709
2730
|
|
|
2731
|
+
/**
|
|
2732
|
+
* The `anchor_ref.kind` a sandbox plane writes for a witness it simulated
|
|
2733
|
+
* (chain id `sanning-sandbox`). Never looked up and never counted as checked
|
|
2734
|
+
* on a chain.
|
|
2735
|
+
*/
|
|
2736
|
+
export const SIMULATED_WITNESS_KIND = "simulated";
|
|
2737
|
+
|
|
2710
2738
|
interface StampRefetchSummary {
|
|
2711
2739
|
onChainChecked: boolean;
|
|
2712
2740
|
gatewayUnavailable: boolean;
|