619 lines
17 KiB
TypeScript
619 lines
17 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
closeSync,
|
|
constants,
|
|
fstatSync,
|
|
lstatSync,
|
|
opendirSync,
|
|
openSync,
|
|
readSync,
|
|
} 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 MAX_DIRECTORY_ENTRIES = 16;
|
|
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 runtimeGroup(): number {
|
|
if (process.platform === "win32" || typeof process.getegid !== "function")
|
|
throw invalid();
|
|
const gid = process.getegid();
|
|
if (!Number.isSafeInteger(gid) || gid < 0) throw invalid();
|
|
return gid;
|
|
}
|
|
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.gid !== runtimeGroup() ||
|
|
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.gid !== runtimeGroup() ||
|
|
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 ||
|
|
(root.length > 1 && root.endsWith("/"))
|
|
)
|
|
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 directory = opendirSync(`/proc/self/fd/${opened.fd}`, {
|
|
bufferSize: 1,
|
|
});
|
|
const names: string[] = [];
|
|
try {
|
|
for (;;) {
|
|
const entry = directory.readSync();
|
|
if (entry === null) break;
|
|
if (names.length === MAX_DIRECTORY_ENTRIES) throw invalid();
|
|
names.push(entry.name);
|
|
}
|
|
} finally {
|
|
try {
|
|
directory.closeSync();
|
|
} catch {}
|
|
}
|
|
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;
|
|
}
|
|
interface RootObservation {
|
|
descriptor: Identity;
|
|
path: Identity;
|
|
}
|
|
function sameObject(left: Identity, right: Identity): boolean {
|
|
return left.dev === right.dev && left.ino === right.ino;
|
|
}
|
|
function observeRootAtPathAndDescriptor(
|
|
root: string,
|
|
openedRoot: { fd: number; identity: Identity },
|
|
uid: number,
|
|
): RootObservation {
|
|
const openedAfter = directory(fstatSync(openedRoot.fd) as Stats, uid);
|
|
const pathAfter = directory(lstatSync(root) as Stats, uid);
|
|
if (!sameObject(openedAfter, pathAfter)) throw invalid();
|
|
return { descriptor: openedAfter, path: pathAfter };
|
|
}
|
|
function validateRootAndCurrentAfterLoad(
|
|
root: string,
|
|
openedRoot: { fd: number; identity: Identity },
|
|
currentPath: string,
|
|
selectedCurrent: Identity,
|
|
uid: number,
|
|
): void {
|
|
const rootBeforeCurrent = observeRootAtPathAndDescriptor(
|
|
root,
|
|
openedRoot,
|
|
uid,
|
|
);
|
|
const currentAfter = regular(
|
|
lstatSync(currentPath) as Stats,
|
|
uid,
|
|
MAX_SELECTOR_BYTES,
|
|
);
|
|
const rootAfterCurrent = observeRootAtPathAndDescriptor(
|
|
root,
|
|
openedRoot,
|
|
uid,
|
|
);
|
|
if (
|
|
!same(openedRoot.identity, rootBeforeCurrent.descriptor) ||
|
|
!same(rootBeforeCurrent.descriptor, rootBeforeCurrent.path) ||
|
|
!same(rootBeforeCurrent.path, rootAfterCurrent.descriptor) ||
|
|
!same(rootAfterCurrent.descriptor, rootAfterCurrent.path)
|
|
) {
|
|
const latestCurrent = regular(
|
|
lstatSync(currentPath) as Stats,
|
|
uid,
|
|
MAX_SELECTOR_BYTES,
|
|
);
|
|
if (replaced(selectedCurrent, latestCurrent)) throw new CurrentReplaced();
|
|
throw invalid();
|
|
}
|
|
if (!same(selectedCurrent, currentAfter)) {
|
|
if (replaced(selectedCurrent, currentAfter)) throw new CurrentReplaced();
|
|
throw invalid();
|
|
}
|
|
}
|
|
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);
|
|
validateRootAndCurrentAfterLoad(
|
|
root,
|
|
openedRoot,
|
|
currentPath,
|
|
selectedCurrent.identity,
|
|
uid,
|
|
);
|
|
return {
|
|
value: selectedGeneration.value,
|
|
revision: 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();
|
|
},
|
|
};
|
|
}
|