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: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('바뀐 조각이 있는 파일은 꺼내지 않고 (부분 파일도 남기지 않음) 오류', () 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": 1', '"format": 2')); await expectLater(Vault.unlock(dir, 'pw'), throwsA(isA())); }); }); }