diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index b0f2f0d0729..dc585f17a35 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -298,3 +298,4 @@ xctestrun xcworkspace xvfb yoroi +Pbkdf2 \ No newline at end of file diff --git a/catalyst_voices/lib/dependency/dependencies.dart b/catalyst_voices/lib/dependency/dependencies.dart index eee7f7d3df8..f9754006dd2 100644 --- a/catalyst_voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/lib/dependency/dependencies.dart @@ -55,7 +55,7 @@ final class Dependencies extends DependencyProvider { void _registerServices() { registerLazySingleton(() => const SecureStorage()); - registerLazySingleton(() => const SecureStorageVault()); + registerLazySingleton(SecureStorageVault.new); registerLazySingleton( () => const SecureDummyAuthStorage(), ); diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart index e38d3ad0a0c..30d58759880 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart @@ -29,12 +29,6 @@ final class SessionBloc extends Bloc ) async { if (!await _keychain.hasSeedPhrase) { emit(const VisitorSessionState()); - } else if (await _keychain.isUnlocked) { - // TODO(damian-molinski): we shouldn't keep the keychain unlocked - // after leaving the app. In the future once keychain stays locked - // when leaving the app the logic here is not needed. - await _keychain.lock(); - emit(const GuestSessionState()); } else { emit(const GuestSessionState()); } @@ -56,7 +50,9 @@ final class SessionBloc extends Bloc LockSessionEvent event, Emitter emit, ) async { - await _keychain.lock(); + if (await _keychain.hasLock) { + await _keychain.lock(); + } emit(const GuestSessionState()); } @@ -88,6 +84,10 @@ final class SessionBloc extends Bloc GuestSessionEvent event, Emitter emit, ) async { + if (await _keychain.hasLock && !await _keychain.isUnlocked) { + await _keychain.unlock(_dummyUnlockFactor); + } + await _keychain.setLockAndBeginWith( seedPhrase: _dummySeedPhrase, unlockFactor: _dummyUnlockFactor, @@ -101,6 +101,10 @@ final class SessionBloc extends Bloc ActiveUserSessionEvent event, Emitter emit, ) async { + if (await _keychain.hasLock && !await _keychain.isUnlocked) { + await _keychain.unlock(_dummyUnlockFactor); + } + await _keychain.setLockAndBeginWith( seedPhrase: _dummySeedPhrase, unlockFactor: _dummyUnlockFactor, diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/crypto_exception.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/crypto_exception.dart new file mode 100644 index 00000000000..c557c65762a --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/crypto_exception.dart @@ -0,0 +1,62 @@ +import 'package:equatable/equatable.dart'; + +sealed class CryptoException extends Equatable implements Exception { + const CryptoException(); + + @override + List get props => []; +} + +/// Usually thrown when trying to decrypt with invalid key +final class CryptoAuthenticationException extends CryptoException { + const CryptoAuthenticationException(); + + @override + String toString() => 'CryptoAuthenticationException'; +} + +/// Thrown when trying to decrypt tampered data +final class CryptoDataMalformed extends CryptoException { + final String? message; + + const CryptoDataMalformed([this.message]); + + @override + String toString() { + if (message != null) return 'CryptoDataMalformed: $message'; + return 'CryptoDataMalformed'; + } + + @override + List get props => [message]; +} + +final class CryptoVersionUnsupported extends CryptoException { + final String? message; + + const CryptoVersionUnsupported([this.message]); + + @override + String toString() { + if (message != null) return 'CryptoVersionUnsupported: $message'; + return 'CryptoVersionUnsupported'; + } + + @override + List get props => [message]; +} + +final class CryptoAlgorithmUnsupported extends CryptoException { + final String? message; + + const CryptoAlgorithmUnsupported([this.message]); + + @override + String toString() { + if (message != null) return 'CryptoAlgorithmUnsupported: $message'; + return 'CryptoAlgorithmUnsupported'; + } + + @override + List get props => [message]; +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/errors.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/errors.dart index f4604d99cbb..c467093a31c 100644 --- a/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/errors.dart +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/errors.dart @@ -1,2 +1,4 @@ +export 'crypto_exception.dart'; export 'network_error.dart'; export 'secure_storage_error.dart'; +export 'vault_exception.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/vault_exception.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/vault_exception.dart new file mode 100644 index 00000000000..39282d85b90 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/vault_exception.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +sealed class VaultException extends Equatable implements Exception { + const VaultException(); + + @override + List get props => []; +} + +final class LockNotFoundException extends VaultException { + final String? message; + + const LockNotFoundException([this.message]); + + @override + String toString() { + if (message != null) return 'LockNotFoundException: $message'; + return 'LockNotFoundException'; + } + + @override + List get props => [message]; +} + +final class VaultLockedException extends VaultException { + const VaultLockedException(); + + @override + String toString() => 'VaultLockedException'; +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart index a08df2f9185..8ff75bee4fb 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart @@ -7,6 +7,5 @@ export 'storage/dummy_auth_storage.dart'; export 'storage/secure_storage.dart'; export 'storage/storage.dart'; export 'storage/vault/lock_factor.dart'; -export 'storage/vault/lock_factor_codec.dart' show LockFactorCodec; export 'storage/vault/secure_storage_vault.dart'; export 'storage/vault/vault.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/crypto/crypto_service.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/crypto/crypto_service.dart new file mode 100644 index 00000000000..edafb8d76a1 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/crypto/crypto_service.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; + +/// An abstract interface that defines cryptographic operations such as +/// key derivation, encryption, decryption, and key verification. +// TODO(damian-molinski): Expose KeyDerivation interface and have it +// delegate implementation +abstract interface class CryptoService { + /// Derives a cryptographic key from a given seed, with an optional salt. + /// + /// The derived key is generated based on the provided [seed], which serves + /// as the primary input. Optionally, a [salt] can be used to further + /// randomize the key derivation process, increasing security. + /// + /// - [seed]: The main input data used for key derivation. + /// - [salt]: Optional salt value to randomize the derived key (can be null). + /// + /// Returns a [Future] that completes with the derived key as a [Uint8List]. + Future deriveKey( + Uint8List seed, { + Uint8List? salt, + }); + + /// Verifies if a given cryptographic key is correctly derived from a seed. + /// + /// This method checks whether the provided [key] matches the key that would + /// be derived from the given [seed]. This can be useful to verify integrity + /// or correctness of the key derivation process. + /// + /// - [seed]: The input data used for key derivation. + /// - [key]: The derived key that needs to be verified. + /// + /// Returns a [Future] that completes with `true` if the [key] is valid and + /// correctly derived from the [seed], or `false` otherwise. + Future verifyKey( + Uint8List seed, { + required Uint8List key, + }); + + /// Decrypts the provided [data] using the specified cryptographic [key], + /// usually build using [deriveKey]. + /// + /// This method takes encrypted [data] and decrypts it using the provided + /// [key]. The decryption algorithm and the format of the data should be + /// defined by the implementing class. + /// + /// - [data]: The encrypted data to be decrypted. + /// - [key]: The key used for decryption. + /// + /// Returns a [Future] that completes with the decrypted data as a + /// [Uint8List]. + Future decrypt( + Uint8List data, { + required Uint8List key, + }); + + /// Encrypts the provided [data] using the specified cryptographic [key], + /// usually build using [deriveKey]. + /// + /// This method takes plaintext [data] and encrypts it using the provided + /// [key]. The encryption algorithm and format of the output should be defined + /// by the implementing class. + /// + /// - [data]: The plaintext data to be encrypted. + /// - [key]: The key used for encryption. + /// + /// Returns a [Future] that completes with the encrypted data as a + /// [Uint8List]. + Future encrypt( + Uint8List data, { + required Uint8List key, + }); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/crypto/vault_crypto_service.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/crypto/vault_crypto_service.dart new file mode 100644 index 00000000000..0bc0e6505bb --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/crypto/vault_crypto_service.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_services/src/crypto/crypto_service.dart'; +import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('VaultCryptoService'); + +/// [CryptoService] implementation used by default in [Vault]. +/// +/// It uses Pbkdf2 for key derivation as well as +/// AesGcm for encryption/decryption. +/// +/// Only keys build by [VaultCryptoService.deriveKey] should be used +/// for crypto operations are we're adding [VaultCryptoService] specific +/// metadata to them. +/// +/// Supports version for future changes. +final class VaultCryptoService implements CryptoService { + /// Salt length for key derivation. + static const int _saltLength = 16; + + /// Salt length for AesGcm data encryption. + static const int _viLength = 12; + + /// Versioning for future improvements. + static const int _currentVersion = 1; + + /// Algorithm id for future improvements. + /// AES-GCM + static const int _currentAlgorithmId = 1; + + final Random _random; + + VaultCryptoService({ + Random? random, + }) : _random = random ?? Random.secure(); + + /// 3-byte marker attached at the end of encrypted data. + Uint8List get _checksum => utf8.encode('CHK'); + + // Note. Argon2id has no native browser implementation and dart one is + // slow > 1s. That's why Pbkdf2 is used. + @override + Future deriveKey( + Uint8List seed, { + Uint8List? salt, + }) { + Future run() async { + final algorithm = Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: 10000, // 20k iterations + bits: 256, // 256 bits = 32 bytes output + ); + + final keySalt = salt ?? _generateRandomList(length: _saltLength); + + final secretKey = await algorithm.deriveKey( + secretKey: SecretKey(seed), + nonce: keySalt, + ); + + final keyBytes = await secretKey.extractBytes().then(Uint8List.fromList); + + secretKey.destroy(); + + // Combine salt and hashed password for storage + return Uint8List.fromList([...keySalt, ...keyBytes]); + } + + if (kDebugMode) { + return _benchmark(run(), debugLabel: 'DeriveKey'); + } else { + return run(); + } + } + + @override + Future verifyKey( + Uint8List seed, { + required Uint8List key, + }) { + Future run() async { + final salt = key.sublist(0, _saltLength); + if (salt.length < _saltLength) { + return false; + } + + final derivedKey = await deriveKey(seed, salt: salt); + return listEquals(derivedKey, key); + } + + if (kDebugMode) { + return _benchmark(run(), debugLabel: 'VerifyKey'); + } else { + return run(); + } + } + + @override + Future decrypt( + Uint8List data, { + required Uint8List key, + }) { + Future run() async { + if (data.length < 2) { + throw const CryptoDataMalformed(); + } + + // Extract the version, algorithm ID + final version = data[0]; + final algorithmId = data[1]; + + if (version != _currentVersion) { + throw CryptoVersionUnsupported('Version $version'); + } + + if (algorithmId != _currentAlgorithmId) { + throw CryptoAlgorithmUnsupported('Algorithm $version'); + } + + final algorithm = AesGcm.with256bits(nonceLength: _viLength); + final secretKey = SecretKey(key.sublist(_saltLength)); + + final encryptedData = data.sublist(2); + + final secretBox = SecretBox.fromConcatenation( + encryptedData, + nonceLength: _viLength, + macLength: algorithm.macAlgorithm.macLength, + ); + + final decryptedData = await algorithm + .decrypt(secretBox, secretKey: secretKey) + .then(Uint8List.fromList) + .onError( + (_, __) => throw const CryptoAuthenticationException(), + ); + + // Verify checksum/marker + final checksum = _checksum; + if (decryptedData.length < checksum.length) { + throw const CryptoDataMalformed('Data is too short'); + } + final originalDataLength = decryptedData.length - checksum.length; + final originalData = decryptedData.sublist(0, originalDataLength); + final extractedChecksum = decryptedData.sublist(originalDataLength); + + if (!listEquals(checksum, extractedChecksum)) { + throw const CryptoDataMalformed('Checksum mismatch'); + } + + return originalData; + } + + if (kDebugMode) { + return _benchmark(run(), debugLabel: 'Decrypt'); + } else { + return run(); + } + } + + @override + Future encrypt( + Uint8List data, { + required Uint8List key, + }) { + Future run() async { + final algorithm = AesGcm.with256bits(nonceLength: _viLength); + final secretKey = SecretKey(key.sublist(_saltLength)); + + final checksum = _checksum; + final combinedData = Uint8List.fromList([...data, ...checksum]); + + final secretBox = await algorithm.encrypt( + combinedData, + secretKey: secretKey, + nonce: null, + aad: [], + possibleBuffer: null, + ); + + final concatenation = secretBox.concatenation(); + + final metadata = + Uint8List.fromList([_currentVersion, _currentAlgorithmId]); + + final result = Uint8List.fromList([ + ...metadata, + ...concatenation, + ]); + + return result; + } + + if (kDebugMode) { + return _benchmark(run(), debugLabel: 'Encrypt'); + } else { + return run(); + } + } + + /// Builds list with [length] and random bytes in it. + Uint8List _generateRandomList({ + required int length, + }) { + final list = Uint8List(length); + + for (var i = 0; i < length; i++) { + list[i] = _random.nextInt(255); + } + + return list; + } + + Future _benchmark( + Future future, { + required String debugLabel, + }) { + final stopwatch = Stopwatch()..start(); + + return future.whenComplete( + () { + stopwatch.stop(); + _logger.finer('Took[$debugLabel] ${stopwatch.elapsed}'); + }, + ); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart index 33ea17b5b16..40827992654 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart @@ -13,6 +13,9 @@ const _seedPhraseKey = 'keychain_seed_phrase'; // TODO(dtscalac): in the future when key derivation algorithm spec // will become stable consider to store derived keys instead of deriving // them each time they are needed. + +// TODO(damian-molinski): because we have dummy lock factors vault unlocking +// is dummy too. Any operation on vault require correct lock factor input. final class Keychain { final _logger = Logger('Keychain'); @@ -21,9 +24,12 @@ final class Keychain { Keychain(this._keyDerivation, this._vault); - /// Returns true if the keychain is unlocked, false otherwise. + /// See [Vault.isUnlocked]. Future get isUnlocked => _vault.isUnlocked; + /// See [Vault.hasLock]. + Future get hasLock => _vault.hasLock; + /// Returns true if the keychain contains the seed phrase, false otherwise. Future get hasSeedPhrase async => _hasSeedPhrase; @@ -37,8 +43,14 @@ final class Keychain { bool unlocked = true, }) async { _logger.info('setLockAndBeginWith, unlocked: $unlocked'); - await _changeLock(unlockFactor, unlocked: unlocked); + + await _changeLock(unlockFactor); + await _vault.unlock(unlockFactor); await _beginWith(seedPhrase: seedPhrase); + + if (!unlocked) { + await _vault.lock(); + } } /// Clears the keychain and all associated data. @@ -47,9 +59,7 @@ final class Keychain { /// from the underlying storage. Future clearAndLock() async { _logger.info('clearAndLock'); - await _ensureUnlocked(); - await _vault.delete(key: _seedPhraseKey); - await _vault.setLock(const VoidLockFactor()); + await _vault.clear(); await _vault.lock(); } @@ -95,16 +105,8 @@ final class Keychain { } } - Future _changeLock( - LockFactor lockFactor, { - bool unlocked = false, - }) async { + Future _changeLock(LockFactor lockFactor) async { await _vault.setLock(lockFactor); - if (unlocked) { - await _vault.unlock(lockFactor); - } else { - await _vault.lock(); - } } Future _beginWith({ diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_service.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_service.dart index ac4119f9253..411fdf70030 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_service.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_service.dart @@ -33,6 +33,7 @@ final class RegistrationService { required SeedPhrase seedPhrase, required String unlockPassword, }) async { + await _keychain.clearAndLock(); await _keychain.setLockAndBeginWith( seedPhrase: seedPhrase, unlockFactor: PasswordLockFactor(unlockPassword), diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor.dart index c3ec5cd5225..3a47ff91673 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor.dart @@ -1,6 +1,7 @@ -import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; +import 'dart:convert'; -enum _LockFactorType { voidFactor, password } +import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; +import 'package:flutter/foundation.dart'; // Note. // In future we may add MultiLockFactor for bio and password unlock factors @@ -8,50 +9,8 @@ enum _LockFactorType { voidFactor, password } /// Abstract representation of different factors that can lock [Vault] with. /// /// Most common is [PasswordLockFactor] which can be use as standalone factor. -/// -/// This class is serializable to/from json. -sealed class LockFactor { - /// Use [LockFactor.toJson] as parameter for this factory. - factory LockFactor.fromJson(Map json) { - final typeName = json['type']; - final type = _LockFactorType.values.asNameMap()[typeName]; - - return switch (type) { - _LockFactorType.voidFactor => const VoidLockFactor(), - _LockFactorType.password => PasswordLockFactor.fromJson(json), - null => throw ArgumentError('Unknown type name($typeName)', 'json'), - }; - } - - /// Returns true when this [LockFactor] can be used to unlock - /// other [LockFactor]. - bool unlocks(LockFactor factor); - - /// Returns json representation on this [LockFactor]. - /// - /// Should be used with [LockFactor.fromJson]. - Map toJson(); -} - -/// Can not be used to unlock anything. Useful as default value for [LockFactor] -/// variables. -/// -/// [unlocks] always returns false. -final class VoidLockFactor implements LockFactor { - const VoidLockFactor(); - - @override - bool unlocks(LockFactor factor) => false; - - @override - Map toJson() { - return { - 'type': _LockFactorType.voidFactor.name, - }; - } - - @override - String toString() => 'VoidLockFactor'; +abstract interface class LockFactor { + Uint8List get seed; } /// Password matching [LockFactor]. @@ -63,24 +22,8 @@ final class PasswordLockFactor implements LockFactor { const PasswordLockFactor(this._data); - factory PasswordLockFactor.fromJson(Map json) { - return PasswordLockFactor( - json['data'] as String, - ); - } - - @override - bool unlocks(LockFactor factor) { - return factor is PasswordLockFactor && _data == factor._data; - } - @override - Map toJson() { - return { - 'type': _LockFactorType.password.name, - 'data': _data, - }; - } + Uint8List get seed => utf8.encode(_data); @override String toString() => 'PasswordLockFactor(${_data.hashCode})'; diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor_codec.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor_codec.dart deleted file mode 100644 index c6754ed75e0..00000000000 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor_codec.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:convert'; - -import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; - -abstract class LockFactorCodec extends Codec { - const LockFactorCodec(); -} - -/// Uses [LockFactor.toJson] and [LockFactor.fromJson] to serialize to -/// [String] using [json]. -class DefaultLockFactorCodec extends LockFactorCodec { - const DefaultLockFactorCodec(); - - @override - Converter get decoder => const _LockFactorDecoder(); - - @override - Converter get encoder => const _LockFactorEncoder(); -} - -class _LockFactorDecoder extends Converter { - const _LockFactorDecoder(); - - @override - LockFactor convert(String input) { - final json = jsonDecode(input) as Map; - - return LockFactor.fromJson(json); - } -} - -class _LockFactorEncoder extends Converter { - const _LockFactorEncoder(); - - @override - String convert(LockFactor input) { - final json = input.toJson(); - - return jsonEncode(json); - } -} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart index d33fa8f1b55..412254b03f1 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart @@ -1,62 +1,95 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_services/src/crypto/crypto_service.dart'; +import 'package:catalyst_voices_services/src/crypto/vault_crypto_service.dart'; import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; -import 'package:catalyst_voices_services/src/storage/vault/lock_factor_codec.dart'; import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; const _keyPrefix = 'SecureStorageVault'; -const _lockKey = 'LockFactorKey'; -const _unlockKey = 'UnlockFactorKey'; +const _lockKey = 'LockKey'; -// TODO(damian-molinski): Maybe we'll need to encrypt data with LockFactor /// Implementation of [Vault] that uses [FlutterSecureStorage] as /// facade for read/write operations. base class SecureStorageVault with StorageAsStringMixin implements Vault { final FlutterSecureStorage _secureStorage; - final LockFactorCodec _lockCodec; + final CryptoService _cryptoService; - const SecureStorageVault({ + bool _isUnlocked = false; + + SecureStorageVault({ FlutterSecureStorage secureStorage = const FlutterSecureStorage(), - LockFactorCodec lockCodec = const DefaultLockFactorCodec(), + CryptoService? cryptoService, }) : _secureStorage = secureStorage, - _lockCodec = lockCodec; + _cryptoService = cryptoService ?? VaultCryptoService(); - /// If storage does not have [LockFactor] this getter will - /// return [VoidLockFactor] as fallback. - Future get _lock => _readLock(_lockKey); + Future get _lock async { + final effectiveKey = _buildVaultKey(_lockKey); + final encodedLock = await _secureStorage.read(key: effectiveKey); + return encodedLock != null ? base64.decode(encodedLock) : null; + } - /// If storage does not have [LockFactor] this getter will - /// return [VoidLockFactor] as fallback. - Future get _unlock => _readLock(_unlockKey); + Future get _requireLock async { + final lock = await _lock; + if (lock == null) { + throw const LockNotFoundException(); + } + return lock; + } @override - Future get isUnlocked async { - final lock = await _lock; - final unlock = await _unlock; + Future get isUnlocked => Future(() => _isUnlocked); - return unlock.unlocks(lock); + @override + Future get hasLock async { + final effectiveKey = _buildVaultKey(_lockKey); + return _secureStorage.containsKey(key: effectiveKey); } @override - Future lock() => _writeLock(null, key: _unlockKey); + Future lock() async { + _isUnlocked = false; + } @override Future unlock(LockFactor unlock) async { - await _writeLock(unlock, key: _unlockKey); + if (!await hasLock) { + throw const LockNotFoundException('Set lock before unlocking Vault'); + } + + final seed = unlock.seed; + final lock = await _requireLock; + + _isUnlocked = await _cryptoService.verifyKey(seed, key: lock); + + // TODO(damian-molinski): Erase lock; return isUnlocked; } @override - Future setLock(LockFactor lock) { - return _writeLock(lock, key: _lockKey); + Future setLock(LockFactor lock) async { + if (await hasLock && !await isUnlocked) { + throw const VaultLockedException(); + } + + final seed = lock.seed; + final key = await _cryptoService.deriveKey(seed); + final encodedKey = base64.encode(key); + + final effectiveLockKey = _buildVaultKey(_lockKey); + + await _secureStorage.write(key: effectiveLockKey, value: encodedKey); } @override Future contains({required String key}) async { - return await _guardedRead(key: key, requireUnlocked: false) != null; + final effectiveKey = _buildVaultKey(key); + return _secureStorage.containsKey(key: effectiveKey); } @override @@ -80,40 +113,29 @@ base class SecureStorageVault with StorageAsStringMixin implements Vault { } } - Future _readLock(String key) async { - final value = await _guardedRead(key: key, requireUnlocked: false); - - return value != null ? _lockCodec.decode(value) : const VoidLockFactor(); - } - - Future _writeLock( - LockFactor? lock, { - required String key, - }) { - final encodedLock = lock != null ? _lockCodec.encode(lock) : null; - - return _guardedWrite( - encodedLock, - key: key, - requireUnlocked: false, - ); - } - /// Allows operation only when [isUnlocked] it true, otherwise returns null. /// /// Returns value assigned to [key]. May return null if not found for [key]. Future _guardedRead({ required String key, - bool requireUnlocked = true, }) async { - final hasAccess = !requireUnlocked || await isUnlocked; - if (!hasAccess) { - return null; + final isUnlocked = await this.isUnlocked; + if (!isUnlocked) { + throw const VaultLockedException(); } final effectiveKey = _buildVaultKey(key); + final encryptedData = await _secureStorage.read(key: effectiveKey); + if (encryptedData == null) { + return null; + } + + final lock = await _requireLock; + final decrypted = await _decrypt(encryptedData, key: lock); - return _secureStorage.read(key: effectiveKey); + // TODO(damian-molinski): Erase lock; + + return decrypted; } /// Allows operation only when [isUnlocked] it true, otherwise non op. @@ -123,20 +145,43 @@ base class SecureStorageVault with StorageAsStringMixin implements Vault { Future _guardedWrite( String? value, { required String key, - bool requireUnlocked = true, }) async { - final hasAccess = !requireUnlocked || await isUnlocked; - if (!hasAccess) { - return; + final isUnlocked = await this.isUnlocked; + if (!isUnlocked) { + throw const VaultLockedException(); } final effectiveKey = _buildVaultKey(key); - if (value != null) { - await _secureStorage.write(key: effectiveKey, value: value); - } else { + if (value == null) { await _secureStorage.delete(key: effectiveKey); + return; } + + final lock = await _requireLock; + final encryptedData = await _encrypt(value, key: lock); + + // TODO(damian-molinski): Erase lock; + + await _secureStorage.write(key: effectiveKey, value: encryptedData); + } + + Future _encrypt( + String data, { + required Uint8List key, + }) async { + final decodedData = base64.decode(data); + final encryptedData = await _cryptoService.encrypt(decodedData, key: key); + return base64.encode(encryptedData); + } + + Future _decrypt( + String data, { + required Uint8List key, + }) async { + final decodedData = base64.decode(data); + final decryptedData = await _cryptoService.decrypt(decodedData, key: key); + return base64.encode(decryptedData); } String _buildVaultKey(String key) { diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/vault.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/vault.dart index 35717e704b2..e67abc1e69e 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/vault.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/vault.dart @@ -6,12 +6,13 @@ import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; /// /// In order to unlock [Vault] sufficient [LockFactor] have to be /// set via [unlock] that can unlock [LockFactor] from [setLock]. -/// -/// See [LockFactor.unlocks] for more details. abstract interface class Vault implements Storage { /// Returns true when have sufficient [LockFactor] from [unlock]. Future get isUnlocked; + /// Returns whether currently have active lock from [setLock]. + Future get hasLock; + /// Deletes unlockFactor if have any. Future lock(); diff --git a/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml index 08f8c5c9b3d..f44a07f10c3 100644 --- a/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: path: ../catalyst_voices_repositories chopper: ^7.2.0 convert: ^3.1.1 + cryptography: ^2.7.0 ed25519_hd_key: ^2.3.0 flutter: sdk: flutter diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/crypto/vault_crypto_service_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/crypto/vault_crypto_service_test.dart new file mode 100644 index 00000000000..1c89588cab7 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/crypto/vault_crypto_service_test.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_services/src/crypto/vault_crypto_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:test/test.dart'; + +void main() { + final cryptoService = VaultCryptoService(); + + group('key derivation', () { + test( + 'derived key matches when verifying with same seed', + () async { + // Given + const lockFactor = PasswordLockFactor('admin'); + + // When + final seed = lockFactor.seed; + final key = await cryptoService.deriveKey(seed); + + // Then + final isVerified = await cryptoService.verifyKey(seed, key: key); + + expect(isVerified, isTrue); + }, + ); + + test( + 'derived key does not matches when verifying with different seed', + () async { + // Given + const lockFactor = PasswordLockFactor('admin'); + const otherLockFactor = PasswordLockFactor('1234'); + + // When + final seed = lockFactor.seed; + final otherSeed = otherLockFactor.seed; + + final key = await cryptoService.deriveKey(seed); + + // Then + final isVerified = await cryptoService.verifyKey(otherSeed, key: key); + + expect(isVerified, isFalse); + }, + ); + + test( + 'verifying key against too short seed returns false', + () async { + // Given + const lockFactor = PasswordLockFactor('admin'); + final invalidSeed = Uint8List.fromList([0, 0, 0]); + + // When + final seed = lockFactor.seed; + final key = await cryptoService.deriveKey(seed); + + // Then + final isVerified = await cryptoService.verifyKey(invalidSeed, key: key); + + expect(isVerified, isFalse); + }, + ); + }); + + group('crypto', () { + test('encrypted and later decrypted data with same key matches', () async { + // Given + const lockFactor = PasswordLockFactor('admin'); + const data = 'Hello Catalyst!'; + + // When + final key = await cryptoService.deriveKey(lockFactor.seed); + + // Then + final encoded = utf8.encode(data); + final encrypted = await cryptoService.encrypt(encoded, key: key); + + expect(encrypted, isNot(equals(encoded))); + + final decrypted = await cryptoService.decrypt(encrypted, key: key); + final decoded = utf8.decode(decrypted); + + expect(decoded, equals(data)); + }); + + test( + 'encrypted and later decrypted data with ' + 'different key gives throws exception', () async { + // Given + const lockFactor = PasswordLockFactor('admin'); + const unlockFactor = PasswordLockFactor('1234'); + + const data = 'Hello Catalyst!'; + + // When + final encryptKey = await cryptoService.deriveKey(lockFactor.seed); + final decryptKey = await cryptoService.deriveKey(unlockFactor.seed); + + // Then + final encoded = utf8.encode(data); + final encrypted = await cryptoService.encrypt(encoded, key: encryptKey); + + expect(encrypted, isNot(equals(encoded))); + + expect( + () => cryptoService.decrypt(encrypted, key: decryptKey), + throwsA(isA()), + ); + }); + }); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_codec_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_codec_test.dart deleted file mode 100644 index d4857ba8eba..00000000000 --- a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_codec_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:catalyst_voices_services/catalyst_voices_services.dart'; -import 'package:catalyst_voices_services/src/storage/vault/lock_factor_codec.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -void main() { - test('encoding and decoding results in same lock factor', () { - // Given - const lock = PasswordLockFactor('pass1234'); - const LockFactorCodec codec = DefaultLockFactorCodec(); - - // When - final encoded = codec.encoder.convert(lock); - final decoded = codec.decoder.convert(encoded); - - // Then - expect(decoded, isA()); - expect(decoded.unlocks(lock), isTrue); - }); -} diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_test.dart index 3faf9efa23b..b1e688c55a2 100644 --- a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_test.dart +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_test.dart @@ -1,120 +1,21 @@ +import 'dart:convert'; + import 'package:catalyst_voices_services/catalyst_voices_services.dart'; import 'package:test/test.dart'; void main() { - group('LockFactor', () { - test('void lock serialization does work', () { - // Given - const lock = VoidLockFactor(); - - // When - final json = lock.toJson(); - final deserializedFactor = LockFactor.fromJson(json); - - // Then - expect(deserializedFactor, isA()); - }); - - test('description', () { - // Given - const lock = PasswordLockFactor('pass1234'); - - // When - final json = lock.toJson(); - final deserializedFactor = LockFactor.fromJson(json); - - // Then - expect(deserializedFactor, isA()); - expect(deserializedFactor.unlocks(lock), isTrue); - }); - }); - - group('VoidLockFactor', () { - test('does not unlocks any other lock', () { - // Given - const lock = VoidLockFactor(); - const locks = [ - VoidLockFactor(), - PasswordLockFactor('pass1234'), - ]; - - // When - final unlocks = locks.map((e) => lock.unlocks(e)).toList(); - - // Then - expect(unlocks, everyElement(false)); - }); - - test('toJson result has type field', () { - // Given - const lock = VoidLockFactor(); - - // When - final json = lock.toJson(); - - // Then - expect(json.containsKey('type'), isTrue); - }); - - test('toString equals class name', () { - // Given - const lock = VoidLockFactor(); - - // When - final asString = lock.toString(); - - // Then - expect(asString, lock.runtimeType.toString()); - }); - }); - - group('PasswordLockFactor', () { - test('unlocks other PasswordLockFactor with matching data', () { + group(PasswordLockFactor, () { + test('seed generates utf8 version of password', () { // Given - const lock = PasswordLockFactor('admin1234'); - const otherLock = PasswordLockFactor('admin1234'); - - // When - final unlocks = lock.unlocks(otherLock); - - // Then - expect(unlocks, isTrue); - }); - - test('does not unlocks other PasswordLockFactor with different data', () { - // Given - const lock = PasswordLockFactor('admin1234'); - const otherLock = PasswordLockFactor('pass1234'); - - // When - final unlocks = lock.unlocks(otherLock); - - // Then - expect(unlocks, isFalse); - }); - - test('does not unlocks other non PasswordLockFactor', () { - // Given - const lock = PasswordLockFactor('admin1234'); - const otherLock = VoidLockFactor(); - - // When - final unlocks = lock.unlocks(otherLock); - - // Then - expect(unlocks, isFalse); - }); - - test('toJson result has type and data field', () { - // Given - const lock = PasswordLockFactor('admin1234'); + const password = 'admin1234'; + const lock = PasswordLockFactor(password); + final expected = utf8.encode(password); // When - final json = lock.toJson(); + final seed = lock.seed; // Then - expect(json.containsKey('type'), isTrue); - expect(json.containsKey('data'), isTrue); + expect(seed, expected); }); test('toString does not contain password', () { diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/secure_storage_vault_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/secure_storage_vault_test.dart index cfba41eb733..04f14875d53 100644 --- a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/secure_storage_vault_test.dart +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/secure_storage_vault_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; import 'package:catalyst_voices_services/src/storage/vault/secure_storage_vault.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -28,31 +31,34 @@ void main() { expect(isUnlocked, isFalse); }); - test('read returns null when not unlocked', () async { + test('read when not unlocked throws exception', () async { // Given const key = 'SecureStorageVault.key'; const value = 'username'; // When await flutterSecureStorage.write(key: key, value: value); - final readValue = await vault.readString(key: key); // Then - expect(readValue, isNull); + expect( + () => vault.readString(key: key), + throwsA(isA()), + ); }); - test('write wont happen when is locked', () async { + test('write throws exception when is locked', () async { // Given const key = 'key'; - const fKey = 'SecureStorageVault.$key'; const value = 'username'; // When - await vault.writeString(value, key: key); - final readValue = await flutterSecureStorage.read(key: fKey); + await vault.lock(); // Then - expect(readValue, isNull); + expect( + () => vault.writeString(value, key: key), + throwsA(isA()), + ); }); test('unlock update lock and returns null when locked', () async { @@ -90,9 +96,9 @@ void main() { test('clear removes all vault keys', () async { // Given const lock = PasswordLockFactor('pass1234'); - const vaultKeyValues = { - 'one': 'qqq', - 'two': 'qqq', + final vaultKeyValues = { + 'one': utf8.fuse(base64).encode('qqq'), + 'two': utf8.fuse(base64).encode('qqq'), }; const nonVaultKeyValues = { 'three': 'qqq',