mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-06 14:39:10 +00:00
386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
|
|
export const credentialNames = [
|
|
'CREDS_KEY',
|
|
'CREDS_IV',
|
|
'JWT_SECRET',
|
|
'JWT_REFRESH_SECRET',
|
|
] as const;
|
|
|
|
export type CredentialName = (typeof credentialNames)[number];
|
|
|
|
export type CredentialSource = 'environment' | 'temporary' | 'legacy-default';
|
|
|
|
export interface CredentialRuntimeState {
|
|
filePath: string;
|
|
sources: Record<CredentialName, CredentialSource>;
|
|
generated: CredentialName[];
|
|
loadedFromFile: CredentialName[];
|
|
missingFromEnvironment: CredentialName[];
|
|
persistenceFailed: boolean;
|
|
}
|
|
|
|
export interface CredentialFingerprintRecord {
|
|
CREDS_KEY: string;
|
|
CREDS_IV: string;
|
|
JWT_SECRET: string;
|
|
JWT_REFRESH_SECRET: string;
|
|
}
|
|
|
|
interface CredentialFileResult {
|
|
values: Partial<Record<CredentialName, string>>;
|
|
exists: boolean;
|
|
readable: boolean;
|
|
}
|
|
|
|
type CredentialFileWriteResult = 'written' | 'exists' | 'failed';
|
|
|
|
export const credentialMetadataId = 'primary';
|
|
export const credentialMetadataCollection = 'librechatCredentialMetadata';
|
|
|
|
const credentialRuntimeKey = Symbol.for('librechat.credentials.runtime');
|
|
const tempCredentialPathEnvironment = 'LIBRECHAT_TEMP_CREDENTIALS_PATH';
|
|
|
|
const legacyCredentialFingerprints: Partial<Record<CredentialName, string[]>> = {
|
|
CREDS_KEY: [
|
|
'7c1154b5152983978147ea87e4a2066d4768673e9d85d6a792cddc2f784efd2f',
|
|
'6aa4b14dcf6d5094e8f3ed7ac00ba5f0a4d36af50940e870c987a101149a06b3',
|
|
],
|
|
CREDS_IV: [
|
|
'06d2e911edb4d45985b9d6338ec3d4c6b4bde69f8143437797c187ab07085531',
|
|
'be066640b0c70fcf71e9181eca99768f87da3e4e3764282dfc8063da2f4010ca',
|
|
],
|
|
JWT_SECRET: ['69024f21e9ad17594dcccd93e87399af24a5426ccfe6108d1787f0335966abc4'],
|
|
JWT_REFRESH_SECRET: ['282ad5f60261639fefed381976b4d0dde52eab5527a1ab2ec75d5be1efa1165b'],
|
|
};
|
|
|
|
function getRuntimeState(): CredentialRuntimeState | undefined {
|
|
const runtime = globalThis as typeof globalThis &
|
|
Record<symbol, CredentialRuntimeState | undefined>;
|
|
return runtime[credentialRuntimeKey];
|
|
}
|
|
|
|
function setRuntimeState(state: CredentialRuntimeState): void {
|
|
const runtime = globalThis as typeof globalThis &
|
|
Record<symbol, CredentialRuntimeState | undefined>;
|
|
runtime[credentialRuntimeKey] = state;
|
|
}
|
|
|
|
function getCredentialPath(): string {
|
|
const configuredPath = process.env[tempCredentialPathEnvironment]?.trim();
|
|
return path.resolve(configuredPath || path.join(process.cwd(), '.env.temp'));
|
|
}
|
|
|
|
function parseCredentialFile(contents: string): Partial<Record<CredentialName, string>> {
|
|
return contents.split(/\r?\n/).reduce<Partial<Record<CredentialName, string>>>((values, line) => {
|
|
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
|
if (!match || !credentialNames.includes(match[1] as CredentialName)) {
|
|
return values;
|
|
}
|
|
|
|
const value = match[2].trim();
|
|
values[match[1] as CredentialName] =
|
|
value.length >= 2 && value.startsWith('"') && value.endsWith('"')
|
|
? value.slice(1, -1)
|
|
: value;
|
|
return values;
|
|
}, {});
|
|
}
|
|
|
|
function readCredentialFile(filePath: string): CredentialFileResult {
|
|
try {
|
|
return {
|
|
values: parseCredentialFile(fs.readFileSync(filePath, 'utf8')),
|
|
exists: true,
|
|
readable: true,
|
|
};
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return { values: {}, exists: false, readable: true };
|
|
}
|
|
|
|
console.warn(`[credentials] Unable to read temporary credentials file at ${filePath}.`);
|
|
return { values: {}, exists: true, readable: false };
|
|
}
|
|
}
|
|
|
|
function isConfiguredCredential(value: string | undefined): value is string {
|
|
return Boolean(value?.trim());
|
|
}
|
|
|
|
function isUsableTemporaryCredential(
|
|
name: CredentialName,
|
|
value: string | undefined,
|
|
): value is string {
|
|
if (!value?.trim()) {
|
|
return false;
|
|
}
|
|
|
|
if (name === 'CREDS_KEY') {
|
|
return /^[0-9a-f]{64}$/i.test(value);
|
|
}
|
|
|
|
if (name === 'CREDS_IV') {
|
|
return /^[0-9a-f]{32}$/i.test(value);
|
|
}
|
|
|
|
return value.length >= 32;
|
|
}
|
|
|
|
function generateCredential(name: CredentialName): string {
|
|
return crypto.randomBytes(name === 'CREDS_IV' ? 16 : 32).toString('hex');
|
|
}
|
|
|
|
function isProtectedEnvironmentPath(filePath: string): boolean {
|
|
const basename = path.basename(path.resolve(filePath));
|
|
return basename === '.env' || basename === '.env.example';
|
|
}
|
|
|
|
function serializeCredentialFile(values: Partial<Record<CredentialName, string>>): string {
|
|
return [
|
|
'# Automatically generated by LibreChat. Keep this file private and persistent.',
|
|
...credentialNames.filter((name) => values[name]).map((name) => `${name}=${values[name]}`),
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function hasUsableCredentialValues(filePath: string, names: CredentialName[]): boolean {
|
|
const file = readCredentialFile(filePath);
|
|
return (
|
|
file.readable && names.every((name) => isUsableTemporaryCredential(name, file.values[name]))
|
|
);
|
|
}
|
|
|
|
function writeCredentialFile(
|
|
filePath: string,
|
|
values: Partial<Record<CredentialName, string>>,
|
|
overwrite: boolean,
|
|
generatedNames: CredentialName[],
|
|
): CredentialFileWriteResult {
|
|
if (isProtectedEnvironmentPath(filePath)) {
|
|
return 'failed';
|
|
}
|
|
|
|
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
const lockPath = `${filePath}.lock`;
|
|
let ownsLock = false;
|
|
try {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(temporaryPath, serializeCredentialFile(values), { mode: 0o600 });
|
|
|
|
if (overwrite) {
|
|
try {
|
|
fs.linkSync(temporaryPath, lockPath);
|
|
ownsLock = true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
|
|
if (hasUsableCredentialValues(lockPath, generatedNames)) {
|
|
try {
|
|
fs.renameSync(lockPath, filePath);
|
|
} catch {
|
|
// The lock owner may have already promoted the same credential values.
|
|
}
|
|
}
|
|
return hasUsableCredentialValues(filePath, generatedNames) ? 'exists' : 'failed';
|
|
}
|
|
|
|
if (hasUsableCredentialValues(filePath, generatedNames)) {
|
|
return 'exists';
|
|
}
|
|
fs.renameSync(temporaryPath, filePath);
|
|
} else {
|
|
try {
|
|
fs.linkSync(temporaryPath, filePath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
return 'exists';
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
fs.chmodSync(filePath, 0o600);
|
|
return 'written';
|
|
} catch {
|
|
return 'failed';
|
|
} finally {
|
|
try {
|
|
fs.unlinkSync(temporaryPath);
|
|
} catch {
|
|
// The temporary path is absent after a successful rename.
|
|
}
|
|
if (ownsLock) {
|
|
try {
|
|
fs.unlinkSync(lockPath);
|
|
} catch {
|
|
// A contender may have promoted the lock while adopting its values.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function hardenCredentialFile(filePath: string): void {
|
|
try {
|
|
fs.chmodSync(filePath, 0o600);
|
|
} catch {
|
|
console.warn(`[credentials] Unable to restrict permissions on ${filePath}.`);
|
|
}
|
|
}
|
|
|
|
function adoptCredentialFile(
|
|
filePath: string,
|
|
names: CredentialName[],
|
|
sources: Record<CredentialName, CredentialSource>,
|
|
loadedFromFile: CredentialName[],
|
|
): boolean {
|
|
const file = readCredentialFile(filePath);
|
|
if (!file.readable) {
|
|
return false;
|
|
}
|
|
|
|
for (const name of names) {
|
|
const value = file.values[name];
|
|
if (!isUsableTemporaryCredential(name, value)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (const name of names) {
|
|
process.env[name] = file.values[name];
|
|
sources[name] = 'temporary';
|
|
loadedFromFile.push(name);
|
|
}
|
|
hardenCredentialFile(filePath);
|
|
return true;
|
|
}
|
|
|
|
export function bootstrapCredentials(): CredentialRuntimeState {
|
|
const existingState = getRuntimeState();
|
|
if (existingState) {
|
|
return existingState;
|
|
}
|
|
|
|
const filePath = getCredentialPath();
|
|
const file = readCredentialFile(filePath);
|
|
const sources = {} as Record<CredentialName, CredentialSource>;
|
|
const generated: CredentialName[] = [];
|
|
const loadedFromFile: CredentialName[] = [];
|
|
const missingFromEnvironment: CredentialName[] = [];
|
|
const temporaryValues: Partial<Record<CredentialName, string>> = {};
|
|
|
|
for (const name of credentialNames) {
|
|
const environmentValue = process.env[name];
|
|
if (isConfiguredCredential(environmentValue)) {
|
|
sources[name] = isLegacyCredential(name, environmentValue) ? 'legacy-default' : 'environment';
|
|
continue;
|
|
}
|
|
|
|
missingFromEnvironment.push(name);
|
|
const fileValue = file.values[name];
|
|
if (isUsableTemporaryCredential(name, fileValue)) {
|
|
process.env[name] = fileValue;
|
|
sources[name] = 'temporary';
|
|
loadedFromFile.push(name);
|
|
temporaryValues[name] = fileValue;
|
|
continue;
|
|
}
|
|
|
|
const generatedValue = generateCredential(name);
|
|
process.env[name] = generatedValue;
|
|
sources[name] = 'temporary';
|
|
generated.push(name);
|
|
temporaryValues[name] = generatedValue;
|
|
}
|
|
|
|
let persistenceFailed = false;
|
|
if (generated.length > 0 && file.readable) {
|
|
const writeResult = writeCredentialFile(filePath, temporaryValues, file.exists, generated);
|
|
if (writeResult === 'exists') {
|
|
const adopted = adoptCredentialFile(filePath, generated, sources, loadedFromFile);
|
|
if (adopted) {
|
|
generated.length = 0;
|
|
} else {
|
|
persistenceFailed = true;
|
|
}
|
|
} else {
|
|
persistenceFailed = writeResult === 'failed';
|
|
}
|
|
} else if (generated.length > 0) {
|
|
persistenceFailed = true;
|
|
} else if (loadedFromFile.length > 0) {
|
|
hardenCredentialFile(filePath);
|
|
}
|
|
|
|
const state: CredentialRuntimeState = {
|
|
filePath,
|
|
sources,
|
|
generated,
|
|
loadedFromFile,
|
|
missingFromEnvironment,
|
|
persistenceFailed,
|
|
};
|
|
setRuntimeState(state);
|
|
|
|
if (generated.length > 0 && !persistenceFailed) {
|
|
console.warn(
|
|
`[credentials] Generated temporary credentials for ${generated.join(', ')}. ` +
|
|
`They are stored in ${filePath}; configure permanent values before production use.`,
|
|
);
|
|
} else if (generated.length > 0) {
|
|
console.warn(
|
|
`[credentials] Generated process-local credentials for ${generated.join(', ')}. ` +
|
|
'Configure permanent values before production use.',
|
|
);
|
|
} else if (loadedFromFile.length > 0) {
|
|
console.warn(
|
|
`[credentials] Using temporary credentials from ${filePath} for ${loadedFromFile.join(', ')}. ` +
|
|
'Configure permanent values before production use.',
|
|
);
|
|
}
|
|
|
|
if (persistenceFailed) {
|
|
console.warn(
|
|
`[credentials] Could not persist temporary credentials to ${filePath}. ` +
|
|
'The generated values will only remain valid for this process.',
|
|
);
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
export function getCredentialRuntimeState(): CredentialRuntimeState | undefined {
|
|
return getRuntimeState();
|
|
}
|
|
|
|
function fingerprintCredential(value: string | undefined): string {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(value ?? '')
|
|
.digest('hex');
|
|
}
|
|
|
|
export function getCredentialFingerprints(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): CredentialFingerprintRecord {
|
|
return credentialNames.reduce<CredentialFingerprintRecord>((fingerprints, name) => {
|
|
fingerprints[name] = fingerprintCredential(env[name]);
|
|
return fingerprints;
|
|
}, {} as CredentialFingerprintRecord);
|
|
}
|
|
|
|
export function isLegacyCredential(name: CredentialName, value: string | undefined): boolean {
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
|
|
return legacyCredentialFingerprints[name]?.includes(fingerprintCredential(value)) ?? false;
|
|
}
|
|
|
|
export function getLegacyCredentialNames(env: NodeJS.ProcessEnv = process.env): CredentialName[] {
|
|
return credentialNames.filter((name) => isLegacyCredential(name, env[name]));
|
|
}
|