Skip to content
110 changes: 79 additions & 31 deletions src/claude/intercept/local-ca-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const MAX_PEM_BYTES = 64 * 1024;
const KEY = "ca.key";
const CERT = "ca.pem";
type FileMode = 0o600 | 0o644;
type AclLevel = "owner" | "inherited" | "private";
type AclCheck = ((path: string, stat: BigIntStats, level?: AclLevel) => void) & { forget(stat: BigIntStats): void };
type FileHooks = { beforeRead?: (path: string) => void; beforeWrite?: (path: string) => void; beforePublish?: (path: string) => void };
let hooks: FileHooks = {};
/** Boundary fault injection for substitution and zero-secret-write assertions. */
Expand All @@ -28,34 +30,48 @@ function owned(stat: BigIntStats, directory = false, mode: FileMode = 0o600): vo
if ((Number(stat.mode) & forbidden) !== 0) throw unsafe();
}
}
function same(path: string, expected: BigIntStats, directory = false, mode: FileMode = 0o600): void {
function same(path: string, expected: BigIntStats, directory = false, mode: FileMode = 0o600): BigIntStats {
const stat = lstatSync(path, { bigint: true });
owned(stat, directory, mode);
if (stat.dev !== expected.dev || stat.ino !== expected.ino || stat.ino === 0n) throw unsafe();
return stat;
}
function inspect(path: string, mode: FileMode, sqliteSidecar = false): BigIntStats | null {
function inspect(path: string, mode: FileMode, checkAcl: AclCheck, sqliteSidecar = false): BigIntStats | null {
let stat: BigIntStats;
try { stat = lstatSync(path, { bigint: true }); } catch (error) { if (missing(error)) return null; throw error; }
owned(stat, false, mode);
if (windowsSecretAclApplies()) assertLocalCaWindowsAcl(path, sqliteSidecar ? "inherited" : "private");
if (windowsSecretAclApplies()) {
if (sqliteSidecar) checkAcl(path, stat, "inherited");
else {
try { checkAcl(path, stat); }
catch {
// Legacy files may inherit only trusted grants; verify before narrowing.
checkAcl(path, stat, "inherited");
same(path, stat, false, mode);
hardenSecretPath(path, { required: true });
same(path, stat, false, mode);
checkAcl(path, stat);
}
}
}
same(path, stat, false, mode);
return stat;
}

function protectNew(path: string, fd: number, mode: FileMode): void {
function protectNew(path: string, fd: number, mode: FileMode, checkAcl: AclCheck): void {
const created = fstatSync(fd, { bigint: true });
owned(created, false, mode);
if (windowsSecretAclApplies()) {
// Owner inspection precedes ACL mutation; inherited grants on a new EMPTY file
// are removed by the existing hardener before the strict inspection and write.
assertLocalCaWindowsAcl(path, "owner");
// Only O_CREAT|O_EXCL entries reach here. Narrow the empty file we created,
// then strictly verify before writing; an Administrators default owner still fails closed.
same(path, created, false, mode);
hardenSecretPath(path, { required: true });
assertLocalCaWindowsAcl(path);
checkAcl(path, created);
} else fchmodSync(fd, mode);
same(path, created, false, mode);
}

function readPinned(path: string, expected: BigIntStats, mode: FileMode, assertDirectory: () => void): string {
function readPinned(path: string, expected: BigIntStats, mode: FileMode, assertDirectory: () => void, checkAcl: AclCheck): string {
hooks.beforeRead?.(path);
assertDirectory();
same(path, expected, false, mode);
Expand All @@ -64,7 +80,7 @@ function readPinned(path: string, expected: BigIntStats, mode: FileMode, assertD
const opened = fstatSync(fd, { bigint: true });
owned(opened, false, mode);
if (opened.dev !== expected.dev || opened.ino !== expected.ino || opened.size > BigInt(MAX_PEM_BYTES)) throw unsafe();
if (windowsSecretAclApplies()) assertLocalCaWindowsAcl(path);
checkAcl(path, opened);
same(path, opened, false, mode);
const bytes = Buffer.alloc(MAX_PEM_BYTES + 1);
let length = 0;
Expand All @@ -87,9 +103,9 @@ export interface LocalCaFiles {
writePair(pair: { certPem: string; keyPem: string }): void;
}

function stage(path: string, contents: string, mode: FileMode, assertDirectory: () => void): { publish(): void; dispose(): void } {
function stage(path: string, contents: string, mode: FileMode, assertDirectory: () => void, checkAcl: AclCheck): { publish(): void; dispose(): void } {
assertDirectory();
const previous = inspect(path, mode);
const previous = inspect(path, mode, checkAcl);
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`);
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK, mode);
let created: BigIntStats;
Expand All @@ -102,26 +118,27 @@ function stage(path: string, contents: string, mode: FileMode, assertDirectory:
if (removed) return;
try { same(temp, created, false, mode); unlinkSync(temp); removed = true; }
catch (error) { if (missing(error)) removed = true; else throw error; }
if (removed) forgetEphemeralSecretPath(temp);
if (removed) { checkAcl.forget(created); forgetEphemeralSecretPath(temp); }
};
try {
protectNew(temp, fd, mode);
protectNew(temp, fd, mode, checkAcl);
assertDirectory();
hooks.beforeWrite?.(temp);
same(temp, created, false, mode);
assertDirectory();
if (windowsSecretAclApplies()) assertLocalCaWindowsAcl(temp);
checkAcl(temp, created);
writeFileSync(fd, contents, { encoding: "utf8" });
same(temp, created, false, mode);
return {
publish() {
hooks.beforePublish?.(path);
assertDirectory();
same(temp, created, false, mode);
if (windowsSecretAclApplies()) assertLocalCaWindowsAcl(temp);
const current = inspect(path, mode);
checkAcl(temp, created);
const current = inspect(path, mode, checkAcl);
if (previous ? !current || previous.dev !== current.dev || previous.ino !== current.ino : current !== null) throw unsafe();
renameSync(temp, path);
if (current) checkAcl.forget(current);
removed = true;
forgetEphemeralSecretPath(temp);
same(path, created, false, mode);
Expand All @@ -142,6 +159,27 @@ export function withLocalCaPublication<T>(dir: string, lockName: string, work: (
let created = false;
try { mkdirSync(dir, { mode: 0o700 }); created = true; }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
// Publication-local only: a private DACL can be changed only by the owner,
// SYSTEM or Administrators. Every memo hit still checks lstat identity via same().
// bigint birthtimeNs adds discrimination for recycled SQLite sidecar file IDs; it is not a
// uniqueness guarantee (NTFS tunneling can restore creation times). Safety rests on the
// verified-private directory DACL. Creation time survives same-volume rename, so staged
// entries keep their memo.
const verified = new Map<string, AclLevel>();
const strength = { owner: 0, inherited: 1, private: 2 };
const checkAcl: AclCheck = Object.assign((path: string, stat: BigIntStats, level: AclLevel = "private") => {
if (!windowsSecretAclApplies()) return;
const identity = same(path, stat, stat.isDirectory());
const key = `${identity.dev}:${identity.ino}:${identity.birthtimeNs}`;
const prior = verified.get(key);
if (prior !== undefined && strength[prior] >= strength[level]) return;
assertLocalCaWindowsAcl(path, level);
same(path, stat, stat.isDirectory());
verified.set(key, level);
}, {
// An identity this publication unlinked or replaced is retired; a recycled id must be re-inspected.
forget(stat: BigIntStats): void { verified.delete(`${stat.dev}:${stat.ino}:${stat.birthtimeNs}`); },
});
const directory = lstatSync(dir, { bigint: true });
owned(directory, true);
const dirFd = windowsSecretAclApplies() ? null : openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW);
Expand All @@ -152,30 +190,40 @@ export function withLocalCaPublication<T>(dir: string, lockName: string, work: (
if (opened.dev !== directory.dev || opened.ino !== directory.ino) throw unsafe();
owned(opened, true);
} else {
assertLocalCaWindowsAcl(dir, created ? "owner" : "private");
hardenSecretDir(dir, { required: true });
assertLocalCaWindowsAcl(dir);
let needsHardening = created;
// A successful mkdir created this directory; strict verification after narrowing
// still refuses an Administrators default owner before any CA access.
if (!created) {
try { checkAcl(dir, directory); }
catch { checkAcl(dir, directory, "inherited"); needsHardening = true; }
}
if (needsHardening) {
same(dir, directory, true);
hardenSecretDir(dir, { required: true });
same(dir, directory, true);
checkAcl(dir, directory);
}
}
const assertDirectory = (): void => { same(dir, directory, true); };
assertDirectory();
const lockPath = join(dir, lockName);
const assertEntries = (): void => {
assertDirectory();
inspect(join(dir, KEY), 0o600);
inspect(join(dir, CERT), 0o644);
inspect(lockPath, 0o600);
inspect(join(dir, KEY), 0o600, checkAcl);
inspect(join(dir, CERT), 0o644, checkAcl);
inspect(lockPath, 0o600, checkAcl);
// SQLite creates sidecars itself, inheriting only the protected directory's
// current-user ACE. Inspect every grant and owner; an inherited DACL is valid here.
for (const suffix of ["-journal", "-wal", "-shm"]) inspect(`${lockPath}${suffix}`, 0o600, true);
for (const suffix of ["-journal", "-wal", "-shm"]) inspect(`${lockPath}${suffix}`, 0o600, checkAcl, true);
assertDirectory();
};
assertEntries();
try {
lockFd = openSync(lockPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK, 0o600);
protectNew(lockPath, lockFd, 0o600);
protectNew(lockPath, lockFd, 0o600, checkAcl);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
const existing = inspect(lockPath, 0o600);
const existing = inspect(lockPath, 0o600, checkAcl);
if (!existing) throw unsafe();
lockFd = openSync(lockPath, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
const opened = fstatSync(lockFd, { bigint: true });
Expand All @@ -191,17 +239,17 @@ export function withLocalCaPublication<T>(dir: string, lockName: string, work: (
const files: LocalCaFiles = {
readPair() {
assertEntries();
const cert = inspect(join(dir, CERT), 0o644);
const key = inspect(join(dir, KEY), 0o600);
const cert = inspect(join(dir, CERT), 0o644, checkAcl);
const key = inspect(join(dir, KEY), 0o600, checkAcl);
if (!cert || !key) return null;
return { certPem: readPinned(join(dir, CERT), cert, 0o644, assertDirectory), keyPem: readPinned(join(dir, KEY), key, 0o600, assertDirectory) };
return { certPem: readPinned(join(dir, CERT), cert, 0o644, assertDirectory, checkAcl), keyPem: readPinned(join(dir, KEY), key, 0o600, assertDirectory, checkAcl) };
},
writePair(pair) {
assertEntries();
const key = stage(join(dir, KEY), pair.keyPem, 0o600, assertDirectory);
const key = stage(join(dir, KEY), pair.keyPem, 0o600, assertDirectory, checkAcl);
let cert: ReturnType<typeof stage> | undefined;
try {
cert = stage(join(dir, CERT), pair.certPem, 0o644, assertDirectory);
cert = stage(join(dir, CERT), pair.certPem, 0o644, assertDirectory, checkAcl);
assertEntries();
key.publish();
cert.publish();
Expand Down
16 changes: 13 additions & 3 deletions src/claude/intercept/local-ca-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@ const SID = /^S-1-(?:\d+-)+\d+$/i;

function nativeAclRunner(path: string, timeoutMs: number): IcaclsResult {
// Encode the path as data, not PowerShell syntax. SID-form rules avoid localized account names.
// .NET only: Get-Acl/ConvertTo-Json/ForEach-Object are module-autoloaded cmdlets, and
// autoload resolves them through the per-profile module analysis cache. With a fresh or
// redirected LOCALAPPDATA that rebuild scans every PSModulePath module and can exceed the
// inspection budget (hosted Windows runners: 30 s timeouts during `ocx start`); it also lets
// a module earlier on PSModulePath shadow the cmdlet. SIDs, ints and booleans need no escaping;
// the JSON quote is [char]34 so the -Command argument carries no double quotes to re-parse.
const encodedPath = Buffer.from(path, "utf8").toString("base64");
const script = [
"$ErrorActionPreference='Stop'",
`$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
"$a=Get-Acl -LiteralPath $p",
"$s=[System.Security.Principal.SecurityIdentifier]",
"$r=@($a.GetAccessRules($true,$true,$s)|ForEach-Object {@{sid=$_.IdentityReference.Value;type=[int]$_.AccessControlType;rights=[long]$_.FileSystemRights}})",
"@{owner=$a.GetOwner($s).Value;protected=$a.AreAccessRulesProtected;rules=$r}|ConvertTo-Json -Depth 4 -Compress",
"$q=[char]34",
"$c=[System.Security.AccessControl.AccessControlSections]'Owner,Access'",
"if([System.IO.Directory]::Exists($p)){$a=[System.Security.AccessControl.DirectorySecurity]::new($p,$c)}else{$a=[System.Security.AccessControl.FileSecurity]::new($p,$c)}",
"$r=foreach($x in $a.GetAccessRules($true,$true,$s)){'{'+$q+'sid'+$q+':'+$q+$x.IdentityReference.Value+$q+','+$q+'type'+$q+':'+[int]$x.AccessControlType+','+$q+'rights'+$q+':'+[long]$x.FileSystemRights+'}'}",
"$o='{'+$q+'owner'+$q+':'+$q+$a.GetOwner($s).Value+$q+','+$q+'protected'+$q+':'+$(if($a.AreAccessRulesProtected){'true'}else{'false'})+','+$q+'rules'+$q+':['+(@($r) -join ',')+']}'",
"[Console]::Out.Write($o)",
"[Console]::Out.Flush()",
].join(";");
const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: timeoutMs, windowsHide: true,
Expand Down
12 changes: 10 additions & 2 deletions src/claude/intercept/picker-ca-persistence.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { X509Certificate } from "node:crypto";
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, writeFileSync, type Stats } from "node:fs";
import { join } from "node:path";
import {
decodePickerCaCredential, openPickerCaCredential, pickerCaConfigId, readPickerCaCredential,
Expand All @@ -21,11 +21,16 @@ export function canonicalPickerConfigDir(configDir: string): string {

/** Public records still cannot follow symlinks or accept oversized/malformed recovery data. */
function readState(path: string, configId: string, initialization: boolean): Initialization | AuthorityMetadata | null {
let expected: Stats;
try { expected = lstatSync(path); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
if (expected.isSymbolicLink()) throw new Error("picker_ca_metadata_unsafe");
let fd: number;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
try {
const stat = fstatSync(fd);
if (stat.dev !== expected.dev || stat.ino !== expected.ino) throw new Error("picker_ca_metadata_unsafe");
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_PUBLIC_STATE_BYTES
|| (process.platform !== "win32" && stat.uid !== process.getuid!())) throw new Error();
const bytes = Buffer.alloc(MAX_PUBLIC_STATE_BYTES + 1);
Expand All @@ -46,7 +51,10 @@ function readState(path: string, configId: string, initialization: boolean): Ini
if (initialization && value.predecessor !== null
&& new X509Certificate(value.predecessor).toString() !== value.predecessor) throw new Error();
return value;
} catch { throw new Error("picker_ca_metadata_invalid"); }
} catch (error) {
if (error instanceof Error && error.message === "picker_ca_metadata_unsafe") throw error;
throw new Error("picker_ca_metadata_invalid");
}
finally { closeSync(fd); }
}

Expand Down
3 changes: 2 additions & 1 deletion structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ The shared CONNECT primitive accepts optional `allowedTargets` authorities. It s
normalizes that list at startup; an empty list denies all, and other host/port pairs receive 403
before tunnel selection or dialing. Authentication and loopback refusal remain in force.
Existing Claude consumers omit this option and retain blind forwarding; it enables no new integration or certificate trust.
Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo.
The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority.

On macOS, when the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also
Expand Down Expand Up @@ -228,7 +229,7 @@ application-access dialogs are controlled by macOS; restart or upgrade does not
`picker-ca-store.ts` owns the versioned OS credential service, canonical-config identity namespace,
bounded exact-shape payload, full constrained CA profile, validity and P-256 private-key match validation.
`picker-ca-persistence.ts` validates public `authority.json` and `authority-init.json` records under
the canonical CA lock. Initialization journals the config identity, new fingerprint and public
the canonical CA lock, rejecting symlinks and mismatched pre-open/path and descriptor identities. Initialization journals the config identity, new fingerprint and public
predecessor before writing the credential, verifies readback, then commits metadata and publication;
it removes the journal last. Recovery requires matching journal/store identity; missing initialized
credentials, unavailable storage or inconsistent metadata fail closed without publishing a replacement.
Expand Down
Loading
Loading