From 6f5550dc5066b7ba4f3ce42e7b3a41a8a8918728 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 06:31:45 +0200 Subject: [PATCH] fix: decouple at-rest encryption from JWT_SECRET, add JWT rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a dedicated ENCRYPTION_KEY for encrypting stored secrets (API keys, MFA TOTP, SMTP password, OIDC client secret) so that rotating the JWT signing secret no longer invalidates encrypted data, and a compromised JWT_SECRET no longer exposes stored credentials. - server/src/config.ts: add ENCRYPTION_KEY (auto-generated to data/.encryption_key if not set, same pattern as JWT_SECRET); switch JWT_SECRET to `export let` so updateJwtSecret() keeps the CJS module binding live for all importers without restart - apiKeyCrypto.ts, mfaCrypto.ts: derive encryption keys from ENCRYPTION_KEY instead of JWT_SECRET - admin POST /rotate-jwt-secret: generates a new 32-byte hex secret, persists it to data/.jwt_secret, updates the live in-process binding via updateJwtSecret(), and writes an audit log entry - Admin panel (Settings → Danger Zone): "Rotate JWT Secret" button with a confirmation modal warning that all sessions will be invalidated; on success the acting admin is logged out immediately - docker-compose.yml, .env.example, README, Helm chart (values.yaml, secret.yaml, deployment.yaml, NOTES.txt, README): document ENCRYPTION_KEY and its upgrade migration path --- README.md | 2 + chart/README.md | 3 +- chart/templates/NOTES.txt | 25 +++++---- chart/templates/deployment.yaml | 6 +++ chart/templates/secret.yaml | 3 ++ chart/values.yaml | 15 ++++-- client/src/api/client.ts | 1 + client/src/pages/AdminPage.tsx | 80 ++++++++++++++++++++++++++++- docker-compose.yml | 1 + server/.env.example | 4 ++ server/src/config.ts | 53 ++++++++++++++++--- server/src/routes/admin.ts | 25 +++++++++ server/src/services/apiKeyCrypto.ts | 4 +- server/src/services/mfaCrypto.ts | 4 +- 14 files changed, 199 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 65755a5..d70b8f1 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ services: - NODE_ENV=production - PORT=3000 - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details @@ -246,6 +247,7 @@ trek.yourdomain.com { | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment (`production` / `development`) | `production` | | `JWT_SECRET` | JWT signing secret; auto-generated and saved to `data/` if not set | Auto-generated | +| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC); auto-generated and saved to `data/` if not set. **Upgrading:** set to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials to migrate | Auto-generated | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | diff --git a/chart/README.md b/chart/README.md index c5689b9..1e02981 100644 --- a/chart/README.md +++ b/chart/README.md @@ -29,5 +29,6 @@ See `values.yaml` for more options. ## Notes - Ingress is off by default. Enable and configure hosts for your domain. - PVCs require a default StorageClass or specify one as needed. -- JWT_SECRET must be set for production use. +- `JWT_SECRET` should be set for production use; auto-generated and persisted to the data PVC if not provided. +- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Auto-generated and persisted to the data PVC if not provided. **Upgrading:** if a previous version used `JWT_SECRET`-derived encryption, set `secretEnv.ENCRYPTION_KEY` to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials via the admin panel. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 45a1993..5698c1b 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,13 +1,18 @@ -1. JWT_SECRET handling: - - By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`. - - To generate a random JWT_SECRET at install, set `generateJwtSecret: true`. - - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`). +1. Secret handling (JWT_SECRET and ENCRYPTION_KEY): + - By default, the chart creates a Kubernetes Secret from the values in `secretEnv.JWT_SECRET` and `secretEnv.ENCRYPTION_KEY`. + - To generate random values for both at install (preserved across upgrades), set `generateJwtSecret: true`. + - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must contain a key matching `existingSecretKey` (defaults to `JWT_SECRET`) and optionally an `ENCRYPTION_KEY` key. If `ENCRYPTION_KEY` is absent from the external secret, the server auto-generates and persists it to the data volume. -2. Example usage: - - Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret` - - Generate a random secret: `--set generateJwtSecret=true` +2. ENCRYPTION_KEY notes: + - Encrypts stored API keys, MFA secrets, SMTP password, and OIDC client secret at rest. + - If left empty, auto-generated by the server and saved to the data PVC — safe as long as the PVC persists. + - Upgrading from a version that used JWT_SECRET for encryption: set `secretEnv.ENCRYPTION_KEY` to your old JWT_SECRET value to keep existing encrypted data readable, then re-save credentials via the admin panel. + +3. Example usage: + - Set explicit secrets: `--set secretEnv.JWT_SECRET=your_secret --set secretEnv.ENCRYPTION_KEY=your_enc_key` + - Generate random secrets: `--set generateJwtSecret=true` - Use an existing secret: `--set existingSecret=my-k8s-secret` - - Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY` + - Use a custom key for JWT in existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_JWT_KEY` -3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence. - If using `existingSecret`, ensure the referenced secret and key exist in the target namespace. +4. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence. + If using `existingSecret`, ensure the referenced secret and keys exist in the target namespace. diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 25169f6..20e5ac8 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -41,6 +41,12 @@ spec: secretKeyRef: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: {{ .Values.existingSecretKey | default "JWT_SECRET" }} + - name: ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: ENCRYPTION_KEY + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index 6ead7f1..24e7915 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -8,6 +8,7 @@ metadata: type: Opaque data: {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }} + ENCRYPTION_KEY: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }} @@ -23,7 +24,9 @@ type: Opaque stringData: {{- if and $existingSecret $existingSecret.data }} {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "JWT_SECRET") | b64dec }} + ENCRYPTION_KEY: {{ index $existingSecret.data "ENCRYPTION_KEY" | b64dec }} {{- else }} {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }} + ENCRYPTION_KEY: {{ randAlphaNum 32 }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 0d9d0c9..83bea2a 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -19,15 +19,22 @@ env: # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. -# JWT secret configuration +# Secret environment variables stored in a Kubernetes Secret secretEnv: - # If set, use this value for JWT_SECRET (base64-encoded in secret.yaml) + # JWT signing secret. Auto-generated and persisted to the data PVC if not set. JWT_SECRET: "" + # At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.). + # Auto-generated and persisted to the data PVC if not set. + # Upgrading from a version that used JWT_SECRET for encryption: set this to your + # old JWT_SECRET value to keep existing encrypted data readable, then re-save + # credentials via the admin panel and rotate to a fresh random key. + ENCRYPTION_KEY: "" -# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET) +# If true, random values for JWT_SECRET and ENCRYPTION_KEY are generated at install +# and preserved across upgrades (overrides secretEnv values) generateJwtSecret: false -# If set, use an existing Kubernetes secret for JWT_SECRET +# If set, use an existing Kubernetes secret for JWT_SECRET (and optionally ENCRYPTION_KEY) existingSecret: "" existingSecretKey: JWT_SECRET diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f42b0c3..ed4f5b9 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -185,6 +185,7 @@ export const adminApi = { deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), + rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), } export const addonsApi = { diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index c682e75..d4d6306 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -16,7 +16,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import AuditLogPanel from '../components/Admin/AuditLogPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' interface AdminUser { @@ -123,10 +123,13 @@ export default function AdminPage(): React.ReactElement { const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore() const navigate = useNavigate() const toast = useToast() + const [showRotateJwtModal, setShowRotateJwtModal] = useState(false) + const [rotatingJwt, setRotatingJwt] = useState(false) + useEffect(() => { loadData() loadAppConfig() @@ -1132,6 +1135,31 @@ export default function AdminPage(): React.ReactElement { + + {/* Danger Zone */} +
+
+

