Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .changeset/encryption-key-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

`THINKER_ENCRYPTION_KEY` is required and separate from `BETTER_AUTH_SECRET` (#27): the server refuses to start when it is missing, shorter than 32 characters or equal to `BETTER_AUTH_SECRET`, and at-rest encryption never falls back to the session secret. Ships inside 0.1.0; to be listed in docs/releases/0.1.0.md.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ jobs:
DATABASE_URL: postgresql://myuser:mypassword@localhost:5432/thinker_e2e_test
THINKER_E2E_ALLOW_DROP: "1"
BETTER_AUTH_SECRET: ci-test-secret-at-least-32-characters-long!!
THINKER_ENCRYPTION_KEY: ci-test-encryption-key-at-least-32-characters!!
NODE_ENV: test
TZ: ${{ matrix.tz }}
steps:
Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/docker-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,18 @@ jobs:
printf 'services:\n manifest:\n image: thinker:ci\n' > "$override"
echo "COMPOSE_FILE=$GITHUB_WORKSPACE/docker/docker-compose.yml:$override" >> "$GITHUB_ENV"

- name: Prepare .env from .env.example with a generated secret
- name: Prepare .env from .env.example with generated secrets
working-directory: docker
run: |
cp .env.example .env
# A fresh secret per run, so BETTER_AUTH_SECRET=${VAR:?…} succeeds.
# It never leaves the runner and is not printed.
# Fresh, distinct secrets per run, so both ${VAR:?…} checks succeed.
# They never leave the runner and are not printed.
secret=$(openssl rand -hex 32)
encryption_key=$(openssl rand -hex 32)
sed -i "s/^BETTER_AUTH_SECRET=\$/BETTER_AUTH_SECRET=${secret}/" .env
sed -i "s/^# THINKER_ENCRYPTION_KEY=\$/THINKER_ENCRYPTION_KEY=${encryption_key}/" .env
grep -Eq '^BETTER_AUTH_SECRET=[0-9a-f]{64}$' .env
grep -Eq '^THINKER_ENCRYPTION_KEY=[0-9a-f]{64}$' .env

- name: Verify self-host configuration wiring
working-directory: docker
Expand Down Expand Up @@ -451,6 +454,7 @@ jobs:
# .env must have a real BETTER_AUTH_SECRET written — not the
# empty template value.
grep -Eq '^BETTER_AUTH_SECRET=[0-9a-f]{64}$' "$install_dir/.env"
grep -Eq '^THINKER_ENCRYPTION_KEY=[0-9a-f]{64}$' "$install_dir/.env"

- name: Verify Compose project name is pinned (no dir-basename leak)
run: |
Expand Down
11 changes: 6 additions & 5 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
# Session signing secret. Generate with: openssl rand -hex 32
BETTER_AUTH_SECRET=

# ─── At-rest encryption (recommended) ──────────────────────────────────────
# Encrypts stored LLM provider API keys / OAuth tokens. If unset, falls back
# to BETTER_AUTH_SECRET — convenient, but a session-cookie leak then also
# decrypts every stored provider credential. Set to a SEPARATE 32+ char value
# so the two leak surfaces stay independent. Generate with: openssl rand -hex 32
# ─── At-rest encryption (required) ─────────────────────────────────────────
# Encrypts stored LLM provider API keys, OAuth tokens and request recordings.
# Required: the server refuses to start without it. Must be 32+ chars and
# DIFFERENT from BETTER_AUTH_SECRET, so a leak of one does not expose what the
# other protects. Generate with: openssl rand -hex 32
# The install script fills it in; when editing by hand, uncomment and set it.
# THINKER_ENCRYPTION_KEY=


Expand Down
14 changes: 9 additions & 5 deletions docker/DOCKER_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ openssl rand -hex 32

`THINKER_ENCRYPTION_KEY` encrypts the provider API keys, OAuth tokens and
request recordings Thinker stores; the rest of the database is not encrypted
by it. Left unset it falls back to `BETTER_AUTH_SECRET`, which means one leaked
session-signing secret also decrypts every stored credential and recording.
Set it before first boot.
by it. It is required: the server refuses to start when it is missing, shorter
than 32 characters, or equal to `BETTER_AUTH_SECRET`, so a leaked
session-signing secret never decrypts a stored credential or recording.
Recordings written under a previous secret are not migrated: after the secret
changes they can no longer be decrypted and the dashboard shows them as
unavailable, while retention removes them on schedule.
Expand Down Expand Up @@ -169,6 +169,7 @@ docker run -d \
-p 2099:2099 \
-e DATABASE_URL=postgresql://user:pass@host:5432/manifest \
-e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
-e THINKER_ENCRYPTION_KEY=$(openssl rand -hex 32) \
-e BETTER_AUTH_URL=http://localhost:2099 \
tmsoftbrasil/thinker
```
Expand All @@ -180,11 +181,13 @@ docker run -d \

```powershell
$secret = -join ((48..57 + 97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ })
$encryptionKey = -join ((48..57 + 97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ })

docker run -d `
-p 2099:2099 `
-e DATABASE_URL=postgresql://user:pass@host:5432/manifest `
-e BETTER_AUTH_SECRET=$secret `
-e THINKER_ENCRYPTION_KEY=$encryptionKey `
-e BETTER_AUTH_URL=http://localhost:2099 `
tmsoftbrasil/thinker
```
Expand All @@ -194,13 +197,14 @@ docker run -d `
<details>
<summary><strong>Windows (CMD)</strong></summary>

Generate a 64-character hex secret with any tool you trust, then:
Generate two different 64-character hex secrets with any tool you trust, then:

```cmd
docker run -d ^
-p 2099:2099 ^
-e DATABASE_URL=postgresql://user:pass@host:5432/manifest ^
-e BETTER_AUTH_SECRET=<your-64-char-secret> ^
-e THINKER_ENCRYPTION_KEY=<another-64-char-secret> ^
-e BETTER_AUTH_URL=http://localhost:2099 ^
tmsoftbrasil/thinker
```
Expand Down Expand Up @@ -408,7 +412,7 @@ refused, including when a hostname starts resolving to them after it was saved.
| ---------------------------------- | ----------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Yes | -- | PostgreSQL connection string |
| `BETTER_AUTH_SECRET` | Yes | -- | Session signing secret (min 32 chars) |
| `THINKER_ENCRYPTION_KEY` | Recommended | falls back to `BETTER_AUTH_SECRET` | Separate 32+ char key encrypting stored provider keys and OAuth tokens. The install script generates one; set it before first boot, since introducing it later means re-encrypting what is already stored. |
| `THINKER_ENCRYPTION_KEY` | Yes | -- | 32+ char key encrypting stored provider keys, OAuth tokens and request recordings. Must differ from `BETTER_AUTH_SECRET`; the server refuses to start otherwise. The install script generates one. |
| `BETTER_AUTH_URL` | No | `http://localhost:${PORT}` | Public URL. Must match the URL in the browser |
| `PORT` | No | `2099` | Dashboard port — sets both the published host port and the internal listener |
| `OLLAMA_HOST` | No | `http://host.docker.internal:11434` | Ollama endpoint for the built-in tile. Override to point at a LAN-hosted Ollama. |
Expand Down
9 changes: 5 additions & 4 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
# admin account via the setup wizard at /setup. No hardcoded credentials.
#
# Configuration comes from a `.env` file next to this compose file.
# Copy `.env.example` to `.env` and set at least BETTER_AUTH_SECRET before
# Copy `.env.example` to `.env` and set BETTER_AUTH_SECRET and
# THINKER_ENCRYPTION_KEY (two different values) before
# booting. The install script does this automatically.
#
# Connecting local LLM servers (Ollama, LM Studio, or any OpenAI-compatible server):
Expand Down Expand Up @@ -61,9 +62,9 @@ services:
- PORT=${PORT:-2099}
- DATABASE_URL=${DATABASE_URL:-postgresql://manifest:manifest@postgres:5432/manifest}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set in .env}
# Optional dedicated at-rest encryption key for stored provider/OAuth
# credentials. When unset, the backend falls back to BETTER_AUTH_SECRET.
- THINKER_ENCRYPTION_KEY=${THINKER_ENCRYPTION_KEY:-}
# Required at-rest encryption key for stored provider keys, OAuth tokens
# and request recordings. Must differ from BETTER_AUTH_SECRET.
- THINKER_ENCRYPTION_KEY=${THINKER_ENCRYPTION_KEY:?THINKER_ENCRYPTION_KEY must be set in .env}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:${HOST_PORT:-${PORT:-2099}}}
# Extra hosts that may serve the dashboard/API, comma-separated. The
# OAuth callback and session cookie follow the request host, so set this
Expand Down
9 changes: 5 additions & 4 deletions docs/dev/manifest-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ npm start # node packages/backend/dist/main.js — serves frontend + API

### Starting the Dev Server

The backend requires a `.env` file at `packages/backend/.env` with at least `BETTER_AUTH_SECRET` (32+ chars). The `auth.instance.ts` reads `process.env` at import time, before NestJS `ConfigModule` loads `.env`, so env vars must be available to the Node process.
The backend requires a `.env` file at `packages/backend/.env` with at least `BETTER_AUTH_SECRET` and `THINKER_ENCRYPTION_KEY` (32+ chars each, different values). The `auth.instance.ts` reads `process.env` at import time, before NestJS `ConfigModule` loads `.env`, so env vars must be available to the Node process.

**Quick start (run these in parallel):**

Expand Down Expand Up @@ -258,6 +258,7 @@ PORT=3001
BIND_ADDRESS=127.0.0.1
NODE_ENV=development
BETTER_AUTH_SECRET=<random-hex-64-chars>
THINKER_ENCRYPTION_KEY=<another-random-hex-64-chars>
DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydatabase
API_KEY=dev-api-key-12345
SEED_DATA=true
Expand Down Expand Up @@ -463,13 +464,13 @@ Every resource belongs to a tenant; users only authenticate and (optionally) app

See `packages/backend/.env.example` for all variables. Key ones:

Thinker reads only `THINKER_*` names (#21); the upstream-era names are not aliases. At boot, before migrations, `common/utils/legacy-env.ts` logs one warning naming every upstream-prefixed variable that is set (names only, never values) with its `THINKER_*` replacement, or says it no longer exists. When the old encryption-key name is set without `THINKER_ENCRYPTION_KEY`, `assertLegacyEncryptionKeyMigrated()` refuses to boot with an error naming both variables, because the backend would otherwise fall back to `BETTER_AUTH_SECRET` and fail to decrypt stored credentials. The Compose file only passes the variables it lists, so an old name left in `docker/.env` never reaches the container and is not reported.
Thinker reads only `THINKER_*` names (#21); the upstream-era names are not aliases. At boot, before migrations, `common/utils/legacy-env.ts` logs one warning naming every upstream-prefixed variable that is set (names only, never values) with its `THINKER_*` replacement, or says it no longer exists. When the old encryption-key name is set without `THINKER_ENCRYPTION_KEY`, `assertLegacyEncryptionKeyMigrated()` refuses to boot with an error naming both variables, because that is the actionable message for a stale `.env` (the encryption-key check below would otherwise only say the key is missing). The Compose file only passes the variables it lists, so an old name left in `docker/.env` never reaches the container and is not reported.

- `BETTER_AUTH_SECRET` — **Required.** Secret for Better Auth session signing (min 32 chars). Generate with `openssl rand -hex 32`.
- `DATABASE_URL` — **Required** in every environment, `NODE_ENV=test` included (there is no default URL, see "Backend e2e database safety"). The app, Better Auth, the migration runner and the message-provider backfill CLI throw if it is unset (a blank `MIGRATION_DATABASE_URL`/`DATABASE_UNPOOLED_URL` does not override it). Format: `postgresql://user:password@host:port/database`.
- `THINKER_E2E_ALLOW_DROP` — Test-only. Must be `1` for the backend e2e suite to run (together with a `DATABASE_URL` whose database name has `test` as a separate token), because the suite drops the schema. Never set it outside a disposable test database.
- `THINKER_ENCRYPTION_KEY` — Recommended. AES-256-GCM key (min 32 chars) for encrypting stored provider API keys and OAuth tokens. Defaults to `BETTER_AUTH_SECRET` if unset — set this independently so a session-cookie leak doesn't also expose provider credentials.
- `THINKER_ENCRYPTION_KEY_PREVIOUS` — Optional, rotation only. A secret that stored values may still be encrypted under. Decryption always tries `THINKER_ENCRYPTION_KEY`, then this, then `BETTER_AUTH_SECRET`, so old rows stay readable. While it is set, one replica rewrites every row found under an older secret onto the current one after boot (`database/secret-reencryption.service.ts`, advisory-locked, never blocks or fails boot); a normal boot without it never scans. Rotation: set the new `THINKER_ENCRYPTION_KEY` and `THINKER_ENCRYPTION_KEY_PREVIOUS=<old>`, deploy, wait for the log line `Nothing left under an older secret`, then remove `PREVIOUS`. Introducing a dedicated key on an install that used the `BETTER_AUTH_SECRET` fallback: set `THINKER_ENCRYPTION_KEY` (old rows keep decrypting via the session secret); to move them onto the new key, set `PREVIOUS` to the session secret's value for one deploy. Request recordings are not rewritten by this pass: they decrypt under the current key only, so a key change makes older recordings unreadable until retention removes them.
- `THINKER_ENCRYPTION_KEY` — **Required** (#27). Secret (min 32 chars) from which the AES-256-GCM key for stored provider API keys, OAuth tokens and request recordings is derived (scrypt). There is no fallback to `BETTER_AUTH_SECRET`: `assertEncryptionKeyConfig()` (`common/utils/crypto.util.ts`) runs in `bootstrap()` before the app and its migrations start, and the process exits non-zero with a `Configuration error` naming the problem (never the value) when the key is missing, shorter than 32 chars, or equal to `BETTER_AUTH_SECRET`, or when `THINKER_ENCRYPTION_KEY_PREVIOUS` is set but shorter than 32 chars. The request path (`getEncryptionSecret()`) only reads the variable; the derived key stays cached. Unit tests get a test key from `test/support/unit-env.ts`; the e2e runner and CI set one too.
- `THINKER_ENCRYPTION_KEY_PREVIOUS` — Optional, rotation only. A secret that stored values may still be encrypted under. Decryption tries `THINKER_ENCRYPTION_KEY`, then this, so old rows stay readable; `BETTER_AUTH_SECRET` is never a candidate. While it is set, one replica rewrites every row found under an older secret onto the current one after boot (`database/secret-reencryption.service.ts`, advisory-locked, never blocks or fails boot); a normal boot without it never scans. Rotation: set the new `THINKER_ENCRYPTION_KEY` and `THINKER_ENCRYPTION_KEY_PREVIOUS=<old>`, deploy, wait for the log line `Nothing left under an older secret`, then remove `PREVIOUS`. Request recordings are not rewritten by this pass: they decrypt under the current key only, so a key change makes older recordings unreadable until retention removes them.
- `PORT` — Server port. Default: `3001`
- `BIND_ADDRESS` — Bind address. Default: `127.0.0.1` (use `0.0.0.0` for Railway/Docker)
- `NODE_ENV` — `development` or `production`. Dev allows the Vite dashboard origin (`CORS_ORIGIN`); production allows no cross-origin browser caller beyond `THINKER_CORS_ORIGINS`.
Expand Down
16 changes: 7 additions & 9 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@
BETTER_AUTH_SECRET= # Min 32 chars. Generate with: openssl rand -hex 32
DATABASE_URL= # PostgreSQL connection string: postgresql://user:password@host:port/database

# ── At-rest encryption (recommended) ─────────────────
# Also encrypts stored request recordings; changing the secret makes older recordings unreadable.
# Encrypts stored LLM provider API keys / OAuth tokens (the AES-256-GCM key
# is derived from this secret via scrypt). If unset, MANIFEST falls back to
# BETTER_AUTH_SECRET — convenient, but a session-cookie leak then also
# decrypts every stored provider credential. Set this to a SEPARATE 32+ char
# value so the two leak surfaces stay independent.
# THINKER_ENCRYPTION_KEY= # Min 32 chars. Generate with: openssl rand -hex 32
# THINKER_ENCRYPTION_KEY_PREVIOUS= # Rotation only: the old key (or BETTER_AUTH_SECRET's value when moving off the fallback); rows are rewritten after boot while set, remove once the log reports nothing left
# ── At-rest encryption (required) ────────────────────
# Encrypts stored LLM provider API keys, OAuth tokens and request recordings
# (the AES-256-GCM key is derived from this value via scrypt). Required: the
# server refuses to start when it is missing, shorter than 32 chars, or equal
# to BETTER_AUTH_SECRET. Changing it makes older recordings unreadable.
THINKER_ENCRYPTION_KEY= # Min 32 chars, different from BETTER_AUTH_SECRET. Generate with: openssl rand -hex 32
# THINKER_ENCRYPTION_KEY_PREVIOUS= # Rotation only: the old key (min 32 chars); rows are rewritten after boot while set, remove once the log reports nothing left
# # Stored request recordings are not rewritten: after a key change they become unreadable and retention removes them


Expand Down
5 changes: 4 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@
"!**/otlp/proto/**"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"setupFiles": [
"<rootDir>/../test/support/unit-env.ts"
]
}
}
2 changes: 2 additions & 0 deletions packages/backend/scripts/test-e2e-docker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ async function main() {
THINKER_E2E_ALLOW_DROP: '1',
BETTER_AUTH_SECRET:
process.env.BETTER_AUTH_SECRET || 'e2e-docker-secret-at-least-32-characters!!',
THINKER_ENCRYPTION_KEY:
process.env.THINKER_ENCRYPTION_KEY || 'e2e-docker-encryption-key-at-least-32-characters',
};

log('Running migrations');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AgentDuplicationService } from './agent-duplication.service';
import { Agent } from '../../entities/agent.entity';
import { RoutingCacheService } from '../../routing/routing-core/routing-cache.service';

process.env['BETTER_AUTH_SECRET'] = 'a'.repeat(64);
process.env['THINKER_ENCRYPTION_KEY'] = 'a'.repeat(64);

describe('AgentDuplicationService', () => {
let service: AgentDuplicationService;
Expand Down
Loading
Loading