Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion src/claude/intercept/picker-ca-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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 {
// BigInt stats: a Windows file ID exceeds 2^53, so Number ino loses low bits and two files can compare equal.
// Refuse zero identity or metadata changes between the path snapshot and opened descriptor.
let expected: BigIntStats;
try { expected = lstatSync(path, { bigint: true }); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
Expand All @@ -31,7 +32,8 @@ function readState(path: string, configId: string, initialization: boolean): Ini
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
try {
const stat = fstatSync(fd, { bigint: true });
if (stat.dev !== expected.dev || stat.ino !== expected.ino) throw new Error("picker_ca_metadata_unsafe");
if (stat.dev !== expected.dev || stat.ino !== expected.ino || stat.ino === 0n
|| stat.ctimeNs !== expected.ctimeNs) throw new Error("picker_ca_metadata_unsafe");
if (!stat.isFile() || stat.nlink !== 1n || stat.size > BigInt(MAX_PUBLIC_STATE_BYTES)
|| (process.platform !== "win32" && stat.uid !== BigInt(process.getuid!()))) throw new Error();
const bytes = Buffer.alloc(MAX_PUBLIC_STATE_BYTES + 1);
Expand Down
2 changes: 1 addition & 1 deletion structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ application-access dialogs are controlled by macOS; restart or upgrade does not
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, rejecting symlinks and mismatched pre-open/path and descriptor identities.
Device and inode comparisons use bigint stats so distinct full-width file IDs cannot alias through numeric rounding. Initialization journals the config identity, new fingerprint and public
Device and inode comparisons use bigint stats so distinct full-width file IDs cannot alias through numeric rounding. Zero inode identities and changed `ctimeNs` between the path snapshot and opened descriptor are rejected. 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
38 changes: 38 additions & 0 deletions tests/claude-integration/claude-picker-ca-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,44 @@ test("file identities that differ only beyond 2^53 still count as a replacement"
} finally { lstat.mockRestore(); opening.mockRestore(); fstat.mockRestore(); }
});

for (const fault of ["zero inode", "changed status time"] as const) {
test(`unverified metadata identity (${fault}) fails before credential access`, () => {
const dir = root();
const fake = memoryPickerCaStore();
ensurePickerCa(dir, { persistent: true, rotation: "startup", store: fake.store });
const path = join(filesystem.realpathSync(dir), "claude-picker", "authority.json");
const original = readFileSync(path, "utf8");
const { lstatSync, fstatSync, openSync } = filesystem;
let authorityFd = -1;
const lstat = spyOn(filesystem, "lstatSync").mockImplementation(((target: filesystem.PathLike, options?: object) => {
const stat = lstatSync(target, options as never);
return target === path && fault === "zero inode" ? Object.create(stat, { ino: { value: 0n } }) : stat;
}) as typeof lstatSync);
const opening = spyOn(filesystem, "openSync").mockImplementation((target, flags, mode) => {
const fd = openSync(target, flags, mode);
if (target === path) authorityFd = fd;
return fd;
});
const fstat = spyOn(filesystem, "fstatSync").mockImplementation(((fd: number, options?: object) => {
const stat = fstatSync(fd, options as never);
if (fd !== authorityFd) return stat;
const identity = fstatSync(fd, { bigint: true });
return fault === "zero inode" ? Object.create(stat, { ino: { value: 0n } })
: Object.create(stat, { ctimeNs: { value: identity.ctimeNs + 1n } });
}) as typeof fstatSync);
let storeCalls = 0;
try {
expect(() => ensurePickerCa(dir, { persistent: true, rotation: "startup", store: (service, account) => {
storeCalls++;
return fake.store(service, account);
} })).toThrow("picker_ca_metadata_unsafe");
expect(storeCalls).toBe(0);
expect(fake.writes).toBe(1);
expect(readFileSync(path, "utf8")).toBe(original);
} finally { lstat.mockRestore(); opening.mockRestore(); fstat.mockRestore(); }
});
}

test("unsafe picker directory or lock cannot reach the credential store", () => {
for (const pathKind of ["directory", "lock"] as const) {
const dir = root();
Expand Down
Loading