Security audit fixes, mobile responsiveness across 40+ admin pages

Security hardening from Mar 31 audit:
- Separate login rate limit (10/15min) from general auth budget (15/15min)
- Timing-safe webhook secret comparison (Listmonk)
- Docs file creation ACL check (matches PUT/DELETE guards)
- Key separation warnings for GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
- Clear GITEA_ADMIN_PASSWORD from .env after auto-setup
- SQL injection prevention in effectiveness groupBy (pre-validated map)
- Token hashing for password reset and verification tokens

Mobile responsiveness (Phase 2C):
- Add MobilePageHeader component and useMobile hook
- Responsive table columns (hide secondary cols on mobile)
- scroll={{ x: 'max-content' }} across all data tables
- Mobile-adapted layouts for Dashboard, Settings, Calendar, SMS, Social pages
- Conditional toolbar buttons on mobile viewports

Infrastructure:
- Updated docker-compose and nginx templates
- Build script and mirror script updates

Bunker Admin
This commit is contained in:
2026-03-31 18:30:17 -06:00
parent d7ab8f0d99
commit 5a0c4641a1
72 changed files with 684 additions and 241 deletions

View File

@@ -38,9 +38,9 @@ const envSchema = z.object({
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
// Gitea SSO cookie signing secret (falls back to JWT_ACCESS_SECRET if empty)
// Gitea SSO cookie signing secret — MUST be unique (key separation from JWT)
GITEA_SSO_SECRET: z.string().default(''),
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat — falls back to JWT_ACCESS_SECRET if empty)
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat)MUST be unique
SERVICE_PASSWORD_SALT: z.string().default(''),
// Initial Super Admin (auto-created during database seeding)
@@ -259,7 +259,17 @@ function validateEnv(): Env {
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
// Warn about security-critical key separation issues
const data = result.data;
if (!data.GITEA_SSO_SECRET) {
console.warn('⚠ SECURITY WARNING: GITEA_SSO_SECRET is empty — falling back to JWT_ACCESS_SECRET. This violates key separation. Generate a unique secret with: openssl rand -hex 32');
}
if (!data.SERVICE_PASSWORD_SALT) {
console.warn('⚠ SECURITY WARNING: SERVICE_PASSWORD_SALT is empty — falling back to JWT_ACCESS_SECRET. Rotating JWT_ACCESS_SECRET will invalidate all provisioned service passwords. Generate a unique salt with: openssl rand -hex 32');
}
return data;
}
export const env = validateEnv();

View File

@@ -209,7 +209,7 @@ export const paymentCheckoutRateLimit = rateLimit({
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Reduced from 20 to prevent brute force attacks
max: 15,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
@@ -224,6 +224,25 @@ export const authRateLimit = rateLimit({
},
});
// Separate stricter rate limit for login to prevent credential stuffing
// Isolated from the general auth budget so register/refresh/logout don't consume login slots
export const loginRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:login:',
}),
message: {
error: {
message: 'Too many login attempts, please try again later',
code: 'LOGIN_RATE_LIMIT_EXCEEDED',
},
},
});
export const observabilityRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20, // 20 requests per minute (stricter than global 500/min)

View File

