diff --git a/src/App/Platforms/Android/Autofill/CredentialHelpers.cs b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs index e05aff3a1..6153a8aa4 100644 --- a/src/App/Platforms/Android/Autofill/CredentialHelpers.cs +++ b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs @@ -10,6 +10,7 @@ using Bit.App.Abstractions; using Bit.App.Droid.Utilities; using Bit.Core.Abstractions; using Bit.Core.Resources.Localization; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Core.Utilities.Fido2; using Bit.Core.Utilities.Fido2.Extensions; @@ -252,7 +253,7 @@ namespace Bit.App.Platforms.Android.Autofill return reader.ReadToEnd(); } - catch (Exception ex) + catch { return null; } @@ -262,7 +263,7 @@ namespace Bit.App.Platforms.Android.Autofill { if (callingAppInfo.Origin is null) { - return await ValidateNativeAppAndGetOriginAsync(callingAppInfo, rpId); + return await ValidateAssetLinksAndGetOriginAsync(callingAppInfo, rpId); } var priviligedAllowedList = await LoadFido2PriviligedAllowedListAsync(); @@ -285,10 +286,18 @@ namespace Bit.App.Platforms.Android.Autofill } } - private static async Task ValidateNativeAppAndGetOriginAsync(CallingAppInfo callingAppInfo, string rpId) + private static async Task ValidateAssetLinksAndGetOriginAsync(CallingAppInfo callingAppInfo, string rpId) { - // TODO: do asset links verification - return callingAppInfo.GetAndroidOrigin(); + if (!ServiceContainer.TryResolve(out var assetLinksService)) + { + throw new InvalidOperationException("Can't resolve IAssetLinksService"); + } + + var normalizedFingerprint = callingAppInfo.GetLatestCertificationFingerprint(); + + var isValid = await assetLinksService.ValidateAssetLinksAsync(rpId, callingAppInfo.PackageName, normalizedFingerprint); + + return isValid ? callingAppInfo.GetAndroidOrigin() : null; } } } diff --git a/src/App/Platforms/Android/Utilities/CallingAppInfoExtensions.cs b/src/App/Platforms/Android/Utilities/CallingAppInfoExtensions.cs index 27de6cb38..1d5cd8654 100644 --- a/src/App/Platforms/Android/Utilities/CallingAppInfoExtensions.cs +++ b/src/App/Platforms/Android/Utilities/CallingAppInfoExtensions.cs @@ -19,5 +19,19 @@ namespace Bit.App.Droid.Utilities var certHash = md.Digest(cert); return $"android:apk-key-hash:{CoreHelpers.Base64UrlEncode(certHash)}"; } + + public static string GetLatestCertificationFingerprint(this CallingAppInfo callingAppInfo) + { + if (callingAppInfo.SigningInfo.HasMultipleSigners) + { + return null; + } + + var signature = callingAppInfo.SigningInfo.GetSigningCertificateHistory()[0].ToByteArray(); + var md = MessageDigest.GetInstance("SHA-256"); + var digestedSignature = md.Digest(signature); + var normalizedFingerprint = string.Join(":", digestedSignature.Select(b => b.ToString("X2"))); + return normalizedFingerprint; + } } } diff --git a/src/Core/Abstractions/IApiService.cs b/src/Core/Abstractions/IApiService.cs index b60d90267..bbd3b9357 100644 --- a/src/Core/Abstractions/IApiService.cs +++ b/src/Core/Abstractions/IApiService.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Request; using Bit.Core.Models.Response; @@ -100,5 +95,6 @@ namespace Bit.Core.Abstractions Task GetDevicesExistenceByTypes(DeviceType[] deviceTypes); Task GetConfigsAsync(); Task GetFastmailAccountIdAsync(string apiKey); + Task> GetDigitalAssetLinksForRpAsync(string rpId); } } diff --git a/src/Core/Abstractions/IAssetLinksService.cs b/src/Core/Abstractions/IAssetLinksService.cs new file mode 100644 index 000000000..c9dc5ca85 --- /dev/null +++ b/src/Core/Abstractions/IAssetLinksService.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Services +{ + public interface IAssetLinksService + { + Task ValidateAssetLinksAsync(string rpId, string packageName, string normalizedFingerprint); + } +} diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index 44a9f414c..f26de73a4 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -839,6 +839,33 @@ namespace Bit.Core.Services } } + public async Task> GetDigitalAssetLinksForRpAsync(string rpId) + { + using (var httpclient = new HttpClient()) + { + HttpResponseMessage response; + try + { + httpclient.DefaultRequestHeaders.Add("Accept", "application/json"); + response = await httpclient.GetAsync(new Uri($"https://{rpId}/.well-known/assetlinks.json")); + } + catch (Exception e) + { + throw new ApiException(HandleWebError(e)); + } + if (!response.IsSuccessStatusCode) + { + throw new ApiException(new ErrorResponse + { + StatusCode = response.StatusCode, + Message = $"Digital Asset links Rp error: {(int)response.StatusCode} {response.ReasonPhrase}." + }); + } + var json = await response.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject>(json); + } + } + private ErrorResponse HandleWebError(Exception e) { return new ErrorResponse diff --git a/src/Core/Services/AssetLinksService.cs b/src/Core/Services/AssetLinksService.cs new file mode 100644 index 000000000..87c61bb7a --- /dev/null +++ b/src/Core/Services/AssetLinksService.cs @@ -0,0 +1,35 @@ +using Bit.Core.Abstractions; + +namespace Bit.Core.Services +{ + public class AssetLinksService : IAssetLinksService + { + private readonly IApiService _apiService; + + public AssetLinksService(IApiService apiService) + { + _apiService = apiService; + } + + /// + /// Gets the digital asset links file associated with the and + /// validates that the and matches. + /// + /// True if matches, False otherwise. + public async Task ValidateAssetLinksAsync(string rpId, string packageName, string normalizedFingerprint) + { + 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)); + } + } +} diff --git a/src/Core/Utilities/DigitalAssetLinks/Statement.cs b/src/Core/Utilities/DigitalAssetLinks/Statement.cs new file mode 100644 index 000000000..1ccc10dfa --- /dev/null +++ b/src/Core/Utilities/DigitalAssetLinks/Statement.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Utilities.DigitalAssetLinks +{ + public class Statement + { + public IEnumerable Relation { get; set; } + public Target Target { get; set; } + } +} diff --git a/src/Core/Utilities/DigitalAssetLinks/Target.cs b/src/Core/Utilities/DigitalAssetLinks/Target.cs new file mode 100644 index 000000000..6e3cd2e8a --- /dev/null +++ b/src/Core/Utilities/DigitalAssetLinks/Target.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Bit.Core.Utilities.DigitalAssetLinks +{ + public class Target + { + public string Namespace { get; set; } + [JsonProperty("package_name")] + public string PackageName { get; set; } + [JsonProperty("sha256_cert_fingerprints")] + public IEnumerable Sha256CertFingerprints { get; set; } + + } +} diff --git a/src/Core/Utilities/ServiceContainer.cs b/src/Core/Utilities/ServiceContainer.cs index 3f8147a8e..983a06090 100644 --- a/src/Core/Utilities/ServiceContainer.cs +++ b/src/Core/Utilities/ServiceContainer.cs @@ -116,6 +116,9 @@ namespace Bit.Core.Utilities Register(usernameGenerationService); Register(deviceTrustCryptoService); Register(passwordResetEnrollmentService); +#if ANDROID + Register(new AssetLinksService(apiService)); +#endif } public static void Register(string serviceName, T obj) diff --git a/test/Core.Test/Services/AssetLinksServiceTest.cs b/test/Core.Test/Services/AssetLinksServiceTest.cs new file mode 100644 index 000000000..f2bd79d58 --- /dev/null +++ b/test/Core.Test/Services/AssetLinksServiceTest.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Services; +using Bit.Core.Utilities.DigitalAssetLinks; +using Bit.Test.Common.AutoFixture; +using Newtonsoft.Json; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services +{ + public class AssetLinksServiceTest : IDisposable + { + private readonly SutProvider _sutProvider = new SutProvider().Create(); + + private readonly string _validRpId = "example.com"; + private readonly string _validPackageName = "com.example.app"; + private readonly string _validFingerprint = "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00"; + + private List Deserialize(string json) + { + return JsonConvert.DeserializeObject>(json); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_True_When_Data_Has_One_Statement_And_One_Fingerprint() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementOneFingerprintJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + + // Assert + Assert.True(isValid); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_True_When_Data_Has_One_Statement_And_Multiple_Fingerprints() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementMultipleFingerprintsJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + + // Assert + Assert.True(isValid); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_True_When_Data_Has_Multiple_Statements() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.MultipleStatementsJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + + // Assert + Assert.True(isValid); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_No_GetLoginCreds_Relation() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementNoGetLoginCredsRelationJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + + // Assert + Assert.False(isValid); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_No_HandleAllUrls_Relation() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementNoHandleAllUrlsRelationJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + + // Assert + Assert.False(isValid); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_Wrong_Namespace() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementWrongNamespaceJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + + // Assert + Assert.False(isValid); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_False_When_Data_Statement_Has_No_Fingerprints() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementNoFingerprintsJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, _validPackageName, _validFingerprint); + + // Assert + Assert.False(isValid); + } + + [Fact] + public async Task ValidateAssetLinksAsync_Returns_False_When_Data_PackageName_Doesnt_Match() + { + // Arrange + _sutProvider.GetDependency() + .GetDigitalAssetLinksForRpAsync(_validRpId) + .Returns(Task.FromResult(Deserialize(BasicAssetLinksTestData.OneStatementOneFingerprintJson()))); + + // Act + var isValid = await _sutProvider.Sut.ValidateAssetLinksAsync(_validRpId, "com.foo.another", _validFingerprint); + + // 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); + } + + public void Dispose() {} + } +} \ No newline at end of file diff --git a/test/Core.Test/Services/AssetLinksServiceTestData/BasicAssetLinksTestData.cs b/test/Core.Test/Services/AssetLinksServiceTestData/BasicAssetLinksTestData.cs new file mode 100644 index 000000000..a9cfcecd7 --- /dev/null +++ b/test/Core.Test/Services/AssetLinksServiceTestData/BasicAssetLinksTestData.cs @@ -0,0 +1,179 @@ +public static class BasicAssetLinksTestData +{ + #region Valid statements + + public static string OneStatementOneFingerprintJson() + { + return + """ + [ + { + "relation": [ + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [ + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" + ] + } + } + ] + """; + } + + public static string OneStatementMultipleFingerprintsJson() + { + return + """ + [ + { + "relation": [ + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [ + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:01", + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:02" + ] + } + } + ] + """; + } + + public static string MultipleStatementsJson() + { + return + """ + [ + { + "relation": [ + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [ + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:01", + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:02" + ] + } + }, + { + "relation": [ + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.foo.app", + "sha256_cert_fingerprints": [ + "10:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", + "10:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:01", + "10:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:02" + ] + } + } + ] + """; + } + + #endregion + + #region Invalid statements + + public static string OneStatementNoGetLoginCredsRelationJson() + { + return + """ + [ + { + "relation": [ + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [ + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" + ] + } + } + ] + """; + } + + public static string OneStatementNoHandleAllUrlsRelationJson() + { + return + """ + [ + { + "relation": [ + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [ + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" + ] + } + } + ] + """; + } + + public static string OneStatementWrongNamespaceJson() + { + return + """ + [ + { + "relation": [ + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "NOT_android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [ + "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" + ] + } + } + ] + """; + } + + public static string OneStatementNoFingerprintsJson() + { + return + """ + [ + { + "relation": [ + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [] + } + } + ] + """; + } + + #endregion +} \ No newline at end of file diff --git a/test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs b/test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs index ba79a1bac..76708f93b 100644 --- a/test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs +++ b/test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs @@ -45,7 +45,7 @@ namespace Bit.Core.Test.Services _authenticatorResult = new Fido2AuthenticatorGetAssertionResult { AuthenticatorData = RandomBytes(32), - SelectedCredential = new Fido2AuthenticatorGetAssertionSelectedCredential + SelectedCredential = new Fido2SelectedCredential { Id = RandomBytes(16), UserHandle = RandomBytes(32) @@ -74,7 +74,7 @@ namespace Bit.Core.Test.Services _params.Origin = "invalid-domain-name"; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); @@ -90,7 +90,7 @@ namespace Bit.Core.Test.Services _params.RpId = "bitwarden.com"; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); @@ -105,7 +105,7 @@ namespace Bit.Core.Test.Services _params.RpId = "bitwarden.com"; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); @@ -124,7 +124,7 @@ namespace Bit.Core.Test.Services })); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code); @@ -139,7 +139,7 @@ namespace Bit.Core.Test.Services .Throws(new InvalidStateError()); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); @@ -155,7 +155,7 @@ namespace Bit.Core.Test.Services .Throws(new Exception("unknown error")); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code); @@ -168,7 +168,7 @@ namespace Bit.Core.Test.Services _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(false); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); @@ -182,7 +182,7 @@ namespace Bit.Core.Test.Services _sutProvider.GetDependency().GetWebVaultUrl().Returns("https://vault.bitwarden.com"); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); @@ -201,7 +201,7 @@ namespace Bit.Core.Test.Services .Returns(_authenticatorResult); // Act - await _sutProvider.Sut.AssertCredentialAsync(_params); + await _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams()); // Assert await _sutProvider.GetDependency().Received() @@ -216,12 +216,13 @@ namespace Bit.Core.Test.Services { // Arrange var mockHash = RandomBytes(32); + var extraParams = new Fido2ExtraAssertCredentialParams(mockHash, null); _sutProvider.GetDependency() .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) .Returns(_authenticatorResult); // Act - await _sutProvider.Sut.AssertCredentialAsync(_params, mockHash); + await _sutProvider.Sut.AssertCredentialAsync(_params, extraParams); // Assert await _sutProvider.GetDependency().Received() @@ -241,7 +242,7 @@ namespace Bit.Core.Test.Services .Returns(_authenticatorResult); // Act - var result = await _sutProvider.Sut.AssertCredentialAsync(_params); + var result = await _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams()); // Assert await _sutProvider.GetDependency() @@ -268,6 +269,45 @@ namespace Bit.Core.Test.Services Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue()); } + [Fact] + public async Task AssertCredentialAsync_ReturnsAssertionWithAndroidPackage() + { + // Arrange + var packageName = "com.example.app"; + _params.UserVerification = "required"; + _sutProvider.GetDependency() + .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) + .Returns(_authenticatorResult); + + // Act + var result = await _sutProvider.Sut.AssertCredentialAsync(_params, new Fido2ExtraAssertCredentialParams(null, packageName)); + + // Assert + await _sutProvider.GetDependency() + .Received() + .GetAssertionAsync( + Arg.Is(x => + x.RpId == _params.RpId && + x.UserVerificationPreference == Fido2UserVerificationPreference.Required && + x.AllowCredentialDescriptorList.Length == 1 && + x.AllowCredentialDescriptorList[0].Id == _params.AllowCredentials[0].Id + ), + _sutProvider.GetDependency() + ); + + Assert.Equal(_authenticatorResult.SelectedCredential.Id, result.RawId); + Assert.Equal(CoreHelpers.Base64UrlEncode(_authenticatorResult.SelectedCredential.Id), result.Id); + Assert.Equal(_authenticatorResult.AuthenticatorData, result.AuthenticatorData); + Assert.Equal(_authenticatorResult.Signature, result.Signature); + + var clientDataJSON = JsonSerializer.Deserialize(Encoding.UTF8.GetString(result.ClientDataJSON)); + Assert.Equal("webauthn.get", clientDataJSON["type"].GetValue()); + Assert.Equal(CoreHelpers.Base64UrlEncode(_params.Challenge), clientDataJSON["challenge"].GetValue()); + Assert.Equal(_params.Origin, clientDataJSON["origin"].GetValue()); + Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue()); + Assert.Equal(packageName, clientDataJSON["androidPackageName"].GetValue()); + } + private byte[] RandomBytes(int length) { var bytes = new byte[length]; diff --git a/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs b/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs index 202e93c28..156d5aa62 100644 --- a/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs +++ b/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs @@ -78,7 +78,7 @@ namespace Bit.Core.Test.Services _params.SameOriginWithAncestors = false; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); @@ -92,7 +92,7 @@ namespace Bit.Core.Test.Services _params.User.Id = RandomBytes(0); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code); @@ -106,7 +106,7 @@ namespace Bit.Core.Test.Services _params.User.Id = RandomBytes(65); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code); @@ -125,7 +125,7 @@ namespace Bit.Core.Test.Services _params.Origin = "invalid-domain-name"; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); @@ -141,7 +141,7 @@ namespace Bit.Core.Test.Services _params.Rp.Id = "bitwarden.com"; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); @@ -156,7 +156,7 @@ namespace Bit.Core.Test.Services _params.Rp.Id = "bitwarden.com"; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); @@ -174,7 +174,7 @@ namespace Bit.Core.Test.Services })); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code); @@ -198,7 +198,7 @@ namespace Bit.Core.Test.Services }; // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.NotSupportedError, exception.Code); @@ -230,7 +230,7 @@ namespace Bit.Core.Test.Services .Returns(authenticatorResult); // Act - var result = await _sutProvider.Sut.CreateCredentialAsync(_params); + var result = await _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams()); // Assert await _sutProvider.GetDependency() @@ -258,6 +258,58 @@ namespace Bit.Core.Test.Services Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue()); } + [Fact] + public async Task CreateCredentialAsync_ReturnsNewCredential_WithAndroidPackageName() + { + // Arrange + _params.AuthenticatorSelection = new AuthenticatorSelectionCriteria + { + ResidentKey = "required", + UserVerification = "required" + }; + var authenticatorResult = new Fido2AuthenticatorMakeCredentialResult + { + CredentialId = RandomBytes(32), + AttestationObject = RandomBytes(32), + AuthData = RandomBytes(32), + PublicKey = RandomBytes(32), + PublicKeyAlgorithm = (int)Fido2AlgorithmIdentifier.ES256, + }; + _sutProvider.GetDependency() + .MakeCredentialAsync(Arg.Any(), _sutProvider.GetDependency()) + .Returns(authenticatorResult); + var packageName = "com.example.app"; + + // Act + var result = await _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams(null, packageName)); + + // Assert + await _sutProvider.GetDependency() + .Received() + .MakeCredentialAsync( + Arg.Is(x => + x.RequireResidentKey == true && + x.UserVerificationPreference == Fido2UserVerificationPreference.Required && + x.RpEntity.Id == _params.Rp.Id && + x.UserEntity.DisplayName == _params.User.DisplayName + ), + _sutProvider.GetDependency() + ); + Assert.Equal(authenticatorResult.CredentialId, result.CredentialId); + Assert.Equal(authenticatorResult.AttestationObject, result.AttestationObject); + Assert.Equal(authenticatorResult.AuthData, result.AuthData); + Assert.Equal(authenticatorResult.PublicKey, result.PublicKey); + Assert.Equal(authenticatorResult.PublicKeyAlgorithm, result.PublicKeyAlgorithm); + Assert.Equal(new string[] { "internal" }, result.Transports); + + var clientDataJSON = JsonSerializer.Deserialize(Encoding.UTF8.GetString(result.ClientDataJSON)); + Assert.Equal("webauthn.create", clientDataJSON["type"].GetValue()); + Assert.Equal(CoreHelpers.Base64UrlEncode(_params.Challenge), clientDataJSON["challenge"].GetValue()); + Assert.Equal(_params.Origin, clientDataJSON["origin"].GetValue()); + Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue()); + Assert.Equal(packageName, clientDataJSON["androidPackageName"].GetValue()); + } + [Fact] public async Task CreateCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError() { @@ -272,7 +324,7 @@ namespace Bit.Core.Test.Services .Throws(new InvalidStateError()); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); @@ -288,7 +340,7 @@ namespace Bit.Core.Test.Services .Throws(new Exception("unknown error")); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code); @@ -301,7 +353,7 @@ namespace Bit.Core.Test.Services _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(false); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); @@ -315,14 +367,14 @@ namespace Bit.Core.Test.Services _sutProvider.GetDependency().GetWebVaultUrl().Returns("https://vault.bitwarden.com"); // Act - var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams())); // Assert Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); } [Fact] - public async Task AssertCredentialAsync_ConstructsClientDataHash_WhenHashIsNotProvided() + public async Task CreateCredentialAsync_ConstructsClientDataHash_WhenHashIsNotProvided() { // Arrange var mockHash = RandomBytes(32); @@ -334,18 +386,18 @@ namespace Bit.Core.Test.Services .Returns(_authenticatorResult); // Act - await _sutProvider.Sut.CreateCredentialAsync(_params); + await _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams()); // Assert await _sutProvider.GetDependency().Received() - .GetAssertionAsync( - Arg.Is((Fido2AuthenticatorGetAssertionParams x) => x.Hash == mockHash), - Arg.Any() + .MakeCredentialAsync( + Arg.Is((Fido2AuthenticatorMakeCredentialParams x) => x.Hash == mockHash), + Arg.Any() ); } [Fact] - public async Task AssertCredentialAsync_UsesProvidedClientDataHash_WhenHashIsProvided() + public async Task CreateCredentialAsync_UsesProvidedClientDataHash_WhenHashIsProvided() { // Arrange var mockHash = RandomBytes(32); @@ -354,13 +406,13 @@ namespace Bit.Core.Test.Services .Returns(_authenticatorResult); // Act - await _sutProvider.Sut.CreateCredentialAsync(_params, mockHash); + await _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams(mockHash)); // Assert await _sutProvider.GetDependency().Received() - .GetAssertionAsync( - Arg.Is((Fido2AuthenticatorGetAssertionParams x) => x.Hash == mockHash), - Arg.Any() + .MakeCredentialAsync( + Arg.Is((Fido2AuthenticatorMakeCredentialParams x) => x.Hash == mockHash), + Arg.Any() ); } @@ -378,7 +430,7 @@ namespace Bit.Core.Test.Services .Returns(_authenticatorResult); // Act - var result = await _sutProvider.Sut.CreateCredentialAsync(_params); + var result = await _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams()); // Assert Assert.True(result.Extensions.CredProps?.Rk); @@ -398,7 +450,7 @@ namespace Bit.Core.Test.Services .Returns(_authenticatorResult); // Act - var result = await _sutProvider.Sut.CreateCredentialAsync(_params); + var result = await _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams()); // Assert Assert.False(result.Extensions.CredProps?.Rk); @@ -418,7 +470,7 @@ namespace Bit.Core.Test.Services .Returns(_authenticatorResult); // Act - var result = await _sutProvider.Sut.CreateCredentialAsync(_params); + var result = await _sutProvider.Sut.CreateCredentialAsync(_params, new Fido2ExtraCreateCredentialParams()); // Assert Assert.Null(result.Extensions.CredProps);