@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
package/src/audit.ts ADDED
@@ -0,0 +1,136 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * Audit orchestrator: the full offline pipeline in canonical order.
5
+ *
6
+ * ingest -> verify tiers -> reconstruct -> classify anomalies ->
7
+ * analyze authorities -> identify anchors -> verify witnesses ->
8
+ * derive temporal bounds -> validate attestations
9
+ *
10
+ * Deterministic given the same bundle: the only wall-clock read in the
11
+ * entire pipeline is the runMetadata.startedAt stamp taken here. Every
12
+ * other value in the AuditResult, and in both reports built from it, is a
13
+ * pure function of the bundle contents and the tool version. Certificate
14
+ * validity windows during attestation validation are evaluated at each
15
+ * attestation document's OWN timestamp, never the clock.
16
+ *
17
+ * Zero network access, as everywhere in this package.
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { ingestBundle } from "./ingest.js";
22
+ import { verifyObservedProofs } from "./verify-tiers.js";
23
+ import { reconstructChains } from "./reconstruct.js";
24
+ import { classifyAnomalies } from "./anomalies.js";
25
+ import { analyzeAuthorities } from "./authority.js";
26
+ import { identifyAnchors } from "./anchors.js";
27
+ import { verifyAnchorWitnesses } from "./witness.js";
28
+ import { deriveTemporalBounds } from "./temporal.js";
29
+ import { validateAttestations } from "./attestation.js";
30
+ import type { AuditOptions, AuditResult, ExitFlags } from "./types.js";
31
+
32
+ let cachedToolVersion: string | undefined;
33
+
34
+ /** The audit package's own version, read once from its package.json. */
35
+ export function auditToolVersion(): string {
36
+ if (cachedToolVersion === undefined) {
37
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
38
+ cachedToolVersion = (JSON.parse(raw) as { version: string }).version;
39
+ }
40
+ return cachedToolVersion;
41
+ }
42
+
43
+ /**
44
+ * Run the complete audit pipeline over a bundle (directory, .tar, .tar.gz,
45
+ * or .tgz) and return everything every stage produced.
46
+ */
47
+ export async function runAudit(bundlePath: string, options?: AuditOptions): Promise<AuditResult> {
48
+ // The ONLY wall-clock read in the pipeline. See AuditRunMetadata.
49
+ const startedAt = new Date().toISOString();
50
+
51
+ const ingest = await ingestBundle(bundlePath);
52
+ const verification = await verifyObservedProofs(
53
+ ingest,
54
+ options?.trustAnchors !== undefined ? { trustAnchors: options.trustAnchors } : undefined
55
+ );
56
+ const reconstruction = await reconstructChains(ingest);
57
+ const anomalies = await classifyAnomalies(ingest, reconstruction);
58
+ const authorities = analyzeAuthorities(ingest);
59
+ const anchors = identifyAnchors(ingest);
60
+ const witnesses = await verifyAnchorWitnesses(ingest, anchors);
61
+ // Populates EpochRecord.anchorBounds on the reconstruction result.
62
+ const temporal = deriveTemporalBounds(ingest, reconstruction, anchors, witnesses);
63
+ const attestations = await validateAttestations(
64
+ ingest,
65
+ authorities,
66
+ options?.trustedRootCaDer !== undefined
67
+ ? { trustedRootCaDer: options.trustedRootCaDer }
68
+ : undefined
69
+ );
70
+
71
+ return {
72
+ runMetadata: {
73
+ toolVersion: auditToolVersion(),
74
+ startedAt,
75
+ bundlePath,
76
+ container: ingest.container,
77
+ },
78
+ ingest,
79
+ verification,
80
+ reconstruction,
81
+ anomalies,
82
+ authorities,
83
+ anchors,
84
+ witnesses,
85
+ temporal,
86
+ attestations,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Anchor witness verification-failure codes: every finding the witness
92
+ * stage emits is a failure (verifyAnchorWitnesses records findings only for
93
+ * unverified outcomes), so any witness finding is a genuine problem with
94
+ * supplied evidence and sets bit 2. The three anchor-stage findings
95
+ * (anchor-metadata-disagreement, anchor-metadata-only-claim,
96
+ * anchor-title-unparseable) are informational: the signed body governs and
97
+ * none is a verification failure, so they never set an exit bit.
98
+ */
99
+ const WITNESS_VERIFICATION_FAILURE_CODES: ReadonlySet<string> = new Set([
100
+ "witness-malformed",
101
+ "witness-rlp-invalid",
102
+ "witness-header-shape",
103
+ "witness-hash-mismatch",
104
+ "witness-digest-mismatch",
105
+ "witness-block-number-mismatch",
106
+ "witness-claimed-hash-mismatch",
107
+ "witness-anchor-invalid",
108
+ "witness-unmatched",
109
+ ]);
110
+
111
+ /**
112
+ * Derive the CLI exit bit flags from an audit result. Semantics are
113
+ * documented on the ExitFlags type: bit 1 is verification failures
114
+ * (including unsupported-version rejections), bit 2 is chain or authority
115
+ * anomalies, divergences between valid proofs, or anchor witness
116
+ * verification failures. artifact-unavailable is never a failure by itself;
117
+ * attestation results and informational anchor findings never set bits;
118
+ * benign ingest findings never set bits.
119
+ */
120
+ export function computeExitFlags(result: AuditResult): ExitFlags {
121
+ const verificationFailures =
122
+ result.verification.failed > 0 || result.ingest.counts.unsupportedVersion > 0;
123
+ const witnessVerificationFailures = result.witnesses.findings.some((f) =>
124
+ WITNESS_VERIFICATION_FAILURE_CODES.has(f.code)
125
+ );
126
+ const chainAnomaliesOrDivergences =
127
+ result.anomalies.anomalies.length > 0 ||
128
+ result.anomalies.divergences.length > 0 ||
129
+ result.authorities.anomalies.length > 0 ||
130
+ witnessVerificationFailures;
131
+ return {
132
+ verificationFailures,
133
+ chainAnomaliesOrDivergences,
134
+ code: (verificationFailures ? 1 : 0) | (chainAnomaliesOrDivergences ? 2 : 0),
135
+ };
136
+ }
@@ -0,0 +1,165 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit authority analysis
5
+ *
6
+ * Groups observed proofs by the authority facets of the record: declared
7
+ * measurement, signer key, epochId, chainId, and attestation presence.
8
+ * Flags intra-epoch changes that a single healthy enclave boot cannot
9
+ * produce (one boot means one keypair and one measurement per epoch):
10
+ *
11
+ * - mid-epoch-signer-change: two or more distinct signer keys within
12
+ * one epochId.
13
+ * - mid-epoch-measurement-change: two or more distinct declared
14
+ * measurements within one epochId.
15
+ *
16
+ * The same signer key appearing across different epochs is NORMAL epoch
17
+ * transition evidence and is surfaced without an anomaly.
18
+ *
19
+ * Measurement honesty: everything here reads DECLARED values, which are
20
+ * self-reported fields inside the signed body (environment.measurement).
21
+ * A declared measurement is never conflated with an attested measurement:
22
+ * attestation validation belongs to a later stage, which populates the
23
+ * typed AuthorityGroup.attested extension point after cryptographically
24
+ * validating attestation documents offline. attestationPresent records
25
+ * only that a document exists; presence alone proves nothing.
26
+ *
27
+ * Dimension discipline: grouping and flags cover all observed proofs
28
+ * regardless of verification outcome. An authority anomaly never changes
29
+ * a proof's verification status; per-proof statuses live on the observed
30
+ * proofs themselves.
31
+ */
32
+
33
+ import type {
34
+ AuthorityAnalysis,
35
+ AuthorityGroup,
36
+ ChainAnomaly,
37
+ IngestResult,
38
+ ObservedProof,
39
+ SignerEpochSpan,
40
+ } from "./types.js";
41
+ import { pushMap } from "./validity.js";
42
+
43
+ /**
44
+ * Analyze authority structure over every observed proof (including
45
+ * chainless proofs, which group with epochId absent). Read-only and
46
+ * deterministic.
47
+ */
48
+ export function analyzeAuthorities(ingest: IngestResult): AuthorityAnalysis {
49
+ // -----------------------------------------------------------------------
50
+ // Grouping.
51
+ // -----------------------------------------------------------------------
52
+ const groupsByKey = new Map<string, AuthorityGroup>();
53
+ for (const proof of ingest.proofs) {
54
+ const mapKey = JSON.stringify([
55
+ proof.measurement ?? null,
56
+ proof.publicKeyB64 ?? null,
57
+ proof.epochId ?? null,
58
+ proof.chainId,
59
+ proof.hasAttestation,
60
+ ]);
61
+ const existing = groupsByKey.get(mapKey);
62
+ if (existing !== undefined) {
63
+ existing.proofHashes.push(proof.proofHash);
64
+ continue;
65
+ }
66
+ groupsByKey.set(mapKey, {
67
+ ...(proof.measurement !== undefined ? { measurement: proof.measurement } : {}),
68
+ ...(proof.publicKeyB64 !== undefined ? { publicKeyB64: proof.publicKeyB64 } : {}),
69
+ ...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
70
+ chainId: proof.chainId,
71
+ attestationPresent: proof.hasAttestation,
72
+ proofHashes: [proof.proofHash],
73
+ // attested is deliberately left undefined: the attestation
74
+ // validation stage (Phase 4c) fills it. Declared measurement above
75
+ // is self-reported and never treated as attested.
76
+ });
77
+ }
78
+ const groups = [...groupsByKey.values()].sort(compareGroups);
79
+
80
+ // -----------------------------------------------------------------------
81
+ // Intra-epoch flags. epochId is boot-scoped: one enclave boot generates
82
+ // one keypair and one measurement, shared across every chain it serves,
83
+ // so the flags aggregate per epochId across chains.
84
+ // -----------------------------------------------------------------------
85
+ const byEpoch = new Map<string, ObservedProof[]>();
86
+ for (const proof of ingest.proofs) {
87
+ if (proof.epochId === undefined) continue;
88
+ pushMap(byEpoch, proof.epochId, proof);
89
+ }
90
+
91
+ const anomalies: ChainAnomaly[] = [];
92
+ for (const [epochId, proofs] of [...byEpoch.entries()].sort(([a], [b]) =>
93
+ a < b ? -1 : a > b ? 1 : 0
94
+ )) {
95
+ const keys = new Set<string>();
96
+ const measurements = new Set<string>();
97
+ for (const proof of proofs) {
98
+ if (proof.publicKeyB64 !== undefined) keys.add(proof.publicKeyB64);
99
+ if (proof.measurement !== undefined) measurements.add(proof.measurement);
100
+ }
101
+
102
+ if (keys.size >= 2) {
103
+ anomalies.push({
104
+ code: "mid-epoch-signer-change",
105
+ proofHashes: proofs.map((p) => p.proofHash),
106
+ message:
107
+ `${keys.size} distinct signer keys appear within epoch ${epochId}. A single enclave boot ` +
108
+ `generates exactly one keypair per epoch, so multiple keys under one epochId cannot come ` +
109
+ `from one healthy authority lifecycle. All key groups are preserved for adjudication.`,
110
+ details: {
111
+ epochId,
112
+ publicKeysB64: [...keys].sort(),
113
+ },
114
+ });
115
+ }
116
+
117
+ if (measurements.size >= 2) {
118
+ anomalies.push({
119
+ code: "mid-epoch-measurement-change",
120
+ proofHashes: proofs.map((p) => p.proofHash),
121
+ message:
122
+ `${measurements.size} distinct declared measurements appear within epoch ${epochId}. A ` +
123
+ `single enclave boot has exactly one measurement, so multiple declared measurements under ` +
124
+ `one epochId cannot come from one healthy authority lifecycle. Declared measurements are ` +
125
+ `self-reported; attestation validation is reported separately.`,
126
+ details: {
127
+ epochId,
128
+ measurements: [...measurements].sort(),
129
+ },
130
+ });
131
+ }
132
+ }
133
+
134
+ // -----------------------------------------------------------------------
135
+ // Cross-epoch same-signer spans: normal transition evidence.
136
+ // -----------------------------------------------------------------------
137
+ const epochsByKey = new Map<string, Set<string>>();
138
+ for (const proof of ingest.proofs) {
139
+ if (proof.publicKeyB64 === undefined || proof.epochId === undefined) continue;
140
+ const set = epochsByKey.get(proof.publicKeyB64);
141
+ if (set === undefined) epochsByKey.set(proof.publicKeyB64, new Set([proof.epochId]));
142
+ else set.add(proof.epochId);
143
+ }
144
+ const sharedSignersAcrossEpochs: SignerEpochSpan[] = [...epochsByKey.entries()]
145
+ .filter(([, epochs]) => epochs.size >= 2)
146
+ .map(([publicKeyB64, epochs]) => ({ publicKeyB64, epochIds: [...epochs].sort() }))
147
+ .sort((a, b) => (a.publicKeyB64 < b.publicKeyB64 ? -1 : a.publicKeyB64 > b.publicKeyB64 ? 1 : 0));
148
+
149
+ return { groups, anomalies, sharedSignersAcrossEpochs };
150
+ }
151
+
152
+ function compareGroups(a: AuthorityGroup, b: AuthorityGroup): number {
153
+ if (a.chainId !== b.chainId) return a.chainId < b.chainId ? -1 : 1;
154
+ const ea = a.epochId ?? "";
155
+ const eb = b.epochId ?? "";
156
+ if (ea !== eb) return ea < eb ? -1 : 1;
157
+ const ka = a.publicKeyB64 ?? "";
158
+ const kb = b.publicKeyB64 ?? "";
159
+ if (ka !== kb) return ka < kb ? -1 : 1;
160
+ const ma = a.measurement ?? "";
161
+ const mb = b.measurement ?? "";
162
+ if (ma !== mb) return ma < mb ? -1 : 1;
163
+ if (a.attestationPresent !== b.attestationPresent) return a.attestationPresent ? -1 : 1;
164
+ return 0;
165
+ }
@@ -0,0 +1,47 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * AWS Nitro Enclaves Root CA G1
5
+ *
6
+ * Source: https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip
7
+ * Subject: CN=aws.nitro-enclaves, O=Amazon, OU=AWS, C=US
8
+ * Validity: 2019-10-28 to 2049-10-28
9
+ * SHA-256 fingerprint (DER):
10
+ * 641a0321a3e244efe456463195d606317ed7cdcc3c1756e09893f3c68f79bb5b
11
+ *
12
+ * This is the trust anchor for all AWS Nitro Enclave attestations. It is
13
+ * stable, well-known, and never changes, so it is safe to embed. The PEM
14
+ * string below is byte-for-byte the same constant the website validator
15
+ * embeds (website/src/lib/aws-nitro-root-ca.ts); the fingerprint above
16
+ * matches the value AWS publishes for the Root G1 certificate.
17
+ *
18
+ * No network access: this constant exists precisely so the audit tool
19
+ * never fetches trust material at runtime.
20
+ */
21
+
22
+ export const AWS_NITRO_ROOT_CA_PEM = `-----BEGIN CERTIFICATE-----
23
+ MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL
24
+ MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD
25
+ VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4
26
+ MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL
27
+ DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG
28
+ BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb
29
+ 48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE
30
+ h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF
31
+ R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC
32
+ MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW
33
+ rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N
34
+ IwLz3/Y=
35
+ -----END CERTIFICATE-----`;
36
+
37
+ /** Strip PEM headers/footers and whitespace, return base64 DER. */
38
+ export function rootCaDerB64(): string {
39
+ return AWS_NITRO_ROOT_CA_PEM.replace(/-----BEGIN CERTIFICATE-----/, "")
40
+ .replace(/-----END CERTIFICATE-----/, "")
41
+ .replace(/\s+/g, "");
42
+ }
43
+
44
+ /** Decode the root CA PEM to raw DER bytes. */
45
+ export function rootCaDerBytes(): Uint8Array {
46
+ return new Uint8Array(Buffer.from(rootCaDerB64(), "base64"));
47
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
3
+
4
+ /**
5
+ * bitgraph-audit CLI: run the full offline audit pipeline over a bundle
6
+ * and write audit-report.json and/or audit-report.md.
7
+ *
8
+ * Report content goes to files only; stdout carries a short completion
9
+ * line naming the written files and the exit meaning. The CLI performs
10
+ * no network access. Arguments are parsed with plain process.argv
11
+ * handling, no dependency.
12
+ *
13
+ * Exit codes are bit flags (documented in --help and on the ExitFlags
14
+ * type): 0 clean, 1 verification failures (including unsupported-version
15
+ * rejections), 2 chain or authority anomalies, divergences between valid
16
+ * proofs, or anchor witness verification failures, 3 both, 64 usage or
17
+ * input error (no report was produced).
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { mkdir, writeFile } from "node:fs/promises";
22
+ import { join } from "node:path";
23
+ import type { VerificationPolicy } from "@mikeargento/bitgraph-verify";
24
+ import { auditToolVersion, computeExitFlags, runAudit } from "./audit.js";
25
+ import { buildJsonReport } from "./report-json.js";
26
+ import { buildMarkdownReport } from "./report-md.js";
27
+ import type { ExitFlags } from "./types.js";
28
+
29
+ const USAGE_EXIT_CODE = 64;
30
+
31
+ /** The 14 fields of the canonical VerificationPolicy (verify package, G7). */
32
+ const KNOWN_POLICY_FIELDS: readonly string[] = [
33
+ "requireEnforcement",
34
+ "allowedMeasurements",
35
+ "allowedPublicKeys",
36
+ "requireAttestation",
37
+ "requireAttestationFormat",
38
+ "minCounter",
39
+ "maxCounter",
40
+ "minTime",
41
+ "maxTime",
42
+ "requireEpochId",
43
+ "requireActor",
44
+ "allowedActorKeyIds",
45
+ "allowedActorProviders",
46
+ "requireSlot",
47
+ ];
48
+
49
+ class UsageError extends Error {}
50
+
51
+ const USAGE_LINE =
52
+ "Usage: bitgraph-audit <path-to-bundle> [--out <dir>] [--format json,md] [--trust-policy <path>]";
53
+
54
+ function helpText(): string {
55
+ return [
56
+ `bitgraph-audit ${auditToolVersion()}: offline audit of BitGraph proof bundles.`,
57
+ "",
58
+ USAGE_LINE,
59
+ "",
60
+ "The bundle may be a directory, a .tar archive, or a .tar.gz/.tgz",
61
+ "archive. The audit runs entirely offline: no RPC, no HTTP, no DNS.",
62
+ "",
63
+ "Options:",
64
+ " --out <dir> Directory to write the report files into",
65
+ " (default: current directory; created if missing).",
66
+ " --format <list> Comma-separated formats to write: json, md",
67
+ " (default: json,md). json writes audit-report.json;",
68
+ " md writes audit-report.md.",
69
+ " --trust-policy <path> JSON file parsed as the canonical",
70
+ " VerificationPolicy and applied at both",
71
+ " verification tiers. Valid fields:",
72
+ ` ${KNOWN_POLICY_FIELDS.join(", ")}.`,
73
+ " Any other field is an error.",
74
+ " --help, -h Print this help and exit 0.",
75
+ "",
76
+ "Exit codes (bit flags):",
77
+ " 0 Clean: no verification failures, no chain anomalies, no",
78
+ " divergences.",
79
+ " 1 Verification failures: at least one proof failed its canonical",
80
+ " cryptographic checks at either tier, or at least one",
81
+ " proof-shaped input was rejected as an unsupported version",
82
+ " (only bitgraph/1 is supported). A proof whose artifact bytes",
83
+ " are absent from the bundle is NOT a failure by itself: its",
84
+ " bytes-free checks decide, unless a supplied trust policy makes",
85
+ " them fail (for example requireSlot), in which case it counts",
86
+ " here.",
87
+ " 2 Chain or authority anomalies, divergences between valid proofs,",
88
+ " or anchor witness verification failures: unexplained counter",
89
+ " positions, chain breaks, collisions, cross-kind position reuse,",
90
+ " forks, authority changes inside an epoch, epoch link anomalies,",
91
+ " or a supplied anchor witness that fails its offline verification",
92
+ " (block-hash mismatch, digest binding, block number, header or",
93
+ " RLP malformation, an invalid candidate anchor, or an unmatched",
94
+ " witness). Benign findings are reported but never set exit bits:",
95
+ " duplicate copies, manifest advisories, unsafe paths, embedded",
96
+ " proofHash mismatches, and informational anchor findings (unsigned",
97
+ " metadata disagreements, metadata-only anchor claims, and",
98
+ " unparseable anchor titles, all overridden by the signed body).",
99
+ " 3 Both 1 and 2.",
100
+ " 64 Usage or input error: unknown option, unreadable bundle, or",
101
+ " invalid trust policy. No report was produced.",
102
+ "",
103
+ "Attestation validation results are always reported in full but never",
104
+ "change the exit code on their own: an invalid attestation document on",
105
+ "an otherwise verified proof is reported without affecting the exit",
106
+ "code, and counts under exit bit 1 only when a supplied trust policy",
107
+ "made verification itself fail.",
108
+ ].join("\n");
109
+ }
110
+
111
+ interface ParsedArgs {
112
+ bundlePath: string;
113
+ outDir: string;
114
+ formats: ReadonlySet<"json" | "md">;
115
+ trustPolicyPath?: string;
116
+ }
117
+
118
+ function parseArgs(argv: string[]): ParsedArgs | "help" {
119
+ let bundlePath: string | undefined;
120
+ let outDir = ".";
121
+ let formats: Set<"json" | "md"> = new Set(["json", "md"]);
122
+ let trustPolicyPath: string | undefined;
123
+
124
+ const takeValue = (flag: string, index: number): string => {
125
+ const value = argv[index + 1];
126
+ if (value === undefined) throw new UsageError(`${flag} requires a value.`);
127
+ return value;
128
+ };
129
+
130
+ for (let i = 0; i < argv.length; i++) {
131
+ const arg = argv[i] as string;
132
+ if (arg === "--help" || arg === "-h") return "help";
133
+ if (arg === "--out") {
134
+ outDir = takeValue(arg, i);
135
+ i++;
136
+ } else if (arg === "--format") {
137
+ const raw = takeValue(arg, i);
138
+ i++;
139
+ const parts = raw
140
+ .split(",")
141
+ .map((p) => p.trim())
142
+ .filter((p) => p.length > 0);
143
+ if (parts.length === 0) throw new UsageError("--format requires at least one of: json, md.");
144
+ formats = new Set();
145
+ for (const part of parts) {
146
+ if (part !== "json" && part !== "md") {
147
+ throw new UsageError(`unknown format "${part}". Valid formats: json, md.`);
148
+ }
149
+ formats.add(part);
150
+ }
151
+ } else if (arg === "--trust-policy") {
152
+ trustPolicyPath = takeValue(arg, i);
153
+ i++;
154
+ } else if (arg.startsWith("-")) {
155
+ throw new UsageError(`unknown option "${arg}".`);
156
+ } else if (bundlePath === undefined) {
157
+ bundlePath = arg;
158
+ } else {
159
+ throw new UsageError("more than one bundle path was given.");
160
+ }
161
+ }
162
+
163
+ if (bundlePath === undefined) throw new UsageError("missing bundle path.");
164
+ return {
165
+ bundlePath,
166
+ outDir,
167
+ formats,
168
+ ...(trustPolicyPath !== undefined ? { trustPolicyPath } : {}),
169
+ };
170
+ }
171
+
172
+ function loadTrustPolicy(path: string): VerificationPolicy {
173
+ let raw: string;
174
+ try {
175
+ raw = readFileSync(path, "utf8");
176
+ } catch (err) {
177
+ throw new UsageError(
178
+ `cannot read trust policy file "${path}": ${err instanceof Error ? err.message : String(err)}`
179
+ );
180
+ }
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = JSON.parse(raw);
184
+ } catch {
185
+ throw new UsageError(`trust policy file "${path}" is not valid JSON.`);
186
+ }
187
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
188
+ throw new UsageError("trust policy must be a JSON object.");
189
+ }
190
+ const unknown = Object.keys(parsed).filter((key) => !KNOWN_POLICY_FIELDS.includes(key));
191
+ if (unknown.length > 0) {
192
+ throw new UsageError(
193
+ `unknown trust policy ${unknown.length === 1 ? "field" : "fields"}: ` +
194
+ `${unknown.join(", ")}. Valid fields: ${KNOWN_POLICY_FIELDS.join(", ")}.`
195
+ );
196
+ }
197
+ return parsed as VerificationPolicy;
198
+ }
199
+
200
+ function exitMeaning(flags: ExitFlags): string {
201
+ if (flags.code === 0) return "clean: no verification failures, no chain anomalies, no divergences";
202
+ const parts: string[] = [];
203
+ if (flags.verificationFailures) parts.push("verification failures");
204
+ if (flags.chainAnomaliesOrDivergences) {
205
+ parts.push("chain anomalies, divergences, or anchor witness verification failures");
206
+ }
207
+ return parts.join("; ");
208
+ }
209
+
210
+ async function main(): Promise<number> {
211
+ let parsed: ParsedArgs | "help";
212
+ try {
213
+ parsed = parseArgs(process.argv.slice(2));
214
+ } catch (err) {
215
+ if (err instanceof UsageError) {
216
+ process.stderr.write(`bitgraph-audit: ${err.message}\n${USAGE_LINE}\nSee --help for details.\n`);
217
+ return USAGE_EXIT_CODE;
218
+ }
219
+ throw err;
220
+ }
221
+ if (parsed === "help") {
222
+ process.stdout.write(helpText() + "\n");
223
+ return 0;
224
+ }
225
+
226
+ let trustAnchors: VerificationPolicy | undefined;
227
+ try {
228
+ if (parsed.trustPolicyPath !== undefined) {
229
+ trustAnchors = loadTrustPolicy(parsed.trustPolicyPath);
230
+ }
231
+ } catch (err) {
232
+ if (err instanceof UsageError) {
233
+ process.stderr.write(`bitgraph-audit: ${err.message}\n${USAGE_LINE}\nSee --help for details.\n`);
234
+ return USAGE_EXIT_CODE;
235
+ }
236
+ throw err;
237
+ }
238
+
239
+ let result;
240
+ try {
241
+ result = await runAudit(
242
+ parsed.bundlePath,
243
+ trustAnchors !== undefined ? { trustAnchors } : undefined
244
+ );
245
+ } catch (err) {
246
+ process.stderr.write(
247
+ `bitgraph-audit: cannot audit "${parsed.bundlePath}": ` +
248
+ `${err instanceof Error ? err.message : String(err)}\n`
249
+ );
250
+ return USAGE_EXIT_CODE;
251
+ }
252
+
253
+ await mkdir(parsed.outDir, { recursive: true });
254
+ const written: string[] = [];
255
+ if (parsed.formats.has("json")) {
256
+ const path = join(parsed.outDir, "audit-report.json");
257
+ await writeFile(path, JSON.stringify(buildJsonReport(result), null, 2) + "\n", "utf8");
258
+ written.push(path);
259
+ }
260
+ if (parsed.formats.has("md")) {
261
+ const path = join(parsed.outDir, "audit-report.md");
262
+ await writeFile(path, buildMarkdownReport(result), "utf8");
263
+ written.push(path);
264
+ }
265
+
266
+ const flags = computeExitFlags(result);
267
+ process.stdout.write(
268
+ `bitgraph-audit ${auditToolVersion()}: wrote ${written.join(", ")}\n` +
269
+ `exit ${flags.code}: ${exitMeaning(flags)}\n`
270
+ );
271
+ return flags.code;
272
+ }
273
+
274
+ main().then(
275
+ (code) => {
276
+ process.exitCode = code;
277
+ },
278
+ (err) => {
279
+ process.stderr.write(
280
+ `bitgraph-audit: unexpected error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
281
+ );
282
+ process.exitCode = USAGE_EXIT_CODE;
283
+ }
284
+ );