+ + Danger Zone +

+
+
+
+
+

Rotate JWT Secret

+

Generate a new JWT signing secret. All active sessions will be invalidated immediately.

+
+ +
+
+
)} @@ -1361,6 +1389,54 @@ docker run -d --name nomad \\ )} + + {/* Rotate JWT Secret confirmation modal */} + setShowRotateJwtModal(false)} + title="Rotate JWT Secret" + size="sm" + footer={ +
+ + +
+ } + > +
+
+ +
+
+

Warning, this will invalidate all sessions and log you out.

+

A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.

+
+
+
) } diff --git a/docker-compose.yml b/docker-compose.yml index 37e1123..8d27b48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - NODE_ENV=production - PORT=3000 - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Auto-generated if not set. If upgrading, set to your old JWT_SECRET value to keep existing encrypted secrets readable. - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links diff --git a/server/.env.example b/server/.env.example index 8fad512..1d13427 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,10 @@ PORT=3001 # Port to run the server on NODE_ENV=development # development = development mode; production = production mode JWT_SECRET=your-super-secret-jwt-key-change-in-production # Auto-generated if not set; persist across restarts for stable sessions +# ENCRYPTION_KEY= # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.) +# Auto-generated and persisted to ./data/.encryption_key if not set. +# Upgrade: set to your old JWT_SECRET value if you have existing encrypted data from a previous installation. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details diff --git a/server/src/config.ts b/server/src/config.ts index adbc559..f42c25b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,19 +2,19 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -let JWT_SECRET: string = process.env.JWT_SECRET || ''; +let _jwtSecret: string = process.env.JWT_SECRET || ''; -if (!JWT_SECRET) { +if (!_jwtSecret) { const dataDir = path.resolve(__dirname, '../data'); const secretFile = path.join(dataDir, '.jwt_secret'); try { - JWT_SECRET = fs.readFileSync(secretFile, 'utf8').trim(); + _jwtSecret = fs.readFileSync(secretFile, 'utf8').trim(); } catch { - JWT_SECRET = crypto.randomBytes(32).toString('hex'); + _jwtSecret = crypto.randomBytes(32).toString('hex'); try { if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(secretFile, JWT_SECRET, { mode: 0o600 }); + fs.writeFileSync(secretFile, _jwtSecret, { mode: 0o600 }); console.log('Generated and saved JWT secret to', secretFile); } catch (writeErr: unknown) { console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr); @@ -25,4 +25,45 @@ if (!JWT_SECRET) { const JWT_SECRET_IS_GENERATED = !process.env.JWT_SECRET; -export { JWT_SECRET, JWT_SECRET_IS_GENERATED }; +// export let so TypeScript's CJS output keeps exports.JWT_SECRET live +// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret) +export let JWT_SECRET = _jwtSecret; + +// Called by the admin rotate-jwt-secret endpoint to update the in-process +// binding that all middleware and route files reference. +export function updateJwtSecret(newSecret: string): void { + JWT_SECRET = newSecret; +} + +// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets +// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.). +// Keeping it separate from JWT_SECRET means you can rotate session tokens without +// invalidating all stored encrypted data, and vice-versa. +// +// Upgrade note: if you already have encrypted data stored under a previous build +// that used JWT_SECRET for encryption, set ENCRYPTION_KEY to the value of your +// old JWT_SECRET so existing encrypted values continue to decrypt correctly. +// After re-saving all credentials via the admin panel you can switch to a new +// random ENCRYPTION_KEY. +let ENCRYPTION_KEY: string = process.env.ENCRYPTION_KEY || ''; + +if (!ENCRYPTION_KEY) { + const dataDir = path.resolve(__dirname, '../data'); + const keyFile = path.join(dataDir, '.encryption_key'); + + try { + ENCRYPTION_KEY = fs.readFileSync(keyFile, 'utf8').trim(); + } catch { + ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(keyFile, ENCRYPTION_KEY, { mode: 0o600 }); + console.log('Generated and saved encryption key to', keyFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Encrypted secrets will be unreadable after restart. Set ENCRYPTION_KEY env var for persistent encryption.'); + } + } +} + +export { JWT_SECRET_IS_GENERATED, ENCRYPTION_KEY }; diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 421b8a1..5b62298 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -10,6 +10,7 @@ import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions'; import { revokeUserSessions } from '../mcp'; import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { updateJwtSecret } from '../config'; const router = express.Router(); @@ -559,4 +560,28 @@ router.delete('/mcp-tokens/:id', (req: Request, res: Response) => { res.json({ success: true }); }); +router.post('/rotate-jwt-secret', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const newSecret = crypto.randomBytes(32).toString('hex'); + const dataDir = path.resolve(__dirname, '../../data'); + const secretFile = path.join(dataDir, '.jwt_secret'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(secretFile, newSecret, { mode: 0o600 }); + } catch (err: unknown) { + return res.status(500).json({ error: 'Failed to persist new JWT secret to disk' }); + } + updateJwtSecret(newSecret); + writeAudit({ + user_id: authReq.user?.id ?? null, + username: authReq.user?.username ?? 'unknown', + action: 'admin.rotate_jwt_secret', + target_type: 'system', + target_id: null, + details: null, + ip: getClientIp(req), + }); + res.json({ success: true }); +}); + export default router; diff --git a/server/src/services/apiKeyCrypto.ts b/server/src/services/apiKeyCrypto.ts index 881b840..0ad9cb2 100644 --- a/server/src/services/apiKeyCrypto.ts +++ b/server/src/services/apiKeyCrypto.ts @@ -1,10 +1,10 @@ import * as crypto from 'crypto'; -import { JWT_SECRET } from '../config'; +import { ENCRYPTION_KEY } from '../config'; const ENCRYPTED_PREFIX = 'enc:v1:'; function get_key() { - return crypto.createHash('sha256').update(`${JWT_SECRET}:api_keys:v1`).digest(); + return crypto.createHash('sha256').update(`${ENCRYPTION_KEY}:api_keys:v1`).digest(); } export function encrypt_api_key(plain: unknown) { diff --git a/server/src/services/mfaCrypto.ts b/server/src/services/mfaCrypto.ts index 748f9bd..2a64743 100644 --- a/server/src/services/mfaCrypto.ts +++ b/server/src/services/mfaCrypto.ts @@ -1,8 +1,8 @@ import crypto from 'crypto'; -import { JWT_SECRET } from '../config'; +import { ENCRYPTION_KEY } from '../config'; function getKey(): Buffer { - return crypto.createHash('sha256').update(`${JWT_SECRET}:mfa:v1`).digest(); + return crypto.createHash('sha256').update(`${ENCRYPTION_KEY}:mfa:v1`).digest(); } /** Encrypt TOTP secret for storage in SQLite. */