Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,62 @@ MONGO_AUTO_INDEX=
# Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
MONGO_AUTO_CREATE=

# ──────────────────────────────────────────────────────────────────────────────
# Client-Side Field Level Encryption (CSFLE)
# ──────────────────────────────────────────────────────────────────────────────
# Encrypts messages.text and messages.content at the MongoDB driver layer.
# Requires mongodb-client-encryption and crypt_shared (bundled in Docker image).
CSFLE_ENABLED=

# When true, LibreChat backfills existing plaintext docs at startup.
# State is tracked in __csfle_migrations (idempotent).
CSFLE_AUTO_MIGRATE=

# Startup behaviour if auto-migration fails.
# strict (default) — abort startup on migration failure (recommended for prod)
# warn — log the error and continue startup
CSFLE_STARTUP_POLICY=strict

# Force re-run of the migration even if already applied.
# Use to encrypt docs that were missed (e.g. after a bug fix).
# Set to true for a single startup, then remove.
# WARNING: scans entire messages collection — use during a maintenance window.
CSFLE_FORCE_REMIGRATE=

# Key vault namespace. Defaults to "<dbName>.__keyVault".
MONGO_CSFLE_KEY_VAULT_NAMESPACE=LibreChat.__keyVault

# Path to the crypt_shared shared library.
# Bundled in Docker image at /app/lib/mongo_crypt_v1.so (set automatically via ENV).
# Override only if using a different version or running outside Docker.
MONGO_CRYPT_SHARED_LIB_PATH=

# ── Local dev master key (96-byte, base64-encoded) ───────────────────────────
# Used when GCP KMS is not configured (local dev / CI only).
# Generate: node -e "console.log(require('crypto').randomBytes(96).toString('base64'))"
MONGO_CSFLE_LOCAL_MASTER_KEY=

# ── GCP Cloud KMS (production) ───────────────────────────────────────────────
# You can follow this documentation to create the KMS in GCP:
# https://www.mongodb.com/docs/manual/core/csfle/tutorials/gcp/gcp-automatic/?language-no-dependencies=nodejs#set-up-the-kms
GCP_KMS_PROJECT_ID=
GCP_KMS_LOCATION=
GCP_KMS_KEY_RING=
GCP_KMS_KEY_NAME=

# GCP service account JSON key file for CSFLE KMS auth.
# Resolution order:
# 1. CSFLE_GCP_SERVICE_ACCOUNT_FILE (preferred, CSFLE-specific)
# 2. GOOGLE_SERVICE_KEY_FILE (shared with other GCP integrations)
# 3. Neither set → uses ADC / K8s Workload Identity automatically
# Path must point to a standard GCP service account JSON file containing
# client_email and private_key fields.
CSFLE_GCP_SERVICE_ACCOUNT_FILE=

# ── mongocryptd fallback (only needed without crypt_shared) ──────────────────
MONGOCRYPTD_URI=
MONGOCRYPTD_BYPASS_SPAWN=

DOMAIN_CLIENT=http://localhost:3080
DOMAIN_SERVER=http://localhost:3080

Expand Down
51 changes: 44 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
# v0.8.7

# Base node image
FROM node:24.16.0-alpine AS node
# ── Stage 1: download crypt_shared ──────────────────────────────────────────
# mongo_crypt_v1.so is the Automatic Encryption Shared Library required for
# CSFLE. Pinned to 7.0.21 with verified SHA-256 checksums (both amd64 and
# arm64) for reproducible, supply-chain-safe builds.
FROM alpine:3.21 AS crypt-shared

RUN apk upgrade --no-cache
RUN apk add --no-cache jemalloc
RUN apk add --no-cache python3 py3-pip uv
ARG CRYPT_VERSION=7.0.21
ARG CRYPT_BASE_URL=https://downloads.mongodb.com/linux/mongo_crypt_shared_v1-linux

# SHA-256 digests verified 2026-06-21
ARG CRYPT_SHA256_AMD64=25190407f7131989fdd9c113ef0aa4c7ef618cccee024b35e8de0aeaf3f74764
ARG CRYPT_SHA256_ARM64=81bbf9120dd00e856a36d5f8234c88b0fdd4c9d6677a327e9047a7483a9e88d0

RUN apk add --no-cache curl tar && \
ARCH="$(uname -m)" && \
case "$ARCH" in \
x86_64) MONGO_ARCH=x86_64; EXPECTED_SHA256="$CRYPT_SHA256_AMD64" ;; \
aarch64) MONGO_ARCH=aarch64; EXPECTED_SHA256="$CRYPT_SHA256_ARM64" ;; \
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
esac && \
URL="${CRYPT_BASE_URL}-${MONGO_ARCH}-enterprise-ubuntu2204-${CRYPT_VERSION}.tgz" && \
curl -fsSL "$URL" -o /tmp/crypt_shared.tgz && \
echo "${EXPECTED_SHA256} /tmp/crypt_shared.tgz" | sha256sum -c - && \
mkdir -p /cryptlib /tmp/crypt_extract && \
tar -xzf /tmp/crypt_shared.tgz -C /tmp/crypt_extract && \
find /tmp/crypt_extract -name 'mongo_crypt_v1.so' -exec cp {} /cryptlib/mongo_crypt_v1.so \; && \
rm -rf /tmp/crypt_shared.tgz /tmp/crypt_extract

