@mikeargento/bitgraph-audit 0.1.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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/anchors.d.ts +31 -0
  4. package/dist/anchors.d.ts.map +1 -0
  5. package/dist/anchors.js +116 -0
  6. package/dist/anchors.js.map +1 -0
  7. package/dist/anomalies.d.ts +42 -0
  8. package/dist/anomalies.d.ts.map +1 -0
  9. package/dist/anomalies.js +642 -0
  10. package/dist/anomalies.js.map +1 -0
  11. package/dist/attestation.d.ts +20 -0
  12. package/dist/attestation.d.ts.map +1 -0
  13. package/dist/attestation.js +795 -0
  14. package/dist/attestation.js.map +1 -0
  15. package/dist/audit.d.ts +19 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +116 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/authority.d.ts +37 -0
  20. package/dist/authority.d.ts.map +1 -0
  21. package/dist/authority.js +126 -0
  22. package/dist/authority.js.map +1 -0
  23. package/dist/aws-nitro-root-ca.d.ts +24 -0
  24. package/dist/aws-nitro-root-ca.d.ts.map +1 -0
  25. package/dist/aws-nitro-root-ca.js +44 -0
  26. package/dist/aws-nitro-root-ca.js.map +1 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +257 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/contents-hash.d.ts +30 -0
  32. package/dist/contents-hash.d.ts.map +1 -0
  33. package/dist/contents-hash.js +73 -0
  34. package/dist/contents-hash.js.map +1 -0
  35. package/dist/export.d.ts +64 -0
  36. package/dist/export.d.ts.map +1 -0
  37. package/dist/export.js +192 -0
  38. package/dist/export.js.map +1 -0
  39. package/dist/index.d.ts +31 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +17 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ingest.d.ts +36 -0
  44. package/dist/ingest.d.ts.map +1 -0
  45. package/dist/ingest.js +828 -0
  46. package/dist/ingest.js.map +1 -0
  47. package/dist/reconstruct.d.ts +40 -0
  48. package/dist/reconstruct.d.ts.map +1 -0
  49. package/dist/reconstruct.js +400 -0
  50. package/dist/reconstruct.js.map +1 -0
  51. package/dist/report-json.d.ts +3 -0
  52. package/dist/report-json.d.ts.map +1 -0
  53. package/dist/report-json.js +290 -0
  54. package/dist/report-json.js.map +1 -0
  55. package/dist/report-md.d.ts +3 -0
  56. package/dist/report-md.d.ts.map +1 -0
  57. package/dist/report-md.js +853 -0
  58. package/dist/report-md.js.map +1 -0
  59. package/dist/rlp.d.ts +37 -0
  60. package/dist/rlp.d.ts.map +1 -0
  61. package/dist/rlp.js +120 -0
  62. package/dist/rlp.js.map +1 -0
  63. package/dist/tar.d.ts +47 -0
  64. package/dist/tar.d.ts.map +1 -0
  65. package/dist/tar.js +483 -0
  66. package/dist/tar.js.map +1 -0
  67. package/dist/temporal.d.ts +55 -0
  68. package/dist/temporal.d.ts.map +1 -0
  69. package/dist/temporal.js +461 -0
  70. package/dist/temporal.js.map +1 -0
  71. package/dist/types.d.ts +1389 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +3 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/validity.d.ts +21 -0
  76. package/dist/validity.d.ts.map +1 -0
  77. package/dist/validity.js +86 -0
  78. package/dist/validity.js.map +1 -0
  79. package/dist/verify-tiers.d.ts +11 -0
  80. package/dist/verify-tiers.d.ts.map +1 -0
  81. package/dist/verify-tiers.js +120 -0
  82. package/dist/verify-tiers.js.map +1 -0
  83. package/dist/witness.d.ts +25 -0
  84. package/dist/witness.d.ts.map +1 -0
  85. package/dist/witness.js +308 -0
  86. package/dist/witness.js.map +1 -0
  87. package/package.json +42 -0
  88. package/src/anchors.ts +168 -0
  89. package/src/anomalies.ts +773 -0
  90. package/src/attestation.ts +878 -0
  91. package/src/audit.ts +136 -0
  92. package/src/authority.ts +165 -0
  93. package/src/aws-nitro-root-ca.ts +47 -0
  94. package/src/cli.ts +284 -0
  95. package/src/contents-hash.ts +95 -0
  96. package/src/export.ts +278 -0
  97. package/src/index.ts +122 -0
  98. package/src/ingest.ts +967 -0
  99. package/src/reconstruct.ts +491 -0
  100. package/src/report-json.ts +331 -0
  101. package/src/report-md.ts +1067 -0
  102. package/src/rlp.ts +142 -0
  103. package/src/tar.ts +587 -0
  104. package/src/temporal.ts +629 -0
  105. package/src/types.ts +1528 -0
  106. package/src/validity.ts +90 -0
  107. package/src/verify-tiers.ts +137 -0
  108. package/src/witness.ts +407 -0
