크라우니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가지 원칙을 지키면 실전 수준 보안:
- 128비트 엔트로피 (충분한 복잡도)
- bcrypt 12라운드 + 개별 솔트 (강력한 해싱)
- used=true 플래그 (1회용 강제)
- 실패 잠금 + 타이밍 방어 (무차별 방지)
- HTTPS 암호화 (도청 방지)
관련 파일
/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: "비밀번호를 잊었나요?" 페이지
- 문서: 사용자 가이드 (복구코드 저장/관리)