fix(security): self-healing secrets + auth/SSRF/header hardening (v0.16.10-beta)

Follow-up to the /terminal authorization fix: a full-codebase security
review surfaced several more issues, fixed here. All changes are designed
to roll out with no interruption to existing deployments — no forced
migration, no manual re-key, no boot failure.

Self-healing secret management (utils/secrets.ts):
- JWT_SECRET / ENCRYPTION_KEY auto-generate to strong values on first boot
  and persist to the app_data volume (0600, outside the DB) when not set
  to a strong value in the environment. Replaces the prior behavior of
  running on the public repo defaults.
- decrypt() tries the current key plus every legacy/default key, so
  existing device credentials keep decrypting after a key change; a
  startup sweep re-encrypts stale rows forward (db/reencryptCredentials.ts).
- verifyToken() accepts the current signing secret plus prior *strong*
  secrets (never the public defaults), so rotation doesn't invalidate live
  sessions; sessions signed with a default secret require one re-login.
- Dockerfile pre-creates /app/data as the app user so the non-root
  process can persist to a fresh named volume; app_data volume added to
  both compose files; compose secret defaults changed to empty (auto-gen).

Authorization / input:
- Default Socket.IO namespace now requires a valid JWT (was unauthenticated
  and broadcasting fleet-activity events); frontend sends the token and
  drops the shared socket on logout.
- Webhook delivery blocks SSRF to loopback/private/link-local/CGNAT ranges
  and pins the request to the validated IP (WebhookService.ts).
- Rate limit added to /auth/totp/verify; global mutation backstop on /api.
- Password policy (>=10 chars, letter+digit) enforced on set/change/create.
- system.ts used verifyToken as middleware (always 500); now requireAuth.

Hardening:
- CSP + HSTS/nosniff/frame-deny headers in nginx, scoped to allow the
  OpenStreetMap tile/geocoding hosts the maps use.

