190 lines
7.1 KiB
Dart
190 lines
7.1 KiB
Dart
import 'dart:convert';
|
|
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';
|
|
|
|
/// 비밀 값 (WebDAV · OpenSubtitles 아이디 · 비밀번호 · API 키) 을 두는 곳.
|
|
/// 사용자 결정 (10/9): 설정 파일 (settings.json · .bak · 버전 보관본) 에 평문으로 쓰지 않는다.
|
|
/// Android 는 Keystore 키로 암호화해 앱 전용 저장소에, Windows 는 DPAPI (이 Windows 사용자만 풀 수 있음) 로 암호화한 파일에.
|
|
/// 설정 파일과 따로 있어 업데이트 · 예전 버전 설치 · 되돌리기 · 설정 복원 뒤에도 남는다.
|
|
abstract class SecretStore {
|
|
Future<Map<String, String>> readAll();
|
|
Future<void> write(String key, String value);
|
|
Future<void> delete(String key);
|
|
|
|
/// 저장소를 풀 수 없어 새로 시작했으면 풀지 못한 원래 파일을 보관한 곳 (켤 때 알림 - 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();
|
|
if (Platform.isAndroid) return _AndroidSecretStore();
|
|
return MemorySecretStore();
|
|
}
|
|
}
|
|
|
|
/// Android: MainActivity 의 SecretBox (AndroidKeyStore AES-GCM 키 · SharedPreferences "jj_secrets")
|
|
class _AndroidSecretStore implements SecretStore {
|
|
static const _ch = MethodChannel('jj_mkvmaker/android');
|
|
|
|
@override
|
|
String? get lostCopy => null;
|
|
|
|
@override
|
|
Future<Map<String, String>> readAll() async {
|
|
final m = await _ch.invokeMethod<Map<Object?, Object?>>('secretReadAll');
|
|
return {for (final e in (m ?? const {}).entries) '${e.key}': '${e.value}'};
|
|
}
|
|
|
|
@override
|
|
Future<void> write(String key, String value) => _ch.invokeMethod('secretWrite', {'key': key, 'value': value});
|
|
@override
|
|
Future<void> delete(String key) => _ch.invokeMethod('secretDelete', {'key': key});
|
|
}
|
|
|
|
/// Windows: 모든 값을 JSON 으로 묶어 DPAPI (CryptProtectData, 지금 사용자 범위) 로 암호화한 secrets.dat
|
|
class _DpapiSecretStore implements SecretStore {
|
|
/// 둘 폴더 (null 이면 앱 데이터 폴더 - 시험만 따로 정한다)
|
|
final String? dir;
|
|
_DpapiSecretStore({this.dir});
|
|
|
|
Future<File> _file() async => File(p.join(dir ?? (await getApplicationSupportDirectory()).path, 'secrets.dat'));
|
|
|
|
Map<String, String>? _cache;
|
|
|
|
/// 읽은 파일의 고친 시각 · 크기 (130: 다른 창 · 프로세스가 고쳤으면 다시 읽는다)
|
|
(DateTime, int)? _stamp;
|
|
|
|
@override
|
|
String? lostCopy;
|
|
|
|
@override
|
|
Future<Map<String, String>> readAll() async => Map.of(await _load());
|
|
|
|
/// 파일이 읽은 뒤로 바뀌지 않았으면 기억해 둔 것, 바뀌었으면 (두 번째 재생 창 · 다른 실행이 씀) 다시 읽는다
|
|
Future<Map<String, String>> _load() async {
|
|
final f = await _file();
|
|
if (!await f.exists()) {
|
|
_stamp = null;
|
|
return _cache = {};
|
|
}
|
|
final st = await f.stat();
|
|
final stamp = (st.modified, st.size);
|
|
if (_cache != null && _stamp == stamp) return _cache!;
|
|
final bytes = await f.readAsBytes();
|
|
try {
|
|
final j = jsonDecode(utf8.decode(_dpapi(bytes, protect: false))) as Map;
|
|
_stamp = stamp;
|
|
return _cache = {for (final e in j.entries) '${e.key}': '${e.value}'};
|
|
} catch (_) {
|
|
// 풀 수 없음 (다른 PC · 다른 사용자에서 복사해 옴 등): 지우지 않고 보관한 뒤 새로 시작
|
|
final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first;
|
|
final kept = p.join(f.parent.path, 'secrets.broken-$stamp.dat');
|
|
try {
|
|
await f.copy(kept);
|
|
lostCopy = kept;
|
|
} catch (_) {
|
|
// 보관하지 못하면 저장소를 쓰지 않는다 (덮어써서 잃지 않게)
|
|
rethrow;
|
|
}
|
|
return _cache = {};
|
|
}
|
|
}
|
|
|
|
Future<void> _save(Map<String, String> m) async {
|
|
final f = await _file();
|
|
await f.parent.create(recursive: true);
|
|
final tmp = File('${f.path}.tmp');
|
|
await tmp.writeAsBytes(_dpapi(Uint8List.fromList(utf8.encode(jsonEncode(m))), protect: true), flush: true);
|
|
await tmp.rename(f.path);
|
|
final st = await f.stat();
|
|
_stamp = (st.modified, st.size);
|
|
_cache = m;
|
|
}
|
|
|
|
/// 130: 쓰기 · 지우기 바로 전에 파일을 다시 읽어 (다른 창이 그사이 바꾼 것과) 합친다
|
|
@override
|
|
Future<void> write(String key, String value) async => _save({...await _load(), key: value});
|
|
|
|
@override
|
|
Future<void> delete(String key) async {
|
|
final m = Map.of(await _load());
|
|
if (m.remove(key) != null) await _save(m);
|
|
}
|
|
}
|
|
|
|
final class _Blob extends Struct {
|
|
@Uint32()
|
|
external int cbData;
|
|
external Pointer<Uint8> pbData;
|
|
}
|
|
|
|
typedef _CryptNative = Int32 Function(
|
|
Pointer<_Blob>, Pointer<Utf16>, Pointer<_Blob>, Pointer<Void>, Pointer<Void>, Uint32, Pointer<_Blob>);
|
|
typedef _CryptDart = int Function(
|
|
Pointer<_Blob>, Pointer<Utf16>, Pointer<_Blob>, Pointer<Void>, Pointer<Void>, int, Pointer<_Blob>);
|
|
|
|
/// CryptProtectData / CryptUnprotectData (CRYPTPROTECT_UI_FORBIDDEN)
|
|
Uint8List _dpapi(Uint8List data, {required bool protect}) {
|
|
final crypt32 = DynamicLibrary.open('crypt32.dll');
|
|
final kernel32 = DynamicLibrary.open('kernel32.dll');
|
|
final fn = protect
|
|
? crypt32.lookupFunction<_CryptNative, _CryptDart>('CryptProtectData')
|
|
: crypt32.lookupFunction<_CryptNative, _CryptDart>('CryptUnprotectData');
|
|
final localFree = kernel32.lookupFunction<Pointer<Void> Function(Pointer<Void>), Pointer<Void> Function(Pointer<Void>)>('LocalFree');
|
|
final input = calloc<_Blob>();
|
|
final output = calloc<_Blob>();
|
|
final buf = calloc<Uint8>(data.isEmpty ? 1 : data.length);
|
|
try {
|
|
buf.asTypedList(data.length).setAll(0, data);
|
|
input.ref
|
|
..cbData = data.length
|
|
..pbData = buf;
|
|
final ok = fn(input, nullptr, nullptr, nullptr, nullptr, 0x1, output);
|
|
if (ok == 0) throw StateError('DPAPI ${protect ? 'protect' : 'unprotect'} failed');
|
|
final out = Uint8List.fromList(output.ref.pbData.asTypedList(output.ref.cbData));
|
|
localFree(output.ref.pbData.cast());
|
|
return out;
|
|
} finally {
|
|
calloc.free(buf);
|
|
calloc.free(input);
|
|
calloc.free(output);
|
|
}
|
|
}
|
|
|
|
/// 시험용 · 설정 파일 경로를 직접 준 경우 (메모리에만)
|
|
class MemorySecretStore implements SecretStore {
|
|
final Map<String, String> values = {};
|
|
|
|
/// 시험: 읽기 · 쓰기 실패 흉내
|
|
bool failRead = false;
|
|
bool failWrite = false;
|
|
|
|
@override
|
|
String? lostCopy;
|
|
|
|
@override
|
|
Future<Map<String, String>> readAll() async {
|
|
if (failRead) throw StateError('secret store unavailable');
|
|
return Map.of(values);
|
|
}
|
|
|
|
@override
|
|
Future<void> write(String key, String value) async {
|
|
if (failWrite) throw StateError('secret store write failed');
|
|
values[key] = value;
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(String key) async => values.remove(key);
|
|
}
|