import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:pointycastle/export.dart'; import '../app/master_lock.dart' show pbkdf2Sha256; /// X-plore 비교 5-2 암호 보관함 (사용자 결정 10/21, 관리자 결정 · 설계안: 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)). /// 암호 부분만 (파일 · 화면 없음 - 시험하기 쉽게): /// - 비밀번호 → PBKDF2-SHA256 (반복 수는 vault.json 에, 처음 60만 번 - OWASP 권장, 관리자 결정 10/21) → 열쇠 감싸는 열쇠 (KEK) → AES-256-GCM 으로 보관함 열쇠 (무작위 32바이트) 를 감쌈 /// - 파일마다 (넣을 때마다 · 목록을 다시 쓸 때마다) 새 무작위 id (16바이트) → 파일 열쇠 - 조각 nonce 가 (파일 열쇠, 조각 번호) 라 같은 id 를 다시 쓰지 않는다 = HMAC-SHA256(보관함 열쇠, "jjv-file|" + id) /// - 파일 = 머리 + 조각들. 조각마다 AES-256-GCM (nonce = 0 8바이트 + 조각 번호, AAD = 머리 + 조각 번호 + 마지막 표시) - /// 조각을 바꾸거나 · 바꿔 끼우거나 · 끝을 자르면 풀 때 알아챈다 /// 암호 라이브러리: pointycastle (MIT, dartssh2 를 통해 이미 앱에 있던 판) class VaultCrypto { static const magic = 'JJV1'; static const version = 1; static const defaultChunk = 1024 * 1024; static const defaultIterations = 600000; static const _tag = 16; static final _rng = Random.secure(); static Uint8List random(int n) => Uint8List.fromList(List.generate(n, (_) => _rng.nextInt(256))); /// 비밀번호 → 열쇠 감싸는 열쇠 (32바이트) static Uint8List kek(String password, Uint8List salt, int iterations) => pbkdf2Sha256(utf8.encode(password), salt, iterations, 32); static Uint8List _gcm(bool encrypt, Uint8List key, Uint8List nonce, Uint8List data, Uint8List aad) { final c = GCMBlockCipher(AESEngine())..init(encrypt, AEADParameters(KeyParameter(key), _tag * 8, nonce, aad)); return c.process(data); } /// AES-256-GCM 한 번 (시험 벡터 확인용으로도 연다) static Uint8List seal(Uint8List key, Uint8List nonce, Uint8List plain, [Uint8List? aad]) => _gcm(true, key, nonce, plain, aad ?? Uint8List(0)); /// 풀기 - 인증이 틀리면 [VaultAuthException] static Uint8List open(Uint8List key, Uint8List nonce, Uint8List sealed, [Uint8List? aad]) { try { return _gcm(false, key, nonce, sealed, aad ?? Uint8List(0)); } on InvalidCipherTextException { throw const VaultAuthException(); } on ArgumentError { throw const VaultAuthException(); } } static final _wrapAad = Uint8List.fromList(utf8.encode('jjvault-key-v1')); /// 보관함 열쇠를 [kek] 로 감싼다 → (nonce, 감싼 것) static (Uint8List, Uint8List) wrapKey(Uint8List kek, Uint8List vaultKey) { final nonce = random(12); return (nonce, seal(kek, nonce, vaultKey, _wrapAad)); } /// 감싼 보관함 열쇠를 푼다 - 비밀번호가 틀리면 [VaultAuthException] static Uint8List unwrapKey(Uint8List kek, Uint8List nonce, Uint8List wrapped) => open(kek, nonce, wrapped, _wrapAad); static Uint8List fileKey(Uint8List vaultKey, Uint8List fileId) => Uint8List.fromList(Hmac(sha256, vaultKey).convert([...utf8.encode('jjv-file|'), ...fileId]).bytes); /// 파일 머리: "JJV1" · 판 (1) · 조각 크기 (4, 큰 쪽 먼저) · 파일 id (16) = 25바이트 static Uint8List header(Uint8List fileId, int chunk) => Uint8List.fromList([ ...ascii.encode(magic), version, (chunk >> 24) & 0xFF, (chunk >> 16) & 0xFF, (chunk >> 8) & 0xFF, chunk & 0xFF, ...fileId, ]); static const headerLength = 25; static Uint8List _nonce(int index) => Uint8List(12)..buffer.asByteData().setUint32(8, index); static Uint8List _aad(Uint8List header, int index, bool last) { final b = BytesBuilder(copy: false) ..add(header) ..add(Uint8List(4)..buffer.asByteData().setUint32(0, index)) ..addByte(last ? 1 : 0); return b.takeBytes(); } /// 조각 하나를 감싼다 ([last] 는 마지막 조각 - 끝을 잘라 낸 것을 알아채려고) static Uint8List sealChunk(Uint8List fileKey, Uint8List header, int index, Uint8List plain, {required bool last}) => seal(fileKey, _nonce(index), plain, _aad(header, index, last)); static Uint8List openChunk(Uint8List fileKey, Uint8List header, int index, Uint8List sealed, {required bool last}) => open(fileKey, _nonce(index), sealed, _aad(header, index, last)); /// 머리를 읽는다 → (파일 id, 조각 크기). 모르는 꼴이면 [VaultFormatException] static (Uint8List, int) parseHeader(Uint8List h) { if (h.length < headerLength || ascii.decode(h.sublist(0, 4), allowInvalid: true) != magic) throw const VaultFormatException('magic'); if (h[4] != version) throw VaultFormatException('version ${h[4]}'); final chunk = h.buffer.asByteData(h.offsetInBytes).getUint32(5); if (chunk < 1024 || chunk > 64 * 1024 * 1024) throw const VaultFormatException('chunk'); return (Uint8List.fromList(h.sublist(9, 25)), chunk); } /// 감싼 조각의 길이 (평문 + 인증 16) static int sealedChunkLength(int plain) => plain + _tag; /// 바이트 전체를 한 번에 (작은 목록 파일 index.jjv 용) static Uint8List encryptAll(Uint8List vaultKey, Uint8List plain, {int chunk = defaultChunk}) { final id = random(16); final h = header(id, chunk); final key = fileKey(vaultKey, id); final out = BytesBuilder(copy: false)..add(h); var i = 0; var off = 0; do { final end = min(off + chunk, plain.length); out.add(sealChunk(key, h, i, Uint8List.sublistView(plain, off, end), last: end >= plain.length)); off = end; i++; } while (off < plain.length); return out.takeBytes(); } static Uint8List decryptAll(Uint8List vaultKey, Uint8List data) { final (id, chunk) = parseHeader(Uint8List.sublistView(data, 0, min(data.length, headerLength))); final h = Uint8List.sublistView(data, 0, headerLength); final key = fileKey(vaultKey, id); final out = BytesBuilder(copy: false); var off = headerLength; var i = 0; final full = sealedChunkLength(chunk); if (off >= data.length) throw const VaultAuthException(); while (off < data.length) { final end = min(off + full, data.length); out.add(openChunk(key, h, i, Uint8List.sublistView(data, off, end), last: end >= data.length)); off = end; i++; } return out.takeBytes(); } } /// 비밀번호가 틀렸거나 내용이 바뀜 (인증 실패) class VaultAuthException implements Exception { const VaultAuthException(); @override String toString() => 'VaultAuthException'; } /// 모르는 꼴 (다른 판 · 깨진 파일) class VaultFormatException implements Exception { final String message; const VaultFormatException(this.message); @override String toString() => 'VaultFormatException: $message'; }