Files
picpeak/backend/src/services/mfaService.js
T
2026-07-03 11:45:07 +02:00

184 lines
6.0 KiB
JavaScript

/**
* mfaService — TOTP (RFC 6238) multi-factor auth for admin accounts (#738).
*
* Responsibilities:
* - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so
* Google Authenticator / Authy / 1Password all work);
* - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't
* yield working authenticator seeds;
* - generate/verify one-time recovery codes, hashed (bcrypt) and single-use;
* - build the otpauth:// URI + QR data-URL for enrollment.
*
* The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set,
* otherwise from JWT_SECRET. Rotating either invalidates stored secrets —
* the same blast radius as rotating JWT_SECRET already has for sessions, and
* `reset-admin-mfa.js` is the recovery path.
*/
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift.
authenticator.options = { window: 1 };
const ISSUER = 'PicPeak';
const RECOVERY_CODE_COUNT = 10;
const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code
const RECOVERY_BCRYPT_ROUNDS = 10;
const ENC_ALGO = 'aes-256-gcm';
const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable
function getEncryptionKey() {
const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET;
if (!material) {
throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set');
}
return crypto.scryptSync(material, ENC_SALT, 32);
}
/** Generate a fresh base32 TOTP secret. */
function generateSecret() {
return authenticator.generateSecret();
}
/** AES-256-GCM encrypt a secret → "iv.tag.ciphertext" (all base64url). */
function encryptSecret(plainSecret) {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
}
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
function decryptSecret(stored) {
const key = getEncryptionKey();
const [ivB64, tagB64, ctB64] = String(stored).split('.');
if (!ivB64 || !tagB64 || !ctB64) {
throw new Error('mfaService: malformed encrypted secret');
}
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
return pt.toString('utf8');
}
/** Verify a 6-digit TOTP code against the (plaintext) secret. */
function verifyTotp(code, plainSecret) {
if (!code || !plainSecret) return false;
try {
return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret });
} catch {
return false;
}
}
/** Verify a code against a STORED (encrypted) secret. */
function verifyTotpEncrypted(code, storedSecret) {
try {
return verifyTotp(code, decryptSecret(storedSecret));
} catch {
return false;
}
}
/** otpauth:// URI for an authenticator app. */
function buildOtpauthUri(accountName, plainSecret) {
return authenticator.keyuri(accountName, ISSUER, plainSecret);
}
/** QR code (PNG data URL) for the otpauth URI. */
async function buildQrDataUrl(otpauthUri) {
return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
}
/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */
function formatRecoveryCode(raw) {
return raw.match(/.{1,4}/g).join('-');
}
/**
* Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes
* (shown to the admin ONCE) and their bcrypt hashes (persisted).
*/
async function generateRecoveryCodes() {
const plain = [];
const hashed = [];
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
// base32-ish, lowercase, no ambiguous chars
const raw = crypto.randomBytes(RECOVERY_CODE_BYTES)
.toString('base64')
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase()
.slice(0, 10);
const code = formatRecoveryCode(raw);
plain.push(code);
hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS));
}
return { plain, hashed };
}
function normalizeRecoveryInput(code) {
return String(code || '').trim().toLowerCase();
}
/**
* Check a submitted recovery code against the stored hash array. On match,
* returns the remaining hashes (matched one removed — single use). On miss,
* matched:false and the array unchanged.
*
* @param {string[]} storedHashes
* @returns {Promise<{matched: boolean, remainingHashes: string[]}>}
*/
async function consumeRecoveryCode(code, storedHashes) {
const input = normalizeRecoveryInput(code);
const hashes = Array.isArray(storedHashes) ? storedHashes : [];
if (!input) return { matched: false, remainingHashes: hashes };
for (let i = 0; i < hashes.length; i++) {
// eslint-disable-next-line no-await-in-loop
if (await bcrypt.compare(input, hashes[i])) {
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
return { matched: true, remainingHashes: remaining };
}
}
return { matched: false, remainingHashes: hashes };
}
/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */
function isEnrolled(admin) {
const v = admin && admin.two_factor_enabled;
return v === true || v === 1 || v === '1';
}
/** Parse the DB column (JSON text) into an array of hashes. */
function parseRecoveryCodes(raw) {
if (!raw) return [];
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
module.exports = {
generateSecret,
encryptSecret,
decryptSecret,
verifyTotp,
verifyTotpEncrypted,
buildOtpauthUri,
buildQrDataUrl,
generateRecoveryCodes,
consumeRecoveryCode,
parseRecoveryCodes,
isEnrolled,
formatRecoveryCode,
ISSUER,
RECOVERY_CODE_COUNT,
};