CH-J SM Mobileمدیریت سرور از طریق SSH
منو
منبع منتشرشده

CH-J SM Mobile

پوشه‌ها و فایل‌های یک نسخهٔ مشخص از برنامه را مرور کنید.

دانلود ZIP منبع
CH-J / SM Mobile 175 فایل
sm-mobile-20261016T185235Z-8e93ecc1b59a افزوده‌شده: · ساخته‌شده:
vault_test.dart افزوده‌شده: تاریخ ثبت نشده است 14.6 KB · 450 خط
کد دانلود فایل
1import 'dart:convert';
2import 'dart:math';
3import 'package:cryptography/cryptography.dart';
4import 'package:flutter/material.dart';
5import 'package:flutter/services.dart';
6import 'package:flutter_test/flutter_test.dart';
7import 'package:sm_mobile/core/app_controller.dart';
8import 'package:sm_mobile/core/failure.dart';
9import 'package:sm_mobile/l10n/strings.dart';
10import 'package:sm_mobile/models/server_profile.dart';
11import 'package:sm_mobile/services/blob_store.dart';
12import 'package:sm_mobile/services/secure_store.dart';
13import 'package:sm_mobile/services/vault.dart';
14import 'package:sm_mobile/ui/gates.dart';
15import 'support.dart';
17Matcher failure(String code) =>
18 throwsA(isA<Failure>().having((f) => f.code, 'code', code));
20/// A Vault envelope exactly as SM Mobile 0.0.2 wrote it (version 1, PBKDF2).
21Future<String> legacyEnvelope(String password, Map data) async {
22 const aad = 'CHJ_SM_MOBILE_VAULT_V1';
23 final aes = AesGcm.with256bits();
24 final random = Random.secure();
25 final salt = List<int>.generate(16, (_) => random.nextInt(256));
26 final derived =
27 await (await Pbkdf2(
28 macAlgorithm: Hmac.sha256(),
29 iterations: 600000,
30 bits: 256,
31 ).deriveKey(secretKey: SecretKey(utf8.encode(password)), nonce: salt))
32 .extractBytes();
33 final key = await (await aes.newSecretKey()).extractBytes();
34 final wrapped = await aes.encrypt(
35 key,
36 secretKey: SecretKey(derived),
37 aad: utf8.encode('$aad:wrap'),
38 );
39 final box = await aes.encrypt(
40 utf8.encode(jsonEncode(data)),
41 secretKey: SecretKey(key),
42 aad: utf8.encode(aad),
43 );
44 return jsonEncode({
45 'version': 1,
46 'salt': base64Encode(salt),
47 'wrap': base64Encode(wrapped.concatenation()),
48 'data': base64Encode(box.concatenation()),
49 });
52/// Simulates the system biometric store: reads either release the key or
53/// fail like a cancelled prompt.
54class FakeBiometric extends MemoryStore {
55 int reads = 0;
56 bool cancel = false;
57 @override
58 Future<String?> read(String key) async {
59 reads++;
60 if (cancel) {
61 throw PlatformException(
62 code: 'Unexpected security result code',
63 details: -128,
64 );
65 }
66 return super.read(key);
67 }
70void main() {
71 TestWidgetsFlutterBinding.ensureInitialized();
73 group('master password length', () {
74 test(
75 '3 characters are rejected, 4, 12 and 64 accepted, 65 rejected',
76 () async {
77 await expectLater(
78 Vault(MemoryStore()).create('abc'),
79 failure('passwordLength'),
80 );
81 for (final length in [4, 12, 64]) {
82 final v = Vault(MemoryStore());
83 await v.create('x' * (length - 1) + '!');
84 expect(v.unlocked, true, reason: '$length characters');
85 v.lock();
86 }
87 await expectLater(
88 Vault(MemoryStore()).create('y' * 65),
89 failure('passwordLength'),
90 );
91 },
92 );
94 test(
95 'a 4-digit PIN, spaces and special characters are kept as typed',
96 () async {
97 final store = MemoryStore();
98 final v = Vault(store);
99 await v.create('1234');
100 v.lock();
101 await v.unlock('1234');
102 expect(v.unlocked, true);
103 v.lock();
105 final spaced = Vault(MemoryStore());
106 await spaced.create(' p ä§ ');
107 spaced.lock();
108 // Not trimmed: the inner text alone does not open it.
109 await expectLater(spaced.unlock('p ä§'), failure('unlockFailed'));
110 await spaced.unlock(' p ä§ ');
111 expect(spaced.unlocked, true);
112 },
113 );
115 test('length counts user-perceived characters', () {
116 expect(passwordLength('👍🏽👍🏽👍🏽👍🏽'), 4);
117 expect(() => checkNewPassword('👍🏽👍🏽👍🏽'), throwsA(isA<Failure>()));
118 checkNewPassword('👍🏽👍🏽👍🏽👍🏽');
119 expect(passwordStrength('1234'), 0);
120 expect(passwordStrength('correct horse battery staple 42!'), 4);
121 });
122 });
124 group('key derivation', () {
125 test('new Vaults use Argon2id with RFC 9106 parameters', () async {
126 final store = MemoryStore();
127 final v = Vault(store);
128 await v.create('argon test');
129 final e = jsonDecode(store.values['vault']!);
130 expect(e['version'], 2);
131 expect(e['kdf'], {
132 'name': 'argon2id',
133 'memory': 65536,
134 'iterations': 3,
135 'parallelism': 1,
136 });
137 expect(v.kdfName, 'argon2id');
138 });
140 test('weakened or unknown KDF parameters are refused', () async {
141 final store = MemoryStore();
142 final v = Vault(store);
143 await v.create('argon test');
144 v.lock();
145 final e = jsonDecode(store.values['vault']!);
146 e['kdf']['memory'] = 1024;
147 store.values['vault'] = jsonEncode(e);
148 await expectLater(v.unlock('argon test'), failure('storageError'));
149 });
151 test('a version 1 (PBKDF2) Vault opens with its old long password and is '
152 'upgraded to Argon2id without data loss', () async {
153 final long = 'L' * 100; // allowed before; above today's maximum
154 final profile = ServerProfile(
155 name: 'old',
156 host: 'example.com',
157 username: 'root',
158 );
159 final store = MemoryStore({
160 'vault': await legacyEnvelope(long, {
161 'profiles': [profile.toJson()],
162 'secrets': {
163 profile.id: {'password': 'kept'},
164 },
165 'hosts': {'example.com:22': 'SHA256:abc'},
166 }),
167 });
168 final v = Vault(store);
169 await v.unlock(long);
170 await v.idle;
171 expect(jsonDecode(store.values['vault']!)['version'], 2);
172 expect(v.profiles.single.name, 'old');
173 expect(v.secret(profile.id, 'password'), 'kept');
174 v.lock();
175 final reopened = Vault(store);
176 await reopened.unlock(long);
177 expect(reopened.fingerprint('example.com:22'), 'SHA256:abc');
178 expect(reopened.kdfName, 'argon2id');
179 }, timeout: const Timeout(Duration(minutes: 2)));
180 });
182 group('change password', () {
183 late MemoryStore store;
184 late Vault vault;
185 late ServerProfile profile;
186 setUp(() async {
187 store = MemoryStore();
188 vault = Vault(store);
189 await vault.create('old password');
190 profile = ServerProfile(
191 name: 'web',
192 host: 'web.example',
193 username: 'admin',
194 );
195 await vault.save(profile, password: 'ssh-secret', privateKey: 'KEY');
196 await vault.trust('web.example:22', 'SHA256:fp');
197 await vault.setSection('documents', {
198 'recent': ['a'],
199 });
200 });
202 test('keeps all data; old password fails, new password works', () async {
203 await vault.changePassword('old password', '9876');
204 expect(vault.unlocked, true);
205 vault.lock();
206 await expectLater(vault.unlock('old password'), failure('unlockFailed'));
207 await vault.unlock('9876');
208 expect(vault.profiles.single.name, 'web');
209 expect(vault.secret(profile.id, 'password'), 'ssh-secret');
210 expect(vault.secret(profile.id, 'privateKey'), 'KEY');
211 expect(vault.fingerprint('web.example:22'), 'SHA256:fp');
212 expect(vault.section('documents'), {
213 'recent': ['a'],
214 });
215 });
217 test(
218 'wrong current password or invalid new length change nothing',
219 () async {
220 final before = store.values['vault'];
221 await expectLater(
222 vault.changePassword('wrong', 'new password'),
223 failure('unlockFailed'),
224 );
225 await expectLater(
226 vault.changePassword('old password', 'abc'),
227 failure('passwordLength'),
228 );
229 await expectLater(
230 vault.changePassword('old password', 'z' * 65),
231 failure('passwordLength'),
232 );
233 expect(store.values['vault'], before);
234 vault.lock();
235 await vault.unlock('old password');
236 expect(vault.profiles, hasLength(1));
237 },
238 );
240 test('a failed write keeps the old password and data working', () async {
241 final before = store.values['vault'];
242 store.failWrites = true;
243 await expectLater(
244 vault.changePassword('old password', 'new password'),
245 throwsStateError,
246 );
247 store.failWrites = false;
248 expect(store.values['vault'], before);
249 vault.lock();
250 await expectLater(vault.unlock('new password'), failure('unlockFailed'));
251 await vault.unlock('old password');
252 expect(vault.secret(profile.id, 'password'), 'ssh-secret');
253 });
255 test(
256 'a biometric key stored before the change no longer opens the Vault',
257 () async {
258 final gated = MemoryStore();
259 await vault.enableBiometric(gated);
260 await vault.changePassword('old password', 'new password');
261 vault.lock();
262 await expectLater(
263 vault.unlockBiometric(gated),
264 failure('biometricInvalid'),
265 );
266 expect(vault.unlocked, false);
267 },
268 );
269 });
271 group('drafts', () {
272 test(
273 'unsaved buffers survive a lock encrypted and a password change',
274 () async {
275 final store = MemoryStore();
276 final blobs = MemoryBlobStore();
277 final vault = Vault(store, blobs: blobs);
278 await vault.create('draft password');
279 vault.lock(
280 drafts: [
281 const Draft({'name': 'nginx.conf', 'text': 'secret draft text'}),
282 ],
283 );
284 await vault.idle;
285 final stored = blobs.values.values.single;
286 expect(
287 utf8.decode(stored, allowMalformed: true),
288 isNot(contains('secret draft text')),
289 );
290 await vault.unlock('draft password');
291 expect(vault.drafts.single.json['text'], 'secret draft text');
292 await vault.changePassword('draft password', 'other password');
293 vault.lock();
294 await vault.unlock('other password');
295 expect(vault.drafts.single.json['name'], 'nginx.conf');
296 await vault.clearDrafts();
297 expect(vault.drafts, isEmpty);
298 vault.lock();
299 await vault.unlock('other password');
300 expect(vault.drafts, isEmpty);
301 },
302 );
303 });
305 group('biometric unlock on the lock screen', () {
306 Future<AppController> controller(
307 WidgetTester tester,
308 FakeBiometric gated,
309 ) async {
310 final store = MemoryStore();
311 final c = AppController(store);
312 c.biometricStore = (_) => gated;
313 c.biometricSupported = () async => true;
314 await tester.runAsync(() async {
315 await c.initialize();
316 await c.acceptLicense();
317 await c.openVault('lock screen password', create: true);
318 await c.setBiometric(true, 'reason');
319 });
320 c.lock();
321 return c;
322 }
324 Widget app(AppController c) => MaterialApp(
325 locale: const Locale('en'),
326 localizationsDelegates: AppLocalizations.localizationsDelegates,
327 supportedLocales: AppLocalizations.supportedLocales,
328 home: ListenableBuilder(
329 listenable: c,
330 builder: (_, _) =>
331 c.vault.unlocked ? const Text('UNLOCKED') : VaultGate(c),
332 ),
333 );
335 testWidgets('starts by itself and unlocks with the released key', (
336 tester,
337 ) async {
338 final gated = FakeBiometric();
339 final c = await controller(tester, gated);
340 await tester.pumpWidget(app(c));
341 await tester.runAsync(() async {
342 await tester.pump();
343 await tester.pump();
344 for (var i = 0; i < 100 && !c.vault.unlocked; i++) {
345 await Future<void>.delayed(const Duration(milliseconds: 20));
346 }
347 });
348 await tester.pump();
349 expect(gated.reads, 1);
350 expect(find.text('UNLOCKED'), findsOneWidget);
351 c.dispose();
352 });
354 testWidgets(
355 'a cancelled prompt is not repeated; the password still works',
356 (tester) async {
357 final gated = FakeBiometric()..cancel = true;
358 final c = await controller(tester, gated);
359 await tester.pumpWidget(app(c));
360 await tester.runAsync(() async {
361 for (var i = 0; i < 10; i++) {
362 await tester.pump();
363 await Future<void>.delayed(const Duration(milliseconds: 20));
364 }
365 });
366 await tester.pump();
367 expect(gated.reads, 1);
368 expect(c.vault.unlocked, false);
369 expect(
370 find.textContaining('Biometric check cancelled'),
371 findsOneWidget,
372 );
373 expect(find.text('Try biometrics again'), findsOneWidget);
374 // The fallback is the master password, not the device passcode.
375 await tester.enterText(
376 find.byType(TextField).first,
377 'lock screen password',
378 );
379 await tester.runAsync(() async {
380 await tester.tap(find.widgetWithText(FilledButton, 'Unlock'));
381 for (var i = 0; i < 200 && !c.vault.unlocked; i++) {
382 await Future<void>.delayed(const Duration(milliseconds: 20));
383 }
384 });
385 await tester.pump();
386 expect(c.vault.unlocked, true);
387 expect(gated.reads, 1);
388 c.dispose();
389 },
390 );
392 testWidgets('a key removed by the system turns biometric unlock off', (
393 tester,
394 ) async {
395 final gated = FakeBiometric();
396 final c = await controller(tester, gated);
397 gated.values.clear(); // what iOS/Android do after a biometric change
398 await tester.pumpWidget(app(c));
399 await tester.runAsync(() async {
400 for (var i = 0; i < 10; i++) {
401 await tester.pump();
402 await Future<void>.delayed(const Duration(milliseconds: 20));
403 }
404 });
405 await tester.pump();
406 expect(c.biometric, false);
407 expect(c.vault.unlocked, false);
408 expect(find.textContaining('no longer valid'), findsOneWidget);
409 c.dispose();
410 });
412 testWidgets('password change renews the biometric key', (tester) async {
413 final gated = FakeBiometric();
414 final c = await controller(tester, gated);
415 await tester.runAsync(() async {
416 await c.openVault('lock screen password');
417 await c.changePassword('lock screen password', '4321', 'reason');
418 c.lock();
419 await c.vault.unlockBiometric(gated);
420 });
421 expect(c.vault.unlocked, true);
422 expect(c.biometric, true);
423 c.dispose();
424 });
425 });
427 test('legacy biometric settings ask for the password once', () async {
428 final store = MemoryStore({
429 'preferences': jsonEncode({'biometric': true}),
430 });
431 final c = AppController(store);
432 await c.initialize();
433 expect(c.biometricVersion, 1);
434 expect(c.biometricNotice, 'biometricUpgrade');
435 c.dispose();
436 });
438 test('device store keeps strong-only, passcode-free biometric options', () {
439 final s = DeviceStore.biometric('t');
440 expect(
441 s.storage.iOptions.params['accessControlFlags'],
442 contains('biometryCurrentSet'),
443 );
444 expect(
445 s.storage.iOptions.params['accessControlFlags'],
446 isNot(contains('userPresence')),
447 );
448 expect(s.storage.aOptions.params['biometricType'], 'strongBiometricOnly');
449 });
SHA-256a7da0b66672c5d1c1495ead6f741ce953aacf1dc9ef9705bda67246cafb9828b

CH-J Proprietary Software License 1.14 · کد منبع تحت CH-J Proprietary Software License 1.14 ارائه می‌شود. در دسترس بودن آن شرایط مجوز را تغییر نمی‌دهد و حق اضافه‌ای اعطا نمی‌کند.

SHA-256 بایگانی46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655
فایل‌ها