WIP 암호 보관함 1단계-1 암호 · 폴더 (관리자 결정 10/21, 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)): 화면 없이 바탕만 - 비밀번호 → PBKDF2-SHA256 (반복 수는 vault.json 에, 처음 30만 번 - d3 판단: 설계안의 60만은 이 PC 에서 10만 = 0.3초라 폰에서 수 초, 반복 수를 파일에 두어 나중에 올릴 수 있음, Isolate 에서) → AES-256-GCM 으로 무작위 보관함 열쇠를 감쌈 (비밀번호를 바꿔도 파일은 다시 암호화 안 함). 파일마다 무작위 id → 파일 열쇠 HMAC-SHA256, 1MB 조각마다 AES-256-GCM (nonce = 조각 번호, AAD = 머리 + 조각 번호 + 마지막 표시 - 바꾸기 · 바꿔 끼우기 · 끝 자르기 · 덧붙이기를 알아챔). 이름 · 크기 · 넣은 날은 암호화한 index.jjv 에만, 바깥 이름은 무작위. 쓰기는 .tmp → 이름 바꾸기, 목록이 기준 (넣기: 내용 → 목록, 지우기: 목록 → 내용), 다음에 열 때 끊긴 것 정리. 꺼내기는 다 풀고 인증이 맞은 뒤에만 그 이름으로. vault.json 형식 번호 - 모르는 새 형식은 열지 않음. pointycastle (MIT) 직접 의존 (dartssh2 를 통해 있던 4.0.0 그대로), 고지에 "보관함 암호화에도 씀" · 시험 10개 (AES-GCM · PBKDF2 알려진 벡터 · 틀린 비밀번호 · 변조 6가지 · 왕복 크기 · 끊긴 쓰기 4가지 · 형식) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>xplore_p3b
parent
9425de7fc7
commit
b51a17a82b
|
|
@ -51,7 +51,7 @@ WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도
|
|||
- archive 4.3.0 (ZIP 풀기 · 묶기) MIT https://pub.dev/packages/archive
|
||||
Copyright (c) 2013-2021 Brendan Duncan.
|
||||
- dartssh2 4.1.0 (SFTP) MIT https://pub.dev/packages/dartssh2
|
||||
Copyright (c) 2022 xuty. 함께 쓰는 pointycastle (MIT) · pinenacl (MIT) · asn1lib (BSD-3-Clause) 의 원문은 "환경 설정 > 라이선스"
|
||||
Copyright (c) 2022 xuty. 함께 쓰는 pointycastle (MIT, 앱의 암호 보관함 암호화 (AES-256-GCM) 에도 씀) · pinenacl (MIT) · asn1lib (BSD-3-Clause) 의 원문은 "환경 설정 > 라이선스"
|
||||
- qr 3.0.2 (Wi-Fi 공유 접속 QR) BSD-3-Clause https://pub.dev/packages/qr
|
||||
Copyright 2014, the Dart QR project authors.
|
||||
- Microsoft Visual C++ 런타임 (msvcp140.dll, vcruntime140*.dll)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스
|
|||
- smbj 0.15.0 (SMB, SMB2 · SMB3) Apache-2.0 https://github.com/hierynomus/smbj
|
||||
함께: asn-one 0.6.0 (Apache-2.0) · Bouncy Castle bcprov 1.85.2 (MIT) · SLF4J API 2.0.18 (MIT) · MBassador 1.3.2 (MIT).
|
||||
원문: 앱 안 "오픈 소스 라이선스" 의 smbj (SMBJ_LICENSES.txt - Apache License 2.0 전문 포함)
|
||||
Copyright (c) 2022 xuty. 함께 쓰는 pointycastle (MIT) · pinenacl (MIT) · asn1lib (BSD-3-Clause) 의 원문은 [패키지 라이선스]
|
||||
Copyright (c) 2022 xuty. 함께 쓰는 pointycastle (MIT, 앱의 암호 보관함 암호화 (AES-256-GCM) 에도 씀) · pinenacl (MIT) · asn1lib (BSD-3-Clause) 의 원문은 [패키지 라이선스]
|
||||
- qr 3.0.2 (Wi-Fi 공유 접속 QR) BSD-3-Clause https://pub.dev/packages/qr
|
||||
Copyright 2014, the Dart QR project authors.
|
||||
- charset (Apache-2.0) · cp949_codec (BSD) · 그 밖의 Dart 패키지: [패키지 라이선스]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
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';
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'vault_crypto.dart';
|
||||
|
||||
/// 보관함 안의 파일 하나 (원래 이름 · 크기 · 넣은 날은 암호화한 목록 index.jjv 에만)
|
||||
class VaultEntry {
|
||||
final String blob; // 보관함 폴더 안의 무작위 이름 (확장자 .jjv 뺌)
|
||||
final String name;
|
||||
final int size;
|
||||
final DateTime added;
|
||||
const VaultEntry(this.blob, this.name, this.size, this.added);
|
||||
|
||||
Map<String, Object> toJson() => {'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch};
|
||||
factory VaultEntry.fromJson(Map<String, dynamic> j) => VaultEntry(
|
||||
'${j['blob']}', '${j['name']}', (j['size'] as num?)?.toInt() ?? 0, DateTime.fromMillisecondsSinceEpoch((j['added'] as num?)?.toInt() ?? 0));
|
||||
}
|
||||
|
||||
/// X-plore 비교 5-2 암호 보관함 - 폴더 하나 (처음 자리: Android Download/JJ_MKVMaker/vault, Windows 문서\JJ_MKVMaker\vault).
|
||||
/// - vault.json: 형식 번호 · PBKDF2 반복 수 · 솔트 · 감싼 보관함 열쇠 (비밀번호를 바꾸면 이것만 바뀜)
|
||||
/// - index.jjv: 암호화한 목록 (원래 이름 · 크기 · 넣은 날)
|
||||
/// - <무작위>.jjv: 파일마다 암호화한 내용 (이름은 바깥에서 보이지 않음)
|
||||
/// 쓰기는 늘 ".tmp" 에 다 쓴 뒤 이름을 바꾼다. 목록 (index) 이 기준 - 넣을 때는 내용 → 목록 차례로, 지울 때는 목록 → 내용 차례로 바꿔
|
||||
/// 중간에 끊겨도 목록과 다른 것은 다음에 열 때 ([recover]) 지운다
|
||||
class Vault {
|
||||
final Directory dir;
|
||||
final Uint8List _key;
|
||||
final List<VaultEntry> _entries;
|
||||
Vault._(this.dir, this._key, this._entries);
|
||||
|
||||
static const format = 1;
|
||||
static const _json = 'vault.json', _index = 'index.jjv', _ext = '.jjv';
|
||||
|
||||
List<VaultEntry> get entries => List.unmodifiable(_entries);
|
||||
|
||||
/// 보관함 열쇠 ([마스터로도 열기] 가 안전 저장소에 한 벌 둘 때만 꺼낸다)
|
||||
Uint8List get keyForMasterCopy => Uint8List.fromList(_key);
|
||||
|
||||
static Future<bool> exists(Directory dir) => File(p.join(dir.path, _json)).exists();
|
||||
|
||||
/// 새 보관함 (이미 있으면 [StateError])
|
||||
static Future<Vault> create(Directory dir, String password, {int iterations = VaultCrypto.defaultIterations}) async {
|
||||
if (await exists(dir)) throw StateError('vault exists');
|
||||
await dir.create(recursive: true);
|
||||
final key = VaultCrypto.random(32);
|
||||
final v = Vault._(dir, key, []);
|
||||
await v._writeHeader(password, iterations);
|
||||
await v._saveIndex();
|
||||
return v;
|
||||
}
|
||||
|
||||
Future<void> _writeHeader(String password, int iterations) async {
|
||||
final salt = VaultCrypto.random(16);
|
||||
final kek = await Isolate.run(() => VaultCrypto.kek(password, salt, iterations));
|
||||
final (nonce, wrapped) = VaultCrypto.wrapKey(kek, _key);
|
||||
final j = {
|
||||
'format': format,
|
||||
'kdf': 'pbkdf2-sha256',
|
||||
'iterations': iterations,
|
||||
'salt': base64.encode(salt),
|
||||
'nonce': base64.encode(nonce),
|
||||
'key': base64.encode(wrapped),
|
||||
};
|
||||
await _atomicWrite(File(p.join(dir.path, _json)), utf8.encode(const JsonEncoder.withIndent(' ').convert(j)));
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _readHeader(Directory dir) async {
|
||||
final j = jsonDecode(await File(p.join(dir.path, _json)).readAsString());
|
||||
if (j is! Map<String, dynamic>) throw const VaultFormatException('vault.json');
|
||||
final f = j['format'];
|
||||
if (f is! int || f > format) throw VaultFormatException('format $f');
|
||||
if (j['kdf'] != 'pbkdf2-sha256') throw VaultFormatException('kdf ${j['kdf']}');
|
||||
return j;
|
||||
}
|
||||
|
||||
/// 비밀번호로 연다 - 틀리면 [VaultAuthException], 이 판이 모르는 꼴이면 [VaultFormatException]
|
||||
static Future<Vault> unlock(Directory dir, String password) async {
|
||||
final j = await _readHeader(dir);
|
||||
final salt = base64.decode('${j['salt']}');
|
||||
final iterations = (j['iterations'] as num).toInt();
|
||||
final kek = await Isolate.run(() => VaultCrypto.kek(password, salt, iterations));
|
||||
final key = VaultCrypto.unwrapKey(kek, base64.decode('${j['nonce']}'), base64.decode('${j['key']}'));
|
||||
return _open(dir, key);
|
||||
}
|
||||
|
||||
/// 안전 저장소의 열쇠로 연다 ([마스터로도 열기])
|
||||
static Future<Vault> unlockWithKey(Directory dir, Uint8List key) async {
|
||||
await _readHeader(dir);
|
||||
return _open(dir, key);
|
||||
}
|
||||
|
||||
static Future<Vault> _open(Directory dir, Uint8List key) async {
|
||||
final data = await File(p.join(dir.path, _index)).readAsBytes();
|
||||
final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, data))) as List;
|
||||
final v = Vault._(dir, key, [for (final x in list) VaultEntry.fromJson(x as Map<String, dynamic>)]);
|
||||
await v.recover();
|
||||
return v;
|
||||
}
|
||||
|
||||
/// 끊긴 쓰기 정리: 남은 .tmp · 목록에 없는 내용 파일을 지운다
|
||||
Future<List<String>> recover() async {
|
||||
final removed = <String>[];
|
||||
final known = {for (final e in _entries) '${e.blob}$_ext'};
|
||||
await for (final f in dir.list()) {
|
||||
if (f is! File) continue;
|
||||
final name = p.basename(f.path);
|
||||
final orphan = name.endsWith('.tmp') || (name.endsWith(_ext) && name != _index && !known.contains(name));
|
||||
if (!orphan) continue;
|
||||
try {
|
||||
await f.delete();
|
||||
removed.add(name);
|
||||
} catch (_) {}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
Future<void> _saveIndex() async {
|
||||
final plain = utf8.encode(jsonEncode([for (final e in _entries) e.toJson()]));
|
||||
await _atomicWrite(File(p.join(dir.path, _index)), VaultCrypto.encryptAll(_key, Uint8List.fromList(plain)));
|
||||
}
|
||||
|
||||
static Future<void> _atomicWrite(File f, List<int> bytes) async {
|
||||
final tmp = File('${f.path}.tmp');
|
||||
final raf = await tmp.open(mode: FileMode.write);
|
||||
try {
|
||||
await raf.writeFrom(bytes);
|
||||
await raf.flush();
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
await tmp.rename(f.path);
|
||||
}
|
||||
|
||||
String _newBlob() {
|
||||
final r = Random.secure();
|
||||
String n() => List.generate(16, (_) => r.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
|
||||
var b = n();
|
||||
while (_entries.any((e) => e.blob == b)) {
|
||||
b = n();
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/// [src] 를 암호화해 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). [onProgress] (넣은 바이트, 전체)
|
||||
Future<VaultEntry> add(String src, {String? name, int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async {
|
||||
final blob = _newBlob();
|
||||
final out = File(p.join(dir.path, '$blob$_ext'));
|
||||
final tmp = File('${out.path}.tmp');
|
||||
final inp = await File(src).open();
|
||||
final len = await inp.length();
|
||||
final id = VaultCrypto.random(16);
|
||||
final h = VaultCrypto.header(id, chunk);
|
||||
final key = VaultCrypto.fileKey(_key, id);
|
||||
final w = await tmp.open(mode: FileMode.write);
|
||||
try {
|
||||
await w.writeFrom(h);
|
||||
var off = 0;
|
||||
var i = 0;
|
||||
do {
|
||||
final plain = await inp.read(min(chunk, len - off));
|
||||
off += plain.length;
|
||||
await w.writeFrom(VaultCrypto.sealChunk(key, h, i, plain, last: off >= len));
|
||||
i++;
|
||||
onProgress?.call(off, len);
|
||||
if (plain.isEmpty && off < len) throw const FileSystemException('short read');
|
||||
} while (off < len);
|
||||
await w.flush();
|
||||
} catch (_) {
|
||||
await w.close();
|
||||
await tmp.delete().catchError((_) => tmp);
|
||||
rethrow;
|
||||
} finally {
|
||||
await inp.close();
|
||||
}
|
||||
await w.close();
|
||||
await tmp.rename(out.path);
|
||||
final e = VaultEntry(blob, name ?? p.basename(src), len, DateTime.now());
|
||||
_entries.add(e);
|
||||
try {
|
||||
await _saveIndex();
|
||||
} catch (_) {
|
||||
_entries.remove(e);
|
||||
await out.delete().catchError((_) => out);
|
||||
rethrow;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/// 풀어서 [outPath] 에 쓴다 (다 풀고 인증이 맞은 뒤에만 그 이름으로 - 바뀐 조각이 있으면 [VaultAuthException] 이고 파일을 남기지 않음)
|
||||
Future<void> export(VaultEntry e, String outPath) async {
|
||||
final f = await File(p.join(dir.path, '${e.blob}$_ext')).open();
|
||||
final tmp = File('$outPath.jjvtmp');
|
||||
final w = await tmp.open(mode: FileMode.write);
|
||||
try {
|
||||
final len = await f.length();
|
||||
final h = await f.read(VaultCrypto.headerLength);
|
||||
final (id, chunk) = VaultCrypto.parseHeader(h);
|
||||
final key = VaultCrypto.fileKey(_key, id);
|
||||
final full = VaultCrypto.sealedChunkLength(chunk);
|
||||
var off = VaultCrypto.headerLength;
|
||||
var i = 0;
|
||||
if (off >= len) throw const VaultAuthException();
|
||||
while (off < len) {
|
||||
final sealed = await f.read(min(full, len - off));
|
||||
off += sealed.length;
|
||||
await w.writeFrom(VaultCrypto.openChunk(key, h, i, sealed, last: off >= len));
|
||||
i++;
|
||||
}
|
||||
await w.flush();
|
||||
} catch (_) {
|
||||
await w.close();
|
||||
await tmp.delete().catchError((_) => tmp);
|
||||
rethrow;
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
await w.close();
|
||||
await tmp.rename(outPath);
|
||||
}
|
||||
|
||||
/// 보관함에서 지운다 (목록 먼저 - 그 뒤 내용 파일)
|
||||
Future<void> remove(VaultEntry e) async {
|
||||
_entries.removeWhere((x) => x.blob == e.blob);
|
||||
await _saveIndex();
|
||||
await File(p.join(dir.path, '${e.blob}$_ext')).delete().catchError((_) => File(''));
|
||||
}
|
||||
|
||||
/// 이름 바꾸기 (목록만)
|
||||
Future<void> rename(VaultEntry e, String name) async {
|
||||
final i = _entries.indexWhere((x) => x.blob == e.blob);
|
||||
if (i < 0) return;
|
||||
_entries[i] = VaultEntry(e.blob, name, e.size, e.added);
|
||||
await _saveIndex();
|
||||
}
|
||||
|
||||
/// 비밀번호 바꾸기 (감싼 열쇠만 다시 - 파일은 다시 암호화하지 않음)
|
||||
Future<void> changePassword(String password, {int iterations = VaultCrypto.defaultIterations}) => _writeHeader(password, iterations);
|
||||
}
|
||||
|
|
@ -826,7 +826,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ dependencies:
|
|||
# 시험판이 사용자 데이터와 다른 폴더를 쓰게 (JJ_MKVMAKER_DATA, Windows) - path_provider 가 이미 쓰는 것
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
dartssh2: ^4.1.0
|
||||
# X-plore 비교 5-2 암호 보관함 (AES-256-GCM, MIT) - dartssh2 를 통해 이미 들어와 있던 판 그대로
|
||||
pointycastle: ^4.0.0
|
||||
dependency_overrides:
|
||||
flutter_inappwebview_android:
|
||||
path: packages/flutter_inappwebview_android
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
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>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue