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; const VaultEntry(this.blob, this.name, this.size, this.added); Map toJson() => {'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch}; factory VaultEntry.fromJson(Map j) => VaultEntry( '${j['blob']}', '${j['name']}', (j['size'] as num?)?.toInt() ?? 0, DateTime.fromMillisecondsSinceEpoch((j['added'] as num?)?.toInt() ?? 0)); } /// X-plore 비교 5-2 암호 보관함 - 폴더 하나 (처음 자리: Android Download/JJ_MKVMaker/vault, Windows 문서\JJ_MKVMaker\vault). /// - vault.json: 형식 번호 · PBKDF2 반복 수 · 솔트 · 감싼 보관함 열쇠 (비밀번호를 바꾸면 이것만 바뀜) /// - index.jjv: 암호화한 목록 (원래 이름 · 크기 · 넣은 날) /// - <무작위>.jjv: 파일마다 암호화한 내용 (이름은 바깥에서 보이지 않음) /// 쓰기는 늘 ".tmp" 에 다 쓴 뒤 이름을 바꾼다. 목록 (index) 이 기준 - 넣을 때는 내용 → 목록 차례로, 지울 때는 목록 → 내용 차례로 바꿔 /// 중간에 끊겨도 목록과 다른 것은 다음에 열 때 ([recover]) 지운다 class Vault { final Directory dir; final Uint8List _key; final List _entries; Vault._(this.dir, this._key, this._entries); static const format = 1; static const _json = 'vault.json', _index = 'index.jjv', _ext = '.jjv'; 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); } /// 안전 저장소의 열쇠로 연다 ([마스터로도 열기]) static Future unlockWithKey(Directory dir, Uint8List key) async { await _readHeader(dir); return _open(dir, key); } static Future _open(Directory dir, Uint8List key) async { final data = await File(p.join(dir.path, _index)).readAsBytes(); final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, data))) as List; final v = Vault._(dir, key, [for (final x in list) VaultEntry.fromJson(x as Map)]); 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))); } 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] 를 암호화해 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). [onProgress] (넣은 바이트, 전체) Future add(String src, {String? name, int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async { 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, name ?? p.basename(src), len, DateTime.now()); _entries.add(e); try { await _saveIndex(); } catch (_) { _entries.remove(e); await out.delete().catchError((_) => out); rethrow; } return e; } /// 풀어서 [outPath] 에 쓴다 (다 풀고 인증이 맞은 뒤에만 그 이름으로 - 바뀐 조각이 있으면 [VaultAuthException] 이고 파일을 남기지 않음) Future export(VaultEntry e, String outPath) async { final f = await File(p.join(dir.path, '${e.blob}$_ext')).open(); final tmp = File('$outPath.jjvtmp'); final w = await tmp.open(mode: FileMode.write); try { final len = await f.length(); final h = await f.read(VaultCrypto.headerLength); final (id, chunk) = VaultCrypto.parseHeader(h); final key = VaultCrypto.fileKey(_key, id); final full = VaultCrypto.sealedChunkLength(chunk); var off = VaultCrypto.headerLength; var i = 0; if (off >= len) throw const VaultAuthException(); while (off < len) { final sealed = await f.read(min(full, len - off)); off += sealed.length; await w.writeFrom(VaultCrypto.openChunk(key, h, i, sealed, last: off >= len)); i++; } await w.flush(); } catch (_) { await w.close(); await tmp.delete().catchError((_) => tmp); rethrow; } finally { await f.close(); } await w.close(); await tmp.rename(outPath); } /// 메모리로 푼다 (관리자 지시 10/21: 그림 보기는 파일로 풀지 않음). [maxBytes] 보다 크면 [StateError] Future readBytes(VaultEntry e, {int maxBytes = 256 * 1024 * 1024}) async { if (e.size > maxBytes) throw StateError('too big'); final data = await File(p.join(dir.path, '${e.blob}$_ext')).readAsBytes(); return VaultCrypto.decryptAll(_key, data); } /// 보관함에서 지운다 (목록 먼저 - 그 뒤 내용 파일) 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('')); } /// 이름 바꾸기 (목록만) Future rename(VaultEntry e, String name) async { final i = _entries.indexWhere((x) => x.blob == e.blob); if (i < 0) return; _entries[i] = VaultEntry(e.blob, name, e.size, e.added); await _saveIndex(); } /// 비밀번호 바꾸기 (감싼 열쇠만 다시 - 파일은 다시 암호화하지 않음) Future changePassword(String password, {int iterations = VaultCrypto.defaultIterations}) => _writeHeader(password, iterations); }