diff --git a/src/Core/Abstractions/IConditionedAwaiterManager.cs b/src/Core/Abstractions/IConditionedAwaiterManager.cs index ea7dd951b..6eb4df5dc 100644 --- a/src/Core/Abstractions/IConditionedAwaiterManager.cs +++ b/src/Core/Abstractions/IConditionedAwaiterManager.cs @@ -5,7 +5,8 @@ namespace Bit.Core.Abstractions { public enum AwaiterPrecondition { - EnvironmentUrlsInited + EnvironmentUrlsInited, + AndroidWindowCreated } public interface IConditionedAwaiterManager diff --git a/src/Core/Pages/Accounts/HomePage.xaml.cs b/src/Core/Pages/Accounts/HomePage.xaml.cs index 9a418ac4d..a70526468 100644 --- a/src/Core/Pages/Accounts/HomePage.xaml.cs +++ b/src/Core/Pages/Accounts/HomePage.xaml.cs @@ -12,12 +12,14 @@ namespace Bit.App.Pages private readonly HomeViewModel _vm; private readonly AppOptions _appOptions; private IBroadcasterService _broadcasterService; + private IConditionedAwaiterManager _conditionedAwaiterManager; readonly LazyResolve _logger = new LazyResolve(); public HomePage(AppOptions appOptions = null) { _broadcasterService = ServiceContainer.Resolve(); + _conditionedAwaiterManager = ServiceContainer.Resolve(); _appOptions = appOptions; InitializeComponent(); _vm = BindingContext as HomeViewModel; @@ -56,6 +58,8 @@ namespace Bit.App.Pages PerformNavigationOnAccountChangedOnLoad = false; accountsManager.NavigateOnAccountChangeAsync().FireAndForget(); } + + _conditionedAwaiterManager.SetAsCompleted(AwaiterPrecondition.AndroidWindowCreated); #endif } diff --git a/src/Core/Pages/AndroidNavigationRedirectPage.xaml.cs b/src/Core/Pages/AndroidNavigationRedirectPage.xaml.cs index a96fa09ee..21fe1bd31 100644 --- a/src/Core/Pages/AndroidNavigationRedirectPage.xaml.cs +++ b/src/Core/Pages/AndroidNavigationRedirectPage.xaml.cs @@ -1,4 +1,5 @@ using Bit.App.Abstractions; +using Bit.Core.Abstractions; using Bit.Core.Utilities; namespace Bit.Core.Pages; @@ -6,15 +7,18 @@ namespace Bit.Core.Pages; public partial class AndroidNavigationRedirectPage : ContentPage { private readonly IAccountsManager _accountsManager; + private readonly IConditionedAwaiterManager _conditionedAwaiterManager; public AndroidNavigationRedirectPage() { _accountsManager = ServiceContainer.Resolve("accountsManager"); + _conditionedAwaiterManager = ServiceContainer.Resolve(); InitializeComponent(); } private void AndroidNavigationRedirectPage_OnLoaded(object sender, EventArgs e) { _accountsManager.NavigateOnAccountChangeAsync().FireAndForget(); + _conditionedAwaiterManager.SetAsCompleted(AwaiterPrecondition.AndroidWindowCreated); } } diff --git a/src/Core/Services/ConditionedAwaiterManager.cs b/src/Core/Services/ConditionedAwaiterManager.cs index f318996f5..30b744923 100644 --- a/src/Core/Services/ConditionedAwaiterManager.cs +++ b/src/Core/Services/ConditionedAwaiterManager.cs @@ -10,7 +10,8 @@ namespace Bit.Core.Services { private readonly ConcurrentDictionary> _preconditionsTasks = new ConcurrentDictionary> { - [AwaiterPrecondition.EnvironmentUrlsInited] = new TaskCompletionSource() + [AwaiterPrecondition.EnvironmentUrlsInited] = new TaskCompletionSource(), + [AwaiterPrecondition.AndroidWindowCreated] = new TaskCompletionSource() }; public Task GetAwaiterForPrecondition(AwaiterPrecondition awaiterPrecondition) diff --git a/src/Core/Services/LegacySecureStorage/AndroidKeyStore.cs b/src/Core/Services/LegacySecureStorage/AndroidKeyStore.cs new file mode 100644 index 000000000..164d5db2f --- /dev/null +++ b/src/Core/Services/LegacySecureStorage/AndroidKeyStore.cs @@ -0,0 +1,295 @@ +#if ANDROID + +using Android.Content; +using Android.OS; +using Android.Runtime; +using Android.Security; +using Android.Security.Keystore; +using Java.Security; +using Javax.Crypto; +using Javax.Crypto.Spec; +using System.Text; + +namespace Bit.Core.Services; + +class AndroidKeyStore +{ + const string androidKeyStore = "AndroidKeyStore"; // this is an Android const value + const string aesAlgorithm = "AES"; + const string cipherTransformationAsymmetric = "RSA/ECB/PKCS1Padding"; + const string cipherTransformationSymmetric = "AES/GCM/NoPadding"; + const string prefsMasterKey = "SecureStorageKey"; + const int initializationVectorLen = 12; // Android supports an IV of 12 for AES/GCM + + internal AndroidKeyStore(Context context, string keystoreAlias, bool alwaysUseAsymmetricKeyStorage) + { + alwaysUseAsymmetricKey = alwaysUseAsymmetricKeyStorage; + appContext = context; + alias = keystoreAlias; + + keyStore = KeyStore.GetInstance(androidKeyStore); + keyStore.Load(null); + } + + readonly Context appContext; + readonly string alias; + readonly bool alwaysUseAsymmetricKey; + readonly string useSymmetricPreferenceKey = "essentials_use_symmetric"; + + KeyStore keyStore; + bool useSymmetric = false; + + ISecretKey GetKey() + { + // check to see if we need to get our key from past-versions or newer versions. + // we want to use symmetric if we are >= 23 or we didn't set it previously. + var hasApiLevel = Build.VERSION.SdkInt >= BuildVersionCodes.M; + + useSymmetric = Preferences.Get(useSymmetricPreferenceKey, hasApiLevel, alias); + + // If >= API 23 we can use the KeyStore's symmetric key + if (useSymmetric && !alwaysUseAsymmetricKey) + return GetSymmetricKey(); + + // NOTE: KeyStore in < API 23 can only store asymmetric keys + // specifically, only RSA/ECB/PKCS1Padding + // So we will wrap our symmetric AES key we just generated + // with this and save the encrypted/wrapped key out to + // preferences for future use. + // ECB should be fine in this case as the AES key should be + // contained in one block. + + // Get the asymmetric key pair + var keyPair = GetAsymmetricKeyPair(); + + var existingKeyStr = Preferences.Get(prefsMasterKey, null, alias); + + if (!string.IsNullOrEmpty(existingKeyStr)) + { + try + { + var wrappedKey = Convert.FromBase64String(existingKeyStr); + + var unwrappedKey = UnwrapKey(wrappedKey, keyPair.Private); + var kp = unwrappedKey.JavaCast(); + + return kp; + } + catch (InvalidKeyException ikEx) + { + System.Diagnostics.Debug.WriteLine( + $"Unable to unwrap key: Invalid Key. This may be caused by system backup or upgrades. All secure storage items will now be removed. {ikEx.Message}"); + } + catch (IllegalBlockSizeException ibsEx) + { + System.Diagnostics.Debug.WriteLine( + $"Unable to unwrap key: Illegal Block Size. This may be caused by system backup or upgrades. All secure storage items will now be removed. {ibsEx.Message}"); + } + catch (BadPaddingException paddingEx) + { + System.Diagnostics.Debug.WriteLine( + $"Unable to unwrap key: Bad Padding. This may be caused by system backup or upgrades. All secure storage items will now be removed. {paddingEx.Message}"); + } + + LegacySecureStorage.RemoveAll(); + } + + var keyGenerator = KeyGenerator.GetInstance(aesAlgorithm); + var defSymmetricKey = keyGenerator.GenerateKey(); + + var newWrappedKey = WrapKey(defSymmetricKey, keyPair.Public); + + Preferences.Set(prefsMasterKey, Convert.ToBase64String(newWrappedKey), alias); + + return defSymmetricKey; + } + + // API 23+ Only +#pragma warning disable CA1416 + ISecretKey GetSymmetricKey() + { + Preferences.Set(useSymmetricPreferenceKey, true, alias); + + var existingKey = keyStore.GetKey(alias, null); + + if (existingKey != null) + { + var existingSecretKey = existingKey.JavaCast(); + return existingSecretKey; + } + + var keyGenerator = KeyGenerator.GetInstance(KeyProperties.KeyAlgorithmAes, androidKeyStore); + var builder = new KeyGenParameterSpec.Builder(alias, KeyStorePurpose.Encrypt | KeyStorePurpose.Decrypt) + .SetBlockModes(KeyProperties.BlockModeGcm) + .SetEncryptionPaddings(KeyProperties.EncryptionPaddingNone) + .SetRandomizedEncryptionRequired(false); + + keyGenerator.Init(builder.Build()); + + return keyGenerator.GenerateKey(); + } +#pragma warning restore CA1416 + + KeyPair GetAsymmetricKeyPair() + { + // set that we generated keys on pre-m device. + Preferences.Set(useSymmetricPreferenceKey, false, alias); + + var asymmetricAlias = $"{alias}.asymmetric"; + + var privateKey = keyStore.GetKey(asymmetricAlias, null)?.JavaCast(); + var publicKey = keyStore.GetCertificate(asymmetricAlias)?.PublicKey; + + // Return the existing key if found + if (privateKey != null && publicKey != null) + return new KeyPair(publicKey, privateKey); + + var originalLocale = Java.Util.Locale.Default; + try + { + // Force to english for known bug in date parsing: + // https://issuetracker.google.com/issues/37095309 + SetLocale(Java.Util.Locale.English); + + // Otherwise we create a new key +#pragma warning disable CA1416 + var generator = KeyPairGenerator.GetInstance(KeyProperties.KeyAlgorithmRsa, androidKeyStore); +#pragma warning restore CA1416 + + var end = DateTime.UtcNow.AddYears(20); + var startDate = new Java.Util.Date(); +#pragma warning disable CS0618 // Type or member is obsolete + var endDate = new Java.Util.Date(end.Year, end.Month, end.Day); +#pragma warning restore CS0618 // Type or member is obsolete + +#pragma warning disable CS0618 + var builder = new KeyPairGeneratorSpec.Builder(Platform.AppContext) + .SetAlias(asymmetricAlias) + .SetSerialNumber(Java.Math.BigInteger.One) + .SetSubject(new Javax.Security.Auth.X500.X500Principal($"CN={asymmetricAlias} CA Certificate")) + .SetStartDate(startDate) + .SetEndDate(endDate); + + generator.Initialize(builder.Build()); +#pragma warning restore CS0618 + + return generator.GenerateKeyPair(); + } + finally + { + SetLocale(originalLocale); + } + } + + byte[] WrapKey(IKey keyToWrap, IKey withKey) + { + var cipher = Cipher.GetInstance(cipherTransformationAsymmetric); + cipher.Init(CipherMode.WrapMode, withKey); + return cipher.Wrap(keyToWrap); + } + +#pragma warning disable CA1416 + IKey UnwrapKey(byte[] wrappedData, IKey withKey) + { + var cipher = Cipher.GetInstance(cipherTransformationAsymmetric); + cipher.Init(CipherMode.UnwrapMode, withKey); + var unwrapped = cipher.Unwrap(wrappedData, KeyProperties.KeyAlgorithmAes, KeyType.SecretKey); + return unwrapped; + } +#pragma warning restore CA1416 + + internal byte[] Encrypt(string data) + { + var key = GetKey(); + + // Generate initialization vector + var iv = new byte[initializationVectorLen]; + + var sr = new SecureRandom(); + sr.NextBytes(iv); + + Cipher cipher; + + // Attempt to use GCMParameterSpec by default + try + { + cipher = Cipher.GetInstance(cipherTransformationSymmetric); + cipher.Init(CipherMode.EncryptMode, key, new GCMParameterSpec(128, iv)); + } + catch (InvalidAlgorithmParameterException) + { + // If we encounter this error, it's likely an old bouncycastle provider version + // is being used which does not recognize GCMParameterSpec, but should work + // with IvParameterSpec, however we only do this as a last effort since other + // implementations will error if you use IvParameterSpec when GCMParameterSpec + // is recognized and expected. + cipher = Cipher.GetInstance(cipherTransformationSymmetric); + cipher.Init(CipherMode.EncryptMode, key, new IvParameterSpec(iv)); + } + + var decryptedData = Encoding.UTF8.GetBytes(data); + var encryptedBytes = cipher.DoFinal(decryptedData); + + // Combine the IV and the encrypted data into one array + var r = new byte[iv.Length + encryptedBytes.Length]; + Buffer.BlockCopy(iv, 0, r, 0, iv.Length); + Buffer.BlockCopy(encryptedBytes, 0, r, iv.Length, encryptedBytes.Length); + + return r; + } + + internal string Decrypt(byte[] data) + { + if (data.Length < initializationVectorLen) + return null; + + var key = GetKey(); + + // IV will be the first 16 bytes of the encrypted data + var iv = new byte[initializationVectorLen]; + Buffer.BlockCopy(data, 0, iv, 0, initializationVectorLen); + + Cipher cipher; + + // Attempt to use GCMParameterSpec by default + try + { + cipher = Cipher.GetInstance(cipherTransformationSymmetric); + cipher.Init(CipherMode.DecryptMode, key, new GCMParameterSpec(128, iv)); + } + catch (InvalidAlgorithmParameterException) + { + // If we encounter this error, it's likely an old bouncycastle provider version + // is being used which does not recognize GCMParameterSpec, but should work + // with IvParameterSpec, however we only do this as a last effort since other + // implementations will error if you use IvParameterSpec when GCMParameterSpec + // is recognized and expected. + cipher = Cipher.GetInstance(cipherTransformationSymmetric); + cipher.Init(CipherMode.DecryptMode, key, new IvParameterSpec(iv)); + } + + // Decrypt starting after the first 16 bytes from the IV + var decryptedData = cipher.DoFinal(data, initializationVectorLen, data.Length - initializationVectorLen); + + return Encoding.UTF8.GetString(decryptedData); + } + + internal void SetLocale(Java.Util.Locale locale) + { + Java.Util.Locale.Default = locale; + var resources = appContext.Resources; + var config = resources.Configuration; + + if (Build.VERSION.SdkInt >= BuildVersionCodes.N) + config.SetLocale(locale); + else +#pragma warning disable CS0618 // Type or member is obsolete + config.Locale = locale; +#pragma warning restore CS0618 // Type or member is obsolete + +#pragma warning disable CS0618 // Type or member is obsolete + resources.UpdateConfiguration(config, resources.DisplayMetrics); +#pragma warning restore CS0618 // Type or member is obsolete + } +} +#endif diff --git a/src/Core/Services/LegacySecureStorage/KeyChain.cs b/src/Core/Services/LegacySecureStorage/KeyChain.cs new file mode 100644 index 000000000..4d367f4f6 --- /dev/null +++ b/src/Core/Services/LegacySecureStorage/KeyChain.cs @@ -0,0 +1,128 @@ +#if IOS + +using System.Diagnostics; +using Foundation; +using Security; + +namespace Bit.Core.Services; + +internal class KeyChain +{ + SecAccessible accessible; + + internal KeyChain(SecAccessible accessible) => + this.accessible = accessible; + + SecRecord ExistingRecordForKey(string key, string service) + { + return new SecRecord(SecKind.GenericPassword) + { + Account = key, + Service = service + }; + } + + internal string ValueForKey(string key, string service) + { + using (var record = ExistingRecordForKey(key, service)) + using (var match = SecKeyChain.QueryAsRecord(record, out var resultCode)) + { + if (resultCode == SecStatusCode.Success) + return NSString.FromData(match.ValueData, NSStringEncoding.UTF8); + else + return null; + } + } + + internal void SetValueForKey(string value, string key, string service) + { + using (var record = ExistingRecordForKey(key, service)) + { + if (string.IsNullOrEmpty(value)) + { + if (!string.IsNullOrEmpty(ValueForKey(key, service))) + RemoveRecord(record); + + return; + } + + // if the key already exists, remove it + if (!string.IsNullOrEmpty(ValueForKey(key, service))) + RemoveRecord(record); + } + + using (var newRecord = CreateRecordForNewKeyValue(key, value, service)) + { + var result = SecKeyChain.Add(newRecord); + + switch (result) + { + case SecStatusCode.DuplicateItem: + { + Debug.WriteLine("Duplicate item found. Attempting to remove and add again."); + + // try to remove and add again + if (Remove(key, service)) + { + result = SecKeyChain.Add(newRecord); + if (result != SecStatusCode.Success) + throw new Exception($"Error adding record: {result}"); + } + else + { + Debug.WriteLine("Unable to remove key."); + } + } + break; + case SecStatusCode.Success: + return; + default: + throw new Exception($"Error adding record: {result}"); + } + } + } + + internal bool Remove(string key, string service) + { + using (var record = ExistingRecordForKey(key, service)) + using (var match = SecKeyChain.QueryAsRecord(record, out var resultCode)) + { + if (resultCode == SecStatusCode.Success) + { + RemoveRecord(record); + return true; + } + } + return false; + } + + internal void RemoveAll(string service) + { + using (var query = new SecRecord(SecKind.GenericPassword) { Service = service }) + { + SecKeyChain.Remove(query); + } + } + + SecRecord CreateRecordForNewKeyValue(string key, string value, string service) + { + return new SecRecord(SecKind.GenericPassword) + { + Account = key, + Service = service, + Label = key, + Accessible = accessible, + ValueData = NSData.FromString(value, NSStringEncoding.UTF8), + }; + } + + bool RemoveRecord(SecRecord record) + { + var result = SecKeyChain.Remove(record); + if (result != SecStatusCode.Success && result != SecStatusCode.ItemNotFound) + throw new Exception($"Error removing record: {result}"); + + return true; + } +} +#endif diff --git a/src/Core/Services/LegacySecureStorage/LegacySecureStorage.cs b/src/Core/Services/LegacySecureStorage/LegacySecureStorage.cs new file mode 100644 index 000000000..5820ed5b0 --- /dev/null +++ b/src/Core/Services/LegacySecureStorage/LegacySecureStorage.cs @@ -0,0 +1,108 @@ +#nullable enable + +#if IOS +using Security; +#endif + +#if ANDROID +using Javax.Crypto; +#endif + +namespace Bit.Core.Services; + +public class LegacySecureStorage +{ + internal static readonly string Alias = $"{AppInfo.PackageName}.xamarinessentials"; + +#if IOS + private static SecAccessible DefaultAccessible { get; set; } = SecAccessible.AfterFirstUnlock; +#endif + + + public static Task GetAsync(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentNullException(nameof(key)); + +#if ANDROID + return Task.Run(() => + { + object locker = new object(); + string? encVal = Preferences.Get(key, null, Alias); + + if (!string.IsNullOrEmpty(encVal)) + { + try + { + byte[] encData = Convert.FromBase64String(encVal); + lock (locker) + { + AndroidKeyStore keyStore = new AndroidKeyStore(Platform.AppContext, Alias, false); + return keyStore.Decrypt(encData); + } + } + catch (AEADBadTagException) + { + System.Diagnostics.Debug.WriteLine($"Unable to decrypt key, {key}, which is likely due to an app uninstall. Removing old key and returning null."); + Remove(key); + } + } + + return null; + }); +#elif IOS + var keyChain = new KeyChain(DefaultAccessible); + return Task.FromResult(keyChain.ValueForKey(key, Alias)); +#else + return Task.FromResult((string?)null); +#endif + } + + public static Task SetAsync(string key, string value) + { +#if ANDROID + return Task.Run(() => + { + var context = Platform.AppContext; + + byte[] encryptedData = null; + object locker = new object(); + lock (locker) + { + AndroidKeyStore keyStore = new AndroidKeyStore(Platform.AppContext, Alias, false); + encryptedData = keyStore.Encrypt(value); + } + + var encStr = Convert.ToBase64String(encryptedData); + Preferences.Set(key, encStr, Alias); + }); +#elif IOS + KeyChain keyChain = new KeyChain(DefaultAccessible); + keyChain.SetValueForKey(value, key, Alias); +#endif + return Task.CompletedTask; + } + + public static bool Remove(string key) + { +#if ANDROID + Preferences.Remove(key, Alias); + return true; +#elif IOS + var keyChain = new KeyChain(DefaultAccessible); + return keyChain.Remove(key, Alias); +#else + return false; +#endif + } + + public static void RemoveAll() + { +#if ANDROID + Preferences.Clear(Alias); +#elif IOS + var keyChain = new KeyChain(DefaultAccessible); + keyChain.RemoveAll(Alias); +#endif + } +} diff --git a/src/Core/Services/SecureStorageService.cs b/src/Core/Services/SecureStorageService.cs index b519c21e7..76c561699 100644 --- a/src/Core/Services/SecureStorageService.cs +++ b/src/Core/Services/SecureStorageService.cs @@ -1,5 +1,5 @@ -using System.Threading.Tasks; -using Bit.Core.Abstractions; +using Bit.Core.Abstractions; +using Bit.Core.Services; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -16,7 +16,7 @@ namespace Bit.App.Services public async Task GetAsync(string key) { var formattedKey = string.Format(_keyFormat, key); - var val = await Microsoft.Maui.Storage.SecureStorage.GetAsync(formattedKey); + var val = await LegacySecureStorage.GetAsync(formattedKey); if (typeof(T) == typeof(string)) { return (T)(object)val; @@ -37,11 +37,11 @@ namespace Bit.App.Services var formattedKey = string.Format(_keyFormat, key); if (typeof(T) == typeof(string)) { - await Microsoft.Maui.Storage.SecureStorage.SetAsync(formattedKey, obj as string); + await LegacySecureStorage.SetAsync(formattedKey, obj as string); } else { - await Microsoft.Maui.Storage.SecureStorage.SetAsync(formattedKey, + await LegacySecureStorage.SetAsync(formattedKey, JsonConvert.SerializeObject(obj, _jsonSettings)); } } @@ -49,7 +49,7 @@ namespace Bit.App.Services public Task RemoveAsync(string key) { var formattedKey = string.Format(_keyFormat, key); - Microsoft.Maui.Storage.SecureStorage.Remove(formattedKey); + LegacySecureStorage.Remove(formattedKey); return Task.FromResult(0); } } diff --git a/src/Core/Utilities/AccountManagement/AccountsManager.cs b/src/Core/Utilities/AccountManagement/AccountsManager.cs index 34a4df975..724471d56 100644 --- a/src/Core/Utilities/AccountManagement/AccountsManager.cs +++ b/src/Core/Utilities/AccountManagement/AccountsManager.cs @@ -60,7 +60,9 @@ namespace Bit.App.Utilities.AccountManagement public async Task StartDefaultNavigationFlowAsync(Action appOptionsAction) { await _conditionedAwaiterManager.GetAwaiterForPrecondition(AwaiterPrecondition.EnvironmentUrlsInited); - +#if ANDROID + await _conditionedAwaiterManager.GetAwaiterForPrecondition(AwaiterPrecondition.AndroidWindowCreated); +#endif appOptionsAction(Options); await NavigateOnAccountChangeAsync(); @@ -69,6 +71,9 @@ namespace Bit.App.Utilities.AccountManagement public async Task NavigateOnAccountChangeAsync(bool? isAuthed = null) { await _conditionedAwaiterManager.GetAwaiterForPrecondition(AwaiterPrecondition.EnvironmentUrlsInited); +#if ANDROID + await _conditionedAwaiterManager.GetAwaiterForPrecondition(AwaiterPrecondition.AndroidWindowCreated); +#endif // TODO: this could be improved by doing chain of responsability pattern // but for now it may be an overkill, if logic gets more complex consider refactoring it