Files
OmniRoute/src/lib/machineToken.ts
Diego Rodrigues de Sa e Souza 21772f40f3 fix(security): generate a random per-install CLI token salt (#13679) (#13909)
Both src/lib/machineToken.ts::getActiveSalt() and its mirror in
bin/cli/utils/cliToken.mjs derived the CLI/management bearer token as
HMAC-SHA256(raw machine-id, salt) with a checked-in literal default salt
("omniroute-cli-auth-v1"). Since /etc/machine-id is commonly world-readable,
any local user who never set OMNIROUTE_CLI_SALT could derive the same
bearer token as the server.

getActiveSalt() now generates a random 64-char-hex salt on first use and
persists it under <DATA_DIR>/cli-token-salt.json (falling back to the
literal only when neither the env override nor a persisted/writable salt
can be established). Both implementations use the same resolution order
and the same wx-flag create-race handling so the CLI and server keep
deriving the same token. OMNIROUTE_CLI_SALT stays the explicit operator
override, unchanged.

Regression test: tests/unit/machine-token-random-salt-13679.test.ts
2026-09-17 10:45:43 -03:00

136 lines
4.8 KiB
TypeScript

import { createHash, createHmac, randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import * as nodeModule from "node:module";
import { resolveDataDir, isTestContext } from "./dataPaths";
let machineIdSync: (original?: boolean) => string;
try {
// Anchor runtime resolution to the process entrypoint. Turbopack rewrites
// createRequire(import.meta.url) into an in-bundle resolver, which cannot load
// external CommonJS packages from the installed standalone node_modules tree.
const runtimeRequire = nodeModule.createRequire(process.argv[1] || process.cwd());
const mod = runtimeRequire("node-machine-id");
machineIdSync = mod.machineIdSync || mod.default?.machineIdSync;
} catch {
machineIdSync = () => "";
}
// #13679 PR B: checked-in literal, used ONLY as a last-resort fallback (see
// getActiveSalt() below) — /etc/machine-id is commonly world-readable, so relying on
// this literal as the real default let any local user derive the same bearer token.
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
const SALT_FILE_NAME = "cli-token-salt.json";
const PERSISTED_SALT_RE = /^[0-9a-f]{64}$/;
function saltFilePath(dataDir: string): string {
return path.join(dataDir, SALT_FILE_NAME);
}
function readPersistedSalt(filePath: string): string | null {
try {
const raw = fs.readFileSync(filePath, "utf8");
const parsed: unknown = JSON.parse(raw);
const salt = (parsed as { salt?: unknown } | null)?.salt;
if (typeof salt === "string" && PERSISTED_SALT_RE.test(salt)) return salt;
} catch {
// Missing, unreadable, or corrupt — fall through to (re)generation.
}
return null;
}
/**
* Generate a random per-install salt on first use and persist it under DATA_DIR so
* every process on this install (the server and every `omniroute` CLI invocation,
* see the mirrored logic in bin/cli/utils/cliToken.mjs) converges on the same value.
* The write uses the `wx` flag (fails if the file already exists) so a race between
* two processes both hitting "no file yet" at once cannot clobber one another — the
* loser just reads back what the winner wrote instead of overwriting it.
*/
function establishPersistedSalt(dataDir: string): string | null {
const filePath = saltFilePath(dataDir);
const existing = readPersistedSalt(filePath);
if (existing) return existing;
const generated = randomBytes(32).toString("hex");
try {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ salt: generated }), { flag: "wx", mode: 0o600 });
return generated;
} catch (err) {
if ((err as NodeJS.ErrnoException | null)?.code === "EEXIST") {
return readPersistedSalt(filePath);
}
return null;
}
}
let cachedActiveSalt: string | null = null;
function getActiveSalt(): string {
const envSalt = process.env.OMNIROUTE_CLI_SALT;
if (envSalt) return envSalt;
if (cachedActiveSalt) return cachedActiveSalt;
const hasExplicitDataDir = !!process.env.DATA_DIR?.trim();
// A test process that never opted into an explicit DATA_DIR must not write a salt
// file into the operator's real home directory — mirrors the same class of guard
// resolveWritableDataDir() applies for the DB (dataPaths.ts::isTestContext). Falling
// back to the literal here only affects tests that forgot to set DATA_DIR; every
// production path (server boot, packaged CLI) always has one.
if (!hasExplicitDataDir && isTestContext()) {
cachedActiveSalt = BUILTIN_DEFAULT_SALT;
return cachedActiveSalt;
}
const dataDir = resolveDataDir();
const persisted = establishPersistedSalt(dataDir);
cachedActiveSalt = persisted ?? BUILTIN_DEFAULT_SALT;
return cachedActiveSalt;
}
export function deriveMachineToken(rawId: string, salt: string): string {
if (!rawId) return "";
return createHmac("sha256", rawId).update(salt).digest("hex");
}
export function deriveLegacyCliToken(machineId: string, salt: string): string {
if (!machineId) return "";
return createHash("sha256")
.update(machineId + salt)
.digest("hex")
.substring(0, 32);
}
let cached: string | null = null;
let cachedSalt: string | null = null;
export function getMachineTokenSync(salt?: string): string {
const activeSalt = salt ?? getActiveSalt();
try {
// machineIdSync(true) returns the original unhashed hardware ID.
const rawId = machineIdSync(true);
if (!rawId) return "";
if (activeSalt === cachedSalt && cached !== null) return cached;
const token = deriveMachineToken(rawId, activeSalt);
if (!salt) {
cached = token;
cachedSalt = activeSalt;
}
return token;
} catch {
return "";
}
}
export function getLegacyCliTokenSync(salt?: string): string {
const activeSalt = salt ?? getActiveSalt();
try {
const machineId = machineIdSync();
return deriveLegacyCliToken(machineId, activeSalt);
} catch {
return "";
}
}