@counterfact-labs/cli 0.1.3 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -42
- package/bundle/client-release.json +7 -0
- package/bundle/sealed.json +908 -0
- package/dist/adapters/anthropic.js +151 -0
- package/dist/adapters/completion.js +112 -0
- package/dist/adapters/durable-write.js +34 -0
- package/dist/adapters/files.js +140 -0
- package/dist/adapters/folder-path.js +6 -0
- package/dist/adapters/git-ignore.js +49 -0
- package/dist/adapters/git.js +44 -0
- package/dist/adapters/ignored-files.js +36 -0
- package/dist/adapters/instructions.js +35 -0
- package/dist/adapters/models.js +51 -0
- package/dist/adapters/output-paths.js +92 -0
- package/dist/adapters/prompt-cache.js +28 -0
- package/dist/adapters/public-instructions.js +36 -0
- package/dist/adapters/quality.js +6 -0
- package/dist/adapters/relay-plan.js +15 -0
- package/dist/adapters/release.js +57 -0
- package/dist/adapters/report.js +29 -0
- package/dist/adapters/repository-files.js +199 -0
- package/dist/adapters/repository-root.js +120 -0
- package/dist/adapters/results.js +9 -0
- package/dist/adapters/settings.js +25 -0
- package/dist/adapters/snapshot.js +68 -0
- package/dist/analysis-core/analysis/analysis-flags.js +1 -0
- package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -0
- package/dist/analysis-core/analysis/finding-quality/config.js +1 -0
- package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
- package/dist/analysis-core/analysis/headlines/attachment.js +1 -0
- package/dist/analysis-core/analysis/headlines/input.js +1 -0
- package/dist/analysis-core/analysis/headlines/types.js +1 -0
- package/dist/analysis-core/analysis/schemas.js +1 -0
- package/dist/analysis-core/analysis/source-notes/types.js +1 -0
- package/dist/analysis-core/chunks/2PSNOSJ2.js +1 -0
- package/dist/analysis-core/chunks/3227KXHF.js +1 -0
- package/dist/analysis-core/chunks/326F4XNG.js +1 -0
- package/dist/analysis-core/chunks/3MTXB4MK.js +6 -0
- package/dist/analysis-core/chunks/4F6ZIHXU.js +1 -0
- package/dist/analysis-core/chunks/57M4OQJM.js +1 -0
- package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
- package/dist/analysis-core/chunks/724WZMYW.js +2 -0
- package/dist/analysis-core/chunks/74FPPPIG.js +1 -0
- package/dist/analysis-core/chunks/7QDOHWSY.js +27 -0
- package/dist/analysis-core/chunks/BZNHHLNP.js +1 -0
- package/dist/analysis-core/chunks/DTMHXKP6.js +7 -0
- package/dist/analysis-core/chunks/E7TFIHC5.js +1 -0
- package/dist/analysis-core/chunks/EH3QQ3T4.js +1 -0
- package/dist/analysis-core/chunks/EQTIRIMU.js +37 -0
- package/dist/analysis-core/chunks/FSLWD2PE.js +1 -0
- package/dist/analysis-core/chunks/G322LST6.js +1 -0
- package/dist/analysis-core/chunks/HTLIRVP7.js +1 -0
- package/dist/analysis-core/chunks/IBCFGUHI.js +1 -0
- package/dist/analysis-core/chunks/IL2KRP3T.js +1 -0
- package/dist/analysis-core/chunks/J4AXGE5W.js +1 -0
- package/dist/analysis-core/chunks/JF4GYMOZ.js +1 -0
- package/dist/analysis-core/chunks/KEKTZDIK.js +1 -0
- package/dist/analysis-core/chunks/KU5DINLW.js +1 -0
- package/dist/analysis-core/chunks/M7MMAUAK.js +1 -0
- package/dist/analysis-core/chunks/OFBJNHC6.js +1 -0
- package/dist/analysis-core/chunks/ORCYIIHB.js +60 -0
- package/dist/analysis-core/chunks/PVPEY7NF.js +1 -0
- package/dist/analysis-core/chunks/PYKAERZZ.js +1 -0
- package/dist/analysis-core/chunks/QDYMR6KS.js +1 -0
- package/dist/analysis-core/chunks/QPXNXMLC.js +1 -0
- package/dist/analysis-core/chunks/SL27GMEN.js +1 -0
- package/dist/analysis-core/chunks/STZOC7LN.js +1 -0
- package/dist/analysis-core/chunks/T5TREZCQ.js +1 -0
- package/dist/analysis-core/chunks/TDNTU4TF.js +1 -0
- package/dist/analysis-core/chunks/U5HOEVPE.js +1 -0
- package/dist/analysis-core/chunks/UEWL3GMQ.js +1 -0
- package/dist/analysis-core/chunks/VER355VN.js +1 -0
- package/dist/analysis-core/chunks/VFENG4MR.js +1 -0
- package/dist/analysis-core/chunks/VGYWI4X2.js +1 -0
- package/dist/analysis-core/chunks/Y5AUKKYG.js +1 -0
- package/dist/analysis-core/chunks/Y7JGSUOA.js +1 -0
- package/dist/analysis-core/chunks/YVDCJN7D.js +14 -0
- package/dist/analysis-core/chunks/ZAA44GPI.js +1 -0
- package/dist/analysis-core/chunks/ZMUBMYWN.js +1 -0
- package/dist/analysis-core/contracts/requests.js +1 -0
- package/dist/analysis-core/conventions/file-admission.js +1 -0
- package/dist/analysis-core/data/finding-types.js +1 -0
- package/dist/analysis-core/errors.js +1 -0
- package/dist/analysis-core/findings/presentation.js +1 -0
- package/dist/analysis-core/github/repo-key.js +1 -0
- package/dist/analysis-core/import/outside-loader-sources.js +1 -0
- package/dist/analysis-core/index.js +6 -0
- package/dist/analysis-core/llm/cache-policy.js +1 -0
- package/dist/analysis-core/llm/cost.js +1 -0
- package/dist/analysis-core/llm/execution.js +1 -0
- package/dist/analysis-core/llm/output-limit.js +1 -0
- package/dist/analysis-core/llm/slots.js +1 -0
- package/dist/analysis-core/llm/stable-json.js +1 -0
- package/dist/analysis-core/llm/structured-output-error.js +1 -0
- package/dist/analysis-core/map-limit.js +1 -0
- package/dist/analysis-core/requests/answer-formats.js +1 -0
- package/dist/analysis-core/requests/check-diagnostics.js +1 -0
- package/dist/analysis-core/requests/headline-request.js +1 -0
- package/dist/analysis-core/requests/instruction-text.js +1 -0
- package/dist/analysis-core/requests/local-plan.js +1 -0
- package/dist/analysis-core/requests/registry.js +1 -0
- package/dist/analysis-core/requests/schema-descriptions.js +1 -0
- package/dist/analysis-core/requests/shape-finding-input.js +1 -0
- package/dist/analysis-core/requests/template-version.js +1 -0
- package/dist/analysis-core/requests/tools.js +1 -0
- package/dist/analysis-core/settings.js +1 -0
- package/dist/bundle/seal/deserialise.js +75 -0
- package/dist/bundle/seal/encryption.js +25 -0
- package/dist/bundle/seal/metadata.js +69 -0
- package/dist/bundle/seal/open.js +38 -0
- package/dist/bundle/seal/planned-calls.js +20 -0
- package/dist/bundle/seal/signature.js +51 -0
- package/dist/cli/agent-folder.js +17 -0
- package/dist/cli/api-types.js +2 -0
- package/dist/cli/api.js +1 -0
- package/dist/{claude-code-permission.js → cli/claude-code-permission.js} +4 -3
- package/dist/cli/commands/analyze.js +73 -0
- package/dist/cli/commands/login.js +51 -0
- package/dist/cli/completion-recovery.js +18 -0
- package/dist/{context.js → cli/context.js} +8 -14
- package/dist/cli/email-sign-in.js +56 -0
- package/dist/{git.js → cli/git.js} +12 -8
- package/dist/cli/intake-payload.js +18 -0
- package/dist/{main.js → cli/main.js} +23 -12
- package/dist/{mcp.js → cli/mcp.js} +109 -40
- package/dist/cli/output.js +10 -0
- package/dist/cli/relay-check.js +239 -0
- package/dist/cli/relay-preparation.js +31 -0
- package/dist/cli/sign-in-input.js +97 -0
- package/dist/{sign-in.js → cli/sign-in.js} +2 -1
- package/dist/cli/suggested-email.js +10 -0
- package/dist/cli/summary.js +30 -0
- package/dist/contract/cli-api-messages.js +5 -0
- package/dist/contract/cli-refusals.js +22 -0
- package/dist/contract/cli-version.js +1 -0
- package/dist/contract/console-url.js +8 -0
- package/dist/contract/console.js +111 -0
- package/dist/contract/email-sign-in-messages.js +18 -0
- package/dist/contract/messages.js +252 -0
- package/dist/contract/release-key.js +2 -0
- package/dist/contract/runtime-environment.js +37 -0
- package/dist/core/analysis/finding-quality/config.js +4 -0
- package/dist/core/analysis/run-to-end.js +55 -0
- package/dist/core/analysis/structural-run.js +16 -0
- package/dist/gatekeeper/attestation.js +67 -0
- package/dist/gatekeeper/certificate-algorithms.js +18 -0
- package/dist/gatekeeper/certificate-path.js +99 -0
- package/dist/gatekeeper/cli-api.js +174 -0
- package/dist/gatekeeper/client.js +76 -0
- package/dist/gatekeeper/connection-exchange.js +60 -0
- package/dist/gatekeeper/connection.js +140 -0
- package/dist/gatekeeper/console.js +168 -0
- package/dist/gatekeeper/loopback.js +5 -0
- package/dist/gatekeeper/model.js +70 -0
- package/dist/gatekeeper/production-attestation.js +149 -0
- package/dist/gatekeeper/trust/hardware.js +21 -0
- package/dist/gatekeeper/trust/policy.js +42 -0
- package/dist/gatekeeper/trust/production.json +25 -0
- package/dist/setup-pings/client.js +31 -0
- package/dist/setup-pings/contract.js +2 -0
- package/dist/setup-pings/policy.js +3 -0
- package/dist/setup-pings/state.js +261 -0
- package/dist/setup-pings/worker.js +95 -0
- package/dist/transport/base64url.js +9 -0
- package/dist/transport/http-response.js +89 -0
- package/dist/transport/tls-exporter.js +5 -0
- package/package.json +57 -11
- package/LICENSE +0 -1
- package/dist/api-types.js +0 -2
- package/dist/api.js +0 -97
- package/dist/commands/check.js +0 -104
- package/dist/commands/login.js +0 -9
- package/dist/files.js +0 -99
- package/dist/gitignore.js +0 -70
- package/dist/output.js +0 -23
- package/dist/secret-files.js +0 -5
- /package/dist/{browser.js → cli/browser.js} +0 -0
- /package/dist/{commands → cli/commands}/balance.js +0 -0
- /package/dist/{commands → cli/commands}/finding.js +0 -0
- /package/dist/{commands → cli/commands}/logout.js +0 -0
- /package/dist/{credentials.js → cli/credentials.js} +0 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { executionRequest } from "../analysis-core/contracts/requests.js";
|
|
3
|
+
import { checkRuntimeEnvironment } from "./runtime-environment.js";
|
|
4
|
+
/**
|
|
5
|
+
* The messages that cross between the customer's machine and the sealed relay. This module is the only owner of
|
|
6
|
+
* their shape: the relay, the gatekeeper and the bundle tooling all import these schemas and never redefine them.
|
|
7
|
+
* Design: docs/design/sealed-model-relay.md and docs/design/relay-contract.md.
|
|
8
|
+
*/
|
|
9
|
+
const compactJwtPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
10
|
+
const hexChallenge = z.string().length(64).regex(/^[0-9a-f]+$/);
|
|
11
|
+
/** The wire adds routing identity to the core's data-only execution envelope. */
|
|
12
|
+
export const relayRequest = executionRequest.extend({
|
|
13
|
+
bundleVersion: z.string().min(1).max(128),
|
|
14
|
+
reservationId: z.string().min(1).max(128),
|
|
15
|
+
reservationToken: z.string().min(1).max(256),
|
|
16
|
+
/** Stable paid-call identity; required by the production delivery transport. */
|
|
17
|
+
callId: z.uuid(),
|
|
18
|
+
/** Fresh per call, echoed in the answer, so an answer cannot be replayed into another call. */
|
|
19
|
+
nonce: z.string().min(16).max(256),
|
|
20
|
+
template: z.string().min(1).max(128),
|
|
21
|
+
answerFormat: z.string().min(1).max(128),
|
|
22
|
+
limits: executionRequest.shape.limits.unwrap().extend({ maxTokens: z.number().int().positive().safe().max(64_000).optional() }).optional(),
|
|
23
|
+
}).strict();
|
|
24
|
+
export const relayUsage = z.object({
|
|
25
|
+
inputTokens: z.number().int().nonnegative().safe(),
|
|
26
|
+
outputTokens: z.number().int().nonnegative().safe(),
|
|
27
|
+
cacheReadTokens: z.number().int().nonnegative().safe(),
|
|
28
|
+
cacheWriteTokens: z.number().int().nonnegative().safe(),
|
|
29
|
+
cacheWrite5mTokens: z.number().int().nonnegative().safe().optional(),
|
|
30
|
+
cacheWrite1hTokens: z.number().int().nonnegative().safe().optional(),
|
|
31
|
+
}).strict();
|
|
32
|
+
export const relayAnswer = z.discriminatedUnion("ok", [
|
|
33
|
+
z.object({
|
|
34
|
+
ok: z.literal(true),
|
|
35
|
+
nonce: z.string().max(256),
|
|
36
|
+
/** Validated against the shared format; local parsing applies defaults, fallbacks and cleanup. */
|
|
37
|
+
answer: z.unknown(),
|
|
38
|
+
usage: relayUsage,
|
|
39
|
+
/** True when the answer quoted our instruction text and the relay removed it. */
|
|
40
|
+
redacted: z.boolean(),
|
|
41
|
+
}).strict(),
|
|
42
|
+
z.object({
|
|
43
|
+
ok: z.literal(false),
|
|
44
|
+
nonce: z.string().max(256),
|
|
45
|
+
code: z.enum(["bad_request", "unknown_template", "bundle_version", "reservation", "model_failed", "leak_refused", "call_in_progress", "call_status", "busy"]),
|
|
46
|
+
}).strict(),
|
|
47
|
+
]);
|
|
48
|
+
export const RELAY_TLS_EXPORTER_LABEL = "EXPORTER-counterfact-relay-v1";
|
|
49
|
+
export const RELAY_MAX_FRAME_BYTES = 2 * 1024 * 1024;
|
|
50
|
+
export const deliveryConfirmation = z.object({
|
|
51
|
+
callId: z.uuid(), attemptId: z.uuid(), connectionId: z.uuid(),
|
|
52
|
+
challenge: hexChallenge,
|
|
53
|
+
}).strict();
|
|
54
|
+
/** The challenge is released with the complete answer, never in headers or a separate preliminary frame. */
|
|
55
|
+
export const relayAnswerFrame = z.object({
|
|
56
|
+
answer: relayAnswer,
|
|
57
|
+
delivery: z.object({ confirmation: deliveryConfirmation, deadlineMs: z.number().int().positive().safe() }).strict().optional(),
|
|
58
|
+
}).strict().refine(frame => frame.answer.ok === (frame.delivery !== undefined));
|
|
59
|
+
export const deliveryReceipt = z.object({ receiptId: z.uuid() }).strict();
|
|
60
|
+
// This is the count-only server boundary; it never accepts arbitrary diagnostics or request content.
|
|
61
|
+
const billingId = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/);
|
|
62
|
+
const billingScope = { actionId: z.uuid(), reservationId: z.uuid(), reservationToken: z.string().min(1).max(256), callId: z.uuid() };
|
|
63
|
+
const inputCount = z.number().int().nonnegative().max(200_000);
|
|
64
|
+
const outputCount = z.number().int().nonnegative().max(64_000);
|
|
65
|
+
/** A reservation lookup establishes the owner without reserving spend or contacting the model provider. */
|
|
66
|
+
export const reservationCheck = z.object({ action: z.literal("check"), ...billingScope }).strict();
|
|
67
|
+
export const reservationIdentity = z.object({ clientId: z.string().min(1).max(128) }).strict();
|
|
68
|
+
export const callCounts = z.object({ inputBytes: z.number().int().positive().max(800_000),
|
|
69
|
+
inputTokens: inputCount.positive(), maxOutputTokens: outputCount.positive() }).strict();
|
|
70
|
+
export const authorizationRequest = z.object({ action: z.literal("authorize"), ...billingScope,
|
|
71
|
+
routeId: billingId, templateId: billingId, answerFormatId: billingId, model: billingId, ...callCounts.shape }).strict();
|
|
72
|
+
export const callAuthorization = z.object({ attemptId: z.uuid(), model: billingId, role: z.enum(["required", "optional"]),
|
|
73
|
+
/** Stable opaque owner supplied only by the authenticated authorization service. */
|
|
74
|
+
clientId: reservationIdentity.shape.clientId,
|
|
75
|
+
deadline: z.iso.datetime(), countLimits: z.object({ maxInputBytes: z.number().int().positive().max(800_000),
|
|
76
|
+
maxInputTokens: inputCount.positive(), maxOutputTokens: outputCount.positive() }).strict() }).strict();
|
|
77
|
+
export const providerUsage = z.object({ kind: z.enum(["actual", "estimated", "unpaid"]),
|
|
78
|
+
outcome: z.enum(["success", "unknown", "retryable_failure", "terminal_provider_failure", "schema_refusal", "leak_refusal"]),
|
|
79
|
+
inputTokens: inputCount, outputTokens: outputCount, cacheReadTokens: inputCount, cacheWriteTokens: inputCount,
|
|
80
|
+
cacheWrite5mTokens: inputCount.optional(), cacheWrite1hTokens: inputCount.optional() }).strict();
|
|
81
|
+
export const serviceTiming = z.object({ kind: z.enum(["relay", "provider"]), startedAt: z.number().int().nonnegative().safe(),
|
|
82
|
+
endedAt: z.number().int().nonnegative().safe() }).strict().refine(timing => timing.endedAt >= timing.startedAt);
|
|
83
|
+
export const usageReport = z.object({ action: z.literal("usage"), ...billingScope, attemptId: z.uuid(),
|
|
84
|
+
usage: providerUsage, timing: z.array(serviceTiming).max(2) }).strict();
|
|
85
|
+
export const deliveryReport = z.object({ action: z.literal("delivery"), ...billingScope, attemptId: z.uuid(),
|
|
86
|
+
delivery: z.discriminatedUnion("status", [
|
|
87
|
+
z.object({ status: z.literal("confirmed"), confirmation: deliveryConfirmation.extend({
|
|
88
|
+
challenge: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
|
|
89
|
+
}) }).strict(),
|
|
90
|
+
z.object({ status: z.enum(["unknown", "failed"]) }).strict(),
|
|
91
|
+
]) }).strict();
|
|
92
|
+
export const reservationAction = z.discriminatedUnion("action", [reservationCheck, authorizationRequest, usageReport, deliveryReport]);
|
|
93
|
+
export const RELAY_HEALTH_VERSION = 2;
|
|
94
|
+
export const RELAY_HEALTH_AUDIENCE = "counterfact-relay-health";
|
|
95
|
+
export const RELAY_HEALTH_MAX_REQUEST_BYTES = 1024;
|
|
96
|
+
/** Billing supplies a fresh random challenge; this route accepts no analysis data. */
|
|
97
|
+
export const relayHealthRequest = z.object({
|
|
98
|
+
version: z.literal(RELAY_HEALTH_VERSION),
|
|
99
|
+
challenge: hexChallenge,
|
|
100
|
+
}).strict();
|
|
101
|
+
export const relayHealthRelease = z.object({
|
|
102
|
+
releaseId: z.string().min(1).max(128),
|
|
103
|
+
relayVersion: z.string().min(1).max(128),
|
|
104
|
+
bundleVersion: z.string().min(1).max(128),
|
|
105
|
+
workloadPolicySha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
106
|
+
}).strict();
|
|
107
|
+
export const relayHealthFilesystem = z.object({
|
|
108
|
+
readOnlyRootFilesystem: z.boolean(),
|
|
109
|
+
tmpFilesystemType: z.string().min(1).max(128).regex(/^[a-zA-Z0-9._-]+$/),
|
|
110
|
+
}).strict();
|
|
111
|
+
export const relayHealthResponse = z.discriminatedUnion("ok", [
|
|
112
|
+
z.object({
|
|
113
|
+
version: z.literal(RELAY_HEALTH_VERSION),
|
|
114
|
+
ok: z.literal(true),
|
|
115
|
+
bootId: z.uuid(),
|
|
116
|
+
challenge: hexChallenge,
|
|
117
|
+
release: relayHealthRelease,
|
|
118
|
+
filesystem: relayHealthFilesystem,
|
|
119
|
+
/** The evidence owner authenticates the audience and all three nonces before release. */
|
|
120
|
+
evidence: z.string().max(32 * 1024).regex(compactJwtPattern),
|
|
121
|
+
}).strict(),
|
|
122
|
+
z.object({
|
|
123
|
+
version: z.literal(RELAY_HEALTH_VERSION),
|
|
124
|
+
ok: z.literal(false),
|
|
125
|
+
code: z.enum(["bad_request", "unavailable"]),
|
|
126
|
+
}).strict(),
|
|
127
|
+
]);
|
|
128
|
+
export const PRODUCTION_EVIDENCE_VERSION = 1;
|
|
129
|
+
export const RELAY_CHANNEL_AUDIENCE = "https://relay.counterfactlabs.com";
|
|
130
|
+
// Passthrough forwarding keeps the non-root workload's listener port unchanged.
|
|
131
|
+
export const RELAY_PORT = 8443;
|
|
132
|
+
export const RELAY_URL = `${RELAY_CHANNEL_AUDIENCE}:${RELAY_PORT}`;
|
|
133
|
+
export const RELAY_HEALTH_PATH = "/health";
|
|
134
|
+
export const RELAY_HEALTH_URL = `${RELAY_URL}${RELAY_HEALTH_PATH}`;
|
|
135
|
+
export const GOOGLE_ATTESTATION_ISSUER = "https://confidentialcomputing.googleapis.com";
|
|
136
|
+
export const MAX_ATTESTATION_BYTES = 32 * 1024;
|
|
137
|
+
export const attestationChallenge = z.object({
|
|
138
|
+
version: z.literal(PRODUCTION_EVIDENCE_VERSION),
|
|
139
|
+
challenge: hexChallenge,
|
|
140
|
+
}).strict();
|
|
141
|
+
export const productionEvidence = z.object({
|
|
142
|
+
version: z.literal(PRODUCTION_EVIDENCE_VERSION),
|
|
143
|
+
token: z.string().max(MAX_ATTESTATION_BYTES).regex(compactJwtPattern),
|
|
144
|
+
}).strict();
|
|
145
|
+
/** Google's launcher accepts up to six caller nonces, each at most 74 characters. */
|
|
146
|
+
export const evidenceBinding = z.object({
|
|
147
|
+
audience: z.string().min(1).max(256).regex(/^[A-Za-z0-9:/._-]+$/),
|
|
148
|
+
nonces: z.array(z.string().min(1).max(74).regex(/^[A-Za-z0-9_:-]+$/)).min(1).max(6),
|
|
149
|
+
}).strict();
|
|
150
|
+
/** Native claims are interpreted only after the PKI chain and token signature are authenticated. */
|
|
151
|
+
export const googleAttestationClaims = z.object({
|
|
152
|
+
iss: z.literal(GOOGLE_ATTESTATION_ISSUER),
|
|
153
|
+
aud: z.string(),
|
|
154
|
+
sub: z.string(),
|
|
155
|
+
iat: z.number().int(),
|
|
156
|
+
nbf: z.number().int(),
|
|
157
|
+
exp: z.number().int(),
|
|
158
|
+
eat_nonce: z.union([z.string(), z.array(z.string()).min(1).max(6)]),
|
|
159
|
+
secboot: z.literal(true),
|
|
160
|
+
dbgstat: z.literal("disabled-since-boot"),
|
|
161
|
+
oemid: z.literal(11129),
|
|
162
|
+
hwmodel: z.string(),
|
|
163
|
+
swname: z.literal("CONFIDENTIAL_SPACE"),
|
|
164
|
+
swversion: z.array(z.string()).length(1),
|
|
165
|
+
google_service_accounts: z.array(z.string()).min(1),
|
|
166
|
+
development: z.never().optional(),
|
|
167
|
+
identity: z.never().optional(),
|
|
168
|
+
submods: z.object({
|
|
169
|
+
confidential_space: z.object({
|
|
170
|
+
support_attributes: z.array(z.string()),
|
|
171
|
+
monitoring_enabled: z.object({ memory: z.literal(false) }).passthrough(),
|
|
172
|
+
}).passthrough(),
|
|
173
|
+
container: z.object({
|
|
174
|
+
image_digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
175
|
+
image_signatures: z.array(z.object({
|
|
176
|
+
key_id: z.string(), signature: z.string().min(1),
|
|
177
|
+
signature_algorithm: z.literal("RSASSA_PSS_SHA256"),
|
|
178
|
+
}).passthrough()).min(1),
|
|
179
|
+
// Google omits both override claims when nothing was overridden; when present they must still be empty.
|
|
180
|
+
cmd_override: z.array(z.unknown()).length(0).optional(),
|
|
181
|
+
env_override: z.object({}).strict().optional(),
|
|
182
|
+
// Real tokens list the container's own environment; the relay's start-up rule decides which settings are allowed.
|
|
183
|
+
env: z.record(z.string(), z.string()).refine(env => {
|
|
184
|
+
try {
|
|
185
|
+
checkRuntimeEnvironment(env);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}),
|
|
192
|
+
}).passthrough(),
|
|
193
|
+
}).passthrough(),
|
|
194
|
+
}).passthrough();
|
|
195
|
+
/** Authenticated observations describe a refused workload without granting it an admitted identity. */
|
|
196
|
+
export const observedAttestationClaims = googleAttestationClaims.extend({
|
|
197
|
+
dbgstat: z.string(),
|
|
198
|
+
submods: googleAttestationClaims.shape.submods.extend({
|
|
199
|
+
container: googleAttestationClaims.shape.submods.shape.container.extend({
|
|
200
|
+
image_signatures: z.array(googleAttestationClaims.shape.submods.shape.container.shape.image_signatures.element),
|
|
201
|
+
cmd_override: z.array(z.unknown()).optional(),
|
|
202
|
+
env_override: z.record(z.string(), z.unknown()).optional(),
|
|
203
|
+
}),
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
/**
|
|
207
|
+
* Local development claims. These cannot establish production identity; production uses native Google claims.
|
|
208
|
+
*/
|
|
209
|
+
export const attestationClaims = z.object({
|
|
210
|
+
iss: z.string().url(),
|
|
211
|
+
/** The relay image's digest. Must be one we published. */
|
|
212
|
+
imageDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
213
|
+
/** Hash of the relay's channel public key, so the token is bound to this connection. */
|
|
214
|
+
channelKeyHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
215
|
+
exp: z.number().int(),
|
|
216
|
+
iat: z.number().int(),
|
|
217
|
+
}).passthrough();
|
|
218
|
+
const assetMetadata = z.object({
|
|
219
|
+
/** HMAC-SHA256 keyed by a secret stored only in the encrypted payload. */
|
|
220
|
+
fingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
|
221
|
+
size: z.number().int().nonnegative(),
|
|
222
|
+
}).strict();
|
|
223
|
+
export const bundleMetadata = z.object({
|
|
224
|
+
templates: z.record(z.string().min(1), assetMetadata.extend({
|
|
225
|
+
role: z.enum(["required", "optional"]),
|
|
226
|
+
answerFormats: z.array(z.string().min(1)).min(1),
|
|
227
|
+
}).strict()),
|
|
228
|
+
includes: z.record(z.string().min(1), assetMetadata.extend({
|
|
229
|
+
expandedSize: z.number().int().nonnegative(),
|
|
230
|
+
expandedFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
|
231
|
+
}).strict()),
|
|
232
|
+
descriptions: z.record(z.string().min(1), assetMetadata),
|
|
233
|
+
headlinePlan: assetMetadata.extend({ inputOverheadBytes: z.number().int().nonnegative(), evidenceCopies: z.number().int().nonnegative(),
|
|
234
|
+
resultVersion: z.number().int().positive().optional() }).strict().optional(),
|
|
235
|
+
}).strict();
|
|
236
|
+
/** The sealed instruction bundle as shipped with the CLI. Only the relay can open `sealed`. */
|
|
237
|
+
export const sealedBundle = z.object({
|
|
238
|
+
version: z.string().min(1),
|
|
239
|
+
/** The relay versions able to open this bundle. */
|
|
240
|
+
relayVersions: z.array(z.string().min(1)).min(1),
|
|
241
|
+
/** Template names and answer formats are public; the wording is not (design section 9). */
|
|
242
|
+
templates: z.array(z.string().min(1)),
|
|
243
|
+
answerFormats: z.array(z.string().min(1)),
|
|
244
|
+
metadata: bundleMetadata,
|
|
245
|
+
/** AES-256-GCM over the serialised registry and private fingerprint key, base64. */
|
|
246
|
+
sealed: z.string().min(1),
|
|
247
|
+
iv: z.string().min(1),
|
|
248
|
+
/** The data key, wrapped by the key service so it is released only to an attested relay image. */
|
|
249
|
+
wrappedKey: z.string().min(1),
|
|
250
|
+
/** Ed25519 signature by our release key over every field above, base64. */
|
|
251
|
+
signature: z.string().min(1),
|
|
252
|
+
}).strict();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The pinned distroless base image sets SSL_CERT_FILE to its bundled certificates. The relay accepts exactly that
|
|
2
|
+
// value, so the image works while no operator setting can point the relay at a different trust store.
|
|
3
|
+
export const BASE_CERTIFICATE_FILE = "/etc/ssl/certs/ca-certificates.crt";
|
|
4
|
+
const fixedValues = Object.freeze({ SSL_CERT_FILE: BASE_CERTIFICATE_FILE });
|
|
5
|
+
// Native startup consumes these data, so loader checks have the same owner as runtime checks.
|
|
6
|
+
export const runtimeEnvironmentPolicy = Object.freeze({
|
|
7
|
+
fixedValues,
|
|
8
|
+
forbiddenPrefixes: ["CF_RELAY_", "ANTHROPIC_", "GOOGLE_AUTH_", "LD_"],
|
|
9
|
+
forbiddenNames: ["GOOGLE_APPLICATION_CREDENTIALS", "NODE_OPTIONS", "NODE_USE_ENV_PROXY", "NODE_EXTRA_CA_CERTS",
|
|
10
|
+
"NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_DIR", "GLIBC_TUNABLES", "GCONV_PATH"],
|
|
11
|
+
forbiddenNamesIgnoringCase: ["http_proxy", "https_proxy", "all_proxy"],
|
|
12
|
+
});
|
|
13
|
+
export const reviewedNativeImageEnvironment = Object.freeze([
|
|
14
|
+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
15
|
+
`SSL_CERT_FILE=${BASE_CERTIFICATE_FILE}`, "NODE_ENV=production", "TMPDIR=/tmp",
|
|
16
|
+
]);
|
|
17
|
+
/** Refuses settings that could redirect credentials, trust or traffic; the image checker reads the same fixed values. */
|
|
18
|
+
export function checkRuntimeEnvironment(env) {
|
|
19
|
+
for (const name of Object.keys(env)) {
|
|
20
|
+
const value = env[name];
|
|
21
|
+
if (value === undefined || value === "")
|
|
22
|
+
continue;
|
|
23
|
+
if (fixedValues[name] !== undefined) {
|
|
24
|
+
if (value !== fixedValues[name])
|
|
25
|
+
throw new Error();
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (runtimeEnvironmentPolicy.forbiddenPrefixes.some(prefix => name.startsWith(prefix)) ||
|
|
29
|
+
runtimeEnvironmentPolicy.forbiddenNamesIgnoringCase.includes(name.toLowerCase()) ||
|
|
30
|
+
runtimeEnvironmentPolicy.forbiddenNames.includes(name))
|
|
31
|
+
throw new Error();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** True when an image configuration Env entry pins one of the fixed values the runtime accepts. */
|
|
35
|
+
export function isFixedImageEnvironment(entry) {
|
|
36
|
+
return Object.entries(fixedValues).some(([name, value]) => entry === `${name}=${value}`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { readLocalQualityConfig as readFindingQualityConfig } from "../../../adapters/quality.js";
|
|
2
|
+
import { assertFindingQualityPrompts as assertSharedPrompts } from "../../../analysis-core/analysis/finding-quality/config.js";
|
|
3
|
+
import { readableRequestTools } from "../../../adapters/instructions.js";
|
|
4
|
+
export const assertFindingQualityPrompts = (config) => assertSharedPrompts(config, readableRequestTools());
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { JobError } from "../../analysis-core/errors.js";
|
|
2
|
+
import { readAnalysisFlags } from "../../analysis-core/analysis/analysis-flags.js";
|
|
3
|
+
import { mapStage, inspectStage, sourceNotesStage, headlinesStage, applyAnalysisDelta, buildAnalysisResult } from "../../analysis-core/index.js";
|
|
4
|
+
import { readFindingQualityConfig } from "./finding-quality/config.js";
|
|
5
|
+
import { buildLocalReport } from "../../adapters/report.js";
|
|
6
|
+
import { runStructures } from "./structural-run.js";
|
|
7
|
+
import { analysisExecution } from "../../adapters/models.js";
|
|
8
|
+
/** Keep the hosted stage order while holding source files and results only in memory. */
|
|
9
|
+
export async function runAnalysisToEnd(input, model, settings = {}, options = {}) {
|
|
10
|
+
const execution = await analysisExecution(model, options, settings);
|
|
11
|
+
const { model: llm, requestTools: tools } = execution;
|
|
12
|
+
const flags = readAnalysisFlags(settings);
|
|
13
|
+
const qualityConfig = readFindingQualityConfig(settings, tools);
|
|
14
|
+
if (!Object.keys(input.files).length)
|
|
15
|
+
throw new JobError("empty_folder", "No admitted source files were found.");
|
|
16
|
+
let prepared = { repos: [{ key: input.repoKey, kind: "fs", files: input.files, ...(input.root !== undefined ? { root: input.root } : {}) }],
|
|
17
|
+
warnings: input.warnings ?? [], ...(input.fragment ? { mapped: [input.fragment] } : {}) };
|
|
18
|
+
const ports = { readers: [], model: llm, instructions: execution.instructions, requestTools: async () => tools, context: {},
|
|
19
|
+
progress: { checkpoint: async () => { }, assertHeld: () => { }, elapsedMs: () => 0, log: () => { } } };
|
|
20
|
+
for (;;) {
|
|
21
|
+
const delta = await mapStage({ phase: "mapping", state: prepared, settings, execution: { scopeConcurrency: 4, forLocal: true,
|
|
22
|
+
preparation: { capUsd: 20, confirmPrice: false, qualityConfig } } }, ports);
|
|
23
|
+
prepared = applyAnalysisDelta(prepared, delta);
|
|
24
|
+
if (delta.next !== "mapping")
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
prepared = { ...prepared, analysisFlags: { ...flags, sourceNotes: !options.findingsOnly && flags.sourceNotes },
|
|
28
|
+
findingQuality: { config: qualityConfig }, warnings: [...(prepared.warnings ?? []), ...qualityConfig.diagnostics] };
|
|
29
|
+
let phase = "inspecting";
|
|
30
|
+
ports.progress.checkpoint = async (fields, part) => {
|
|
31
|
+
prepared = { ...prepared, ...fields, ...(part ? { passResults: { ...prepared.passResults, [part.index]: part.result } } : {}) };
|
|
32
|
+
};
|
|
33
|
+
while (phase !== "structuring") {
|
|
34
|
+
const step = phase === "inspecting" ? inspectStage : sourceNotesStage;
|
|
35
|
+
const delta = await step({ phase, state: prepared, settings, execution: { scopeConcurrency: 4, passConcurrency: 4, forLocal: true } }, ports);
|
|
36
|
+
prepared = applyAnalysisDelta(prepared, delta);
|
|
37
|
+
phase = delta.next;
|
|
38
|
+
}
|
|
39
|
+
const output = () => buildAnalysisResult({ phase: "persisting", state: prepared, settings, execution: { scopeConcurrency: 4 } });
|
|
40
|
+
// Structural child references index the local display order, including optional ranking.
|
|
41
|
+
const report = buildLocalReport(input.repoKey, output(), prepared);
|
|
42
|
+
if (!options.findingsOnly) {
|
|
43
|
+
const findingsRaw = prepared.findingsRaw;
|
|
44
|
+
const structural = await runStructures(settings, llm, { repos: prepared.repos,
|
|
45
|
+
fragments: [{ repoKey: input.repoKey, fragment: prepared.mapped[0] }],
|
|
46
|
+
graph: report.graph, findings: report.findings, state: prepared }, { execution });
|
|
47
|
+
// Structural child references keep display order; wording uses the original cleaned order.
|
|
48
|
+
prepared = { ...structural.state, findingsRaw };
|
|
49
|
+
}
|
|
50
|
+
prepared = applyAnalysisDelta(prepared, await headlinesStage({ phase: "persisting", state: prepared, settings,
|
|
51
|
+
execution: { scopeConcurrency: 4, forLocal: true } }, ports));
|
|
52
|
+
const result = output();
|
|
53
|
+
await options.resultSink?.(result);
|
|
54
|
+
return buildLocalReport(input.repoKey, result, prepared);
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { structureStage, applyAnalysisDelta } from "../../analysis-core/index.js";
|
|
2
|
+
import { analysisExecution } from "../../adapters/models.js";
|
|
3
|
+
/** The local product supplies its in-memory allowance; the shared stage owns every transition. */
|
|
4
|
+
export async function runStructures(settings, model, input, options = {}) {
|
|
5
|
+
const execution = await analysisExecution(model, options, settings);
|
|
6
|
+
let state = { ...input.state, repos: input.repos, mapped: input.fragments.map(({ fragment }) => fragment),
|
|
7
|
+
findingsRaw: input.findings, warnings: input.state?.warnings ?? [], splitCheckPriced: true, splitFindingsPriced: true };
|
|
8
|
+
const ports = { readers: [], model: execution.model, instructions: execution.instructions, requestTools: async () => execution.requestTools, context: {},
|
|
9
|
+
progress: { checkpoint: async () => { }, assertHeld: () => { }, elapsedMs: () => 0, log: () => { } } };
|
|
10
|
+
for (;;) {
|
|
11
|
+
const delta = await structureStage({ phase: "structuring", state, settings, execution: { scopeConcurrency: 4, forLocal: true } }, ports);
|
|
12
|
+
state = applyAnalysisDelta(state, delta);
|
|
13
|
+
if (delta.next === "persisting")
|
|
14
|
+
return { state, structures: (state.structuresRaw ?? []), warnings: state.warnings ?? [] };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash, verify } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { MAX_ATTESTATION_BYTES, attestationClaims } from "../contract/messages.js";
|
|
4
|
+
import { decodeBase64url } from "../transport/base64url.js";
|
|
5
|
+
export function attestationPolicy(settings, trust) {
|
|
6
|
+
return { ...trust, allowDevelopment: settings.CF_RELAY_DEV === "1" };
|
|
7
|
+
}
|
|
8
|
+
export class AttestationError extends Error {
|
|
9
|
+
name = "AttestationError";
|
|
10
|
+
}
|
|
11
|
+
const tokenHeader = z.object({
|
|
12
|
+
alg: z.literal("EdDSA"), kid: z.string().min(1).optional(), typ: z.literal("JWT").optional(),
|
|
13
|
+
}).strict();
|
|
14
|
+
/** Local signatures never establish a production identity, even if an old RSA issuer policy is supplied. */
|
|
15
|
+
export function verifyAttestation(token, channelPublicKey, policy, now = Math.floor(Date.now() / 1000)) {
|
|
16
|
+
if (!policy.allowDevelopment)
|
|
17
|
+
throw new AttestationError("Relay development issuer is disabled");
|
|
18
|
+
let payload;
|
|
19
|
+
try {
|
|
20
|
+
if (token.length > MAX_ATTESTATION_BYTES)
|
|
21
|
+
throw new Error();
|
|
22
|
+
const parts = token.split(".");
|
|
23
|
+
if (parts.length !== 3)
|
|
24
|
+
throw new Error();
|
|
25
|
+
const [header, body, signature] = parts;
|
|
26
|
+
const parsed = tokenHeader.parse(JSON.parse(decodeBase64url(header).toString("utf8")));
|
|
27
|
+
payload = decodeBase64url(body);
|
|
28
|
+
const issuer = policy.developmentIssuer;
|
|
29
|
+
const key = issuer?.keys.find(item => parsed.kid === undefined || item.kid === parsed.kid)?.publicKey;
|
|
30
|
+
if (!key || key.type !== "public" || key.asymmetricKeyType !== "ed25519" ||
|
|
31
|
+
!verify(null, Buffer.from(`${header}.${body}`), key, decodeBase64url(signature))) {
|
|
32
|
+
throw new AttestationError("Invalid relay attestation signature");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof AttestationError)
|
|
37
|
+
throw error;
|
|
38
|
+
throw new AttestationError("Malformed relay attestation token");
|
|
39
|
+
}
|
|
40
|
+
const result = attestationClaims.safeParse((() => {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(payload.toString("utf8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
})());
|
|
48
|
+
if (!result.success)
|
|
49
|
+
throw new AttestationError("Invalid relay attestation claims");
|
|
50
|
+
const claims = result.data;
|
|
51
|
+
if (claims.iss !== policy.developmentIssuer?.issuer)
|
|
52
|
+
throw new AttestationError("Untrusted relay attestation issuer");
|
|
53
|
+
if (!policy.imageDigests.includes(claims.imageDigest))
|
|
54
|
+
throw new AttestationError("Untrusted relay image digest");
|
|
55
|
+
if (!Number.isSafeInteger(now))
|
|
56
|
+
throw new AttestationError("Invalid attestation clock");
|
|
57
|
+
if (claims.exp <= now)
|
|
58
|
+
throw new AttestationError("Relay attestation has expired");
|
|
59
|
+
if (claims.iat > now || claims.exp <= claims.iat)
|
|
60
|
+
throw new AttestationError("Invalid relay attestation lifetime");
|
|
61
|
+
if (channelPublicKey.type !== "public")
|
|
62
|
+
throw new AttestationError("Invalid relay channel key");
|
|
63
|
+
const hash = createHash("sha256").update(channelPublicKey.export({ format: "der", type: "spki" })).digest("hex");
|
|
64
|
+
if (claims.channelKeyHash !== hash)
|
|
65
|
+
throw new AttestationError("Relay attestation channel key mismatch");
|
|
66
|
+
return claims;
|
|
67
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const RSA_PSS_SIGNATURE_ALGORITHM = "1.2.840.113549.1.1.10";
|
|
2
|
+
const curves = new Set(["prime256v1", "secp384r1", "secp521r1"]);
|
|
3
|
+
const signatures = new Set([
|
|
4
|
+
"1.2.840.113549.1.1.11", "1.2.840.113549.1.1.12", "1.2.840.113549.1.1.13",
|
|
5
|
+
"1.2.840.10045.4.3.2", "1.2.840.10045.4.3.3", "1.2.840.10045.4.3.4",
|
|
6
|
+
"1.3.101.112", "1.3.101.113",
|
|
7
|
+
]);
|
|
8
|
+
const pssHashes = new Set(["2.16.840.1.101.3.4.2.1", "2.16.840.1.101.3.4.2.2", "2.16.840.1.101.3.4.2.3"]);
|
|
9
|
+
/** WebCrypto can verify obsolete algorithms; accepting a signature also requires a local strength policy. */
|
|
10
|
+
export function hasStrongCertificateAlgorithms(algorithms) {
|
|
11
|
+
const { keyType, modulusLength, namedCurve, signatureAlgorithm, pssHashAlgorithm } = algorithms;
|
|
12
|
+
const strongKey = keyType === "rsa" || keyType === "rsa-pss" ? (modulusLength ?? 0) >= 2048
|
|
13
|
+
: keyType === "ec" ? curves.has(namedCurve ?? "") : keyType === "ed25519" || keyType === "ed448";
|
|
14
|
+
if (!strongKey)
|
|
15
|
+
return false;
|
|
16
|
+
return signatureAlgorithm === RSA_PSS_SIGNATURE_ALGORITHM
|
|
17
|
+
? pssHashes.has(pssHashAlgorithm ?? "") : signatures.has(signatureAlgorithm);
|
|
18
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { X509Certificate, webcrypto } from "node:crypto";
|
|
2
|
+
import { BitString, fromBER } from "asn1js";
|
|
3
|
+
import { BasicConstraints, Certificate, CertificateChainValidationEngine, CryptoEngine, ExtensionValueFactory, RSASSAPSSParams } from "pkijs";
|
|
4
|
+
import { AttestationError } from "./attestation.js";
|
|
5
|
+
import { hasStrongCertificateAlgorithms, RSA_PSS_SIGNATURE_ALGORITHM } from "./certificate-algorithms.js";
|
|
6
|
+
const crypto = new CryptoEngine({ name: "node", crypto: webcrypto,
|
|
7
|
+
subtle: webcrypto.subtle });
|
|
8
|
+
function parseCertificate(cert) {
|
|
9
|
+
const bytes = Uint8Array.from(cert.raw);
|
|
10
|
+
const decoded = fromBER(bytes.buffer);
|
|
11
|
+
if (decoded.offset !== bytes.length)
|
|
12
|
+
throw new Error();
|
|
13
|
+
return new Certificate({ schema: decoded.result });
|
|
14
|
+
}
|
|
15
|
+
function checkPathConstraints(path) {
|
|
16
|
+
let intermediateCount = 0;
|
|
17
|
+
for (const [index, cert] of path.entries()) {
|
|
18
|
+
for (const extension of cert.extensions ?? []) {
|
|
19
|
+
if (!extension.critical)
|
|
20
|
+
continue;
|
|
21
|
+
// PKIjs also parses unknown extensions as generic ASN.1; parsing alone does not establish recognition.
|
|
22
|
+
// Subject key identifier, key usage and inhibit-any-policy use primitive ASN.1 rather than registered classes.
|
|
23
|
+
const recognized = ExtensionValueFactory.find(extension.extnID) || ["2.5.29.14", "2.5.29.15", "2.5.29.54"].includes(extension.extnID);
|
|
24
|
+
const value = extension.parsedValue;
|
|
25
|
+
if (!recognized || !value || "parsingError" in value)
|
|
26
|
+
throw new Error();
|
|
27
|
+
}
|
|
28
|
+
// PKIjs does not enforce pathLenConstraint. Count only non-self-issued CAs below each issuer, never the leaf.
|
|
29
|
+
const constraints = cert.extensions?.find(extension => extension.extnID === "2.5.29.19")?.parsedValue;
|
|
30
|
+
if (constraints instanceof BasicConstraints && constraints.pathLenConstraint !== undefined) {
|
|
31
|
+
const limit = constraints.pathLenConstraint;
|
|
32
|
+
if (intermediateCount > (typeof limit === "number" ? limit : limit.toBigInt()))
|
|
33
|
+
throw new Error();
|
|
34
|
+
}
|
|
35
|
+
if (index > 0 && !cert.subject.isEqual(cert.issuer))
|
|
36
|
+
intermediateCount++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** PKIjs owns path validation; only shipped roots are trusted, with no network or system CA discovery. */
|
|
40
|
+
export async function validateCertificatePath(chain, roots, now) {
|
|
41
|
+
try {
|
|
42
|
+
if (!Number.isSafeInteger(now) || chain.length < 1 || chain.length > 5 || !roots.length || roots.length > 8)
|
|
43
|
+
throw new Error();
|
|
44
|
+
// Reviewed root rotations can overlap; an unused expired anchor must not invalidate a different valid path.
|
|
45
|
+
const current = (cert) => now * 1000 >= Date.parse(cert.validFrom) && now * 1000 < Date.parse(cert.validTo);
|
|
46
|
+
const anchors = roots.map(root => new X509Certificate(root)).filter(root => root.ca && current(root));
|
|
47
|
+
if (!anchors.length || chain.some(cert => !current(cert)))
|
|
48
|
+
throw new Error();
|
|
49
|
+
const [leaf] = chain;
|
|
50
|
+
if (leaf.ca || leaf.publicKey.asymmetricKeyType !== "rsa")
|
|
51
|
+
throw new Error();
|
|
52
|
+
// Path builders may discard extraneous certificates; the wire format requires one ordered, connected path.
|
|
53
|
+
if (new Set(chain.map(cert => cert.fingerprint256)).size !== chain.length)
|
|
54
|
+
throw new Error();
|
|
55
|
+
for (let index = 1; index < chain.length; index++) {
|
|
56
|
+
if (!chain[index].ca || !chain[index - 1].checkIssued(chain[index]))
|
|
57
|
+
throw new Error();
|
|
58
|
+
}
|
|
59
|
+
const isAnchor = (cert) => anchors.some(root => root.fingerprint256 === cert.fingerprint256);
|
|
60
|
+
const last = chain.at(-1);
|
|
61
|
+
if (last.checkIssued(last) && !isAnchor(last))
|
|
62
|
+
throw new Error();
|
|
63
|
+
const supplied = chain.map(cert => ({ cert, parsed: parseCertificate(cert) }));
|
|
64
|
+
const trusted = anchors.map(cert => ({ cert, parsed: parseCertificate(cert) }));
|
|
65
|
+
for (const { cert, parsed } of [...supplied, ...trusted]) {
|
|
66
|
+
const key = cert.publicKey;
|
|
67
|
+
const signature = parsed.signatureAlgorithm;
|
|
68
|
+
const pssHashAlgorithm = signature.algorithmId === RSA_PSS_SIGNATURE_ALGORITHM && signature.algorithmParams
|
|
69
|
+
? new RSASSAPSSParams({ schema: signature.algorithmParams }).hashAlgorithm.algorithmId : undefined;
|
|
70
|
+
if (!hasStrongCertificateAlgorithms({ keyType: key.asymmetricKeyType,
|
|
71
|
+
modulusLength: key.asymmetricKeyDetails?.modulusLength, namedCurve: key.asymmetricKeyDetails?.namedCurve,
|
|
72
|
+
signatureAlgorithm: signature.algorithmId, pssHashAlgorithm }))
|
|
73
|
+
throw new Error();
|
|
74
|
+
}
|
|
75
|
+
// RFC 5280: a leaf without a key usage extension is unrestricted, and Google's Confidential Space leaves omit it.
|
|
76
|
+
// When the extension is present it must still allow digital signatures.
|
|
77
|
+
const usageExtension = supplied[0].parsed.extensions?.find((extension) => extension.extnID === "2.5.29.15");
|
|
78
|
+
if (usageExtension) {
|
|
79
|
+
const usage = usageExtension.parsedValue;
|
|
80
|
+
if (!(usage instanceof BitString) || !(usage.valueBlock.valueHexView[0] & 0x80))
|
|
81
|
+
throw new Error();
|
|
82
|
+
}
|
|
83
|
+
const validator = new CertificateChainValidationEngine({
|
|
84
|
+
trustedCerts: trusted.map(({ parsed }) => parsed),
|
|
85
|
+
// PKIjs takes the target certificate last, independently of the wire's leaf-first order.
|
|
86
|
+
certs: supplied.filter(({ cert }) => !isAnchor(cert)).reverse().map(({ parsed }) => parsed),
|
|
87
|
+
checkDate: new Date(now * 1000),
|
|
88
|
+
});
|
|
89
|
+
const result = await validator.verify({}, crypto);
|
|
90
|
+
if (!result.result || !result.certificatePath)
|
|
91
|
+
throw new Error();
|
|
92
|
+
checkPathConstraints(result.certificatePath);
|
|
93
|
+
return leaf.publicKey;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Certificate and dependency diagnostics can contain token material; retain only the fixed category.
|
|
97
|
+
throw new AttestationError("Invalid production attestation certificate path");
|
|
98
|
+
}
|
|
99
|
+
}
|