Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J SM Mobile
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / SM Mobile
175 Dateien
sm-mobile-20261016T185235Z-8e93ecc1b59a
Hinzugefügt: · Erstellt:
1
import 'dart:async';2
import 'dart:convert';3
import 'dart:isolate';4
import 'dart:math';5
import 'dart:typed_data';6
import 'package:characters/characters.dart';7
import 'package:cryptography/cryptography.dart';8
import '../core/failure.dart';9
import '../models/server_profile.dart';10
import 'blob_store.dart';11
import 'secure_store.dart';13
const _aad = 'CHJ_SM_MOBILE_VAULT_V1';15
/// Allowed master password length, in user-perceived characters (grapheme16
/// clusters, the unit a text field counts). Spaces are part of the password.17
const minPasswordLength = 4, maxPasswordLength = 64;19
int passwordLength(String password) => password.characters.length;21
/// Rejects a new master password outside [minPasswordLength] and22
/// [maxPasswordLength]. Only applied when a password is set: an existing23
/// Vault opens with whatever password it was created with.24
void checkNewPassword(String password) {25
final n = passwordLength(password);26
if (n < minPasswordLength || n > maxPasswordLength) {27
throw const Failure('passwordLength');28
}29
}31
/// A rough strength estimate (0 = very weak … 4 = strong) for the indicator32
/// shown while a password is typed. It is advice only: every password of33
/// allowed length is accepted, and the Vault encryption is the same for all.34
int passwordStrength(String password) {35
final n = passwordLength(password);36
if (n == 0) return 0;37
var pool = 0;38
if (RegExp(r'[a-z]').hasMatch(password)) pool += 26;39
if (RegExp(r'[A-Z]').hasMatch(password)) pool += 26;40
if (RegExp(r'[0-9]').hasMatch(password)) pool += 10;41
if (RegExp(r'[^A-Za-z0-9]').hasMatch(password)) pool += 33;42
// Repeated characters add little.43
final distinct = password.characters.toSet().length;44
final bits = distinct * (log(max(pool, 10)) / ln2) + (n - distinct) * 1.5;45
if (bits < 28) return 0;46
if (bits < 40) return 1;47
if (bits < 60) return 2;48
if (bits < 80) return 3;49
return 4;50
}52
/// Key derivation used by an envelope. Version 1 Vaults use PBKDF2; new and53
/// re-keyed Vaults use Argon2id (RFC 9106), which is memory-hard.54
class Kdf {55
final String name;56
final int memory, iterations, parallelism;57
const Kdf.argon2id({58
this.memory = 65536,59
this.iterations = 3,60
this.parallelism = 1,61
}) : name = 'argon2id';62
const Kdf.pbkdf2()63
: name = 'pbkdf2-sha256',64
memory = 0,65
iterations = 600000,66
parallelism = 1;68
static const current = Kdf.argon2id();70
Map<String, dynamic> toJson() => name == 'argon2id'71
? {72
'name': name,73
'memory': memory,74
'iterations': iterations,75
'parallelism': parallelism,76
}77
: {'name': name, 'iterations': iterations};79
static Kdf of(Map<String, dynamic> envelope) {80
if (envelope['version'] == 1) return const Kdf.pbkdf2();81
final k = Map<String, dynamic>.from(envelope['kdf'] as Map);82
if (k['name'] != 'argon2id') throw const Failure('storageError');83
final memory = k['memory'], iterations = k['iterations'];84
final parallelism = k['parallelism'];85
// Bounds keep a damaged envelope from demanding absurd resources, and86
// keep the parameters from ever being weaker than the default.87
if (memory is! int ||88
iterations is! int ||89
parallelism is! int ||90
memory < 65536 ||91
memory > 1048576 ||92
iterations < 3 ||93
iterations > 20 ||94
parallelism < 1 ||95
parallelism > 8) {96
throw const Failure('storageError');97
}98
return Kdf.argon2id(99
memory: memory,100
iterations: iterations,101
parallelism: parallelism,102
);103
}105
Future<Uint8List> derive(String password, List<int> salt) {106
final name = this.name, memory = this.memory;107
final iterations = this.iterations, parallelism = this.parallelism;108
return Isolate.run(() async {109
final KdfAlgorithm algorithm = name == 'argon2id'110
? Argon2id(111
memory: memory,112
iterations: iterations,113
parallelism: parallelism,114
hashLength: 32,115
)116
: Pbkdf2(117
macAlgorithm: Hmac.sha256(),118
iterations: iterations,119
bits: 256,120
);121
final key = await algorithm.deriveKey(122
secretKey: SecretKey(utf8.encode(password)),123
nonce: salt,124
);125
return Uint8List.fromList(await key.extractBytes());126
});127
}128
}130
/// An unsaved editor buffer kept across a lock. It is stored encrypted with131
/// the Vault data key and only readable after the next unlock.132
class Draft {133
final Map<String, dynamic> json;134
const Draft(this.json);135
}137
class Vault {138
final SecureStore store;139
final BlobStore blobs;140
Uint8List? _key;141
Map<String, dynamic>? _data, _envelope;142
List<Draft> _drafts = const [];143
int generation = 0;144
Future<void> _queue = Future.value();145
Vault(this.store, {BlobStore? blobs}) : blobs = blobs ?? MemoryBlobStore();146
bool get unlocked => _key != null && _data != null;147
Future<bool> exists() async => await store.read('vault') != null;149
/// The key derivation of the open Vault ('argon2id' or 'pbkdf2-sha256').150
String? get kdfName => _envelope == null ? null : Kdf.of(_envelope!).name;151
List<ServerProfile> get profiles {152
_require();153
return (_data!['profiles'] as List)154
.map((e) => ServerProfile.fromJson(Map<String, dynamic>.from(e)))155
.toList();156
}158
/// Unsaved editor buffers recovered at the last unlock.159
List<Draft> get drafts {160
_require();161
return _drafts;162
}164
void _require() {165
if (!unlocked) throw const Failure('locked');166
}168
static List<int> _salt() {169
final random = Random.secure();170
return List<int>.generate(16, (_) => random.nextInt(256));171
}173
static String _newKeyId() {174
final random = Random.secure();175
return List.generate(176
16,177
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),178
).join();179
}181
/// A version 2 envelope for [data] sealed with [key], whose key is wrapped182
/// with a key derived from [password].183
Future<Map<String, dynamic>> _envelopeFor(184
String password,185
Map<String, dynamic> data,186
List<int> key,187
String keyId,188
) async {189
final aes = AesGcm.with256bits();190
final salt = _salt();191
const kdf = Kdf.current;192
final derived = await kdf.derive(password, salt);193
try {194
final wrapped = await aes.encrypt(195
key,196
secretKey: SecretKey(derived),197
aad: utf8.encode('$_aad:wrap'),198
);199
return {200
'version': 2,201
'kdf': kdf.toJson(),202
'keyId': keyId,203
'salt': base64Encode(salt),204
'wrap': base64Encode(wrapped.concatenation()),205
'data': await _seal(data, key),206
};207
} finally {208
derived.fillRange(0, derived.length, 0);209
}210
}212
Future<void> create(String password) async {213
final epoch = generation;214
checkNewPassword(password);215
if (await exists()) throw const Failure('storageError');216
final key = Uint8List.fromList(217
await (await AesGcm.with256bits().newSecretKey()).extractBytes(),218
);219
try {220
final data = <String, dynamic>{221
'profiles': [],222
'secrets': {},223
'hosts': {},224
};225
final envelope = await _envelopeFor(password, data, key, _newKeyId());226
if (epoch != generation) throw const Failure('locked');227
await store.write('vault', jsonEncode(envelope));228
if (epoch != generation) throw const Failure('locked');229
_envelope = envelope;230
_data = data;231
_drafts = const [];232
_key = Uint8List.fromList(key);233
} finally {234
key.fillRange(0, key.length, 0);235
}236
}238
Future<Map<String, dynamic>> _readEnvelope() async {239
final text = await store.read('vault');240
if (text == null) throw const Failure('storageError');241
final e = Map<String, dynamic>.from(jsonDecode(text));242
if (e['version'] != 1 && e['version'] != 2) {243
throw const Failure('storageError');244
}245
return e;246
}248
/// The data key wrapped in [e], unwrapped with [password]. Throws249
/// 'unlockFailed' for a wrong password.250
Future<Uint8List> _unwrap(Map<String, dynamic> e, String password) async {251
Uint8List? derived;252
try {253
derived = await Kdf.of(e).derive(password, base64Decode(e['salt']));254
return Uint8List.fromList(255
await AesGcm.with256bits().decrypt(256
SecretBox.fromConcatenation(257
base64Decode(e['wrap']),258
nonceLength: 12,259
macLength: 16,260
),261
secretKey: SecretKey(derived),262
aad: utf8.encode('$_aad:wrap'),263
),264
);265
} on Failure {266
rethrow;267
} catch (_) {268
throw const Failure('unlockFailed');269
} finally {270
derived?.fillRange(0, derived.length, 0);271
}272
}274
/// Waits for writes queued before a lock (such as stashed drafts), so an275
/// unlock never reads an envelope that is about to be replaced.276
Future<void> _settled() => _queue.catchError((Object _) {});278
Future<void> unlock(String password) async {279
await _settled();280
final epoch = generation;281
Uint8List? key;282
try {283
final e = await _readEnvelope();284
key = await _unwrap(e, password);285
await _open(e, key, epoch);286
if (e['version'] == 1) _upgrade(password, epoch);287
} on Failure {288
rethrow;289
} catch (_) {290
throw const Failure('unlockFailed');291
} finally {292
key?.fillRange(0, key.length, 0);293
}294
}296
/// Re-wraps the unchanged data key of a version 1 (PBKDF2) Vault with297
/// Argon2id. The old envelope stays in place until the new one is written,298
/// so a failure leaves the Vault as it was.299
void _upgrade(String password, int epoch) {300
final result = _queue.then((_) async {301
if (epoch != generation || !unlocked) return;302
final current = await _readEnvelope();303
if (current['version'] != 1) return;304
final envelope = await _envelopeFor(password, _data!, _key!, 'v1');305
if (epoch != generation) return;306
await store.write('vault', jsonEncode(envelope));307
_envelope = envelope;308
});309
_queue = result.catchError((Object _) {});310
}312
/// Completes when queued background writes (an upgrade, stashed drafts)313
/// are done. Used by tests and before reporting a change as finished.314
Future<void> get idle => _settled();316
Future<void> _open(Map<String, dynamic> e, List<int> key, int epoch) async {317
final plaintext = await AesGcm.with256bits().decrypt(318
SecretBox.fromConcatenation(319
base64Decode(e['data']),320
nonceLength: 12,321
macLength: 16,322
),323
secretKey: SecretKey(key),324
aad: utf8.encode(_aad),325
);326
final data = Map<String, dynamic>.from(jsonDecode(utf8.decode(plaintext)));327
final drafts = await _readDrafts(e, key);328
if (epoch != generation) throw const Failure('locked');329
_envelope = e;330
_data = data;331
_drafts = drafts;332
_key = Uint8List.fromList(key);333
}335
static String _draftName(Map<String, dynamic> e) =>336
'drafts-${e['keyId'] ?? 'v1'}';338
Future<List<Draft>> _readDrafts(Map<String, dynamic> e, List<int> key) async {339
try {340
final box = await blobs.read(_draftName(e));341
if (box == null) return const [];342
final plain = await AesGcm.with256bits().decrypt(343
SecretBox.fromConcatenation(box, nonceLength: 12, macLength: 16),344
secretKey: SecretKey(key),345
aad: utf8.encode('$_aad:drafts'),346
);347
return [348
for (final d in jsonDecode(utf8.decode(plain)) as List)349
Draft(Map<String, dynamic>.from(d as Map)),350
];351
} catch (_) {352
// Unreadable drafts never block access to the Vault itself.353
return const [];354
}355
}357
Future<Uint8List> _sealDrafts(List<Draft> drafts, List<int> key) async =>358
(await AesGcm.with256bits().encrypt(359
utf8.encode(jsonEncode([for (final d in drafts) d.json])),360
secretKey: SecretKey(key),361
aad: utf8.encode('$_aad:drafts'),362
)).concatenation();364
/// Keeps only [keep] of the recovered drafts (sealed again with the data365
/// key); the others are gone for good.366
Future<void> keepDrafts(List<Draft> keep) {367
if (keep.isEmpty) return clearDrafts();368
final epoch = generation;369
final result = _queue.then((_) async {370
_require();371
if (epoch != generation) throw const Failure('locked');372
await blobs.write(_draftName(_envelope!), await _sealDrafts(keep, _key!));373
_drafts = List.unmodifiable(keep);374
});375
_queue = result.catchError((Object _) {});376
return result;377
}379
/// Removes recovered drafts once the user restored or discarded them.380
Future<void> clearDrafts() {381
final epoch = generation;382
final result = _queue.then((_) async {383
_require();384
if (epoch != generation) throw const Failure('locked');385
await blobs.delete(_draftName(_envelope!));386
_drafts = const [];387
});388
_queue = result.catchError((Object _) {});389
return result;390
}392
Future<String> _seal(Map<String, dynamic> data, List<int> key) async =>393
base64Encode(394
(await AesGcm.with256bits().encrypt(395
utf8.encode(jsonEncode(data)),396
secretKey: SecretKey(key),397
aad: utf8.encode(_aad),398
)).concatenation(),399
);400
Future<void> change(void Function(Map<String, dynamic>) update) {401
final epoch = generation;402
final result = _queue.then((_) async {403
_require();404
if (epoch != generation) throw const Failure('locked');405
final data = Map<String, dynamic>.from(jsonDecode(jsonEncode(_data)));406
update(data);407
final envelope = {..._envelope!, 'data': await _seal(data, _key!)};408
if (epoch != generation) throw const Failure('locked');409
await store.write('vault', jsonEncode(envelope));410
if (epoch != generation) throw const Failure('locked');411
_envelope = envelope;412
_data = data;413
});414
_queue = result.catchError((Object _) {});415
return result;416
}418
/// Changes the master password.419
///420
/// The current password is checked against the stored envelope. A new421
/// random data key then replaces the old one: the data and any drafts are422
/// sealed again, and the new envelope is verified with the new password423
/// before it replaces the old envelope in a single write. Until that write424
/// succeeds the old password and data stay valid; afterwards nothing425
/// derived from the old password or the old data key (such as a stored426
/// biometric key) can open the Vault.427
Future<void> changePassword(String current, String next) {428
final epoch = generation;429
final result = _queue.then((_) async {430
_require();431
checkNewPassword(next);432
final stored = await _readEnvelope();433
final check = await _unwrap(stored, current);434
try {435
if (!_same(check, _key!)) throw const Failure('unlockFailed');436
} finally {437
check.fillRange(0, check.length, 0);438
}439
if (epoch != generation) throw const Failure('locked');440
final key = Uint8List.fromList(441
await (await AesGcm.with256bits().newSecretKey()).extractBytes(),442
);443
try {444
final envelope = await _envelopeFor(next, _data!, key, _newKeyId());445
// Verify before replacing anything.446
final verify = await _unwrap(envelope, next);447
try {448
if (!_same(verify, key)) throw const Failure('storageError');449
await AesGcm.with256bits().decrypt(450
SecretBox.fromConcatenation(451
base64Decode(envelope['data']),452
nonceLength: 12,453
macLength: 16,454
),455
secretKey: SecretKey(verify),456
aad: utf8.encode(_aad),457
);458
} finally {459
verify.fillRange(0, verify.length, 0);460
}461
if (epoch != generation) throw const Failure('locked');462
// Drafts first, under the new key id: until the envelope is written463
// they are unused, and the old drafts stay readable with the old key.464
final oldDrafts = _draftName(stored);465
if (_drafts.isNotEmpty) {466
await blobs.write(467
_draftName(envelope),468
await _sealDrafts(_drafts, key),469
);470
}471
await store.write('vault', jsonEncode(envelope));472
_envelope = envelope;473
_key!.fillRange(0, _key!.length, 0);474
_key = Uint8List.fromList(key);475
try {476
await blobs.delete(oldDrafts);477
} catch (_) {478
/* Sealed with a key that no longer exists. */479
}480
} finally {481
key.fillRange(0, key.length, 0);482
}483
});484
_queue = result.catchError((Object _) {});485
return result;486
}488
static bool _same(List<int> a, List<int> b) {489
if (a.length != b.length) return false;490
var diff = 0;491
for (var i = 0; i < a.length; i++) {492
diff |= a[i] ^ b[i];493
}494
return diff == 0;495
}497
Future<void> save(498
ServerProfile profile, {499
String? password,500
String? privateKey,501
bool clearSecrets = false,502
}) => change((d) {503
final list = d['profiles'] as List;504
final index = list.indexWhere((p) => p['id'] == profile.id);505
if (index < 0) {506
list.add(profile.toJson());507
} else {508
list[index] = profile.toJson();509
}510
final secrets = d['secrets'] as Map;511
if (clearSecrets) secrets.remove(profile.id);512
final previous = Map<String, dynamic>.from(secrets[profile.id] ?? {});513
if (password != null) {514
if (password.isEmpty) {515
previous.remove('password');516
} else {517
previous['password'] = password;518
}519
}520
if (privateKey != null) {521
if (privateKey.isEmpty) {522
previous.remove('privateKey');523
} else {524
previous['privateKey'] = privateKey;525
}526
}527
secrets[profile.id] = previous;528
});529
Future<void> deleteProfile(String id) => change((d) {530
(d['profiles'] as List).removeWhere((p) => p['id'] == id);531
(d['secrets'] as Map).remove(id);532
});533
String? secret(String id, String field) {534
_require();535
return _data!['secrets'][id]?[field] as String?;536
}538
String? fingerprint(String endpoint) {539
_require();540
return _data!['hosts'][endpoint] as String?;541
}543
Future<void> trust(String endpoint, String fingerprint) => change((d) {544
d['hosts'][endpoint] = fingerprint;545
});547
/// Small encrypted settings kept with the Vault data (recent and favorite548
/// files). Not part of the profile export.549
Map<String, dynamic> section(String name) {550
_require();551
return Map<String, dynamic>.from(552
jsonDecode(jsonEncode(_data![name] ?? {})) as Map,553
);554
}556
Future<void> setSection(String name, Map<String, dynamic> value) =>557
change((d) => d[name] = value);559
Future<void> enableBiometric(SecureStore gatedStore) async {560
_require();561
final epoch = generation;562
await gatedStore.write('key', base64Encode(_key!));563
if (epoch != generation) {564
await gatedStore.delete('key');565
throw const Failure('locked');566
}567
}569
/// Opens the Vault with the data key released by the operating system570
/// after biometric authentication. Throws 'biometricInvalid' when there is571
/// no key (removed by the system after a biometric change) or the key no572
/// longer opens this Vault (for example after a password change).573
Future<void> unlockBiometric(SecureStore gatedStore) async {574
await _settled();575
final epoch = generation;576
final value = await gatedStore.read('key');577
if (value == null) throw const Failure('biometricInvalid');578
final key = base64Decode(value);579
try {580
await _open(await _readEnvelope(), key, epoch);581
} on Failure {582
rethrow;583
} catch (_) {584
throw const Failure('biometricInvalid');585
} finally {586
key.fillRange(0, key.length, 0);587
}588
}590
/// Locks the Vault. [drafts] are unsaved editor buffers; they are sealed591
/// with the data key before it is erased, and written after any pending592
/// change, so they survive the lock without ever being stored in clear.593
void lock({List<Draft> drafts = const []}) {594
final key = _key == null ? null : Uint8List.fromList(_key!);595
final envelope = _envelope;596
generation++;597
_key?.fillRange(0, _key!.length, 0);598
_key = null;599
_data = null;600
_envelope = null;601
_drafts = const [];602
if (key == null) return;603
if (drafts.isEmpty || envelope == null) {604
key.fillRange(0, key.length, 0);605
return;606
}607
final result = _queue.then((_) async {608
try {609
// The envelope can only have changed through this Vault, whose610
// pending writes are already done.611
final current = await _readEnvelope();612
if (current['keyId'] != envelope['keyId']) return;613
// Drafts from an earlier lock that were never restored are kept.614
final previous = await _readDrafts(current, key);615
await blobs.write(616
_draftName(current),617
await _sealDrafts([...previous, ...drafts], key),618
);619
} finally {620
key.fillRange(0, key.length, 0);621
}622
});623
_queue = result.catchError((Object _) {});624
}625
}SHA-256
9bedd75afac47b757791978fded0e2d4d1994d1e67fa122a1fca28612039ebfaCH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655