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
+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);