From 1596df724a840f894831fc74f21de8d8df72fcb1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 7 Aug 2026 07:25:05 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AB=86=20chore:=20Remove=20Published=20Cr?= =?UTF-8?q?edential=20Defaults=20(#14680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 18 +- Dockerfile | 3 +- Dockerfile.multi | 1 + api/config/credentials.js | 5 + api/server/experimental.js | 10 +- api/server/experimental.spec.js | 7 + api/server/index.js | 2 + api/server/index.spec.js | 12 +- deploy-compose.yml | 3 + docker-compose.yml | 3 + helm/librechat/readme.md | 3 +- helm/librechat/values.yaml | 7 +- packages/api/package.json | 6 + packages/api/src/app/checks.ts | 156 ++++++++- packages/api/src/app/credentials.spec.ts | 117 +++++++ packages/api/src/credentials.spec.ts | 138 ++++++++ packages/api/src/credentials.ts | 386 +++++++++++++++++++++++ packages/api/src/index.ts | 1 + packages/api/tsdown.config.mjs | 2 +- 19 files changed, 841 insertions(+), 39 deletions(-) create mode 100644 api/config/credentials.js create mode 100644 packages/api/src/app/credentials.spec.ts create mode 100644 packages/api/src/credentials.spec.ts create mode 100644 packages/api/src/credentials.ts diff --git a/.env.example b/.env.example index 33b3133670..9a95186e5a 100644 --- a/.env.example +++ b/.env.example @@ -500,8 +500,10 @@ ASSISTANTS_API_KEY=user_provided # More info, including how to enable use of Assistants with Azure here: # https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure -CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0 -CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb +# Leave these blank to let LibreChat generate and persist temporary credentials in .env.temp. +# Configure unique, persistent values before using a production instance. +CREDS_KEY= +CREDS_IV= # Azure AI Search #----------------- @@ -581,10 +583,12 @@ ZAPIER_NLA_API_KEY= # Search # #==================================================# -SEARCH=true +# Set both SEARCH=true and a unique MEILI_MASTER_KEY to enable search. +SEARCH=false MEILI_NO_ANALYTICS=true MEILI_HOST=http://0.0.0.0:7700 -MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt +# Set a unique value when Meilisearch is enabled; do not reuse a published default. +MEILI_MASTER_KEY= # Optional: Disable indexing, useful in a multi-node setup # where only one instance should perform an index sync. @@ -684,8 +688,10 @@ REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7 # Set to false only for HTTP-only deployments where browsers drop Secure cookies. # SESSION_COOKIE_SECURE=false -JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef -JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418 +# Leave these blank to use generated temporary secrets from .env.temp. +# Configure unique, persistent values before using a production instance. +JWT_SECRET= +JWT_REFRESH_SECRET= # Discord DISCORD_CLIENT_ID= diff --git a/Dockerfile b/Dockerfile index 9e5d41b5d6..c135700354 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,8 @@ RUN \ # Allow mounting of these files, which have no default touch .env ; \ # Create directories for the volumes to inherit the correct permissions - mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill ; \ + mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill /app/data ; \ + chmod 1777 /app/data ; \ npm config set fetch-retry-maxtimeout 600000 ; \ npm config set fetch-retries 5 ; \ npm config set fetch-retry-mintimeout 15000 ; \ diff --git a/Dockerfile.multi b/Dockerfile.multi index ce429c02bb..f2fe8de220 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -91,6 +91,7 @@ RUN npm run build FROM base-min AS api-build ARG NPM_CI_TIMEOUT_SECONDS=1500 ARG NPM_CI_ATTEMPTS=2 +RUN mkdir -p /app/data && chmod 1777 /app/data # Add `uv` for extended MCP support COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/ RUN uv --version diff --git a/api/config/credentials.js b/api/config/credentials.js new file mode 100644 index 0000000000..e4ece9f575 --- /dev/null +++ b/api/config/credentials.js @@ -0,0 +1,5 @@ +require('dotenv').config(); + +const { bootstrapCredentials } = require('@librechat/api/credentials'); + +module.exports = bootstrapCredentials(); diff --git a/api/server/experimental.js b/api/server/experimental.js index 9bc60f627b..f702297474 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -1,4 +1,4 @@ -require('dotenv').config(); +require('../config/credentials'); const fs = require('fs'); const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..') }); @@ -300,7 +300,7 @@ if (cluster.isMaster) { app.set('trust proxy', trusted_proxy); /** Seed database (idempotent) */ - await seedDatabase(); + await runAsSystem(seedDatabase); /* Mirrors `server/index.js`; `runAsSystem` for tenant-isolated File. */ runAsSystem(sweepOrphanedPreviews).catch((err) => { @@ -323,8 +323,10 @@ if (cluster.isMaster) { }); expiredFileSweepOptions = { appConfig, loadAppConfig: getAppConfig }; startExpiredFileSweepOnce(); - await performStartupChecks(appConfig); - await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions }); + await runAsSystem(async () => { + await performStartupChecks(appConfig); + await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions }); + }); /** Load index.html for SPA serving */ const indexPath = path.join(appConfig.paths.dist, 'index.html'); diff --git a/api/server/experimental.spec.js b/api/server/experimental.spec.js index db58e7c20c..371f36b29f 100644 --- a/api/server/experimental.spec.js +++ b/api/server/experimental.spec.js @@ -12,4 +12,11 @@ describe('Experimental server configuration', () => { expect(timeoutConfigIndex).toBeGreaterThan(-1); expect(listenIndex).toBeLessThan(timeoutConfigIndex); }); + + it('runs cross-tenant startup work in the system context', () => { + expect(source).toContain('await runAsSystem(seedDatabase);'); + expect(source).toMatch( + /await runAsSystem\(async \(\) => \{\s+await performStartupChecks\(appConfig\);\s+await updateInterfacePerms/, + ); + }); }); diff --git a/api/server/index.js b/api/server/index.js index f578fb61fb..dc9c456736 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -1,3 +1,5 @@ +require('../config/credentials'); + const telemetry = require('./telemetry'); const fs = require('fs'); const path = require('path'); diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 9945c51253..f13faa54c0 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -50,13 +50,17 @@ jest.mock( describe('Telemetry wiring', () => { const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); - it('loads telemetry before other server imports', () => { - const firstStatement = source + it('loads credentials before telemetry and other server imports', () => { + const firstStatements = source .split('\n') .map((line) => line.trim()) - .find(Boolean); + .filter(Boolean) + .slice(0, 2); - expect(firstStatement).toBe("const telemetry = require('./telemetry');"); + expect(firstStatements).toEqual([ + "require('../config/credentials');", + "const telemetry = require('./telemetry');", + ]); }); it('mounts telemetry middleware after static assets and before routes', () => { diff --git a/deploy-compose.yml b/deploy-compose.yml index a024d97259..2309050f62 100644 --- a/deploy-compose.yml +++ b/deploy-compose.yml @@ -21,6 +21,7 @@ services: - NODE_ENV=production - MONGO_URI=mongodb://mongodb:27017/LibreChat - MEILI_HOST=http://meilisearch:7700 + - LIBRECHAT_TEMP_CREDENTIALS_PATH=/app/data/.env.temp - RAG_PORT=${RAG_PORT:-8000} - RAG_API_URL=http://rag_api:${RAG_PORT:-8000} - PROXY=${PROXY:-} @@ -39,6 +40,7 @@ services: - ./uploads:/app/uploads - ./logs:/app/api/logs - ./skill:/app/skill + - librechat-data:/app/data admin-panel: image: registry.librechat.ai/clickhouse/librechat-admin-panel:latest @@ -111,3 +113,4 @@ services: volumes: pgdata2: + librechat-data: diff --git a/docker-compose.yml b/docker-compose.yml index 0fada4bb69..cfbd4fc795 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - HOST=0.0.0.0 - MONGO_URI=mongodb://mongodb:27017/LibreChat - MEILI_HOST=http://meilisearch:7700 + - LIBRECHAT_TEMP_CREDENTIALS_PATH=/app/data/.env.temp - RAG_PORT=${RAG_PORT:-8000} - RAG_API_URL=http://rag_api:${RAG_PORT:-8000} - PROXY=${PROXY:-} @@ -35,6 +36,7 @@ services: - ./uploads:/app/uploads - ./logs:/app/logs - ./skill:/app/skill + - librechat-data:/app/data admin-panel: container_name: admin-panel image: registry.librechat.ai/clickhouse/librechat-admin-panel:latest @@ -94,3 +96,4 @@ services: volumes: pgdata2: + librechat-data: diff --git a/helm/librechat/readme.md b/helm/librechat/readme.md index 920709af15..121f67afe1 100755 --- a/helm/librechat/readme.md +++ b/helm/librechat/readme.md @@ -7,7 +7,7 @@ In this Chart, LibreChat will only work with environment Variables. You can Spec ## Setup 1. Generate Variables -Generate `CREDS_KEY`, `JWT_SECRET`, `JWT_REFRESH_SECRET` and `MEILI_MASTER_KEY` using `openssl rand -hex 32` and `CREDS_IV` using openssl rand -hex 16. +Generate unique values for `CREDS_KEY`, `JWT_SECRET`, `JWT_REFRESH_SECRET`, and `MEILI_MASTER_KEY` using `openssl rand -hex 32`, and `CREDS_IV` using `openssl rand -hex 16`. Store them in the existing Kubernetes Secret so every replica uses the same values. place them in a secret like this (If you want to change the secret name, remember to change it in your helm values): ```yaml apiVersion: v1 @@ -18,6 +18,7 @@ metadata: type: Opaque stringData: CREDS_KEY: + CREDS_IV: JWT_SECRET: JWT_REFRESH_SECRET: MEILI_MASTER_KEY: diff --git a/helm/librechat/values.yaml b/helm/librechat/values.yaml index bcefc74342..c72936e1da 100755 --- a/helm/librechat/values.yaml +++ b/helm/librechat/values.yaml @@ -40,11 +40,8 @@ librechat: adminPanelUrl: "" configEnv: - # IMPORTANT -- GENERATE your own: openssl rand -hex 32 and openssl rand -hex 16 for CREDS_IV. Best Practise: Put into Secret. See global.librechat.existingSecretName - CREDS_KEY: 9e95d9894da7e68dd69c0046caf5343c8b1e80c89609b5a1e40e6568b5b23ce6 - CREDS_IV: ac028c86ba23f4cd48165e0ca9f2c683 - JWT_SECRET: 16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef - JWT_REFRESH_SECRET: eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418 + # Set unique, persistent values in global.librechat.existingSecretName before production use. + # If omitted, LibreChat generates temporary values in .env.temp when the container filesystem is persistent. # Set Config Params here # ENV_NAME: env-value diff --git a/packages/api/package.json b/packages/api/package.json index 6d933121fc..0dccd0d638 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,6 +17,12 @@ "types": "./dist/telemetry.d.cts", "default": "./dist/telemetry.cjs" } + }, + "./credentials": { + "require": { + "types": "./dist/credentials.d.cts", + "default": "./dist/credentials.cjs" + } } }, "scripts": { diff --git a/packages/api/src/app/checks.ts b/packages/api/src/app/checks.ts index 019d2a1f56..0376657ad8 100644 --- a/packages/api/src/app/checks.ts +++ b/packages/api/src/app/checks.ts @@ -1,16 +1,25 @@ +import mongoose from 'mongoose'; import { logger, webSearchKeys } from '@librechat/data-schemas'; import { Constants, extractVariableName } from 'librechat-data-provider'; import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; +import type { CredentialFingerprintRecord } from '~/credentials'; +import { + credentialMetadataCollection, + credentialMetadataId, + credentialNames, + getCredentialFingerprints, + getCredentialRuntimeState, + getLegacyCredentialNames, +} from '~/credentials'; import { isEnabled, checkEmailConfig } from '~/utils'; import { handleRateLimits } from './limits'; -const secretDefaults = { - CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', - CREDS_IV: 'e2341419ec3dd3d19b13a1a87fafcbfb', - JWT_SECRET: '16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef', - JWT_REFRESH_SECRET: 'eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418', -}; +interface CredentialMetadata { + _id: string; + fingerprints: Partial; + createdAt: Date; +} const deprecatedVariables = [ { @@ -106,26 +115,48 @@ function checkPasswordReset() { * @param {Function} options.checkEmailConfig - Function to check email configuration */ export function checkVariables(): void { - let hasDefaultSecrets = false; - for (const [key, value] of Object.entries(secretDefaults)) { - if (process.env[key] === value) { - logger.warn(`Default value for ${key} is being used.`); - if (!hasDefaultSecrets) { - hasDefaultSecrets = true; - } - } + const legacyNames = getLegacyCredentialNames(); + for (const key of legacyNames) { + logger.warn( + `Legacy default value for ${key} is being used. Generate and configure a unique value.`, + ); } - if (hasDefaultSecrets) { - logger.info('Please replace any default secret values.'); + if (legacyNames.length > 0) { + logger.info( + 'Replace legacy credential defaults before exposing this instance to untrusted users.', + ); logger.info(`\u200B - For your convenience, use this tool to generate your own secret values: + Generate unique values with a cryptographically secure random source, for example: + openssl rand -hex 32 + openssl rand -hex 16 for CREDS_IV + + For more guidance, see: https://www.librechat.ai/toolkit/creds_generator \u200B`); } + const runtimeState = getCredentialRuntimeState(); + if (runtimeState?.missingFromEnvironment.length && !runtimeState.persistenceFailed) { + const temporaryNames = runtimeState.missingFromEnvironment.filter( + (name) => runtimeState.sources[name] === 'temporary', + ); + if (temporaryNames.length > 0) { + logger.warn( + `[credentials] No configured value was found for ${temporaryNames.join(', ')}. ` + + `Temporary credentials from ${runtimeState.filePath} are being used.`, + ); + } + } + + if (runtimeState?.persistenceFailed) { + logger.warn( + '[credentials] Temporary credentials could not be persisted. Existing sessions and encrypted data may become inaccessible after restart.', + ); + } + deprecatedVariables.forEach(({ key, description }) => { if (process.env[key]) { logger.warn(`The \`${key}\` environment variable is deprecated. ${description}`); @@ -135,6 +166,96 @@ export function checkVariables(): void { checkPasswordReset(); } +/** + * Compares active credential fingerprints with the database marker. The marker contains hashes + * only, allowing a new instance to establish its identity without storing secret values. + */ +export async function checkCredentialDatabase(): Promise { + if (mongoose.connection.readyState !== 1 || !mongoose.connection.db) { + return; + } + + const User = mongoose.models.User; + if (!User) { + return; + } + + try { + const collection = mongoose.connection.db.collection( + credentialMetadataCollection, + ); + const [existingUser, existingMetadata] = await Promise.all([ + User.exists({}).exec(), + collection.findOne({ _id: credentialMetadataId }), + ]); + const hasUsers = existingUser !== null; + let metadata = existingMetadata; + + if (!metadata) { + if (!hasUsers) { + const activeFingerprints = getCredentialFingerprints(); + const result = await collection.updateOne( + { _id: credentialMetadataId }, + { + $setOnInsert: { + _id: credentialMetadataId, + fingerprints: activeFingerprints, + createdAt: new Date(), + }, + }, + { upsert: true }, + ); + metadata = await collection.findOne({ _id: credentialMetadataId }); + if (result.upsertedCount === 1) { + logger.info( + '[credentials] New database detected. Credential fingerprints were recorded for future key-drift checks.', + ); + } + } else { + logger.warn( + '[credentials] Existing database has no credential fingerprint record. The active credentials may not match existing JWTs or encrypted records; provide the original values or use a controlled credential migration before rotating them.', + ); + return; + } + } + + const fingerprints = metadata?.fingerprints ?? {}; + const activeFingerprints = getCredentialFingerprints(); + const mismatchedNames: string[] = []; + const matchingNames: string[] = []; + for (const name of credentialNames) { + if (!fingerprints[name]) { + mismatchedNames.push(name); + continue; + } + if (fingerprints[name] === activeFingerprints[name]) { + matchingNames.push(name); + } else { + mismatchedNames.push(name); + } + } + + if (mismatchedNames.length === 0) { + return; + } + + let mismatchDetail = ' Another startup instance may be using different temporary credentials.'; + if (matchingNames.length > 0) { + mismatchDetail = ` ${matchingNames.join(', ')} still match, which indicates mixed credential versions.`; + } else if (hasUsers) { + mismatchDetail = ' Existing encrypted records or JWTs may require the previous values.'; + } + + logger.warn( + `[credentials] Active fingerprints for ${mismatchedNames.join(', ')} do not match the database credential record.` + + mismatchDetail + + ' Do not overwrite the database marker; migrate the affected records and rotate all credentials together.', + ); + } catch (error) { + logger.warn('[credentials] Unable to inspect database credential metadata:', error); + } +} + /** * Checks the health of auxiliary API's by attempting a fetch request to their respective `/health` endpoints. * Logs information or warning based on the API's availability and response. @@ -227,6 +348,7 @@ export function checkInterfaceConfig(appConfig: AppConfig): void { */ export async function performStartupChecks(appConfig?: AppConfig): Promise { checkVariables(); + await checkCredentialDatabase(); if (appConfig?.endpoints?.azureOpenAI) { checkAzureVariables(); } diff --git a/packages/api/src/app/credentials.spec.ts b/packages/api/src/app/credentials.spec.ts new file mode 100644 index 0000000000..49133cc5c8 --- /dev/null +++ b/packages/api/src/app/credentials.spec.ts @@ -0,0 +1,117 @@ +import mongoose from 'mongoose'; +import { logger } from '@librechat/data-schemas'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { Collection } from 'mongodb'; +import type { CredentialFingerprintRecord, CredentialName } from '~/credentials'; +import { + credentialMetadataCollection, + credentialMetadataId, + credentialNames, + getCredentialFingerprints, +} from '~/credentials'; +import { checkCredentialDatabase } from './checks'; + +const configuredCredentials: Record = { + CREDS_KEY: 'a'.repeat(64), + CREDS_IV: 'b'.repeat(32), + JWT_SECRET: 'c'.repeat(64), + JWT_REFRESH_SECRET: 'd'.repeat(64), +}; + +interface CredentialMetadataDocument { + _id: string; + fingerprints: CredentialFingerprintRecord; + createdAt: Date; +} + +function getCredentialMetadataCollection(): Collection { + return mongoose.connection.db!.collection( + credentialMetadataCollection, + ); +} + +describe('checkCredentialDatabase', () => { + const originalEnv = process.env; + let mongoServer: MongoMemoryServer | undefined; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + mongoose.model('User', new mongoose.Schema({}, { collection: 'users', strict: false })); + await mongoose.connect(mongoServer.getUri()); + }); + + beforeEach(async () => { + process.env = { ...originalEnv, ...configuredCredentials }; + await Promise.all([ + mongoose.models.User.deleteMany({}), + getCredentialMetadataCollection().deleteMany({ _id: credentialMetadataId }), + ]); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer?.stop(); + }); + + it('records fingerprints for a new database', async () => { + jest.spyOn(logger, 'info').mockImplementation(); + await checkCredentialDatabase(); + + const metadata = await getCredentialMetadataCollection().findOne({ + _id: credentialMetadataId, + }); + expect(metadata?.fingerprints).toEqual(getCredentialFingerprints()); + }); + + it('does not establish a marker for a database that already has users', async () => { + const warn = jest.spyOn(logger, 'warn').mockImplementation(); + await mongoose.models.User.collection.insertOne({ email: 'existing@example.com' }); + + await checkCredentialDatabase(); + + const metadata = await getCredentialMetadataCollection().findOne({ + _id: credentialMetadataId, + }); + expect(metadata).toBeNull(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Existing database has no credential fingerprint record'), + ); + }); + + it('warns without overwriting a mismatched marker', async () => { + const warn = jest.spyOn(logger, 'warn').mockImplementation(); + const originalFingerprints = getCredentialFingerprints(); + await getCredentialMetadataCollection().insertOne({ + _id: credentialMetadataId, + fingerprints: originalFingerprints, + createdAt: new Date(), + }); + process.env.JWT_SECRET = 'e'.repeat(64); + + await checkCredentialDatabase(); + + const metadata = await getCredentialMetadataCollection().findOne({ + _id: credentialMetadataId, + }); + expect(metadata?.fingerprints).toEqual(originalFingerprints); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET')); + }); + + it('accepts a marker that matches all active credentials', async () => { + const warn = jest.spyOn(logger, 'warn').mockImplementation(); + await getCredentialMetadataCollection().insertOne({ + _id: credentialMetadataId, + fingerprints: getCredentialFingerprints(), + createdAt: new Date(), + }); + + await checkCredentialDatabase(); + + expect(warn).not.toHaveBeenCalled(); + expect(credentialNames).toHaveLength(4); + }); +}); diff --git a/packages/api/src/credentials.spec.ts b/packages/api/src/credentials.spec.ts new file mode 100644 index 0000000000..f67bea5def --- /dev/null +++ b/packages/api/src/credentials.spec.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { CredentialRuntimeState } from './credentials'; +import { + bootstrapCredentials, + credentialNames, + getCredentialFingerprints, + getCredentialRuntimeState, + isLegacyCredential, +} from './credentials'; + +const credentialRuntimeKey = Symbol.for('librechat.credentials.runtime'); + +function resetCredentialRuntime(): void { + const runtime = globalThis as typeof globalThis & + Record; + delete runtime[credentialRuntimeKey]; +} + +describe('credentials', () => { + const originalEnv = process.env; + let tempDirectory: string; + let tempFile: string; + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(); + tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'librechat-credentials-')); + tempFile = path.join(tempDirectory, '.env.temp'); + process.env = { + ...originalEnv, + LIBRECHAT_TEMP_CREDENTIALS_PATH: tempFile, + }; + for (const name of credentialNames) { + delete process.env[name]; + } + resetCredentialRuntime(); + }); + + afterEach(() => { + resetCredentialRuntime(); + process.env = originalEnv; + fs.rmSync(tempDirectory, { recursive: true, force: true }); + }); + + it('generates and persists temporary credentials when values are absent', () => { + const state = bootstrapCredentials(); + + expect(state.generated).toEqual(credentialNames); + expect(state.loadedFromFile).toEqual([]); + expect(state.persistenceFailed).toBe(false); + expect(getCredentialRuntimeState()).toEqual(state); + expect(fs.statSync(tempFile).mode & 0o777).toBe(0o600); + expect(getCredentialFingerprints()).toEqual( + expect.objectContaining({ + CREDS_KEY: expect.any(String), + CREDS_IV: expect.any(String), + JWT_SECRET: expect.any(String), + JWT_REFRESH_SECRET: expect.any(String), + }), + ); + }); + + it('reuses persisted credentials on the next startup', () => { + bootstrapCredentials(); + const originalValues = Object.fromEntries( + credentialNames.map((name) => [name, process.env[name]]), + ); + resetCredentialRuntime(); + for (const name of credentialNames) { + delete process.env[name]; + } + + const state = bootstrapCredentials(); + + expect(state.generated).toEqual([]); + expect(state.loadedFromFile).toEqual(credentialNames); + for (const name of credentialNames) { + expect(process.env[name]).toBe(originalValues[name]); + } + }); + + it('adopts a winning repair when an existing credential file is incomplete', () => { + const winningValues = { + CREDS_KEY: 'a'.repeat(64), + CREDS_IV: 'b'.repeat(32), + JWT_SECRET: 'c'.repeat(64), + JWT_REFRESH_SECRET: 'd'.repeat(64), + }; + fs.writeFileSync( + tempFile, + `CREDS_KEY=${winningValues.CREDS_KEY}\nCREDS_IV=${winningValues.CREDS_IV}\n`, + ); + fs.writeFileSync( + `${tempFile}.lock`, + credentialNames.map((name) => `${name}=${winningValues[name]}`).join('\n'), + { mode: 0o600 }, + ); + + const state = bootstrapCredentials(); + + expect(state.generated).toEqual([]); + expect(state.loadedFromFile).toEqual(credentialNames); + expect(state.persistenceFailed).toBe(false); + expect(fs.existsSync(`${tempFile}.lock`)).toBe(false); + for (const name of credentialNames) { + expect(process.env[name]).toBe(winningValues[name]); + } + }); + + it('preserves explicitly configured JWT secrets for backward compatibility', () => { + process.env.JWT_SECRET = 'short-but-explicit'; + process.env.JWT_REFRESH_SECRET = 'another-explicit-value'; + + const state = bootstrapCredentials(); + + expect(process.env.JWT_SECRET).toBe('short-but-explicit'); + expect(process.env.JWT_REFRESH_SECRET).toBe('another-explicit-value'); + expect(state.sources.JWT_SECRET).toBe('environment'); + expect(state.sources.JWT_REFRESH_SECRET).toBe('environment'); + expect(state.generated).toEqual(['CREDS_KEY', 'CREDS_IV']); + }); + + it('does not overwrite an explicitly selected environment file', () => { + const environmentFile = path.join(tempDirectory, '.env'); + process.env.LIBRECHAT_TEMP_CREDENTIALS_PATH = environmentFile; + + const state = bootstrapCredentials(); + + expect(state.persistenceFailed).toBe(true); + expect(fs.existsSync(environmentFile)).toBe(false); + }); + + it('does not classify an arbitrary credential as a legacy default', () => { + expect(isLegacyCredential('JWT_SECRET', 'test-only-secret')).toBe(false); + }); +}); diff --git a/packages/api/src/credentials.ts b/packages/api/src/credentials.ts new file mode 100644 index 0000000000..b3bba9523f --- /dev/null +++ b/packages/api/src/credentials.ts @@ -0,0 +1,386 @@ +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; + 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>; + 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> = { + 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; + return runtime[credentialRuntimeKey]; +} + +function setRuntimeState(state: CredentialRuntimeState): void { + const runtime = globalThis as typeof globalThis & + Record; + 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> { + return contents.split(/\r?\n/).reduce>>((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>): 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>, + 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, + 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; + const generated: CredentialName[] = []; + const loadedFromFile: CredentialName[] = []; + const missingFromEnvironment: CredentialName[] = []; + const temporaryValues: Partial> = {}; + + 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((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])); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 769ca417ee..479104cd36 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,5 @@ export * from './app'; +export * from './credentials'; /* Artifacts */ export * from './artifacts'; /* Admin */ diff --git a/packages/api/tsdown.config.mjs b/packages/api/tsdown.config.mjs index 9ce2bf347a..b815bbd659 100644 --- a/packages/api/tsdown.config.mjs +++ b/packages/api/tsdown.config.mjs @@ -6,7 +6,7 @@ export default defineConfig({ // `src/telemetry/index.ts` barrel: oxc emits declarations flat into outDir keyed // by source basename, so two `index.ts` entries would collide (index.d.cts + // index2.d.cts). Distinct basenames yield stable `index.*` / `telemetry.*` output. - entry: ['src/index.ts', 'src/telemetry.ts'], + entry: ['src/index.ts', 'src/telemetry.ts', 'src/credentials.ts'], format: ['cjs'], platform: 'node', dts: { oxc: true },