feat(auth): load immutable runtime projection snapshots

This commit is contained in:
User
2026-08-21 22:31:38 +02:00
parent de86760942
commit 05f8615887
7 changed files with 1263 additions and 28 deletions
+3 -3
View File
@@ -228,7 +228,7 @@ function canonicalRevision(value: AuthenticationConfig): string {
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
}
function parseAuthenticationConfig(source: string): AuthenticationConfig {
export function parseAuthenticationConfigSource(source: string): AuthenticationConfig {
try {
const document = parseDocument(source, { uniqueKeys: true });
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
@@ -254,7 +254,7 @@ function parseAuthenticationConfig(source: string): AuthenticationConfig {
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
const read = readBoundedConfig(path);
const value = parseAuthenticationConfig(read.source);
const value = parseAuthenticationConfigSource(read.source);
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
}
@@ -266,7 +266,7 @@ function loadWindowsAuthenticationConfig(
const contents = bridge.readAuthConfig(path);
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_AUTH_CONFIG_BYTES) throw invalid();
const source = new TextDecoder("utf-8", { fatal: true }).decode(contents);
const value = parseAuthenticationConfig(source);
const value = parseAuthenticationConfigSource(source);
return { value, revision: canonicalRevision(value), sourcePath: path };
} catch {
throw invalid();
+47 -23
View File
@@ -12,7 +12,7 @@ import { dirname, isAbsolute, join, normalize } from "node:path";
import { parseDocument } from "yaml";
import { z } from "zod";
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
import type { LoadedAuthConfig, Role } from "./types.js";
import type { LoadedAuthConfig, LocalUserRecord, Role } from "./types.js";
import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js";
const MAX_USERS_YAML_BYTES = 1 << 20;
@@ -28,16 +28,7 @@ function runtimeOwner(): number {
return owner;
}
export interface LocalUserRecord {
id: string;
username: string;
normalizedUsername: string;
displayName?: string;
passwordHash: string;
roles: readonly Role[];
enabled: boolean;
authRevision: number;
}
export type { LocalUserRecord } from "./types.js";
export interface LocalUserRegistry {
/** Safe production diagnostic probe; never returns user records or hashes. */
@@ -193,7 +184,7 @@ function readBounded(path: string, owner: number): { source: string; identity: R
}
}
function parseRegistry(source: string): LocalUserRecord[] {
export function parseLocalUserRegistrySource(source: string): readonly LocalUserRecord[] {
try {
const document = parseDocument(source, { uniqueKeys: true });
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
@@ -216,24 +207,24 @@ function parseRegistry(source: string): LocalUserRecord[] {
authRevision: user.authRevision,
});
});
return records;
return Object.freeze(records);
} catch {
throw invalid();
}
}
function load(path: string, owner: number): { records: LocalUserRecord[]; identity: RegistryIdentity } {
function load(path: string, owner: number): { records: readonly LocalUserRecord[]; identity: RegistryIdentity } {
const read = readBounded(path, owner);
return { records: parseRegistry(read.source), identity: read.identity };
return { records: parseLocalUserRegistrySource(read.source), identity: read.identity };
}
export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry {
let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined;
let cached: { records: readonly LocalUserRecord[]; identity: RegistryIdentity } | undefined;
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
function currentPosix(): LocalUserRecord[] {
function currentPosix(): readonly LocalUserRecord[] {
try {
const owner = runtimeOwner();
const before = registryIdentity(usersPath, owner);
@@ -251,19 +242,19 @@ export function createLocalUserRegistry(usersPath: string, options: LocalUserReg
throw invalid();
}
async function current(): Promise<LocalUserRecord[]> {
async function current(): Promise<readonly LocalUserRecord[]> {
if (process.platform !== "win32") return currentPosix();
try {
if (!windowsStorage) throw invalid();
const contents = await windowsStorage.readLocalUsers(usersPath);
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_USERS_YAML_BYTES) throw invalid();
return parseRegistry(new TextDecoder("utf-8", { fatal: true }).decode(contents));
return parseLocalUserRegistrySource(new TextDecoder("utf-8", { fatal: true }).decode(contents));
} catch {
throw invalid();
}
}
async function operationalRecords(): Promise<LocalUserRecord[]> {
async function operationalRecords(): Promise<readonly LocalUserRecord[]> {
const records = await current();
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
return records;
@@ -291,15 +282,48 @@ export function createLocalUserRegistry(usersPath: string, options: LocalUserReg
}
export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver {
let current: { usersPath: string; registry: LocalUserRegistry } | undefined;
let current: { key: object | string; registry: LocalUserRegistry } | undefined;
return {
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
if (loaded.value.mode !== "local") return undefined;
const runtimeProjection = loaded.runtimeProjection;
const projectedUsers = runtimeProjection?.localUsers;
if (projectedUsers && runtimeProjection) {
if (current && current.key === runtimeProjection) return current.registry;
const registry = createInMemoryLocalUserRegistry(projectedUsers);
current = { key: runtimeProjection, registry };
return registry;
}
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
if (current?.usersPath === usersPath) return current.registry;
if (current && current.key === usersPath) return current.registry;
const registry = createLocalUserRegistry(usersPath, options);
current = { usersPath, registry };
current = { key: usersPath, registry };
return registry;
},
};
}
function createInMemoryLocalUserRegistry(records: readonly LocalUserRecord[]): LocalUserRegistry {
async function operationalRecords(): Promise<readonly LocalUserRecord[]> {
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
return records;
}
return {
async hasEnabledAdmin(): Promise<boolean> {
return records.some((user) => user.enabled && user.roles.includes("admin"));
},
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
return (await operationalRecords()).find((user) => user.normalizedUsername === normalizeUsername(username));
},
async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
return (await operationalRecords()).find((user) => user.id === id);
},
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
if (!user || !user.enabled) {
await verifyWithDummy(password);
return false;
}
return await verifyPassword(password, user.passwordHash);
},
};
}
+2 -1
View File
@@ -3,12 +3,13 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type {
AuthenticationConfigProvider,
LoadedAuthConfig,
LocalUserRecord,
OidcAuthenticationConfig,
OidcStateRecord,
OidcTransactionTransport,
Role,
} from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import type { LocalUserRegistry } from "./local-registry.js";
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js";
import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
+534
View File
@@ -0,0 +1,534 @@
import { createHash } from "node:crypto";
import {
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readSync,
readdirSync,
} from "node:fs";
import type { Stats } from "node:fs";
import { isAbsolute, join, normalize } from "node:path";
import { parseAuthenticationConfigSource } from "./config.js";
import { parseLocalUserRegistrySource } from "./local-registry.js";
import type {
AuthenticationConfigProvider,
LoadedAuthConfig,
LocalUserRecord,
RuntimeProjectionSnapshot,
} from "./types.js";
const MAX_AUTH_BYTES = 1 << 20;
const MAX_USERS_BYTES = 1 << 20;
const MAX_SELECTOR_BYTES = 4096;
const MAX_MANIFEST_BYTES = 4096;
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
const GENERATION = /^[0-9a-f]{64}$/;
const TRANSACTION = /^[0-9a-f]{32}$/;
const invalid = (): Error =>
new Error("authentication runtime projection is invalid");
interface Identity {
dev: number;
ino: number;
uid: number;
gid: number;
mode: number;
nlink: number;
size: number;
mtimeMs: number;
ctimeMs: number;
}
interface Selector {
version: 1;
state: "ready" | "blocked";
transaction: string;
generation?: string;
previousGenerations?: readonly string[];
}
interface ManifestFile {
name: "auth.yaml" | "users.yaml";
size: number;
sha256: string;
}
interface Manifest {
version: 1;
generation: string;
mode: "local" | "oidc";
canonicalRevision: string;
files: readonly ManifestFile[];
}
class CurrentReplaced extends Error {}
function runtimeOwner(): number {
if (process.platform === "win32" || typeof process.geteuid !== "function")
throw invalid();
const uid = process.geteuid();
if (!Number.isSafeInteger(uid) || uid < 0) throw invalid();
return uid;
}
function meta(info: Stats): Identity {
return {
dev: info.dev,
ino: info.ino,
uid: info.uid,
gid: info.gid,
mode: info.mode & 0o7777,
nlink: info.nlink,
size: info.size,
mtimeMs: info.mtimeMs,
ctimeMs: info.ctimeMs,
};
}
function same(a: Identity, b: Identity): boolean {
return (
a.dev === b.dev &&
a.ino === b.ino &&
a.uid === b.uid &&
a.gid === b.gid &&
a.mode === b.mode &&
a.nlink === b.nlink &&
a.size === b.size &&
a.mtimeMs === b.mtimeMs &&
a.ctimeMs === b.ctimeMs
);
}
function directory(info: Stats, uid: number): Identity {
const value = meta(info);
if (!info.isDirectory() || value.uid !== uid || value.mode !== DIR_MODE)
throw invalid();
return value;
}
function regular(info: Stats, uid: number, maximum: number): Identity {
const value = meta(info);
if (
!info.isFile() ||
value.uid !== uid ||
value.mode !== FILE_MODE ||
value.nlink !== 1 ||
value.size < 0 ||
value.size > maximum
)
throw invalid();
return value;
}
function checkRoot(root: string): void {
if (
typeof root !== "string" ||
root.length === 0 ||
root.includes("\0") ||
!isAbsolute(root) ||
normalize(root) !== root
)
throw invalid();
}
function openDirectory(
path: string,
uid: number,
): { fd: number; identity: Identity } {
let fd: number | undefined;
try {
const before = directory(lstatSync(path) as Stats, uid);
fd = openSync(
path,
constants.O_RDONLY |
(constants.O_DIRECTORY ?? 0) |
constants.O_NOFOLLOW |
constants.O_NONBLOCK,
);
const opened = directory(fstatSync(fd) as Stats, uid);
if (!same(before, opened)) throw invalid();
return { fd, identity: opened };
} catch {
if (fd !== undefined)
try {
closeSync(fd);
} catch {}
throw invalid();
}
}
function stableDirectory(
path: string,
opened: { fd: number; identity: Identity },
uid: number,
): void {
if (
!same(opened.identity, directory(fstatSync(opened.fd) as Stats, uid)) ||
!same(opened.identity, directory(lstatSync(path) as Stats, uid))
)
throw invalid();
}
function entries(path: string, uid: number, expected: readonly string[]): void {
const opened = openDirectory(path, uid);
try {
const names = readdirSync(path);
if (
names.length !== expected.length ||
new Set(names).size !== names.length ||
names.some((name) => !expected.includes(name))
)
throw invalid();
stableDirectory(path, opened, uid);
} finally {
try {
closeSync(opened.fd);
} catch {}
}
}
function replaced(before: Identity, after: Identity): boolean {
return before.dev !== after.dev || before.ino !== after.ino;
}
function readRegular(
path: string,
parentPath: string,
uid: number,
maximum: number,
retryOnReplacement = false,
): { bytes: Buffer; identity: Identity } {
let fd: number | undefined;
let parent: { fd: number; identity: Identity } | undefined;
try {
parent = openDirectory(parentPath, uid);
const before = regular(lstatSync(path) as Stats, uid, maximum);
fd = openSync(
path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
const opened = regular(fstatSync(fd) as Stats, uid, maximum);
if (!same(before, opened)) {
if (retryOnReplacement && replaced(before, opened))
throw new CurrentReplaced();
throw invalid();
}
const buffer = Buffer.allocUnsafe(maximum + 1);
let offset = 0;
while (offset < buffer.length) {
const count = readSync(fd, buffer, offset, buffer.length - offset, null);
if (count === 0) break;
offset += count;
}
if (offset > maximum) throw invalid();
const after = regular(fstatSync(fd) as Stats, uid, maximum);
const atPath = regular(lstatSync(path) as Stats, uid, maximum);
if (!same(opened, after) || !same(after, atPath)) {
if (retryOnReplacement && replaced(after, atPath))
throw new CurrentReplaced();
throw invalid();
}
stableDirectory(parentPath, parent, uid);
return { bytes: buffer.subarray(0, offset), identity: after };
} catch (error) {
if (error instanceof CurrentReplaced) throw error;
throw invalid();
} finally {
if (fd !== undefined)
try {
closeSync(fd);
} catch {}
if (parent)
try {
closeSync(parent.fd);
} catch {}
}
}
function text(bytes: Buffer): string {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw invalid();
}
}
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
throw invalid();
return value as Record<string, unknown>;
}
function safeGeneration(value: unknown): string {
if (typeof value !== "string" || !GENERATION.test(value)) throw invalid();
return value;
}
function strictJson<T>(
contents: string,
normalizeValue: (raw: unknown) => T,
): T {
try {
const value = normalizeValue(JSON.parse(contents));
if (`${JSON.stringify(value)}\n` !== contents) throw invalid();
return value;
} catch {
throw invalid();
}
}
function selector(contents: string): Selector {
return strictJson(contents, (raw) => {
const value = object(raw);
if (
value.version !== 1 ||
typeof value.transaction !== "string" ||
!TRANSACTION.test(value.transaction)
)
throw invalid();
if (value.state === "blocked" && Object.keys(value).length === 3)
return { version: 1, state: "blocked", transaction: value.transaction };
if (
value.state !== "ready" ||
(Object.keys(value).length !== 4 && Object.keys(value).length !== 5)
)
throw invalid();
const generation = safeGeneration(value.generation);
const previousGenerations =
value.previousGenerations === undefined
? []
: Array.isArray(value.previousGenerations)
? value.previousGenerations.map(safeGeneration)
: (() => {
throw invalid();
})();
if (
previousGenerations.length > 2 ||
(previousGenerations.length === 0 && Object.keys(value).length !== 4) ||
(previousGenerations.length > 0 && Object.keys(value).length !== 5)
)
throw invalid();
if (
new Set([generation, ...previousGenerations]).size !==
previousGenerations.length + 1
)
throw invalid();
return {
version: 1,
state: "ready",
transaction: value.transaction,
generation,
...(previousGenerations.length > 0 ? { previousGenerations } : {}),
};
});
}
function manifest(contents: string): Manifest {
return strictJson(contents, (raw) => {
const value = object(raw);
if (
Object.keys(value).length !== 5 ||
value.version !== 1 ||
(value.mode !== "local" && value.mode !== "oidc")
)
throw invalid();
const mode = value.mode;
const generation = safeGeneration(value.generation);
if (
value.canonicalRevision !== `sha256:${generation}` ||
!Array.isArray(value.files)
)
throw invalid();
const wanted: readonly ("auth.yaml" | "users.yaml")[] =
mode === "local" ? ["auth.yaml", "users.yaml"] : ["auth.yaml"];
if (value.files.length !== wanted.length) throw invalid();
const files: ManifestFile[] = value.files.map((candidate, index) => {
const item = object(candidate);
const name = wanted[index]!;
const maximum = name === "auth.yaml" ? MAX_AUTH_BYTES : MAX_USERS_BYTES;
if (
Object.keys(item).length !== 3 ||
item.name !== name ||
!Number.isSafeInteger(item.size) ||
(item.size as number) < 0 ||
(item.size as number) > maximum ||
typeof item.sha256 !== "string" ||
!GENERATION.test(item.sha256)
)
throw invalid();
return { name, size: item.size as number, sha256: item.sha256 };
});
return {
version: 1,
generation,
mode,
canonicalRevision: value.canonicalRevision as string,
files,
};
});
}
function digest(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
function generationFor(
mode: "local" | "oidc",
auth: Buffer,
users?: Buffer,
): string {
return digest(
Buffer.from(
`thothii-auth-projection-v1\nmode=${mode}\nauth=${digest(auth)}\nusers=${mode === "local" && users ? digest(users) : "-"}\n`,
"utf8",
),
);
}
function snapshot(
generation: string,
users?: readonly LocalUserRecord[],
): RuntimeProjectionSnapshot {
const localUsers =
users === undefined
? undefined
: Object.freeze(
users.map((user) =>
Object.freeze({ ...user, roles: Object.freeze([...user.roles]) }),
),
);
return Object.freeze({
generation,
canonicalRevision: `sha256:${generation}`,
...(localUsers ? { localUsers } : {}),
});
}
interface ValidGeneration {
value: ReturnType<typeof parseAuthenticationConfigSource>;
users?: readonly LocalUserRecord[];
}
function validateGeneration(
generationsPath: string,
generation: string,
uid: number,
): ValidGeneration {
const selectedPath = join(generationsPath, generation);
const openedGeneration = openDirectory(selectedPath, uid);
try {
const readManifest = readRegular(
join(selectedPath, "manifest.json"),
selectedPath,
uid,
MAX_MANIFEST_BYTES,
);
const loadedManifest = manifest(text(readManifest.bytes));
if (loadedManifest.generation !== generation) throw invalid();
entries(
selectedPath,
uid,
[
...loadedManifest.files.map((item) => item.name),
"manifest.json",
].sort(),
);
const auth = readRegular(
join(selectedPath, "auth.yaml"),
selectedPath,
uid,
MAX_AUTH_BYTES,
);
if (
loadedManifest.files[0]?.size !== auth.bytes.length ||
loadedManifest.files[0]?.sha256 !== digest(auth.bytes)
)
throw invalid();
const value = parseAuthenticationConfigSource(text(auth.bytes));
if (value.mode !== loadedManifest.mode) throw invalid();
let users: readonly LocalUserRecord[] | undefined;
let userBytes: Buffer | undefined;
if (loadedManifest.mode === "local") {
const readUsers = readRegular(
join(selectedPath, "users.yaml"),
selectedPath,
uid,
MAX_USERS_BYTES,
);
if (
loadedManifest.files[1]?.size !== readUsers.bytes.length ||
loadedManifest.files[1]?.sha256 !== digest(readUsers.bytes)
)
throw invalid();
userBytes = readUsers.bytes;
users = parseLocalUserRegistrySource(text(userBytes));
}
if (
generationFor(loadedManifest.mode, auth.bytes, userBytes) !== generation
)
throw invalid();
stableDirectory(selectedPath, openedGeneration, uid);
return { value, users };
} finally {
try {
closeSync(openedGeneration.fd);
} catch {}
}
}
function load(root: string): LoadedAuthConfig {
const uid = runtimeOwner();
checkRoot(root);
const openedRoot = openDirectory(root, uid);
try {
entries(root, uid, ["CURRENT", "generations"]);
const currentPath = join(root, "CURRENT");
const selectedCurrent = readRegular(
currentPath,
root,
uid,
MAX_SELECTOR_BYTES,
true,
);
const selected = selector(text(selectedCurrent.bytes));
if (selected.state !== "ready" || !selected.generation) throw invalid();
const generationsPath = join(root, "generations");
const openedGenerations = openDirectory(generationsPath, uid);
try {
entries(generationsPath, uid, [
selected.generation,
...(selected.previousGenerations ?? []),
]);
const selectedGeneration = validateGeneration(
generationsPath,
selected.generation,
uid,
);
for (const predecessor of selected.previousGenerations ?? [])
validateGeneration(generationsPath, predecessor, uid);
stableDirectory(generationsPath, openedGenerations, uid);
const afterCurrent = regular(
lstatSync(currentPath) as Stats,
uid,
MAX_SELECTOR_BYTES,
);
if (!same(selectedCurrent.identity, afterCurrent)) {
if (replaced(selectedCurrent.identity, afterCurrent))
throw new CurrentReplaced();
throw invalid();
}
stableDirectory(root, openedRoot, uid);
return {
value: selectedGeneration.value,
revision: `sha256:${selected.generation}`,
sourcePath: join(generationsPath, selected.generation, "auth.yaml"),
runtimeProjection: snapshot(
selected.generation,
selectedGeneration.users,
),
};
} finally {
try {
closeSync(openedGenerations.fd);
} catch {}
}
} finally {
try {
closeSync(openedRoot.fd);
} catch {}
}
}
export function createProjectedAuthenticationConfigProvider(
root: string,
): AuthenticationConfigProvider {
return {
current(): LoadedAuthConfig {
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
return load(root);
} catch (error) {
if (error instanceof CurrentReplaced && attempt === 0) continue;
throw invalid();
}
}
throw invalid();
},
};
}
+18
View File
@@ -45,10 +45,28 @@ export interface OidcAuthenticationConfig {
export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig;
export interface LocalUserRecord {
id: string;
username: string;
normalizedUsername: string;
displayName?: string;
passwordHash: string;
roles: readonly Role[];
enabled: boolean;
authRevision: number;
}
export interface RuntimeProjectionSnapshot {
generation: string;
canonicalRevision: string;
localUsers?: readonly LocalUserRecord[];
}
export interface LoadedAuthConfig {
value: AuthenticationConfig;
revision: string;
sourcePath: string;
runtimeProjection?: RuntimeProjectionSnapshot;
}
export interface AuthenticationConfigProvider {