From 6bb724ff068709237dfc1c292d4e17d7b48dc623 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 30 Jan 2024 13:10:09 +0100 Subject: [PATCH] [PM-5731] feat: add support for silent discoverability --- .../IFido2AuthenticatorService.cs | 2 + .../Services/Fido2AuthenticatorService.cs | 13 ++ ...enticatorDiscoverableCredentialMetadata.cs | 16 +++ .../Fido2/PublicKeyCredentialDescriptor.cs | 6 +- ...enticatorSilentCredentialDiscoveryTests.cs | 123 ++++++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/Core/Utilities/Fido2/Fido2AuthenticatorDiscoverableCredentialMetadata.cs create mode 100644 test/Core.Test/Services/Fido2AuthenticatorSilentCredentialDiscoveryTests.cs diff --git a/src/Core/Abstractions/IFido2AuthenticatorService.cs b/src/Core/Abstractions/IFido2AuthenticatorService.cs index 3cd861c62..a9cc46f30 100644 --- a/src/Core/Abstractions/IFido2AuthenticatorService.cs +++ b/src/Core/Abstractions/IFido2AuthenticatorService.cs @@ -6,5 +6,7 @@ namespace Bit.Core.Abstractions { Task MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams); Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams); + // TODO: Should this return a List? Or maybe IEnumerable? + Task SilentCredentialDiscoveryAsync(string rpId); } } diff --git a/src/Core/Services/Fido2AuthenticatorService.cs b/src/Core/Services/Fido2AuthenticatorService.cs index 43ed81401..d3a252c47 100644 --- a/src/Core/Services/Fido2AuthenticatorService.cs +++ b/src/Core/Services/Fido2AuthenticatorService.cs @@ -194,6 +194,19 @@ namespace Bit.Core.Services } } + public async Task SilentCredentialDiscoveryAsync(string rpId) + { + var credentials = (await FindCredentialsByRpAsync(rpId)).Select(cipher => new Fido2AuthenticatorDiscoverableCredentialMetadata { + Type = "public-key", + Id = GuidToRawFormat(cipher.Login.MainFido2Credential.CredentialId), + RpId = cipher.Login.MainFido2Credential.RpId, + UserHandle = cipher.Login.MainFido2Credential.UserHandleValue, + UserName = cipher.Login.MainFido2Credential.UserName + }).ToArray(); + + return credentials; + } + /// /// Finds existing crendetials and returns the `CipherId` for each one /// diff --git a/src/Core/Utilities/Fido2/Fido2AuthenticatorDiscoverableCredentialMetadata.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorDiscoverableCredentialMetadata.cs new file mode 100644 index 000000000..90d21dff4 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorDiscoverableCredentialMetadata.cs @@ -0,0 +1,16 @@ +/// +/// Represents the metadata of a discoverable credential for a FIDO2 authenticator. +/// See: https://www.w3.org/TR/webauthn-3/#sctn-op-silent-discovery +/// +public class Fido2AuthenticatorDiscoverableCredentialMetadata +{ + public string Type { get; set; } + + public byte[] Id { get; set; } + + public string RpId { get; set; } + + public byte[] UserHandle { get; set; } + + public string UserName { get; set; } +} diff --git a/src/Core/Utilities/Fido2/PublicKeyCredentialDescriptor.cs b/src/Core/Utilities/Fido2/PublicKeyCredentialDescriptor.cs index 07e3d601b..16fa9e698 100644 --- a/src/Core/Utilities/Fido2/PublicKeyCredentialDescriptor.cs +++ b/src/Core/Utilities/Fido2/PublicKeyCredentialDescriptor.cs @@ -1,9 +1,9 @@ namespace Bit.Core.Utilities.Fido2 { public class PublicKeyCredentialDescriptor { - public byte[] Id {get; set;} - public string[] Transports; - public string Type; + public byte[] Id { get; set; } + public string[] Transports { get; set; } + public string Type { get; set; } } } diff --git a/test/Core.Test/Services/Fido2AuthenticatorSilentCredentialDiscoveryTests.cs b/test/Core.Test/Services/Fido2AuthenticatorSilentCredentialDiscoveryTests.cs new file mode 100644 index 000000000..b0ca70167 --- /dev/null +++ b/test/Core.Test/Services/Fido2AuthenticatorSilentCredentialDiscoveryTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Services; +using Bit.Core.Models.View; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using System.Collections.Generic; +using System.Linq; +using System.Diagnostics.CodeAnalysis; + +namespace Bit.Core.Test.Services +{ + public class Fido2AuthenticatorSilentCredentialDiscoveryTests + { + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] + public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_NoCredentialsExist(SutProvider sutProvider) + { + sutProvider.GetDependency().GetAllDecryptedAsync().Returns([]); + + var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com"); + + Assert.Empty(result); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] + public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_OnlyNonDiscoverableCredentialsExist(SutProvider sutProvider) + { + sutProvider.GetDependency().GetAllDecryptedAsync().Returns([ + CreateCipherView("bitwarden.com", false), + CreateCipherView("bitwarden.com", false), + CreateCipherView("bitwarden.com", false) + ]); + + var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com"); + + Assert.Empty(result); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] + public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_NoCredentialsWithMatchingRpIdExist(SutProvider sutProvider) + { + sutProvider.GetDependency().GetAllDecryptedAsync().Returns([ + CreateCipherView("a.bitwarden.com", true), + CreateCipherView("example.com", true) + ]); + + var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com"); + + Assert.Empty(result); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] + public async Task SilentCredentialDiscoveryAsync_ReturnsCredentials_DiscoverableCredentialsWithMatchingRpIdExist(SutProvider sutProvider) + { + var matchingCredentials = new List { + CreateCipherView("bitwarden.com", true), + CreateCipherView("bitwarden.com", true) + }; + var nonMatchingCredentials = new List { + CreateCipherView("example.com", true) + }; + sutProvider.GetDependency().GetAllDecryptedAsync().Returns( + matchingCredentials.Concat(nonMatchingCredentials).ToList() + ); + + var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com"); + + Assert.True( + result.SequenceEqual(matchingCredentials.Select(c => new Fido2AuthenticatorDiscoverableCredentialMetadata { + Type = "public-key", + Id = Guid.Parse(c.Login.MainFido2Credential.CredentialId).ToByteArray(), + RpId = "bitwarden.com", + UserHandle = c.Login.MainFido2Credential.UserHandleValue, + UserName = c.Login.MainFido2Credential.UserName + }), new MetadataComparer()) + ); + } + + private byte[] RandomBytes(int length) + { + var bytes = new byte[length]; + new Random().NextBytes(bytes); + return bytes; + } + + #nullable enable + private CipherView CreateCipherView(string rpId, bool discoverable) + { + return new CipherView { + Type = CipherType.Login, + Id = Guid.NewGuid().ToString(), + Reprompt = CipherRepromptType.None, + Login = new LoginView { + Fido2Credentials = new List { + new Fido2CredentialView { + CredentialId = Guid.NewGuid().ToString(), + RpId = rpId ?? "null.com", + DiscoverableValue = discoverable, + UserHandleValue = RandomBytes(32), + KeyValue = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgO4wC7AlY4eJP7uedRUJGYsAIJAd6gN1Vp7uJh6xXAp6hRANCAARGvr56F_t27DEG1Tzl-qJRhrTUtC7jOEbasAEEZcE3TiMqoWCan0sxKDPylhRYk-1qyrBC_feN1UtGWH57sROa" + } + } + } + }; + } + + private class MetadataComparer : IEqualityComparer + { + public int GetHashCode([DisallowNull] Fido2AuthenticatorDiscoverableCredentialMetadata obj) => throw new NotImplementedException(); + + public bool Equals(Fido2AuthenticatorDiscoverableCredentialMetadata? a, Fido2AuthenticatorDiscoverableCredentialMetadata? b) => + a != null && b != null && a.Type == b.Type && a.RpId == b.RpId && a.UserName == b.UserName && a.Id.SequenceEqual(b.Id) && a.UserHandle.SequenceEqual(b.UserHandle); + } + } +}