JJ_MKVMaker/test/vault_test.dart

278 lines
17 KiB
Dart

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<int> 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<VaultAuthException>()));
});
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<VaultAuthException>()));
expect(() => VaultCrypto.decryptAll(vaultKey, Uint8List.fromList(a.sublist(0, h))), throwsA(isA<VaultAuthException>()), 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<File>().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<VaultAuthException>()));
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<VaultAuthException>()));
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<VaultAuthException>()));
});
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 = <String>{};
final indexIds = <String>{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<VaultAuthException>()));
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<FileSystemException>()));
expect(v.entries, isEmpty);
expect(dir.listSync().length, 2, reason: 'vault.json · index.jjv 만');
await expectLater(Vault.create(dir, 'x', iterations: 1000), throwsA(isA<StateError>()));
final j = File(p.join(dir.path, 'vault.json'));
j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 2', '"format": 3'));
await expectLater(Vault.unlock(dir, 'pw'), throwsA(isA<VaultFormatException>()));
});
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<StateError>()), reason: '자기 안으로');
await expectLater(again.moveFolder('여행', '빈 폴더'), throwsA(isA<StateError>()), 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<StateError>()));
await expectLater(again.rename(f.first, '../x'), throwsA(isA<ArgumentError>()));
await expectLater(again.mkdir('a/../b'), throwsA(isA<ArgumentError>()));
// 폴더째 지우기: 목록 · 내용 파일 모두
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) 보관함: 열리고, 열쇠가 맞은 뒤 형식 번호만 2 로 (1단계만 아는 판은 "새 판으로" 를 알림)
final j = File(p.join(dir.path, 'vault.json'));
final before = jsonDecode(j.readAsStringSync()) as Map;
j.writeAsStringSync(j.readAsStringSync().replaceFirst('"format": 2', '"format": 1'));
await expectLater(Vault.unlock(dir, 'wrong'), throwsA(isA<VaultAuthException>()));
expect((jsonDecode(j.readAsStringSync()) as Map)['format'], 1, reason: '틀린 비밀번호면 바꾸지 않음');
await Vault.unlock(dir, 'pw');
final after = jsonDecode(j.readAsStringSync()) as Map;
expect(after['format'], 2);
expect([after['salt'], after['key'], after['nonce']], [before['salt'], before['key'], before['nonce']]);
});
});
}