/** * portal/auth/totp.mjs — TOTP(RFC 6238) 다중 인증 구현. 외부 의존성 없이 node:crypto 만 쓴다. * * - 비밀: 20바이트 무작위 → base32(RFC 4648, 패딩 없음). 인증 앱에 수동 입력할 수 있게 4자 단위로 끊어 보여 준다. * - 코드: HMAC-SHA1(비밀, 30초 카운터) 동적 절단 → 6자리. 검증은 ±1 구간(시계 차이 허용)과 상수 시간 비교. * - 재사용 방지: 마지막으로 성공한 카운터(lastCounter)를 저장해 같은 코드로 두 번 로그인하지 못하게 한다. */ import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto'; const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; /** 바이트 → base32 문자열(패딩 없음) */ export function base32Encode(buf) { let bits = 0, value = 0, out = ''; for (const b of buf) { value = (value << 8) | b; bits += 8; while (bits >= 5) { out += ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31]; return out; } /** base32 문자열 → 바이트(공백·소문자 허용) */ export function base32Decode(str) { const s = String(str || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); let bits = 0, value = 0; const out = []; for (const c of s) { value = (value << 5) | ALPHABET.indexOf(c); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(out); } /** 새 비밀(base32) */ export const generateSecret = () => base32Encode(randomBytes(20)); /** 수동 입력용 표시: 4자씩 띄어쓰기 */ export const prettySecret = (secret) => String(secret).replace(/(.{4})/g, '$1 ').trim(); /** 인증 앱 등록 URI(QR 코드 생성은 화면에서 이 문자열로) */ export const otpauthUri = (secret, account, issuer = 'Ariweb Suite') => `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; /** 카운터 값으로 코드 계산 */ export function codeAt(secret, counter) { const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter)); const h = createHmac('sha1', base32Decode(secret)).update(msg).digest(); const off = h[h.length - 1] & 0xf; const n = ((h[off] & 0x7f) << 24) | (h[off + 1] << 16) | (h[off + 2] << 8) | h[off + 3]; return String(n % 1_000_000).padStart(6, '0'); } export const counterAt = (ms = Date.now()) => Math.floor(ms / 1000 / 30); /** 현재 코드(테스트용) */ export const currentCode = (secret, ms = Date.now()) => codeAt(secret, counterAt(ms)); /** * 코드 검증. 성공하면 맞은 카운터를 돌려주고(호출자가 lastCounter 로 저장), 실패하면 null. * @param {number|null} lastCounter 마지막 성공 카운터 — 같거나 작은 카운터는 재사용으로 보고 거부 */ export function verifyCode(secret, code, { ms = Date.now(), window = 1, lastCounter = null } = {}) { const c = String(code || '').replace(/\s/g, ''); if (!/^\d{6}$/.test(c)) return null; const base = counterAt(ms); for (let d = -window; d <= window; d++) { const k = base + d; if (lastCounter != null && k <= lastCounter) continue; const a = Buffer.from(codeAt(secret, k)); const b = Buffer.from(c); if (a.length === b.length && timingSafeEqual(a, b)) return k; } return null; } /** 복구 코드 10개(xxxx-xxxx 형태, 한 번만 표시) */ export function generateRecoveryCodes(n = 10) { return Array.from({ length: n }, () => { const h = randomBytes(5).toString('hex'); return `${h.slice(0, 5)}-${h.slice(5)}`; }); }