fix: decouple at-rest encryption from JWT_SECRET, add JWT rotation

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
This commit is contained in:
jubnl
2026-04-01 06:31:45 +02:00
parent dfdd473eca
commit 6f5550dc50
14 changed files with 199 additions and 27 deletions

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
}
export const addonsApi = {

View File

@@ -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<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(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<boolean>(false)
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
useEffect(() => {
loadData()
loadAppConfig()
@@ -1132,6 +1135,31 @@ export default function AdminPage(): React.ReactElement {
</div>
</div>
</div>
{/* Danger Zone */}
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
<h2 className="font-semibold text-red-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
Danger Zone
</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">Rotate JWT Secret</p>
<p className="text-xs text-slate-400 mt-0.5">Generate a new JWT signing secret. All active sessions will be invalidated immediately.</p>
</div>
<button
onClick={() => setShowRotateJwtModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
Rotate
</button>
</div>
</div>
</div>
</div>
)}
@@ -1361,6 +1389,54 @@ docker run -d --name nomad \\
</div>
</div>
)}
{/* Rotate JWT Secret confirmation modal */}
<Modal
isOpen={showRotateJwtModal}
onClose={() => setShowRotateJwtModal(false)}
title="Rotate JWT Secret"
size="sm"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowRotateJwtModal(false)}
disabled={rotatingJwt}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={async () => {
setRotatingJwt(true)
try {
await adminApi.rotateJwtSecret()
setShowRotateJwtModal(false)
logout()
navigate('/login')
} catch {
toast.error(t('common.error'))
setRotatingJwt(false)
}
}}
disabled={rotatingJwt}
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-lg font-medium"
>
{rotatingJwt ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Rotate &amp; Log out
</button>
</div>
}
>
<div className="flex gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div>
<p className="text-sm font-medium text-slate-900 mb-1">Warning, this will invalidate all sessions and log you out.</p>
<p className="text-xs text-slate-500">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.</p>
</div>
</div>
</Modal>
</div>
)
}

View File

@@ -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

View File

@@ -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=<random-256-bit-hex> # 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

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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. */