490 lines
21 KiB
Dart
490 lines
21 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;
|
|
|
|
/// 2단계: 보관함 안 폴더 ("" = 맨 위, "가/나" 처럼 / 로 이음). 바깥 폴더에는 만들지 않는다 - 목록에만
|
|
final String folder;
|
|
|
|
/// 형식 3 (28 지적 · 관리자 지시 10/21): 내용 파일 머리의 무작위 id (16진). 풀 때 머리 id 와 같아야 한다 -
|
|
/// 다른 항목의 내용 파일로 바꿔 끼우거나 같은 항목의 예전 내용 파일로 되돌린 것을 알아챈다. "" = 모름 (풀지 않음)
|
|
final String id;
|
|
const VaultEntry(this.blob, this.name, this.size, this.added, {this.folder = '', this.id = ''});
|
|
|
|
/// 보관함 안 경로 (폴더/이름)
|
|
String get path => folder.isEmpty ? name : '$folder/$name';
|
|
|
|
VaultEntry copyWith({String? name, String? folder, String? id}) =>
|
|
VaultEntry(blob, name ?? this.name, size, added, folder: folder ?? this.folder, id: id ?? this.id);
|
|
|
|
// 맨 위 파일은 'folder' 를 쓰지 않는다 (1단계 판이 읽는 목록과 같게)
|
|
Map<String, Object> toJson() =>
|
|
{'blob': blob, 'name': name, 'size': size, 'added': added.millisecondsSinceEpoch, if (folder.isNotEmpty) 'folder': folder, if (id.isNotEmpty) 'id': id};
|
|
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),
|
|
folder: Vault.cleanFolder('${j['folder'] ?? ''}') ?? '',
|
|
id: RegExp(r'^[0-9a-f]{32}$').hasMatch('${j['id'] ?? ''}') ? '${j['id']}' : '');
|
|
}
|
|
|
|
/// X-plore 비교 5-2 암호 보관함 - 폴더 하나 (처음 자리: Android Download/JJ_MKVMaker/vault, Windows 문서\JJ_MKVMaker\vault).
|
|
/// - vault.json: 형식 번호 · PBKDF2 반복 수 · 솔트 · 감싼 보관함 열쇠 (비밀번호를 바꾸면 이것만 바뀜)
|
|
/// - index.jjv: 암호화한 목록 (원래 이름 · 크기 · 넣은 날)
|
|
/// - <무작위>.jjv: 파일마다 암호화한 내용 (이름은 바깥에서 보이지 않음)
|
|
/// - folders.jjd (2단계): 암호화한 빈 폴더 목록 (파일이 든 폴더는 index 의 folder 로 안다). 1단계 판의 [recover] 는 .jjv 만 지우므로 남는다
|
|
/// 쓰기는 늘 ".tmp" 에 다 쓴 뒤 이름을 바꾼다. 목록 (index) 이 기준 - 넣을 때는 내용 → 목록 차례로, 지울 때는 목록 → 내용 차례로 바꿔
|
|
/// 중간에 끊겨도 목록과 다른 것은 다음에 열 때 ([recover]) 지운다
|
|
class Vault {
|
|
final Directory dir;
|
|
final Uint8List _key;
|
|
final List<VaultEntry> _entries;
|
|
|
|
/// 사용자가 만든 폴더 (비어도 남게). 파일이 든 폴더 · 그 위 폴더는 [folders] 가 따로 센다
|
|
final Set<String> _folders;
|
|
Vault._(this.dir, this._key, this._entries, [Set<String>? folders]) : _folders = folders ?? {};
|
|
|
|
/// 1 = 1단계, 2 = 2단계 (보관함 안 폴더 - 관리자 지시 10/21: 1단계만 아는 판은 "새 판으로" 를 알리게)
|
|
/// 3 = 목록 항목에 내용 파일 id (28 지적 · 관리자 지시 10/21, 1 · 2 는 열 때 목록에 id 를 채우고 올림)
|
|
static const format = 3;
|
|
static const _json = 'vault.json', _index = 'index.jjv', _ext = '.jjv', _folderFile = 'folders.jjd';
|
|
|
|
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, j);
|
|
}
|
|
|
|
/// 안전 저장소의 열쇠로 연다 ([마스터로도 열기])
|
|
static Future<Vault> unlockWithKey(Directory dir, Uint8List key) async {
|
|
return _open(dir, key, await _readHeader(dir));
|
|
}
|
|
|
|
static Future<Vault> _open(Directory dir, Uint8List key, Map<String, dynamic> header) async {
|
|
final data = await File(p.join(dir.path, _index)).readAsBytes();
|
|
final list = jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, data))) as List;
|
|
final folders = <String>{};
|
|
final ff = File(p.join(dir.path, _folderFile));
|
|
if (await ff.exists()) {
|
|
for (final x in jsonDecode(utf8.decode(VaultCrypto.decryptAll(key, await ff.readAsBytes()))) as List) {
|
|
final f = cleanFolder('$x');
|
|
if (f != null && f.isNotEmpty) folders.add(f);
|
|
}
|
|
}
|
|
final v = Vault._(dir, key, [for (final x in list) VaultEntry.fromJson(x as Map<String, dynamic>)], folders);
|
|
// 예전 형식 (1 · 2) 은 열쇠가 맞은 뒤 목록에 내용 파일 id 를 채워 다시 쓰고 형식 번호를 올린다 (감싼 열쇠 · 솔트는 그대로).
|
|
// 아직 어느 판에도 나가지 않은 형식이라 지금 내용 파일을 그대로 믿는다 (관리자 지시 10/21)
|
|
if ((header['format'] as int) < format) {
|
|
for (var i = 0; i < v._entries.length; i++) {
|
|
final e = v._entries[i];
|
|
if (e.id.isNotEmpty) continue;
|
|
try {
|
|
final raf = await File(p.join(dir.path, '${e.blob}$_ext')).open();
|
|
try {
|
|
final (id, _) = VaultCrypto.parseHeader(await raf.read(VaultCrypto.headerLength));
|
|
v._entries[i] = e.copyWith(id: _hex(id));
|
|
} finally {
|
|
await raf.close();
|
|
}
|
|
} catch (_) {
|
|
// 없거나 깨진 내용 파일은 id 를 모른 채 둔다 (풀 때 실패)
|
|
}
|
|
}
|
|
await v._saveIndex();
|
|
await _atomicWrite(File(p.join(dir.path, _json)), utf8.encode(const JsonEncoder.withIndent(' ').convert({...header, 'format': format})));
|
|
}
|
|
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)));
|
|
}
|
|
|
|
Future<void> _saveFolders() async {
|
|
final plain = utf8.encode(jsonEncode(_folders.toList()..sort()));
|
|
await _atomicWrite(File(p.join(dir.path, _folderFile)), VaultCrypto.encryptAll(_key, Uint8List.fromList(plain)));
|
|
}
|
|
|
|
/// 보관함 안 이름 한 칸으로 쓸 수 있는지 (비었거나 . · .. · / · \ · 줄바꿈 · \0 이 들면 안 됨 - 앞뒤 빈칸은 Android 파일 이름에 있을 수 있어 받음)
|
|
static bool validName(String n) =>
|
|
n.isNotEmpty && n != '.' && n != '..' && !n.contains(RegExp(r'[/\\\u0000\n\r]'));
|
|
|
|
/// "가/나" 꼴로 고른다 (틀리면 null, "" = 맨 위)
|
|
static String? cleanFolder(String f) {
|
|
if (f.isEmpty) return '';
|
|
final parts = f.split('/');
|
|
return parts.every(validName) ? parts.join('/') : null;
|
|
}
|
|
|
|
static String _join(String folder, String name) => folder.isEmpty ? name : '$folder/$name';
|
|
|
|
static String _hex(List<int> b) => b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
|
|
|
|
/// 목록 항목과 내용 파일의 짝: 머리 id 가 목록의 id 와 같아야 한다 (모르면 풀지 않음)
|
|
static void _checkId(VaultEntry e, List<int> headerId) {
|
|
if (e.id.isEmpty || e.id != _hex(headerId)) throw const VaultAuthException();
|
|
}
|
|
static bool _inside(String folder, String parent) => parent.isEmpty || folder == parent || folder.startsWith('$parent/');
|
|
|
|
/// 있는 폴더 모두 (만든 것 + 파일이 든 것 + 그 위 폴더)
|
|
List<String> get folders {
|
|
final out = <String>{};
|
|
void addUp(String f) {
|
|
while (f.isNotEmpty && out.add(f)) {
|
|
final cut = f.lastIndexOf('/');
|
|
f = cut < 0 ? '' : f.substring(0, cut);
|
|
}
|
|
}
|
|
|
|
_folders.forEach(addUp);
|
|
for (final e in _entries) {
|
|
addUp(e.folder);
|
|
}
|
|
return out.toList()..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
|
}
|
|
|
|
/// [parent] 바로 아래 폴더 이름
|
|
List<String> subfolders(String parent) => [
|
|
for (final f in folders)
|
|
if (f != parent && _inside(f, parent) && !f.substring(parent.isEmpty ? 0 : parent.length + 1).contains('/'))
|
|
f.substring(parent.isEmpty ? 0 : parent.length + 1),
|
|
];
|
|
|
|
/// [folder] 바로 안의 파일
|
|
List<VaultEntry> filesIn(String folder) => [for (final e in _entries) if (e.folder == folder) e];
|
|
|
|
/// [folder] 와 그 안쪽 모든 파일
|
|
List<VaultEntry> filesUnder(String folder) => [for (final e in _entries) if (_inside(e.folder, folder)) e];
|
|
|
|
bool _taken(String folder, String name, {String? except}) =>
|
|
_entries.any((e) => e.folder == folder && e.name == name && e.blob != except) || folders.contains(_join(folder, name));
|
|
|
|
/// [folder] 안에서 겹치지 않는 이름 ("사진.jpg" → "사진 (2).jpg")
|
|
String freeName(String folder, String name, {String? except}) {
|
|
if (!_taken(folder, name, except: except)) return name;
|
|
final ext = p.extension(name), base = p.basenameWithoutExtension(name);
|
|
for (var i = 2;; i++) {
|
|
final n = '$base ($i)$ext';
|
|
if (!_taken(folder, n, except: except)) return n;
|
|
}
|
|
}
|
|
|
|
/// 보관함 안 폴더 만들기 (이미 있으면 그대로, 같은 이름 파일이 있으면 [StateError])
|
|
Future<void> mkdir(String folder) async {
|
|
final f = cleanFolder(folder);
|
|
if (f == null || f.isEmpty) throw ArgumentError.value(folder, 'folder');
|
|
if (folders.contains(f)) return;
|
|
if (_entries.any((e) => e.path == f)) throw StateError('name taken');
|
|
_folders.add(f);
|
|
try {
|
|
await _saveFolders();
|
|
} catch (_) {
|
|
_folders.remove(f);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 파일을 다른 폴더로 (목록만 - 이름이 겹치면 " (2)")
|
|
Future<VaultEntry> moveEntry(VaultEntry e, String folder) async {
|
|
final f = cleanFolder(folder);
|
|
if (f == null) throw ArgumentError.value(folder, 'folder');
|
|
final i = _entries.indexWhere((x) => x.blob == e.blob);
|
|
if (i < 0) return e;
|
|
final old = _entries[i];
|
|
if (old.folder == f) return old;
|
|
final moved = old.copyWith(folder: f, name: freeName(f, old.name, except: old.blob));
|
|
_entries[i] = moved;
|
|
try {
|
|
await _saveIndex();
|
|
} catch (_) {
|
|
_entries[i] = old;
|
|
rethrow;
|
|
}
|
|
return moved;
|
|
}
|
|
|
|
/// 폴더 이름 바꾸기 · 옮기기 ([from] → [to], 안의 것 모두 - 목록만). [to] 가 [from] 안이거나 이미 있으면 [StateError]
|
|
Future<void> moveFolder(String from, String to) async {
|
|
final a = cleanFolder(from), b = cleanFolder(to);
|
|
if (a == null || a.isEmpty || b == null || b.isEmpty) throw ArgumentError.value('$from -> $to');
|
|
if (a == b) return;
|
|
if (_inside(b, a)) throw StateError('into itself');
|
|
if (folders.contains(b) || _entries.any((e) => e.path == b)) throw StateError('name taken');
|
|
String re(String f) => _inside(f, a) ? b + f.substring(a.length) : f;
|
|
final oldEntries = [..._entries], oldFolders = {..._folders};
|
|
for (var i = 0; i < _entries.length; i++) {
|
|
if (_inside(_entries[i].folder, a)) _entries[i] = _entries[i].copyWith(folder: re(_entries[i].folder));
|
|
}
|
|
final moved = {for (final f in _folders) re(f)};
|
|
_folders
|
|
..clear()
|
|
..addAll(moved)
|
|
..add(b); // 빈 폴더였어도 남게
|
|
try {
|
|
await _saveIndex();
|
|
await _saveFolders();
|
|
} catch (_) {
|
|
_entries
|
|
..clear()
|
|
..addAll(oldEntries);
|
|
_folders
|
|
..clear()
|
|
..addAll(oldFolders);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 폴더와 안의 것 모두 지우기 (목록 먼저 - 그 뒤 내용 파일). 지운 파일 수
|
|
Future<int> removeFolder(String folder) async {
|
|
final f = cleanFolder(folder);
|
|
if (f == null || f.isEmpty) throw ArgumentError.value(folder, 'folder');
|
|
final gone = filesUnder(f);
|
|
_entries.removeWhere((e) => _inside(e.folder, f));
|
|
_folders.removeWhere((x) => _inside(x, f));
|
|
await _saveIndex();
|
|
await _saveFolders();
|
|
for (final e in gone) {
|
|
await File(p.join(dir.path, '${e.blob}$_ext')).delete().catchError((_) => File(''));
|
|
}
|
|
return gone.length;
|
|
}
|
|
|
|
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] 를 암호화해 [folder] 에 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). 이름이 겹치면 " (2)".
|
|
/// [onProgress] (넣은 바이트, 전체)
|
|
Future<VaultEntry> add(String src,
|
|
{String? name, String folder = '', int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async {
|
|
final f = cleanFolder(folder);
|
|
if (f == null) throw ArgumentError.value(folder, 'folder');
|
|
final want = name ?? p.basename(src);
|
|
if (!validName(want)) throw ArgumentError.value(want, 'name');
|
|
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, freeName(f, want), len, DateTime.now(), folder: f, id: _hex(id));
|
|
_entries.add(e);
|
|
try {
|
|
await _saveIndex();
|
|
} catch (_) {
|
|
_entries.remove(e);
|
|
await out.delete().catchError((_) => out);
|
|
rethrow;
|
|
}
|
|
return e;
|
|
}
|
|
|
|
/// 풀어서 [outPath] 에 쓴다 (다 풀고 인증이 맞은 뒤에만 그 이름으로 - 바뀐 조각 · 다른 항목의 내용 파일 · 크기가 다르면
|
|
/// [VaultAuthException] 이고 파일을 남기지 않음). [onTemp] · [onTempDone]: 받는 폴더에 잠깐 쓰는 "이름.jjvtmp" (평문) 를
|
|
/// 앱 전용 기록에 적고 지우게 (강제로 꺼져 남으면 다음에 켤 때 지움 - 관리자 지시 10/21)
|
|
Future<void> export(VaultEntry e, String outPath, {void Function(String tmp)? onTemp, void Function(String tmp)? onTempDone}) async {
|
|
final f = await File(p.join(dir.path, '${e.blob}$_ext')).open();
|
|
final tmp = File('$outPath.jjvtmp');
|
|
onTemp?.call(tmp.path);
|
|
RandomAccessFile w;
|
|
try {
|
|
w = await tmp.open(mode: FileMode.write);
|
|
} catch (_) {
|
|
await f.close();
|
|
onTempDone?.call(tmp.path);
|
|
rethrow;
|
|
}
|
|
try {
|
|
final len = await f.length();
|
|
final h = await f.read(VaultCrypto.headerLength);
|
|
final (id, chunk) = VaultCrypto.parseHeader(h);
|
|
_checkId(e, id);
|
|
final key = VaultCrypto.fileKey(_key, id);
|
|
final full = VaultCrypto.sealedChunkLength(chunk);
|
|
var off = VaultCrypto.headerLength;
|
|
var i = 0;
|
|
var written = 0;
|
|
if (off >= len) throw const VaultAuthException();
|
|
while (off < len) {
|
|
final sealed = await f.read(min(full, len - off));
|
|
off += sealed.length;
|
|
final plain = VaultCrypto.openChunk(key, h, i, sealed, last: off >= len);
|
|
written += plain.length;
|
|
await w.writeFrom(plain);
|
|
i++;
|
|
}
|
|
if (written != e.size) throw const VaultAuthException();
|
|
await w.flush();
|
|
} catch (_) {
|
|
await w.close();
|
|
await tmp.delete().catchError((_) => tmp);
|
|
onTempDone?.call(tmp.path);
|
|
rethrow;
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
await w.close();
|
|
try {
|
|
await tmp.rename(outPath);
|
|
} finally {
|
|
onTempDone?.call(tmp.path);
|
|
}
|
|
}
|
|
|
|
/// 메모리로 푼다 (관리자 지시 10/21: 그림 보기는 파일로 풀지 않음). [maxBytes] 보다 크면 [StateError]
|
|
Future<Uint8List> readBytes(VaultEntry e, {int maxBytes = 256 * 1024 * 1024}) async {
|
|
if (e.size > maxBytes) throw StateError('too big');
|
|
final data = await File(p.join(dir.path, '${e.blob}$_ext')).readAsBytes();
|
|
final (id, _) = VaultCrypto.parseHeader(Uint8List.sublistView(data, 0, min(data.length, VaultCrypto.headerLength)));
|
|
_checkId(e, id);
|
|
final out = VaultCrypto.decryptAll(_key, data);
|
|
if (out.length != e.size) throw const VaultAuthException();
|
|
return out;
|
|
}
|
|
|
|
/// 보관함에서 지운다 (목록 먼저 - 그 뒤 내용 파일)
|
|
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(''));
|
|
}
|
|
|
|
/// 이름 바꾸기 (목록만). 같은 폴더에 같은 이름이 있으면 [StateError]
|
|
Future<void> rename(VaultEntry e, String name) async {
|
|
if (!validName(name)) throw ArgumentError.value(name, 'name');
|
|
final i = _entries.indexWhere((x) => x.blob == e.blob);
|
|
if (i < 0) return;
|
|
final old = _entries[i];
|
|
if (_taken(old.folder, name, except: old.blob)) throw StateError('name taken');
|
|
_entries[i] = old.copyWith(name: name);
|
|
try {
|
|
await _saveIndex();
|
|
} catch (_) {
|
|
_entries[i] = old;
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 비밀번호 바꾸기 (감싼 열쇠만 다시 - 파일은 다시 암호화하지 않음)
|
|
Future<void> changePassword(String password, {int iterations = VaultCrypto.defaultIterations}) => _writeHeader(password, iterations);
|
|
}
|