← 목록
AI 2026-06-13 9KB 읽기 9분

크라우니AI 비밀번호 재설정 안전 설계

개요

크라우니AI(crowny.org)는 계정을 아이디 + 비밀번호만으로 관리합니다. 이메일/전화 없는 시스템에서 비밀번호 분실 시 계정을 복구하는 유일한 방법은 1회용 복구코드(Recovery Code) 시스템입니다.

이 문서는 보안 함정을 피하고 실전 수준의 구현 기준을 제시합니다.


핵심 구현 설계

1. 복구코드 생성 (가입 시 1회)

요구사항:

  • 8개 코드, 각 16자 (hex: ABCD1234-WXYZ9876)
  • 최소 128비트 엔트로피 (2^128 = 10^38, 무차별 불가능)
  • 공식적 도구: crypto.randomBytes(16)
저장 방식 (★필수★):
각 코드마다:
1. 솔트 = crypto.randomBytes(16) (독립적으로)
2. 코드_해시 = bcrypt(코드, saltRounds=12)
3. DB에 code_hash 저장 (평문 절대 금지)

❌ 금지:
  - 평문 저장
  - SHA256/MD5 (빠른 해싱, 무차별 취약)
  - 단일 솔트 (모든 코드 같은 솔트 = 레인보우 테이블)

배포:

  • 화면에 한 번만 표시
  • 사용자가 안전한 곳에 저장 (암호화 관리자 권장)
  • PDF 다운로드 옵션 제공
  • 이후 서버에서 평문을 절대 복구할 수 없음

2. 비밀번호 재설정 플로우

로그인 페이지 → "비밀번호를 잊었나요?"
  ↓
아이디 + 복구코드 입력
  ↓
서버: bcrypt.compare(입력, 저장된_해시)
  ↓
  ✅ 일치 → 임시 토큰(5분, 1회용) 발급
  ❌ 불일치 → 실패 카운트 증가 (3회 → 5분 잠금)
  ↓
새 비밀번호 설정
  ↓
기존 8개 복구코드 모두 무효화 + 새 8개 자동 생성

3. 데이터베이스 스키마

sqlCREATE TABLE recovery_codes (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(255) NOT NULL,
  code_hash VARCHAR(255) NOT NULL,  -- bcrypt 해시
  used BOOLEAN DEFAULT false,        -- 1회용 강제
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  used_at TIMESTAMP NULL,
  expires_at TIMESTAMP,              -- 1년 후 자동 무효
  FOREIGN KEY (username) REFERENCES users(username),
  INDEX (username, used),
  INDEX (username, created_at)
);

CREATE TABLE recovery_attempts (
  username VARCHAR(255),
  ip_address VARCHAR(45),
  failed_count INT DEFAULT 0,
  locked_until TIMESTAMP NULL,
  PRIMARY KEY (username, ip_address)
);


5가지 보안 함정 & 대응책

함정 1: 무차별 공격 (Brute Force)

시나리오: 공격자가 복구코드 8개를 반복 시도

방어:

  • 실패 3회 → 5분 잠금 (지수백오프: 5m → 15m → 60m)
  • IP별 + username별 분리 추적
  • 1분에 3회 이상 시도 차단
구현:
javascriptconst { failed_count, locked_until } = await db.query(
  'SELECT failed_count, locked_until FROM recovery_attempts WHERE username=? AND ip_address=?',
  [username, clientIp]
);

if (locked_until && new Date() < locked_until) {
  return res.status(429).json({ error: '너무 많은 시도. 나중에 다시 시도하세요.' });
}

if (failed_count >= 3) {
  const locked_until = new Date(Date.now() + 5 * 60 * 1000); // 5분
  await db.query(
    'UPDATE recovery_attempts SET locked_until=? WHERE username=? AND ip_address=?',
    [locked_until, username, clientIp]
  );
  return res.status(429).json({ error: '계정이 잠겼습니다.' });
}

함정 2: 코드 재사용 (Replay Attack)

시나리오: 한 번 사용한 코드를 다시 사용

방어:

  • used=true 플래그로 1회용 강제
  • 코드 사용 직후 즉시 표시 (DELETE 아님, UPDATE 필수)
  • 재발급 시 기존 8개 모두 무효화
구현:
javascriptasync function verifyRecoveryCode(username, inputCode) {
  const input = inputCode.replace(/[-\s]/g, '').toUpperCase();
  
  const rows = await db.query(
    'SELECT id, code_hash FROM recovery_codes WHERE username=? AND used=false',
    [username]
  );
  
  for (const row of rows) {
    if (await bcrypt.compare(input, row.code_hash)) {
      // 즉시 사용 표시 (동시성 race condition 방지)
      await db.query(
        'UPDATE recovery_codes SET used=true, used_at=NOW() WHERE id=?',
        [row.id]
      );
      return { success: true };
    }
  }
  return { success: false };
}

함정 3: 타이밍 공격 (Timing Attack)

시나리오: bcrypt 비교 시간이 다름 → 코드 존재 유무 추측

방어:

  • 항상 bcrypt.compare 실행 (상수 시간)
  • 코드 없으면 더미해시와 비교
구현:
javascript// ❌ 취약한 방식
const codeExists = hashList.find(h => h.hash === inputHash);
if (!codeExists) return false; // 빨리 반환 ← 타이밍 차이

