Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/claude/desktop-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { OcxConfig } from "../types";
import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
import { getConfigDir } from "../config/paths";
import { readFileSync, existsSync } from "node:fs";
import { pickerCaCertPath, pickerCaFingerprints, ensurePickerCa, issuePickerLeaf, pickerLeafCertPath, publishedPickerCaSha256 } from "./intercept/picker-ca";
import { acceptsPickerAuthority, pickerCaCertPath, pickerCaFingerprints, ensurePickerCa, issuePickerLeaf, pickerLeafCertPath, publishedPickerCaSha256 } from "./intercept/picker-ca";
import { inspectPickerTrust, trustPickerCa, untrustPickerCa } from "./intercept/picker-trust";
import type { PickerRuntime } from "./intercept/picker-runtime";
import type { PickerTrustState, SecurityRunner } from "./intercept/picker-trust";
Expand All @@ -29,7 +29,7 @@ function profileReleased(profile: DesktopPickerProfileInspection): boolean {

export type DesktopPickerReason = "active" | "restart_required" | "unsupported_platform" | "not_first_party"
| "integration_off" | "disabled" | "proxy_unavailable" | "mode_not_committed" | "trust_pending"
| "trust_declined" | "profile_failed";
| "trust_declined" | "profile_failed" | "ca_unverified";

export interface DesktopPickerStatus {
desired: boolean;
Expand Down Expand Up @@ -229,6 +229,10 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps)
let caSha1: string;
try {
const ca = ensurePickerCa(deps.configDir);
// The CLI path gates trust on the same profile check; a published root whose bytes do not
// match the profile this process mints must not reach the keychain through the server
// path either, even when a live peer published it.
if (!acceptsPickerAuthority(ca.certPem)) return refuse("ca_unverified");
issuePickerLeaf(ca, deps.configDir);
caPath = pickerCaCertPath(deps.configDir);
caSha1 = pickerCaFingerprints(ca.certPem).sha1;
Expand Down
26 changes: 25 additions & 1 deletion src/claude/intercept/local-ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export interface AuthorityOptions {
permittedDnsNames?: readonly string[];
/** With permittedDnsNames: also exclude every IP address (default true). */
excludeAllIpAddresses?: boolean;
}
}

export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa {
const validityDays = options.validityDays ?? CA_VALIDITY_DAYS;
Expand Down Expand Up @@ -235,6 +235,30 @@ export function createLocalInterceptCa(): LocalInterceptCa {
return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME });
}

/**
* Test hook for trust-boundary suites: mint a self-signed authority carrying an arbitrary list of
* DER-encoded Extension items so adversarial profiles still bear a valid signature. Production
* issuance always goes through createCertificateAuthority's fixed extension set.
*/
export function mintAuthorityWithExtensionsForTests(commonName: string, extensions: Uint8Array[]): LocalInterceptCa {
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
const name = distinguishedName(commonName);
const der = issueCertificate({
subject: name,
issuer: name,
subjectKey: publicKey,
signingKey: privateKey,
validityDays: CA_VALIDITY_DAYS,
extensions,
});
return {
certPem: toPem("CERTIFICATE", der),
keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string,
publicKey,
privateKey,
};
}

