JJ_MKVMaker/lib/core/vault_crypto.dart

153 lines
6.7 KiB
Dart

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 에, 처음 30만 번) → 열쇠 감싸는 열쇠 (KEK) → AES-256-GCM 으로 보관함 열쇠 (무작위 32바이트) 를 감쌈
/// - 파일마다 무작위 id (16바이트) → 파일 열쇠 = 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 = 300000;
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';
}