306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import {
|
|
closeSync,
|
|
constants,
|
|
fstatSync,
|
|
lstatSync,
|
|
openSync,
|
|
readSync,
|
|
realpathSync,
|
|
} from "node:fs";
|
|
import type { Stats } from "node:fs";
|
|
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 { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js";
|
|
|
|
const MAX_USERS_YAML_BYTES = 1 << 20;
|
|
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/;
|
|
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
const ROLES = ["user", "admin"] as const;
|
|
const invalid = (): Error => new Error("local_user_registry_invalid");
|
|
|
|
function runtimeOwner(): number {
|
|
if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid();
|
|
const owner = process.geteuid();
|
|
if (!Number.isSafeInteger(owner) || owner < 0) throw invalid();
|
|
return owner;
|
|
}
|
|
|
|
export interface LocalUserRecord {
|
|
id: string;
|
|
username: string;
|
|
normalizedUsername: string;
|
|
displayName?: string;
|
|
passwordHash: string;
|
|
roles: readonly Role[];
|
|
enabled: boolean;
|
|
authRevision: number;
|
|
}
|
|
|
|
export interface LocalUserRegistry {
|
|
/** Safe production diagnostic probe; never returns user records or hashes. */
|
|
hasEnabledAdmin(): Promise<boolean>;
|
|
findByUsername(username: string): Promise<LocalUserRecord | undefined>;
|
|
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
|
|
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
|
|
}
|
|
|
|
/** Keeps only the registry named by the current coherent authentication-config snapshot. */
|
|
export interface CurrentLocalUserRegistryResolver {
|
|
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined;
|
|
}
|
|
|
|
/** Native Windows obtains protected registry bytes only from the hidden tht bridge. */
|
|
export interface LocalUserRegistryOptions {
|
|
windowsStorageBridge?: Pick<WindowsAuthStorageBridge, "readLocalUsers">;
|
|
}
|
|
|
|
interface FileIdentity {
|
|
dev: number;
|
|
ino: number;
|
|
uid: number;
|
|
size: number;
|
|
mtimeMs: number;
|
|
}
|
|
|
|
interface DirectoryIdentity {
|
|
dev: number;
|
|
ino: number;
|
|
uid: number;
|
|
mode: number;
|
|
}
|
|
|
|
interface RegistryIdentity {
|
|
file: FileIdentity;
|
|
directory: DirectoryIdentity;
|
|
}
|
|
|
|
const roleSchema = z.enum(ROLES);
|
|
const userSchema = z.strictObject({
|
|
id: z.string().regex(UUID_V4_PATTERN),
|
|
username: z.string().regex(USERNAME_PATTERN),
|
|
displayName: z.string().optional().refine((value) => value === undefined || !/\p{Cc}/u.test(value)),
|
|
passwordHash: z.string().refine(isValidPasswordHash),
|
|
roles: z.array(roleSchema).min(1).superRefine((roles, context) => {
|
|
if (new Set(roles).size !== roles.length) context.addIssue({ code: "custom", message: "duplicate role" });
|
|
}),
|
|
enabled: z.boolean(),
|
|
authRevision: z.number().int().positive().safe(),
|
|
});
|
|
const registrySchema = z.strictObject({ version: z.literal(1), users: z.array(userSchema).min(1) });
|
|
|
|
function normalizeUsername(username: string): string {
|
|
return username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
}
|
|
|
|
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
|
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid
|
|
&& left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
}
|
|
|
|
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
|
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode;
|
|
}
|
|
|
|
function sameIdentity(left: RegistryIdentity, right: RegistryIdentity): boolean {
|
|
return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory);
|
|
}
|
|
|
|
function validateCanonicalPath(path: string): void {
|
|
if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path) throw invalid();
|
|
const parent = dirname(path);
|
|
if (realpathSync(parent) !== parent) throw invalid();
|
|
}
|
|
|
|
function fileMetadata(info: Stats, owner: number): FileIdentity {
|
|
if (!info.isFile() || info.uid !== owner || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid();
|
|
if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid();
|
|
return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs };
|
|
}
|
|
|
|
function directoryMetadata(info: Stats, owner: number): DirectoryIdentity {
|
|
if (!info.isDirectory() || info.uid !== owner || (info.mode & 0o7777) !== 0o700) throw invalid();
|
|
return { dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o7777 };
|
|
}
|
|
|
|
function directoryIdentity(path: string, owner: number): DirectoryIdentity {
|
|
const parent = dirname(path);
|
|
if (realpathSync(parent) !== parent) throw invalid();
|
|
return directoryMetadata(lstatSync(parent) as Stats, owner);
|
|
}
|
|
|
|
function registryIdentity(path: string, owner: number): RegistryIdentity {
|
|
validateCanonicalPath(path);
|
|
const info = lstatSync(path);
|
|
return { file: fileMetadata(info as Stats, owner), directory: directoryIdentity(path, owner) };
|
|
}
|
|
|
|
function openDirectoryDescriptor(path: string): number | undefined {
|
|
if (process.platform === "win32") return undefined;
|
|
const flags = constants.O_RDONLY
|
|
| (constants.O_DIRECTORY ?? 0)
|
|
| (constants.O_NOFOLLOW ?? 0)
|
|
| (constants.O_NONBLOCK ?? 0);
|
|
return openSync(path, flags);
|
|
}
|
|
|
|
function readBounded(path: string, owner: number): { source: string; identity: RegistryIdentity } {
|
|
validateCanonicalPath(path);
|
|
const beforeDirectory = directoryIdentity(path, owner);
|
|
const beforePath = lstatSync(path);
|
|
const before = fileMetadata(beforePath as Stats, owner);
|
|
let directoryDescriptor: number | undefined;
|
|
let descriptor: number | undefined;
|
|
try {
|
|
directoryDescriptor = openDirectoryDescriptor(dirname(path));
|
|
const openedDirectory = directoryDescriptor === undefined
|
|
? beforeDirectory
|
|
: directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner);
|
|
if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid();
|
|
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
const opened = fileMetadata(fstatSync(descriptor) as Stats, owner);
|
|
if (!sameFileIdentity(before, opened)) throw invalid();
|
|
const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1);
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null);
|
|
if (bytesRead === 0) break;
|
|
offset += bytesRead;
|
|
}
|
|
if (offset > MAX_USERS_YAML_BYTES) throw invalid();
|
|
const after = fileMetadata(fstatSync(descriptor) as Stats, owner);
|
|
const afterPath = fileMetadata(lstatSync(path) as Stats, owner);
|
|
const afterDirectory = directoryMetadata(lstatSync(dirname(path)) as Stats, owner);
|
|
const afterOpenedDirectory = directoryDescriptor === undefined
|
|
? afterDirectory
|
|
: directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner);
|
|
if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath)
|
|
|| !sameDirectoryIdentity(beforeDirectory, afterDirectory)
|
|
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
|
|
const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
|
return { source, identity: { file: after, directory: afterDirectory } };
|
|
} catch {
|
|
throw invalid();
|
|
} finally {
|
|
if (descriptor !== undefined) {
|
|
try { closeSync(descriptor); } catch { /* sanitized by design */ }
|
|
}
|
|
if (directoryDescriptor !== undefined) {
|
|
try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseRegistry(source: string): LocalUserRecord[] {
|
|
try {
|
|
const document = parseDocument(source, { uniqueKeys: true });
|
|
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
|
|
const parsed = registrySchema.parse(document.toJSON());
|
|
const ids = new Set<string>();
|
|
const usernames = new Set<string>();
|
|
const records = parsed.users.map((user) => {
|
|
const normalizedUsername = normalizeUsername(user.username);
|
|
if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid();
|
|
ids.add(user.id);
|
|
usernames.add(normalizedUsername);
|
|
return Object.freeze({
|
|
id: user.id,
|
|
username: user.username,
|
|
normalizedUsername,
|
|
...(user.displayName === undefined ? {} : { displayName: user.displayName }),
|
|
passwordHash: user.passwordHash,
|
|
roles: Object.freeze([...user.roles]) as readonly Role[],
|
|
enabled: user.enabled,
|
|
authRevision: user.authRevision,
|
|
});
|
|
});
|
|
return records;
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
function load(path: string, owner: number): { records: LocalUserRecord[]; identity: RegistryIdentity } {
|
|
const read = readBounded(path, owner);
|
|
return { records: parseRegistry(read.source), identity: read.identity };
|
|
}
|
|
|
|
export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry {
|
|
let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined;
|
|
const windowsStorage = process.platform === "win32"
|
|
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
|
|
: undefined;
|
|
|
|
function currentPosix(): LocalUserRecord[] {
|
|
try {
|
|
const owner = runtimeOwner();
|
|
const before = registryIdentity(usersPath, owner);
|
|
if (cached && sameIdentity(cached.identity, before)) return cached.records;
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
const loaded = load(usersPath, owner);
|
|
if (sameIdentity(loaded.identity, registryIdentity(usersPath, owner))) {
|
|
cached = loaded;
|
|
return loaded.records;
|
|
}
|
|
}
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
throw invalid();
|
|
}
|
|
|
|
async function current(): Promise<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));
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
async function operationalRecords(): Promise<LocalUserRecord[]> {
|
|
const records = await current();
|
|
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
|
|
return records;
|
|
}
|
|
|
|
return {
|
|
async hasEnabledAdmin(): Promise<boolean> {
|
|
return (await current()).some((user) => user.enabled && user.roles.includes("admin"));
|
|
},
|
|
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
|
|
const normalized = normalizeUsername(username);
|
|
return (await operationalRecords()).find((user) => user.normalizedUsername === normalized);
|
|
},
|
|
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);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver {
|
|
let current: { usersPath: string; registry: LocalUserRegistry } | undefined;
|
|
return {
|
|
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
|
|
if (loaded.value.mode !== "local") return undefined;
|
|
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
|
|
if (current?.usersPath === usersPath) return current.registry;
|
|
const registry = createLocalUserRegistry(usersPath, options);
|
|
current = { usersPath, registry };
|
|
return registry;
|
|
},
|
|
};
|
|
}
|