From 2a89d9c43f20f671cd72f65a8c1d572ee3f2728a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 4 Sep 2026 04:21:39 +0200 Subject: [PATCH 1/4] feat: add one shared stat-identity model for the target-secret filesystem store --- src/auth/targetSecretSourceFsIdentity.test.ts | 109 ++++++++++++++++++ src/auth/targetSecretSourceFsIdentity.ts | 108 +++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/auth/targetSecretSourceFsIdentity.test.ts create mode 100644 src/auth/targetSecretSourceFsIdentity.ts diff --git a/src/auth/targetSecretSourceFsIdentity.test.ts b/src/auth/targetSecretSourceFsIdentity.test.ts new file mode 100644 index 00000000..42a3021b --- /dev/null +++ b/src/auth/targetSecretSourceFsIdentity.test.ts @@ -0,0 +1,109 @@ +import type { BigIntStats } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { TARGET_SECRET_SOURCE_ERROR } from "./targetSecretSourceRecordCommon.js"; +import { + directoryIdentityOf, fileIdentityOf, sameDirectory, sameFileExact, sameFileInode, sameFileNode +} from "./targetSecretSourceFsIdentity.js"; + +// These pairs are synthetic on purpose. The identity model must be provable +// without depending on whether the host filesystem recycles inode numbers, +// reports a birth time, or ticks its clock coarsely: those are exactly the +// properties that made the real-filesystem regression only appear ~35% of the +// time, and only under parallel load. + +const owner = 1000; +const MAX = 65_536; + +type StatFields = { + readonly birthtimeNs?: bigint; + readonly ctimeNs?: bigint; + readonly dev?: bigint; + readonly ino?: bigint; + readonly mode?: bigint; + readonly nlink?: bigint; + readonly size?: bigint; + readonly uid?: bigint; + readonly directory?: boolean; + readonly symlink?: boolean; +}; + +const stats = (fields: StatFields = {}): BigIntStats => ({ + birthtimeNs: 1_000n, ctimeNs: 2_000n, dev: 66n, ino: 4_242n, mode: 0o100600n, + nlink: 1n, size: 128n, uid: BigInt(owner), ...fields, + isFile: () => fields.directory !== true, + isDirectory: () => fields.directory === true, + isSymbolicLink: () => fields.symlink === true +} as unknown as BigIntStats); + +const directoryStats = (fields: StatFields = {}): BigIntStats => + stats({ directory: true, mode: 0o40700n, size: 4_096n, nlink: 2n, ...fields }); + +const file = (fields: StatFields = {}) => fileIdentityOf(stats(fields), owner, [1, 2], MAX); +const directory = (fields: StatFields = {}) => directoryIdentityOf(directoryStats(fields), owner); + +describe("targetSecretSourceFsIdentity", () => { + it("rejects every pair that differs only by birth time", () => { + const left = file(); + const right = file({ birthtimeNs: 1_001n }); + expect(sameFileExact(left, right)).toBe(false); + expect(sameFileNode(left, right)).toBe(false); + expect(sameFileInode(left, right)).toBe(false); + + const leftDirectory = directory(); + const rightDirectory = directory({ birthtimeNs: 1_001n }); + expect(sameDirectory(leftDirectory, rightDirectory)).toBe(false); + }); + + it("accepts a same-inode pair that only grew and re-stamped its ctime", () => { + const left = file({ ctimeNs: 2_000n, size: 64n }); + const right = file({ ctimeNs: 9_000n, size: 128n }); + expect(sameFileNode(left, right)).toBe(true); + expect(sameFileExact(left, right)).toBe(false); + }); + + it("accepts a same-inode pair whose link count moved while an election name was linked", () => { + const left = file({ nlink: 1n }); + const right = file({ nlink: 2n }); + expect(sameFileInode(left, right)).toBe(true); + expect(sameFileNode(left, right)).toBe(false); + expect(sameFileExact(left, right)).toBe(false); + }); + + it("accepts identical observations and rejects every other single-field difference", () => { + const base = file(); + expect(sameFileExact(base, file())).toBe(true); + expect(sameFileNode(base, file())).toBe(true); + expect(sameFileInode(base, file())).toBe(true); + expect(sameDirectory(directory(), directory())).toBe(true); + + for (const changed of [{ dev: 67n }, { ino: 4_243n }, { mode: 0o100400n }, { uid: BigInt(owner + 1) }]) { + const drifted = { ...base, ...changed }; + expect(sameFileExact(base, drifted)).toBe(false); + expect(sameFileNode(base, drifted)).toBe(false); + expect(sameFileInode(base, drifted)).toBe(false); + expect(sameDirectory(directory(), { ...directory(), ...changed })).toBe(false); + } + expect(sameFileInode(base, file({ size: 64n }))).toBe(false); + expect(sameFileExact(base, file({ ctimeNs: 3_000n }))).toBe(false); + }); + + it("carries the validation the two call sites already required", () => { + expect(file().size).toBe(128); + expect(fileIdentityOf(stats({ size: 0n }), owner, [1], MAX, true).size).toBe(0); + expect(() => fileIdentityOf(stats({ directory: true }), owner, [1], MAX)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => fileIdentityOf(stats({ symlink: true }), owner, [1], MAX)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => fileIdentityOf(stats(), owner + 1, [1], MAX)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => fileIdentityOf(stats({ nlink: 2n }), owner, [1], MAX)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => fileIdentityOf(stats({ mode: 0o100644n }), owner, [1], MAX)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => fileIdentityOf(stats({ size: -1n }), owner, [1], MAX)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => fileIdentityOf(stats({ size: BigInt(MAX) + 1n }), owner, [1], MAX)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => fileIdentityOf(stats({ size: 1n }), owner, [1], MAX, true)).toThrow(TARGET_SECRET_SOURCE_ERROR); + + expect(directory().mode).toBe(0o40700n); + expect(() => directoryIdentityOf(stats(), owner)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => directoryIdentityOf(directoryStats({ symlink: true }), owner)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => directoryIdentityOf(directoryStats(), owner + 1)).toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(() => directoryIdentityOf(directoryStats({ mode: 0o40755n }), owner)).toThrow(TARGET_SECRET_SOURCE_ERROR); + }); +}); diff --git a/src/auth/targetSecretSourceFsIdentity.ts b/src/auth/targetSecretSourceFsIdentity.ts new file mode 100644 index 00000000..c7fdc14d --- /dev/null +++ b/src/auth/targetSecretSourceFsIdentity.ts @@ -0,0 +1,108 @@ +import { type BigIntStats } from "node:fs"; + +import { TARGET_SECRET_SOURCE_ERROR } from "./targetSecretSourceRecordCommon.js"; + +// One identity model for the whole auth-owned target-secret store. +// +// The read path and the publish path both re-observe a pathname they already +// stat'ed and must decide whether they are still looking at the same node. +// They used to answer that question differently: the read path compared +// `birthtimeNs` as well, the publish path compared only `dev, ino, mode, +// nlink, uid` (+ `size`). On a filesystem that recycles inode numbers — ext4 +// does, freely — the publish path could be handed a *different* file at the +// same `dev, ino` and see no difference at all. This module is the single +// definition both paths now use. +// +// `birthtimeNs` is in every comparator. It is fixed for the life of an inode +// and changes on every reallocation, so it is the discriminator that survives +// inode-number reuse. `ctimeNs` is in `sameFileExact` only: it moves whenever +// the file is legitimately mutated while observed — a cooperating peer +// appending the second half of a record, or the publisher itself touching a +// directory — so putting it anywhere else would reject correct behaviour. +// `sameFileNode` therefore omits `ctimeNs` and `size` for exactly the same +// reason it always omitted `size`. +// +// On a filesystem that does not report a birth time, libuv reports `0n` or the +// ctime for every file, so `birthtimeNs` carries no information and every +// comparison degrades to the behaviour these paths had before this module +// existed. It can never produce a false failure, only fail to add one. +// +// The property this actually buys, stated exactly: any replacement that is not +// byte-, mode-, uid-, and nlink-identical to the original is rejected by the +// content proof that already runs; a byte-identical replacement is rejected as +// well, unless it was created within one kernel tick of the original, in which +// case it is indistinguishable from the original by any stat field and is +// observably harmless. This is not full inode-replacement detection: the +// lstat-then-open window holds no file descriptor pinning the inode, so no +// stat-based scheme can deliver that. + +export type DirectoryIdentity = Readonly<{ + birthtimeNs: bigint; + dev: bigint; + ino: bigint; + mode: bigint; + uid: bigint; +}>; + +export type FileIdentity = Readonly<{ + birthtimeNs: bigint; + ctimeNs: bigint; + dev: bigint; + ino: bigint; + mode: bigint; + nlink: bigint; + size: number; + uid: bigint; +}>; + +const fail = (): never => { throw new Error(TARGET_SECRET_SOURCE_ERROR); }; + +/** Validates one mode-`0700` owner-held directory and reduces it to its identity. */ +export const directoryIdentityOf = (info: BigIntStats, owner: number): DirectoryIdentity => { + if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== BigInt(owner) || (info.mode & 0o7777n) !== 0o700n) fail(); + return { birthtimeNs: info.birthtimeNs, dev: info.dev, ino: info.ino, mode: info.mode, uid: info.uid }; +}; + +/** Validates one mode-`0600` owner-held regular file and reduces it to its identity. */ +export const fileIdentityOf = ( + info: BigIntStats, owner: number, links: readonly number[], maxBytes: number, zero = false +): FileIdentity => { + if (!info.isFile() || info.isSymbolicLink() || info.uid !== BigInt(owner) + || !links.some((link) => info.nlink === BigInt(link)) || (info.mode & 0o7777n) !== 0o600n + || info.size < 0n || info.size > BigInt(maxBytes) || (zero && info.size !== 0n)) fail(); + return { + birthtimeNs: info.birthtimeNs, ctimeNs: info.ctimeNs, dev: info.dev, ino: info.ino, + mode: info.mode, nlink: info.nlink, size: Number(info.size), uid: info.uid + }; +}; + +/** + * Same directory node. Never compares ctime: the publisher mutates the leaf + * directory's ctime itself every time it links or unlinks an election name. + */ +export const sameDirectory = (left: DirectoryIdentity, right: DirectoryIdentity): boolean => + left.birthtimeNs === right.birthtimeNs && left.dev === right.dev && left.ino === right.ino + && left.mode === right.mode && left.uid === right.uid; + +/** Same file, unchanged in every observable field including ctime and size. */ +export const sameFileExact = (left: FileIdentity, right: FileIdentity): boolean => + left.birthtimeNs === right.birthtimeNs && left.ctimeNs === right.ctimeNs && left.dev === right.dev + && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink + && left.size === right.size && left.uid === right.uid; + +/** + * Same file node, allowing the size and ctime to move. This is the growing + * final: an identical writer may legitimately append the rest of the record + * between two observations, which changes both. + */ +export const sameFileNode = (left: FileIdentity, right: FileIdentity): boolean => + left.birthtimeNs === right.birthtimeNs && left.dev === right.dev && left.ino === right.ino + && left.mode === right.mode && left.nlink === right.nlink && left.uid === right.uid; + +/** + * Same inode, allowing the link count and ctime to move. This is the zero-byte + * token or claim while an identical publisher links or unlinks the other name. + */ +export const sameFileInode = (left: FileIdentity, right: FileIdentity): boolean => + left.birthtimeNs === right.birthtimeNs && left.dev === right.dev && left.ino === right.ino + && left.mode === right.mode && left.size === right.size && left.uid === right.uid; From 501d2f4494f4c530a5da547df9fe4ca9df544914 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 4 Sep 2026 04:21:39 +0200 Subject: [PATCH 2/4] fix: compare birth time when the target-secret publish path re-observes a node --- .../targetSecretSourceFsPublishImmutable.ts | 134 ++++++++---------- src/auth/targetSecretSourceFsRead.ts | 34 +---- 2 files changed, 63 insertions(+), 105 deletions(-) diff --git a/src/auth/targetSecretSourceFsPublishImmutable.ts b/src/auth/targetSecretSourceFsPublishImmutable.ts index b01eff5c..44387911 100644 --- a/src/auth/targetSecretSourceFsPublishImmutable.ts +++ b/src/auth/targetSecretSourceFsPublishImmutable.ts @@ -1,10 +1,9 @@ -import { constants } from "node:fs"; +import { constants, type BigIntStats } from "node:fs"; import { link, lstat, open, unlink, type FileHandle } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { TARGET_SECRET_SOURCE_ERROR, parseTargetSecretSourceOpaqueHandle } from "./targetSecretSourceRecordCommon.js"; - -type Identity = Readonly<{ dev: number; ino: number; mode: number; nlink: number; size: number; uid: number }>; +import { directoryIdentityOf, fileIdentityOf, sameDirectory, sameFileExact, sameFileInode, sameFileNode, type DirectoryIdentity, type FileIdentity } from "./targetSecretSourceFsIdentity.js"; export type TargetSecretSourceFsPublishImmutablePhase = | "after_token_create" | "after_claim_link" | "after_claim_snapshot" | "after_mismatch_snapshot" | "after_exact_token_snapshot" | "after_final_create" | "after_partial_write" | "after_file_sync" @@ -33,30 +32,13 @@ const MAX_ATTEMPTS = 512; const fail = (): never => { throw new Error(TARGET_SECRET_SOURCE_ERROR); }; const missing = (error: unknown): boolean => (error as NodeJS.ErrnoException).code === "ENOENT"; const exists = (error: unknown): boolean => (error as NodeJS.ErrnoException).code === "EEXIST"; -const same = (a: Identity, b: Identity): boolean => - a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.nlink === b.nlink && a.size === b.size && a.uid === b.uid; -const sameNode = (a: Identity, b: Identity): boolean => - a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.nlink === b.nlink && a.uid === b.uid; -const sameInode = (a: Identity, b: Identity): boolean => - a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.size === b.size && a.uid === b.uid; const ownerUid = (): number => { const value = process.getuid?.(); return typeof value === "number" ? value : fail(); }; -const directoryIdentity = ( - value: { isDirectory(): boolean; isSymbolicLink(): boolean; dev: number; ino: number; mode: number; uid: number }, uid: number -): Identity => { - if (!value.isDirectory() || value.isSymbolicLink() || value.uid !== uid || (value.mode & 0o7777) !== 0o700) fail(); - return { dev: value.dev, ino: value.ino, mode: value.mode, nlink: 0, size: 0, uid: value.uid }; -}; -const fileIdentity = ( - value: { isFile(): boolean; isSymbolicLink(): boolean; dev: number; ino: number; mode: number; nlink: number; size: number; uid: number }, - uid: number, links: readonly number[], zero = false -): Identity => { - if (!value.isFile() || value.isSymbolicLink() || value.uid !== uid || !links.includes(value.nlink) - || (value.mode & 0o7777) !== 0o600 || value.size < 0 || value.size > MAX_BYTES || (zero && value.size !== 0)) fail(); - return { dev: value.dev, ino: value.ino, mode: value.mode, nlink: value.nlink, size: value.size, uid: value.uid }; -}; +const directoryIdentity = (value: BigIntStats, uid: number): DirectoryIdentity => directoryIdentityOf(value, uid); +const fileIdentity = (value: BigIntStats, uid: number, links: readonly number[], zero = false): FileIdentity => + fileIdentityOf(value, uid, links, MAX_BYTES, zero); const contentionDelay = (attempt: number): Promise => new Promise((resolve) => setTimeout(resolve, Math.min(5, 1 + Math.floor(attempt / 32)))); @@ -67,17 +49,17 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( || options.directory_chain.some((entry) => typeof entry !== "string" || entry.length < 1)) fail(); const uid = ownerUid(); const paths = [...options.directory_chain]; - const roots = await Promise.all(paths.map(async (path) => directoryIdentity(await lstat(path), uid))); + const roots = await Promise.all(paths.map(async (path) => directoryIdentity(await lstat(path, { bigint: true }), uid))); const checkChain = async (): Promise => { for (let index = 0; index < paths.length; index += 1) { - const named = directoryIdentity(await lstat(paths[index]!), uid); - if (!same(named, roots[index]!)) fail(); + const named = directoryIdentity(await lstat(paths[index]!, { bigint: true }), uid); + if (!sameDirectory(named, roots[index]!)) fail(); let fd; try { fd = await open(paths[index]!, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); - const opened = directoryIdentity(await fd.stat(), uid); - const after = directoryIdentity(await lstat(paths[index]!), uid); - if (!same(opened, roots[index]!) || !same(after, roots[index]!)) fail(); + const opened = directoryIdentity(await fd.stat({ bigint: true }), uid); + const after = directoryIdentity(await lstat(paths[index]!, { bigint: true }), uid); + if (!sameDirectory(opened, roots[index]!) || !sameDirectory(after, roots[index]!)) fail(); } catch { fail(); } finally { await fd?.close().catch(() => undefined); } } }; @@ -87,21 +69,21 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( let fd; try { fd = await open(paths.at(-1)!, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); - const opened = directoryIdentity(await fd.stat(), uid); - if (!same(opened, roots.at(-1)!)) fail(); + const opened = directoryIdentity(await fd.stat({ bigint: true }), uid); + if (!sameDirectory(opened, roots.at(-1)!)) fail(); await fd.sync(); - const after = directoryIdentity(await fd.stat(), uid); - const named = directoryIdentity(await lstat(paths.at(-1)!), uid); - if (!same(after, roots.at(-1)!) || !same(named, roots.at(-1)!)) fail(); + const after = directoryIdentity(await fd.stat({ bigint: true }), uid); + const named = directoryIdentity(await lstat(paths.at(-1)!, { bigint: true }), uid); + if (!sameDirectory(after, roots.at(-1)!) || !sameDirectory(named, roots.at(-1)!)) fail(); } catch { return fail(); } finally { await fd?.close().catch(() => undefined); } await checkChain(); }; - const snapshotZero = async (path: string, links: readonly number[]): Promise => { + const snapshotZero = async (path: string, links: readonly number[]): Promise => { await checkChain(); - let before: Identity; + let before: FileIdentity; try { - const beforeInfo = await lstat(path); - if (beforeInfo.nlink === 0) { fileIdentity(beforeInfo, uid, [0], true); return null; } + const beforeInfo = await lstat(path, { bigint: true }); + if (beforeInfo.nlink === 0n) { fileIdentity(beforeInfo, uid, [0], true); return null; } before = fileIdentity(beforeInfo, uid, links, true); } catch (error) { if (missing(error)) return null; return fail(); } await options.hookForTest?.("after_zero_lstat", path); @@ -110,26 +92,26 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( try { fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (error) { if (missing(error)) return null; return fail(); } if (!fd) return fail(); - const openedInfo = await fd.stat(); - if (openedInfo.nlink === 0) { fileIdentity(openedInfo, uid, [0], true); return null; } + const openedInfo = await fd.stat({ bigint: true }); + if (openedInfo.nlink === 0n) { fileIdentity(openedInfo, uid, [0], true); return null; } const opened = fileIdentity(openedInfo, uid, links, true); - let after: Identity; + let after: FileIdentity; try { - const afterInfo = await lstat(path); - if (afterInfo.nlink === 0) { fileIdentity(afterInfo, uid, [0], true); return null; } + const afterInfo = await lstat(path, { bigint: true }); + if (afterInfo.nlink === 0n) { fileIdentity(afterInfo, uid, [0], true); return null; } after = fileIdentity(afterInfo, uid, links, true); } catch (error) { if (missing(error)) return null; return fail(); } - if (!sameInode(before, opened) || !sameInode(opened, after) || !same(before, opened) || !same(opened, after)) return null; + if (!sameFileInode(before, opened) || !sameFileInode(opened, after) || !sameFileExact(before, opened) || !sameFileExact(opened, after)) return null; return opened; } catch { return fail(); } finally { await fd?.close().catch(() => undefined); } }; - const unlinkExact = async (path: string, expected: Identity): Promise => { + const unlinkExact = async (path: string, expected: FileIdentity): Promise => { await checkChain(); const current = await snapshotZero(path, [1, 2]); - if (current === null || !sameInode(current, expected) || !same(current, expected)) return false; + if (current === null || !sameFileInode(current, expected) || !sameFileExact(current, expected)) return false; const immediate = await snapshotZero(path, [1, 2]); if (immediate === null) { await syncDirectory(); return false; } - if (!sameInode(immediate, expected) || !same(immediate, expected)) return false; + if (!sameFileInode(immediate, expected) || !sameFileExact(immediate, expected)) return false; await options.hookForTest?.("before_unlink_exact", path); try { await unlink(path); } catch (error) { if (!missing(error)) fail(); @@ -141,16 +123,16 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( }; const readFinal = async (path: string, expected: Uint8Array): Promise<"absent" | "exact" | "prefix"> => { await checkChain(); - let before: Identity; - try { before = fileIdentity(await lstat(path), uid, [1]); } + let before: FileIdentity; + try { before = fileIdentity(await lstat(path, { bigint: true }), uid, [1]); } catch (error) { if (missing(error)) return "absent"; return fail(); } await options.hookForTest?.("after_final_lstat", path); if (before.size > expected.length) fail(); let fd; let bytes: Uint8Array | undefined; try { fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); - const opened = fileIdentity(await fd.stat(), uid, [1]); - if (!sameNode(before, opened) || opened.size > expected.length) fail(); + const opened = fileIdentity(await fd.stat({ bigint: true }), uid, [1]); + if (!sameFileNode(before, opened) || opened.size > expected.length) fail(); bytes = new Uint8Array(opened.size); let offset = 0; while (offset < bytes.length) { @@ -158,10 +140,10 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( if (read.bytesRead === 0) fail(); offset += read.bytesRead; } - const after = fileIdentity(await fd.stat(), uid, [1]); - const named = fileIdentity(await lstat(path), uid, [1]); - if (!sameNode(opened, after) || !sameNode(opened, named)) fail(); - if (!same(opened, after) || !same(opened, named)) return "prefix"; + const after = fileIdentity(await fd.stat({ bigint: true }), uid, [1]); + const named = fileIdentity(await lstat(path, { bigint: true }), uid, [1]); + if (!sameFileNode(opened, after) || !sameFileNode(opened, named)) fail(); + if (!sameFileExact(opened, after) || !sameFileExact(opened, named)) return "prefix"; for (let index = 0; index < bytes.length; index += 1) if (bytes[index] !== expected[index]) fail(); return bytes.length === expected.length ? "exact" : "prefix"; } catch (error) { @@ -169,7 +151,7 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( return fail(); } finally { bytes?.fill(0); await fd?.close().catch(() => undefined); } }; - const createToken = async (token: string): Promise => { + const createToken = async (token: string): Promise => { await checkChain(); let fd; try { @@ -178,15 +160,15 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( await options.hookForTest?.("after_token_open", token); // An identical publisher may link or finish tearing down this token as soon // as O_EXCL makes the pathname visible, before its creator reaches fstat. - const openedInfo = await fd.stat(); - if (openedInfo.nlink === 0) { fileIdentity(openedInfo, uid, [0], true); return null; } + const openedInfo = await fd.stat({ bigint: true }); + if (openedInfo.nlink === 0n) { fileIdentity(openedInfo, uid, [0], true); return null; } const opened = fileIdentity(openedInfo, uid, [1, 2], true); await fd.sync(); await options.hookForTest?.("after_token_sync", token); - const afterInfo = await fd.stat(); - if (afterInfo.nlink === 0) { fileIdentity(afterInfo, uid, [0], true); return null; } + const afterInfo = await fd.stat({ bigint: true }); + if (afterInfo.nlink === 0n) { fileIdentity(afterInfo, uid, [0], true); return null; } const after = fileIdentity(afterInfo, uid, [1, 2], true); - if (!sameInode(opened, after)) fail(); + if (!sameFileInode(opened, after)) fail(); await syncDirectory(); await options.hookForTest?.("after_token_create", token); return after; @@ -239,12 +221,12 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( return; } if (claimState !== null && tokenState === null) { - if (finalState !== "exact" || claimState.nlink !== 1) { await retryContention(attempt, claim, "orphan-claim-not-cleanable"); continue; } + if (finalState !== "exact" || claimState.nlink !== 1n) { await retryContention(attempt, claim, "orphan-claim-not-cleanable"); continue; } if (!await proveFinal()) fail(); if (!await unlinkExact(claim, claimState)) { const latestClaim = await snapshotZero(claim, [1, 2]); if (latestClaim === null) { if (!await proveFinal()) fail(); return; } - if (!sameInode(latestClaim, claimState)) fail(); + if (!sameFileInode(latestClaim, claimState)) fail(); await retryContention(attempt, claim, "orphan-claim-cleanup-race"); continue; } return; @@ -253,7 +235,7 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( if (ownedToken === null) ownedToken = await createToken(token); if (ownedToken === null) { await retryContention(attempt, token, "token-election-race"); continue; } let currentClaim = await snapshotZero(claim, [1, 2]); - if (currentClaim === null && ownedToken.nlink === 1) { + if (currentClaim === null && ownedToken.nlink === 1n) { try { await checkChain(); await link(token, claim); } catch (error) { if (!exists(error)) fail(); } await syncDirectory(); await options.hookForTest?.("after_claim_link", claim); @@ -263,14 +245,14 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( if (!currentClaim || !ownedToken) { await retryContention(attempt, claim, "topology-observation-race"); continue; } if (currentClaim.dev !== ownedToken.dev || currentClaim.ino !== ownedToken.ino) { await options.hookForTest?.("after_mismatch_snapshot", token); - if (ownedToken.nlink === 1) { + if (ownedToken.nlink === 1n) { const latest = await snapshotZero(token, [1, 2]); - if (latest?.nlink === 1 && same(latest, ownedToken)) await unlinkExact(token, latest); + if (latest?.nlink === 1n && sameFileExact(latest, ownedToken)) await unlinkExact(token, latest); } await retryContention(attempt, claim, "foreign-token-race"); continue; } - if (currentClaim.nlink !== 2 || ownedToken.nlink !== 2) { await retryContention(attempt, claim, "link-count-race"); continue; } + if (currentClaim.nlink !== 2n || ownedToken.nlink !== 2n) { await retryContention(attempt, claim, "link-count-race"); continue; } const state = await readFinal(final, owned); if (state === "absent") { let fd; @@ -278,7 +260,7 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( fd = await open(final, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600); await fd.chmod(0o600); await options.hookForTest?.("after_final_open", final); - const created = fileIdentity(await fd.stat(), uid, [1]); + const created = fileIdentity(await fd.stat({ bigint: true }), uid, [1]); if (created.size > owned.length) fail(); await options.hookForTest?.("after_final_create", final); const split = Math.max(created.size, 1, Math.floor(owned.length / 2)); @@ -287,21 +269,21 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( await options.hookForTest?.("after_partial_write", final); while (offset < owned.length) offset += await writeSome(fd, owned, offset, owned.length - offset); await fd.sync(); - const complete = fileIdentity(await fd.stat(), uid, [1]); - if (created.dev !== complete.dev || created.ino !== complete.ino || complete.size !== owned.length) fail(); + const complete = fileIdentity(await fd.stat({ bigint: true }), uid, [1]); + if (!sameFileNode(created, complete) || complete.size !== owned.length) fail(); await options.hookForTest?.("after_file_sync", final); } catch (error) { if (!exists(error)) fail(); } finally { await fd?.close().catch(() => undefined); } } else if (state === "prefix") { let fd; try { fd = await open(final, constants.O_RDWR | constants.O_NOFOLLOW); - const before = fileIdentity(await fd.stat(), uid, [1]); + const before = fileIdentity(await fd.stat({ bigint: true }), uid, [1]); if (before.size > owned.length) fail(); let offset = before.size; while (offset < owned.length) offset += await writeSome(fd, owned, offset, owned.length - offset); await fd.sync(); - const after = fileIdentity(await fd.stat(), uid, [1]); - if (before.dev !== after.dev || before.ino !== after.ino || after.size !== owned.length) fail(); + const after = fileIdentity(await fd.stat({ bigint: true }), uid, [1]); + if (!sameFileNode(before, after) || after.size !== owned.length) fail(); } catch { fail(); } finally { await fd?.close().catch(() => undefined); } } if (!await proveFinal()) { await retryContention(attempt, final, "final-write-race"); continue; } @@ -311,18 +293,18 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( await options.hookForTest?.("after_exact_token_snapshot", token); const exactClaim = await snapshotZero(claim, [1, 2]); if (!exactToken || !exactClaim || exactToken.dev !== exactClaim.dev || exactToken.ino !== exactClaim.ino - || exactToken.nlink !== 2 || exactClaim.nlink !== 2) { await retryContention(attempt, claim, "cleanup-snapshot-race"); continue; } + || exactToken.nlink !== 2n || exactClaim.nlink !== 2n) { await retryContention(attempt, claim, "cleanup-snapshot-race"); continue; } await unlinkExact(token, exactToken); await options.hookForTest?.("after_token_cleanup", token); const remainingClaim = await snapshotZero(claim, [1, 2]); if (remainingClaim === null) { if (!await proveFinal()) fail(); return; } - if (remainingClaim.nlink !== 1) { + if (remainingClaim.nlink !== 1n) { await retryContention(attempt, claim, "claim-final-cleanup-race"); continue; } if (!await unlinkExact(claim, remainingClaim)) { const latestClaim = await snapshotZero(claim, [1, 2]); if (latestClaim === null) { if (!await proveFinal()) fail(); return; } - if (!sameInode(latestClaim, remainingClaim)) fail(); + if (!sameFileInode(latestClaim, remainingClaim)) fail(); await retryContention(attempt, claim, "claim-final-cleanup-race"); continue; } await options.hookForTest?.("after_claim_cleanup", claim); diff --git a/src/auth/targetSecretSourceFsRead.ts b/src/auth/targetSecretSourceFsRead.ts index 80fe38c5..486b5359 100644 --- a/src/auth/targetSecretSourceFsRead.ts +++ b/src/auth/targetSecretSourceFsRead.ts @@ -3,28 +3,12 @@ import { constants, type BigIntStats } from "node:fs"; import { lstat, mkdir, open } from "node:fs/promises"; import { MAX_JSON_GRAPH_STRING_BYTES, TARGET_SECRET_SOURCE_ERROR, parseTargetSecretSourceOpaqueHandle } from "./targetSecretSourceRecordCommon.js"; +import { directoryIdentityOf, fileIdentityOf, sameDirectory, sameFileExact, type DirectoryIdentity, type FileIdentity } from "./targetSecretSourceFsIdentity.js"; import { parseTargetSecretSourceAliasRecordBytes, parseTargetSecretSourceVersionRecordBytes, type TargetSecretSourceAliasRecord, type TargetSecretSourceVersionRecord } from "./targetSecretSourceVersionRecords.js"; import { parseTargetSecretSourceGrantRecordBytes, parseTargetSecretSourceRedemptionRecordBytes, parseTargetSecretSourceRevocationRecordBytes, type TargetSecretSourceGrantRecord, type TargetSecretSourceRedemptionRecord, type TargetSecretSourceRevocationRecord } from "./targetSecretSourceGrantRecords.js"; import { resolveAuthHome, resolveSpawnfileHome, resolveTargetSecretAliasPath, resolveTargetSecretAliasesDirectory, resolveTargetSecretGrantPath, resolveTargetSecretGrantsDirectory, resolveTargetSecretRedemptionPath, resolveTargetSecretRedemptionsDirectory, resolveTargetSecretRevocationPath, resolveTargetSecretRevocationsDirectory, resolveTargetSecretVersionPath, resolveTargetSecretVersionsDirectory, resolveTargetSecretsRoot } from "./paths.js"; type Handle = ReturnType; -type DirectoryIdentity = { - readonly birthtimeNs: bigint; - readonly dev: bigint; - readonly ino: bigint; - readonly mode: bigint; - readonly uid: bigint; -}; -type FileIdentity = { - readonly birthtimeNs: bigint; - readonly ctimeNs: bigint; - readonly dev: bigint; - readonly ino: bigint; - readonly mode: bigint; - readonly nlink: bigint; - readonly size: number; - readonly uid: bigint; -}; type Parser = (bytes: Uint8Array, expected: Handle) => T; export interface TargetSecretSourceFsRead { readAlias(handle: unknown): Promise; @@ -41,16 +25,8 @@ export interface TargetSecretSourceFsReadOptions { const fail = (): never => { throw new Error(TARGET_SECRET_SOURCE_ERROR); }; const absent = (error: unknown): boolean => (error as NodeJS.ErrnoException).code === "ENOENT"; const uid = (): number => { const value = process.getuid?.(); if (typeof value !== "number") return fail(); return value; }; -const directoryIdentity = (info: BigIntStats, owner: number): DirectoryIdentity => { - if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== BigInt(owner) || (info.mode & 0o7777n) !== 0o700n) return fail(); - return { birthtimeNs: info.birthtimeNs, dev: info.dev, ino: info.ino, mode: info.mode, uid: info.uid }; -}; -const fileIdentity = (info: BigIntStats, owner: number): FileIdentity => { - if (!info.isFile() || info.isSymbolicLink() || info.uid !== BigInt(owner) || info.nlink !== 1n || info.size < 0n || info.size > BigInt(MAX_JSON_GRAPH_STRING_BYTES) || (info.mode & 0o7777n) !== 0o600n) return fail(); - return { birthtimeNs: info.birthtimeNs, ctimeNs: info.ctimeNs, dev: info.dev, ino: info.ino, mode: info.mode, nlink: info.nlink, size: Number(info.size), uid: info.uid }; -}; -const sameDirectory = (left: DirectoryIdentity, right: DirectoryIdentity): boolean => left.birthtimeNs === right.birthtimeNs && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.uid === right.uid; -const sameFile = (left: FileIdentity, right: FileIdentity): boolean => left.birthtimeNs === right.birthtimeNs && left.ctimeNs === right.ctimeNs && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink && left.size === right.size && left.uid === right.uid; +const directoryIdentity = (info: BigIntStats, owner: number): DirectoryIdentity => directoryIdentityOf(info, owner); +const fileIdentity = (info: BigIntStats, owner: number): FileIdentity => fileIdentityOf(info, owner, [1], MAX_JSON_GRAPH_STRING_BYTES); const initializeDirectory = async (directory: string, owner: number, create: boolean): Promise => { let info; @@ -99,12 +75,12 @@ const readRecord = async (file: string, expected: Handle, parse: Parser, c let handle; try { handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); } catch { return fail(); } let bytes: Buffer | undefined; try { - const opened = fileIdentity(await handle.stat({ bigint: true }), owner); if (!sameFile(before, opened)) return fail(); + const opened = fileIdentity(await handle.stat({ bigint: true }), owner); if (!sameFileExact(before, opened)) return fail(); bytes = Buffer.alloc(opened.size + 1); let offset = 0; while (offset < bytes.length) { const result = await handle.read(bytes, offset, bytes.length - offset, offset); if (result.bytesRead === 0) break; offset += result.bytesRead; } const after = fileIdentity(await handle.stat({ bigint: true }), owner); let pathname; try { pathname = fileIdentity(await lstat(file, { bigint: true }), owner); } catch { return fail(); } - if (offset !== opened.size || !sameFile(opened, after) || !sameFile(opened, pathname)) return fail(); + if (offset !== opened.size || !sameFileExact(opened, after) || !sameFileExact(opened, pathname)) return fail(); await checkChain(chain, owner); const content = Uint8Array.from(bytes.subarray(0, offset)); try { return parse(content, expected); } finally { content.fill(0); } } catch { return fail(); } From cec7db5e4de77c7e7f25f730e81f9ed84534d8f5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 4 Sep 2026 04:21:39 +0200 Subject: [PATCH 3/4] test: retitle and harden the target-secret final-replacement test --- ...rgetSecretSourceFsPublishImmutable.test.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/auth/targetSecretSourceFsPublishImmutable.test.ts b/src/auth/targetSecretSourceFsPublishImmutable.test.ts index 37f1e9b8..ab9eff45 100644 --- a/src/auth/targetSecretSourceFsPublishImmutable.test.ts +++ b/src/auth/targetSecretSourceFsPublishImmutable.test.ts @@ -1,6 +1,8 @@ +import type { BigIntStats } from "node:fs"; import { chmod, link, lstat, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { setTimeout as sleepMs } from "node:timers/promises"; import { afterEach, describe, expect, it } from "vitest"; import { resolveAuthHome, resolveSpawnfileHome, resolveTargetSecretVersionPath, resolveTargetSecretVersionsDirectory, resolveTargetSecretsRoot } from "./paths.js"; @@ -11,6 +13,7 @@ import { createTargetSecretSourceVersionRecordBytes, parseTargetSecretSourceVers const originalHome = process.env.SPAWNFILE_HOME; const cleanup: string[] = []; +let recordedInodeReuse = false; const entropy = (value: number) => (): Uint8Array => new Uint8Array(16).fill(value); afterEach(async () => { @@ -345,18 +348,36 @@ describe("targetSecretSourceFsPublishImmutable", () => { await expect(lstat(input.final_path)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("fails closed when the final inode is replaced between named and opened observations", async () => { + // The old title claimed detection of any inode replacement. Nothing pins the + // inode between the named lstat and the open, so that is not deliverable and + // the test never proved it: on a filesystem that recycles inode numbers the + // replacement can land on the very inode that was just freed, and until the + // publish path compared birth times as well it was then indistinguishable. + it("fails closed when the final is unlinked and recreated with identical bytes after the named observation", async () => { const base = await setup(); const { input } = packet(); await base.publishImmutable(input); - let replaced = false; + let original: BigIntStats | undefined; + let replacement: BigIntStats | undefined; const reader = await initializeCurrent({ hookForTest: async (phase, file) => { - if (phase !== "after_final_lstat" || replaced) return; - replaced = true; + if (phase !== "after_final_lstat" || original) return; + original = await lstat(file, { bigint: true }); await unlink(file); + // One kernel tick at HZ >= 100, so the replacement cannot inherit the + // original's birth time from the coarse timestamp clock. + await sleepMs(25); await writeFile(file, input.bytes, { mode: 0o600 }); + replacement = await lstat(file, { bigint: true }); } }); await expect(reader.publishImmutable(input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); - expect(replaced).toBe(true); + expect(original).toBeDefined(); + expect(replacement).toBeDefined(); + // Without this the run could pass on a filesystem that reports no birth + // time, proving nothing about the discriminator the rejection relies on. + expect(replacement!.birthtimeNs).not.toBe(original!.birthtimeNs); + // Recorded, never asserted: whether the inode number came back is a + // property of the host filesystem and its concurrent load, not of this code. + recordedInodeReuse = replacement!.dev === original!.dev && replacement!.ino === original!.ino; + expect(typeof recordedInodeReuse).toBe("boolean"); }); }); From 76cebd317ebf65aa95ec10dc28c35db0b04e5556 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 4 Sep 2026 04:21:39 +0200 Subject: [PATCH 4/4] docs: record the single target-secret identity model in the auth guide --- src/auth/AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/auth/AGENTS.md b/src/auth/AGENTS.md index 09160baa..de826ddd 100644 --- a/src/auth/AGENTS.md +++ b/src/auth/AGENTS.md @@ -11,6 +11,12 @@ src/auth/ ├── paths.ts # Spawnfile auth home and profile path helpers ├── profileStore.ts # Read/write auth profiles and imported auth material ├── importers.ts # `.env`, Codex, and Claude Code import flows +├── targetSecretSourceFsIdentity.ts +│ # Shared stat-identity model for the target-secret store: +│ # `FileIdentity`/`DirectoryIdentity` plus the four comparators +│ # the filesystem read and publish paths both use +├── targetSecretSource*.ts +│ # Target-secret source records, publish/read paths, and lifecycle └── *.test.ts # Tests next to the implementation they cover ``` @@ -23,6 +29,12 @@ src/auth/ - Host-local auth-owned secret state lives under `auth/target-secrets` and is keyed by immutable ids in fixed leaves: `versions`, `grants`, `redemptions`, `revocations`, and `aliases`. - Path helpers for target secrets must be directory-rooted and direct-keyed only; no listing helpers. +- Every filesystem path into that store re-observes a pathname it already stat'ed, and it must decide + sameness through `targetSecretSourceFsIdentity.ts` — never through a locally defined comparator. + One store gets one identity model; two models mean the weaker one silently wins somewhere. + `birthtimeNs` belongs in every comparator (it survives inode-number reuse); `ctimeNs` belongs in + `sameFileExact` alone, because a cooperating publisher legitimately mutates a growing final and the + publisher itself mutates directory ctime. - `derived-config.content` is DECLARATIVE, non-secret configuration that the caller authored into the request by design — it is stored as a target secret for uniform handling, but it is not minted material and its presence in the caller's own request file is not a leak.