import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import 'dart:math'; import 'dart:typed_data'; import 'package:path/path.dart' as p; import 'vault_crypto.dart'; /// 보관함 안의 파일 하나 (원래 이름 · 크기 · 넣은 날 · 보관함 안 폴더는 암호화한 목록 index.jjv 에만) class VaultEntry { final String blob; // 보관함 폴더 안의 무작위 이름 (확장자 .jjv 뺌) final String name; final int size; final DateTime added; /// 2단계: 보관함 안 폴더 ("" = 맨 위, "가/나" 처럼 / 로 이음). 바깥 폴더에는 만들지 않는다 - 목록에만 final String folder; /// 형식 3 (28 지적 · 관리자 지시 10/21): 내용 파일 머리의 무작위 id (16진). 풀 때 머리 id 와 같아야 한다 - /// 다른 항목의 내용 파일로 바꿔 끼우거나 같은 항목의 예전 내용 파일로 되돌린 것을 알아챈다. "" = 모름 (풀지 않음) final String id; const VaultEntry(this.blob, this.name, this.size, this.added, {this.folder = '', this.id = ''}); /// 보관함 안 경로 (폴더/이름) String get path => folder.isEmpty ? name : '$folder/$name'; VaultEntry copyWith({String? name, String? folder, String? id}) => VaultEntry(blob, name ?? this.name, size, added, folder: folder ?? this.folder, id: id ?? this.id); // 맨 위 파일은 'folder' 를 쓰지 않는다 (1단계 판이 읽는 목록과 같게) Map toJson() => {'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch, if (folder.isNotEmpty) 'folder': folder, if (id.isNotEmpty) 'id': id}; factory VaultEntry.fromJson(Map j) => VaultEntry( '${j['blob']}', '${j['name']}', (j['size'] as num?)?.toInt() ?? 0, DateTime.fromMillisecondsSinceEpoch((j['added'] as num?)?.toInt() ?? 0), folder: Vault.cleanFolder('${j['folder'] ?? ''}') ?? '', id: RegExp(r'^[0-9a-f]{32}$').hasMatch('${j['id'] ?? ''}') ? '${j['id']}' : ''); } /// X-plore 비교 5-2 암호 보관함 - 폴더 하나 (처음 자리: Android Download/JJ_MKVMaker/vault, Windows 문서\JJ_MKVMaker\vault). /// - vault.json: 형식 번호 · PBKDF2 반복 수 · 솔트 · 감싼 보관함 열쇠 (비밀번호를 바꾸면 이것만 바뀜) /// - index.jjv: 암호화한 목록 (원래 이름 · 크기 · 넣은 날) /// - <무작위>.jjv: 파일마다 암호화한 내용 (이름은 바깥에서 보이지 않음) /// - folders.jjd (2단계): 암호화한 빈 폴더 목록 (파일이 든 폴더는 index 의 folder 로 안다). 1단계 판의 [recover] 는 .jjv 만 지우므로 남는다 /// 쓰기는 늘 ".tmp" 에 다 쓴 뒤 이름을 바꾼다. 목록 (index) 이 기준 - 넣을 때는 내용 → 목록 차례로, 지울 때는 목록 → 내용 차례로 바꿔 /// 중간에 끊겨도 목록과 다른 것은 다음에 열 때 ([recover]) 지운다 class Vault { final Directory dir; final Uint8List _key; final List _entries; /// 사용자가 만든 폴더 (비어도 남게). 파일이 든 폴더 · 그 위 폴더는 [folders] 가 따로 센다 final Set _folders; Vault._(this.dir, this._key, this._entries, [Set? folders]) : _folders = folders ?? {}; /// 1 = 1단계, 2 = 2단계 (보관함 안 폴더 - 관리자 지시 10/21: 1단계만 아는 판은 "새 판으로" 를 알리게) /// 3 = 목록 항목에 내용 파일 id (28 지적 · 관리자 지시 10/21, 1 · 2 는 열 때 목록에 id 를 채우고 올림) static const format = 3; static const _json = 'vault.json', _index = 'index.jjv', _ext = '.jjv', _folderFile = 'folders.jjd'; List get entries => List.unmodifiable(_entries); /// 보관함 열쇠 ([마스터로도 열기] 가 안전 저장소에 한 벌 둘 때만 꺼낸다) Uint8List get keyForMasterCopy => Uint8List.fromList(_key); static Future exists(Directory dir) => File(p.join(dir.path, _json)).exists(); /// 새 보관함 (이미 있으면 [StateError]) static Future create(Directory dir, String password, {int iterations = VaultCrypto.defaultIterations}) async { if (await exists(dir)) throw StateError('vault exists'); await dir.create(recursive: true); final key = VaultCrypto.random(32); final v = Vault._(dir, key, []); await v._writeHeader(password, iterations); await v._saveIndex(); return v; } Future _writeHeader(String password, int iterations) async { final salt = VaultCrypto.random(16); final kek = await Isolate.run(() => VaultCrypto.kek(password, salt, iterations)); final (nonce, wrapped) = VaultCrypto.wrapKey(kek, _key); final j = { 'format': format, 'kdf': 'pbkdf2-sha256', 'iterations': iterations, 'salt': base64.encode(salt), 'nonce': base64.encode(nonce), 'key': base64.encode(wrapped), }; await _atomicWrite(File(p.join(dir.path, _json)), utf8.encode(const JsonEncoder.withIndent(' ').convert(j))); } static Future> _readHeader(Directory dir) async { final j = jsonDecode(await File(p.join(dir.path, _json)).readAsString()); if (j is! Map) throw const VaultFormatException('vault.json'); final f = j['format']; if (f is! int || f > format) throw VaultFormatException('format $f'); if (j['kdf'] != 'pbkdf2-sha256') throw VaultFormatException('kdf ${j['kdf']}'); return j; } /// 비밀번호로 연다 - 틀리면 [VaultAuthException], 이 판이 모르는 꼴이면 [VaultFormatException] static Future unlock(Directory dir, String password) async { final j = await _readHeader(dir); final salt = base64.decode('${j['salt']}'); final iterations = (j['iterations'] as num).toInt(); final kek = await Isolate.run(() => VaultCrypto.kek(password, salt, iterations)); final key = VaultCrypto.unwrapKey(kek, base64.decode('${j['nonce']}'), base64.decode('${j['key']}')); return _open(dir, key, j); } /// 안전 저장소의 열쇠로 연다 ([마스터로도 열기]) static Future unlockWithKey(Directory dir, Uint8List key) async { return _open(dir, key, await _readHeader(dir)); } static Future _open(Directory dir, Uint8List key, Map header) async { final data = await File(p.join(dir.path, _index)).readAsBytes(); final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, data))) as List; final folders = {}; final ff = File(p.join(dir.path, _folderFile)); if (await ff.exists()) { for (final x in jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, await ff.readAsBytes()))) as List) { final f = cleanFolder('$x'); if (f != null && f.isNotEmpty) folders.add(f); } } final v = Vault._(dir, key, [for (final x in list) VaultEntry.fromJson(x as Map)], folders); // 예전 형식 (1 · 2) 은 열쇠가 맞은 뒤 목록에 내용 파일 id 를 채워 다시 쓰고 형식 번호를 올린다 (감싼 열쇠 · 솔트는 그대로). // 아직 어느 판에도 나가지 않은 형식이라 지금 내용 파일을 그대로 믿는다 (관리자 지시 10/21) if ((header['format'] as int) < format) { for (var i = 0; i < v._entries.length; i++) { final e = v._entries[i]; if (e.id.isNotEmpty) continue; try { final raf = await File(p.join(dir.path, '${e.blob}$_ext')).open(); try { final (id, _) = VaultCrypto.parseHeader(await raf.read(VaultCrypto.headerLength)); v._entries[i] = e.copyWith(id: _hex(id)); } finally { await raf.close(); } } catch (_) { // 없거나 깨진 내용 파일은 id 를 모른 채 둔다 (풀 때 실패) } } await v._saveIndex(); await _atomicWrite(File(p.join(dir.path, _json)), utf8.encode(const JsonEncoder.withIndent(' ').convert({...header, 'format': format}))); } await v.recover(); return v; } /// 끊긴 쓰기 정리: 남은 .tmp · 목록에 없는 내용 파일을 지운다 Future> recover() async { final removed = []; final known = {for (final e in _entries) '${e.blob}$_ext'}; await for (final f in dir.list()) { if (f is! File) continue; final name = p.basename(f.path); final orphan = name.endsWith('.tmp') || (name.endsWith(_ext) && name != _index && !known.contains(name)); if (!orphan) continue; try { await f.delete(); removed.add(name); } catch (_) {} } return removed; } Future _saveIndex() async { final plain = utf8.encode(jsonEncode([for (final e in _entries) e.toJson()])); await _atomicWrite(File(p.join(dir.path, _index)), VaultCrypto.encryptAll(_key, Uint8List.fromList(plain))); } Future _saveFolders() async { final plain = utf8.encode(jsonEncode(_folders.toList()..sort())); await _atomicWrite(File(p.join(dir.path, _folderFile)), VaultCrypto.encryptAll(_key, Uint8List.fromList(plain))); } /// 보관함 안 이름 한 칸으로 쓸 수 있는지 (비었거나 . · .. · / · \ · 줄바꿈 · \0 이 들면 안 됨 - 앞뒤 빈칸은 Android 파일 이름에 있을 수 있어 받음) static bool validName(String n) => n.isNotEmpty && n != '.' && n != '..' && !n.contains(RegExp(r'[/\\\u0000\n\r]')); /// "가/나" 꼴로 고른다 (틀리면 null, "" = 맨 위) static String? cleanFolder(String f) { if (f.isEmpty) return ''; final parts = f.split('/'); return parts.every(validName) ? parts.join('/') : null; } static String _join(String folder, String name) => folder.isEmpty ? name : '$folder/$name'; static String _hex(List b) => b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); /// 목록 항목과 내용 파일의 짝: 머리 id 가 목록의 id 와 같아야 한다 (모르면 풀지 않음) static void _checkId(VaultEntry e, List headerId) { if (e.id.isEmpty || e.id != _hex(headerId)) throw const VaultAuthException(); } static bool _inside(String folder, String parent) => parent.isEmpty || folder == parent || folder.startsWith('$parent/'); /// 있는 폴더 모두 (만든 것 + 파일이 든 것 + 그 위 폴더) List get folders { final out = {}; void addUp(String f) { while (f.isNotEmpty && out.add(f)) { final cut = f.lastIndexOf('/'); f = cut < 0 ? '' : f.substring(0, cut); } } _folders.forEach(addUp); for (final e in _entries) { addUp(e.folder); } return out.toList()..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); } /// [parent] 바로 아래 폴더 이름 List subfolders(String parent) => [ for (final f in folders) if (f != parent && _inside(f, parent) && !f.substring(parent.isEmpty ? 0 : parent.length + 1).contains('/')) f.substring(parent.isEmpty ? 0 : parent.length + 1), ]; /// [folder] 바로 안의 파일 List filesIn(String folder) => [for (final e in _entries) if (e.folder == folder) e]; /// [folder] 와 그 안쪽 모든 파일 List filesUnder(String folder) => [for (final e in _entries) if (_inside(e.folder, folder)) e]; bool _taken(String folder, String name, {String? except}) => _entries.any((e) => e.folder == folder && e.name == name && e.blob != except) || folders.contains(_join(folder, name)); /// [folder] 안에서 겹치지 않는 이름 ("사진.jpg" → "사진 (2).jpg") String freeName(String folder, String name, {String? except}) { if (!_taken(folder, name, except: except)) return name; final ext = p.extension(name), base = p.basenameWithoutExtension(name); for (var i = 2;; i++) { final n = '$base ($i)$ext'; if (!_taken(folder, n, except: except)) return n; } } /// 보관함 안 폴더 만들기 (이미 있으면 그대로, 같은 이름 파일이 있으면 [StateError]) Future mkdir(String folder) async { final f = cleanFolder(folder); if (f == null || f.isEmpty) throw ArgumentError.value(folder, 'folder'); if (folders.contains(f)) return; if (_entries.any((e) => e.path == f)) throw StateError('name taken'); _folders.add(f); try { await _saveFolders(); } catch (_) { _folders.remove(f); rethrow; } } /// 파일을 다른 폴더로 (목록만 - 이름이 겹치면 " (2)") Future moveEntry(VaultEntry e, String folder) async { final f = cleanFolder(folder); if (f == null) throw ArgumentError.value(folder, 'folder'); final i = _entries.indexWhere((x) => x.blob == e.blob); if (i < 0) return e; final old = _entries[i]; if (old.folder == f) return old; final moved = old.copyWith(folder: f, name: freeName(f, old.name, except: old.blob)); _entries[i] = moved; try { await _saveIndex(); } catch (_) { _entries[i] = old; rethrow; } return moved; } /// 폴더 이름 바꾸기 · 옮기기 ([from] → [to], 안의 것 모두 - 목록만). [to] 가 [from] 안이거나 이미 있으면 [StateError] Future moveFolder(String from, String to) async { final a = cleanFolder(from), b = cleanFolder(to); if (a == null || a.isEmpty || b == null || b.isEmpty) throw ArgumentError.value('$from -> $to'); if (a == b) return; if (_inside(b, a)) throw StateError('into itself'); if (folders.contains(b) || _entries.any((e) => e.path == b)) throw StateError('name taken'); String re(String f) => _inside(f, a) ? b + f.substring(a.length) : f; final oldEntries = [..._entries], oldFolders = {..._folders}; for (var i = 0; i < _entries.length; i++) { if (_inside(_entries[i].folder, a)) _entries[i] = _entries[i].copyWith(folder: re(_entries[i].folder)); } final moved = {for (final f in _folders) re(f)}; _folders ..clear() ..addAll(moved) ..add(b); // 빈 폴더였어도 남게 try { await _saveIndex(); await _saveFolders(); } catch (_) { _entries ..clear() ..addAll(oldEntries); _folders ..clear() ..addAll(oldFolders); rethrow; } } /// 폴더와 안의 것 모두 지우기 (목록 먼저 - 그 뒤 내용 파일). 지운 파일 수 Future removeFolder(String folder) async { final f = cleanFolder(folder); if (f == null || f.isEmpty) throw ArgumentError.value(folder, 'folder'); final gone = filesUnder(f); _entries.removeWhere((e) => _inside(e.folder, f)); _folders.removeWhere((x) => _inside(x, f)); await _saveIndex(); await _saveFolders(); for (final e in gone) { await File(p.join(dir.path, '${e.blob}$_ext')).delete().catchError((_) => File('')); } return gone.length; } static Future _atomicWrite(File f, List bytes) async { final tmp = File('${f.path}.tmp'); final raf = await tmp.open(mode: FileMode.write); try { await raf.writeFrom(bytes); await raf.flush(); } finally { await raf.close(); } await tmp.rename(f.path); } String _newBlob() { final r = Random.secure(); String n() => List.generate(16, (_) => r.nextInt(256).toRadixString(16).padLeft(2, '0')).join(); var b = n(); while (_entries.any((e) => e.blob == b)) { b = n(); } return b; } /// [src] 를 암호화해 [folder] 에 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). 이름이 겹치면 " (2)". /// [onProgress] (넣은 바이트, 전체) Future add(String src, {String? name, String folder = '', int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async { final f = cleanFolder(folder); if (f == null) throw ArgumentError.value(folder, 'folder'); final want = name ?? p.basename(src); if (!validName(want)) throw ArgumentError.value(want, 'name'); final blob = _newBlob(); final out = File(p.join(dir.path, '$blob$_ext')); final tmp = File('${out.path}.tmp'); final inp = await File(src).open(); final len = await inp.length(); final id = VaultCrypto.random(16); final h = VaultCrypto.header(id, chunk); final key = VaultCrypto.fileKey(_key, id); final w = await tmp.open(mode: FileMode.write); try { await w.writeFrom(h); var off = 0; var i = 0; do { final plain = await inp.read(min(chunk, len - off)); off += plain.length; await w.writeFrom(VaultCrypto.sealChunk(key, h, i, plain, last: off >= len)); i++; onProgress?.call(off, len); if (plain.isEmpty && off < len) throw const FileSystemException('short read'); } while (off < len); await w.flush(); } catch (_) { await w.close(); await tmp.delete().catchError((_) => tmp); rethrow; } finally { await inp.close(); } await w.close(); await tmp.rename(out.path); final e = VaultEntry(blob, freeName(f, want), len, DateTime.now(), folder: f, id: _hex(id)); _entries.add(e); try { await _saveIndex(); } catch (_) { _entries.remove(e); await out.delete().catchError((_) => out); rethrow; } return e; } /// 풀어서 [outPath] 에 쓴다 (다 풀고 인증이 맞은 뒤에만 그 이름으로 - 바뀐 조각 · 다른 항목의 내용 파일 · 크기가 다르면 /// [VaultAuthException] 이고 파일을 남기지 않음). [onTemp] · [onTempDone]: 받는 폴더에 잠깐 쓰는 "이름.jjvtmp" (평문) 를 /// 앱 전용 기록에 적고 지우게 (강제로 꺼져 남으면 다음에 켤 때 지움 - 관리자 지시 10/21) Future export(VaultEntry e, String outPath, {void Function(String tmp)? onTemp, void Function(String tmp)? onTempDone}) async { // 내용 파일이 없으면 (목록을 예전 것으로 되돌렸거나 폴더를 다른 곳에서 바꿈) 짝이 맞지 않는 것과 같게 (28 지적 · 관리자 지시 10/21) final blob = File(p.join(dir.path, '${e.blob}$_ext')); if (!await blob.exists()) throw const VaultAuthException(); final f = await blob.open(); final tmp = File('$outPath.jjvtmp'); onTemp?.call(tmp.path); RandomAccessFile w; try { w = await tmp.open(mode: FileMode.write); } catch (_) { await f.close(); onTempDone?.call(tmp.path); rethrow; } try { final len = await f.length(); final h = await f.read(VaultCrypto.headerLength); final (id, chunk) = VaultCrypto.parseHeader(h); _checkId(e, id); final key = VaultCrypto.fileKey(_key, id); final full = VaultCrypto.sealedChunkLength(chunk); var off = VaultCrypto.headerLength; var i = 0; var written = 0; if (off >= len) throw const VaultAuthException(); while (off < len) { final sealed = await f.read(min(full, len - off)); off += sealed.length; final plain = VaultCrypto.openChunk(key, h, i, sealed, last: off >= len); written += plain.length; await w.writeFrom(plain); i++; } if (written != e.size) throw const VaultAuthException(); await w.flush(); } catch (_) { await w.close(); await tmp.delete().catchError((_) => tmp); onTempDone?.call(tmp.path); rethrow; } finally { await f.close(); } await w.close(); try { await tmp.rename(outPath); } finally { onTempDone?.call(tmp.path); } } /// 메모리로 푼다 (관리자 지시 10/21: 그림 보기는 파일로 풀지 않음). [maxBytes] 보다 크면 [StateError] Future readBytes(VaultEntry e, {int maxBytes = 256 * 1024 * 1024}) async { if (e.size > maxBytes) throw StateError('too big'); final blob = File(p.join(dir.path, '${e.blob}$_ext')); if (!await blob.exists()) throw const VaultAuthException(); final data = await blob.readAsBytes(); final (id, _) = VaultCrypto.parseHeader(Uint8List.sublistView(data, 0, min(data.length, VaultCrypto.headerLength))); _checkId(e, id); final out = VaultCrypto.decryptAll(_key, data); if (out.length != e.size) throw const VaultAuthException(); return out; } /// 보관함에서 지운다 (목록 먼저 - 그 뒤 내용 파일) Future remove(VaultEntry e) async { _entries.removeWhere((x) => x.blob == e.blob); await _saveIndex(); await File(p.join(dir.path, '${e.blob}$_ext')).delete().catchError((_) => File('')); } /// 이름 바꾸기 (목록만). 같은 폴더에 같은 이름이 있으면 [StateError] Future rename(VaultEntry e, String name) async { if (!validName(name)) throw ArgumentError.value(name, 'name'); final i = _entries.indexWhere((x) => x.blob == e.blob); if (i < 0) return; final old = _entries[i]; if (_taken(old.folder, name, except: old.blob)) throw StateError('name taken'); _entries[i] = old.copyWith(name: name); try { await _saveIndex(); } catch (_) { _entries[i] = old; rethrow; } } /// 비밀번호 바꾸기 (감싼 열쇠만 다시 - 파일은 다시 암호화하지 않음) Future changePassword(String password, {int iterations = VaultCrypto.defaultIterations}) => _writeHeader(password, iterations); }