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:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user