From 00fac175b9ce0f95cf7600b4fdd089add88c0944 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:43:41 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EC=8B=9C=ED=97=98:=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EC=86=8C=20(DPAPI)=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9D=B4=20=EC=82=AC=EC=9A=A9=EC=9E=90=20secrets.dat=20?= =?UTF-8?q?=EB=A5=BC=20=EA=B1=B4=EB=93=9C=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20-=20=EC=9E=84=EC=8B=9C=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A1=9C,=20=EB=B3=B4=ED=86=B5=20=ED=9A=8C=EA=B7=80=EC=97=90?= =?UTF-8?q?=20=EB=84=A3=EC=9D=8C=20(=EA=B2=80=ED=86=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/17): integration_test/secret_store_platform_test 는 Windows 에서 사용자의 앱 데이터 폴더 (%APPDATA%\com.jj\jj_mkvmaker\secrets.dat) 에 시험 열쇠를 쓰고 지웠다 (값은 지웠지만 파일을 다시 쓰고, 그동안 다른 실행과 겹칠 수 있음). - SecretStore.dpapiAt(dir) (@visibleForTesting): DPAPI 저장소를 정한 폴더의 secrets.dat 로. 앱 동작은 그대로 (platform() 은 앱 데이터 폴더) - test/secret_store_dpapi_test.dart (새, 보통 flutter test 에서 돎 - DPAPI 는 FFI 라 플러그인 없이 됨): 쓰기 · 읽기 · 바꾸기 · 지우기 · 파일에 평문 없음 · .tmp 안 남음, 130 두 창 번갈아 쓰기, 풀 수 없는 파일은 보관 뒤 새로 시작 (40-5). 끝날 때마다 사용자 secrets.dat 의 바뀐 때 · 크기가 그대로인지 확인 - integration_test 의 같은 시험: Windows 는 임시 폴더 저장소로, Android 는 기기 Keystore 그대로 (시험 열쇠만 쓰고 지움) 시험: secret_store_dpapi 3 · secret_store · secret_issue 15 통과, analyze 문제 없음, 사용자 secrets.dat 생기지 않음 · jj_secret* 흔적 0. Co-Authored-By: Claude Opus 5.5 --- .../secret_store_platform_test.dart | 27 +++++-- lib/services/secret_store.dart | 11 ++- test/secret_store_dpapi_test.dart | 73 +++++++++++++++++++ 3 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 test/secret_store_dpapi_test.dart diff --git a/integration_test/secret_store_platform_test.dart b/integration_test/secret_store_platform_test.dart index 4be43ea..4541124 100644 --- a/integration_test/secret_store_platform_test.dart +++ b/integration_test/secret_store_platform_test.dart @@ -1,14 +1,29 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/services/secret_store.dart'; /// 54: 이 기기의 안전 저장소 (Windows DPAPI · Android Keystore) 에 실제로 쓰고 읽고 지운다. /// 시험용 열쇠 하나만 쓰고 끝에 지운다 (사용자가 저장한 값은 건드리지 않음). +/// Windows 는 임시 폴더의 secrets.dat 만 쓴다 (관리자 지시 10/17 - 사용자의 앱 데이터 폴더 secrets.dat 는 건드리지 않음, +/// 같은 시험이 보통 회귀에도 있음: test/secret_store_dpapi_test.dart). Android 는 기기의 Keystore 를 써야 하므로 그대로. void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + Directory? tmp; + SecretStore store() { + if (!Platform.isWindows) return SecretStore.platform(); + return SecretStore.dpapiAt((tmp ??= Directory.systemTemp.createTempSync('jj_secretit_')).path); + } + + tearDownAll(() { + try { + tmp?.deleteSync(recursive: true); + } catch (_) {} + }); testWidgets('안전 저장소: 쓰기 · 읽기 · 지우기', (t) async { - final s = SecretStore.platform(); + final s = store(); const key = 'jj.selftest'; await s.write(key, '비밀-1'); expect((await s.readAll())[key], '비밀-1'); @@ -20,12 +35,12 @@ void main() { testWidgets('130: 두 창 (저장소 두 개) 이 번갈아 써도 서로의 값을 지우지 않는다', (t) async { const a = 'jj.selftest.a', b = 'jj.selftest.b'; - final first = SecretStore.platform(), second = SecretStore.platform(); + final first = store(), second = store(); try { await first.readAll(); // 첫 창이 읽어 둠 await second.write(b, '두 번째 창'); // 두 번째 창이 씀 await first.write(a, '첫 창'); // 첫 창이 쓸 때 두 번째 창의 값을 지우면 안 된다 - final now = await SecretStore.platform().readAll(); + final now = await store().readAll(); expect(now[a], '첫 창'); expect(now[b], '두 번째 창'); await second.delete(b); // 두 번째 창이 지울 때 첫 창의 값을 되살리거나 지우면 안 된다 @@ -33,10 +48,10 @@ void main() { expect(after[a], '첫 창'); expect(after.containsKey(b), isFalse); } finally { - await SecretStore.platform().delete(a); - await SecretStore.platform().delete(b); + await store().delete(a); + await store().delete(b); } - final end = await SecretStore.platform().readAll(); + final end = await store().readAll(); expect(end.containsKey(a) || end.containsKey(b), isFalse); }); } diff --git a/lib/services/secret_store.dart b/lib/services/secret_store.dart index 8058e7f..82526c4 100644 --- a/lib/services/secret_store.dart +++ b/lib/services/secret_store.dart @@ -3,6 +3,7 @@ import 'dart:ffi'; import 'dart:io'; import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -19,6 +20,10 @@ abstract class SecretStore { /// 저장소를 풀 수 없어 새로 시작했으면 풀지 못한 원래 파일을 보관한 곳 (켤 때 알림 - 40-5) String? get lostCopy => null; + /// 시험: Windows DPAPI 저장소를 [dir] 의 secrets.dat 로 (사용자의 앱 데이터 폴더 secrets.dat 를 건드리지 않게 - 관리자 지시 10/17) + @visibleForTesting + factory SecretStore.dpapiAt(String dir) => _DpapiSecretStore(dir: dir); + /// 이 기기의 안전 저장소 factory SecretStore.platform() { if (Platform.isWindows) return _DpapiSecretStore(); @@ -48,7 +53,11 @@ class _AndroidSecretStore implements SecretStore { /// Windows: 모든 값을 JSON 으로 묶어 DPAPI (CryptProtectData, 지금 사용자 범위) 로 암호화한 secrets.dat class _DpapiSecretStore implements SecretStore { - Future _file() async => File(p.join((await getApplicationSupportDirectory()).path, 'secrets.dat')); + /// 둘 폴더 (null 이면 앱 데이터 폴더 - 시험만 따로 정한다) + final String? dir; + _DpapiSecretStore({this.dir}); + + Future _file() async => File(p.join(dir ?? (await getApplicationSupportDirectory()).path, 'secrets.dat')); Map? _cache; diff --git a/test/secret_store_dpapi_test.dart b/test/secret_store_dpapi_test.dart new file mode 100644 index 0000000..a2c9136 --- /dev/null +++ b/test/secret_store_dpapi_test.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'package:path/path.dart' as p; + +/// 54 · 130 (관리자 지시 10/17): Windows 안전 저장소 (DPAPI) 를 실제로 쓰고 읽고 지운다 - 보통 회귀 시험에 들어가게. +/// 임시 폴더의 secrets.dat 만 쓴다 (사용자의 앱 데이터 폴더 secrets.dat 는 건드리지 않음 - integration_test 의 예전 시험은 그것을 썼다). +void main() { + late Directory tmp; + final userFile = Platform.isWindows + ? File(p.join(Platform.environment['APPDATA'] ?? '', 'com.jj', 'jj_mkvmaker', 'secrets.dat')) + : null; + (DateTime, int)? userBefore; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_secret_'); + final f = userFile; + userBefore = f != null && f.existsSync() ? (f.lastModifiedSync(), f.lengthSync()) : null; + }); + + tearDown(() { + // 사용자의 secrets.dat 는 그대로여야 한다 (있으면 바뀐 때 · 크기가 같음) + final f = userFile; + if (f != null && userBefore != null) { + expect((f.lastModifiedSync(), f.lengthSync()), userBefore, reason: '사용자 secrets.dat 를 건드리면 안 됨'); + } + try { + tmp.deleteSync(recursive: true); + } catch (_) {} + }); + + test('쓰기 · 읽기 · 바꾸기 · 지우기 - 파일은 암호화되어 평문이 없다', () async { + final s = SecretStore.dpapiAt(tmp.path); + expect(await s.readAll(), isEmpty); + await s.write('jj.selftest', '비밀-1'); + expect((await s.readAll())['jj.selftest'], '비밀-1'); + final file = File(p.join(tmp.path, 'secrets.dat')); + expect(file.existsSync(), isTrue); + final raw = file.readAsBytesSync(); + expect(String.fromCharCodes(raw).contains('jj.selftest'), isFalse, reason: 'DPAPI 로 암호화 - 열쇠 이름도 보이지 않음'); + await s.write('jj.selftest', '비밀-2'); + expect((await SecretStore.dpapiAt(tmp.path).readAll())['jj.selftest'], '비밀-2', reason: '새로 연 저장소도 같은 값'); + await s.delete('jj.selftest'); + expect((await s.readAll()).containsKey('jj.selftest'), isFalse); + expect(File('${file.path}.tmp').existsSync(), isFalse, reason: '임시 파일이 남지 않음'); + }, skip: Platform.isWindows ? false : 'Windows 만 (DPAPI)'); + + test('130: 두 창 (저장소 두 개) 이 번갈아 써도 서로의 값을 지우지 않는다', () async { + const a = 'jj.selftest.a', b = 'jj.selftest.b'; + final first = SecretStore.dpapiAt(tmp.path), second = SecretStore.dpapiAt(tmp.path); + await first.readAll(); // 첫 창이 읽어 둠 + await second.write(b, '두 번째 창'); + await first.write(a, '첫 창'); // 첫 창이 쓸 때 두 번째 창의 값을 지우면 안 된다 + final now = await SecretStore.dpapiAt(tmp.path).readAll(); + expect(now[a], '첫 창'); + expect(now[b], '두 번째 창'); + await second.delete(b); + final after = await first.readAll(); + expect(after[a], '첫 창'); + expect(after.containsKey(b), isFalse); + }, skip: Platform.isWindows ? false : 'Windows 만 (DPAPI)'); + + test('풀 수 없는 파일 (다른 PC · 사용자에서 옮겨 옴) 은 지우지 않고 보관한 뒤 새로 시작', () async { + final file = File(p.join(tmp.path, 'secrets.dat'))..writeAsBytesSync([1, 2, 3, 4, 5]); + final s = SecretStore.dpapiAt(tmp.path); + expect(await s.readAll(), isEmpty); + expect(s.lostCopy, isNotNull); + expect(File(s.lostCopy!).readAsBytesSync(), [1, 2, 3, 4, 5]); + expect(p.basename(s.lostCopy!), startsWith('secrets.broken-')); + expect(file.readAsBytesSync(), [1, 2, 3, 4, 5], reason: '새로 쓰기 전에는 원래 파일 그대로'); + }, skip: Platform.isWindows ? false : 'Windows 만 (DPAPI)'); +}