M0: 안전 저장소를 못 읽으면 마스터 잠금을 '알 수 없음' 으로 - 문을 열지 않고 마스터를 덮어쓰지 않는다

사용자가 보게 될 동작: 기기 안전 저장소를 읽지 못하면 저장된 비밀번호를 쓰는 기능이 열리지 않고, 환경 설정 > 보안에
까닭과 [다시 읽기] 가 보인다. 다시 읽히면 평소대로 마스터를 묻는다.

- 예전: 읽지 못하면 빈 것으로 보아 마스터가 없는 것처럼 문 (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 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 19:26:02 +09:00
parent 6f9d8f6284
commit 39d81f1a73
9 changed files with 156 additions and 3 deletions

View File

@ -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)",

View File

@ -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 画面を開けません",
"이 기기의 폴더": "この端末のフォルダー",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)",

View File

@ -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 画面",
"이 기기의 폴더": "本设备上的文件夹",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)",

View File

@ -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;

View File

@ -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<void> setAsk(String value) async {
if (!askValues.contains(value)) return;
_writable();
_askStored = value;
await secrets.write(askKey, value);
notifyListeners();
}
/// 안전 저장소에서 읽는다. 읽지 못하면 [storeUnreadable] (예외를 내지 않음 - 앱은 켜지고 문은 닫힌다)
Future<void> load() async {
final all = await secrets.readAll();
final Map<String, String> 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<bool> 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<bool> 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<bool> 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<void> clearMaster() async {
_writable();
_master = null;
_askStored = null;
await secrets.delete(masterKey);
@ -301,6 +335,7 @@ class MasterLock extends ChangeNotifier {
}
Future<void> 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<bool> 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;

View File

@ -142,8 +142,8 @@ class SettingsStore {
stored = await secrets.readAll();
_secretsOk = true;
} catch (_) {
// TODO(M0): 비밀 저장소를 못 읽으면 빈 것으로 보고 이어 간다 - 마스터가 없는 것으로 보여 문이 열린다 (보안).
// 설정 나누기 (A) 뒤 따로 고친다 (관리자 10/22). 나누기 동안 동작은 그대로
// 비밀 저장소를 못 읽으면 빈 것으로 보고 이어 간다 (앱은 켜지고 위 알림 [다시 시도]). 마스터 잠금은 따로 '알 수 없음' 으로 두어
// 문을 열지 않는다 (M0 - MasterLock.storeUnreadable)
stored = {};
_secretsOk = false;
}

View File

@ -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<bool> 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),

View File

@ -314,6 +314,11 @@ Future<void> 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;

View File

@ -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<String, String> values = {};
bool readable = true;
@override
String? lostCopy;
@override
Future<Map<String, String>> readAll() async {
if (!readable) throw StateError('안전 저장소를 읽을 수 없음');
return Map.of(values);
}
@override
Future<void> write(String key, String value) async => values[key] = value;
@override
Future<void> 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<MasterStoreUnreadable>()));
await expectLater(lock.setSuper('s'), throwsA(isA<MasterStoreUnreadable>()));
await expectLater(lock.clearMaster(), throwsA(isA<MasterStoreUnreadable>()));
await expectLater(lock.setAsk('never'), throwsA(isA<MasterStoreUnreadable>()));
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);
});
}