621 lines
18 KiB
TypeScript
621 lines
18 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
chmodSync,
|
|
chownSync,
|
|
existsSync,
|
|
linkSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { stringify } from "yaml";
|
|
import { afterEach, expect, test, vi } from "vitest";
|
|
import { createProjectedAuthenticationConfigProvider } from "../src/auth/runtime-projection.js";
|
|
import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-registry.js";
|
|
|
|
const lstatHook = vi.hoisted(() => ({
|
|
path: undefined as string | undefined,
|
|
callback: undefined as (() => void) | undefined,
|
|
}));
|
|
|
|
vi.mock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
return {
|
|
...actual,
|
|
lstatSync(path: import("node:fs").PathLike) {
|
|
const result = actual.lstatSync(path);
|
|
if (lstatHook.path === String(path)) lstatHook.callback?.();
|
|
return result;
|
|
},
|
|
};
|
|
});
|
|
|
|
const sentinel = "$argon2id$synthetic-sentinel";
|
|
const password = "correct horse battery staple";
|
|
const passwordHash =
|
|
"$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
|
const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
|
const roots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
lstatHook.path = undefined;
|
|
lstatHook.callback = undefined;
|
|
for (const root of roots.splice(0))
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
interface ProjectionFixture {
|
|
username: string;
|
|
hash?: string;
|
|
publicUrl?: string;
|
|
}
|
|
|
|
function sha256(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function generationFor(auth: string, users: string): string {
|
|
return sha256(
|
|
`thothii-auth-projection-v1\nmode=local\nauth=${sha256(auth)}\nusers=${sha256(users)}\n`,
|
|
);
|
|
}
|
|
|
|
function localProjectionFixture(
|
|
username: string,
|
|
hash = passwordHash,
|
|
): ProjectionFixture {
|
|
return { username, hash };
|
|
}
|
|
|
|
function projectionRoot(): string {
|
|
const root = mkdtempSync(join(tmpdir(), "tht-auth-projection-"));
|
|
chmodSync(root, 0o700);
|
|
roots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function currentDocument(
|
|
generation: string,
|
|
previousGenerations: readonly string[] = [],
|
|
): string {
|
|
return `${JSON.stringify({
|
|
version: 1,
|
|
state: "ready",
|
|
transaction: "a".repeat(32),
|
|
generation,
|
|
...(previousGenerations.length === 0 ? {} : { previousGenerations }),
|
|
})}\n`;
|
|
}
|
|
|
|
function projectionSources(fixture: ProjectionFixture): {
|
|
auth: string;
|
|
users: string;
|
|
} {
|
|
return {
|
|
auth: stringify({
|
|
version: 1,
|
|
mode: "local",
|
|
publicUrl: fixture.publicUrl ?? "http://127.0.0.1:8080",
|
|
local: { usersFile: "users.yaml" },
|
|
}),
|
|
users: stringify({
|
|
version: 1,
|
|
users: [
|
|
{
|
|
id: userId,
|
|
username: fixture.username,
|
|
passwordHash: fixture.hash ?? passwordHash,
|
|
roles: ["admin"],
|
|
enabled: true,
|
|
authRevision: 1,
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
}
|
|
|
|
function writeGeneration(root: string, fixture: ProjectionFixture): string {
|
|
const { auth, users } = projectionSources(fixture);
|
|
const generation = generationFor(auth, users);
|
|
const directory = join(root, "generations", generation);
|
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
chmodSync(directory, 0o700);
|
|
const manifest = `${JSON.stringify({
|
|
version: 1,
|
|
generation,
|
|
mode: "local",
|
|
canonicalRevision: `sha256:${generation}`,
|
|
files: [
|
|
{
|
|
name: "auth.yaml",
|
|
size: Buffer.byteLength(auth),
|
|
sha256: sha256(auth),
|
|
},
|
|
{
|
|
name: "users.yaml",
|
|
size: Buffer.byteLength(users),
|
|
sha256: sha256(users),
|
|
},
|
|
],
|
|
})}\n`;
|
|
for (const [name, source] of [
|
|
["manifest.json", manifest],
|
|
["auth.yaml", auth],
|
|
["users.yaml", users],
|
|
] as const) {
|
|
writeFileSync(join(directory, name), source, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(join(directory, name), 0o600);
|
|
}
|
|
return generation;
|
|
}
|
|
|
|
function writeReadyProjection(
|
|
root: string,
|
|
fixture: ProjectionFixture,
|
|
): string {
|
|
mkdirSync(join(root, "generations"), { mode: 0o700 });
|
|
chmodSync(join(root, "generations"), 0o700);
|
|
const generation = writeGeneration(root, fixture);
|
|
writeFileSync(join(root, "CURRENT"), currentDocument(generation), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(join(root, "CURRENT"), 0o600);
|
|
return generation;
|
|
}
|
|
|
|
function expectDenied(operation: () => unknown): void {
|
|
try {
|
|
operation();
|
|
throw new Error("operation unexpectedly succeeded");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
expect(message).toBe("authentication runtime projection is invalid");
|
|
expect(message).not.toContain(sentinel);
|
|
expect(message).not.toContain(passwordHash);
|
|
}
|
|
}
|
|
|
|
test("loads ready projection as one immutable auth and local-users snapshot", () => {
|
|
const root = projectionRoot();
|
|
const fixture = localProjectionFixture("synthetic-user", passwordHash);
|
|
const generation = writeReadyProjection(root, fixture);
|
|
const loaded = createProjectedAuthenticationConfigProvider(root).current();
|
|
|
|
expect(loaded.revision).toBe(`sha256:${generation}`);
|
|
expect(loaded.sourcePath).toBe(
|
|
join(root, "generations", generation, "auth.yaml"),
|
|
);
|
|
expect(loaded.runtimeProjection?.generation).toBe(generation);
|
|
expect(loaded.runtimeProjection?.canonicalRevision).toBe(
|
|
`sha256:${generation}`,
|
|
);
|
|
expect(Object.isFrozen(loaded.runtimeProjection)).toBe(true);
|
|
expect(Object.isFrozen(loaded.runtimeProjection?.localUsers)).toBe(true);
|
|
expect(Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0])).toBe(true);
|
|
expect(
|
|
Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0]?.roles),
|
|
).toBe(true);
|
|
});
|
|
|
|
test.each([
|
|
["missing", undefined],
|
|
[
|
|
"blocked",
|
|
`${JSON.stringify({ version: 1, state: "blocked", transaction: "a".repeat(32) })}\n`,
|
|
],
|
|
["malformed", "{not-json}\n"],
|
|
[
|
|
"duplicate-field",
|
|
`{"version":1,"version":1,"state":"ready","transaction":"${"a".repeat(32)}","generation":"${"b".repeat(64)}"}\n`,
|
|
],
|
|
[
|
|
"unknown-version",
|
|
`${JSON.stringify({ version: 2, state: "ready", transaction: "a".repeat(32), generation: "b".repeat(64) })}\n`,
|
|
],
|
|
])("rejects %s CURRENT without secret disclosure", (_label, contents) => {
|
|
const root = projectionRoot();
|
|
writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", sentinel),
|
|
);
|
|
if (contents === undefined) unlinkSync(join(root, "CURRENT"));
|
|
else
|
|
writeFileSync(join(root, "CURRENT"), contents, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
});
|
|
|
|
test.each([
|
|
"root traversal",
|
|
"CURRENT symlink",
|
|
"CURRENT hardlink",
|
|
"permissive mode",
|
|
"unexpected root entry",
|
|
])("rejects %s without secret disclosure", (kind) => {
|
|
const root = projectionRoot();
|
|
writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", sentinel),
|
|
);
|
|
const current = join(root, "CURRENT");
|
|
if (kind === "root traversal") {
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(
|
|
`${root}/../${root.split("/").at(-1)!}`,
|
|
).current(),
|
|
);
|
|
return;
|
|
}
|
|
if (kind === "CURRENT symlink") {
|
|
renameSync(current, join(root, "current-target"));
|
|
symlinkSync(join(root, "current-target"), current);
|
|
} else if (kind === "CURRENT hardlink") {
|
|
linkSync(current, join(root, "current-link"));
|
|
} else if (kind === "permissive mode") {
|
|
chmodSync(current, 0o640);
|
|
} else {
|
|
writeFileSync(join(root, "unexpected"), "x", {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
}
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
});
|
|
|
|
test.runIf(process.geteuid?.() === 0)(
|
|
"rejects wrong owner at every projection boundary without secret disclosure",
|
|
() => {
|
|
const root = projectionRoot();
|
|
writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", sentinel),
|
|
);
|
|
// Root may safely construct a synthetic foreign-owned fixture; no real account is touched.
|
|
chownSync(join(root, "CURRENT"), 1, 1);
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
},
|
|
);
|
|
|
|
test("rejects a symlinked runtime root", () => {
|
|
const root = projectionRoot();
|
|
writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", passwordHash),
|
|
);
|
|
const linkedRoot = `${root}-link`;
|
|
symlinkSync(root, linkedRoot, "dir");
|
|
roots.push(linkedRoot);
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(linkedRoot).current(),
|
|
);
|
|
});
|
|
|
|
test.each([
|
|
"root",
|
|
"generations",
|
|
"selected generation",
|
|
"manifest",
|
|
"auth",
|
|
"users",
|
|
])("rejects unsafe mode on %s", (boundary) => {
|
|
const root = projectionRoot();
|
|
const generation = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", passwordHash),
|
|
);
|
|
const selected = join(root, "generations", generation);
|
|
const path =
|
|
boundary === "root"
|
|
? root
|
|
: boundary === "generations"
|
|
? join(root, "generations")
|
|
: boundary === "selected generation"
|
|
? selected
|
|
: join(
|
|
selected,
|
|
boundary === "manifest" ? "manifest.json" : `${boundary}.yaml`,
|
|
);
|
|
chmodSync(
|
|
path,
|
|
boundary === "root" ||
|
|
boundary === "generations" ||
|
|
boundary === "selected generation"
|
|
? 0o750
|
|
: 0o640,
|
|
);
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
});
|
|
|
|
test.each(["manifest", "generation", "size", "digest"])(
|
|
"rejects changed %s integrity data without secret disclosure",
|
|
(kind) => {
|
|
const root = projectionRoot();
|
|
const generation = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", sentinel),
|
|
);
|
|
const manifestPath = join(root, "generations", generation, "manifest.json");
|
|
const manifest = JSON.parse(String(readFileSync(manifestPath))) as Record<
|
|
string,
|
|
any
|
|
>;
|
|
if (kind === "manifest") manifest.unexpected = true;
|
|
if (kind === "generation") manifest.generation = "b".repeat(64);
|
|
if (kind === "size") manifest.files[0].size += 1;
|
|
if (kind === "digest") manifest.files[1].sha256 = "b".repeat(64);
|
|
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(manifestPath, 0o600);
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
},
|
|
);
|
|
|
|
test("switches atomically to a later complete generation", () => {
|
|
const root = projectionRoot();
|
|
const first = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("first", passwordHash),
|
|
);
|
|
const provider = createProjectedAuthenticationConfigProvider(root);
|
|
expect(provider.current().runtimeProjection?.generation).toBe(first);
|
|
const second = writeGeneration(
|
|
root,
|
|
localProjectionFixture("second", passwordHash),
|
|
);
|
|
const temporary = join(root, ".current-switch.tmp");
|
|
writeFileSync(temporary, currentDocument(second, [first]), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(temporary, 0o600);
|
|
renameSync(temporary, join(root, "CURRENT"));
|
|
expect(provider.current().runtimeProjection?.generation).toBe(second);
|
|
});
|
|
|
|
test("retries once when CURRENT is atomically replaced between lstat and open", () => {
|
|
const root = projectionRoot();
|
|
const first = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("first", passwordHash),
|
|
);
|
|
const second = writeGeneration(
|
|
root,
|
|
localProjectionFixture("second", passwordHash),
|
|
);
|
|
const temporary = join(root, ".current-replacement.tmp");
|
|
lstatHook.path = join(root, "CURRENT");
|
|
lstatHook.callback = () => {
|
|
lstatHook.callback = undefined;
|
|
writeFileSync(temporary, currentDocument(second, [first]), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(temporary, 0o600);
|
|
renameSync(temporary, join(root, "CURRENT"));
|
|
};
|
|
expect(
|
|
createProjectedAuthenticationConfigProvider(root).current()
|
|
.runtimeProjection?.generation,
|
|
).toBe(second);
|
|
});
|
|
|
|
test("rejects a second CURRENT replacement after the one permitted retry", () => {
|
|
const root = projectionRoot();
|
|
const first = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("first", passwordHash),
|
|
);
|
|
const second = writeGeneration(
|
|
root,
|
|
localProjectionFixture("second", passwordHash),
|
|
);
|
|
const third = writeGeneration(
|
|
root,
|
|
localProjectionFixture("third", passwordHash),
|
|
);
|
|
const replacements = [
|
|
{ generation: second, previous: [first] },
|
|
{ generation: third, previous: [second, first] },
|
|
];
|
|
lstatHook.path = join(root, "CURRENT");
|
|
lstatHook.callback = () => {
|
|
const replacement = replacements.shift();
|
|
if (!replacement) return;
|
|
const temporary = join(
|
|
root,
|
|
`.current-replacement-${replacement.generation}.tmp`,
|
|
);
|
|
writeFileSync(
|
|
temporary,
|
|
currentDocument(replacement.generation, replacement.previous),
|
|
{ encoding: "utf8", mode: 0o600 },
|
|
);
|
|
chmodSync(temporary, 0o600);
|
|
renameSync(temporary, join(root, "CURRENT"));
|
|
};
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
});
|
|
|
|
test("fails deterministically when generations is replaced during a load", () => {
|
|
const root = projectionRoot();
|
|
const generation = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", passwordHash),
|
|
);
|
|
const replacement = mkdtempSync(
|
|
join(tmpdir(), "tht-auth-projection-replacement-"),
|
|
);
|
|
roots.push(replacement);
|
|
chmodSync(replacement, 0o700);
|
|
mkdirSync(join(replacement, generation), { recursive: true, mode: 0o700 });
|
|
chmodSync(join(replacement, generation), 0o700);
|
|
for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) {
|
|
const source = join(root, "generations", generation, name);
|
|
writeFileSync(join(replacement, generation, name), readFileSync(source), {
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(join(replacement, generation, name), 0o600);
|
|
}
|
|
lstatHook.path = join(root, "generations");
|
|
lstatHook.callback = () => {
|
|
lstatHook.callback = undefined;
|
|
renameSync(join(root, "generations"), join(root, "generations-retired"));
|
|
renameSync(replacement, join(root, "generations"));
|
|
};
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
});
|
|
|
|
test("fails deterministically when the selected generation directory is replaced during a load", () => {
|
|
const root = projectionRoot();
|
|
const generation = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", passwordHash),
|
|
);
|
|
const replacement = mkdtempSync(
|
|
join(tmpdir(), "tht-auth-projection-generation-replacement-"),
|
|
);
|
|
roots.push(replacement);
|
|
chmodSync(replacement, 0o700);
|
|
for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) {
|
|
const source = join(root, "generations", generation, name);
|
|
writeFileSync(join(replacement, name), readFileSync(source), {
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(join(replacement, name), 0o600);
|
|
}
|
|
lstatHook.path = join(root, "generations", generation);
|
|
lstatHook.callback = () => {
|
|
lstatHook.callback = undefined;
|
|
renameSync(
|
|
join(root, "generations", generation),
|
|
join(root, "generation-retired"),
|
|
);
|
|
renameSync(replacement, join(root, "generations", generation));
|
|
};
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
});
|
|
|
|
test.each(["corrupt", "symlink"])(
|
|
"rejects a %s retained predecessor generation",
|
|
(kind) => {
|
|
const root = projectionRoot();
|
|
const first = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("first", passwordHash),
|
|
);
|
|
const second = writeGeneration(
|
|
root,
|
|
localProjectionFixture("second", passwordHash),
|
|
);
|
|
writeFileSync(join(root, "CURRENT"), currentDocument(second, [first]), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(join(root, "CURRENT"), 0o600);
|
|
const predecessor = join(root, "generations", first);
|
|
if (kind === "corrupt") {
|
|
writeFileSync(join(predecessor, "auth.yaml"), "tampered", {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(join(predecessor, "auth.yaml"), 0o600);
|
|
} else {
|
|
const replacement = mkdtempSync(
|
|
join(tmpdir(), "tht-auth-projection-history-"),
|
|
);
|
|
roots.push(replacement);
|
|
chmodSync(replacement, 0o700);
|
|
rmSync(predecessor, { recursive: true, force: true });
|
|
symlinkSync(replacement, predecessor, "dir");
|
|
}
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
},
|
|
);
|
|
|
|
test("has no direct-file fallback when CURRENT is absent", () => {
|
|
const root = projectionRoot();
|
|
const generation = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("synthetic-user", sentinel),
|
|
);
|
|
unlinkSync(join(root, "CURRENT"));
|
|
writeFileSync(
|
|
join(root, "auth.yaml"),
|
|
projectionSources(localProjectionFixture("synthetic-user", sentinel)).auth,
|
|
{ mode: 0o600 },
|
|
);
|
|
expect(existsSync(join(root, "generations", generation, "auth.yaml"))).toBe(
|
|
true,
|
|
);
|
|
expectDenied(() =>
|
|
createProjectedAuthenticationConfigProvider(root).current(),
|
|
);
|
|
});
|
|
|
|
test("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => {
|
|
const root = projectionRoot();
|
|
const first = writeReadyProjection(
|
|
root,
|
|
localProjectionFixture("first", passwordHash),
|
|
);
|
|
const provider = createProjectedAuthenticationConfigProvider(root);
|
|
const loadedA = provider.current();
|
|
const second = writeGeneration(
|
|
root,
|
|
localProjectionFixture("second", passwordHash),
|
|
);
|
|
const temporary = join(root, ".current-switch.tmp");
|
|
writeFileSync(temporary, currentDocument(second), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
chmodSync(temporary, 0o600);
|
|
renameSync(temporary, join(root, "CURRENT"));
|
|
rmSync(join(root, "generations", first), { recursive: true, force: true });
|
|
|
|
const resolver = createCurrentLocalUserRegistryResolver();
|
|
const registryA = resolver.resolve(loadedA);
|
|
const userA = await registryA?.findByUsername("FIRST");
|
|
await expect(registryA?.verify(userA, password)).resolves.toBe(true);
|
|
const loadedB = provider.current();
|
|
const registryB = resolver.resolve(loadedB);
|
|
await expect(registryB?.findByUsername("second")).resolves.toMatchObject({
|
|
username: "second",
|
|
});
|
|
await expect(registryB?.findByUsername("first")).resolves.toBeUndefined();
|
|
});
|