# ── Stage 2: application ─────────────────────────────────────────────────────
# Debian Bookworm (glibc) is required: mongo_crypt_v1.so uses glibc symbols
# (e.g. pthread_cond_clockwait) that Alpine's musl/gcompat layer does not provide.
FROM node:24.16.0-bookworm-slim AS node

RUN apt-get update && apt-get upgrade -y --no-install-recommends && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libjemalloc2 \
python3 \
&& ln -s "$(find /usr/lib -name 'libjemalloc.so.2' -print -quit)" /usr/local/lib/libjemalloc.so.2 \
&& rm -rf /var/lib/apt/lists/*

# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so.2

# Add `uv` for extended MCP support
COPY --from=ghcr.io/astral-sh/uv:0.9.5-python3.12-alpine /usr/local/bin/uv /usr/local/bin/uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.5-python3.12-bookworm /usr/local/bin/uv /usr/local/bin/uvx /bin/
RUN uv --version

# Set configurable max-old-space-size with default
Expand All @@ -22,6 +55,10 @@ ARG NPM_CI_ATTEMPTS=2
RUN mkdir -p /app && chown node:node /app
WORKDIR /app

# Bundle crypt_shared so CSFLE works without a host mount
COPY --from=crypt-shared --chown=node:node /cryptlib/mongo_crypt_v1.so /app/lib/mongo_crypt_v1.so
ENV MONGO_CRYPT_SHARED_LIB_PATH=/app/lib/mongo_crypt_v1.so

USER node

COPY --chown=node:node package.json package-lock.json ./
Expand Down
210 changes: 210 additions & 0 deletions api/csfle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# CSFLE Integration — api/csfle

LibreChat encrypts sensitive message fields at the MongoDB driver layer using
**Client-Side Field Level Encryption (CSFLE)**. Encryption and decryption happen
transparently in the application process — MongoDB never sees plaintext for the
protected fields.

---

## Encrypted fields

| Collection | Field | Type | Algorithm |
|---|---|---|---|
| `messages` | `text` | string | Random |
| `messages` | `content` | array | Random |

Random encryption means ciphertext differs on every write. Equality queries on
these fields are not supported (and not used by LibreChat).

---

## Prerequisites

- `mongodb-client-encryption` npm package (already in `api/package.json`).
- `crypt_shared` or `mongocryptd` to handle BSON transformation locally.
**Recommended:** use `crypt_shared` — it is bundled in the Docker image at
`/app/lib/mongo_crypt_v1.so` with `MONGO_CRYPT_SHARED_LIB_PATH` pre-set.

---

## Environment variables

### Core flags

| Variable | Default | Description |
|---|---|---|
| `CSFLE_ENABLED` | — | Set `true` to activate CSFLE. When absent/false no other CSFLE vars are read. |
| `CSFLE_AUTO_MIGRATE` | `false` | When `true`, backfills existing plaintext docs at startup. |
| `CSFLE_STARTUP_POLICY` | `strict` | `strict` — abort startup on failure. `warn` — log and continue. |
| `CSFLE_FORCE_REMIGRATE` | — | Set `true` to re-run the migration even if already applied (single-use). |

### Key vault

| Variable | Default | Description |
|---|---|---|
| `MONGO_CSFLE_KEY_VAULT_NAMESPACE` | `<dbName>.__keyVault` | Override when the DB name differs. |

### Crypto backend

| Variable | Description |
|---|---|
| `MONGO_CRYPT_SHARED_LIB_PATH` | Path to `crypt_shared` inside the container. Pre-set to `/app/lib/mongo_crypt_v1.so` in the Docker image. |
| `MONGOCRYPTD_URI` | Fallback if `MONGO_CRYPT_SHARED_LIB_PATH` is not set. |
| `MONGOCRYPTD_BYPASS_SPAWN` | Set `true` when mongocryptd is managed externally. |

### KMS — local dev / CI

```sh
# Generate a 96-byte local master key (base64):
node -e "console.log(require('crypto').randomBytes(96).toString('base64'))"
```

| Variable | Description |
|---|---|
| `MONGO_CSFLE_LOCAL_MASTER_KEY` | Base64-encoded 96-byte key. Used when `GCP_KMS_PROJECT_ID` is not set. |

### KMS — production (GCP Cloud KMS + Workload Identity)

| Variable | Example |
|---|---|
| `GCP_KMS_PROJECT_ID` | `librechat-prod` |
| `GCP_KMS_LOCATION` | `europe-west1` |
| `GCP_KMS_KEY_RING` | `mongodb-csfle` |
| `GCP_KMS_KEY_NAME` | `csfle-cmk` |
| `CSFLE_GCP_SERVICE_ACCOUNT_FILE` | `/run/secrets/csfle-sa.json` *(optional)* |

**GCP auth resolution order** (when `GCP_KMS_PROJECT_ID` is set):
1. `CSFLE_GCP_SERVICE_ACCOUNT_FILE` — path to a GCP service account JSON key file (preferred)
2. `GOOGLE_SERVICE_KEY_FILE` — fallback to the shared GCP key file used by other app integrations
3. Neither set → **ADC / K8s Workload Identity** (recommended in production, no file needed)

The JSON file must contain `client_email` and `private_key`. If a path is configured but the file is missing, unreadable, or malformed, LibreChat throws an explicit startup error naming the env var and path.

---

## Local dev setup

1. Generate a local master key and add it to your `.env`:
```sh
node -e "console.log(require('crypto').randomBytes(96).toString('base64'))"
# → paste as MONGO_CSFLE_LOCAL_MASTER_KEY=<base64>
```

2. Enable CSFLE in `.env`:
```
CSFLE_ENABLED=true
CSFLE_AUTO_MIGRATE=true
CSFLE_STARTUP_POLICY=warn
MONGO_CSFLE_LOCAL_MASTER_KEY=<your-base64-key>
```

3. Start LibreChat normally. On first startup the app will:
- Create the `__keyVault` collection and `dek-messages` DEK.
- Backfill any existing plaintext `messages.text` / `messages.content` docs.

---

## Google Cloud KMS setup

1. Create a KMS key ring and key in `europe-west1`.
2. Grant the service account / Workload Identity `roles/cloudkms.cryptoKeyEncrypterDecrypter`.
3. Set the four `GCP_KMS_*` env vars.
4. Set `CSFLE_ENABLED=true`.

**Auth options** (mutually exclusive, use one):

- **K8s Workload Identity / ADC** *(recommended in production)* — leave `CSFLE_GCP_SERVICE_ACCOUNT_FILE` and `GOOGLE_SERVICE_KEY_FILE` unset. libmongocrypt picks up credentials from the environment automatically.
- **Service account JSON file** *(local docker / CI)* — mount the key file into the container and set `CSFLE_GCP_SERVICE_ACCOUNT_FILE=/path/to/sa.json`.

```yaml
# docker-compose example — GCP key file mount
services:
api:
environment:
CSFLE_ENABLED: "true"
GCP_KMS_PROJECT_ID: "librechat-prod"
GCP_KMS_LOCATION: "europe-west1"
GCP_KMS_KEY_RING: "mongodb-csfle"
GCP_KMS_KEY_NAME: "csfle-cmk"
CSFLE_GCP_SERVICE_ACCOUNT_FILE: "/run/secrets/csfle-sa.json"
volumes:
- ./secrets/csfle-sa.json:/run/secrets/csfle-sa.json:ro
```

---

## Startup flow

```
connectDb() (api/db/connect.js)
├─ CSFLE_ENABLED=true
│ buildAutoEncryptionOptions()
│ bootstrapKeyVault() ← creates __keyVault + dek-messages (idempotent)
│ fetch DEK UUID from keyVault
│ buildSchemaMap() ← messages.text + messages.content schema
│ mongoose.connect(uri, { autoEncryption })
└─ CSFLE_AUTO_MIGRATE=true && !cached.migrationRan
runStartupMigration(mongoUri) (api/csfle/manager.js)
├─ check __csfle_migrations for version=1
├─ if APPLIED and !FORCE_REMIGRATE → skip
└─ backfillCollection(messages, ['text','content'])
for each plaintext doc: updateOne + $set
mark APPLIED / FAILED in __csfle_migrations
on error:
strict → throw (startup aborts)
warn → log, continue
```

---

## Migration state

Applied migrations are tracked in `__csfle_migrations` (same database):

```js
{
version: 1,
description: 'Backfill messages.text and messages.content with CSFLE encryption',
status: 'applied' | 'failed' | 'pending',
startedAt: Date,
appliedAt: Date,
stats: { migrated, skipped, errors },
}
```

- `applied` → skip on next run (idempotent).
- `pending` / `failed` → retry on next run (crash recovery).

To re-run after a bug fix: set `CSFLE_FORCE_REMIGRATE=true`, restart once, then remove it.

---

## Verifying encryption

In MongoDB Compass or mongosh with a raw (non-CSFLE) client, encrypted docs show
`text` and `content` as `BinData(6, ...)`. Plaintext values indicate the migration
has not yet run or `CSFLE_ENABLED` is not set in the app.

```js
// mongosh — check a sample message
db.messages.findOne({}, { text: 1, content: 1 })
// Encrypted: { text: BinData(6,"..."), content: BinData(6,"...") }
// Plaintext: { text: "hello world", content: [...] }
```

---

## MeiliSearch

`messages.text` and `messages.content` are encrypted — MeiliSearch receives
`BinData` and cannot index these fields. LibreChat skips the MeiliSearch plugin
for the `messages` model when `CSFLE_ENABLED=true`. Full-text search will not
work on message content while CSFLE is enabled.

---
Loading