@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.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/anchors.d.ts +31 -0
- package/dist/anchors.d.ts.map +1 -0
- package/dist/anchors.js +116 -0
- package/dist/anchors.js.map +1 -0
- package/dist/anomalies.d.ts +42 -0
- package/dist/anomalies.d.ts.map +1 -0
- package/dist/anomalies.js +642 -0
- package/dist/anomalies.js.map +1 -0
- package/dist/attestation.d.ts +20 -0
- package/dist/attestation.d.ts.map +1 -0
- package/dist/attestation.js +795 -0
- package/dist/attestation.js.map +1 -0
- package/dist/audit.d.ts +19 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +116 -0
- package/dist/audit.js.map +1 -0
- package/dist/authority.d.ts +37 -0
- package/dist/authority.d.ts.map +1 -0
- package/dist/authority.js +126 -0
- package/dist/authority.js.map +1 -0
- package/dist/aws-nitro-root-ca.d.ts +24 -0
- package/dist/aws-nitro-root-ca.d.ts.map +1 -0
- package/dist/aws-nitro-root-ca.js +44 -0
- package/dist/aws-nitro-root-ca.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/contents-hash.d.ts +30 -0
- package/dist/contents-hash.d.ts.map +1 -0
- package/dist/contents-hash.js +73 -0
- package/dist/contents-hash.js.map +1 -0
- package/dist/export.d.ts +64 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +192 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +36 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +828 -0
- package/dist/ingest.js.map +1 -0
- package/dist/reconstruct.d.ts +40 -0
- package/dist/reconstruct.d.ts.map +1 -0
- package/dist/reconstruct.js +400 -0
- package/dist/reconstruct.js.map +1 -0
- package/dist/report-json.d.ts +3 -0
- package/dist/report-json.d.ts.map +1 -0
- package/dist/report-json.js +290 -0
- package/dist/report-json.js.map +1 -0
- package/dist/report-md.d.ts +3 -0
- package/dist/report-md.d.ts.map +1 -0
- package/dist/report-md.js +853 -0
- package/dist/report-md.js.map +1 -0
- package/dist/rlp.d.ts +37 -0
- package/dist/rlp.d.ts.map +1 -0
- package/dist/rlp.js +120 -0
- package/dist/rlp.js.map +1 -0
- package/dist/tar.d.ts +47 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +483 -0
- package/dist/tar.js.map +1 -0
- package/dist/temporal.d.ts +55 -0
- package/dist/temporal.d.ts.map +1 -0
- package/dist/temporal.js +461 -0
- package/dist/temporal.js.map +1 -0
- package/dist/types.d.ts +1389 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validity.d.ts +21 -0
- package/dist/validity.d.ts.map +1 -0
- package/dist/validity.js +86 -0
- package/dist/validity.js.map +1 -0
- package/dist/verify-tiers.d.ts +11 -0
- package/dist/verify-tiers.d.ts.map +1 -0
- package/dist/verify-tiers.js +120 -0
- package/dist/verify-tiers.js.map +1 -0
- package/dist/witness.d.ts +25 -0
- package/dist/witness.d.ts.map +1 -0
- package/dist/witness.js +308 -0
- package/dist/witness.js.map +1 -0
- package/package.json +42 -0
- package/src/anchors.ts +168 -0
- package/src/anomalies.ts +773 -0
- package/src/attestation.ts +878 -0
- package/src/audit.ts +136 -0
- package/src/authority.ts +165 -0
- package/src/aws-nitro-root-ca.ts +47 -0
- package/src/cli.ts +284 -0
- package/src/contents-hash.ts +95 -0
- package/src/export.ts +278 -0
- package/src/index.ts +122 -0
- package/src/ingest.ts +967 -0
- package/src/reconstruct.ts +491 -0
- package/src/report-json.ts +331 -0
- package/src/report-md.ts +1067 -0
- package/src/rlp.ts +142 -0
- package/src/tar.ts +587 -0
- package/src/temporal.ts +629 -0
- package/src/types.ts +1528 -0
- package/src/validity.ts +90 -0
- package/src/verify-tiers.ts +137 -0
- 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
|
+
}
|
package/src/authority.ts
ADDED
|
@@ -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
|
+
);
|