Initial Commit
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { parseEnv } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export interface VaultProject {
|
||||
workspaceId: string;
|
||||
defaultEnvironment: string;
|
||||
domain: string;
|
||||
secretPath: string;
|
||||
}
|
||||
export const BOOTSTRAP_KEYS = ['INFISICAL_UNIVERSAL_AUTH_CLIENT_ID', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET'] as const;
|
||||
export const APPLICATION_KEYS = [
|
||||
'EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'TYPESAFE_API_KEY', 'TOKEN_ENCRYPTION_KEY',
|
||||
'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS',
|
||||
] as const;
|
||||
export const REQUIRED_KEYS = ['EVE_CLIENT_ID', 'TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS'] as const;
|
||||
export const ADMIN_KEY = 'CONVEX_SELF_HOSTED_ADMIN_KEY';
|
||||
export type Environment = Record<string, string | undefined>;
|
||||
export type CliRunner = (args: string[], env: Environment) => Promise<string>;
|
||||
|
||||
export function validateProject(input: unknown): VaultProject {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid Infisical project configuration');
|
||||
const project = input as Record<string, unknown>;
|
||||
if (typeof project.workspaceId !== 'string' || !/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(project.workspaceId)) throw new Error('Invalid Infisical project ID');
|
||||
if (typeof project.defaultEnvironment !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(project.defaultEnvironment)) throw new Error('Invalid Infisical environment');
|
||||
// This repository is deliberately pinned to the operator-authorized vault origin.
|
||||
// A repository config change alone must never send the bootstrap credential elsewhere.
|
||||
if (project.domain !== 'https://secrets.jb.codes') throw new Error('Infisical origin is not the approved instance');
|
||||
if (typeof project.secretPath !== 'string' || !/^\/(?:[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*)?$/.test(project.secretPath)) throw new Error('Invalid Infisical secret path');
|
||||
return project as unknown as VaultProject;
|
||||
}
|
||||
|
||||
export async function readCredentials(path: string): Promise<Record<string, string>> {
|
||||
let handle;
|
||||
try {
|
||||
// Do not follow a symlink or read a world/group-accessible bootstrap secret.
|
||||
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.() || stat.size > 16_384) {
|
||||
throw new Error('Invalid credentials file');
|
||||
}
|
||||
const parsed = parseEnv(await handle.readFile('utf8'));
|
||||
const selected: Record<string, string> = {};
|
||||
for (const name of BOOTSTRAP_KEYS) {
|
||||
if (!parsed[name]?.trim()) throw new Error('Missing bootstrap credential');
|
||||
selected[name] = parsed[name];
|
||||
}
|
||||
return selected;
|
||||
} catch {
|
||||
throw new Error('Cannot read Universal Auth credentials: use a regular, owner-owned 0600 file containing the two documented variables');
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function boundedOutput(stream: ReadableStream<Uint8Array>, limit: number): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > limit) throw new Error('Infisical output exceeded its safety limit');
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
async function captureSecretCommand(argv: string[], env: Environment): Promise<string> {
|
||||
const child = Bun.spawn(argv, { env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
||||
const deadline = setTimeout(() => child.kill('SIGKILL'), 30_000);
|
||||
deadline.unref();
|
||||
try {
|
||||
const [output, , code] = await Promise.all([
|
||||
boundedOutput(child.stdout, 2 * 1024 * 1024),
|
||||
boundedOutput(child.stderr, 64 * 1024),
|
||||
child.exited,
|
||||
]);
|
||||
if (code !== 0) throw new Error('CLI failed');
|
||||
return output;
|
||||
} catch {
|
||||
// CLI errors can embed request details. Never echo raw stdout/stderr or credentials.
|
||||
throw new Error('Credential command failed; check permissions and connectivity (raw output suppressed)');
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
export const runCli: CliRunner = async (args, env) => {
|
||||
const executable = fileURLToPath(new URL('../../node_modules/.bin/infisical', import.meta.url));
|
||||
return captureSecretCommand([executable, ...args], env);
|
||||
};
|
||||
|
||||
export async function localDeploymentAdmin(env: Environment, runner = captureSecretCommand): Promise<string> {
|
||||
if (!['http://127.0.0.1:3210', 'http://localhost:3210'].includes(env.CONVEX_SELF_HOSTED_URL ?? '')) {
|
||||
throw new Error('Local admin derivation is restricted to this host’s Convex deployment');
|
||||
}
|
||||
// The backend derives its admin credential from its persistent random instance
|
||||
// secret. Docker-host access is already administrative; no second secret file
|
||||
// or write-enabled Infisical identity is required. Only explicit --deployment
|
||||
// commands receive this key; Compose never forwards it to the web container.
|
||||
const key = (await runner(['docker', 'compose', 'exec', '-T', 'backend', './generate_admin_key.sh'], cleanEnvironment(env))).trim();
|
||||
if (!key || /\s/.test(key) || key.length > 16_384) throw new Error('Unexpected local admin response; output suppressed');
|
||||
return key;
|
||||
}
|
||||
|
||||
export function parseExport(text: string): Record<string, string> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
const values: Record<string, string> = Object.create(null);
|
||||
const entries: unknown[] = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object'
|
||||
? Object.entries(parsed).map(([key, value]) => ({ key, value }))
|
||||
: [];
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error();
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') throw new Error();
|
||||
const row = entry as Record<string, unknown>;
|
||||
const key = row.key ?? row.secretKey;
|
||||
const value = row.value ?? row.secretValue;
|
||||
if (typeof key !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof value !== 'string' || Object.hasOwn(values, key)) throw new Error();
|
||||
values[key] = value;
|
||||
}
|
||||
return values;
|
||||
} catch { throw new Error('Invalid Infisical export; values suppressed'); }
|
||||
}
|
||||
|
||||
export async function fetchSecrets(project: VaultProject, credentials: Record<string, string>, runner: CliRunner = runCli, base: Environment = process.env): Promise<Record<string, string>> {
|
||||
validateProject(project);
|
||||
for (const name of BOOTSTRAP_KEYS) if (!credentials[name]) throw new Error(`Missing ${name}`);
|
||||
const env = cleanEnvironment(base);
|
||||
const common = ['--domain', project.domain, '--silent', '--telemetry=false'];
|
||||
const bootstrap = Object.fromEntries(BOOTSTRAP_KEYS.map(key => [key, credentials[key]]));
|
||||
const token = (await runner(['login', '--method=universal-auth', '--plain', ...common], { ...env, ...bootstrap })).trim();
|
||||
if (!token || /\s/.test(token) || token.length > 16_384) throw new Error('Unexpected Infisical login response; values suppressed');
|
||||
const output = await runner([
|
||||
'export', '--projectId', project.workspaceId, '--env', project.defaultEnvironment, '--path', project.secretPath,
|
||||
'--format=json', '--expand=false', '--include-imports=false', '--secret-overriding=false', ...common,
|
||||
], { ...env, INFISICAL_TOKEN: token });
|
||||
return parseExport(output);
|
||||
}
|
||||
|
||||
export function cleanEnvironment(base: Environment): Environment {
|
||||
const managed = new Set<string>([ADMIN_KEY, ...APPLICATION_KEYS]);
|
||||
return Object.fromEntries(Object.entries(base).filter(([key]) => !key.startsWith('INFISICAL_') && !managed.has(key)));
|
||||
}
|
||||
|
||||
const PUBLIC_KEYS = new Set([
|
||||
'APP_MODE', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL',
|
||||
'CONVEX_URL', 'CONVEX_SELF_HOSTED_URL', 'CONVEX_SITE_ORIGIN', 'REFERENCE_DIR', 'ESI_USER_AGENT',
|
||||
'WEB_BIND_ADDRESS', 'CONVEX_BIND_ADDRESS', 'WEB_PORT', 'CONVEX_PORT', 'CONVEX_SITE_PORT',
|
||||
'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE',
|
||||
]);
|
||||
export function applicationEnvironment(base: Environment, secrets: Record<string, string>, publicConfig: Environment, deployment = false): Environment {
|
||||
const env = cleanEnvironment(base);
|
||||
// Public file is the configured deployment, not defaults inherited from Bun's .env.local.
|
||||
for (const [key, value] of Object.entries(publicConfig)) {
|
||||
if (!PUBLIC_KEYS.has(key) || typeof value !== 'string') throw new Error('Unexpected public deployment configuration key');
|
||||
env[key] = value;
|
||||
}
|
||||
// Managed app keys must come from the selected vault, never a stale ambient .env.
|
||||
for (const key of APPLICATION_KEYS) {
|
||||
delete env[key];
|
||||
if (secrets[key] !== undefined) env[key] = secrets[key];
|
||||
}
|
||||
if (deployment && secrets[ADMIN_KEY]) env[ADMIN_KEY] = secrets[ADMIN_KEY];
|
||||
return env;
|
||||
}
|
||||
Reference in New Issue
Block a user