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:
co-authored by
Claude Opus 4.8
parent
27560fa1d6
commit
9e962939ea
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user