@@ -0,0 +1,90 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit intrinsic validity (internal)
5
+ *
6
+ * Shared by reconstruction and anomaly classification to answer one
7
+ * question: is this proof object cryptographically sound on its own
8
+ * (structure, canonical signature, slot binding, epoch link shape and
9
+ * successor binding), independent of run-order effects and of any supplied
10
+ * trust policy?
11
+ *
12
+ * Sources, in order:
13
+ *
14
+ * 1. The run verification record: "verified" and "artifact-unavailable"
15
+ * are intrinsically valid.
16
+ * 2. Anything else (status "failed", or the verification pass has not
17
+ * run) gets an isolated bytes-free recheck through
18
+ * verifyProofIntegrity with a fresh epoch link state and no policy.
19
+ * This separates order-dependent failures (the epoch link
20
+ * single-successor check fails whichever fork consumer verifies
21
+ * second) and policy rejections from cryptographic unsoundness.
22
+ *
23
+ * The run verification record is never modified: a proof that failed in
24
+ * the run keeps that status everywhere it is reported. Intrinsic validity
25
+ * only decides whether the proof may stand as a competing branch in a
26
+ * divergence or carry a hard lineage edge.
27
+ *
28
+ * State discipline: the recheck resets the verify package's module-level
29
+ * epoch link state before and after each call. Callers therefore run
30
+ * reconstruction and classification only after verifyObservedProofs has
31
+ * completed; that pass resets its own state at the start of every run, so
32
+ * later audit runs are unaffected.
33
+ */
34
+
35
+ import { resetEpochLinkState, verifyProofIntegrity } from "@mikeargento/bitgraph-verify";
36
+ import type { ObservedProof } from "./types.js";
37
+
38
+ const cache = new WeakMap<object, boolean>();
39
+
40
+ /** See module doc. Results are cached per ObservedProof object. */
41
+ export async function isIntrinsicallyValid(observed: ObservedProof): Promise<boolean> {
42
+ const status = observed.verification?.status;
43
+ if (status === "verified" || status === "artifact-unavailable") return true;
44
+
45
+ const cached = cache.get(observed);
46
+ if (cached !== undefined) return cached;
47
+
48
+ resetEpochLinkState();
49
+ let valid = false;
50
+ try {
51
+ valid = (await verifyProofIntegrity({ proof: observed.proof })).valid;
52
+ } catch {
53
+ valid = false;
54
+ }
55
+ resetEpochLinkState();
56
+
57
+ cache.set(observed, valid);
58
+ return valid;
59
+ }
60
+
61
+ /**
62
+ * Strict decimal counter parse for topology purposes. Accepts ASCII digit
63
+ * strings (leading zeros normalize through BigInt); anything else is
64
+ * treated as no counter evidence. The canonical verifier separately
65
+ * reports malformed counters on the verification dimension.
66
+ */
67
+ export function parseCounter(value: string | undefined): bigint | undefined {
68
+ if (value === undefined || value.length === 0 || !/^[0-9]+$/.test(value)) return undefined;
69
+ return BigInt(value);
70
+ }
71
+
72
+ /** Deterministic member order: parseable counter ascending, then canonical hash. */
73
+ export function byCounterThenHash(
74
+ a: { counter?: string; proofHash: string },
75
+ b: { counter?: string; proofHash: string }
76
+ ): number {
77
+ const ca = parseCounter(a.counter);
78
+ const cb = parseCounter(b.counter);
79
+ if (ca !== undefined && cb !== undefined && ca !== cb) return ca < cb ? -1 : 1;
80
+ if (ca !== undefined && cb === undefined) return -1;
81
+ if (ca === undefined && cb !== undefined) return 1;
82
+ return a.proofHash < b.proofHash ? -1 : a.proofHash > b.proofHash ? 1 : 0;
83
+ }
84
+
85
+ /** Append to a Map-of-arrays. */
86
+ export function pushMap<K, V>(map: Map<K, V[]>, key: K, value: V): void {
87
+ const list = map.get(key);
88
+ if (list === undefined) map.set(key, [value]);
89
+ else list.push(value);
90
+ }
@@ -0,0 +1,137 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit verification tiers
5
+ *
6
+ * Applies the canonical verification tiers to every unique observed proof
7
+ * in an IngestResult:
8
+ *
9
+ * - Artifact bytes present (content-addressed match in the bundle):
10
+ * full verification through verify() from @mikeargento/bitgraph-verify,
11
+ * with the actual artifact bytes. Status "verified" or "failed" with
12
+ * the verifier's exact reason.
13
+ * - Artifact bytes absent: verifyProofIntegrity(), the bytes-free
14
+ * integrity API. Status "artifact-unavailable" when the checks pass;
15
+ * never "verified", because digest matching was not independently
16
+ * checked. Status "failed" with the exact reason otherwise.
17
+ *
18
+ * All verification semantics live in the verify package; this module only
19
+ * routes proofs and records results. Verification status and chain
20
+ * topology are separate dimensions: nothing here reads or writes chain
21
+ * structure, and chainless proofs (tagged at ingest) verify exactly like
22
+ * any other proof.
23
+ *
24
+ * Ordering and epoch link state: verifyEpochLink() in the verify package
25
+ * keeps module-level single-successor state, so resetEpochLinkState() is
26
+ * called exactly once per audit run, before any proof is verified. Within
27
+ * a run, proofs are verified in a deterministic order: first the
28
+ * artifact-matched proofs as their artifact bytes stream by (container
29
+ * order), then the remaining proofs in first-observation order. Re-running
30
+ * the pass over a re-ingested bundle therefore always reproduces the same
31
+ * results.
32
+ */
33
+
34
+ import {
35
+ resetEpochLinkState,
36
+ verify,
37
+ verifyProofIntegrity,
38
+ } from "@mikeargento/bitgraph-verify";
39
+ import { streamMatchedArtifacts } from "./ingest.js";
40
+ import type {
41
+ IngestResult,
42
+ ObservedProof,
43
+ ProofVerification,
44
+ VerificationSummary,
45
+ VerifyObservedOptions,
46
+ } from "./types.js";
47
+
48
+ /**
49
+ * Verify every unique observed proof in the ingest result, attaching a
50
+ * ProofVerification record to each. Idempotent per proof: records already
51
+ * attached (from a previous call on the same object) are left untouched;
52
+ * for a fresh deterministic run, re-ingest the bundle first.
53
+ *
54
+ * @returns aggregate counts. Per-proof results live on ingest.proofs.
55
+ */
56
+ export async function verifyObservedProofs(
57
+ ingest: IngestResult,
58
+ options?: VerifyObservedOptions
59
+ ): Promise<VerificationSummary> {
60
+ // Fresh single-successor state once per audit run (verify package G7).
61
+ resetEpochLinkState();
62
+
63
+ const trustAnchors = options?.trustAnchors;
64
+
65
+ // Index proofs by the hex form of their artifact digest, mirroring the
66
+ // strict decoding used for artifact matching at ingest. Resolution goes
67
+ // through a proofHash map so a bundle with many artifacts stays
68
+ // O(proofs + artifacts + matches); ingest built matchedProofHashes in
69
+ // first-observation order, so the per-artifact proof order is unchanged.
70
+ const proofsByHash = new Map<string, ObservedProof>(
71
+ ingest.proofs.map((p) => [p.proofHash, p])
72
+ );
73
+ const byDigestHex = new Map<string, ObservedProof[]>();
74
+ for (const artifact of ingest.artifacts) {
75
+ if (artifact.matchedProofHashes.length === 0) continue;
76
+ const matched = artifact.matchedProofHashes
77
+ .map((hash) => proofsByHash.get(hash))
78
+ .filter((p): p is ObservedProof => p !== undefined);
79
+ byDigestHex.set(artifact.sha256Hex, matched);
80
+ }
81
+
82
+ // Tier 1: full verification for proofs whose artifact bytes are present.
83
+ for await (const artifact of streamMatchedArtifacts(ingest)) {
84
+ const proofs = byDigestHex.get(artifact.sha256Hex) ?? [];
85
+ for (const proof of proofs) {
86
+ if (proof.verification !== undefined) continue;
87
+ const result = await verify({
88
+ proof: proof.proof,
89
+ bytes: artifact.bytes,
90
+ ...(trustAnchors !== undefined ? { trustAnchors } : {}),
91
+ });
92
+ const record: ProofVerification = {
93
+ tier: "full",
94
+ status: result.valid ? "verified" : "failed",
95
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
96
+ artifactPath: artifact.path,
97
+ };
98
+ proof.verification = record;
99
+ }
100
+ }
101
+
102
+ // Tier 2: bytes-free integrity for everything else, in observation order.
103
+ for (const proof of ingest.proofs) {
104
+ if (proof.verification !== undefined) continue;
105
+ const result = await verifyProofIntegrity({
106
+ proof: proof.proof,
107
+ ...(trustAnchors !== undefined ? { trustAnchors } : {}),
108
+ });
109
+ const record: ProofVerification = {
110
+ tier: "integrity",
111
+ status: result.valid ? "artifact-unavailable" : "failed",
112
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
113
+ };
114
+ proof.verification = record;
115
+ }
116
+
117
+ // Summary.
118
+ let verified = 0;
119
+ let failed = 0;
120
+ let artifactUnavailable = 0;
121
+ let chainless = 0;
122
+ for (const proof of ingest.proofs) {
123
+ const status = proof.verification?.status;
124
+ if (status === "verified") verified++;
125
+ else if (status === "failed") failed++;
126
+ else if (status === "artifact-unavailable") artifactUnavailable++;
127
+ if (proof.chainless) chainless++;
128
+ }
129
+
130
+ return {
131
+ total: ingest.proofs.length,
132
+ verified,
133
+ failed,
134
+ artifactUnavailable,
135
+ chainless,
136
+ };
137
+ }
package/src/witness.ts ADDED
@@ -0,0 +1,407 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit anchor witness verification
5
+ *
6
+ * Implements the mandatory procedure of docs/BUNDLE-FORMAT.md section
7
+ * 10.3, exactly and fully offline:
8
+ *
9
+ * 1. Hex-decode headerRlpHex and require a single well-formed RLP list.
10
+ * 2. Recompute the block hash locally with Keccak-256 (Ethereum's
11
+ * original Keccak padding, explicitly NOT FIPS-202 SHA3-256).
12
+ * 3. Compare against the anchor's SIGNED attribution.message
13
+ * (lowercased, full 0x string).
14
+ * 4. Bind the signed message to the committed artifact: SHA-256 over
15
+ * the UTF-8 bytes of the exact signed message string must equal the
16
+ * strictly decoded artifact.digestB64.
17
+ * 5. Cross-check the claimed fields: header RLP index 8 (number)
18
+ * against the witness's blockNumber and, when the signed
19
+ * attribution.title parses as an Etherscan block URL, against the
20
+ * URL; and the witness's claimed blockHash against the recomputed
21
+ * hash.
22
+ * 6. Only then read the timestamp from RLP index 11 as external
23
+ * wall-clock evidence.
24
+ *
25
+ * Preconditions per the spec: the candidate anchor is identified by its
26
+ * signed attribution and its own cryptographic verification has
27
+ * succeeded. A witness cannot rescue an invalid proof. Intrinsic
28
+ * validity (validity.ts) is used for the precondition, so a run-order
29
+ * epoch-link artifact never disqualifies a sound anchor, and the run
30
+ * verification record is never modified.
31
+ *
32
+ * Any failure means the witness confers nothing: no timestamp is
33
+ * reported, and the anchor proof's own standing is unchanged in both
34
+ * directions. Failure codes are stable AnomalyCode literals.
35
+ *
36
+ * Zero network access: witnesses are inbound files, trust in them is
37
+ * established purely by local recomputation.
38
+ */
39
+
40
+ import { sha256 } from "@noble/hashes/sha256";
41
+ import { keccak_256 } from "@noble/hashes/sha3";
42
+ import { bytesToHex0x, decodeRlpList, hexToBytes, rlpBytesToBigInt } from "./rlp.js";
43
+ import { isIntrinsicallyValid } from "./validity.js";
44
+ import type {
45
+ AnchorIdentification,
46
+ AnchorWitnessAnalysis,
47
+ AnchorWitnessFile,
48
+ AnchorWitnessOutcome,
49
+ AnomalyCode,
50
+ AuditFinding,
51
+ IngestResult,
52
+ ObservedProof,
53
+ } from "./types.js";
54
+
55
+ const HEADER_NUMBER_INDEX = 8;
56
+ const HEADER_TIMESTAMP_INDEX = 11;
57
+ const BLOCK_HASH_SHAPE = /^0x[0-9a-fA-F]{64}$/;
58
+ const ETHERSCAN_BLOCK_URL = /^https:\/\/etherscan\.io\/block\/([0-9]+)$/;
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Public API
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Verify every witness file in the bundle against the identified anchors.
66
+ *
67
+ * Matching is by block hash: a witness is a candidate for every anchor
68
+ * whose signed attribution.message equals (case-insensitively) either the
69
+ * locally recomputed header hash or the witness's claimed blockHash. The
70
+ * second route exists so a witness with a tampered header still fails
71
+ * loudly against its intended anchor (witness-hash-mismatch) instead of
72
+ * disappearing as unmatched. A witness matching no anchor is reported
73
+ * witness-unmatched.
74
+ *
75
+ * Deterministic: witnesses in observation order, candidate anchors in
76
+ * identification (observation) order. Run after verifyObservedProofs and
77
+ * identifyAnchors.
78
+ */
79
+ export async function verifyAnchorWitnesses(
80
+ ingest: IngestResult,
81
+ identification: AnchorIdentification
82
+ ): Promise<AnchorWitnessAnalysis> {
83
+ const outcomes: AnchorWitnessOutcome[] = [];
84
+ const findings: AuditFinding[] = [];
85
+ const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
86
+
87
+ for (const witnessFile of ingest.witnesses) {
88
+ const decoded = decodeWitness(witnessFile);
89
+ if (decoded.failure !== undefined) {
90
+ const outcome: AnchorWitnessOutcome = {
91
+ witnessPath: witnessFile.path,
92
+ verified: false,
93
+ reason: decoded.failure.reason,
94
+ detail: decoded.failure.detail,
95
+ };
96
+ outcomes.push(outcome);
97
+ findings.push(toFinding(outcome));
98
+ continue;
99
+ }
100
+
101
+ // Candidate anchors by recomputed hash or claimed hash.
102
+ const computed = decoded.computedHash as string;
103
+ const claimed = (decoded.claimedHash as string).toLowerCase();
104
+ const candidates = identification.anchors.filter((anchor) => {
105
+ const message = anchor.blockHash?.toLowerCase();
106
+ return message !== undefined && (message === computed || message === claimed);
107
+ });
108
+
109
+ if (candidates.length === 0) {
110
+ const outcome: AnchorWitnessOutcome = {
111
+ witnessPath: witnessFile.path,
112
+ verified: false,
113
+ reason: "witness-unmatched",
114
+ detail:
115
+ "neither the locally recomputed block hash nor the claimed blockHash matches any " +
116
+ "observed anchor's signed attribution.message",
117
+ computedBlockHash: computed,
118
+ };
119
+ outcomes.push(outcome);
120
+ findings.push(toFinding(outcome));
121
+ continue;
122
+ }
123
+
124
+ for (const candidate of candidates) {
125
+ const anchorProof = byHash.get(candidate.proofHash);
126
+ const anchorValid = anchorProof !== undefined && (await isIntrinsicallyValid(anchorProof));
127
+ const outcome = runProcedure(decoded, witnessFile.path, candidate.proofHash, anchorProof, anchorValid);
128
+ outcomes.push(outcome);
129
+ if (!outcome.verified) findings.push(toFinding(outcome));
130
+ }
131
+ }
132
+
133
+ return { outcomes, findings };
134
+ }
135
+
136
+ /**
137
+ * Verify one witness file against one specific anchor proof, running the
138
+ * full spec 10.3 procedure. Exposed for targeted use; the pipeline
139
+ * (verifyAnchorWitnesses) applies the same procedure with hash-based
140
+ * matching.
141
+ */
142
+ export async function verifyAnchorWitness(
143
+ witnessFile: AnchorWitnessFile,
144
+ anchorProof: ObservedProof
145
+ ): Promise<AnchorWitnessOutcome> {
146
+ const decoded = decodeWitness(witnessFile);
147
+ if (decoded.failure !== undefined) {
148
+ return {
149
+ witnessPath: witnessFile.path,
150
+ anchorProofHash: anchorProof.proofHash,
151
+ verified: false,
152
+ reason: decoded.failure.reason,
153
+ detail: decoded.failure.detail,
154
+ };
155
+ }
156
+ const anchorValid = await isIntrinsicallyValid(anchorProof);
157
+ return runProcedure(decoded, witnessFile.path, anchorProof.proofHash, anchorProof, anchorValid);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Witness decoding (spec 10.3 steps 1 and 2, anchor-independent)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ interface DecodedWitness {
165
+ /** Present when decoding failed at or before step 2. */
166
+ failure?: { reason: AnomalyCode; detail: string };
167
+ /** 0x + 64 lowercase hex, locally recomputed. */
168
+ computedHash?: string;
169
+ /** RLP items of the header list. */
170
+ items?: ReturnType<typeof decodeRlpList>;
171
+ claimedHash?: string;
172
+ claimedNumber?: bigint;
173
+ }
174
+
175
+ function decodeWitness(witnessFile: AnchorWitnessFile): DecodedWitness {
176
+ const w = witnessFile.witness;
177
+
178
+ // Field rules of spec 10.2.
179
+ const headerRlpHex = w["headerRlpHex"];
180
+ if (typeof headerRlpHex !== "string") {
181
+ return malformed("headerRlpHex is missing or not a string");
182
+ }
183
+ const blockNumber = w["blockNumber"];
184
+ if (typeof blockNumber !== "number" || !Number.isInteger(blockNumber) || blockNumber < 0) {
185
+ return malformed("blockNumber is missing or not a non-negative integer");
186
+ }
187
+ const blockHash = w["blockHash"];
188
+ if (typeof blockHash !== "string" || !BLOCK_HASH_SHAPE.test(blockHash)) {
189
+ return malformed("blockHash is missing or not 0x followed by 64 hex characters");
190
+ }
191
+
192
+ // Step 1: hex-decode, then require a single well-formed RLP list.
193
+ const headerBytes = hexToBytes(headerRlpHex);
194
+ if (headerBytes === null) {
195
+ return malformed("headerRlpHex is not 0x-prefixed even-length hex");
196
+ }
197
+ let items: ReturnType<typeof decodeRlpList>;
198
+ try {
199
+ items = decodeRlpList(headerBytes);
200
+ } catch (error) {
201
+ return {
202
+ failure: {
203
+ reason: "witness-rlp-invalid",
204
+ detail: error instanceof Error ? error.message : String(error),
205
+ },
206
+ };
207
+ }
208
+
209
+ // Step 2: Keccak-256 over the exact decoded header bytes.
210
+ const computedHash = bytesToHex0x(keccak_256(headerBytes));
211
+
212
+ return {
213
+ computedHash,
214
+ items,
215
+ claimedHash: blockHash,
216
+ claimedNumber: BigInt(blockNumber),
217
+ };
218
+ }
219
+
220
+ function malformed(detail: string): DecodedWitness {
221
+ return { failure: { reason: "witness-malformed", detail } };
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Steps 3 through 6 against one candidate anchor
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function runProcedure(
229
+ decoded: DecodedWitness,
230
+ witnessPath: string,
231
+ anchorProofHash: string,
232
+ anchorProof: ObservedProof | undefined,
233
+ anchorValid: boolean
234
+ ): AnchorWitnessOutcome {
235
+ const computedHash = decoded.computedHash as string;
236
+ const base = {
237
+ witnessPath,
238
+ anchorProofHash,
239
+ computedBlockHash: computedHash,
240
+ };
241
+ const fail = (reason: AnomalyCode, detail: string, extra?: { blockNumber?: string }): AnchorWitnessOutcome => ({
242
+ ...base,
243
+ verified: false,
244
+ reason,
245
+ detail,
246
+ ...(extra?.blockNumber !== undefined ? { blockNumber: extra.blockNumber } : {}),
247
+ });
248
+
249
+ // Preconditions: the anchor proof's own cryptographic verification must
250
+ // have succeeded. A witness cannot rescue an invalid proof.
251
+ if (anchorProof === undefined || !anchorValid) {
252
+ return fail(
253
+ "witness-anchor-invalid",
254
+ "the candidate anchor proof is not cryptographically valid; the witness confers nothing"
255
+ );
256
+ }
257
+
258
+ // Step 3: recomputed hash against the SIGNED attribution.message.
259
+ const message = readSignedMessage(anchorProof);
260
+ if (message === undefined || message.toLowerCase() !== computedHash) {
261
+ return fail(
262
+ "witness-hash-mismatch",
263
+ message === undefined
264
+ ? "the anchor carries no signed attribution.message to compare against"
265
+ : "the locally recomputed block hash does not equal the anchor's signed attribution.message"
266
+ );
267
+ }
268
+
269
+ // Step 4: bind the signed message string to the committed artifact.
270
+ // SHA-256 over the exact signed string, no case normalization: the
271
+ // digest covers the string as signed (bitcoin-anchor.ts hashes the
272
+ // block-hash STRING).
273
+ const digest = strictBase64Decode32(readDigestB64(anchorProof));
274
+ if (digest === null) {
275
+ return fail(
276
+ "witness-digest-mismatch",
277
+ "the anchor's artifact.digestB64 does not strictly decode as base64 of 32 bytes"
278
+ );
279
+ }
280
+ const messageHash = sha256(new TextEncoder().encode(message));
281
+ if (!bytesEqual(messageHash, digest)) {
282
+ return fail(
283
+ "witness-digest-mismatch",
284
+ "SHA-256 of the exact signed attribution.message string does not equal the anchor's artifact digest"
285
+ );
286
+ }
287
+
288
+ // Step 5: cross-check the claimed fields.
289
+ const items = decoded.items as ReturnType<typeof decodeRlpList>;
290
+ const numberItem = items[HEADER_NUMBER_INDEX];
291
+ if (numberItem === undefined || !(numberItem instanceof Uint8Array)) {
292
+ return fail(
293
+ "witness-header-shape",
294
+ `header RLP item ${HEADER_NUMBER_INDEX} (block number) is missing or not a byte string`
295
+ );
296
+ }
297
+ const headerNumber = rlpBytesToBigInt(numberItem);
298
+ if (headerNumber !== decoded.claimedNumber) {
299
+ return fail(
300
+ "witness-block-number-mismatch",
301
+ `the witness claims blockNumber ${decoded.claimedNumber}, but the header's number field is ${headerNumber}`,
302
+ { blockNumber: String(headerNumber) }
303
+ );
304
+ }
305
+ const title = readSignedTitle(anchorProof);
306
+ if (title !== undefined) {
307
+ const urlNumber = ETHERSCAN_BLOCK_URL.exec(title)?.[1];
308
+ if (urlNumber !== undefined && BigInt(urlNumber) !== headerNumber) {
309
+ return fail(
310
+ "witness-block-number-mismatch",
311
+ `the anchor's signed Etherscan URL names block ${urlNumber}, but the header's number field is ${headerNumber}`,
312
+ { blockNumber: String(headerNumber) }
313
+ );
314
+ }
315
+ }
316
+ if ((decoded.claimedHash as string).toLowerCase() !== computedHash) {
317
+ return fail(
318
+ "witness-claimed-hash-mismatch",
319
+ "the witness's claimed blockHash does not equal the locally recomputed block hash",
320
+ { blockNumber: String(headerNumber) }
321
+ );
322
+ }
323
+
324
+ // Step 6: only now, read the timestamp.
325
+ const timestampItem = items[HEADER_TIMESTAMP_INDEX];
326
+ if (timestampItem === undefined || !(timestampItem instanceof Uint8Array)) {
327
+ return fail(
328
+ "witness-header-shape",
329
+ `header RLP item ${HEADER_TIMESTAMP_INDEX} (timestamp) is missing or not a byte string`,
330
+ { blockNumber: String(headerNumber) }
331
+ );
332
+ }
333
+ const timestamp = rlpBytesToBigInt(timestampItem);
334
+ if (timestamp > BigInt(Number.MAX_SAFE_INTEGER)) {
335
+ return fail(
336
+ "witness-header-shape",
337
+ "header timestamp does not fit a safe integer",
338
+ { blockNumber: String(headerNumber) }
339
+ );
340
+ }
341
+
342
+ return {
343
+ ...base,
344
+ verified: true,
345
+ blockNumber: String(headerNumber),
346
+ timestamp: Number(timestamp),
347
+ };
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Helpers
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function readSignedMessage(proof: ObservedProof): string | undefined {
355
+ return readAttribution(proof, "message");
356
+ }
357
+
358
+ function readSignedTitle(proof: ObservedProof): string | undefined {
359
+ return readAttribution(proof, "title");
360
+ }
361
+
362
+ function readAttribution(proof: ObservedProof, field: string): string | undefined {
363
+ const attribution = (proof.proof as unknown as Record<string, unknown>)["attribution"];
364
+ if (attribution === null || typeof attribution !== "object" || Array.isArray(attribution)) {
365
+ return undefined;
366
+ }
367
+ const value = (attribution as Record<string, unknown>)[field];
368
+ return typeof value === "string" ? value : undefined;
369
+ }
370
+
371
+ function readDigestB64(proof: ObservedProof): string | undefined {
372
+ const artifact = (proof.proof as unknown as Record<string, unknown>)["artifact"];
373
+ if (artifact === null || typeof artifact !== "object" || Array.isArray(artifact)) {
374
+ return undefined;
375
+ }
376
+ const value = (artifact as Record<string, unknown>)["digestB64"];
377
+ return typeof value === "string" ? value : undefined;
378
+ }
379
+
380
+ /** Strict standard base64 of exactly 32 bytes (spec 6.3 rules), or null. */
381
+ function strictBase64Decode32(value: string | undefined): Uint8Array | null {
382
+ if (value === undefined || value.length === 0) return null;
383
+ const decoded = Buffer.from(value, "base64");
384
+ if (decoded.length !== 32 || decoded.toString("base64") !== value) return null;
385
+ return new Uint8Array(decoded);
386
+ }
387
+
388
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
389
+ if (a.length !== b.length) return false;
390
+ let diff = 0;
391
+ for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number);
392
+ return diff === 0;
393
+ }
394
+
395
+ function toFinding(outcome: AnchorWitnessOutcome): AuditFinding {
396
+ return {
397
+ code: outcome.reason as AnomalyCode,
398
+ path: outcome.witnessPath,
399
+ message: `anchor witness rejected: ${outcome.detail ?? "verification failed"}. The witness confers nothing; the anchor proof's own standing is unchanged.`,
400
+ details: {
401
+ ...(outcome.anchorProofHash !== undefined ? { anchorProofHash: outcome.anchorProofHash } : {}),
402
+ ...(outcome.computedBlockHash !== undefined
403
+ ? { computedBlockHash: outcome.computedBlockHash }
404
+ : {}),
405
+ },
406
+ };
407
+ }