mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
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
136 lines
4.8 KiB
TypeScript
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 "";
|
|
}
|
|
}
|