fix(auth): harden restore verification transaction

This commit is contained in:
2026-08-18 00:58:12 +02:00
parent 0651f3316f
commit dee17893b4
10 changed files with 698 additions and 115 deletions
+1
View File
@@ -91,6 +91,7 @@ async function workspaceIntegrity(config: AppConfig): Promise<{
ready: true;
state: "uninitialized" | "active";
workspaces: number;
fingerprint: string;
}> {
const integrity = await new WorkspaceRegistry(config.workspaceRegistry).verifyStoredState();
return { ready: true, ...integrity };
+156 -51
View File
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import { lstatSync, readFileSync } from "node:fs";
import { lstatSync, readdirSync, readFileSync } from "node:fs";
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
@@ -34,6 +34,7 @@ export interface WorkspaceRevision {
export interface StoredWorkspaceIntegrity {
state: "uninitialized" | "active";
workspaces: number;
fingerprint: string;
}
export interface SessionRevisionLease {
@@ -188,43 +189,137 @@ export class WorkspaceRegistry {
/**
* Validate only persisted local registry state. Restore uses this path while the installation
* is stopped: it must neither contact Git nor turn a never-used registry into initialized state.
* The only never-initialized shape is an existing empty root. An initialized root is exactly
* repo/, snapshots/, state/, and locks/: locks contains repository.lock plus an empty
* empty-hooks/, state contains active.json plus an optional empty revision-leases/, and
* snapshots contains exact immutable commit snapshots plus an optional empty runtime/.
* Descendants may contain only ordinary directories and regular files; links and special files
* are rejected by the stable whole-tree fingerprint before any shape is accepted.
*/
async verifyStoredState(): Promise<StoredWorkspaceIntegrity> {
await this.repository.ensureLayout();
return await this.lock.run(async () => {
try {
const stateEntries = await readdir(this.repository.statePath, { withFileTypes: true });
if (stateEntries.some((entry) => (
entry.name !== "active.json" || !entry.isFile() || entry.isSymbolicLink()
))) throw new Error("workspace state directory is partial");
const snapshotEntries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
for (const entry of snapshotEntries) {
const isRuntime = entry.name === "runtime";
const isSnapshot = /^[0-9a-f]{40}$/.test(entry.name);
if ((!isRuntime && !isSnapshot) || !entry.isDirectory() || entry.isSymbolicLink()) {
throw new Error("workspace snapshot directory is partial");
}
}
const hasActiveState = stateEntries.length === 1;
if (!hasActiveState) {
if (snapshotEntries.some((entry) => entry.name !== "runtime") || this.storedPathExists(this.repository.repoPath)) {
throw new Error("workspace registry is partially initialized");
}
return { state: "uninitialized", workspaces: 0 };
}
const active = await this.activeState();
for (const entry of snapshotEntries) {
if (entry.name === "runtime" || entry.name === active.head) continue;
await this.snapshotState(entry.name);
}
return { state: "active", workspaces: active.revisions.length };
} catch (error) {
throw workspaceError(error);
try {
const before = await this.storedStateFingerprint();
const rootEntries = await readdir(this.repository.root, { withFileTypes: true });
if (rootEntries.length === 0) {
const after = await this.storedStateFingerprint();
if (after !== before) throw new Error("workspace registry changed during inspection");
return { state: "uninitialized", workspaces: 0, fingerprint: `sha256:${before}` };
}
});
this.assertExactDirectoryEntries(rootEntries, {
locks: "directory", repo: "directory", snapshots: "directory", state: "directory",
});
this.assertExactDirectoryEntries(
await readdir(this.repository.locksPath, { withFileTypes: true }),
{ "empty-hooks": "directory", "repository.lock": "file" },
);
this.assertExactDirectoryEntries(
await readdir(join(this.repository.locksPath, "empty-hooks"), { withFileTypes: true }),
{},
);
const stateEntries = await readdir(this.repository.statePath, { withFileTypes: true });
const stateShape: Record<string, "file" | "directory"> = { "active.json": "file" };
if (stateEntries.some((entry) => entry.name === "revision-leases")) {
stateShape["revision-leases"] = "directory";
}
this.assertExactDirectoryEntries(stateEntries, stateShape);
if (stateShape["revision-leases"] !== undefined) {
this.assertExactDirectoryEntries(
await readdir(join(this.repository.statePath, "revision-leases"), { withFileTypes: true }),
{},
);
}
const active = this.decodeActiveState(JSON.parse(await readFile(
join(this.repository.statePath, "active.json"), "utf8",
)));
const snapshotEntries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
if (snapshotEntries.length === 0) throw new Error("workspace snapshots are unavailable");
let activeSnapshotFound = false;
for (const entry of snapshotEntries) {
if (entry.name === "runtime") {
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("workspace runtime path is invalid");
this.assertExactDirectoryEntries(
await readdir(join(this.repository.snapshotsPath, "runtime"), { withFileTypes: true }),
{},
);
continue;
}
if (!/^[0-9a-f]{40}$/.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) {
throw new Error("workspace snapshot path is invalid");
}
const state = entry.name === active.head ? active : await this.readStoredSnapshotState(entry.name);
await this.assertSnapshotIntegrity(state, false, true);
if (entry.name === active.head) activeSnapshotFound = true;
}
if (!activeSnapshotFound) throw new Error("active workspace snapshot is unavailable");
const after = await this.storedStateFingerprint();
if (after !== before) throw new Error("workspace registry changed during inspection");
return { state: "active", workspaces: active.revisions.length, fingerprint: `sha256:${before}` };
} catch (error) {
throw workspaceError(error);
}
}
private assertExactDirectoryEntries(
entries: Array<{ name: string; isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }>,
expected: Record<string, "file" | "directory">,
): void {
if (entries.length !== Object.keys(expected).length) throw new Error("workspace registry shape is invalid");
for (const entry of entries) {
const kind = expected[entry.name];
if (kind === undefined || entry.isSymbolicLink()
|| (kind === "file" && !entry.isFile())
|| (kind === "directory" && !entry.isDirectory())) {
throw new Error("workspace registry shape is invalid");
}
}
}
private async storedStateFingerprint(): Promise<string> {
const records: string[] = [];
let entries = 0;
let totalBytes = 0;
const visit = async (path: string, relative: string): Promise<void> => {
const before = lstatSync(path);
if (before.isSymbolicLink()) throw new Error("workspace registry link is invalid");
const metadata = [
before.dev, before.ino, before.mode, before.uid, before.gid,
before.size, before.mtimeMs, before.ctimeMs,
].join(":");
entries += 1;
if (entries > 65_536) throw new Error("workspace registry contains too many entries");
if (before.isDirectory()) {
records.push(`directory:${relative}:${metadata}`);
const children = await readdir(path);
children.sort();
for (const name of children) {
await visit(join(path, name), relative === "." ? name : `${relative}/${name}`);
}
} else if (before.isFile()) {
if (before.size > 256 * 1024 * 1024) throw new Error("workspace registry file is too large");
totalBytes += before.size;
if (totalBytes > 2 * 1024 * 1024 * 1024) throw new Error("workspace registry is too large");
const contents = await readFile(path);
records.push(`file:${relative}:${metadata}:${contents.length}:${digest(contents)}`);
} else {
throw new Error("workspace registry entry is invalid");
}
const after = lstatSync(path);
if (before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode
|| before.uid !== after.uid || before.gid !== after.gid || before.size !== after.size
|| before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
throw new Error("workspace registry changed during inspection");
}
};
await visit(this.repository.root, ".");
return digest(records.join("\n"));
}
private async readStoredSnapshotState(head: string): Promise<ActiveState> {
return this.decodeSnapshotManifest(await this.readSnapshotManifest(safeCommit(head)));
}
async read(id: string): Promise<{ workspace: WorkspaceDescriptor; revision: WorkspaceRevision }> {
@@ -696,7 +791,11 @@ export class WorkspaceRegistry {
return state;
}
private async assertSnapshotIntegrity(state: ActiveState): Promise<void> {
private async assertSnapshotIntegrity(
state: ActiveState,
verifyGitEvidence = true,
exactStoredShape = false,
): Promise<void> {
const directory = join(this.repository.snapshotsPath, state.head);
try {
const manifest = this.decodeSnapshotManifest(await this.readSnapshotManifest(state.head));
@@ -705,13 +804,33 @@ export class WorkspaceRegistry {
throw new Error("manifest state does not match active state");
}
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state, directory));
await this.assertSnapshotEvidenceContexts(state);
if (exactStoredShape) this.assertStoredSnapshotShape(directory, state);
if (verifyGitEvidence) await this.assertSnapshotEvidenceContexts(state);
} catch (error) {
if (error instanceof WorkspaceRegistryError) throw error;
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
}
}
private assertStoredSnapshotShape(directory: string, state: ActiveState): void {
const expected: Record<string, "file" | "directory"> = { "snapshot.json": "file" };
for (const revision of state.revisions) {
expected[`${revision.id}.yaml`] = "file";
expected[`${revision.id}.env.example`] = "file";
expected[`${revision.id}.md`] = "file";
const workspace = parseWorkspaceYaml(readFileSync(join(directory, `${revision.id}.yaml`), "utf8"));
if (workspace.evidence?.source.type === "filesystem") expected[revision.id] = "directory";
}
this.assertExactDirectoryEntries(readdirSync(directory, { withFileTypes: true }), expected);
for (const revision of state.revisions) {
if (expected[revision.id] !== "directory") continue;
this.assertExactDirectoryEntries(
readdirSync(join(directory, revision.id), { withFileTypes: true }),
{ evidence: "directory", "evidence.manifest.json": "file" },
);
}
}
private expectedSnapshotFiles(state: ActiveState, directory: string): string[] {
return state.revisions.flatMap((revision) => {
const names = [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`];
@@ -784,20 +903,6 @@ export class WorkspaceRegistry {
}
}
private storedPathExists(path: string): boolean {
try {
const entry = lstatSync(path);
if (!entry.isDirectory() || entry.isSymbolicLink()) {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace registry path is invalid");
}
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
if (error instanceof WorkspaceRegistryError) throw error;
throw new WorkspaceRegistryError("workspace_invalid", "Workspace registry path is unavailable");
}
}
private pathIsMissing(path: string): boolean {
try {
lstatSync(path);
+116 -6
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync,
chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -338,21 +338,70 @@ function persistedState(root: string, commit: string): { active: any; manifest:
};
}
function filesystemFingerprint(root: string): string {
if (!existsSync(root)) return "absent";
const records: string[] = [];
const visit = (path: string, relative: string): void => {
const entry = lstatSync(path);
const metadata = [
entry.dev, entry.ino, entry.mode, entry.uid, entry.gid,
entry.size, entry.mtimeMs, entry.ctimeMs,
].join(":");
if (entry.isSymbolicLink()) {
records.push(`link:${relative}:${metadata}`);
return;
}
if (entry.isDirectory()) {
records.push(`directory:${relative}:${metadata}`);
for (const name of readdirSync(path).sort()) visit(join(path, name), relative === "." ? name : `${relative}/${name}`);
return;
}
if (entry.isFile()) {
const contents = readFileSync(path);
records.push(`file:${relative}:${metadata}:${contents.length}:${createHash("sha256").update(contents).digest("hex")}`);
return;
}
records.push(`other:${relative}:${metadata}`);
};
visit(root, ".");
return createHash("sha256").update(records.join("\n")).digest("hex");
}
test("verifies a never-initialized registry without contacting its remote", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-uninitialized-"));
temporaryRoots.push(root);
const registryRoot = join(root, "registry");
mkdirSync(registryRoot, { mode: 0o700 });
const registry = new WorkspaceRegistry(config(
registryRoot,
join(root, "missing-remote.git"),
));
const before = filesystemFingerprint(registryRoot);
await expect(registry.verifyStoredState()).resolves.toEqual({
state: "uninitialized",
workspaces: 0,
fingerprint: `sha256:${before}`,
});
expect(filesystemFingerprint(registryRoot)).toBe(before);
expect(existsSync(join(registryRoot, "repo"))).toBe(false);
expect(readdirSync(join(registryRoot, "state"))).toEqual([]);
expect(readdirSync(registryRoot)).toEqual([]);
});
test("never-initialized inspection refuses an absent or partial root without creating it", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-absent-"));
temporaryRoots.push(root);
const registryRoot = join(root, "registry");
const registry = new WorkspaceRegistry(config(registryRoot, join(root, "missing-remote.git")));
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(filesystemFingerprint(registryRoot)).toBe("absent");
mkdirSync(join(registryRoot, "state"), { recursive: true });
const partial = filesystemFingerprint(registryRoot);
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(filesystemFingerprint(registryRoot)).toBe(partial);
});
test("verifies initialized snapshots and rejects partial or malformed persisted state", async () => {
@@ -361,10 +410,25 @@ test("verifies initialized snapshots and rejects partial or malformed persisted
const registry = new WorkspaceRegistry(config(registryRoot, remote.remote));
await registry.bootstrap();
await expect(registry.verifyStoredState()).resolves.toEqual({
state: "active",
workspaces: 1,
});
const before = filesystemFingerprint(registryRoot);
const savedPath = process.env.PATH;
process.env.PATH = join(remote.root, "no-executables");
try {
await expect(registry.verifyStoredState()).resolves.toEqual({
state: "active",
workspaces: 1,
fingerprint: `sha256:${before}`,
});
} finally {
if (savedPath === undefined) delete process.env.PATH;
else process.env.PATH = savedPath;
}
expect(filesystemFingerprint(registryRoot)).toBe(before);
const firstIntegrity = await registry.verifyStoredState();
utimesSync(join(registryRoot, "repo"), new Date(1_000), new Date(1_000));
const metadataIntegrity = await registry.verifyStoredState();
expect(metadataIntegrity.fingerprint).not.toBe(firstIntegrity.fingerprint);
rmSync(join(registryRoot, "state", "active.json"));
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
@@ -373,6 +437,52 @@ test("verifies initialized snapshots and rejects partial or malformed persisted
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
});
test("read-only inspection rejects extra components, links, and ephemeral runtime contents", async () => {
const remote = await fixture();
const registryRoot = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(registryRoot, remote.remote));
const status = await registry.bootstrap();
const assertHostile = async (setup: () => void, cleanup: () => void): Promise<void> => {
setup();
const before = filesystemFingerprint(registryRoot);
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(filesystemFingerprint(registryRoot)).toBe(before);
cleanup();
};
await assertHostile(
() => mkdirSync(join(registryRoot, "unexpected")),
() => rmSync(join(registryRoot, "unexpected"), { recursive: true }),
);
await assertHostile(
() => rmSync(join(registryRoot, "locks", "empty-hooks"), { recursive: true }),
() => mkdirSync(join(registryRoot, "locks", "empty-hooks")),
);
await assertHostile(
() => writeFileSync(join(registryRoot, "state", "unexpected.json"), "{}"),
() => rmSync(join(registryRoot, "state", "unexpected.json")),
);
await assertHostile(
() => writeFileSync(join(registryRoot, "snapshots", status.head!, "unexpected"), "extra"),
() => rmSync(join(registryRoot, "snapshots", status.head!, "unexpected")),
);
await assertHostile(
() => {
mkdirSync(join(registryRoot, "snapshots", "runtime"), { recursive: true });
writeFileSync(join(registryRoot, "snapshots", "runtime", "restored-secret.yaml"), "secret: forbidden");
},
() => rmSync(join(registryRoot, "snapshots", "runtime"), { recursive: true }),
);
await assertHostile(
() => symlinkSync(join(registryRoot, "state", "active.json"), join(registryRoot, "linked-active.json")),
() => rmSync(join(registryRoot, "linked-active.json")),
);
await assertHostile(
() => symlinkSync(join(registryRoot, "state", "active.json"), join(registryRoot, "repo", "linked-active.json")),
() => rmSync(join(registryRoot, "repo", "linked-active.json")),
);
});
test("rejects a catalog entry without a descriptor instead of creating a bootstrap slot", async () => {
const remote = await contentOnlyFixture();
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));