Ctrl K
ComunidadGuías…

server/src/totp.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 2단계 인증(OTP): TOTP (RFC 6238 — HMAC-SHA1, 30초, 6자리). Google Authenticator·1Password·Authy 등이 그대로 읽는다.
// 서버가 코드를 확인해야 하므로 비밀값을 서버가 갖는다 — DB 파일만 새어 나가서는 못 쓰게 서버 키로 암호화해 둔다
// (SHELL_TOTP_KEY 환경변수, 없으면 데이터 폴더의 totp.key — 이 키를 잃으면 OTP 를 켠 사람은 관리자가 초기화해야 한다).
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { config } from './config.ts';

const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const STEP_MS = 30_000;

export function base32Encode(buf: Buffer) {
  let bits = 0;
  let value = 0;
  let out = '';
  for (const byte of buf) {
    value = (value << 8) | byte;
    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(text: string) {
  const clean = text.toUpperCase().replace(/[^A-Z2-7]/g, '');
  let bits = 0;
  let value = 0;
  const out: number[] = [];
  for (const ch of clean) {
    value = (value << 5) | B32.indexOf(ch);
    bits += 5;
    if (bits >= 8) {
      out.push((value >>> (bits - 8)) & 255);
      bits -= 8;
    }
  }
  return Buffer.from(out);
}

export const newSecret = () => base32Encode(crypto.randomBytes(20));
export const currentStep = (at = Date.now()) => Math.floor(at / STEP_MS);

export function totpAt(secret: string, step: number) {
  const counter = Buffer.alloc(8);
  counter.writeBigUInt64BE(BigInt(step));
  const mac = crypto.createHmac('sha1', base32Decode(secret)).update(counter).digest();
  const offset = mac[mac.length - 1] & 15;
  const n = (mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
  return String(n).padStart(6, '0');
}

// 앞뒤 한 칸(±30초)까지 받는다. 맞으면 그 칸 번호, 아니면 null. lastStep 이하의 칸은 다시 쓸 수 없다(같은 코드 재사용 방지)
export function verifyTotp(secret: string, code: string, lastStep: number, at = Date.now()): number | null {
  if (!/^\d{6}$/.test(code)) return null;
  const now = currentStep(at);
  for (const step of [now - 1, now, now + 1]) {
    if (step <= lastStep) continue;
    if (crypto.timingSafeEqual(Buffer.from(totpAt(secret, step)), Buffer.from(code))) return step;
  }
  return null;
}

export const otpauthUri = (secret: string, account: string) =>
  `otpauth://totp/${encodeURIComponent(`Terminas:${account}`)}?secret=${secret}&issuer=Terminas&algorithm=SHA1&digits=6&period=30`;

// ---------- 비밀값 보관 (서버 키로 AES-256-GCM) ----------
let key: Buffer | null = null;
function serverKey() {
  if (key) return key;
  const fromEnv = process.env.SHELL_TOTP_KEY;
  if (fromEnv) {
    const k = Buffer.from(fromEnv, 'base64');
    if (k.length !== 32) throw new Error('SHELL_TOTP_KEY 는 32바이트 base64 여야 합니다');
    return (key = k);
  }
  const file = path.join(config.dataDir, 'totp.key');
  if (!fs.existsSync(file)) {
    fs.mkdirSync(config.dataDir, { recursive: true });
    fs.writeFileSync(file, crypto.randomBytes(32).toString('base64') + '\n', { flag: 'wx', mode: 0o600 });
  }
  const k = Buffer.from(fs.readFileSync(file, 'utf8').trim(), 'base64');
  if (k.length !== 32) throw new Error(`${file} 가 올바르지 않습니다`);
  return (key = k);
}

export function sealSecret(secret: string, userId: string) {
  const iv = crypto.randomBytes(12);
  const c = crypto.createCipheriv('aes-256-gcm', serverKey(), iv);
  c.setAAD(Buffer.from(`totp:${userId}`));
  const data = Buffer.concat([c.update(secret, 'utf8'), c.final()]);
  return `v1.${Buffer.concat([iv, data, c.getAuthTag()]).toString('base64')}`;
}

export function openSecret(sealed: string, userId: string) {
  const raw = Buffer.from(sealed.replace(/^v1\./, ''), 'base64');
  const d = crypto.createDecipheriv('aes-256-gcm', serverKey(), raw.subarray(0, 12));
  d.setAAD(Buffer.from(`totp:${userId}`));
  d.setAuthTag(raw.subarray(raw.length - 16));
  return Buffer.concat([d.update(raw.subarray(12, raw.length - 16)), d.final()]).toString('utf8');
}

// ---------- 복구 코드: 휴대폰을 잃었을 때 한 번씩 쓰는 코드 10개 ----------
export function newRecoveryCodes(n = 10) {
  return Array.from({ length: n }, () => {
    const s = base32Encode(crypto.randomBytes(7)).slice(0, 10).toLowerCase();
    return `${s.slice(0, 5)}-${s.slice(5)}`;
  });
}
export const recoveryHash = (code: string) => crypto.createHash('sha256').update(code.toLowerCase().replace(/[\s-]/g, '')).digest('hex');