diff --git a/src/App/App.csproj b/src/App/App.csproj index 3c2bc8c12..4de5cea2a 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -266,6 +266,6 @@ - + diff --git a/src/App/Platforms/Android/Autofill/CredentialHelpers.cs b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs index 6153a8aa4..41c847fc9 100644 --- a/src/App/Platforms/Android/Autofill/CredentialHelpers.cs +++ b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Nodes; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; using Android.App; using Android.Content; using Android.OS; @@ -83,23 +84,27 @@ namespace Bit.App.Platforms.Android.Autofill if (callingRequest is null) { - if (ServiceContainer.TryResolve(out var deviceActionService)) - { - await deviceActionService.DisplayAlertAsync(AppResources.ErrorCreatingPasskey, string.Empty, AppResources.Ok); - } + await DisplayAlertAsync(AppResources.AnErrorHasOccurred, string.Empty); FailAndFinish(); return; } var credentialCreationOptions = GetPublicKeyCredentialCreationOptionsFromJson(callingRequest.RequestJson); - var origin = await ValidateCallingAppInfoAndGetOriginAsync(getRequest.CallingAppInfo, credentialCreationOptions.Rp.Id); + string origin; + try + { + origin = await ValidateCallingAppInfoAndGetOriginAsync(getRequest.CallingAppInfo, credentialCreationOptions.Rp.Id); + } + catch (Core.Exceptions.ValidationException valEx) + { + await DisplayAlertAsync(AppResources.AnErrorHasOccurred, valEx.Message); + FailAndFinish(); + return; + } if (origin is null) { - if (ServiceContainer.TryResolve(out var deviceActionService)) - { - await deviceActionService.DisplayAlertAsync(AppResources.ErrorCreatingPasskey, AppResources.PasskeysNotSupportedForThisApp, AppResources.Ok); - } + await DisplayAlertAsync(AppResources.ErrorCreatingPasskey, AppResources.PasskeysNotSupportedForThisApp); FailAndFinish(); return; } @@ -202,6 +207,14 @@ namespace Bit.App.Platforms.Android.Autofill activity.SetResult(Result.Ok, result); activity.Finish(); + async Task DisplayAlertAsync(string title, string message) + { + if (ServiceContainer.TryResolve(out var deviceActionService)) + { + await deviceActionService.DisplayAlertAsync(title, message, AppResources.Ok); + } + } + void FailAndFinish() { var result = new Intent(); @@ -244,11 +257,11 @@ namespace Bit.App.Platforms.Android.Autofill return extensionsJson; } - public static async Task LoadFido2PriviligedAllowedListAsync() + public static async Task LoadFido2PrivilegedAllowedListAsync() { try { - using var stream = await FileSystem.OpenAppPackageFileAsync("fido2_priviliged_allow_list.json"); + using var stream = await FileSystem.OpenAppPackageFileAsync("fido2_privileged_allow_list.json"); using var reader = new StreamReader(stream); return reader.ReadToEnd(); @@ -266,19 +279,24 @@ namespace Bit.App.Platforms.Android.Autofill return await ValidateAssetLinksAndGetOriginAsync(callingAppInfo, rpId); } - var priviligedAllowedList = await LoadFido2PriviligedAllowedListAsync(); - if (priviligedAllowedList is null) + var privilegedAllowedList = await LoadFido2PrivilegedAllowedListAsync(); + if (privilegedAllowedList is null) { - throw new InvalidOperationException("Could not load Fido2 priviliged allowed list"); + throw new InvalidOperationException("Could not load Fido2 privileged allowed list"); + } + + if (!privilegedAllowedList.Contains($"\"package_name\": \"{callingAppInfo.PackageName}\"")) + { + throw new Core.Exceptions.ValidationException(AppResources.PasskeyOperationFailedBecauseBrowserIsNotPrivileged); } try { - return callingAppInfo.GetOrigin(priviligedAllowedList); + return callingAppInfo.GetOrigin(privilegedAllowedList); } catch (Java.Lang.IllegalStateException) { - return null; // not priviliged + throw new Core.Exceptions.ValidationException(AppResources.PasskeyOperationFailedBecauseBrowserSignatureDoesNotMatch); } catch (Java.Lang.IllegalArgumentException) { diff --git a/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs b/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs index 7d406939a..5ea4213c0 100644 --- a/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs +++ b/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs @@ -77,7 +77,18 @@ namespace Bit.Droid.Autofill var packageName = getRequest.CallingAppInfo.PackageName; - var origin = await CredentialHelpers.ValidateCallingAppInfoAndGetOriginAsync(getRequest.CallingAppInfo, RpId); + string origin; + try + { + origin = await CredentialHelpers.ValidateCallingAppInfoAndGetOriginAsync(getRequest.CallingAppInfo, RpId); + } + catch (Core.Exceptions.ValidationException valEx) + { + await _deviceActionService.Value.DisplayAlertAsync(AppResources.AnErrorHasOccurred, valEx.Message, AppResources.Ok); + FailAndFinish(); + return; + } + if (origin is null) { await _deviceActionService.Value.DisplayAlertAsync(AppResources.ErrorReadingPasskey, AppResources.PasskeysNotSupportedForThisApp, AppResources.Ok); diff --git a/src/App/Resources/Raw/fido2_priviliged_allow_list.json b/src/App/Resources/Raw/fido2_privileged_allow_list.json similarity index 100% rename from src/App/Resources/Raw/fido2_priviliged_allow_list.json rename to src/App/Resources/Raw/fido2_privileged_allow_list.json diff --git a/src/Core/Exceptions/ValidationException.cs b/src/Core/Exceptions/ValidationException.cs new file mode 100644 index 000000000..e996148e6 --- /dev/null +++ b/src/Core/Exceptions/ValidationException.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Exceptions +{ + public class ValidationException : Exception + { + public ValidationException(string localizedMessage) + : base(localizedMessage) + { + } + } +} diff --git a/src/Core/Resources/Localization/AppResources.Designer.cs b/src/Core/Resources/Localization/AppResources.Designer.cs index cbe3adf4e..d3d1003a5 100644 --- a/src/Core/Resources/Localization/AppResources.Designer.cs +++ b/src/Core/Resources/Localization/AppResources.Designer.cs @@ -5263,6 +5263,51 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Passkey operation failed because app could not be verified. + /// + public static string PasskeyOperationFailedBecauseAppCouldNotBeVerified { + get { + return ResourceManager.GetString("PasskeyOperationFailedBecauseAppCouldNotBeVerified", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Passkey operation failed because app not found in asset links. + /// + public static string PasskeyOperationFailedBecauseAppNotFoundInAssetLinks { + get { + return ResourceManager.GetString("PasskeyOperationFailedBecauseAppNotFoundInAssetLinks", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Passkey operation failed because browser is not privileged. + /// + public static string PasskeyOperationFailedBecauseBrowserIsNotPrivileged { + get { + return ResourceManager.GetString("PasskeyOperationFailedBecauseBrowserIsNotPrivileged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Passkey operation failed because browser signature does not match. + /// + public static string PasskeyOperationFailedBecauseBrowserSignatureDoesNotMatch { + get { + return ResourceManager.GetString("PasskeyOperationFailedBecauseBrowserSignatureDoesNotMatch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Passkey operation failed because of missing asset links. + /// + public static string PasskeyOperationFailedBecauseOfMissingAssetLinks { + get { + return ResourceManager.GetString("PasskeyOperationFailedBecauseOfMissingAssetLinks", resourceCulture); + } + } + /// /// Looks up a localized string similar to Passkeys. /// diff --git a/src/Core/Resources/Localization/AppResources.resx b/src/Core/Resources/Localization/AppResources.resx index d0940a16d..b3d0bfb71 100644 --- a/src/Core/Resources/Localization/AppResources.resx +++ b/src/Core/Resources/Localization/AppResources.resx @@ -2990,4 +2990,19 @@ Do you want to switch to this account? Passkeys not supported for this app + + Passkey operation failed because browser is not privileged + + + Passkey operation failed because browser signature does not match + + + Passkey operation failed because of missing asset links + + + Passkey operation failed because app not found in asset links + + + Passkey operation failed because app could not be verified + diff --git a/src/Core/Services/AssetLinksService.cs b/src/Core/Services/AssetLinksService.cs index 87c61bb7a..1393dc204 100644 --- a/src/Core/Services/AssetLinksService.cs +++ b/src/Core/Services/AssetLinksService.cs @@ -1,4 +1,5 @@ using Bit.Core.Abstractions; +using Bit.Core.Resources.Localization; namespace Bit.Core.Services { @@ -18,18 +19,35 @@ namespace Bit.Core.Services /// True if matches, False otherwise. public async Task ValidateAssetLinksAsync(string rpId, string packageName, string normalizedFingerprint) { - var statementList = await _apiService.GetDigitalAssetLinksForRpAsync(rpId); + try + { + var statementList = await _apiService.GetDigitalAssetLinksForRpAsync(rpId); - return statementList - .Any(s => s.Target.Namespace == "android_app" - && - s.Target.PackageName == packageName - && - s.Relation.Contains("delegate_permission/common.get_login_creds") - && - s.Relation.Contains("delegate_permission/common.handle_all_urls") - && - s.Target.Sha256CertFingerprints.Contains(normalizedFingerprint)); + var androidAppPackageStatements = statementList + .Where(s => s.Target.Namespace == "android_app" + && + s.Target.PackageName == packageName + && + s.Relation.Contains("delegate_permission/common.get_login_creds") + && + s.Relation.Contains("delegate_permission/common.handle_all_urls")); + + if (!androidAppPackageStatements.Any()) + { + throw new Exceptions.ValidationException(AppResources.PasskeyOperationFailedBecauseAppNotFoundInAssetLinks); + } + + if (!androidAppPackageStatements.Any(s => s.Target.Sha256CertFingerprints.Contains(normalizedFingerprint))) + { + throw new Exceptions.ValidationException(AppResources.PasskeyOperationFailedBecauseAppCouldNotBeVerified); + } + + return true; + } + catch (Exceptions.ApiException) + { + throw new Exceptions.ValidationException(AppResources.PasskeyOperationFailedBecauseOfMissingAssetLinks); + } } } } diff --git a/test/Core.Test/Services/AssetLinksServiceTest.cs b/test/Core.Test/Services/AssetLinksServiceTest.cs index f2bd79d58..fee01cbb3 100644 --- a/test/Core.Test/Services/AssetLinksServiceTest.cs +++ b/test/Core.Test/Services/AssetLinksServiceTest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Bit.Core.Abstractions; +using Bit.Core.Resources.Localization; using Bit.Core.Services; using Bit.Core.Utilities.DigitalAssetLinks; using Bit.Test.Common.AutoFixture; @@ -71,7 +72,7 @@ namespace Bit.Core.Test.Services } [Fact] - public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_No_GetLoginCreds_Relation() + public async Task ValidateAssetLinksAsync_Throws_When_Data_Statement_Has_No_GetLoginCreds_Relation() { // Arrange _sutProvider.GetDependency() @@ -79,14 +80,14 @@ namespace Bit.Core.Test.Services .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementNoGetLoginCredsRelationJson()))); // Act - var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint)); // Assert - Assert.False(isValid); + Assert.Equal(AppResources.PasskeyOperationFailedBecauseAppNotFoundInAssetLinks, exception.Message); } [Fact] - public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_No_HandleAllUrls_Relation() + public async Task ValidateAssetLinksAsync_Throws_When_Data_Statement_Has_No_HandleAllUrls_Relation() { // Arrange _sutProvider.GetDependency() @@ -94,14 +95,14 @@ namespace Bit.Core.Test.Services .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementNoHandleAllUrlsRelationJson()))); // Act - var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint)); // Assert - Assert.False(isValid); + Assert.Equal(AppResources.PasskeyOperationFailedBecauseAppNotFoundInAssetLinks, exception.Message); } [Fact] - public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_Wrong_Namespace() + public async Task ValidateAssetLinksAsync_Throws_When_Data_Statement_Has_Wrong_Namespace() { // Arrange _sutProvider.GetDependency() @@ -109,14 +110,14 @@ namespace Bit.Core.Test.Services .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementWrongNamespaceJson()))); // Act - var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint)); // Assert - Assert.False(isValid); + Assert.Equal(AppResources.PasskeyOperationFailedBecauseAppNotFoundInAssetLinks, exception.Message); } [Fact] - public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_No_Fingerprints() + public async Task ValidateAssetLinksAsync_Throws_When_Data_Statement_Has_No_Fingerprints() { // Arrange _sutProvider.GetDependency() @@ -124,14 +125,30 @@ namespace Bit.Core.Test.Services .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementNoFingerprintsJson()))); // Act - var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint)); // Assert - Assert.False(isValid); + Assert.Equal(AppResources.PasskeyOperationFailedBecauseAppCouldNotBeVerified, exception.Message); } [Fact] - public async Task ValidateAssetLinksAsync_Returns_False_When_Data_PackageName_Doesnt_Match() + public async Task ValidateAssetLinksAsync_Throws_When_Data_PackageName_Doesnt_Match() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementOneFingerprintJson()))); + + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, "com.foo.another", _validFingerprint)); + + // Assert + Assert.Equal(AppResources.PasskeyOperationFailedBecauseAppNotFoundInAssetLinks, exception.Message); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Throws_When_Data_Fingerprint_Doesnt_Match() { // Arrange _sutProvider.GetDependency() @@ -139,25 +156,10 @@ namespace Bit.Core.Test.Services .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementOneFingerprintJson()))); // Act - var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, "com.foo.another", _validFingerprint); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint.Replace("00", "33"))); // Assert - Assert.False(isValid); - } - - [Fact] - public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Fingerprint_Doesnt_Match() - { - // Arrange - _sutProvider.GetDependency() - .GetDigitalAssetLinksForRpAsync(_validRpId) - .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementOneFingerprintJson()))); - - // Act - var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint.Replace("00", "33")); - - // Assert - Assert.False(isValid); + Assert.Equal(AppResources.PasskeyOperationFailedBecauseAppCouldNotBeVerified, exception.Message); } public void Dispose() {}