/** * #30 PIN 코드 잠금 화면 * * - 4자리 PIN 숫자 패드 * - PIN 저장: sha256(pin) → SecureStore 'grd_pin_hash' (평문 저장 금지) * - 검증: sha256(입력) === 저장 해시 * - 5회 실패 시 onFail() 호출 (세션 종료) * * 사용: * // PIN 신규 설정 (2회 확인) * // 잠금 해제 */ import { useState } from 'react' import { View, Text, TouchableOpacity, StyleSheet } from 'react-native' import * as SecureStore from 'expo-secure-store' import { sha256 } from '../services/sha256' import { COLORS } from '../constants/Config' const PIN_HASH_KEY = 'grd_pin_hash' const PIN_LENGTH = 4 const MAX_ATTEMPTS = 5 export const PIN_ENABLED_KEY = 'grd_pin_enabled' export async function isPinSet(): Promise { const h = await SecureStore.getItemAsync(PIN_HASH_KEY) return !!h } export async function savePin(pin: string): Promise { // 평문 저장 절대 금지 — SHA-256 해시만 저장 const hash = sha256(pin) await SecureStore.setItemAsync(PIN_HASH_KEY, hash) await SecureStore.setItemAsync(PIN_ENABLED_KEY, '1') } export async function verifyPin(pin: string): Promise { const stored = await SecureStore.getItemAsync(PIN_HASH_KEY) if (!stored) return false return sha256(pin) === stored } export async function clearPin(): Promise { await SecureStore.deleteItemAsync(PIN_HASH_KEY) await SecureStore.deleteItemAsync(PIN_ENABLED_KEY) } export async function isPinEnabled(): Promise { const v = await SecureStore.getItemAsync(PIN_ENABLED_KEY) return v === '1' && (await isPinSet()) } type Mode = 'set' | 'verify' interface Props { mode: Mode onSuccess: () => void onFail?: () => void onCancel?: () => void } export default function PinLock({ mode, onSuccess, onFail, onCancel }: Props) { const [pin, setPin] = useState('') const [firstPin, setFirstPin] = useState(null) // set 모드 확인용 const [attempts, setAttempts] = useState(0) const [error, setError] = useState('') const title = mode === 'set' ? firstPin == null ? 'PIN 설정' : 'PIN 재입력' : 'PIN 입력' const subtitle = mode === 'set' ? firstPin == null ? '4자리 PIN을 설정하세요' : '확인을 위해 다시 입력하세요' : '잠금을 해제하려면 PIN을 입력하세요' const handleDigit = async (d: string) => { if (pin.length >= PIN_LENGTH) return setError('') const next = pin + d setPin(next) if (next.length === PIN_LENGTH) { await submit(next) } } const handleDelete = () => { setError('') setPin((p) => p.slice(0, -1)) } const submit = async (entered: string) => { if (mode === 'set') { if (firstPin == null) { setFirstPin(entered) setPin('') return } if (firstPin === entered) { await savePin(entered) onSuccess() } else { setError('PIN이 일치하지 않습니다. 다시 설정하세요.') setFirstPin(null) setPin('') } return } // verify const ok = await verifyPin(entered) if (ok) { setAttempts(0) onSuccess() } else { const a = attempts + 1 setAttempts(a) setPin('') if (a >= MAX_ATTEMPTS) { setError('5회 실패 — 보안을 위해 로그아웃됩니다.') onFail?.() } else { setError(`PIN이 올바르지 않습니다. (${a}/${MAX_ATTEMPTS})`) } } } const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'del'] return ( 🛡️ {title} {subtitle} {Array.from({ length: PIN_LENGTH }).map((_, i) => ( ))} {!!error && {error}} {keys.map((k, i) => k === '' ? ( ) : k === 'del' ? ( ) : ( handleDigit(k)}> {k} ) )} {onCancel && ( 취소 )} ) } const s = StyleSheet.create({ container: { flex: 1, backgroundColor: COLORS.gnbBg, alignItems: 'center', justifyContent: 'center', padding: 28, }, logo: { fontSize: 44, marginBottom: 12 }, title: { fontSize: 22, fontWeight: '800', color: '#fff', marginBottom: 6 }, subtitle: { fontSize: 13, color: 'rgba(255,255,255,.6)', marginBottom: 28 }, dots: { flexDirection: 'row', gap: 18, marginBottom: 18 }, dot: { width: 16, height: 16, borderRadius: 8, borderWidth: 2, borderColor: COLORS.accent, backgroundColor: 'transparent', }, dotFilled: { backgroundColor: COLORS.accent }, error: { color: '#fca5a5', fontSize: 13, marginBottom: 12, textAlign: 'center' }, pad: { flexDirection: 'row', flexWrap: 'wrap', width: 264, justifyContent: 'space-between' }, key: { width: 78, height: 78, borderRadius: 39, alignItems: 'center', justifyContent: 'center', marginVertical: 6, }, keyText: { fontSize: 28, color: '#fff', fontWeight: '600' }, keyDel: { fontSize: 26, color: 'rgba(255,255,255,.7)' }, cancel: { marginTop: 24 }, cancelText: { color: COLORS.accent, fontSize: 15, fontWeight: '600' }, })