// ✅ 안전한 방식
const storedHash = codeExists ? actualHash : dummyHash;
const match = await bcrypt.compare(inputCode, storedHash);
return match; // 항상 bcrypt 실행 (상수 시간)

함정 4: 코드 노출 (Disclosure)

시나리오: 사용자 기기 감염 → 저장된 코드 탈취

대응 (완벽한 해결 불가, 완화만):

  • 사용자 교육: "복구코드를 암호화된 비밀번호 관리자에 저장"
  • 로그: 코드 사용 시 부분 마스킹 (처음 4자만)
  • 재발급: 의심 시 사용자가 수동 요청 가능

함정 5: 데이터베이스 유출 (DB Breach)

시나리오: DB 해킹 → 모든 복구코드 해시 탈취 → 무차별 공격

방어:

  • 데이터베이스 전체 암호화 (at-rest encryption)
  • 접근 제어: recovery_codes 테이블 SELECT는 특정 서비스만
  • 모니터링: 대량 SELECT 감지 시 알림

회전 정책 (Rotation)

언제 새 코드를 발급하는가?

① 1개 코드 사용 후 (권장)
   → 즉시 새 8개 발급
   
② 코드 도용 의심 (긴급)
   → 기존 8개 즉시 무효화 + 새 8개 발급
   DELETE FROM recovery_codes WHERE username=?
   
③ 1년 경과 (선택)
   → 자동 갱신 또는 사용자 고지
   
④ 비밀번호 변경 후
   옵션 A (보수적): 새 8개 발급 (최고 보안)
   옵션 B (편의): 기존 코드 유지


실제 사례 분석

GitHub (공개 문서, 2013~현재)

✅ 복구코드: 16개 (과거 10개)
✅ 형식: 4-4-4-4 (16자)
✅ 저장: bcrypt 해싱
✅ 사용정책: 1회용
✅ 재발급: 보안 설정에서 수동
✅ 보호: 2FA 활성화 시 필수

특징:
- 사용자가 보고서 다운로드 지원 (종이/암호화 저장)
- 2FA + 복구코드 조합 (다층 방어)

Kraken (암호거래소)

✅ 복구코드: 10개 (각 8자)
✅ 발급: 2FA 설정 직후
✅ 저장: bcrypt 해싱 (추정)
✅ 사용 대상: OTP 앱 분실 시만

보안:
- 코드 1개 사용 → 재발급 강제
- IP 화이트리스트 + 복구코드 결합

Google (대규모 서비스)

❌ 복구코드 사용 안 함
✅ 대신: 이메일 + 전화 (다층)

이유:
- 복구코드 = 2FA 분실 시만 사용
- 비밀번호 리셋 = 이메일로 처리
- 이메일 있으면 복구코드 불필요


구현 체크리스트

계정 생성 시

  • 8개 복구코드 생성 (crypto.randomBytes(16))
  • 각각 bcrypt 해싱 (saltRounds=12)
  • 화면에 1회만 표시
  • PDF 다운로드 옵션
  • "복구코드를 잃어버리지 마세요" 경고

비밀번호 재설정

  • 복구코드 입력란 (로그인 페이지)
  • bcrypt.compare 검증
  • 실패 3회 → 5분 잠금
  • 타이밍 공격 방어 (더미해시)
  • 임시 토큰 5분 유효기한
  • HTTPS only

데이터베이스

  • recovery_codes 테이블
  • recovery_attempts 테이블
  • (username, used) 인덱스
  • 데이터베이스 암호화

한계 & 현실적 조언

요소위험도완벽 방어실전 조치
코드 노출🔴 높음❌ 불가능사용자 교육 + 암호화 관리자
무차별 공격🟡 중간✅ 가능레이트리밋 + 지수백오프
DB 유출🔴 높음✅ 가능bcrypt + DB 암호화
타이밍 공격🟢 낮음✅ 가능더미해시 비교
사용자 망각🔴 높음❌ 불가능안내 + 다운로드 + 인쇄

최종 결론

비밀번호 재설정 = 계정의 생명줄.

이메일/전화 없는 시스템에서는 복구코드가 필수.

5가지 원칙을 지키면 실전 수준 보안:

  1. 128비트 엔트로피 (충분한 복잡도)
  2. bcrypt 12라운드 + 개별 솔트 (강력한 해싱)
  3. used=true 플래그 (1회용 강제)
  4. 실패 잠금 + 타이밍 방어 (무차별 방지)
  5. HTTPS 암호화 (도청 방지)
이 5가지를 놓치면 보안이 무너진다.


관련 파일

  • /Users/ef/crowny-ai/server.js — engine/auth.js 구현
  • /Users/ef/crowny-ai/public/index.html — 로그인 UI
  • 데이터베이스: 계정 + 복구코드 테이블

잔여 이슈

  • recovery_codes/recovery_attempts 테이블 생성
  • 복구코드 생성 엔드포인트 (/api/recovery-codes/generate)
  • 복구코드 검증 엔드포인트 (/api/recovery-codes/verify)
  • UI: "비밀번호를 잊었나요?" 페이지
  • 문서: 사용자 가이드 (복구코드 저장/관리)