import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/master_lock.dart' show pbkdf2Sha256; import 'package:jj_mkvmaker/core/vault_crypto.dart'; import 'package:jj_mkvmaker/core/vault_store.dart'; import 'package:jj_mkvmaker/core/vault_view.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 5-2 암호 보관함 (관리자 결정 10/21): 알려진 시험 벡터 · 조각 변조 · 틀린 비밀번호 · 끊긴 쓰기 복구 Uint8List hex(String s) { s = s.replaceAll(RegExp(r'\s'), ''); return Uint8List.fromList([for (var i = 0; i < s.length; i += 2) int.parse(s.substring(i, i + 2), radix: 16)]); } String toHex(List b) => b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); void main() { group('알려진 시험 벡터', () { test('AES-256-GCM (McGrew · Viega GCM 명세 Test Case 16)', () { final key = hex('feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308'); final iv = hex('cafebabefacedbaddecaf888'); final pt = hex('d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72' '1c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39'); final aad = hex('feedfacedeadbeeffeedfacedeadbeefabaddad2'); final sealed = VaultCrypto.seal(key, iv, pt, aad); expect(toHex(sealed.sublist(0, pt.length)), '522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662'); expect(toHex(sealed.sublist(pt.length)), '76fc6ece0f4e1768cddf8853bb2d551b'); expect(VaultCrypto.open(key, iv, sealed, aad), pt); }); test('PBKDF2-HMAC-SHA256 ("password" / "salt", 1번 · 4096번)', () { expect(toHex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 1, 32)), '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b'); expect(toHex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 4096, 32)), 'c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a'); }); }); group('열쇠 감싸기 · 조각 암호화', () { final vaultKey = VaultCrypto.random(32); test('비밀번호가 맞으면 보관함 열쇠가 풀리고, 틀리면 VaultAuthException', () { final salt = VaultCrypto.random(16); final kek = VaultCrypto.kek('맞는 비밀번호', salt, 1000); final (nonce, wrapped) = VaultCrypto.wrapKey(kek, vaultKey); expect(VaultCrypto.unwrapKey(kek, nonce, wrapped), vaultKey); final wrong = VaultCrypto.kek('틀린 비밀번호', salt, 1000); expect(() => VaultCrypto.unwrapKey(wrong, nonce, wrapped), throwsA(isA())); }); test('크기마다 왕복 (0 · 1 · 조각-1 · 조각 · 조각+1 · 조각 3개) · 같은 내용도 매번 다른 암호문', () { for (final n in [0, 1, 1023, 1024, 1025, 3072]) { final plain = Uint8List.fromList(List.generate(n, (i) => i % 251)); final a = VaultCrypto.encryptAll(vaultKey, plain, chunk: 1024); expect(VaultCrypto.decryptAll(vaultKey, a), plain, reason: '$n 바이트'); expect(VaultCrypto.encryptAll(vaultKey, plain, chunk: 1024), isNot(a), reason: '파일 id 가 매번 달라 암호문도 다름'); } }); test('조각 변조 감지: 한 바이트 바꾸기 · 조각 바꿔 끼우기 · 끝 조각 잘라 내기 · 조각 덧붙이기 · 머리 바꾸기 · 다른 보관함 열쇠', () { final plain = Uint8List.fromList(List.generate(3000, (i) => i % 251)); final a = VaultCrypto.encryptAll(vaultKey, plain, chunk: 1024); const h = VaultCrypto.headerLength; final full = VaultCrypto.sealedChunkLength(1024); Uint8List copy() => Uint8List.fromList(a); void bad(Uint8List x, String why) => expect(() => VaultCrypto.decryptAll(vaultKey, x), throwsA(anything), reason: why); bad(copy()..[h + 10] ^= 1, '한 바이트'); final swapped = copy() ..setRange(h, h + full, a.sublist(h + full, h + 2 * full)) ..setRange(h + full, h + 2 * full, a.sublist(h, h + full)); bad(swapped, '조각 바꿔 끼우기'); bad(Uint8List.fromList(a.sublist(0, h + 2 * full)), '끝 조각 잘라 냄 (앞 조각은 "마지막 아님" 으로 감싸져 있음)'); bad(Uint8List.fromList([...a, ...a.sublist(h, h + full)]), '조각 덧붙이기'); bad(copy()..[5] ^= 1, '머리의 조각 크기'); bad(copy()..[12] ^= 1, '머리의 파일 id'); expect(() => VaultCrypto.decryptAll(VaultCrypto.random(32), a), throwsA(isA())); expect(() => VaultCrypto.decryptAll(vaultKey, Uint8List.fromList(a.sublist(0, h))), throwsA(isA()), reason: '조각이 하나도 없음'); }); }); group('보관함 폴더', () { late Directory tmp, dir; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_vault_'); dir = Directory(p.join(tmp.path, 'vault')); }); tearDown(() => tmp.deleteSync(recursive: true)); File src(String name, int size) => File(p.join(tmp.path, name))..writeAsBytesSync(List.generate(size, (i) => (i * 7) % 256)); test('만들기 · 넣기 (원본 그대로) · 다시 열기 · 꺼내기 · 이름은 바깥에서 안 보임 · 지우기 · 이름 바꾸기', () async { final v = await Vault.create(dir, '비밀', iterations: 1000); expect(await Vault.exists(dir), isTrue); final a = src('여행 사진.jpg', 5000); final b = src('empty.txt', 0); final ea = await v.add(a.path, chunk: 1024); await v.add(b.path, chunk: 1024); expect(a.existsSync(), isTrue, reason: '넣기 처음 값은 복사'); final names = dir.listSync().map((f) => p.basename(f.path)).join(' '); expect(names, isNot(contains('여행'))); expect(names, isNot(contains('empty'))); final again = await Vault.unlock(dir, '비밀'); expect(again.entries.map((e) => (e.name, e.size)), [('여행 사진.jpg', 5000), ('empty.txt', 0)]); final out = p.join(tmp.path, 'out.jpg'); await again.export(again.entries.first, out); expect(File(out).readAsBytesSync(), a.readAsBytesSync()); final outEmpty = p.join(tmp.path, 'out.txt'); await again.export(again.entries.last, outEmpty); expect(File(outEmpty).lengthSync(), 0); await again.rename(again.entries.first, '바다.jpg'); await again.remove(again.entries.last); final third = await Vault.unlock(dir, '비밀'); expect(third.entries.map((e) => e.name), ['바다.jpg']); expect(File(p.join(dir.path, '${ea.blob}.jjv')).existsSync(), isTrue); expect(dir.listSync().whereType().length, 3, reason: 'vault.json · index.jjv · 내용 1개'); }); test('틀린 비밀번호 · 비밀번호 바꾸기 (예전 것은 안 열림, 파일은 다시 암호화 안 함) · 열쇠로 열기 ([마스터로도 열기])', () async { final v = await Vault.create(dir, 'old', iterations: 1000); final e = await v.add(src('a.bin', 3000).path, chunk: 1024); await expectLater(Vault.unlock(dir, 'wrong'), throwsA(isA())); final blobBefore = File(p.join(dir.path, '${e.blob}.jjv')).readAsBytesSync(); await v.changePassword('new', iterations: 1000); await expectLater(Vault.unlock(dir, 'old'), throwsA(isA())); final n = await Vault.unlock(dir, 'new'); expect(n.entries.single.name, 'a.bin'); expect(File(p.join(dir.path, '${e.blob}.jjv')).readAsBytesSync(), blobBefore); final byKey = await Vault.unlockWithKey(dir, v.keyForMasterCopy); expect(byKey.entries.single.name, 'a.bin'); await expectLater(Vault.unlockWithKey(dir, VaultCrypto.random(32)), throwsA(isA())); }); test('같은 파일 id 를 다시 쓰지 않음: 같은 원본을 두 번 넣어도 · 목록을 다시 쓸 때마다 새 무작위 id (조각 nonce 가 (파일 열쇠, 조각 번호) 이므로)', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); final s = src('same.bin', 2000); Uint8List idOf(String name) => Uint8List.fromList( File(p.join(dir.path, name)).readAsBytesSync().sublist(9, VaultCrypto.headerLength)); final ids = {}; final indexIds = {toHex(idOf('index.jjv'))}; for (var i = 0; i < 5; i++) { final e = await v.add(s.path, chunk: 1024); ids.add(toHex(idOf('${e.blob}.jjv'))); indexIds.add(toHex(idOf('index.jjv'))); } await v.rename(v.entries.first, 'x'); indexIds.add(toHex(idOf('index.jjv'))); expect(ids.length, 5, reason: '넣을 때마다 다른 id'); expect(indexIds.length, 7, reason: '목록도 다시 쓸 때마다 다른 id'); expect(ids.intersection(indexIds), isEmpty); }); test('바뀐 조각이 있는 파일은 꺼내지 않고 (부분 파일도 남기지 않음) 오류', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); final e = await v.add(src('a.bin', 3000).path, chunk: 1024); final f = File(p.join(dir.path, '${e.blob}.jjv')); final bytes = f.readAsBytesSync()..[VaultCrypto.headerLength + 1500] ^= 0xFF; f.writeAsBytesSync(bytes); final out = p.join(tmp.path, 'out.bin'); await expectLater(v.export(e, out), throwsA(isA())); expect(File(out).existsSync(), isFalse); expect(File('$out.jjvtmp').existsSync(), isFalse); }); test('끊긴 쓰기 복구: 넣다 끊김 (내용만 있고 목록에 없음 · .tmp) · 목록 쓰다 끊김 · 지우다 끊김 → 다음에 열 때 정리, 목록은 그대로', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); final keep = await v.add(src('keep.bin', 2000).path, chunk: 1024); final gone = await v.add(src('gone.bin', 100).path, chunk: 1024); // 1) 넣다가 내용 파일 쓰는 중에 끊김 File(p.join(dir.path, 'aaaa.jjv.tmp')).writeAsBytesSync([1, 2, 3]); // 2) 내용은 다 썼는데 목록을 고치기 전에 끊김 (목록에 없는 .jjv) File(p.join(dir.path, 'bbbb.jjv')).writeAsBytesSync(VaultCrypto.encryptAll(VaultCrypto.random(32), Uint8List(10))); // 3) 목록 (index) 을 새로 쓰다 끊김 - 예전 index.jjv 는 그대로 File(p.join(dir.path, 'index.jjv.tmp')).writeAsBytesSync([9, 9, 9]); // 4) 지우기: 목록은 고쳤는데 내용 파일을 지우기 전에 끊김 final goneFile = File(p.join(dir.path, '${gone.blob}.jjv')); final goneBytes = goneFile.readAsBytesSync(); await v.remove(gone); goneFile.writeAsBytesSync(goneBytes); final again = await Vault.unlock(dir, 'pw'); expect(again.entries.map((e) => e.name), ['keep.bin']); final left = dir.listSync().map((f) => p.basename(f.path)).toSet(); expect(left, {'vault.json', 'index.jjv', '${keep.blob}.jjv'}, reason: '끊긴 것은 모두 정리'); await again.export(again.entries.single, p.join(tmp.path, 'k.bin')); expect(File(p.join(tmp.path, 'k.bin')).lengthSync(), 2000); }); test('넣다가 원본을 읽지 못하면 반쪽 내용 파일 · 목록을 남기지 않음 · 이미 있으면 새로 만들지 않음 · 이 판이 모르는 형식', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); await expectLater(v.add(p.join(tmp.path, 'none.bin')), throwsA(isA())); expect(v.entries, isEmpty); expect(dir.listSync().length, 2, reason: 'vault.json · index.jjv 만'); await expectLater(Vault.create(dir, 'x', iterations: 1000), throwsA(isA())); final j = File(p.join(dir.path, 'vault.json')); j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 3', '"format": 4')); await expectLater(Vault.unlock(dir, 'pw'), throwsA(isA())); }); test('2단계 폴더: 넣기 · 빈 폴더 만들기 · 다시 열어도 그대로 · 이름 겹침 " (2)" · 파일 · 폴더 옮기기 · 자기 안으로는 안 됨 · 폴더째 지우기', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); await v.add(src('a.jpg', 100).path, folder: '여행/바다'); await v.add(src('a.jpg', 100).path, folder: '여행/바다'); await v.add(src('top.txt', 5).path); await v.mkdir('빈 폴더'); expect(v.folders, ['빈 폴더', '여행', '여행/바다']); expect(v.filesIn('여행/바다').map((e) => e.name), ['a.jpg', 'a (2).jpg']); expect(v.subfolders(''), ['빈 폴더', '여행']); expect(v.subfolders('여행'), ['바다']); // 바깥에서는 폴더 이름도 보이지 않음 (목록 · 폴더 목록은 암호화) final names = dir.listSync().map((f) => p.basename(f.path)).join(' '); expect(names, isNot(contains('여행'))); expect(names, isNot(contains('빈'))); var again = await Vault.unlock(dir, 'pw'); expect(again.folders, ['빈 폴더', '여행', '여행/바다']); expect(again.filesUnder('여행').length, 2); // 파일 옮기기: 겹치면 " (2)" await again.add(src('a.jpg', 100).path, folder: '빈 폴더'); final moved = await again.moveEntry(again.filesIn('빈 폴더').single, '여행/바다'); expect(moved.name, 'a (3).jpg'); expect(again.folders, contains('빈 폴더'), reason: '만든 폴더는 비어도 남음'); // 폴더 옮기기 · 이름 바꾸기 (안의 것 모두) await expectLater(again.moveFolder('여행', '여행/바다/깊이'), throwsA(isA()), reason: '자기 안으로'); await expectLater(again.moveFolder('여행', '빈 폴더'), throwsA(isA()), reason: '이미 있음'); await again.moveFolder('여행', '빈 폴더/2026 여행'); expect(again.folders, ['빈 폴더', '빈 폴더/2026 여행', '빈 폴더/2026 여행/바다']); expect(again.filesIn('빈 폴더/2026 여행/바다').length, 3); again = await Vault.unlock(dir, 'pw'); expect(again.filesUnder('빈 폴더').map((e) => e.path).toSet(), {'빈 폴더/2026 여행/바다/a.jpg', '빈 폴더/2026 여행/바다/a (2).jpg', '빈 폴더/2026 여행/바다/a (3).jpg'}); // 이름 바꾸기: 같은 폴더에 같은 이름이면 안 됨 · 쓸 수 없는 이름 final f = again.filesIn('빈 폴더/2026 여행/바다'); await expectLater(again.rename(f.first, f.last.name), throwsA(isA())); await expectLater(again.rename(f.first, '../x'), throwsA(isA())); await expectLater(again.mkdir('a/../b'), throwsA(isA())); // 폴더째 지우기: 목록 · 내용 파일 모두 expect(await again.removeFolder('빈 폴더'), 3); expect(again.folders, isEmpty); again = await Vault.unlock(dir, 'pw'); expect(again.entries.map((e) => e.path), ['top.txt']); expect(dir.listSync().where((x) => x.path.endsWith('.jjv') && !x.path.endsWith('index.jjv')).length, 1); }); test('2단계 목록은 1단계 판과 맞음: 맨 위 파일은 "folder" 를 쓰지 않고, 폴더 목록 (folders.jjd) 은 1단계의 정리 (recover) 가 지우지 않음', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); await v.add(src('top.txt', 5).path); await v.add(src('in.txt', 5).path, folder: '가'); await v.mkdir('나'); final key = v.keyForMasterCopy; final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, File(p.join(dir.path, 'index.jjv')).readAsBytesSync()))) as List; expect((list[0] as Map).containsKey('folder'), isFalse); expect((list[1] as Map)['folder'], '가'); await v.recover(); expect(File(p.join(dir.path, 'folders.jjd')).existsSync(), isTrue); // 목록에 이상한 폴더 이름이 들어 있으면 맨 위로 (밖으로 나가는 경로를 만들지 않게) final bad = [ ...list, {'blob': (list[0] as Map)['blob'], 'name': 'x', 'size': 5, 'added': 0, 'folder': '../밖'}, ]; File(p.join(dir.path, 'index.jjv')).writeAsBytesSync(VaultCrypto.encryptAll(key, Uint8List.fromList(utf8.encode(jsonEncode(bad))))); final again = await Vault.unlock(dir, 'pw'); expect(again.entries.last.folder, ''); // 1단계 (형식 1) 보관함: 열리고, 열쇠가 맞은 뒤 형식 번호를 지금 것 (3) 으로 (예전 판은 "새 판으로" 를 알림) final j = File(p.join(dir.path, 'vault.json')); final before = jsonDecode(j.readAsStringSync()) as Map; j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 3', '"format": 1')); await expectLater(Vault.unlock(dir, 'wrong'), throwsA(isA())); expect((jsonDecode(j.readAsStringSync()) as Map)['format'], 1, reason: '틀린 비밀번호면 바꾸지 않음'); await Vault.unlock(dir, 'pw'); final after = jsonDecode(j.readAsStringSync()) as Map; expect(after['format'], 3); expect([after['salt'], after['key'], after['nonce']], [before['salt'], before['key'], before['nonce']]); }); // 28 지적 · 관리자 지시 (10/21): 목록 항목 ↔ 내용 파일의 짝 (id · 크기) List leftovers(String outDir) => Directory(outDir).listSync().map((e) => p.basename(e.path)).toList(); test('형식 3: 다른 항목의 내용 파일로 덮기 · 같은 내용의 다른 내용 파일 (예전 것) 로 되돌리기 · 크기 다름 - 모두 VaultAuthException, 결과 · .jjvtmp 없음', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); final a = await v.add(src('a.txt', 43).path, chunk: 1024); final b = await v.add(src('b.bin', 2500).path, chunk: 1024); expect(a.id, hasLength(32)); final out = Directory(p.join(tmp.path, 'out'))..createSync(); File blob(VaultEntry e) => File(p.join(dir.path, '${e.blob}.jjv')); final bBytes = blob(b).readAsBytesSync(); // ① B 의 내용 파일을 A 의 것으로 blob(b).writeAsBytesSync(blob(a).readAsBytesSync()); await expectLater(v.export(b, p.join(out.path, 'b.bin')), throwsA(isA())); await expectLater(v.readBytes(b), throwsA(isA())); expect(leftovers(out.path), isEmpty, reason: '결과 · .jjvtmp 를 남기지 않음'); // ② 같은 원본을 다시 넣어 만든 내용 파일 (다른 id) 로 되돌리기 final b2 = await v.add(src('b.bin', 2500).path, chunk: 1024); blob(b).writeAsBytesSync(blob(b2).readAsBytesSync()); await expectLater(v.export(b, p.join(out.path, 'b.bin')), throwsA(isA())); expect(leftovers(out.path), isEmpty); // 제 내용 파일이면 그대로 풀림 blob(b).writeAsBytesSync(bBytes); await v.export(b, p.join(out.path, 'b.bin')); expect(File(p.join(out.path, 'b.bin')).readAsBytesSync(), src('b.bin', 2500).readAsBytesSync()); File(p.join(out.path, 'b.bin')).deleteSync(); // ③ 목록의 크기가 다름 (목록을 그대로 다시 암호화해 크기만 바꿈) final key = v.keyForMasterCopy; final idx = File(p.join(dir.path, 'index.jjv')); final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, idx.readAsBytesSync()))) as List; for (final x in list) { if ((x as Map)['blob'] == a.blob) x['size'] = 44; } idx.writeAsBytesSync(VaultCrypto.encryptAll(key, Uint8List.fromList(utf8.encode(jsonEncode(list))))); final again = await Vault.unlock(dir, 'pw'); final a2 = again.entries.firstWhere((e) => e.blob == a.blob); await expectLater(again.export(a2, p.join(out.path, 'a.txt')), throwsA(isA())); await expectLater(again.readBytes(a2), throwsA(isA())); expect(leftovers(out.path), isEmpty); }); test('형식 2 보관함 (목록에 id 없음) 은 비밀번호가 맞을 때 목록에 id 를 채우고 3 으로 · 그 뒤 풀림', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); final a = await v.add(src('a.txt', 300).path, chunk: 1024); final key = v.keyForMasterCopy; final idx = File(p.join(dir.path, 'index.jjv')); final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, idx.readAsBytesSync()))) as List; for (final x in list) { (x as Map).remove('id'); } idx.writeAsBytesSync(VaultCrypto.encryptAll(key, Uint8List.fromList(utf8.encode(jsonEncode(list))))); final j = File(p.join(dir.path, 'vault.json')); j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 3', '"format": 2')); final again = await Vault.unlock(dir, 'pw'); expect(again.entries.single.id, a.id); expect((jsonDecode(j.readAsStringSync()) as Map)['format'], 3); final out = p.join(tmp.path, 'a_out.txt'); await again.export(again.entries.single, out); expect(File(out).readAsBytesSync(), src('a.txt', 300).readAsBytesSync()); }); test('꺼내기 기록: 쓰기 시작에 .jjvtmp 를 적고 끝나면 지움 · 강제로 꺼져 남은 것은 켤 때 기록에 있는 것만 지움', () async { final v = await Vault.create(dir, 'pw', iterations: 1000); final a = await v.add(src('a.txt', 3000).path, chunk: 1024); final root = Directory(p.join(tmp.path, 'app'))..createSync(); final journal = VaultExportJournal(root.path); final seen = []; await v.export(a, p.join(tmp.path, 'out.txt'), onTemp: (x) { seen.add(x); journal.add(x); expect(File(p.join(root.path, VaultExportJournal.name)).readAsStringSync(), contains('out.txt.jjvtmp')); }, onTempDone: journal.remove); expect(seen.single, endsWith('out.txt.jjvtmp')); expect(File(p.join(root.path, VaultExportJournal.name)).existsSync(), isFalse, reason: '끝나면 기록도 지움'); // 강제로 꺼진 것처럼: 기록만 남고 .jjvtmp 가 받는 폴더에 남음 + 기록에 없는 사용자 파일 · 이상한 줄 final left = File(p.join(tmp.path, 'big.mkv.jjvtmp'))..writeAsStringSync('plain'); final user = File(p.join(tmp.path, 'mine.jjvtmp'))..writeAsStringSync('user'); final other = File(p.join(tmp.path, 'notes.txt'))..writeAsStringSync('keep'); journal.add(left.path); journal.add(other.path); expect(cleanVaultExportJournal(root.path), [left.path]); expect(left.existsSync(), isFalse); expect(user.existsSync(), isTrue, reason: '기록에 없는 것은 건드리지 않음'); expect(other.existsSync(), isTrue, reason: '.jjvtmp 가 아니면 지우지 않음'); expect(File(p.join(root.path, VaultExportJournal.name)).existsSync(), isFalse); }); }); }