diff --git a/README.md b/README.md index d70b8f1..7237f6c 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,6 @@ services: environment: - 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) @@ -246,7 +245,6 @@ trek.yourdomain.com { | **Core** | | | | `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` | diff --git a/chart/README.md b/chart/README.md index 1e02981..e6c7cb2 100644 --- a/chart/README.md +++ b/chart/README.md @@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app. ```sh helm install trek ./chart \ - --set secretEnv.JWT_SECRET=your_jwt_secret \ --set ingress.enabled=true \ --set ingress.hosts[0].host=yourdomain.com ``` @@ -29,6 +28,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` should be set for production use; auto-generated and persisted to the data PVC if not provided. +- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `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 5698c1b..3fae1fc 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,18 +1,24 @@ -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. +1. ENCRYPTION_KEY handling: + - ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. + - By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml. + - To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`. + - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must + contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`). + - If left empty, the server auto-generates and persists the key 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, then re-save credentials via the admin panel. -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. +2. JWT_SECRET is managed entirely by the server: + - Auto-generated on first start and persisted to the data PVC (data/.jwt_secret). + - Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret). + - No Helm configuration needed or supported. 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` + - Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key` + - Generate a random key at install: `--set generateEncryptionKey=true` - Use an existing secret: `--set existingSecret=my-k8s-secret` - - Use a custom key for JWT in existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_JWT_KEY` + - Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY` -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. +4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are + set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace. diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 20e5ac8..df5884b 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -36,16 +36,11 @@ spec: - configMapRef: name: {{ include "trek.fullname" . }}-config env: - - name: JWT_SECRET - valueFrom: - 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 + key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }} optional: true volumeMounts: - name: data diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index 24e7915..204e91c 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -1,4 +1,4 @@ -{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }} +{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }} apiVersion: v1 kind: Secret metadata: @@ -7,11 +7,10 @@ metadata: app: {{ include "trek.name" . }} type: Opaque data: - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }} - ENCRYPTION_KEY: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }} {{- end }} -{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }} +{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} {{- $secretName := printf "%s-secret" (include "trek.fullname" .) }} {{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} apiVersion: v1 @@ -23,10 +22,8 @@ metadata: 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 }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }} {{- else }} - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }} - ENCRYPTION_KEY: {{ randAlphaNum 32 }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 83bea2a..8613327 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -19,10 +19,10 @@ env: # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. -# Secret environment variables stored in a Kubernetes Secret +# Secret environment variables stored in a Kubernetes Secret. +# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC, +# rotatable via the admin panel) — it is not configured here. secretEnv: - # 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 @@ -30,13 +30,12 @@ secretEnv: # credentials via the admin panel and rotate to a fresh random key. ENCRYPTION_KEY: "" -# If true, random values for JWT_SECRET and ENCRYPTION_KEY are generated at install -# and preserved across upgrades (overrides secretEnv values) -generateJwtSecret: false +# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades +generateEncryptionKey: false -# If set, use an existing Kubernetes secret for JWT_SECRET (and optionally ENCRYPTION_KEY) +# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY existingSecret: "" -existingSecretKey: JWT_SECRET +existingSecretKey: ENCRYPTION_KEY persistence: enabled: true diff --git a/docker-compose.yml b/docker-compose.yml index 8d27b48..bfa0092 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,6 @@ services: environment: - 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 diff --git a/server/.env.example b/server/.env.example index 1d13427..8396628 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,9 +1,9 @@ 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. +# Upgrade from a version that used JWT_SECRET for encryption: set to your old JWT_SECRET value so +# existing encrypted data remains readable, then re-save credentials via the admin panel. # 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 f42c25b..4fdeb2b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,29 +2,28 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -let _jwtSecret: string = process.env.JWT_SECRET || ''; +const dataDir = path.resolve(__dirname, '../data'); -if (!_jwtSecret) { - const dataDir = path.resolve(__dirname, '../data'); - const secretFile = path.join(dataDir, '.jwt_secret'); +// JWT_SECRET is always managed by the server — auto-generated on first start and +// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it +// via environment variable (env var would override a rotation on next restart). +const jwtSecretFile = path.join(dataDir, '.jwt_secret'); +let _jwtSecret: string; +try { + _jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim(); +} catch { + _jwtSecret = crypto.randomBytes(32).toString('hex'); try { - _jwtSecret = fs.readFileSync(secretFile, 'utf8').trim(); - } catch { - _jwtSecret = crypto.randomBytes(32).toString('hex'); - try { - if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - 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); - console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.'); - } + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 }); + console.log('Generated and saved JWT secret to', jwtSecretFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Sessions will reset on server restart.'); } } -const JWT_SECRET_IS_GENERATED = !process.env.JWT_SECRET; - // 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; @@ -48,7 +47,6 @@ export function updateJwtSecret(newSecret: string): void { 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 { @@ -66,4 +64,4 @@ if (!ENCRYPTION_KEY) { } } -export { JWT_SECRET_IS_GENERATED, ENCRYPTION_KEY }; +export { ENCRYPTION_KEY }; diff --git a/server/src/index.ts b/server/src/index.ts index e588d30..c79bcc8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,4 @@ import 'dotenv/config'; -import { JWT_SECRET_IS_GENERATED } from './config'; import express, { Request, Response, NextFunction } from 'express'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import cors from 'cors'; @@ -268,9 +267,6 @@ const server = app.listen(PORT, () => { '──────────────────────────────────────', ]; banner.forEach(l => console.log(l)); - if (JWT_SECRET_IS_GENERATED) { - sLogWarn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.'); - } if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');