/** IPv4 literal to its four octets, or null. Only the leaf SAN encoder needs it. */
function ipv4Octets(host: string): Uint8Array | null {
const parts = host.split(".");
Expand Down
132 changes: 132 additions & 0 deletions src/claude/intercept/picker-ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, stat
import { join } from "node:path";
import { withClientLifecycleSync } from "../../client/lifecycle-lock";
import {
ALL_IP_ADDRESS_BASES,
createCertificateAuthority,
issueServerLeaf,
type LocalInterceptCa,
Expand Down Expand Up @@ -36,6 +37,137 @@ export function pickerCaFingerprints(certPem: string): { sha1: string; sha256: s
};
}

interface DerItem { tag: number; body: Buffer; next: number }

function readDer(bytes: Buffer, at: number): DerItem | null {
if (at + 2 > bytes.length) return null;
const tag = bytes[at]!;
let length = bytes[at + 1]!;
let cursor = at + 2;
if (length & 0x80) {
const width = length & 0x7f;
if (width === 0 || width > 4 || cursor + width > bytes.length) return null;
length = 0;
for (let i = 0; i < width; i++) length = length * 256 + bytes[cursor++]!;
}
if (cursor + length > bytes.length) return null;
return { tag, body: bytes.subarray(cursor, cursor + length), next: cursor + length };
}

function children(bytes: Buffer): DerItem[] | null {
const out: DerItem[] = [];
for (let cursor = 0; cursor < bytes.length;) {
const item = readDer(bytes, cursor);
if (!item) return null;
out.push(item);
cursor = item.next;
}
return out;
}

function constrainedToPickerHost(value: Buffer): boolean {
const root = readDer(value, 0);
if (!root || root.tag !== 0x30 || root.next !== value.length) return false;
const fields = children(root.body);
if (!fields || fields.length !== 2 || fields[0]!.tag !== 0xa0 || fields[1]!.tag !== 0xa1) return false;
const excluded = children(fields[1]!.body);
const excludesAllIps = excluded?.length === ALL_IP_ADDRESS_BASES.length && excluded.every((subtree, index) => {
const base = subtree.tag === 0x30 ? children(subtree.body) : null;
return base?.length === 1 && base[0]!.tag === 0x87
&& base[0]!.body.equals(Buffer.from(ALL_IP_ADDRESS_BASES[index]!));
});
if (!excludesAllIps) return false;
const subtrees = children(fields[0]!.body);
const subtree = subtrees?.length === 1 && subtrees[0]!.tag === 0x30 ? children(subtrees[0]!.body) : null;
return subtree?.length === 1 && subtree[0]!.tag === 0x82
&& subtree[0]!.body.equals(Buffer.from(PICKER_HOST, "ascii"));
}

interface ParsedExtension { oid: Buffer; critical: boolean; value: Buffer }

/** One Extension SEQUENCE → oid, criticality, and extnValue bytes; malformed shapes return null. */
function parseExtension(item: DerItem): ParsedExtension | null {
if (item.tag !== 0x30) return null;
const fields = children(item.body);
if (!fields || fields.length < 2 || fields.length > 3 || fields[0]!.tag !== 0x06) return null;
const value = fields[fields.length - 1]!;
if (value.tag !== 0x04) return null;
if (fields.length === 2) return { oid: fields[0]!.body, critical: false, value: value.body };
// DER encodes the critical flag only as BOOLEAN TRUE; anything else is not a real extension.
if (fields[1]!.tag !== 0x01 || !fields[1]!.body.equals(Buffer.from([0xff]))) return null;
return { oid: fields[0]!.body, critical: true, value: value.body };
}

// DER OID bodies for the only four extensions a picker authority carries. A SAN, an EKU, or any
// other extension means the certificate is not a picker authority, whatever its subject claims.
const EXT_BASIC_CONSTRAINTS = "551d13";
const EXT_KEY_USAGE = "551d0f";
const EXT_SUBJECT_KEY_IDENTIFIER = "551d0e";
const EXT_NAME_CONSTRAINTS = "551d1e";
const PICKER_CA_EXTENSION_OIDS = new Set([
EXT_BASIC_CONSTRAINTS, EXT_KEY_USAGE, EXT_SUBJECT_KEY_IDENTIFIER, EXT_NAME_CONSTRAINTS,
]);

/** basicConstraints CA:TRUE with pathLenConstraint 0 — the anchor signs leaves, not other CAs. */
const PICKER_CA_BASIC_CONSTRAINTS = Buffer.from([0x30, 0x06, 0x01, 0x01, 0xff, 0x02, 0x01, 0x00]);
/** keyUsage keyCertSign | cRLSign only — the anchor can never sign a TLS handshake itself. */
const PICKER_CA_KEY_USAGE = Buffer.from([0x03, 0x02, 0x01, 0x06]);

/**
* The trust decision accepts exactly the extension profile createCertificateAuthority emits, not
* merely a certificate that happens to contain the right name constraint. A root that also carries
* leaf privileges (SAN, serverAuth EKU, digitalSignature) could be presented directly as an
* off-host server certificate, where name constraints on subordinates no longer apply.
*/
function acceptsExtensionProfile(extensions: DerItem[]): boolean {
const seen = new Set<string>();
for (const item of extensions) {
const extension = parseExtension(item);
if (extension === null) return false;
const key = extension.oid.toString("hex");
if (!PICKER_CA_EXTENSION_OIDS.has(key) || seen.has(key)) return false;
seen.add(key);
switch (key) {
case EXT_BASIC_CONSTRAINTS:
if (!extension.critical || !extension.value.equals(PICKER_CA_BASIC_CONSTRAINTS)) return false;
break;
case EXT_KEY_USAGE:
if (!extension.critical || !extension.value.equals(PICKER_CA_KEY_USAGE)) return false;
break;
case EXT_SUBJECT_KEY_IDENTIFIER: {
const id = readDer(extension.value, 0);
if (extension.critical || id === null || id.tag !== 0x04 || id.next !== extension.value.length) return false;
break;
}
default:
if (!extension.critical || !constrainedToPickerHost(extension.value)) return false;
}
}
return seen.size === PICKER_CA_EXTENSION_OIDS.size;
}

/** Independently authorize a root before installing it as system trust. */
export function acceptsPickerAuthority(certPem: string): boolean {
try {
const cert = new X509Certificate(certPem);
const names = cert.subject.split("\n");
if (!names.includes(`CN=${PICKER_CA_COMMON_NAME}`)) return false;
if (names.filter(line => line.startsWith("CN=")).length !== 1) return false;
// A picker authority is self-issued and self-signed; a foreign issuer is not one of ours.
if (cert.issuer !== cert.subject || !cert.verify(cert.publicKey)) return false;
const root = readDer(cert.raw, 0);
const certificate = root?.tag === 0x30 && root.next === cert.raw.length ? children(root.body) : null;
const tbs = certificate?.[0]?.tag === 0x30 ? children(certificate[0].body) : null;
const extensionField = tbs?.find(item => item.tag === 0xa3);
const wrapped = extensionField && readDer(extensionField.body, 0);
const extensions = wrapped?.tag === 0x30 && wrapped.next === extensionField!.body.length
? children(wrapped.body) : null;
return extensions !== null && acceptsExtensionProfile(extensions);
} catch {
return false;
}
}

/** Atomically publish a public certificate; these files carry no key material. */
function publishPem(path: string, pem: string): void {
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
Expand Down
3 changes: 2 additions & 1 deletion src/cli/claude-desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
} from "../claude/desktop-first-party";
import { FIRST_PARTY_ACCOUNT_RISK } from "../claude/desktop-risk";
import { claudeInterceptEnabled } from "../claude/intercept/runtime";
import { ensurePickerCa, pickerCaCertPath, pickerCaFingerprints, pickerLeafCertPath } from "../claude/intercept/picker-ca";
import { acceptsPickerAuthority, ensurePickerCa, pickerCaCertPath, pickerCaFingerprints, pickerLeafCertPath } from "../claude/intercept/picker-ca";
import { inspectPickerTrust, trustPickerCa, untrustPickerCa, type SecurityRunner } from "../claude/intercept/picker-trust";
import { offlinePickerStatus, removeDesktopPickerArtifacts, type DesktopPickerStatus } from "../claude/desktop-picker";
import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy";
Expand Down Expand Up @@ -156,6 +156,7 @@ async function trustPickerLocally(
try {
const configDir = getConfigDir();
const paths = pickerTrustPaths(deps, configDir);
if (!acceptsPickerAuthority(paths.certPem)) return { ok: false, reason: "ca_unverified" };
if (!deps.ensurePickerCaImpl) {
// The bytes in ca.pem are only safe to trust when they match the authority the running
// server actually owns — a replaced file must never land in the login keychain.
Expand Down
8 changes: 7 additions & 1 deletion structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,13 @@ only `egressProxyUrl`. The previous Desktop selection is stored in

The local controls are `ocx claude desktop picker on|off|status|trust`. With a live server, `on`,
`off`, and transition cleanup use the controller; `trust` performs the operator's local keychain
step and then reports the result to the server. Without a server, `on` is refused and `off` removes
step and then reports the result to the server. Before installing the root, the CLI independently
requires the picker common name on a self-signed CA and the exact critical `claude.ai`-only DNS
and all-IP exclusion constraints, plus the full minted extension profile — critical `CA:TRUE`
basicConstraints, a `keyCertSign|cRLSign`-only keyUsage, a non-critical subjectKeyIdentifier, and
nothing else — so a forged root carrying leaf privileges (SAN, serverAuth EKU, digitalSignature)
is refused. Matching the live server's reported fingerprint is an additional check, not a
replacement for certificate-scope validation. Without a server, `on` is refused and `off` removes
owned artifacts locally. The management surface accepts `GET /api/claude-desktop/picker` and
`PUT /api/claude-desktop/picker` with `{ enabled, persist, trustedLocally?, callerAddedTrust? }`;
unknown keys are rejected, a successful enable/disable or reported refusal returns `200 { ok: true,
Expand Down
26 changes: 25 additions & 1 deletion tests/claude-integration/claude-desktop-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { readClientConnectionState, clearClientConnection } from "../../src/clie
import { HubClientError } from "../../src/client/hub-client";
import { RuntimeApiError } from "../../src/cli/runtime-api";
import type { DesktopPickerStatus } from "../../src/claude/desktop-picker";
import { ensurePickerCa, pickerCaFingerprints } from "../../src/claude/intercept/picker-ca";
import { ensurePickerCa, pickerCaCertPath, pickerCaFingerprints, PICKER_CA_COMMON_NAME } from "../../src/claude/intercept/picker-ca";
import { createCertificateAuthority } from "../../src/claude/intercept/local-ca";
import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../../src/codex/desired-state";
import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime";
import { resetBundledCatalogCacheForTests, setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled";
Expand Down Expand Up @@ -681,6 +682,29 @@ test("picker trust installs the file only when it matches the server-reported CA
} finally { error.mockRestore(); log.mockRestore(); }
});

test("picker trust rejects an unconstrained CA even when the server reports its fingerprint", async () => {
const error = spyOn(console, "error").mockImplementation(() => {});
// The real trust path reads ca.pem from disk; a matching server-reported fingerprint must not
// rescue a root whose profile this process would never mint.
const ca = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME });
const caPath = pickerCaCertPath(process.env.OPENCODEX_HOME!);
mkdirSync(dirname(caPath), { recursive: true });
writeFileSync(caPath, ca.certPem, { mode: 0o644 });
const caSha256 = pickerCaFingerprints(ca.certPem).sha256;
const trusted: string[] = [];
try {
const result = await handleClaudeDesktopCommand(["picker", "trust"], {
findLiveProxyImpl: async () => ({ pid: null, port: 10100, hostname: "127.0.0.1", source: "config" }),
inspectPickerTrustImpl: async () => "untrusted",
trustPickerCaImpl: async () => { trusted.push("trust"); return { ok: true }; },
runtimeRequestImpl: async () => ({ ok: true, picker: pickerStatus("restart_required", caSha256) }),
});
expect(result).toBe(1);
expect(trusted).toEqual([]);
expect(error.mock.calls.flat().join(" ")).toContain("ca_unverified");
} finally { error.mockRestore(); }
});

test("picker off offline persists the preference and removes local artifacts", async () => {
const log = spyOn(console, "log").mockImplementation(() => {});
const removed: string[] = [];
Expand Down
Loading
Loading