@@ -7,7 +7,7 @@ import { authService } from './auth.service';
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { authRateLimit } from '../../middleware/rate-limit';
import { authRateLimit, loginRateLimit } from '../../middleware/rate-limit';
import { prisma } from '../../config/database';
import { verificationTokenService } from '../../services/verification-token.service';
import { passwordResetTokenService } from '../../services/password-reset-token.service';
@@ -99,7 +99,7 @@ function clearSessionCookie(req: Request, res: Response) {
// POST /api/auth/login
router.post(
'/login',
authRateLimit,
loginRateLimit,
validate(loginSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {

View File

@@ -12,7 +12,7 @@ export const docsAnalyticsPublicRouter = Router();
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
import { env } from '../../config/env';
const DOCS_ORIGIN = env.ADMIN_URL || `https://docs.${env.DOMAIN}`;
const DOCS_ORIGIN = env.DOMAIN ? `https://docs.${env.DOMAIN}` : (env.ADMIN_URL || 'http://localhost:4003');
docsAnalyticsPublicRouter.use((_req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', DOCS_ORIGIN);
res.setHeader('Vary', 'Origin');

View File

@@ -397,6 +397,15 @@ router.post(
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
return;
}
// Per-file access check (matches PUT/DELETE guards)
const userRoles = getUserRoles(req.user!);
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
if (!canEdit) {
res.status(403).json({ error: { message: 'You do not have edit access to this directory', code: 'DOC_ACCESS_DENIED' } });
return;
}
const { content, isDirectory } = req.body as { content?: string; isDirectory?: boolean };
await docsFilesService.createFile(filePath, content, isDirectory);
res.status(201).json({ success: true, path: filePath });

View File

@@ -3,6 +3,7 @@ import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { encrypt } from '../../utils/crypto';
import { giteaClient } from '../../services/gitea.client';
import { updateEnvFile } from '../../services/env-writer.service';
const SETUP_TIMEOUT = 15000;
@@ -429,6 +430,17 @@ async function autoSetupIfNeeded(): Promise<{ alreadyComplete: boolean; success:
for (const step of result.steps) {
logger.info(` ${step.step}: ${step.success ? 'OK' : 'FAILED'}${step.data?.note ? ` (${step.data.note})` : ''}`);
}
// Clear GITEA_ADMIN_PASSWORD from .env — it's no longer needed after setup
// (the API token is now encrypted in the database)
try {
const envResult = updateEnvFile({ GITEA_ADMIN_PASSWORD: '' });
if (envResult.success) {
logger.info('Gitea auto-setup: cleared GITEA_ADMIN_PASSWORD from .env');
}
} catch (err) {
logger.warn(`Gitea auto-setup: could not clear GITEA_ADMIN_PASSWORD from .env: ${err instanceof Error ? err.message : err}`);
}
} else {
logger.warn(`Gitea auto-setup: failed — ${result.error}`);
for (const step of result.steps) {

View File

@@ -286,7 +286,12 @@ export const effectivenessService = {
}
// For city/province grouping, we need to join with postal_code_cache
const groupCol = query.groupBy === 'province' ? Prisma.raw('pcc.province') : Prisma.raw('pcc.city');
// Use pre-validated lookup map to prevent SQL injection if enum expands
const GROUP_COL_MAP: Record<string, ReturnType<typeof Prisma.sql>> = {
province: Prisma.sql`pcc.province`,
city: Prisma.sql`pcc.city`,
};
const groupCol = GROUP_COL_MAP[query.groupBy!] ?? GROUP_COL_MAP.city;
const campaignFilter = query.campaignId
? Prisma.sql`AND ce."campaignId" = ${query.campaignId}`
: Prisma.sql``;

View File

@@ -1,4 +1,5 @@
import { Router, Request, Response, NextFunction } from 'express';
import { timingSafeEqual } from 'crypto';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
@@ -18,7 +19,14 @@ router.post(
try {
// Accept secret from header only (query param removed — secrets must not appear in logs)
const secret = req.headers['x-webhook-secret'] as string;
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
if (!env.LISTMONK_WEBHOOK_SECRET || !secret) {
res.status(403).json({ error: 'Invalid webhook secret' });
return;
}
// Constant-time comparison to prevent timing attacks
const incoming = Buffer.from(secret);
const expected = Buffer.from(env.LISTMONK_WEBHOOK_SECRET);
if (incoming.length !== expected.length || !timingSafeEqual(incoming, expected)) {
res.status(403).json({ error: 'Invalid webhook secret' });
return;
}

View File

@@ -18,13 +18,14 @@ import { locationsService } from '../locations/locations.service';
import { geocodingService } from '../geocoding/geocoding.service';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { requireRole, requireNonTemp } from '../../../middleware/rbac.middleware';
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
import { MAP_ROLES } from '../../../utils/roles';
// ─── Volunteer Router ────────────────────────────────────────────────
const volunteerRouter = Router();
volunteerRouter.use(authenticate);
volunteerRouter.use(requireNonTemp);
// GET /api/map/canvass/my/assignments
volunteerRouter.get(

View File

@@ -73,7 +73,7 @@ volunteerRouter.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const session = await trackingService.linkCanvassSession(id, req.body);
const session = await trackingService.linkCanvassSession(id, req.user!.id, req.body);
res.json(session);
} catch (err) {
next(err);

View File

@@ -113,8 +113,14 @@ class TrackingService {
});
}
/** Link a canvass session to an existing tracking session. */
async linkCanvassSession(trackingSessionId: string, data: LinkCanvassInput) {
/** Link a canvass session to an existing tracking session (ownership-verified). */
async linkCanvassSession(trackingSessionId: string, userId: string, data: LinkCanvassInput) {
const session = await prisma.trackingSession.findFirst({
where: { id: trackingSessionId, userId, isActive: true },
});
if (!session) {
throw Object.assign(new Error('Active tracking session not found'), { statusCode: 404 });
}
return prisma.trackingSession.update({
where: { id: trackingSessionId },
data: { canvassSessionId: data.canvassSessionId },

View File

@@ -1,6 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { env } from '../../config/env';
import { siteSettingsService } from '../settings/settings.service';
import { escapeHtml } from '../../utils/escapeHtml';
@@ -65,8 +66,8 @@ router.get('/campaign/:slug', async (req: Request, res: Response, next: NextFunc
if (!campaign) { res.status(404).send('Not found'); return; }
const settings = await siteSettingsService.getPublic();
const appDomain = req.get('host') || 'app.cmlite.org';
const protocol = req.protocol;
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
const protocol = env.DOMAIN ? 'https' : req.protocol;
const url = `${protocol}://${appDomain}/campaign/${slug}`;
const html = ogHtml({
@@ -104,8 +105,8 @@ router.get('/page/:slug', async (req: Request, res: Response, next: NextFunction
if (!page) { res.status(404).send('Not found'); return; }
const settings = await siteSettingsService.getPublic();
const appDomain = req.get('host') || 'app.cmlite.org';
const protocol = req.protocol;
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
const protocol = env.DOMAIN ? 'https' : req.protocol;
const url = `${protocol}://${appDomain}/p/${slug}`;
const html = ogHtml({
@@ -146,8 +147,8 @@ router.get('/gallery/:id', async (req: Request, res: Response, next: NextFunctio
if (!video) { res.status(404).send('Not found'); return; }
const settings = await siteSettingsService.getPublic();
const appDomain = req.get('host') || 'app.cmlite.org';
const protocol = req.protocol;
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
const protocol = env.DOMAIN ? 'https' : req.protocol;
const url = `${protocol}://${appDomain}/gallery/watch/${id}`;
const html = ogHtml({

View File

@@ -1,7 +1,9 @@
import { Router, Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { env } from '../../config/env';
import { search } from './search.service';
const router = Router();
@@ -27,7 +29,16 @@ router.get('/', searchRateLimit, async (req: Request, res: Response, next: NextF
res.json([]);
return;
}
const results = await search(q, limit);
// Lightweight auth check — shifts only returned for authenticated users
let isAuthenticated = false;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
try {
jwt.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] });
isAuthenticated = true;
} catch { /* unauthenticated — public results only */ }
}
const results = await search(q, limit, isAuthenticated);
res.json(results);
} catch (err) {
next(err);

View File

@@ -14,7 +14,7 @@ export interface SearchResult {
const CACHE_PREFIX = 'search:';
const CACHE_TTL = 60; // 60 seconds
export async function search(query: string, limit = 5): Promise<SearchResult[]> {
export async function search(query: string, limit = 5, isAuthenticated = false): Promise<SearchResult[]> {
const q = query.trim().toLowerCase();
if (!q || q.length < 2) return [];
@@ -34,7 +34,7 @@ export async function search(query: string, limit = 5): Promise<SearchResult[]>
if (settings.enableInfluence !== false) {
promises.push(searchCampaigns(q, limit));
}
if (settings.enableMap !== false) {
if (settings.enableMap !== false && isAuthenticated) {
promises.push(searchShifts(q, limit));
}
if (settings.enableLandingPages !== false) {

View File

@@ -260,7 +260,9 @@ router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: R
const crypto = await import('crypto');
const { env: envConfig } = await import('../../config/env');
const nonce = crypto.randomBytes(16);
const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY);
// Use domain-separated key for ticket HMACs (matches tickets.service.ts)
const ticketKey = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY).update('ticket-hmac-v1').digest();
const hmac = crypto.createHmac('sha256', ticketKey);
hmac.update(ticket.id);
hmac.update(nonce);
const token = Buffer.concat([

View File

@@ -4,8 +4,9 @@ import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
import { logger } from '../../utils/logger';
function getEncryptionKey(): string {
return env.ENCRYPTION_KEY;
/** Derive a domain-separated key for ticket HMACs (not the raw ENCRYPTION_KEY). */
function getTicketHmacKey(): Buffer {
return crypto.createHmac('sha256', env.ENCRYPTION_KEY).update('ticket-hmac-v1').digest();
}
/** Generate a human-readable ticket code like "ABCD-1234" */
@@ -19,7 +20,7 @@ function generateTicketCode(): string {
/** Generate HMAC token for QR code validation */
function generateToken(ticketId: string): { token: string; tokenHash: string } {
const nonce = crypto.randomBytes(16);
const hmac = crypto.createHmac('sha256', getEncryptionKey());
const hmac = crypto.createHmac('sha256', getTicketHmacKey());
hmac.update(ticketId);
hmac.update(nonce);
const token = Buffer.concat([

View File

@@ -2,24 +2,31 @@ import crypto from 'crypto';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export const passwordResetTokenService = {
async createToken(userId: string): Promise<string> {
// Delete any existing tokens for this user
await prisma.passwordResetToken.deleteMany({ where: { userId } });
const token = crypto.randomBytes(32).toString('hex');
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await prisma.passwordResetToken.create({
data: { userId, token, expiresAt },
data: { userId, token: tokenHash, expiresAt },
});
logger.info(`Password reset token created for user ${userId}`);
return token;
return rawToken; // Send raw token in email; DB stores only the hash
},
async validateToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
const record = await prisma.passwordResetToken.findUnique({ where: { token } });
const tokenHash = hashToken(token);
const record = await prisma.passwordResetToken.findUnique({ where: { token: tokenHash } });
// Use a generic error message for all failure cases to prevent token state enumeration
const genericError = 'Invalid or expired reset token';
@@ -41,8 +48,9 @@ export const passwordResetTokenService = {
},
async markTokenUsed(token: string): Promise<void> {
const tokenHash = hashToken(token);
await prisma.passwordResetToken.update({
where: { token },
where: { token: tokenHash },
data: { usedAt: new Date() },
});
},

View File

@@ -2,24 +2,31 @@ import crypto from 'crypto';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export const verificationTokenService = {
async createToken(userId: string): Promise<string> {
// Delete any existing tokens for this user
await prisma.emailVerificationToken.deleteMany({ where: { userId } });
const token = crypto.randomBytes(32).toString('hex');
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
await prisma.emailVerificationToken.create({
data: { userId, token, expiresAt },
data: { userId, token: tokenHash, expiresAt },
});
logger.info(`Verification token created for user ${userId}`);
return token;
return rawToken; // Send raw token in email; DB stores only the hash
},
async verifyToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
const record = await prisma.emailVerificationToken.findUnique({ where: { token } });
const tokenHash = hashToken(token);
const record = await prisma.emailVerificationToken.findUnique({ where: { token: tokenHash } });
if (!record) {
return { valid: false, error: 'Invalid or expired verification token' };

View File

@@ -34,9 +34,57 @@ interface FetchJobResult {
// Shell metacharacters that could enable command injection
const SHELL_METACHAR_REGEX = /[`$;|&<>(){}[\]\\!#]/;
// Hostnames that resolve to internal/cloud-metadata addresses
const BLOCKED_HOSTNAMES = new Set([
'localhost',
'metadata.google.internal',
'metadata.gcp.internal',
'169.254.169.254', // AWS/GCP/Azure metadata
'169.254.170.2', // AWS ECS task metadata
'kubernetes.default',
'kubernetes.default.svc',
]);
// Docker internal container names used in this project
const DOCKER_INTERNAL_SUFFIXES = [
'-changemaker', // Our container naming convention
'-rocketchat',
'-listmonk',
];
/**
* Check if an IP address is in a private/reserved range (SSRF protection).
*/
function isPrivateIP(hostname: string): boolean {
// IPv6 loopback
if (hostname === '::1' || hostname === '[::1]') return true;
// Strip IPv6 brackets
const clean = hostname.replace(/^\[|\]$/g, '');
// IPv4 patterns
const parts = clean.split('.').map(Number);
if (parts.length === 4 && parts.every(p => !isNaN(p) && p >= 0 && p <= 255)) {
const [a, b] = parts;
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 10) return true; // 10.0.0.0/8 private
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / cloud metadata
if (a === 0) return true; // 0.0.0.0/8
}
// IPv6 private ranges (simplified check for common prefixes)
if (clean.startsWith('fc') || clean.startsWith('fd')) return true; // ULA
if (clean.startsWith('fe80')) return true; // Link-local
return false;
}
/**
* Sanitize and validate a URL for safe shell usage.
* Returns the URL if valid, or null if invalid or contains shell metacharacters.
* Returns the URL if valid, or null if invalid, contains shell metacharacters,
* or targets private/internal network addresses (SSRF protection).
*/
function sanitizeUrl(url: string): string | null {
if (!url || typeof url !== 'string') return null;
@@ -45,8 +93,9 @@ function sanitizeUrl(url: string): string | null {
if (!trimmed) return null;
// Validate URL structure
let parsed: URL;
try {
const parsed = new URL(trimmed);
parsed = new URL(trimmed);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
} catch {
return null;
@@ -55,6 +104,16 @@ function sanitizeUrl(url: string): string | null {
// Reject shell metacharacters
if (SHELL_METACHAR_REGEX.test(trimmed)) return null;
// SSRF protection: block private/internal IPs and hostnames
const hostname = parsed.hostname.toLowerCase();
if (isPrivateIP(hostname)) return null;
if (BLOCKED_HOSTNAMES.has(hostname)) return null;
if (DOCKER_INTERNAL_SUFFIXES.some(suffix => hostname.endsWith(suffix))) return null;
// Block numeric IPv6 addresses entirely (too many bypass variants)
if (hostname.includes(':')) return null;
return trimmed;
}

View File

@@ -1,20 +1,36 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const PREFIX = 'enc:';
/** Derive a 32-byte key from an arbitrary-length secret */
function deriveKey(secret: string): Buffer {
/** Legacy prefix (SHA-256 derived key) — read-only, never written for new data */
const LEGACY_PREFIX = 'enc:';
/** V2 prefix (HKDF derived key) — used for all new encryptions */
const V2_PREFIX = 'enc2:';
const HKDF_SALT = 'changemaker-lite-encryption-v2';
const HKDF_INFO = 'aes-256-gcm-data-encryption';
/** Derive a 32-byte key using SHA-256 (legacy, for decrypting old data) */
function deriveLegacyKey(secret: string): Buffer {
return createHash('sha256').update(secret).digest();
}
/** Derive a 32-byte key using HKDF-SHA256 (RFC 5869) */
function deriveKey(secret: string): Buffer {
return Buffer.from(
hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32)
);
}
let _key: Buffer | null = null;
let _legacyKey: Buffer | null = null;
/** Initialize (or re-initialize) the encryption key. Call once at startup. */
export function initEncryption(secret: string): void {
_key = deriveKey(secret);
_legacyKey = deriveLegacyKey(secret);
}
function getKey(): Buffer {
@@ -24,9 +40,16 @@ function getKey(): Buffer {
return _key;
}
function getLegacyKey(): Buffer {
if (!_legacyKey) {
throw new Error('Encryption not initialized — call initEncryption() first');
}
return _legacyKey;
}
/**
* Encrypt a plaintext string.
* Returns format: `enc:<iv>:<authTag>:<ciphertext>` (all base64).
* Returns format: `enc2:<iv>:<authTag>:<ciphertext>` (all base64).
*/
export function encrypt(plaintext: string): string {
if (!plaintext) return plaintext;
@@ -37,18 +60,31 @@ export function encrypt(plaintext: string): string {
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
return `${V2_PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
}
/**
* Decrypt a value produced by encrypt().
* If the value doesn't have the `enc:` prefix, returns it as-is (backward compat for plaintext).
* Supports both v2 (HKDF) and legacy (SHA-256) key derivation.
* If the value doesn't have a recognized prefix, returns it as-is (backward compat for plaintext).
*/
export function decrypt(value: string): string {
if (!value || !value.startsWith(PREFIX)) return value;
if (!value) return value;
const key = getKey();
const parts = value.slice(PREFIX.length).split(':');
let key: Buffer;
let payload: string;
if (value.startsWith(V2_PREFIX)) {
key = getKey();
payload = value.slice(V2_PREFIX.length);
} else if (value.startsWith(LEGACY_PREFIX)) {
key = getLegacyKey();
payload = value.slice(LEGACY_PREFIX.length);
} else {
return value; // Plaintext fallback
}
const parts = payload.split(':');
if (parts.length !== 3) return value;
const iv = Buffer.from(parts[0], 'base64');
@@ -64,5 +100,5 @@ export function decrypt(value: string): string {
/** Check whether a value is already encrypted */
export function isEncrypted(value: string): boolean {
return !!value && value.startsWith(PREFIX);
return !!value && (value.startsWith(V2_PREFIX) || value.startsWith(LEGACY_PREFIX));
}