245 lines
9.6 KiB
Dart
245 lines
9.6 KiB
Dart
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);
|
|
}
|