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