Docs/tests:
- README secret section rewritten for the self-healing model.
- New unit tests for secret resolution, persistence, rotation, and
  legacy-key decryption (97 tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-14 12:42:46 -07:00
co-authored by Claude Opus 4.8
parent 27560fa1d6
commit 9e962939ea
20 changed files with 656 additions and 70 deletions
+4 -2
View File
@@ -27,8 +27,10 @@ RUN apk add --no-cache arp-scan libcap openssl \
# Copy built files
COPY --from=builder /app/dist ./dist
# Create backups directory
RUN mkdir -p /app/backups
# Create writable data directories. Pre-creating them (owned by appuser after
# the chown below) means a fresh named volume mounted here inherits appuser
# ownership, so the non-root process can persist backups and secrets.
RUN mkdir -p /app/backups /app/data
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.16.8-beta",
"version": "0.16.10-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+57
View File
@@ -0,0 +1,57 @@
/**
* One-shot forward migration of encrypted credentials.
*
* After a key rotation (including the automatic self-heal off a default key),
* existing ciphertext still decrypts via the legacy-key fallback in crypto.ts,
* but we want it re-encrypted under the current key so the legacy key can
* eventually be retired. This sweep decrypts any stale value and rewrites it.
* It is idempotent and best-effort — failures are logged, never fatal.
*/
import { query } from '../config/database';
import { encrypt, decrypt, needsReencryption } from '../utils/crypto';
interface EncryptedRow {
id: number;
api_password_encrypted: string | null;
ssh_password_encrypted: string | null;
}
const TABLES = ['devices', 'credential_presets'] as const;
const COLUMNS = ['api_password_encrypted', 'ssh_password_encrypted'] as const;
export async function reencryptStaleCredentials(): Promise<number> {
let rewritten = 0;
for (const table of TABLES) {
const rows = await query<EncryptedRow>(
`SELECT id, ${COLUMNS.join(', ')} FROM ${table}`
).catch(() => [] as EncryptedRow[]);
for (const row of rows) {
const updates: Record<string, string> = {};
for (const col of COLUMNS) {
const value = row[col];
if (!value || !needsReencryption(value)) continue;
try {
updates[col] = encrypt(decrypt(value));
} catch {
// Can't decrypt with any known key — leave it untouched.
}
}
const keys = Object.keys(updates);
if (keys.length === 0) continue;
const sets = keys.map((k, i) => `${k} = $${i + 2}`).join(', ');
await query(
`UPDATE ${table} SET ${sets} WHERE id = $1`,
[row.id, ...keys.map((k) => updates[k])]
).catch(() => {});
rewritten += keys.length;
}
}
if (rewritten > 0) {
console.log(`[secrets] re-encrypted ${rewritten} credential value(s) under the current key`);
}
return rewritten;
}
+49
View File
@@ -25,6 +25,9 @@ import {
} from './services/DeviceBulkAddWorker';
import { netflowCollector } from './services/netflow/NetflowCollector';
import { verifyToken, type AuthPayload } from './middleware/auth';
import { rateLimitRedis } from './middleware/rateLimitRedis';
import { initSecrets } from './utils/secrets';
import { reencryptStaleCredentials } from './db/reencryptCredentials';
import { decrypt } from './utils/crypto';
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
@@ -59,6 +62,27 @@ import credentialPresetsRoutes from './routes/credentialPresets';
import systemRoutes from './routes/system';
import { auditMiddleware } from './middleware/auditMiddleware';
// ─── Secret hygiene ───────────────────────────────────────────────────────────
// Self-healing: if JWT_SECRET / ENCRYPTION_KEY aren't set to strong values, we
// auto-generate strong ones and persist them, so a deployment is never left on
// the public repo defaults and never breaks on upgrade. See utils/secrets.ts.
function provisionSecrets(): void {
const info = initSecrets();
const sourceLine = `secrets: jwt=${info.jwtSource}, encryption=${info.encSource}`;
if (info.jwtSource === 'generated' || info.encSource === 'generated') {
console.log(`[secrets] auto-generated strong secret(s) (${sourceLine})`);
} else {
console.log(`[secrets] ${sourceLine}`);
}
if (info.ephemeral) {
console.error(
'[secrets] WARNING: generated secrets could not be persisted (SECRETS_DIR not writable). ' +
'They will change on restart — users will be logged out and credentials written now may not ' +
'decrypt later. Mount a writable volume at SECRETS_DIR (default /app/data) or set JWT_SECRET/ENCRYPTION_KEY.'
);
}
}
const app = express();
// nginx sits exactly one hop in front; trust its X-Forwarded-For so req.ip is the real client IP
app.set('trust proxy', 1);
@@ -71,6 +95,19 @@ const io = new SocketServer(httpServer, {
path: '/socket.io',
});
// The default namespace broadcasts fleet-activity events (device/client/event
// updates); require a valid JWT so unauthenticated clients can't subscribe.
io.use((socket, next) => {
const token = (socket.handshake.auth as { token?: string })?.token;
if (!token) return next(new Error('No token'));
try {
socket.data.user = verifyToken(token);
next();
} catch {
next(new Error('Invalid or expired token'));
}
});
io.on('connection', (socket) => {
console.log(`Socket connected: ${socket.id}`);
socket.on('disconnect', () => {
@@ -249,6 +286,10 @@ if (process.env.NODE_ENV !== 'test') {
}
app.use(auditMiddleware);
// Global backstop rate limit on mutating API requests (per user, falling back to
// per IP before auth runs). Endpoints with stricter needs add their own limiter.
app.use('/api', rateLimitRedis({ windowSec: 60, max: 120, keyPrefix: 'api-global' }));
// ─── Health Check ─────────────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
@@ -288,6 +329,8 @@ app.use(errorHandler);
// ─── Startup ─────────────────────────────────────────────────────────────────
async function start(): Promise<void> {
provisionSecrets();
// Wait for DB to be ready
for (let i = 0; i < 10; i++) {
try {
@@ -303,6 +346,12 @@ async function start(): Promise<void> {
// Run migrations
await runMigrations();
// Migrate any credentials still encrypted under a legacy/default key forward
// to the current key (runs in the background; safe to skip on failure).
reencryptStaleCredentials().catch((e) =>
console.warn('[secrets] credential re-encryption sweep skipped:', (e as Error).message)
);
// Reset vendor entries that were previously set to '' due to API rate-limiting,
// so they get re-resolved by the new local OUI database.
await query(`UPDATE clients SET vendor = NULL WHERE vendor = ''`).catch(() => {});
+33 -4
View File
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { createHash } from 'crypto';
import { query } from '../config/database';
import { jwtSigningSecret, jwtVerifierSecrets } from '../utils/secrets';
export interface AuthPayload {
userId: number;
@@ -20,14 +21,42 @@ declare global {
}
}
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
export function signToken(payload: AuthPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });
return jwt.sign(payload, jwtSigningSecret(), { expiresIn: '24h' });
}
export function verifyToken(token: string): AuthPayload {
return jwt.verify(token, JWT_SECRET) as AuthPayload;
// Accept the current signing secret plus any prior strong secret, so a secret
// rotation doesn't invalidate sessions already issued under the previous one.
const secrets = jwtVerifierSecrets();
let lastErr: unknown;
for (const secret of secrets) {
try {
return jwt.verify(token, secret) as AuthPayload;
} catch (e) {
lastErr = e;
}
}
throw lastErr instanceof Error ? lastErr : new Error('invalid token');
}
/** Sign a short-lived token (e.g. the partial 2FA token) with the current secret. */
export function signRawToken(payload: object, options: jwt.SignOptions): string {
return jwt.sign(payload, jwtSigningSecret(), options);
}
/** Verify a token that was signed with signRawToken, honoring rotated secrets. */
export function verifyRawToken<T>(token: string): T {
const secrets = jwtVerifierSecrets();
let lastErr: unknown;
for (const secret of secrets) {
try {
return jwt.verify(token, secret) as T;
} catch (e) {
lastErr = e;
}
}
throw lastErr instanceof Error ? lastErr : new Error('invalid token');
}
// API tokens ("mtm_…") are hashed at rest; scope maps onto the existing role
+16 -15
View File
@@ -2,18 +2,14 @@ import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import * as OTPAuth from 'otpauth';
import * as qrcode from 'qrcode';
import jwt from 'jsonwebtoken';
import { query, queryOne } from '../config/database';
import { signToken, requireAuth } from '../middleware/auth';
import { signToken, signRawToken, verifyRawToken, requireAuth } from '../middleware/auth';
import { getSecretsInfo } from '../utils/secrets';
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
import { validatePassword } from '../utils/passwordPolicy';
const router = Router();
const DEFAULT_JWT_SECRET = 'changeme';
const DEFAULT_ENCRYPTION_KEY = 'defaultkey32byteslongencryptkey!';
router.get(
'/security-status',
requireAuth,
@@ -21,11 +17,12 @@ router.get(
async (_req: Request, res: Response) => {
const warnings: string[] = [];
if (!process.env.JWT_SECRET || process.env.JWT_SECRET === DEFAULT_JWT_SECRET) {
warnings.push('jwt_secret_default');
}
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY === DEFAULT_ENCRYPTION_KEY) {
warnings.push('encryption_key_default');
// Secrets self-heal to strong auto-generated values, so the only remaining
// risk is an auto-generated secret that couldn't be persisted (it would
// churn on restart) — surface that rather than "default".
const secrets = getSecretsInfo();
if (secrets.ephemeral) {
warnings.push('secrets_not_persisted');
}
const adminUser = await queryOne<{ password_hash: string }>(
@@ -60,7 +57,7 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
}
if (user.totp_enabled) {
const totpToken = jwt.sign({ userId: user.id, partial: true }, JWT_SECRET, { expiresIn: '5m' });
const totpToken = signRawToken({ userId: user.id, partial: true }, { expiresIn: '5m' });
return res.json({ requires_totp: true, totp_token: totpToken });
}
@@ -69,14 +66,14 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
});
// Exchange partial TOTP token + code for a full session token
router.post('/totp/verify', async (req: Request, res: Response) => {
router.post('/totp/verify', rateLimitRedis({ windowSec: 60, max: 5, keyPrefix: 'totp-verify', allMethods: true }), async (req: Request, res: Response) => {
const { totp_token, code } = req.body as { totp_token?: string; code?: string };
if (!totp_token || !code) {
return res.status(400).json({ error: 'totp_token and code are required' });
}
let payload: { userId: number; partial: boolean };
try {
payload = jwt.verify(totp_token, JWT_SECRET) as typeof payload;
payload = verifyRawToken<{ userId: number; partial: boolean }>(totp_token);
} catch {
return res.status(401).json({ error: 'Invalid or expired TOTP token' });
}
@@ -178,6 +175,10 @@ router.put('/password', requireAuth, async (req: Request, res: Response) => {
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current and new password required' });
}
const pwError = validatePassword(newPassword);
if (pwError) {
return res.status(400).json({ error: pwError });
}
const user = await queryOne<{ id: number; password_hash: string }>(
`SELECT id, password_hash FROM users WHERE id = $1`,
+8 -2
View File
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth';
import { netflowCollector } from '../services/netflow/NetflowCollector';
import { validatePassword } from '../utils/passwordPolicy';
const router = Router();
router.use(requireAuth);
@@ -49,6 +50,10 @@ router.post('/users', requireAdmin, async (req: Request, res: Response) => {
if (!username || !password) {
return res.status(400).json({ error: 'username and password required' });
}
const pwError = validatePassword(password);
if (pwError) {
return res.status(400).json({ error: pwError });
}
const validRoles = ['admin', 'operator', 'viewer'];
if (!validRoles.includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be admin, operator, or viewer' });
@@ -94,8 +99,9 @@ router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, userId]);
}
if (password) {
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
const pwError = validatePassword(password);
if (pwError) {
return res.status(400).json({ error: pwError });
}
const hash = await bcrypt.hash(password, 12);
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
+2 -2
View File
@@ -1,10 +1,10 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { verifyToken } from '../middleware/auth';
import { requireAuth } from '../middleware/auth';
import { query } from '../config/database';
const router = Router();
router.use(verifyToken);
router.use(requireAuth);
const CURRENT_VERSION = process.env.npm_package_version || '0.0.0';
const RAW_URL = 'https://raw.githubusercontent.com/2GT-Media-Group-LLC/mikrotik-manager/main/frontend/package.json';
+76 -7
View File
@@ -5,9 +5,63 @@
import { createHmac } from 'crypto';
import * as https from 'https';
import * as http from 'http';
import * as dns from 'dns';
import { isIP } from 'net';
import { URL } from 'url';
import { query } from '../config/database';
const dnsLookup = dns.promises.lookup;
/**
* True if an IP literal is in a range we must never let a webhook reach —
* loopback, private, link-local (incl. cloud metadata 169.254.169.254), CGNAT,
* or unspecified. Guards against SSRF to internal services.
*/
export function isBlockedAddress(ip: string): boolean {
const v = isIP(ip);
if (v === 4) {
const o = ip.split('.').map(Number);
if (o.length !== 4 || o.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
const [a, b] = o;
if (a === 0 || a === 10 || a === 127) return true; // this-net, private, loopback
if (a === 169 && b === 254) return true; // link-local + metadata
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
if (a >= 224) return true; // multicast/reserved/broadcast
return false;
}
if (v === 6) {
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return true;
// IPv4-mapped (::ffff:1.2.3.4) — validate the embedded IPv4
const mapped = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return isBlockedAddress(mapped[1]);
const head = lower.split(':')[0];
if (head.startsWith('fe8') || head.startsWith('fe9') || head.startsWith('fea') || head.startsWith('feb')) return true; // fe80::/10
if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 unique-local
return false;
}
return true; // not a valid IP literal → refuse
}
async function assertPublicHost(hostname: string): Promise<string> {
// Already an IP literal — check directly.
if (isIP(hostname)) {
if (isBlockedAddress(hostname)) throw new Error('URL resolves to a blocked (private/internal) address');
return hostname;
}
const results = await dnsLookup(hostname, { all: true }).catch(() => {
throw new Error('Could not resolve webhook host');
});
if (results.length === 0) throw new Error('Could not resolve webhook host');
for (const r of results) {
if (isBlockedAddress(r.address)) throw new Error('URL resolves to a blocked (private/internal) address');
}
// Pin to the validated address so a re-resolve can't swap in an internal IP.
return results[0].address;
}
export const WEBHOOK_EVENTS = [
'device_offline', 'device_online', 'log_error', 'log_warning',
'high_cpu', 'high_memory', 'cert_expiry', 'device_discovered',
@@ -21,15 +75,30 @@ interface WebhookRow {
events: string[]; enabled: boolean;
}
function postJson(urlStr: string, body: string, headers: Record<string, string>): Promise<number> {
async function postJson(urlStr: string, body: string, headers: Record<string, string>): Promise<number> {
let u: URL;
try { u = new URL(urlStr); } catch { throw new Error('Invalid URL'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('URL must be http(s)');
const isHttps = u.protocol === 'https:';
const pinnedAddress = await assertPublicHost(u.hostname);
const port = u.port ? Number(u.port) : (isHttps ? 443 : 80);
return new Promise((resolve, reject) => {
let u: URL;
try { u = new URL(urlStr); } catch { reject(new Error('Invalid URL')); return; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') { reject(new Error('URL must be http(s)')); return; }
const lib = u.protocol === 'https:' ? https : http;
const req = lib.request(u, {
const lib = isHttps ? https : http;
const req = lib.request({
host: pinnedAddress, // connect to the validated IP, not a re-resolved host
port,
path: `${u.pathname}${u.search}`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
// Preserve virtual-host routing and TLS cert validation against the real name.
servername: isHttps ? u.hostname : undefined,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
Host: u.host,
...headers,
},
timeout: 10_000,
}, (res) => {
res.resume(); // drain
@@ -0,0 +1,83 @@
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import * as nodeCrypto from 'crypto';
// Reproduce ciphertext exactly as legacy code produced it under a given key.
function legacyEncrypt(plain: string, material: string): string {
const key = material.length === 32
? Buffer.from(material, 'utf8')
: nodeCrypto.createHash('sha256').update(material).digest();
const iv = nodeCrypto.randomBytes(16);
const cipher = nodeCrypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return [iv.toString('hex'), cipher.getAuthTag().toString('hex'), enc.toString('hex')].join(':');
}
describe('self-healing secrets', () => {
const OLD_ENV = process.env;
let tmpDir: string;
beforeEach(() => {
jest.resetModules();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mtm-secrets-'));
process.env = { ...OLD_ENV, SECRETS_DIR: tmpDir };
delete process.env.JWT_SECRET;
delete process.env.ENCRYPTION_KEY;
});
afterEach(() => {
process.env = OLD_ENV;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('auto-generates and persists strong secrets when none are set', async () => {
const secrets = await import('../secrets');
const info = secrets.initSecrets();
expect(info.jwtSource).toBe('generated');
expect(info.encSource).toBe('generated');
expect(info.persisted).toBe(true);
expect(info.ephemeral).toBe(false);
expect(secrets.jwtSigningSecret().length).toBeGreaterThanOrEqual(32);
expect(fs.existsSync(path.join(tmpDir, 'secrets.json'))).toBe(true);
});
it('reuses the persisted secret across reloads (stable across restarts)', async () => {
const first = await import('../secrets');
first.initSecrets();
const jwt1 = first.jwtSigningSecret();
jest.resetModules();
const second = await import('../secrets');
second.initSecrets();
expect(second.jwtSigningSecret()).toBe(jwt1);
expect(second.getSecretsInfo().jwtSource).toBe('persisted');
});
it('prefers a strong env secret over generating one', async () => {
process.env.JWT_SECRET = 'a'.repeat(40);
const secrets = await import('../secrets');
const info = secrets.initSecrets();
expect(info.jwtSource).toBe('env');
expect(secrets.jwtSigningSecret()).toBe('a'.repeat(40));
});
it('never accepts a known-default JWT secret as strong', async () => {
process.env.JWT_SECRET = 'changeme_use_a_long_random_secret';
const secrets = await import('../secrets');
const info = secrets.initSecrets();
expect(info.jwtSource).toBe('generated');
expect(secrets.jwtVerifierSecrets()).not.toContain('changeme_use_a_long_random_secret');
});
it('decrypts ciphertext written under the old default key after self-heal', async () => {
// Legacy data on disk: encrypted under the old built-in default key.
const legacyCipher = legacyEncrypt('router-admin-pw', 'changeme32byteslongencryptionkey');
// Boot with no env key → auto-generate a new current key.
const crypto = await import('../crypto');
expect(crypto.decrypt(legacyCipher)).toBe('router-admin-pw'); // legacy fallback works
expect(crypto.needsReencryption(legacyCipher)).toBe(true); // flagged for forward migration
expect(crypto.needsReencryption(crypto.encrypt('x'))).toBe(false); // current-key data is not
});
});
+34 -18
View File
@@ -7,22 +7,13 @@
* "Credential encryption (ENCRYPTION_KEY)".
*/
import * as crypto from 'crypto';
import { encryptionKey, decryptionKeys } from './secrets';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const TAG_LENGTH = 16;
function getKey(): Buffer {
const key = process.env.ENCRYPTION_KEY || 'defaultkey32byteslongencryptkey!';
// Ensure exactly 32 bytes via SHA-256 hash if needed
if (key.length === 32) {
return Buffer.from(key, 'utf8');
}
return crypto.createHash('sha256').update(key).digest();
}
export function encrypt(text: string): string {
const key = getKey();
const key = encryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
@@ -33,20 +24,45 @@ export function encrypt(text: string): string {
return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':');
}
function decryptWith(key: Buffer, iv: Buffer, tag: Buffer, encrypted: Buffer): string {
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
export function decrypt(encryptedText: string): string {
const key = getKey();
const parts = encryptedText.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted text format');
}
const iv = Buffer.from(parts[0], 'hex');
const tag = Buffer.from(parts[1], 'hex');
const encrypted = Buffer.from(parts[2], 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
// Try the current key first, then every legacy key, so ciphertext written
// under an older (or default) key keeps decrypting after a key rotation.
let lastErr: unknown;
for (const key of decryptionKeys()) {
try {
return decryptWith(key, iv, tag, encrypted);
} catch (e) {
lastErr = e;
}
}
throw lastErr instanceof Error ? lastErr : new Error('Unable to decrypt');
}
/**
* True if the ciphertext does NOT decrypt under the current key (i.e. it was
* written with a legacy key and should be re-encrypted forward).
*/
export function needsReencryption(encryptedText: string): boolean {
const parts = encryptedText.split(':');
if (parts.length !== 3) return false;
try {
decryptWith(encryptionKey(), Buffer.from(parts[0], 'hex'), Buffer.from(parts[1], 'hex'), Buffer.from(parts[2], 'hex'));
return false;
} catch {
return true;
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Minimum password policy applied wherever a password is set or changed.
* Returns an error message if the password is unacceptable, or null if it's OK.
*/
export function validatePassword(password: unknown): string | null {
if (typeof password !== 'string') return 'Password must be a string';
if (password.length < 10) return 'Password must be at least 10 characters long';
if (password.length > 200) return 'Password must be at most 200 characters long';
if (!/[A-Za-z]/.test(password)) return 'Password must contain at least one letter';
if (!/[0-9]/.test(password)) return 'Password must contain at least one number';
return null;
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Self-healing secret management for JWT signing and credential encryption.
*
* Goal: a deployment must never break on upgrade or require a manual migration.
* - If JWT_SECRET / ENCRYPTION_KEY are set to strong values in the environment,
* those win (operator stays in control).
* - Otherwise (unset or a well-known repo default), we auto-generate a strong
* secret ONCE and persist it to a file on a durable volume, reusing it on
* every subsequent boot so it's stable (no session/credential churn).
* - Old data keeps working: decryption tries the current key plus every legacy
* key (including the old built-in defaults), and JWTs verify against the
* current secret plus any prior *strong* secret. The public defaults are
* never accepted as a JWT verifier — that would keep the forgery hole open.
*
* Secrets live outside the database (in SECRETS_DIR) so a DB dump alone can't
* reveal the key that protects the credentials stored in it.
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
// Publicly known defaults shipped in this repo / compose files.
const KNOWN_DEFAULT_JWT = ['changeme', 'changeme_use_a_long_random_secret'];
const KNOWN_DEFAULT_ENC = ['defaultkey32byteslongencryptkey!', 'changeme32byteslongencryptionkey'];
interface PersistedSecrets {
jwtSecret?: string;
encryptionKey?: string;
prevJwtSecrets?: string[];
prevEncryptionKeys?: string[];
}
export interface SecretsInfo {
jwtSource: 'env' | 'persisted' | 'generated';
encSource: 'env' | 'persisted' | 'generated';
persisted: boolean;
/** True if a secret was generated but could NOT be persisted (won't survive restart). */
ephemeral: boolean;
}
interface ResolvedSecrets {
jwtCurrent: string;
jwtVerifiers: string[];
encCurrent: Buffer;
encDecryptors: Buffer[];
info: SecretsInfo;
}
let resolved: ResolvedSecrets | null = null;
function isStrongJwt(v: string | undefined): v is string {
return !!v && !KNOWN_DEFAULT_JWT.includes(v) && v.length >= 32;
}
function deriveKey(material: string): Buffer {
return material.length === 32
? Buffer.from(material, 'utf8')
: crypto.createHash('sha256').update(material).digest();
}
function dedupeKeys(materials: string[]): Buffer[] {
const seen = new Set<string>();
const out: Buffer[] = [];
for (const m of materials) {
if (!m) continue;
const key = deriveKey(m);
const h = key.toString('hex');
if (seen.has(h)) continue;
seen.add(h);
out.push(key);
}
return out;
}
function secretsDir(): string {
return process.env.SECRETS_DIR || '/app/data';
}
function secretsFile(): string {
return path.join(secretsDir(), 'secrets.json');
}
function loadPersisted(): PersistedSecrets {
try {
const raw = fs.readFileSync(secretsFile(), 'utf8');
return JSON.parse(raw) as PersistedSecrets;
} catch {
return {};
}
}
/** Persist secrets with owner-only permissions. Returns whether it succeeded. */
function savePersisted(p: PersistedSecrets): boolean {
try {
const dir = secretsDir();
fs.mkdirSync(dir, { recursive: true });
const file = secretsFile();
fs.writeFileSync(file, JSON.stringify(p, null, 2), { mode: 0o600 });
try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
return true;
} catch {
return false;
}
}
export function initSecrets(): SecretsInfo {
if (resolved) return resolved.info;
const persisted = loadPersisted();
const history: PersistedSecrets = {
prevJwtSecrets: persisted.prevJwtSecrets ? [...persisted.prevJwtSecrets] : [],
prevEncryptionKeys: persisted.prevEncryptionKeys ? [...persisted.prevEncryptionKeys] : [],
};
// ── JWT secret ──────────────────────────────────────────────────────────────
const envJwt = process.env.JWT_SECRET;
let jwtCurrent: string;
let jwtSource: SecretsInfo['jwtSource'];
let generatedJwt = false;
if (isStrongJwt(envJwt)) {
jwtCurrent = envJwt;
jwtSource = 'env';
} else if (persisted.jwtSecret) {
jwtCurrent = persisted.jwtSecret;
jwtSource = 'persisted';
} else {
jwtCurrent = crypto.randomBytes(48).toString('base64url');
jwtSource = 'generated';
generatedJwt = true;
}
// Verifiers: current + any prior *strong* secrets (never the public defaults).
const jwtVerifiers = [
jwtCurrent,
...(persisted.jwtSecret ? [persisted.jwtSecret] : []),
...(history.prevJwtSecrets || []),
].filter((v, i, a) => isStrongJwt(v) && a.indexOf(v) === i);
// ── Encryption key ──────────────────────────────────────────────────────────
const envEnc = process.env.ENCRYPTION_KEY;
let encMaterial: string;
let encSource: SecretsInfo['encSource'];
let generatedEnc = false;
if (envEnc && !KNOWN_DEFAULT_ENC.includes(envEnc)) {
encMaterial = envEnc;
encSource = 'env';
} else if (persisted.encryptionKey) {
encMaterial = persisted.encryptionKey;
encSource = 'persisted';
} else {
encMaterial = crypto.randomBytes(16).toString('hex'); // 32 chars, used verbatim
encSource = 'generated';
generatedEnc = true;
}
// Decryptors: current + prior keys + the known defaults, so any legacy
// ciphertext still decrypts. Legacy defaults for decrypt-only are safe.
const encDecryptors = dedupeKeys([
encMaterial,
...(persisted.encryptionKey ? [persisted.encryptionKey] : []),
...(history.prevEncryptionKeys || []),
...KNOWN_DEFAULT_ENC,
]);
// ── Persist auto-managed secrets so they're stable across restarts ───────────
let persistedOk = true;
if (generatedJwt || generatedEnc) {
// Record any superseded auto-managed secret in history for continuity.
if (persisted.jwtSecret && persisted.jwtSecret !== jwtCurrent && isStrongJwt(persisted.jwtSecret)) {
history.prevJwtSecrets = [...new Set([...(history.prevJwtSecrets || []), persisted.jwtSecret])];
}
if (persisted.encryptionKey && persisted.encryptionKey !== encMaterial) {
history.prevEncryptionKeys = [...new Set([...(history.prevEncryptionKeys || []), persisted.encryptionKey])];
}
const toSave: PersistedSecrets = {
// Only store secrets we manage ourselves — never write an env-provided secret to disk.
jwtSecret: jwtSource === 'env' ? persisted.jwtSecret : jwtCurrent,
encryptionKey: encSource === 'env' ? persisted.encryptionKey : encMaterial,
prevJwtSecrets: history.prevJwtSecrets,
prevEncryptionKeys: history.prevEncryptionKeys,
};
persistedOk = savePersisted(toSave);
}
const info: SecretsInfo = {
jwtSource,
encSource,
persisted: persistedOk,
ephemeral: (generatedJwt || generatedEnc) && !persistedOk,
};
resolved = {
jwtCurrent,
jwtVerifiers,
encCurrent: deriveKey(encMaterial),
encDecryptors,
info,
};
return info;
}
function ensure(): ResolvedSecrets {
if (!resolved) initSecrets();
return resolved!;
}
/** Current secret used to SIGN new JWTs. */
export function jwtSigningSecret(): string {
return ensure().jwtCurrent;
}
/** Secrets a JWT may be verified against (current + prior strong secrets). */
export function jwtVerifierSecrets(): string[] {
return ensure().jwtVerifiers;
}
/** Current key used to ENCRYPT new data. */
export function encryptionKey(): Buffer {
return ensure().encCurrent;
}
/** All keys a ciphertext may be decrypted with (current + legacy). */
export function decryptionKeys(): Buffer[] {
return ensure().encDecryptors;
}
export function getSecretsInfo(): SecretsInfo {
return ensure().info;
}
/** Test seam: forget resolved secrets so the next call re-reads env/disk. */
export function _resetSecretsForTest(): void {
resolved = null;
}