Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
export const DEV_AUTH_SECRET_PLACEHOLDER = "dev-secret-change-me-please-32chars";
|
|
export const DEV_ENCRYPTION_KEY_PLACEHOLDER = "dev-encryption-key";
|
|
export const DEV_SUPERVISOR_TOKEN_PLACEHOLDER = "dev-supervisor-token-change-me-32chars";
|
|
export const DEV_SCREEN_PROXY_SECRET_PLACEHOLDER = "dev-screen-proxy-secret-change-me-32chars";
|
|
export const EXAMPLE_SUPERVISOR_TOKEN_PLACEHOLDER =
|
|
"replace-with-32-plus-character-supervisor-token";
|
|
export const EXAMPLE_SCREEN_PROXY_SECRET_PLACEHOLDER =
|
|
"replace-with-32-plus-character-screen-proxy-secret";
|
|
|
|
const RUNTIME_SECRETS_ERROR =
|
|
"Set BETTER_AUTH_SECRET and ENCRYPTION_KEY to long random strings before starting Rakazo outside local development or tests.";
|
|
|
|
const DEDICATED_SECRET_PLACEHOLDERS = new Set([
|
|
DEV_SUPERVISOR_TOKEN_PLACEHOLDER,
|
|
DEV_SCREEN_PROXY_SECRET_PLACEHOLDER,
|
|
EXAMPLE_SUPERVISOR_TOKEN_PLACEHOLDER,
|
|
EXAMPLE_SCREEN_PROXY_SECRET_PLACEHOLDER,
|
|
]);
|
|
|
|
export function isDevSecretAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
if (env.RAKAZO_ALLOW_DEV_SECRETS === "1") return true;
|
|
if (env.VITEST === "true" || env.VITEST === "1") return true;
|
|
const nodeEnv = env.NODE_ENV;
|
|
return nodeEnv === "development" || nodeEnv === "test";
|
|
}
|
|
|
|
export function resolveAuthSecret(env: NodeJS.ProcessEnv = process.env): string {
|
|
const value = env.BETTER_AUTH_SECRET;
|
|
if (value === undefined || !value.trim()) {
|
|
if (isDevSecretAllowed(env)) return DEV_AUTH_SECRET_PLACEHOLDER;
|
|
throw new Error(RUNTIME_SECRETS_ERROR);
|
|
}
|
|
const normalized = value.trim();
|
|
if (!isDevSecretAllowed(env) && normalized === DEV_AUTH_SECRET_PLACEHOLDER) {
|
|
throw new Error(RUNTIME_SECRETS_ERROR);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function resolveEncryptionKey(env: NodeJS.ProcessEnv = process.env): string {
|
|
const value = env.ENCRYPTION_KEY;
|
|
if (value === undefined || !value.trim()) {
|
|
if (isDevSecretAllowed(env)) return DEV_ENCRYPTION_KEY_PLACEHOLDER;
|
|
throw new Error(RUNTIME_SECRETS_ERROR);
|
|
}
|
|
const normalized = value.trim();
|
|
if (!isDevSecretAllowed(env) && normalized === DEV_ENCRYPTION_KEY_PLACEHOLDER) {
|
|
throw new Error(RUNTIME_SECRETS_ERROR);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function resolveDedicatedSecret(
|
|
env: NodeJS.ProcessEnv,
|
|
options: {
|
|
name: string;
|
|
conflicts: string[];
|
|
missingMessage: string;
|
|
conflictMessage: string;
|
|
developmentFallback?: string;
|
|
},
|
|
): string {
|
|
const value = env[options.name]?.trim();
|
|
if (!value) {
|
|
if (options.developmentFallback && isDevSecretAllowed(env)) {
|
|
return options.developmentFallback;
|
|
}
|
|
throw new Error(options.missingMessage);
|
|
}
|
|
if (!isDevSecretAllowed(env) && DEDICATED_SECRET_PLACEHOLDERS.has(value)) {
|
|
throw new Error(`Replace the ${options.name} placeholder with a dedicated random credential.`);
|
|
}
|
|
if (options.conflicts.some((name) => value === env[name])) {
|
|
throw new Error(options.conflictMessage);
|
|
}
|
|
if (!isDevSecretAllowed(env) && value.length < 32) {
|
|
throw new Error(`${options.name} must be at least 32 characters outside local tests.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function resolveSupervisorToken(env: NodeJS.ProcessEnv = process.env): string {
|
|
return resolveDedicatedSecret(env, {
|
|
name: "SANDBOX_SUPERVISOR_TOKEN",
|
|
conflicts: ["BETTER_AUTH_SECRET", "SCREEN_PROXY_SECRET"],
|
|
missingMessage: "Set SANDBOX_SUPERVISOR_TOKEN to a dedicated random supervisor credential.",
|
|
conflictMessage:
|
|
"SANDBOX_SUPERVISOR_TOKEN must differ from BETTER_AUTH_SECRET and SCREEN_PROXY_SECRET.",
|
|
developmentFallback: DEV_SUPERVISOR_TOKEN_PLACEHOLDER,
|
|
});
|
|
}
|
|
|
|
export function resolveScreenProxySecret(env: NodeJS.ProcessEnv = process.env): string {
|
|
return resolveDedicatedSecret(env, {
|
|
name: "SCREEN_PROXY_SECRET",
|
|
conflicts: ["BETTER_AUTH_SECRET", "SANDBOX_SUPERVISOR_TOKEN"],
|
|
missingMessage: "Set SCREEN_PROXY_SECRET to a dedicated random screen proxy credential.",
|
|
conflictMessage:
|
|
"SCREEN_PROXY_SECRET must differ from BETTER_AUTH_SECRET and SANDBOX_SUPERVISOR_TOKEN.",
|
|
developmentFallback: DEV_SCREEN_PROXY_SECRET_PLACEHOLDER,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The updater sidecar holds the Docker socket, which is root-equivalent on the host. Its bearer
|
|
* credential must therefore be independent from the cookie-signing and sandbox credentials: a
|
|
* leak at one boundary must not unlock either of the others.
|
|
*/
|
|
export function resolveUpdaterToken(env: NodeJS.ProcessEnv = process.env): string {
|
|
return resolveDedicatedSecret(env, {
|
|
name: "RAKAZO_UPDATER_TOKEN",
|
|
conflicts: ["BETTER_AUTH_SECRET", "SANDBOX_SUPERVISOR_TOKEN", "SCREEN_PROXY_SECRET"],
|
|
missingMessage: "Set RAKAZO_UPDATER_TOKEN to a dedicated random updater credential.",
|
|
conflictMessage:
|
|
"RAKAZO_UPDATER_TOKEN must differ from BETTER_AUTH_SECRET, SANDBOX_SUPERVISOR_TOKEN, and SCREEN_PROXY_SECRET.",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Constant-time string comparison for shared-secret headers that carry no
|
|
* `Bearer ` prefix (e.g. a vendor static signing-secret header).
|
|
* Same XOR rationale as `hasValidBearerToken` below.
|
|
*/
|
|
export function timingSafeStringEqual(supplied: string | undefined, expected: string): boolean {
|
|
const encoder = new TextEncoder();
|
|
const actual = encoder.encode(expected);
|
|
const candidate = encoder.encode(supplied ?? "");
|
|
if (actual.length !== candidate.length) return false;
|
|
let difference = 0;
|
|
for (let index = 0; index < actual.length; index += 1) {
|
|
difference |= (actual[index] ?? 0) ^ (candidate[index] ?? 0);
|
|
}
|
|
return difference === 0;
|
|
}
|
|
|
|
/**
|
|
* Constant-time bearer comparison, shared by every privileged sidecar.
|
|
*
|
|
* Deliberately not `node:crypto`'s `timingSafeEqual`: this module is reachable from the web bundle
|
|
* through `@rakazo/core`, and importing `node:crypto` here fails the production Vite build with
|
|
* `"timingSafeEqual" is not exported by "__vite-browser-external"`, which takes the whole
|
|
* application image down with it. The XOR accumulation below inspects every byte no matter where
|
|
* the first difference falls, which is the property that mattered. Length is compared first, as it
|
|
* was before — a length mismatch is already observable from the response.
|
|
*/
|
|
export function hasValidBearerToken(authorization: string | undefined, expectedToken: string) {
|
|
const supplied = authorization?.startsWith("Bearer ") ? authorization.slice(7) : "";
|
|
return timingSafeStringEqual(supplied, expectedToken);
|
|
}
|