import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; import 'dart:math'; import 'dart:typed_data'; import 'package:characters/characters.dart'; import 'package:cryptography/cryptography.dart'; import '../core/failure.dart'; import '../models/server_profile.dart'; import 'blob_store.dart'; import 'secure_store.dart'; const _aad = 'CHJ_SM_MOBILE_VAULT_V1'; /// Allowed master password length, in user-perceived characters (grapheme /// clusters, the unit a text field counts). Spaces are part of the password. const minPasswordLength = 4, maxPasswordLength = 64; int passwordLength(String password) => password.characters.length; /// Rejects a new master password outside [minPasswordLength] and /// [maxPasswordLength]. Only applied when a password is set: an existing /// Vault opens with whatever password it was created with. void checkNewPassword(String password) { final n = passwordLength(password); if (n < minPasswordLength || n > maxPasswordLength) { throw const Failure('passwordLength'); } } /// A rough strength estimate (0 = very weak … 4 = strong) for the indicator /// shown while a password is typed. It is advice only: every password of /// allowed length is accepted, and the Vault encryption is the same for all. int passwordStrength(String password) { final n = passwordLength(password); if (n == 0) return 0; var pool = 0; if (RegExp(r'[a-z]').hasMatch(password)) pool += 26; if (RegExp(r'[A-Z]').hasMatch(password)) pool += 26; if (RegExp(r'[0-9]').hasMatch(password)) pool += 10; if (RegExp(r'[^A-Za-z0-9]').hasMatch(password)) pool += 33; // Repeated characters add little. final distinct = password.characters.toSet().length; final bits = distinct * (log(max(pool, 10)) / ln2) + (n - distinct) * 1.5; if (bits < 28) return 0; if (bits < 40) return 1; if (bits < 60) return 2; if (bits < 80) return 3; return 4; } /// Key derivation used by an envelope. Version 1 Vaults use PBKDF2; new and /// re-keyed Vaults use Argon2id (RFC 9106), which is memory-hard. class Kdf { final String name; final int memory, iterations, parallelism; const Kdf.argon2id({ this.memory = 65536, this.iterations = 3, this.parallelism = 1, }) : name = 'argon2id'; const Kdf.pbkdf2() : name = 'pbkdf2-sha256', memory = 0, iterations = 600000, parallelism = 1; static const current = Kdf.argon2id(); Map toJson() => name == 'argon2id' ? { 'name': name, 'memory': memory, 'iterations': iterations, 'parallelism': parallelism, } : {'name': name, 'iterations': iterations}; static Kdf of(Map envelope) { if (envelope['version'] == 1) return const Kdf.pbkdf2(); final k = Map.from(envelope['kdf'] as Map); if (k['name'] != 'argon2id') throw const Failure('storageError'); final memory = k['memory'], iterations = k['iterations']; final parallelism = k['parallelism']; // Bounds keep a damaged envelope from demanding absurd resources, and // keep the parameters from ever being weaker than the default. if (memory is! int || iterations is! int || parallelism is! int || memory < 65536 || memory > 1048576 || iterations < 3 || iterations > 20 || parallelism < 1 || parallelism > 8) { throw const Failure('storageError'); } return Kdf.argon2id( memory: memory, iterations: iterations, parallelism: parallelism, ); } Future derive(String password, List salt) { final name = this.name, memory = this.memory; final iterations = this.iterations, parallelism = this.parallelism; return Isolate.run(() async { final KdfAlgorithm algorithm = name == 'argon2id' ? Argon2id( memory: memory, iterations: iterations, parallelism: parallelism, hashLength: 32, ) : Pbkdf2( macAlgorithm: Hmac.sha256(), iterations: iterations, bits: 256, ); final key = await algorithm.deriveKey( secretKey: SecretKey(utf8.encode(password)), nonce: salt, ); return Uint8List.fromList(await key.extractBytes()); }); } } /// An unsaved editor buffer kept across a lock. It is stored encrypted with /// the Vault data key and only readable after the next unlock. class Draft { final Map json; const Draft(this.json); } class Vault { final SecureStore store; final BlobStore blobs; Uint8List? _key; Map? _data, _envelope; List _drafts = const []; int generation = 0; Future _queue = Future.value(); Vault(this.store, {BlobStore? blobs}) : blobs = blobs ?? MemoryBlobStore(); bool get unlocked => _key != null && _data != null; Future exists() async => await store.read('vault') != null; /// The key derivation of the open Vault ('argon2id' or 'pbkdf2-sha256'). String? get kdfName => _envelope == null ? null : Kdf.of(_envelope!).name; List get profiles { _require(); return (_data!['profiles'] as List) .map((e) => ServerProfile.fromJson(Map.from(e))) .toList(); } /// Unsaved editor buffers recovered at the last unlock. List get drafts { _require(); return _drafts; } void _require() { if (!unlocked) throw const Failure('locked'); } static List _salt() { final random = Random.secure(); return List.generate(16, (_) => random.nextInt(256)); } static String _newKeyId() { final random = Random.secure(); return List.generate( 16, (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), ).join(); } /// A version 2 envelope for [data] sealed with [key], whose key is wrapped /// with a key derived from [password]. Future> _envelopeFor( String password, Map data, List key, String keyId, ) async { final aes = AesGcm.with256bits(); final salt = _salt(); const kdf = Kdf.current; final derived = await kdf.derive(password, salt); try { final wrapped = await aes.encrypt( key, secretKey: SecretKey(derived), aad: utf8.encode('$_aad:wrap'), ); return { 'version': 2, 'kdf': kdf.toJson(), 'keyId': keyId, 'salt': base64Encode(salt), 'wrap': base64Encode(wrapped.concatenation()), 'data': await _seal(data, key), }; } finally { derived.fillRange(0, derived.length, 0); } } Future create(String password) async { final epoch = generation; checkNewPassword(password); if (await exists()) throw const Failure('storageError'); final key = Uint8List.fromList( await (await AesGcm.with256bits().newSecretKey()).extractBytes(), ); try { final data = { 'profiles': [], 'secrets': {}, 'hosts': {}, }; final envelope = await _envelopeFor(password, data, key, _newKeyId()); if (epoch != generation) throw const Failure('locked'); await store.write('vault', jsonEncode(envelope)); if (epoch != generation) throw const Failure('locked'); _envelope = envelope; _data = data; _drafts = const []; _key = Uint8List.fromList(key); } finally { key.fillRange(0, key.length, 0); } } Future> _readEnvelope() async { final text = await store.read('vault'); if (text == null) throw const Failure('storageError'); final e = Map.from(jsonDecode(text)); if (e['version'] != 1 && e['version'] != 2) { throw const Failure('storageError'); } return e; } /// The data key wrapped in [e], unwrapped with [password]. Throws /// 'unlockFailed' for a wrong password. Future _unwrap(Map e, String password) async { Uint8List? derived; try { derived = await Kdf.of(e).derive(password, base64Decode(e['salt'])); return Uint8List.fromList( await AesGcm.with256bits().decrypt( SecretBox.fromConcatenation( base64Decode(e['wrap']), nonceLength: 12, macLength: 16, ), secretKey: SecretKey(derived), aad: utf8.encode('$_aad:wrap'), ), ); } on Failure { rethrow; } catch (_) { throw const Failure('unlockFailed'); } finally { derived?.fillRange(0, derived.length, 0); } } /// Waits for writes queued before a lock (such as stashed drafts), so an /// unlock never reads an envelope that is about to be replaced. Future _settled() => _queue.catchError((Object _) {}); Future unlock(String password) async { await _settled(); final epoch = generation; Uint8List? key; try { final e = await _readEnvelope(); key = await _unwrap(e, password); await _open(e, key, epoch); if (e['version'] == 1) _upgrade(password, epoch); } on Failure { rethrow; } catch (_) { throw const Failure('unlockFailed'); } finally { key?.fillRange(0, key.length, 0); } } /// Re-wraps the unchanged data key of a version 1 (PBKDF2) Vault with /// Argon2id. The old envelope stays in place until the new one is written, /// so a failure leaves the Vault as it was. void _upgrade(String password, int epoch) { final result = _queue.then((_) async { if (epoch != generation || !unlocked) return; final current = await _readEnvelope(); if (current['version'] != 1) return; final envelope = await _envelopeFor(password, _data!, _key!, 'v1'); if (epoch != generation) return; await store.write('vault', jsonEncode(envelope)); _envelope = envelope; }); _queue = result.catchError((Object _) {}); } /// Completes when queued background writes (an upgrade, stashed drafts) /// are done. Used by tests and before reporting a change as finished. Future get idle => _settled(); Future _open(Map e, List key, int epoch) async { final plaintext = await AesGcm.with256bits().decrypt( SecretBox.fromConcatenation( base64Decode(e['data']), nonceLength: 12, macLength: 16, ), secretKey: SecretKey(key), aad: utf8.encode(_aad), ); final data = Map.from(jsonDecode(utf8.decode(plaintext))); final drafts = await _readDrafts(e, key); if (epoch != generation) throw const Failure('locked'); _envelope = e; _data = data; _drafts = drafts; _key = Uint8List.fromList(key); } static String _draftName(Map e) => 'drafts-${e['keyId'] ?? 'v1'}'; Future> _readDrafts(Map e, List key) async { try { final box = await blobs.read(_draftName(e)); if (box == null) return const []; final plain = await AesGcm.with256bits().decrypt( SecretBox.fromConcatenation(box, nonceLength: 12, macLength: 16), secretKey: SecretKey(key), aad: utf8.encode('$_aad:drafts'), ); return [ for (final d in jsonDecode(utf8.decode(plain)) as List) Draft(Map.from(d as Map)), ]; } catch (_) { // Unreadable drafts never block access to the Vault itself. return const []; } } Future _sealDrafts(List drafts, List key) async => (await AesGcm.with256bits().encrypt( utf8.encode(jsonEncode([for (final d in drafts) d.json])), secretKey: SecretKey(key), aad: utf8.encode('$_aad:drafts'), )).concatenation(); /// Keeps only [keep] of the recovered drafts (sealed again with the data /// key); the others are gone for good. Future keepDrafts(List keep) { if (keep.isEmpty) return clearDrafts(); final epoch = generation; final result = _queue.then((_) async { _require(); if (epoch != generation) throw const Failure('locked'); await blobs.write(_draftName(_envelope!), await _sealDrafts(keep, _key!)); _drafts = List.unmodifiable(keep); }); _queue = result.catchError((Object _) {}); return result; } /// Removes recovered drafts once the user restored or discarded them. Future clearDrafts() { final epoch = generation; final result = _queue.then((_) async { _require(); if (epoch != generation) throw const Failure('locked'); await blobs.delete(_draftName(_envelope!)); _drafts = const []; }); _queue = result.catchError((Object _) {}); return result; } Future _seal(Map data, List key) async => base64Encode( (await AesGcm.with256bits().encrypt( utf8.encode(jsonEncode(data)), secretKey: SecretKey(key), aad: utf8.encode(_aad), )).concatenation(), ); Future change(void Function(Map) update) { final epoch = generation; final result = _queue.then((_) async { _require(); if (epoch != generation) throw const Failure('locked'); final data = Map.from(jsonDecode(jsonEncode(_data))); update(data); final envelope = {..._envelope!, 'data': await _seal(data, _key!)}; if (epoch != generation) throw const Failure('locked'); await store.write('vault', jsonEncode(envelope)); if (epoch != generation) throw const Failure('locked'); _envelope = envelope; _data = data; }); _queue = result.catchError((Object _) {}); return result; } /// Changes the master password. /// /// The current password is checked against the stored envelope. A new /// random data key then replaces the old one: the data and any drafts are /// sealed again, and the new envelope is verified with the new password /// before it replaces the old envelope in a single write. Until that write /// succeeds the old password and data stay valid; afterwards nothing /// derived from the old password or the old data key (such as a stored /// biometric key) can open the Vault. Future changePassword(String current, String next) { final epoch = generation; final result = _queue.then((_) async { _require(); checkNewPassword(next); final stored = await _readEnvelope(); final check = await _unwrap(stored, current); try { if (!_same(check, _key!)) throw const Failure('unlockFailed'); } finally { check.fillRange(0, check.length, 0); } if (epoch != generation) throw const Failure('locked'); final key = Uint8List.fromList( await (await AesGcm.with256bits().newSecretKey()).extractBytes(), ); try { final envelope = await _envelopeFor(next, _data!, key, _newKeyId()); // Verify before replacing anything. final verify = await _unwrap(envelope, next); try { if (!_same(verify, key)) throw const Failure('storageError'); await AesGcm.with256bits().decrypt( SecretBox.fromConcatenation( base64Decode(envelope['data']), nonceLength: 12, macLength: 16, ), secretKey: SecretKey(verify), aad: utf8.encode(_aad), ); } finally { verify.fillRange(0, verify.length, 0); } if (epoch != generation) throw const Failure('locked'); // Drafts first, under the new key id: until the envelope is written // they are unused, and the old drafts stay readable with the old key. final oldDrafts = _draftName(stored); if (_drafts.isNotEmpty) { await blobs.write( _draftName(envelope), await _sealDrafts(_drafts, key), ); } await store.write('vault', jsonEncode(envelope)); _envelope = envelope; _key!.fillRange(0, _key!.length, 0); _key = Uint8List.fromList(key); try { await blobs.delete(oldDrafts); } catch (_) { /* Sealed with a key that no longer exists. */ } } finally { key.fillRange(0, key.length, 0); } }); _queue = result.catchError((Object _) {}); return result; } static bool _same(List a, List b) { if (a.length != b.length) return false; var diff = 0; for (var i = 0; i < a.length; i++) { diff |= a[i] ^ b[i]; } return diff == 0; } Future save( ServerProfile profile, { String? password, String? privateKey, bool clearSecrets = false, }) => change((d) { final list = d['profiles'] as List; final index = list.indexWhere((p) => p['id'] == profile.id); if (index < 0) { list.add(profile.toJson()); } else { list[index] = profile.toJson(); } final secrets = d['secrets'] as Map; if (clearSecrets) secrets.remove(profile.id); final previous = Map.from(secrets[profile.id] ?? {}); if (password != null) { if (password.isEmpty) { previous.remove('password'); } else { previous['password'] = password; } } if (privateKey != null) { if (privateKey.isEmpty) { previous.remove('privateKey'); } else { previous['privateKey'] = privateKey; } } secrets[profile.id] = previous; }); Future deleteProfile(String id) => change((d) { (d['profiles'] as List).removeWhere((p) => p['id'] == id); (d['secrets'] as Map).remove(id); }); String? secret(String id, String field) { _require(); return _data!['secrets'][id]?[field] as String?; } String? fingerprint(String endpoint) { _require(); return _data!['hosts'][endpoint] as String?; } Future trust(String endpoint, String fingerprint) => change((d) { d['hosts'][endpoint] = fingerprint; }); /// Small encrypted settings kept with the Vault data (recent and favorite /// files). Not part of the profile export. Map section(String name) { _require(); return Map.from( jsonDecode(jsonEncode(_data![name] ?? {})) as Map, ); } Future setSection(String name, Map value) => change((d) => d[name] = value); Future enableBiometric(SecureStore gatedStore) async { _require(); final epoch = generation; await gatedStore.write('key', base64Encode(_key!)); if (epoch != generation) { await gatedStore.delete('key'); throw const Failure('locked'); } } /// Opens the Vault with the data key released by the operating system /// after biometric authentication. Throws 'biometricInvalid' when there is /// no key (removed by the system after a biometric change) or the key no /// longer opens this Vault (for example after a password change). Future unlockBiometric(SecureStore gatedStore) async { await _settled(); final epoch = generation; final value = await gatedStore.read('key'); if (value == null) throw const Failure('biometricInvalid'); final key = base64Decode(value); try { await _open(await _readEnvelope(), key, epoch); } on Failure { rethrow; } catch (_) { throw const Failure('biometricInvalid'); } finally { key.fillRange(0, key.length, 0); } } /// Locks the Vault. [drafts] are unsaved editor buffers; they are sealed /// with the data key before it is erased, and written after any pending /// change, so they survive the lock without ever being stored in clear. void lock({List drafts = const []}) { final key = _key == null ? null : Uint8List.fromList(_key!); final envelope = _envelope; generation++; _key?.fillRange(0, _key!.length, 0); _key = null; _data = null; _envelope = null; _drafts = const []; if (key == null) return; if (drafts.isEmpty || envelope == null) { key.fillRange(0, key.length, 0); return; } final result = _queue.then((_) async { try { // The envelope can only have changed through this Vault, whose // pending writes are already done. final current = await _readEnvelope(); if (current['keyId'] != envelope['keyId']) return; // Drafts from an earlier lock that were never restored are kept. final previous = await _readDrafts(current, key); await blobs.write( _draftName(current), await _sealDrafts([...previous, ...drafts], key), ); } finally { key.fillRange(0, key.length, 0); } }); _queue = result.catchError((Object _) {}); } }