153 lines
6.7 KiB
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';
|
|
}
|