import 'dart:convert'; import 'dart:math'; import 'package:cryptography/cryptography.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sm_mobile/core/app_controller.dart'; import 'package:sm_mobile/core/failure.dart'; import 'package:sm_mobile/l10n/strings.dart'; import 'package:sm_mobile/models/server_profile.dart'; import 'package:sm_mobile/services/blob_store.dart'; import 'package:sm_mobile/services/secure_store.dart'; import 'package:sm_mobile/services/vault.dart'; import 'package:sm_mobile/ui/gates.dart'; import 'support.dart'; Matcher failure(String code) => throwsA(isA().having((f) => f.code, 'code', code)); /// A Vault envelope exactly as SM Mobile 0.0.2 wrote it (version 1, PBKDF2). Future legacyEnvelope(String password, Map data) async { const aad = 'CHJ_SM_MOBILE_VAULT_V1'; final aes = AesGcm.with256bits(); final random = Random.secure(); final salt = List.generate(16, (_) => random.nextInt(256)); final derived = await (await Pbkdf2( macAlgorithm: Hmac.sha256(), iterations: 600000, bits: 256, ).deriveKey(secretKey: SecretKey(utf8.encode(password)), nonce: salt)) .extractBytes(); final key = await (await aes.newSecretKey()).extractBytes(); final wrapped = await aes.encrypt( key, secretKey: SecretKey(derived), aad: utf8.encode('$aad:wrap'), ); final box = await aes.encrypt( utf8.encode(jsonEncode(data)), secretKey: SecretKey(key), aad: utf8.encode(aad), ); return jsonEncode({ 'version': 1, 'salt': base64Encode(salt), 'wrap': base64Encode(wrapped.concatenation()), 'data': base64Encode(box.concatenation()), }); } /// Simulates the system biometric store: reads either release the key or /// fail like a cancelled prompt. class FakeBiometric extends MemoryStore { int reads = 0; bool cancel = false; @override Future read(String key) async { reads++; if (cancel) { throw PlatformException( code: 'Unexpected security result code', details: -128, ); } return super.read(key); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('master password length', () { test( '3 characters are rejected, 4, 12 and 64 accepted, 65 rejected', () async { await expectLater( Vault(MemoryStore()).create('abc'), failure('passwordLength'), ); for (final length in [4, 12, 64]) { final v = Vault(MemoryStore()); await v.create('x' * (length - 1) + '!'); expect(v.unlocked, true, reason: '$length characters'); v.lock(); } await expectLater( Vault(MemoryStore()).create('y' * 65), failure('passwordLength'), ); }, ); test( 'a 4-digit PIN, spaces and special characters are kept as typed', () async { final store = MemoryStore(); final v = Vault(store); await v.create('1234'); v.lock(); await v.unlock('1234'); expect(v.unlocked, true); v.lock(); final spaced = Vault(MemoryStore()); await spaced.create(' p ä§ '); spaced.lock(); // Not trimmed: the inner text alone does not open it. await expectLater(spaced.unlock('p ä§'), failure('unlockFailed')); await spaced.unlock(' p ä§ '); expect(spaced.unlocked, true); }, ); test('length counts user-perceived characters', () { expect(passwordLength('👍🏽👍🏽👍🏽👍🏽'), 4); expect(() => checkNewPassword('👍🏽👍🏽👍🏽'), throwsA(isA())); checkNewPassword('👍🏽👍🏽👍🏽👍🏽'); expect(passwordStrength('1234'), 0); expect(passwordStrength('correct horse battery staple 42!'), 4); }); }); group('key derivation', () { test('new Vaults use Argon2id with RFC 9106 parameters', () async { final store = MemoryStore(); final v = Vault(store); await v.create('argon test'); final e = jsonDecode(store.values['vault']!); expect(e['version'], 2); expect(e['kdf'], { 'name': 'argon2id', 'memory': 65536, 'iterations': 3, 'parallelism': 1, }); expect(v.kdfName, 'argon2id'); }); test('weakened or unknown KDF parameters are refused', () async { final store = MemoryStore(); final v = Vault(store); await v.create('argon test'); v.lock(); final e = jsonDecode(store.values['vault']!); e['kdf']['memory'] = 1024; store.values['vault'] = jsonEncode(e); await expectLater(v.unlock('argon test'), failure('storageError')); }); test('a version 1 (PBKDF2) Vault opens with its old long password and is ' 'upgraded to Argon2id without data loss', () async { final long = 'L' * 100; // allowed before; above today's maximum final profile = ServerProfile( name: 'old', host: 'example.com', username: 'root', ); final store = MemoryStore({ 'vault': await legacyEnvelope(long, { 'profiles': [profile.toJson()], 'secrets': { profile.id: {'password': 'kept'}, }, 'hosts': {'example.com:22': 'SHA256:abc'}, }), }); final v = Vault(store); await v.unlock(long); await v.idle; expect(jsonDecode(store.values['vault']!)['version'], 2); expect(v.profiles.single.name, 'old'); expect(v.secret(profile.id, 'password'), 'kept'); v.lock(); final reopened = Vault(store); await reopened.unlock(long); expect(reopened.fingerprint('example.com:22'), 'SHA256:abc'); expect(reopened.kdfName, 'argon2id'); }, timeout: const Timeout(Duration(minutes: 2))); }); group('change password', () { late MemoryStore store; late Vault vault; late ServerProfile profile; setUp(() async { store = MemoryStore(); vault = Vault(store); await vault.create('old password'); profile = ServerProfile( name: 'web', host: 'web.example', username: 'admin', ); await vault.save(profile, password: 'ssh-secret', privateKey: 'KEY'); await vault.trust('web.example:22', 'SHA256:fp'); await vault.setSection('documents', { 'recent': ['a'], }); }); test('keeps all data; old password fails, new password works', () async { await vault.changePassword('old password', '9876'); expect(vault.unlocked, true); vault.lock(); await expectLater(vault.unlock('old password'), failure('unlockFailed')); await vault.unlock('9876'); expect(vault.profiles.single.name, 'web'); expect(vault.secret(profile.id, 'password'), 'ssh-secret'); expect(vault.secret(profile.id, 'privateKey'), 'KEY'); expect(vault.fingerprint('web.example:22'), 'SHA256:fp'); expect(vault.section('documents'), { 'recent': ['a'], }); }); test( 'wrong current password or invalid new length change nothing', () async { final before = store.values['vault']; await expectLater( vault.changePassword('wrong', 'new password'), failure('unlockFailed'), ); await expectLater( vault.changePassword('old password', 'abc'), failure('passwordLength'), ); await expectLater( vault.changePassword('old password', 'z' * 65), failure('passwordLength'), ); expect(store.values['vault'], before); vault.lock(); await vault.unlock('old password'); expect(vault.profiles, hasLength(1)); }, ); test('a failed write keeps the old password and data working', () async { final before = store.values['vault']; store.failWrites = true; await expectLater( vault.changePassword('old password', 'new password'), throwsStateError, ); store.failWrites = false; expect(store.values['vault'], before); vault.lock(); await expectLater(vault.unlock('new password'), failure('unlockFailed')); await vault.unlock('old password'); expect(vault.secret(profile.id, 'password'), 'ssh-secret'); }); test( 'a biometric key stored before the change no longer opens the Vault', () async { final gated = MemoryStore(); await vault.enableBiometric(gated); await vault.changePassword('old password', 'new password'); vault.lock(); await expectLater( vault.unlockBiometric(gated), failure('biometricInvalid'), ); expect(vault.unlocked, false); }, ); }); group('drafts', () { test( 'unsaved buffers survive a lock encrypted and a password change', () async { final store = MemoryStore(); final blobs = MemoryBlobStore(); final vault = Vault(store, blobs: blobs); await vault.create('draft password'); vault.lock( drafts: [ const Draft({'name': 'nginx.conf', 'text': 'secret draft text'}), ], ); await vault.idle; final stored = blobs.values.values.single; expect( utf8.decode(stored, allowMalformed: true), isNot(contains('secret draft text')), ); await vault.unlock('draft password'); expect(vault.drafts.single.json['text'], 'secret draft text'); await vault.changePassword('draft password', 'other password'); vault.lock(); await vault.unlock('other password'); expect(vault.drafts.single.json['name'], 'nginx.conf'); await vault.clearDrafts(); expect(vault.drafts, isEmpty); vault.lock(); await vault.unlock('other password'); expect(vault.drafts, isEmpty); }, ); }); group('biometric unlock on the lock screen', () { Future controller( WidgetTester tester, FakeBiometric gated, ) async { final store = MemoryStore(); final c = AppController(store); c.biometricStore = (_) => gated; c.biometricSupported = () async => true; await tester.runAsync(() async { await c.initialize(); await c.acceptLicense(); await c.openVault('lock screen password', create: true); await c.setBiometric(true, 'reason'); }); c.lock(); return c; } Widget app(AppController c) => MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: ListenableBuilder( listenable: c, builder: (_, _) => c.vault.unlocked ? const Text('UNLOCKED') : VaultGate(c), ), ); testWidgets('starts by itself and unlocks with the released key', ( tester, ) async { final gated = FakeBiometric(); final c = await controller(tester, gated); await tester.pumpWidget(app(c)); await tester.runAsync(() async { await tester.pump(); await tester.pump(); for (var i = 0; i < 100 && !c.vault.unlocked; i++) { await Future.delayed(const Duration(milliseconds: 20)); } }); await tester.pump(); expect(gated.reads, 1); expect(find.text('UNLOCKED'), findsOneWidget); c.dispose(); }); testWidgets( 'a cancelled prompt is not repeated; the password still works', (tester) async { final gated = FakeBiometric()..cancel = true; final c = await controller(tester, gated); await tester.pumpWidget(app(c)); await tester.runAsync(() async { for (var i = 0; i < 10; i++) { await tester.pump(); await Future.delayed(const Duration(milliseconds: 20)); } }); await tester.pump(); expect(gated.reads, 1); expect(c.vault.unlocked, false); expect( find.textContaining('Biometric check cancelled'), findsOneWidget, ); expect(find.text('Try biometrics again'), findsOneWidget); // The fallback is the master password, not the device passcode. await tester.enterText( find.byType(TextField).first, 'lock screen password', ); await tester.runAsync(() async { await tester.tap(find.widgetWithText(FilledButton, 'Unlock')); for (var i = 0; i < 200 && !c.vault.unlocked; i++) { await Future.delayed(const Duration(milliseconds: 20)); } }); await tester.pump(); expect(c.vault.unlocked, true); expect(gated.reads, 1); c.dispose(); }, ); testWidgets('a key removed by the system turns biometric unlock off', ( tester, ) async { final gated = FakeBiometric(); final c = await controller(tester, gated); gated.values.clear(); // what iOS/Android do after a biometric change await tester.pumpWidget(app(c)); await tester.runAsync(() async { for (var i = 0; i < 10; i++) { await tester.pump(); await Future.delayed(const Duration(milliseconds: 20)); } }); await tester.pump(); expect(c.biometric, false); expect(c.vault.unlocked, false); expect(find.textContaining('no longer valid'), findsOneWidget); c.dispose(); }); testWidgets('password change renews the biometric key', (tester) async { final gated = FakeBiometric(); final c = await controller(tester, gated); await tester.runAsync(() async { await c.openVault('lock screen password'); await c.changePassword('lock screen password', '4321', 'reason'); c.lock(); await c.vault.unlockBiometric(gated); }); expect(c.vault.unlocked, true); expect(c.biometric, true); c.dispose(); }); }); test('legacy biometric settings ask for the password once', () async { final store = MemoryStore({ 'preferences': jsonEncode({'biometric': true}), }); final c = AppController(store); await c.initialize(); expect(c.biometricVersion, 1); expect(c.biometricNotice, 'biometricUpgrade'); c.dispose(); }); test('device store keeps strong-only, passcode-free biometric options', () { final s = DeviceStore.biometric('t'); expect( s.storage.iOptions.params['accessControlFlags'], contains('biometryCurrentSet'), ); expect( s.storage.iOptions.params['accessControlFlags'], isNot(contains('userPresence')), ); expect(s.storage.aOptions.params['biometricType'], 'strongBiometricOnly'); }); }