Files

56 lines
3.5 KiB
JavaScript

/**
* 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)}`; }); }