@sanning/proof 0.17.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/src/crypto.ts CHANGED
@@ -8,8 +8,10 @@ import * as ed from "@noble/ed25519";
8
8
  // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
9
9
  // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
10
10
  // browser and in Node >= 19, so the same code path runs in the app and in tests.
11
- ed.etc.sha512Async = async (...msgs: Uint8Array[]): Promise<Uint8Array> =>
12
- new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
11
+ // noble 3 ships this provider as its default; it is set here so the kernel
12
+ // says what it hashes with rather than inheriting it.
13
+ ed.hashes.sha512Async = async (message) =>
14
+ new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(message)));
13
15
 
14
16
  // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
15
17
  // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
@@ -76,10 +78,125 @@ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
76
78
  return bytesToHex(await sha256Bytes(bytes));
77
79
  }
78
80
 
79
- // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
80
- // appear in the envelope; message is raw bytes. Never throws — a malformed
81
- // signature or key is "not verified," not an exception, so a hostile envelope
82
- // can't crash the checker.
81
+ // ---------------------------------------------------------------------------
82
+ // The Ed25519 rule (envelope-spec §7, A44, proof#230)
83
+ //
84
+ // Strict RFC 8032 (`zip215: false`, a frozen invariant), and more: the public
85
+ // key A and the signature's R must each be the canonical encoding of a point
86
+ // in the prime-order subgroup other than the identity, and S below the group
87
+ // order L. Only then is the equation checked.
88
+ //
89
+ // Why more than `zip215: false`: noble checks the equation with the cofactor,
90
+ // 8·S·B = 8·R + 8·k·A, and Go's crypto/ed25519 and libsodium without it, so on
91
+ // their own they disagree when A or R has a small-order component. noble 2.3
92
+ // also could not encode an identity R and hashed nothing for k, so it accepted
93
+ // one signature from a key holder over every message. With A and R in the
94
+ // prime-order subgroup the two equations are the same equation, and every
95
+ // kernel accepts exactly the same signatures.
96
+ //
97
+ // Each clause is checked here, in this order, and named in these words: the
98
+ // Python twin is `ed25519_rule_reason` in `src/sanning_proof/ed25519_rule.py`
99
+ // and the Go kernel's `verifyEd25519Strict` in sanning-io/agent `pkg/proof`.
100
+ // Several clauses are ones noble 3 refuses on its own; they are checked here
101
+ // anyway so the refusal names the clause, which a library's `false` cannot.
102
+
103
+ /** The sentence a refused signature reads in an envelope result. */
104
+ export const SIGNATURE_FAILED = "Ed25519 signature verification failed";
105
+
106
+ export const ED25519_RULE_REASONS = [
107
+ "the public key is not the canonical encoding of a point",
108
+ "the public key is a point of small order",
109
+ "the public key is not in the prime-order subgroup",
110
+ "R is not the canonical encoding of a point",
111
+ "R is the identity",
112
+ "R is a point of small order",
113
+ "R is not in the prime-order subgroup",
114
+ "S is not below the group order",
115
+ ] as const;
116
+
117
+ const [
118
+ KEY_NOT_CANONICAL,
119
+ KEY_SMALL_ORDER,
120
+ KEY_NOT_IN_SUBGROUP,
121
+ R_NOT_CANONICAL,
122
+ R_IDENTITY,
123
+ R_SMALL_ORDER,
124
+ R_NOT_IN_SUBGROUP,
125
+ S_NOT_BELOW_L,
126
+ ] = ED25519_RULE_REASONS;
127
+
128
+ const ED25519_L = 2n ** 252n + 27742317777372353535851937790883648493n;
129
+
130
+ function bytesToNumberLE(bytes: Uint8Array): bigint {
131
+ let v = 0n;
132
+ for (let i = bytes.length - 1; i >= 0; i--) v = (v << 8n) | BigInt(bytes[i]);
133
+ return v;
134
+ }
135
+
136
+ // `Point.fromBytes(..., false)` is RFC 8032 decoding with its two strict
137
+ // branches: it throws on y at or above p and on x zero with the sign bit set,
138
+ // the two non-canonical encodings of a point, and on bytes that are no point.
139
+ function decodeStrict(bytes: Uint8Array): ed.Point | null {
140
+ try {
141
+ return ed.Point.fromBytes(bytes, false);
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
149
+ * when it breaks none and the equation is what is left to check. Lengths are
150
+ * the caller's (32 and 64 bytes). Never throws.
151
+ */
152
+ export function ed25519RuleReason(signature: Uint8Array, publicKey: Uint8Array): string | null {
153
+ const A = decodeStrict(publicKey);
154
+ if (A === null) return KEY_NOT_CANONICAL;
155
+ if (A.isSmallOrder()) return KEY_SMALL_ORDER;
156
+ if (!A.isTorsionFree()) return KEY_NOT_IN_SUBGROUP;
157
+ const R = decodeStrict(signature.subarray(0, 32));
158
+ if (R === null) return R_NOT_CANONICAL;
159
+ if (R.is0()) return R_IDENTITY;
160
+ if (R.isSmallOrder()) return R_SMALL_ORDER;
161
+ if (!R.isTorsionFree()) return R_NOT_IN_SUBGROUP;
162
+ if (bytesToNumberLE(signature.subarray(32, 64)) >= ED25519_L) return S_NOT_BELOW_L;
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
168
+ * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
169
+ * with `": "` and the clause of the rule it broke when it broke one. A
170
+ * malformed field, a wrong length and an equation that does not hold read
171
+ * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
172
+ */
173
+ export async function ed25519Refusal(
174
+ message: Uint8Array,
175
+ signatureHex: string,
176
+ publicKeyHex: string,
177
+ ): Promise<string | null> {
178
+ let signature: Uint8Array;
179
+ let publicKey: Uint8Array;
180
+ try {
181
+ signature = hexToBytes(signatureHex);
182
+ publicKey = hexToBytes(publicKeyHex);
183
+ } catch {
184
+ return SIGNATURE_FAILED;
185
+ }
186
+ if (signature.length !== 64 || publicKey.length !== 32) return SIGNATURE_FAILED;
187
+ const reason = ed25519RuleReason(signature, publicKey);
188
+ if (reason !== null) return `${SIGNATURE_FAILED}: ${reason}`;
189
+ try {
190
+ return (await ed.verifyAsync(signature, message, publicKey, { zip215: false })) ? null : SIGNATURE_FAILED;
191
+ } catch {
192
+ return SIGNATURE_FAILED;
193
+ }
194
+ }
195
+
196
+ // Verify an Ed25519 signature by the rule above. Inputs are hex (signature,
197
+ // public key) as they appear in the envelope; message is raw bytes. Never
198
+ // throws — a malformed signature or key is "not verified," not an exception,
199
+ // so a hostile envelope can't crash the checker.
83
200
  // Arguments in the Python twin's order, `ed25519_verify(message,
84
201
  // signature_hex, public_key_hex)` (P-03, 0.13.0). The message came second
85
202
  // until 0.13.0; a caller that still passes the signature first gets a compile
@@ -89,22 +206,14 @@ export async function ed25519Verify(
89
206
  signatureHex: string,
90
207
  publicKeyHex: string,
91
208
  ): Promise<boolean> {
92
- try {
93
- // zip215:false → strict RFC-8032 verification, matching the Go agent's
94
- // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
95
- return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
96
- zip215: false,
97
- });
98
- } catch {
99
- return false;
100
- }
209
+ return (await ed25519Refusal(message, signatureHex, publicKeyHex)) === null;
101
210
  }
102
211
 
103
212
  // Ed25519 SIGN the raw `message` bytes with a 32-byte private-key seed (lowercase
104
213
  // hex). Returns the signature as lowercase hex. The signing counterpart to
105
214
  // ed25519Verify — the ONE signing primitive the composer (`proof export`,
106
215
  // evidence-export.md §5) needs to Ed25519-sign the export wrapper with the
107
- // exporter's key ("anyone can export", P-4). The @noble sha512Async hook wired at
216
+ // exporter's key ("anyone can export", P-4). The @noble sha512Async provider wired at
108
217
  // module load serves signing and verification alike, so this stays WebCrypto-only.
109
218
  export async function ed25519Sign(message: Uint8Array, privateKeyHex: string): Promise<string> {
110
219
  return bytesToHex(await ed.signAsync(message, hexToBytes(privateKeyHex)));
@@ -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 = [DECLARATION_VERSION, DECLARATION_VERSION_V2] as const;
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 === DECLARATION_VERSION_V2;
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
- return reasons.length > 0 ? { ok: false, reasons } : { ok: true, read: { records, pairs, roles } };
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 === DECLARATION_VERSION || version === DECLARATION_VERSION_V2 ? version : null;
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 !== DECLARATION_VERSION && version !== DECLARATION_VERSION_V2) return false;
756
- if (!hasExactKeys(obj, DECLARATION_KEYS)) return false;
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
- version === DECLARATION_VERSION_V2 ? ["event_id", "disclosure", "role"] : ["event_id", "disclosure"];
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
- return handoffs.every((h) => hasExactKeys(h, ["sent_event_id", "received_event_id"]));
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 ? "failed" : contentFindings.length > 0 ? "partial" : "verified";
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
  }