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
6.0 KiB
title
| title |
|---|
| CLI Machine-ID Token |
CLI Machine-ID Token
Overview
OmniRoute CLI commands authenticate against the local management API using a
HMAC-SHA256(machine-id, salt) token sent via the x-omniroute-cli-token
request header.
This allows CLI subcommands (omniroute status, omniroute providers, etc.)
to call management endpoints without requiring the user to supply a JWT or
password on every invocation.
How it works
getMachineTokenSync()reads the hardware machine ID vianode-machine-id(falls back to an empty string on failure, disabling CLI auth).- It computes
HMAC-SHA256(machine_id, salt)and returns the full 64-char hex digest — a deterministic, non-reversible token tied to this machine. - The CLI sends the token as
x-omniroute-cli-tokenonly when the resolved destination is an explicit loopback URL (localhost,127.0.0.0/8, or loopback IPv6). Requests carrying the token useredirect: error, so a local redirect cannot forward it to another origin. Remote contexts use scoped access tokens instead. If derivation is unavailable, the CLI omits the header andomniroute doctorreports the failure instead of treating an empty token as valid. - The server (
src/server/authz/policies/management.ts) recomputes the expected token with the same salt and compares viatimingSafeEqualto prevent timing-based extraction.
Security properties
| Property | Detail |
|---|---|
| Loopback-only | Accepted only when the server's trusted peer-locality stamp (derived from the real TCP peer address) says loopback. The client-controlled Host header is never trusted for locality. |
| Constant-time compare | crypto.timingSafeEqual prevents timing attacks. |
| Non-reversible | HMAC output cannot recover the machine-id. |
No always-protected bypass |
isAlwaysProtectedPath() is evaluated before the CLI token check. /api/shutdown and /api/settings/database always require JWT. |
| Non-exportable | Token is never written to disk or logged. |
Default salt (random per install)
When OMNIROUTE_CLI_SALT is not set, the salt is a random 64-char hex string
generated once and persisted at <DATA_DIR>/cli-token-salt.json (mode 0600) —
not the checked-in literal omniroute-cli-auth-v1. Both getActiveSalt() in
src/lib/machineToken.ts and its mirror in bin/cli/utils/cliToken.mjs read the
same file, so the server and every CLI invocation on this install converge on the
same value; the checked-in literal is used only as a last-resort fallback when no
persisted or env salt can be established yet (for example a fresh CLI-only install
before the server has ever run). This closes a weakness of the old fixed literal
default: /etc/machine-id is commonly world-readable, so any local user could
otherwise derive the same token for every install that never set
OMNIROUTE_CLI_SALT.
Salt rotation
Set OMNIROUTE_CLI_SALT to rotate the derived token without code changes — it
always takes priority over the persisted per-install salt. After rotation, all CLI
processes on this machine will use the new token automatically. Useful after a
process-list leak that may have exposed the previous derived value.
# Persistent rotation (add to shell profile)
export OMNIROUTE_CLI_SALT="my-secret-salt-2026"
# Verify new token is in use
omniroute status
Legacy format (SHA-256, 32-char) — still accepted
Before the HMAC format above, the CLI derived its token as
SHA-256(machineId + salt).hex[0..32] (a 32-char prefix) in
bin/cli/utils/cliToken.mjs (getLegacyCliTokenSync in src/lib/machineToken.ts).
For backwards compatibility the server accepts both formats: the verifier builds
expectedTokens = [getMachineTokenSync(), getLegacyCliTokenSync()] and compares the
incoming header against each with timingSafeEqual
(src/server/authz/policies/management.ts and src/lib/middleware/cliTokenAuth.ts).
So a token is valid if it matches either the 64-char HMAC digest or the 32-char
legacy SHA-256 prefix.
Opt-out: set OMNIROUTE_DISABLE_CLI_TOKEN=true (env or .env) to disable the CLI
token mechanism entirely; all access then requires an explicit API key. On multi-user
hosts this is recommended, since machine-id is per-device (not per-user) and another
user on the same host could compute the same token.
Files
| File | Purpose |
|---|---|
src/lib/machineToken.ts |
Token derivation (getMachineTokenSync) |
bin/cli/utils/cliToken.mjs |
CLI-side mirror of the same derivation |
<DATA_DIR>/cli-token-salt.json |
Persisted random per-install salt |
src/server/authz/headers.ts |
CLI_TOKEN_HEADER constant |
src/server/authz/policies/management.ts |
Server-side verification |
src/server/authz/routeGuard.ts |
Loopback host check (isLoopbackHost) |
See also
docs/security/ROUTE_GUARD_TIERS.md— route protection tiersdocs/architecture/AUTHZ_GUIDE.md— full authorization pipeline