import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import '../services/secret_store.dart'; /// PBKDF2-HMAC-SHA256 ([length] 바이트). /// 반복마다 HMAC 의 안 · 바깥 열쇠 블록을 다시 계산하지 않도록 SHA-256 을 직접 돌린다 (폰에서도 빠르게). Uint8List pbkdf2Sha256(List password, List salt, int iterations, int length) { var key = password; if (key.length > 64) key = sha256.convert(key).bytes; final ipad = Uint8List(64), opad = Uint8List(64); for (var i = 0; i < 64; i++) { final k = i < key.length ? key[i] : 0; ipad[i] = k ^ 0x36; opad[i] = k ^ 0x5c; } // 안 · 바깥 열쇠 블록을 한 번 넣은 상태 final inner = Uint32List.fromList(_h0), outer = Uint32List.fromList(_h0); final w = Uint32List(64); _compress(inner, ipad, 0, w); _compress(outer, opad, 0, w); // 32 바이트 메시지 하나 (앞 블록 64 바이트 다음) 의 HMAC: 패딩까지 넣은 한 블록 final block = Uint8List(64); block[32] = 0x80; block[62] = 0x03; // (64 + 32) * 8 = 768 비트 = 0x300 final st = Uint32List(8); Uint8List hmac32(Uint8List msg) { block.setRange(0, 32, msg); st.setAll(0, inner); _compress(st, block, 0, w); _store(st, block); st.setAll(0, outer); _compress(st, block, 0, w); final out = Uint8List(32); _store(st, out); return out; } final hmac = Hmac(sha256, password); final out = BytesBuilder(); for (var n = 1; out.length < length; n++) { var u = Uint8List.fromList(hmac.convert([...salt, n >> 24 & 0xff, n >> 16 & 0xff, n >> 8 & 0xff, n & 0xff]).bytes); final t = Uint8List.fromList(u); for (var i = 1; i < iterations; i++) { u = hmac32(u); for (var k = 0; k < 32; k++) { t[k] ^= u[k]; } } out.add(t); } return Uint8List.fromList(out.toBytes().sublist(0, length)); } const _h0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; const _k = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, // 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]; int _rotr(int x, int n) => ((x >> n) | (x << (32 - n))) & 0xffffffff; /// SHA-256 블록 하나 ([data] 의 [off] 부터 64 바이트) 를 [h] 에 더한다 void _compress(Uint32List h, Uint8List data, int off, Uint32List w) { for (var i = 0; i < 16; i++) { final j = off + i * 4; w[i] = data[j] << 24 | data[j + 1] << 16 | data[j + 2] << 8 | data[j + 3]; } for (var i = 16; i < 64; i++) { final a = w[i - 15], b = w[i - 2]; final s0 = _rotr(a, 7) ^ _rotr(a, 18) ^ (a >> 3); final s1 = _rotr(b, 17) ^ _rotr(b, 19) ^ (b >> 10); w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & 0xffffffff; } var a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7]; for (var i = 0; i < 64; i++) { final s1 = _rotr(e, 6) ^ _rotr(e, 11) ^ _rotr(e, 25); final ch = (e & f) ^ (~e & 0xffffffff & g); final t1 = (hh + s1 + ch + _k[i] + w[i]) & 0xffffffff; final s0 = _rotr(a, 2) ^ _rotr(a, 13) ^ _rotr(a, 22); final maj = (a & b) ^ (a & c) ^ (b & c); final t2 = (s0 + maj) & 0xffffffff; hh = g; g = f; f = e; e = (d + t1) & 0xffffffff; d = c; c = b; b = a; a = (t1 + t2) & 0xffffffff; } h[0] = (h[0] + a) & 0xffffffff; h[1] = (h[1] + b) & 0xffffffff; h[2] = (h[2] + c) & 0xffffffff; h[3] = (h[3] + d) & 0xffffffff; h[4] = (h[4] + e) & 0xffffffff; h[5] = (h[5] + f) & 0xffffffff; h[6] = (h[6] + g) & 0xffffffff; h[7] = (h[7] + hh) & 0xffffffff; } void _store(Uint32List h, Uint8List out) { for (var i = 0; i < 8; i++) { out[i * 4] = h[i] >> 24 & 0xff; out[i * 4 + 1] = h[i] >> 16 & 0xff; out[i * 4 + 2] = h[i] >> 8 & 0xff; out[i * 4 + 3] = h[i] & 0xff; } } /// 비밀번호 해시 ("pbkdf2-sha256$반복$솔트$해시", 솔트 · 해시는 base64). 원문은 남기지 않는다. class PasswordHash { static const scheme = 'pbkdf2-sha256'; static const defaultIterations = 100000; static String make(String password, {List? salt, int iterations = defaultIterations}) { final s = salt ?? List.generate(16, (_) => Random.secure().nextInt(256)); final h = pbkdf2Sha256(utf8.encode(password), s, iterations, 32); return '$scheme\$$iterations\$${base64Encode(s)}\$${base64Encode(h)}'; } static bool verify(String password, String stored) { final parts = stored.split('\$'); if (parts.length != 4 || parts[0] != scheme) return false; final n = int.tryParse(parts[1]); if (n == null || n < 1) return false; final List salt, want; try { salt = base64Decode(parts[2]); want = base64Decode(parts[3]); } catch (_) { return false; } final got = pbkdf2Sha256(utf8.encode(password), salt, n, want.length); // 걸린 시간으로 맞은 글자 수가 드러나지 않게 끝까지 비교 var diff = got.length ^ want.length; for (var i = 0; i < min(got.length, want.length); i++) { diff |= got[i] ^ want[i]; } return diff == 0; } /// 화면이 멈추지 않게 따로 돌린다 static Future makeAsync(String password) => compute(_make, password); static Future verifyAsync(String password, String stored) => compute(_verify, [password, stored]); static String _make(String p) => make(p); static bool _verify(List a) => verify(a[0], a[1]); } /// 마스터 입력 창에 넣은 값이 무엇이었는지 enum MasterInput { master, superPassword, wrong, wait } /// M0: 안전 저장소를 읽지 못해 마스터 · 최상 비밀번호 상태를 알 수 없을 때 바꾸려 함 (진짜 값을 덮어쓰지 않게 막는다) class MasterStoreUnreadable implements Exception { const MasterStoreUnreadable(); @override String toString() => 'MasterStoreUnreadable'; } /// 124: 비밀번호 세 가지 중 ① 최상 (모두 초기화하는 열쇠) · ② 마스터 (저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때). /// 둘 다 사용자가 정하고, 솔트를 넣은 느린 해시로 안전 저장소에만 둔다. ③ 실제 비밀번호는 [SettingsStore] 가 다룬다. /// /// 마스터는 기능을 여는 자물쇠다 (실제 비밀번호를 마스터로 암호화하지는 않는다 - 안전 저장소가 기기 열쇠로 이미 암호화). /// 한 번 풀면 앱이 켜져 있는 동안 풀린 채로 둔다 (완전히 끄면 다시 묻는다). class MasterLock extends ChangeNotifier { MasterLock(this.secrets, {this.now = DateTime.now}); final SecretStore secrets; final DateTime Function() now; static const masterKey = 'sec.master', superKey = 'sec.super', failKey = 'sec.fails', askKey = 'sec.ask'; static const askValues = ['never', 'startup', 'onUse']; /// 앱에서 쓰는 것 (main 이 정한다) static MasterLock? instance; /// 마스터를 묻는 창 (main 이 화면에 띄우는 함수를 넣는다, [reason] 은 창에 보일 까닭). 풀렸으면 true static Future Function(String? reason)? prompt; String? _master, _super, _askStored; bool unlocked = false; /// 128: 이번 실행에서 마스터 창을 취소했다 - 자동 · 배경 작업은 다시 묻지 않고 건너뛴다 (사용자가 직접 누른 곳만 다시 묻는다) bool declined = false; /// 연달아 틀린 수 · 이때까지 기다림 (앱을 껐다 켜도 남게 안전 저장소에) int fails = 0; DateTime? waitUntil; bool get hasMaster => _master != null; /// M0 (보안): 안전 저장소를 읽지 못했다 - 마스터가 있는지 알 수 없다. 그동안 [ensure] 는 문을 열지 않고 (한 번 다시 읽어 본다), /// 마스터 · 최상 · 묻는 때를 바꾸지 않는다 ([MasterStoreUnreadable]). 예전에는 빈 것으로 보아 마스터가 없는 것처럼 문이 열렸다 bool storeUnreadable = false; bool get hasSuper => _super != null; /// 'never' 물어보지 않기 · 'startup' 처음 시작 시 · 'onUse' 비밀번호가 저장된 기능을 쓸 때. /// 129: 평문 설정 파일이 아니라 안전 저장소에 둔다. 마스터가 있는데 값이 없거나 틀리면 'onUse' (자물쇠가 말없이 꺼지지 않게) String get ask { if (!hasMaster) return 'never'; final a = _askStored; return a != null && askValues.contains(a) ? a : 'onUse'; } Future setAsk(String value) async { if (!askValues.contains(value)) return; _writable(); _askStored = value; await secrets.write(askKey, value); notifyListeners(); } /// 안전 저장소에서 읽는다. 읽지 못하면 [storeUnreadable] (예외를 내지 않음 - 앱은 켜지고 문은 닫힌다) Future load() async { final Map all; try { all = await secrets.readAll(); } catch (_) { storeUnreadable = true; notifyListeners(); return; } storeUnreadable = false; _master = all[masterKey]; _super = all[superKey]; _askStored = all[askKey]; final f = (all[failKey] ?? '').split('|'); fails = int.tryParse(f.first) ?? 0; final until = f.length > 1 ? int.tryParse(f[1]) : null; waitUntil = until == null || until == 0 ? null : DateTime.fromMillisecondsSinceEpoch(until); notifyListeners(); } /// 다시 읽는다 (환경 설정 [다시 읽기] · 비밀번호 다시 저장이 된 뒤). 읽었으면 true Future reload() async { await load(); return !storeUnreadable; } void _writable() { if (storeUnreadable) throw const MasterStoreUnreadable(); } /// 지금 기다려야 하는 시간 (없으면 null) Duration? get waiting { final w = waitUntil; if (w == null) return null; final left = w.difference(now()); return left.isNegative ? null : left; } /// 5번째 틀림부터 30초 · 1분 · 2분 … (최대 15분) 기다리게 한다 static Duration waitFor(int fails) { if (fails < 5) return Duration.zero; final s = 30 * (1 << min(fails - 5, 5)); return Duration(seconds: min(s, 15 * 60)); } Future _saveFails() => secrets.write(failKey, '$fails|${waitUntil?.millisecondsSinceEpoch ?? 0}'); /// 마스터 입력 창의 값을 확인한다 (최상을 넣으면 [MasterInput.superPassword] - 초기화 창으로) Future check(String input) async { if (waiting != null) return MasterInput.wait; final m = _master, s = _super; MasterInput r = MasterInput.wrong; if (m != null && await PasswordHash.verifyAsync(input, m)) { r = MasterInput.master; } else if (s != null && await PasswordHash.verifyAsync(input, s)) { r = MasterInput.superPassword; } if (r == MasterInput.master) declined = false; if (r == MasterInput.wrong) { fails++; final w = waitFor(fails); waitUntil = w == Duration.zero ? null : now().add(w); } else { fails = 0; waitUntil = null; if (r == MasterInput.master) unlocked = true; } await _saveFails(); notifyListeners(); return r; } /// 마스터를 정한다 · 바꾼다. 최상과 같으면 false (정하지 않음) Future setMaster(String password) async { _writable(); final s = _super; if (s != null && await PasswordHash.verifyAsync(password, s)) return false; _master = await PasswordHash.makeAsync(password); await secrets.write(masterKey, _master!); unlocked = true; notifyListeners(); return true; } /// 최상을 정한다 · 바꾼다. 마스터와 같으면 false Future setSuper(String password) async { _writable(); final m = _master; if (m != null && await PasswordHash.verifyAsync(password, m)) return false; _super = await PasswordHash.makeAsync(password); await secrets.write(superKey, _super!); notifyListeners(); return true; } /// X-plore 비교 5-2 암호 보관함 [마스터로도 열기] 가 안전 저장소에 둔 보관함 열쇠 한 벌 (마스터를 초기화하면 이 길만 없앤다 - 관리자 결정 10/21) static const vaultCopyKey = 'vault.masterCopy'; Future clearMaster() async { _writable(); _master = null; _askStored = null; await secrets.delete(masterKey); await secrets.delete(askKey); // 마스터가 없어지면 [마스터로도 열기] 길도 없앤다 (보관함 비밀번호로는 그대로 열림) await secrets.delete(vaultCopyKey); unlocked = true; declined = false; notifyListeners(); } Future clearSuper() async { _writable(); _super = null; await secrets.delete(superKey); notifyListeners(); } /// 마스터를 물어야 하는지 ([startup]: 앱을 켤 때 묻는 자리인지) bool needsPrompt({bool startup = false}) { if (!hasMaster || unlocked) return false; return switch (ask) { 'never' => false, 'startup' => true, // 켤 때 묻고, 그때 취소했으면 쓸 때 다시 묻는다 _ => !startup, }; } Future? _pending; /// 비밀번호가 저장된 기능을 쓰기 전에: 필요하면 마스터를 묻는다. 써도 되면 true. /// - 시작할 때 · 설정 화면 · 배경 작업이 동시에 불러도 창은 하나 (128). /// - 한 번 취소하면 이번 실행 동안 자동 · 배경 호출은 묻지 않고 false. [force]: 사용자가 직접 누른 곳 (다시 묻는다) /// - M0: 안전 저장소를 읽지 못했으면 한 번 다시 읽어 보고, 그래도 못 읽으면 열지 않는다 (false) Future ensure({bool force = false, String? reason}) { if (storeUnreadable) return reload().then((ok) => ok ? ensure(force: force, reason: reason) : false); if (!needsPrompt()) return Future.value(true); final pending = _pending; if (pending != null) return pending; if (declined && !force) return Future.value(false); final p = prompt; if (p == null) return Future.value(false); return _pending = p(reason).then((ok) { declined = !ok && !unlocked; return ok; }).whenComplete(() => _pending = null); } /// [instance] 가 없으면 (시험 · 마스터 기능을 쓰지 않음) 늘 true static Future gate() => instance?.ensure() ?? Future.value(true); }