Files
AriWeb-Suite/hal/totp.mjs

27 lines
2.2 KiB
JavaScript

/**
* hal/totp.mjs — RFC 6238 TOTP(운영자 다중 인증, §3.3·§25). 외부 의존성 없이 node:crypto 만 쓴다.
* 비밀은 base32 로 표시(인증 앱 등록용 otpauth URL)하고 저장은 보관소에만 한다.
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
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 += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += B32[(value << (5 - bits)) & 31]; return out; }
export function base32Decode(str) { const s = String(str).toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, ''); let bits = 0, value = 0; const out = []; for (const c of s) { value = (value << 5) | B32.indexOf(c); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(out); }
/** 새 비밀(base32 32자) */
export const newSecret = () => base32Encode(randomBytes(20));
/** 코드 계산. step 30초, 6자리 */
export function totpCode(secretB32, at = Date.now(), step = 30) {
const counter = Math.floor(at / 1000 / step); const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac('sha1', base32Decode(secretB32)).update(msg).digest(); const off = h[h.length - 1] & 0xf;
const code = ((h[off] & 0x7f) << 24 | (h[off + 1] & 0xff) << 16 | (h[off + 2] & 0xff) << 8 | (h[off + 3] & 0xff)) % 1000000;
return String(code).padStart(6, '0');
}
/** 검증(앞뒤 1단계 허용, 상수 시간 비교) */
export function verifyTotp(secretB32, code, at = Date.now()) {
const c = String(code || '').replace(/\s/g, ''); if (!/^\d{6}$/.test(c)) return false;
for (const d of [-1, 0, 1]) { const exp = Buffer.from(totpCode(secretB32, at + d * 30000)); const got = Buffer.from(c); if (exp.length === got.length && timingSafeEqual(exp, got)) return true; }
return false;
}
/** 인증 앱 등록 URL */
export const otpauthUrl = (secretB32, login, issuer = 'Ariweb HAL') => `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(login)}?secret=${secretB32}&issuer=${encodeURIComponent(issuer)}&digits=6&period=30`;