From 39d81f1a73a833db92991bf0f555a14148e98dd5 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:26:02 +0900 Subject: [PATCH] =?UTF-8?q?M0:=20=EC=95=88=EC=A0=84=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EB=AA=BB=20=EC=9D=BD=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=9E=A0=EA=B8=88=EC=9D=84=20'?= =?UTF-8?q?=EC=95=8C=20=EC=88=98=20=EC=97=86=EC=9D=8C'=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20-=20=EB=AC=B8=EC=9D=84=20=EC=97=B4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EB=A7=88=EC=8A=A4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=8D=AE=EC=96=B4=EC=93=B0=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 보게 될 동작: 기기 안전 저장소를 읽지 못하면 저장된 비밀번호를 쓰는 기능이 열리지 않고, 환경 설정 > 보안에 까닭과 [다시 읽기] 가 보인다. 다시 읽히면 평소대로 마스터를 묻는다. - 예전: 읽지 못하면 빈 것으로 보아 마스터가 없는 것처럼 문 (SecretGate) 이 열렸고, 그 상태에서 마스터를 정하면 진짜 값을 덮어쓸 수 있었다 - MasterLock.storeUnreadable: load 가 예외 대신 표시 (앱은 켜짐). ensure 는 한 번 다시 읽고 못 읽으면 false. setMaster · setSuper · clearMaster · clearSuper · setAsk 는 MasterStoreUnreadable. reload() - 보안 설정: 까닭 · [다시 읽기] (정하기 · 바꾸기 · 끄기 숨김). 보관함 [마스터로도 열기] 는 까닭을 알림 - 비밀번호 다시 저장 [다시 시도] 가 되면 마스터 잠금도 다시 읽음. settings_store 의 TODO(M0) 지움 - 약속 시험 master_store_unreadable_test 4 (읽기가 실제로 실패하는 저장소를 시험 파일 안에 - lib 흉내 플래그 쓰지 않음) - 확인: 단위 1467 ×2 · 통합 6/6 ×2 · 캡처 214장 base10 과 같음 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 4 ++ assets/l10n/ja.json | 4 ++ assets/l10n/zh-Hans.json | 4 ++ lib/app/app_controller.dart | 3 + lib/app/master_lock.dart | 39 +++++++++++- lib/app/settings/settings_store.dart | 4 +- lib/ui/security_settings.dart | 14 +++++ lib/ui/vault/vault_actions.dart | 5 ++ test/master_store_unreadable_test.dart | 82 ++++++++++++++++++++++++++ 9 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 test/master_store_unreadable_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 034a7de..24551ee 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1062,6 +1062,7 @@ "다시 시작": "Restart", "다시 쓰려면 다시 받아야 합니다.": "You'll need to download it again to use it.", "다시 열기": "Open again", + "다시 읽기": "Read again", "다시 찾기": "Search again", "다시 켤 때 동기화": "Sync on restart", "다시 확인": "Check again", @@ -1245,6 +1246,7 @@ "마그넷 · 토렌트가 시작되지 않으면 알리기": "Notify when a magnet / torrent doesn't start", "마스터 · 최상 비밀번호": "Master and super passwords", "마스터 비밀번호": "Master password", + "마스터 비밀번호 - 안전 저장소를 읽지 못함": "Master password - cannot read secure storage", "마스터 비밀번호 넣기": "Enter master password", "마스터 비밀번호 정하기": "Set master password", "마스터 비밀번호가 필요합니다": "The master password is needed", @@ -2237,6 +2239,8 @@ "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "This device's CPU does not support this AI engine", "이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.": "Couldn't find this device's Wi-Fi address. Check that it is connected to Wi-Fi.", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable", + "이 기기의 안전 저장소를 읽지 못해 마스터 비밀번호가 있는지 알 수 없습니다. 그동안 저장된 비밀번호를 쓰는 기능은 열지 않고, 마스터 · 최상 비밀번호는 바꿀 수 없습니다.": "This device's secure storage could not be read, so it is unknown whether a master password is set. Until then, features that use saved passwords stay locked and the master and super passwords cannot be changed.", + "이 기기의 안전 저장소를 읽지 못해 지금은 켤 수 없습니다 (환경 설정 > 보안 [다시 읽기])": "Cannot turn on now because this device's secure storage could not be read (Settings > Security [Read again])", "이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "The Tor screen cannot be opened with this device's web view", "이 기기의 폴더": "Folder on this device", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 3d69a59..cc21c8f 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1062,6 +1062,7 @@ "다시 시작": "再起動", "다시 쓰려면 다시 받아야 합니다.": "再び使うには再ダウンロードが必要です。", "다시 열기": "もう一度開く", + "다시 읽기": "もう一度読む", "다시 찾기": "再検索", "다시 켤 때 동기화": "再起動時の同期", "다시 확인": "再確認", @@ -1245,6 +1246,7 @@ "마그넷 · 토렌트가 시작되지 않으면 알리기": "マグネット・トレントが始まらないときに知らせる", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 비밀번호": "マスターパスワード", + "마스터 비밀번호 - 안전 저장소를 읽지 못함": "マスターパスワード - 安全な保存領域を読めません", "마스터 비밀번호 넣기": "マスターパスワードを入力", "마스터 비밀번호 정하기": "マスターパスワードを設定", "마스터 비밀번호가 필요합니다": "マスターパスワードが必要です", @@ -2237,6 +2239,8 @@ "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "この端末の CPU はこの AI エンジンに対応していません", "이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.": "この端末の Wi-Fi アドレスが見つかりません。Wi-Fi に接続しているか確認してください。", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした", + "이 기기의 안전 저장소를 읽지 못해 마스터 비밀번호가 있는지 알 수 없습니다. 그동안 저장된 비밀번호를 쓰는 기능은 열지 않고, 마스터 · 최상 비밀번호는 바꿀 수 없습니다.": "この端末の安全な保存領域を読めないため、マスターパスワードがあるか分かりません。その間、保存したパスワードを使う機能は開かず、マスター · 最上位パスワードは変更できません。", + "이 기기의 안전 저장소를 읽지 못해 지금은 켤 수 없습니다 (환경 설정 > 보안 [다시 읽기])": "この端末の安全な保存領域を読めないため、今はオンにできません (設定 > セキュリティ [もう一度読む])", "이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "この端末の Web ビューでは Tor 画面を開けません", "이 기기의 폴더": "この端末のフォルダー", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index e5ab4b9..2946ffe 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1062,6 +1062,7 @@ "다시 시작": "重新启动", "다시 쓰려면 다시 받아야 합니다.": "再次使用需要重新下载。", "다시 열기": "再次打开", + "다시 읽기": "重新读取", "다시 찾기": "重新查找", "다시 켤 때 동기화": "重新启动时同步", "다시 확인": "重新检查", @@ -1245,6 +1246,7 @@ "마그넷 · 토렌트가 시작되지 않으면 알리기": "磁力链接 · 种子无法开始时提醒", "마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 비밀번호": "主密码", + "마스터 비밀번호 - 안전 저장소를 읽지 못함": "主密码 - 无法读取安全存储", "마스터 비밀번호 넣기": "输入主密码", "마스터 비밀번호 정하기": "设置主密码", "마스터 비밀번호가 필요합니다": "需要主密码", @@ -2237,6 +2239,8 @@ "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "此设备的 CPU 不支持该 AI 引擎", "이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.": "找不到本设备的 Wi-Fi 地址。请检查是否已连接 Wi-Fi。", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码", + "이 기기의 안전 저장소를 읽지 못해 마스터 비밀번호가 있는지 알 수 없습니다. 그동안 저장된 비밀번호를 쓰는 기능은 열지 않고, 마스터 · 최상 비밀번호는 바꿀 수 없습니다.": "无法读取此设备的安全存储,因此不知道是否设置了主密码。在此期间,使用已保存密码的功能保持锁定,主密码和最高密码无法更改。", + "이 기기의 안전 저장소를 읽지 못해 지금은 켤 수 없습니다 (환경 설정 > 보안 [다시 읽기])": "无法读取此设备的安全存储,暂时无法开启 (设置 > 安全 [重新读取])", "이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "此设备的网页视图无法打开 Tor 画面", "이 기기의 폴더": "本设备上的文件夹", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 9f6e164..a927727 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -19,6 +19,7 @@ import '../services/platform_services.dart'; import '../services/system_usage.dart'; import 'settings.dart'; import 'cleanup.dart' show removeUpdateLeftovers; +import 'master_lock.dart' show MasterLock; import '../l10n/tr.dart'; import 'controller/ai_options.dart'; import 'controller/ai_subtitle_runner.dart'; @@ -91,6 +92,8 @@ class AppController extends ChangeNotifier { final store = settingsStore; if (store == null) return true; final ok = await store.retrySecrets(settings); + // M0: 저장소가 다시 읽히면 마스터 잠금도 다시 읽는다 (알 수 없음 → 평소대로) + if (ok) await MasterLock.instance?.reload(); _applySettings(); notifyListeners(); return ok; diff --git a/lib/app/master_lock.dart b/lib/app/master_lock.dart index 0d9608f..5a0a520 100644 --- a/lib/app/master_lock.dart +++ b/lib/app/master_lock.dart @@ -161,6 +161,13 @@ class PasswordHash { /// 마스터 입력 창에 넣은 값이 무엇이었는지 enum MasterInput { master, superPassword, wrong, wait } +/// M0: 안전 저장소를 읽지 못해 마스터 · 최상 비밀번호 상태를 알 수 없을 때 바꾸려 함 (진짜 값을 덮어쓰지 않게 막는다) +class MasterStoreUnreadable implements Exception { + const MasterStoreUnreadable(); + @override + String toString() => 'MasterStoreUnreadable'; +} + /// 124: 비밀번호 세 가지 중 ① 최상 (모두 초기화하는 열쇠) · ② 마스터 (저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때). /// 둘 다 사용자가 정하고, 솔트를 넣은 느린 해시로 안전 저장소에만 둔다. ③ 실제 비밀번호는 [SettingsStore] 가 다룬다. /// @@ -192,6 +199,10 @@ class MasterLock extends ChangeNotifier { DateTime? waitUntil; bool get hasMaster => _master != null; + + /// M0 (보안): 안전 저장소를 읽지 못했다 - 마스터가 있는지 알 수 없다. 그동안 [ensure] 는 문을 열지 않고 (한 번 다시 읽어 본다), + /// 마스터 · 최상 · 묻는 때를 바꾸지 않는다 ([MasterStoreUnreadable]). 예전에는 빈 것으로 보아 마스터가 없는 것처럼 문이 열렸다 + bool storeUnreadable = false; bool get hasSuper => _super != null; /// 'never' 물어보지 않기 · 'startup' 처음 시작 시 · 'onUse' 비밀번호가 저장된 기능을 쓸 때. @@ -204,13 +215,23 @@ class MasterLock extends ChangeNotifier { Future setAsk(String value) async { if (!askValues.contains(value)) return; + _writable(); _askStored = value; await secrets.write(askKey, value); notifyListeners(); } + /// 안전 저장소에서 읽는다. 읽지 못하면 [storeUnreadable] (예외를 내지 않음 - 앱은 켜지고 문은 닫힌다) Future load() async { - final all = await secrets.readAll(); + final Map all; + try { + all = await secrets.readAll(); + } catch (_) { + storeUnreadable = true; + notifyListeners(); + return; + } + storeUnreadable = false; _master = all[masterKey]; _super = all[superKey]; _askStored = all[askKey]; @@ -221,6 +242,16 @@ class MasterLock extends ChangeNotifier { notifyListeners(); } + /// 다시 읽는다 (환경 설정 [다시 읽기] · 비밀번호 다시 저장이 된 뒤). 읽었으면 true + Future reload() async { + await load(); + return !storeUnreadable; + } + + void _writable() { + if (storeUnreadable) throw const MasterStoreUnreadable(); + } + /// 지금 기다려야 하는 시간 (없으면 null) Duration? get waiting { final w = waitUntil; @@ -266,6 +297,7 @@ class MasterLock extends ChangeNotifier { /// 마스터를 정한다 · 바꾼다. 최상과 같으면 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); @@ -277,6 +309,7 @@ class MasterLock extends ChangeNotifier { /// 최상을 정한다 · 바꾼다. 마스터와 같으면 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); @@ -289,6 +322,7 @@ class MasterLock extends ChangeNotifier { static const vaultCopyKey = 'vault.masterCopy'; Future clearMaster() async { + _writable(); _master = null; _askStored = null; await secrets.delete(masterKey); @@ -301,6 +335,7 @@ class MasterLock extends ChangeNotifier { } Future clearSuper() async { + _writable(); _super = null; await secrets.delete(superKey); notifyListeners(); @@ -321,7 +356,9 @@ class MasterLock extends ChangeNotifier { /// 비밀번호가 저장된 기능을 쓰기 전에: 필요하면 마스터를 묻는다. 써도 되면 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; diff --git a/lib/app/settings/settings_store.dart b/lib/app/settings/settings_store.dart index 5a1cde0..282d34a 100644 --- a/lib/app/settings/settings_store.dart +++ b/lib/app/settings/settings_store.dart @@ -142,8 +142,8 @@ class SettingsStore { stored = await secrets.readAll(); _secretsOk = true; } catch (_) { - // TODO(M0): 비밀 저장소를 못 읽으면 빈 것으로 보고 이어 간다 - 마스터가 없는 것으로 보여 문이 열린다 (보안). - // 설정 나누기 (A) 뒤 따로 고친다 (관리자 10/22). 나누기 동안 동작은 그대로 + // 비밀 저장소를 못 읽으면 빈 것으로 보고 이어 간다 (앱은 켜지고 위 알림 [다시 시도]). 마스터 잠금은 따로 '알 수 없음' 으로 두어 + // 문을 열지 않는다 (M0 - MasterLock.storeUnreadable) stored = {}; _secretsOk = false; } diff --git a/lib/ui/security_settings.dart b/lib/ui/security_settings.dart index 296704b..051b6a2 100644 --- a/lib/ui/security_settings.dart +++ b/lib/ui/security_settings.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../app/master_lock.dart'; import '../l10n/tr.dart'; +import 'theme.dart'; import 'confirm.dart'; import 'master_prompt.dart'; import 'setting_tile.dart'; @@ -24,6 +25,19 @@ class SecuritySettings extends StatelessWidget { // 바꾸기 · 끄기 전에 지금 비밀번호 (마스터 또는 최상) Future current() => askMaster(context, c, lock, forChange: true, reason: tr('바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.')); + // M0: 안전 저장소를 읽지 못함 - 마스터가 있는지 알 수 없어 바꾸지 못하게 (진짜 값을 덮어쓰지 않게) 하고 까닭 · [다시 읽기] + if (lock.storeUnreadable) { + return SettingTile( + leading: Icon(Icons.lock_clock_outlined, color: JjColors.warning), + title: Text(tr('마스터 비밀번호 - 안전 저장소를 읽지 못함')), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('이 기기의 안전 저장소를 읽지 못해 마스터 비밀번호가 있는지 알 수 없습니다. ' + '그동안 저장된 비밀번호를 쓰는 기능은 열지 않고, 마스터 · 최상 비밀번호는 바꿀 수 없습니다.')), + const SizedBox(height: 6), + FilledButton.tonal(onPressed: () => lock.reload(), child: Text(tr('다시 읽기'))), + ]), + ); + } return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SettingTile( leading: const Icon(Icons.lock_outline), diff --git a/lib/ui/vault/vault_actions.dart b/lib/ui/vault/vault_actions.dart index 802f5b1..0d45845 100644 --- a/lib/ui/vault/vault_actions.dart +++ b/lib/ui/vault/vault_actions.dart @@ -314,6 +314,11 @@ Future vaultToggleMasterCopy(BuildContext context, AppController c, VaultU final lock = MasterLock.instance; if (lock == null) return; if (on) { + // M0: 안전 저장소를 읽지 못하면 마스터가 있는지 알 수 없다 - 정하라고 하지 않고 까닭을 + if (lock.storeUnreadable) { + s.snack(tr('이 기기의 안전 저장소를 읽지 못해 지금은 켤 수 없습니다 (환경 설정 > 보안 [다시 읽기])')); + return; + } if (!lock.hasMaster) { s.snack(tr('먼저 환경 설정 > 보안 에서 마스터 비밀번호를 정하세요')); return; diff --git a/test/master_store_unreadable_test.dart b/test/master_store_unreadable_test.dart new file mode 100644 index 0000000..6ec1229 --- /dev/null +++ b/test/master_store_unreadable_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/master_lock.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; + +/// 읽기가 될 때와 안 될 때를 바꿀 수 있는 안전 저장소 (기기 저장소가 잠깐 잠긴 때처럼) +class _FlakyStore implements SecretStore { + final Map values = {}; + bool readable = true; + @override + String? lostCopy; + @override + Future> readAll() async { + if (!readable) throw StateError('안전 저장소를 읽을 수 없음'); + return Map.of(values); + } + + @override + Future write(String key, String value) async => values[key] = value; + @override + Future delete(String key) async => values.remove(key); +} + +/// M0 약속: 안전 저장소를 읽지 못하면 마스터 잠금은 '알 수 없음' - 문을 열지 않고 (한 번 다시 읽어 봄), 마스터 · 최상 · 묻는 때를 바꾸지 않는다. +/// 다시 읽히면 평소대로 (마스터가 있으면 묻는다) +void main() { + late _FlakyStore store; + late int asked; + setUp(() async { + store = _FlakyStore(); + // 마스터가 정해진 기기 + final setup = MasterLock(store); + await setup.load(); + await setup.setMaster('right-pass'); + asked = 0; + MasterLock.prompt = (_) async { + asked++; + return true; + }; + }); + tearDown(() => MasterLock.prompt = null); + + test('읽지 못하면 문을 열지 않는다 (예전: 마스터가 없는 것으로 보여 열렸다)', () async { + store.readable = false; + final lock = MasterLock(store); + await lock.load(); // 예외를 내지 않는다 - 앱은 켜진다 + expect([lock.storeUnreadable, lock.hasMaster], [true, false]); + expect(await lock.ensure(), isFalse); + expect(await lock.ensure(force: true), isFalse, reason: '사용자가 직접 눌러도 열지 않는다'); + expect(asked, 0, reason: '알 수 없는 마스터를 묻지도 않는다'); + }); + + test('알 수 없는 동안 마스터 · 최상 · 묻는 때를 바꾸지 않는다 (진짜 마스터를 덮어쓰지 않게)', () async { + final before = Map.of(store.values); + store.readable = false; + final lock = MasterLock(store); + await lock.load(); + await expectLater(lock.setMaster('new-pass'), throwsA(isA())); + await expectLater(lock.setSuper('s'), throwsA(isA())); + await expectLater(lock.clearMaster(), throwsA(isA())); + await expectLater(lock.setAsk('never'), throwsA(isA())); + expect(store.values, before); + }); + + test('다시 읽히면 평소대로: 문을 열 때 한 번 다시 읽고, 마스터가 있으면 묻는다', () async { + store.readable = false; + final lock = MasterLock(store); + await lock.load(); + store.readable = true; + expect(await lock.ensure(), isTrue); + expect([lock.storeUnreadable, lock.hasMaster, asked], [false, true, 1]); + }); + + test('reload: 읽혔는지 돌려준다', () async { + store.readable = false; + final lock = MasterLock(store); + await lock.load(); + expect(await lock.reload(), isFalse); + store.readable = true; + expect(await lock.reload(), isTrue); + expect(lock.hasMaster, isTrue); + }); +}