diff --git a/src/Core/Abstractions/ICryptoFunctionService.cs b/src/Core/Abstractions/ICryptoFunctionService.cs index 39b6ba6a1..630dc1b96 100644 --- a/src/Core/Abstractions/ICryptoFunctionService.cs +++ b/src/Core/Abstractions/ICryptoFunctionService.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Bit.Core.Enums; +using Bit.Core.Models.Domain; namespace Bit.Core.Abstractions { diff --git a/src/Core/Abstractions/IFido2AuthenticatorService.cs b/src/Core/Abstractions/IFido2AuthenticatorService.cs new file mode 100644 index 000000000..32ec5c0b8 --- /dev/null +++ b/src/Core/Abstractions/IFido2AuthenticatorService.cs @@ -0,0 +1,12 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public interface IFido2AuthenticatorService + { + Task MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams, IFido2MakeCredentialUserInterface userInterface); + Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams, IFido2GetAssertionUserInterface userInterface); + // TODO: Should this return a List? Or maybe IEnumerable? + Task SilentCredentialDiscoveryAsync(string rpId); + } +} diff --git a/src/Core/Abstractions/IFido2ClientService.cs b/src/Core/Abstractions/IFido2ClientService.cs new file mode 100644 index 000000000..a690faacf --- /dev/null +++ b/src/Core/Abstractions/IFido2ClientService.cs @@ -0,0 +1,35 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + /// + /// This class represents an abstraction of the WebAuthn Client as described by W3C: + /// https://www.w3.org/TR/webauthn-3/#webauthn-client + /// + /// The WebAuthn Client is an intermediary entity typically implemented in the user agent + /// (in whole, or in part). Conceptually, it underlies the Web Authentication API and embodies + /// the implementation of the Web Authentication API's operations. + /// + /// It is responsible for both marshalling the inputs for the underlying authenticator operations, + /// and for returning the results of the latter operations to the Web Authentication API's callers. + /// + public interface IFido2ClientService + { + /// + /// Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source. + /// For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential + /// + /// The parameters for the credential creation operation + /// The new credential + Task CreateCredentialAsync(Fido2ClientCreateCredentialParams createCredentialParams); + + /// + /// Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the user’s consent. + /// Relying Party script can optionally specify some criteria to indicate what credential sources are acceptable to it. + /// For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-getAssertion + /// + /// The parameters for the credential assertion operation + /// The asserted credential + Task AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams); + } +} diff --git a/src/Core/Abstractions/IFido2GetAssertionUserInterface.cs b/src/Core/Abstractions/IFido2GetAssertionUserInterface.cs new file mode 100644 index 000000000..9d12121ac --- /dev/null +++ b/src/Core/Abstractions/IFido2GetAssertionUserInterface.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.Abstractions +{ + public struct Fido2GetAssertionUserInterfaceCredential + { + public string CipherId { get; set; } + public bool RequireUserVerification { get; set; } + } + + public interface IFido2GetAssertionUserInterface : IFido2UserInterface + { + /// + /// Ask the user to pick a credential from a list of existing credentials. + /// + /// The credentials that the user can pick from, and if the user must be verified before completing the operation + /// The ID of the cipher that contains the credentials the user picked, and if the user was verified before completing the operation + Task<(string CipherId, bool UserVerified)> PickCredentialAsync(Fido2GetAssertionUserInterfaceCredential[] credentials); + } +} diff --git a/src/Core/Abstractions/IFido2MakeCredentialUserInterface.cs b/src/Core/Abstractions/IFido2MakeCredentialUserInterface.cs new file mode 100644 index 000000000..67d13141e --- /dev/null +++ b/src/Core/Abstractions/IFido2MakeCredentialUserInterface.cs @@ -0,0 +1,37 @@ +namespace Bit.Core.Abstractions +{ + public struct Fido2ConfirmNewCredentialParams + { + /// + /// The name of the credential. + /// + public string CredentialName { get; set; } + + /// + /// The name of the user. + /// + public string UserName { get; set; } + + /// + /// Whether or not the user must be verified before completing the operation. + /// + public bool UserVerification { get; set; } + } + + public interface IFido2MakeCredentialUserInterface : IFido2UserInterface + { + /// + /// Inform the user that the operation was cancelled because their vault contains excluded credentials. + /// + /// The IDs of the excluded credentials. + /// When user has confirmed the message + Task InformExcludedCredentialAsync(string[] existingCipherIds); + + /// + /// Ask the user to confirm the creation of a new credential. + /// + /// The parameters to use when asking the user to confirm the creation of a new credential. + /// The ID of the cipher where the new credential should be saved, and if the user was verified before completing the operation + Task<(string CipherId, bool UserVerified)> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams); + } +} diff --git a/src/Core/Abstractions/IFido2UserInterface.cs b/src/Core/Abstractions/IFido2UserInterface.cs new file mode 100644 index 000000000..f1470bf39 --- /dev/null +++ b/src/Core/Abstractions/IFido2UserInterface.cs @@ -0,0 +1,12 @@ +namespace Bit.Core.Abstractions +{ + public interface IFido2UserInterface + { + /// + /// Make sure that the vault is unlocked. + /// This should open a window and ask the user to login or unlock the vault if necessary. + /// + /// When vault has been unlocked. + Task EnsureUnlockedVaultAsync(); + } +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 9cada6310..47fc6418c 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -34,6 +34,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Core/Models/View/Fido2CredentialView.cs b/src/Core/Models/View/Fido2CredentialView.cs index 92d3ff85b..210e56042 100644 --- a/src/Core/Models/View/Fido2CredentialView.cs +++ b/src/Core/Models/View/Fido2CredentialView.cs @@ -1,5 +1,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Domain; +using Bit.Core.Utilities; namespace Bit.Core.Models.View { @@ -27,9 +28,28 @@ namespace Bit.Core.Models.View public string Counter { get; set; } public DateTime CreationDate { get; set; } + public int CounterValue { + get => int.TryParse(Counter, out var counter) ? counter : 0; + set => Counter = value.ToString(); + } + + public byte[] UserHandleValue { + get => UserHandle == null ? null : CoreHelpers.Base64UrlDecode(UserHandle); + set => UserHandle = value == null ? null : CoreHelpers.Base64UrlEncode(value); + } + + public byte[] KeyBytes { + get => KeyValue == null ? null : CoreHelpers.Base64UrlDecode(KeyValue); + set => KeyValue = value == null ? null : CoreHelpers.Base64UrlEncode(value); + } + + public bool DiscoverableValue { + get => bool.TryParse(Discoverable, out var discoverable) && discoverable; + set => Discoverable = value.ToString().ToLower(); + } + public override string SubTitle => UserName; public override List> LinkedFieldOptions => new List>(); - public bool IsDiscoverable => !string.IsNullOrWhiteSpace(Discoverable); public bool CanLaunch => !string.IsNullOrEmpty(RpId); public string LaunchUri => $"https://{RpId}"; diff --git a/src/Core/Services/Fido2AuthenticatorService.cs b/src/Core/Services/Fido2AuthenticatorService.cs new file mode 100644 index 000000000..357fff543 --- /dev/null +++ b/src/Core/Services/Fido2AuthenticatorService.cs @@ -0,0 +1,456 @@ +using Bit.Core.Abstractions; +using Bit.Core.Models.View; +using Bit.Core.Enums; +using Bit.Core.Utilities.Fido2; +using Bit.Core.Utilities; +using System.Formats.Cbor; +using System.Security.Cryptography; + +namespace Bit.Core.Services +{ + public class Fido2AuthenticatorService : IFido2AuthenticatorService + { + // AAGUID: d548826e-79b4-db40-a3d8-11116f7e8349 + public static readonly byte[] AAGUID = new byte[] { 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49 }; + + private readonly ICipherService _cipherService; + private readonly ISyncService _syncService; + private readonly ICryptoFunctionService _cryptoFunctionService; + + public Fido2AuthenticatorService(ICipherService cipherService, ISyncService syncService, ICryptoFunctionService cryptoFunctionService) + { + _cipherService = cipherService; + _syncService = syncService; + _cryptoFunctionService = cryptoFunctionService; + } + + public async Task MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams, IFido2MakeCredentialUserInterface userInterface) + { + if (makeCredentialParams.CredTypesAndPubKeyAlgs.All((p) => p.Alg != (int) Fido2AlgorithmIdentifier.ES256)) + { + throw new NotSupportedError(); + } + + await userInterface.EnsureUnlockedVaultAsync(); + await _syncService.FullSyncAsync(false); + + var existingCipherIds = await FindExcludedCredentialsAsync( + makeCredentialParams.ExcludeCredentialDescriptorList + ); + if (existingCipherIds.Length > 0) { + await userInterface.InformExcludedCredentialAsync(existingCipherIds); + throw new NotAllowedError(); + } + + var response = await userInterface.ConfirmNewCredentialAsync(new Fido2ConfirmNewCredentialParams { + CredentialName = makeCredentialParams.RpEntity.Name, + UserName = makeCredentialParams.UserEntity.Name, + UserVerification = makeCredentialParams.RequireUserVerification + }); + + var cipherId = response.CipherId; + var userVerified = response.UserVerified; + string credentialId; + if (cipherId == null) { + throw new NotAllowedError(); + } + + try { + var keyPair = GenerateKeyPair(); + var fido2Credential = CreateCredentialView(makeCredentialParams, keyPair.privateKey); + + var encrypted = await _cipherService.GetAsync(cipherId); + var cipher = await encrypted.DecryptAsync(); + + if (!userVerified && (makeCredentialParams.RequireUserVerification || cipher.Reprompt != CipherRepromptType.None)) { + throw new NotAllowedError(); + } + + cipher.Login.Fido2Credentials = [fido2Credential]; + var reencrypted = await _cipherService.EncryptAsync(cipher); + await _cipherService.SaveWithServerAsync(reencrypted); + credentialId = fido2Credential.CredentialId; + + var authData = await GenerateAuthDataAsync( + rpId: makeCredentialParams.RpEntity.Id, + counter: fido2Credential.CounterValue, + userPresence: true, + userVerification: userVerified, + credentialId: credentialId.GuidToRawFormat(), + publicKey: keyPair.publicKey + ); + + return new Fido2AuthenticatorMakeCredentialResult + { + CredentialId = credentialId.GuidToRawFormat(), + AttestationObject = EncodeAttestationObject(authData), + AuthData = authData, + PublicKey = keyPair.publicKey.ExportDer(), + PublicKeyAlgorithm = (int) Fido2AlgorithmIdentifier.ES256, + }; + } catch (NotAllowedError) { + throw; + } catch (Exception) { + throw new UnknownError(); + } + } + + public async Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams, IFido2GetAssertionUserInterface userInterface) + { + List cipherOptions; + + await userInterface.EnsureUnlockedVaultAsync(); + await _syncService.FullSyncAsync(false); + + if (assertionParams.AllowCredentialDescriptorList?.Length > 0) { + cipherOptions = await FindCredentialsByIdAsync( + assertionParams.AllowCredentialDescriptorList, + assertionParams.RpId + ); + } else { + cipherOptions = await FindCredentialsByRpAsync(assertionParams.RpId); + } + + if (cipherOptions.Count == 0) { + throw new NotAllowedError(); + } + + var response = await userInterface.PickCredentialAsync( + cipherOptions.Select((cipher) => new Fido2GetAssertionUserInterfaceCredential { + CipherId = cipher.Id, + RequireUserVerification = assertionParams.RequireUserVerification || cipher.Reprompt != CipherRepromptType.None + }).ToArray() + ); + var selectedCipherId = response.CipherId; + var userVerified = response.UserVerified; + + var selectedCipher = cipherOptions.FirstOrDefault((c) => c.Id == selectedCipherId); + if (selectedCipher == null) { + throw new NotAllowedError(); + } + + if (!userVerified && (assertionParams.RequireUserVerification || selectedCipher.Reprompt != CipherRepromptType.None)) { + throw new NotAllowedError(); + } + + try { + var selectedFido2Credential = selectedCipher.Login.MainFido2Credential; + var selectedCredentialId = selectedFido2Credential.CredentialId; + + if (selectedFido2Credential.CounterValue != 0) { + ++selectedFido2Credential.CounterValue; + } + + await _cipherService.UpdateLastUsedDateAsync(selectedCipher.Id); + var encrypted = await _cipherService.EncryptAsync(selectedCipher); + await _cipherService.SaveWithServerAsync(encrypted); + + var authenticatorData = await GenerateAuthDataAsync( + rpId: selectedFido2Credential.RpId, + userPresence: true, + userVerification: userVerified, + counter: selectedFido2Credential.CounterValue + ); + + var signature = GenerateSignature( + authData: authenticatorData, + clientDataHash: assertionParams.Hash, + privateKey: selectedFido2Credential.KeyBytes + ); + + return new Fido2AuthenticatorGetAssertionResult + { + SelectedCredential = new Fido2AuthenticatorGetAssertionSelectedCredential + { + Id = selectedCredentialId.GuidToRawFormat(), + UserHandle = selectedFido2Credential.UserHandleValue + }, + AuthenticatorData = authenticatorData, + Signature = signature + }; + } catch (Exception) { + throw new UnknownError(); + } + } + + public async Task SilentCredentialDiscoveryAsync(string rpId) + { + var credentials = (await FindCredentialsByRpAsync(rpId)).Select(cipher => new Fido2AuthenticatorDiscoverableCredentialMetadata { + Type = Constants.DefaultFido2CredentialType, + Id = cipher.Login.MainFido2Credential.CredentialId.GuidToRawFormat(), + 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 + /// + private async Task FindExcludedCredentialsAsync( + PublicKeyCredentialDescriptor[] credentials + ) { + if (credentials == null || credentials.Length == 0) { + return Array.Empty(); + } + + var ids = new List(); + + foreach (var credential in credentials) + { + try + { + ids.Add(credential.Id.GuidToStandardFormat()); + } catch {} + } + + if (ids.Count == 0) { + return Array.Empty(); + } + + var ciphers = await _cipherService.GetAllDecryptedAsync(); + return ciphers + .FindAll( + (cipher) => + !cipher.IsDeleted && + cipher.OrganizationId == null && + cipher.Type == CipherType.Login && + cipher.Login.HasFido2Credentials && + ids.Contains(cipher.Login.MainFido2Credential.CredentialId) + ) + .Select((cipher) => cipher.Id) + .ToArray(); + } + + private async Task> FindCredentialsByIdAsync(PublicKeyCredentialDescriptor[] credentials, string rpId) + { + var ids = new List(); + + foreach (var credential in credentials) + { + try + { + ids.Add(credential.Id.GuidToStandardFormat()); + } + catch {} + } + + if (ids.Count == 0) + { + return new List(); + } + + var ciphers = await _cipherService.GetAllDecryptedAsync(); + return ciphers.FindAll((cipher) => + !cipher.IsDeleted && + cipher.Type == CipherType.Login && + cipher.Login.HasFido2Credentials && + cipher.Login.MainFido2Credential.RpId == rpId && + ids.Contains(cipher.Login.MainFido2Credential.CredentialId) + ); + } + + private async Task> FindCredentialsByRpAsync(string rpId) + { + var ciphers = await _cipherService.GetAllDecryptedAsync(); + return ciphers.FindAll((cipher) => + !cipher.IsDeleted && + cipher.Type == CipherType.Login && + cipher.Login.HasFido2Credentials && + cipher.Login.MainFido2Credential.RpId == rpId && + cipher.Login.MainFido2Credential.DiscoverableValue + ); + } + + // TODO: Move this to a separate service + private (PublicKey publicKey, byte[] privateKey) GenerateKeyPair() + { + var dsa = ECDsa.Create(); + dsa.GenerateKey(ECCurve.NamedCurves.nistP256); + var privateKey = dsa.ExportPkcs8PrivateKey(); + + return (new PublicKey(dsa), privateKey); + } + + private Fido2CredentialView CreateCredentialView(Fido2AuthenticatorMakeCredentialParams makeCredentialsParams, byte[] privateKey) + { + return new Fido2CredentialView { + CredentialId = Guid.NewGuid().ToString(), + KeyType = Constants.DefaultFido2CredentialType, + KeyAlgorithm = Constants.DefaultFido2CredentialAlgorithm, + KeyCurve = Constants.DefaultFido2CredentialCurve, + KeyValue = CoreHelpers.Base64UrlEncode(privateKey), + RpId = makeCredentialsParams.RpEntity.Id, + UserHandle = CoreHelpers.Base64UrlEncode(makeCredentialsParams.UserEntity.Id), + UserName = makeCredentialsParams.UserEntity.Name, + CounterValue = 0, + RpName = makeCredentialsParams.RpEntity.Name, + // UserDisplayName = makeCredentialsParams.UserEntity.DisplayName, + DiscoverableValue = makeCredentialsParams.RequireResidentKey, + CreationDate = DateTime.UtcNow + }; + } + + private async Task GenerateAuthDataAsync( + string rpId, + bool userVerification, + bool userPresence, + int counter, + byte[] credentialId = null, + PublicKey publicKey = null + ) { + var isAttestation = credentialId != null && publicKey != null; + + List authData = new List(); + + var rpIdHash = await _cryptoFunctionService.HashAsync(rpId, CryptoHashAlgorithm.Sha256); + authData.AddRange(rpIdHash); + + var flags = AuthDataFlags( + extensionData: false, + attestationData: isAttestation, + userVerification: userVerification, + userPresence: userPresence + ); + authData.Add(flags); + + authData.AddRange([ + (byte)(counter >> 24), + (byte)(counter >> 16), + (byte)(counter >> 8), + (byte)counter + ]); + + if (isAttestation) + { + var attestedCredentialData = new List(); + + attestedCredentialData.AddRange(AAGUID); + + // credentialIdLength (2 bytes) and credential Id + var credentialIdLength = new byte[] { + (byte)((credentialId.Length - (credentialId.Length & 0xff)) / 256), + (byte)(credentialId.Length & 0xff) + }; + attestedCredentialData.AddRange(credentialIdLength); + attestedCredentialData.AddRange(credentialId); + attestedCredentialData.AddRange(publicKey.ExportCose()); + + authData.AddRange(attestedCredentialData); + } + + return authData.ToArray(); + } + + private byte AuthDataFlags(bool extensionData, bool attestationData, bool userVerification, bool userPresence, bool backupEligibility = true, bool backupState = true) { + byte flags = 0; + + if (extensionData) { + flags |= 0b1000000; + } + + if (attestationData) { + flags |= 0b01000000; + } + + if (backupState) + { + flags |= 0b00010000; + } + + if (backupEligibility) + { + flags |= 0b00001000; + } + + if (userVerification) { + flags |= 0b00000100; + } + + if (userPresence) { + flags |= 0b00000001; + } + + return flags; + } + + private byte[] EncodeAttestationObject(byte[] authData) { + var attestationObject = new CborWriter(CborConformanceMode.Ctap2Canonical); + attestationObject.WriteStartMap(3); + attestationObject.WriteTextString("fmt"); + attestationObject.WriteTextString("none"); + attestationObject.WriteTextString("attStmt"); + attestationObject.WriteStartMap(0); + attestationObject.WriteEndMap(); + attestationObject.WriteTextString("authData"); + attestationObject.WriteByteString(authData); + attestationObject.WriteEndMap(); + + return attestationObject.Encode(); + } + + // TODO: Move this to a separate service + private byte[] GenerateSignature(byte[] authData, byte[] clientDataHash, byte[] privateKey) + { + var sigBase = authData.Concat(clientDataHash).ToArray(); + var dsa = ECDsa.Create(); + dsa.ImportPkcs8PrivateKey(privateKey, out var bytesRead); + + if (bytesRead == 0) + { + throw new Exception("Failed to import private key"); + } + + return dsa.SignData(sigBase, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + } + + private class PublicKey + { + private readonly ECDsa _dsa; + + public PublicKey(ECDsa dsa) { + _dsa = dsa; + } + + public byte[] X => _dsa.ExportParameters(false).Q.X; + public byte[] Y => _dsa.ExportParameters(false).Q.Y; + + public byte[] ExportDer() + { + return _dsa.ExportSubjectPublicKeyInfo(); + } + + public byte[] ExportCose() + { + var result = new CborWriter(CborConformanceMode.Ctap2Canonical); + result.WriteStartMap(5); + + // kty = EC2 + result.WriteInt32(1); + result.WriteInt32(2); + + // alg = ES256 + result.WriteInt32(3); + result.WriteInt32((int) Fido2AlgorithmIdentifier.ES256); + + // crv = P-256 + result.WriteInt32(-1); + result.WriteInt32(1); + + // x + result.WriteInt32(-2); + result.WriteByteString(X); + + // y + result.WriteInt32(-3); + result.WriteByteString(Y); + + result.WriteEndMap(); + + return result.Encode(); + } + } + } +} diff --git a/src/Core/Services/Fido2ClientService.cs b/src/Core/Services/Fido2ClientService.cs new file mode 100644 index 000000000..8ca003b4a --- /dev/null +++ b/src/Core/Services/Fido2ClientService.cs @@ -0,0 +1,252 @@ +using System.Text; +using System.Text.Json; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Services +{ + public class Fido2ClientService : IFido2ClientService + { + private readonly IStateService _stateService; + private readonly IEnvironmentService _environmentService; + private readonly ICryptoFunctionService _cryptoFunctionService; + private readonly IFido2AuthenticatorService _fido2AuthenticatorService; + private readonly IFido2GetAssertionUserInterface _getAssertionUserInterface; + private readonly IFido2MakeCredentialUserInterface _makeCredentialUserInterface; + + public Fido2ClientService( + IStateService stateService, + IEnvironmentService environmentService, + ICryptoFunctionService cryptoFunctionService, + IFido2AuthenticatorService fido2AuthenticatorService, + IFido2GetAssertionUserInterface getAssertionUserInterface, + IFido2MakeCredentialUserInterface makeCredentialUserInterface) + { + _stateService = stateService; + _environmentService = environmentService; + _cryptoFunctionService = cryptoFunctionService; + _fido2AuthenticatorService = fido2AuthenticatorService; + _getAssertionUserInterface = getAssertionUserInterface; + _makeCredentialUserInterface = makeCredentialUserInterface; + } + + public async Task CreateCredentialAsync(Fido2ClientCreateCredentialParams createCredentialParams) + { + var blockedUris = await _stateService.GetAutofillBlacklistedUrisAsync(); + var domain = CoreHelpers.GetHostname(createCredentialParams.Origin); + if (blockedUris.Contains(domain)) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.UriBlockedError, + "Origin is blocked by the user"); + } + + if (!await _stateService.IsAuthenticatedAsync()) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.InvalidStateError, + "No user is logged in"); + } + + if (createCredentialParams.Origin == _environmentService.GetWebVaultUrl()) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.NotAllowedError, + "Saving Bitwarden credentials in a Bitwarden vault is not allowed"); + } + + if (!createCredentialParams.SameOriginWithAncestors) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.NotAllowedError, + "Credential creation is now allowed from embedded contexts with different origins"); + } + + if (createCredentialParams.User.Id.Length < 1 || createCredentialParams.User.Id.Length > 64) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.TypeError, + "The length of user.id is not between 1 and 64 bytes (inclusive)"); + } + + if (!createCredentialParams.Origin.StartsWith("https://")) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.SecurityError, + "Origin is not a valid https origin"); + } + + if (!Fido2DomainUtils.IsValidRpId(createCredentialParams.Rp.Id, createCredentialParams.Origin)) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.SecurityError, + "RP ID cannot be used with this origin"); + } + + PublicKeyCredentialParameters[] credTypesAndPubKeyAlgs; + if (createCredentialParams.PubKeyCredParams?.Length > 0) + { + // Filter out all unsupported algorithms + credTypesAndPubKeyAlgs = createCredentialParams.PubKeyCredParams + .Where(kp => kp.Alg == (int) Fido2AlgorithmIdentifier.ES256 && kp.Type == Constants.DefaultFido2CredentialType) + .ToArray(); + } + else + { + // Assign default algorithms + credTypesAndPubKeyAlgs = [ + new PublicKeyCredentialParameters { Alg = (int) Fido2AlgorithmIdentifier.ES256, Type = Constants.DefaultFido2CredentialType }, + new PublicKeyCredentialParameters { Alg = (int) Fido2AlgorithmIdentifier.RS256, Type = Constants.DefaultFido2CredentialType } + ]; + } + + if (credTypesAndPubKeyAlgs.Length == 0) + { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.NotSupportedError, "No supported algorithms found"); + } + + var clientDataJSON = JsonSerializer.Serialize(new { + type = "webauthn.create", + challenge = CoreHelpers.Base64UrlEncode(createCredentialParams.Challenge), + origin = createCredentialParams.Origin, + crossOrigin = !createCredentialParams.SameOriginWithAncestors, + // tokenBinding: {} // Not supported + }); + var clientDataJSONBytes = Encoding.UTF8.GetBytes(clientDataJSON); + var clientDataHash = await _cryptoFunctionService.HashAsync(clientDataJSONBytes, CryptoHashAlgorithm.Sha256); + var makeCredentialParams = MapToMakeCredentialParams(createCredentialParams, credTypesAndPubKeyAlgs, clientDataHash); + + try { + var makeCredentialResult = await _fido2AuthenticatorService.MakeCredentialAsync(makeCredentialParams, _makeCredentialUserInterface); + + return new Fido2ClientCreateCredentialResult { + CredentialId = makeCredentialResult.CredentialId, + AttestationObject = makeCredentialResult.AttestationObject, + AuthData = makeCredentialResult.AuthData, + ClientDataJSON = clientDataJSONBytes, + PublicKey = makeCredentialResult.PublicKey, + PublicKeyAlgorithm = makeCredentialResult.PublicKeyAlgorithm, + Transports = createCredentialParams.Rp.Id == "google.com" ? ["internal", "usb"] : ["internal"] // workaround for a bug on Google's side + }; + } catch (InvalidStateError) { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.InvalidStateError, "Unknown invalid state encountered"); + } catch (Exception) { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.UnknownError, $"An unknown error occurred"); + } + } + + public async Task AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams) + { + var blockedUris = await _stateService.GetAutofillBlacklistedUrisAsync(); + var domain = CoreHelpers.GetHostname(assertCredentialParams.Origin); + if (blockedUris.Contains(domain)) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.UriBlockedError, + "Origin is blocked by the user"); + } + + if (!await _stateService.IsAuthenticatedAsync()) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.InvalidStateError, + "No user is logged in"); + } + + if (assertCredentialParams.Origin == _environmentService.GetWebVaultUrl()) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.NotAllowedError, + "Saving Bitwarden credentials in a Bitwarden vault is not allowed"); + } + + if (!assertCredentialParams.Origin.StartsWith("https://")) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.SecurityError, + "Origin is not a valid https origin"); + } + + if (!Fido2DomainUtils.IsValidRpId(assertCredentialParams.RpId, assertCredentialParams.Origin)) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.SecurityError, + "RP ID cannot be used with this origin"); + } + + var clientDataJSON = JsonSerializer.Serialize(new { + type = "webauthn.get", + challenge = CoreHelpers.Base64UrlEncode(assertCredentialParams.Challenge), + origin = assertCredentialParams.Origin, + crossOrigin = !assertCredentialParams.SameOriginWithAncestors, + }); + var clientDataJSONBytes = Encoding.UTF8.GetBytes(clientDataJSON); + var clientDataHash = await _cryptoFunctionService.HashAsync(clientDataJSONBytes, CryptoHashAlgorithm.Sha256); + var getAssertionParams = MapToGetAssertionParams(assertCredentialParams, clientDataHash); + + try { + var getAssertionResult = await _fido2AuthenticatorService.GetAssertionAsync(getAssertionParams, _getAssertionUserInterface); + + return new Fido2ClientAssertCredentialResult { + AuthenticatorData = getAssertionResult.AuthenticatorData, + ClientDataJSON = clientDataJSONBytes, + Id = CoreHelpers.Base64UrlEncode(getAssertionResult.SelectedCredential.Id), + RawId = getAssertionResult.SelectedCredential.Id, + Signature = getAssertionResult.Signature, + UserHandle = getAssertionResult.SelectedCredential.UserHandle + }; + } catch (InvalidStateError) { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.InvalidStateError, "Unknown invalid state encountered"); + } catch (Exception) { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.UnknownError, $"An unknown error occurred"); + } + + throw new NotImplementedException(); + } + + private Fido2AuthenticatorMakeCredentialParams MapToMakeCredentialParams( + Fido2ClientCreateCredentialParams createCredentialParams, + PublicKeyCredentialParameters[] credTypesAndPubKeyAlgs, + byte[] clientDataHash) + { + var requireResidentKey = createCredentialParams.AuthenticatorSelection?.ResidentKey == "required" || + createCredentialParams.AuthenticatorSelection?.ResidentKey == "preferred" || + (createCredentialParams.AuthenticatorSelection?.ResidentKey == null && + createCredentialParams.AuthenticatorSelection?.RequireResidentKey == true); + + var requireUserVerification = createCredentialParams.AuthenticatorSelection?.UserVerification == "required" || + createCredentialParams.AuthenticatorSelection?.UserVerification == "preferred" || + createCredentialParams.AuthenticatorSelection?.UserVerification == null; + + return new Fido2AuthenticatorMakeCredentialParams { + RequireResidentKey = requireResidentKey, + RequireUserVerification = requireUserVerification, + ExcludeCredentialDescriptorList = createCredentialParams.ExcludeCredentials, + CredTypesAndPubKeyAlgs = credTypesAndPubKeyAlgs, + Hash = clientDataHash, + RpEntity = createCredentialParams.Rp, + UserEntity = createCredentialParams.User, + Extensions = createCredentialParams.Extensions + }; + } + + private Fido2AuthenticatorGetAssertionParams MapToGetAssertionParams( + Fido2ClientAssertCredentialParams assertCredentialParams, + byte[] cliendDataHash) + { + var requireUserVerification = assertCredentialParams.UserVerification == "required" || + assertCredentialParams.UserVerification == "preferred" || + assertCredentialParams.UserVerification == null; + + return new Fido2AuthenticatorGetAssertionParams { + RpId = assertCredentialParams.RpId, + Challenge = assertCredentialParams.Challenge, + AllowCredentialDescriptorList = assertCredentialParams.AllowCredentials, + RequireUserVerification = requireUserVerification, + Hash = cliendDataHash + }; + } + } +} diff --git a/src/Core/Services/PclCryptoFunctionService.cs b/src/Core/Services/PclCryptoFunctionService.cs index 50c555065..4254fa5d5 100644 --- a/src/Core/Services/PclCryptoFunctionService.cs +++ b/src/Core/Services/PclCryptoFunctionService.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading.Tasks; using Bit.Core.Abstractions; using Bit.Core.Enums; +using Bit.Core.Models.Domain; using PCLCrypto; using static PCLCrypto.WinRTCrypto; diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 95ba708c2..4041ca1d4 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -38,12 +38,38 @@ namespace Bit.Core.Utilities #endif } + /// + /// Returns the host (and not port) of the given uri. + /// Does not support plain hostnames without a protocol. + /// + /// Input => Output examples: + /// https://bitwarden.com => bitwarden.com + /// https://login.bitwarden.com:1337 => login.bitwarden.com + /// https://sub.login.bitwarden.com:1337 => sub.login.bitwarden.com + /// https://localhost:8080 => localhost + /// localhost => null + /// bitwarden => null + /// 127.0.0.1 => 127.0.0.1 + /// public static string GetHostname(string uriString) { var uri = GetUri(uriString); return string.IsNullOrEmpty(uri?.Host) ? null : uri.Host; } + /// + /// Returns the host and port of the given uri. + /// Does not support plain hostnames without + /// + /// Input => Output examples: + /// https://bitwarden.com => bitwarden.com + /// https://login.bitwarden.com:1337 => login.bitwarden.com:1337 + /// https://sub.login.bitwarden.com:1337 => sub.login.bitwarden.com:1337 + /// https://localhost:8080 => localhost:8080 + /// localhost => null + /// bitwarden => null + /// 127.0.0.1 => 127.0.0.1 + /// public static string GetHost(string uriString) { var uri = GetUri(uriString); @@ -61,6 +87,19 @@ namespace Bit.Core.Utilities return null; } + /// + /// Returns the second and top level domain of the given uri. + /// Does not support plain hostnames without + /// + /// Input => Output examples: + /// https://bitwarden.com => bitwarden.com + /// https://login.bitwarden.com:1337 => bitwarden.com + /// https://sub.login.bitwarden.com:1337 => bitwarden.com + /// https://localhost:8080 => localhost + /// localhost => null + /// bitwarden => null + /// 127.0.0.1 => 127.0.0.1 + /// public static string GetDomain(string uriString) { var uri = GetUri(uriString); diff --git a/src/Core/Utilities/Fido2/AuthenticatorSelectionCriteria.cs b/src/Core/Utilities/Fido2/AuthenticatorSelectionCriteria.cs new file mode 100644 index 000000000..d988eb07e --- /dev/null +++ b/src/Core/Utilities/Fido2/AuthenticatorSelectionCriteria.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.Utilities.Fido2 +{ + #nullable enable + /// + /// The Relying Party's requirements of the authenticator used in the creation of the credential. + /// + public class AuthenticatorSelectionCriteria + { + public bool? RequireResidentKey { get; set; } + public string? ResidentKey { get; set; } + public string UserVerification { get; set; } = "preferred"; + + /// + /// This member is intended for use by Relying Parties that wish to select the appropriate authenticators to participate in the create() operation. + /// + // public AuthenticatorAttachment? AuthenticatorAttachment { get; set; } // not used + } +} diff --git a/src/Core/Utilities/Fido2/Fido2AlgorithmIdentifier.cs b/src/Core/Utilities/Fido2/Fido2AlgorithmIdentifier.cs new file mode 100644 index 000000000..470c67e28 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2AlgorithmIdentifier.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public enum Fido2AlgorithmIdentifier : int + { + ES256 = -7, + RS256 = -257, + } +} 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/Fido2AuthenticatorException.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorException.cs new file mode 100644 index 000000000..be9fbee46 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorException.cs @@ -0,0 +1,37 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public class Fido2AuthenticatorException : Exception + { + public Fido2AuthenticatorException(string message) : base(message) + { + } + } + + public class NotAllowedError : Fido2AuthenticatorException + { + public NotAllowedError() : base("NotAllowedError") + { + } + } + + public class NotSupportedError : Fido2AuthenticatorException + { + public NotSupportedError() : base("NotSupportedError") + { + } + } + + public class InvalidStateError : Fido2AuthenticatorException + { + public InvalidStateError() : base("InvalidStateError") + { + } + } + + public class UnknownError : Fido2AuthenticatorException + { + public UnknownError() : base("UnknownError") + { + } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionParams.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionParams.cs index ab6fe1e3a..1c6b10d89 100644 --- a/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionParams.cs +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionParams.cs @@ -2,11 +2,25 @@ { public class Fido2AuthenticatorGetAssertionParams { + /** The caller’s RP ID, as determined by the user agent and the client. */ public string RpId { get; set; } - public string CredentialId { get; set; } + /** The hash of the serialized client data, provided by the client. */ + public byte[] Hash { get; set; } - public string Counter { get; set; } + public PublicKeyCredentialDescriptor[] AllowCredentialDescriptorList { get; set; } + + /// + /// Instructs the authenticator to require a user-verifying gesture in order to complete the request. Examples of such gestures are fingerprint scan or a PIN. + /// + public bool RequireUserVerification { get; set; } + + /// + /// The challenge to be signed by the authenticator. + /// + public byte[] Challenge { get; set; } + + public object Extensions { get; set; } } } diff --git a/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionResult.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionResult.cs index 845931143..70331029b 100644 --- a/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionResult.cs +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorGetAssertionResult.cs @@ -1,11 +1,19 @@ -using System; -namespace Bit.Core.Utilities.Fido2 +namespace Bit.Core.Utilities.Fido2 { public class Fido2AuthenticatorGetAssertionResult { public byte[] AuthenticatorData { get; set; } public byte[] Signature { get; set; } + + public Fido2AuthenticatorGetAssertionSelectedCredential SelectedCredential { get; set; } + } + + public class Fido2AuthenticatorGetAssertionSelectedCredential { + public byte[] Id { get; set; } + + #nullable enable + public byte[]? UserHandle { get; set; } } } diff --git a/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialParams.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialParams.cs new file mode 100644 index 000000000..54bb493cd --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialParams.cs @@ -0,0 +1,53 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public class Fido2AuthenticatorMakeCredentialParams + { + /// + /// The Relying Party's PublicKeyCredentialRpEntity. + /// + public PublicKeyCredentialRpEntity RpEntity { get; set; } + + /// + /// The Relying Party's PublicKeyCredentialRpEntity. + /// + public PublicKeyCredentialUserEntity UserEntity { get; set; } + + /// + /// The hash of the serialized client data, provided by the client. + /// + public byte[] Hash { get; set; } + + /// + /// A sequence of pairs of PublicKeyCredentialType and public key algorithms (COSEAlgorithmIdentifier) requested by the Relying Party. This sequence is ordered from most preferred to least preferred. The authenticator makes a best-effort to create the most preferred credential that it can. + /// + public PublicKeyCredentialParameters[] CredTypesAndPubKeyAlgs { get; set; } + + /// + ///An OPTIONAL list of PublicKeyCredentialDescriptor objects provided by the Relying Party with the intention that, if any of these are known to the authenticator, it SHOULD NOT create a new credential. excludeCredentialDescriptorList contains a list of known credentials. + /// + public PublicKeyCredentialDescriptor[] ExcludeCredentialDescriptorList { get; set; } + + /// + /// The effective resident key requirement for credential creation, a Boolean value determined by the client. Resident is synonymous with discoverable. */ + /// + public bool RequireResidentKey { get; set; } + + /// + /// The effective user verification requirement for assertion, a Boolean value provided by the client. + /// + public bool RequireUserVerification { get; set; } + + /// + /// CTAP2 authenticators support setting this to false, but we only support the WebAuthn authenticator model which does not have that option. + /// + // public bool RequireUserPresence { get; set; } // Always required + + /// + /// The authenticator's attestation preference, a string provided by the client. This is a hint that the client gives to the authenticator about what kind of attestation statement it would like. The authenticator makes a best-effort to satisfy the preference. + /// Note: Attestation statements are not supported at this time. + /// + // public string AttestationPreference { get; set; } + + public object Extensions { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialResult.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialResult.cs new file mode 100644 index 000000000..a566e03c4 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialResult.cs @@ -0,0 +1,16 @@ + +namespace Bit.Core.Utilities.Fido2 +{ + public class Fido2AuthenticatorMakeCredentialResult + { + public byte[] CredentialId { get; set; } + + public byte[] AttestationObject { get; set; } + + public byte[] AuthData { get; set; } + + public byte[] PublicKey { get; set; } + + public int PublicKeyAlgorithm { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2ClientAssertCredentialParams.cs b/src/Core/Utilities/Fido2/Fido2ClientAssertCredentialParams.cs new file mode 100644 index 000000000..480d2902a --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2ClientAssertCredentialParams.cs @@ -0,0 +1,57 @@ +namespace Bit.Core.Utilities.Fido2 +{ + #nullable enable + + /// + /// Parameters for asserting a credential. + /// + /// This class is an extended version of the WebAuthn struct: + /// https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialrequestoptions + /// + public class Fido2ClientAssertCredentialParams + { + /// + /// A value which is true if and only if the caller’s environment settings object is same-origin with its ancestors. + /// It is false if caller is cross-origin. + /// + public bool SameOriginWithAncestors { get; set; } + + /// + /// The challenge that the selected authenticator signs, along with other data, when producing an authentication + /// assertion. + /// + public required byte[] Challenge { get; set; } + + /// + /// The relying party identifier claimed by the caller. If omitted, its value will be the CredentialsContainer + /// object's relevant settings object's origin's effective domain. + /// + public string RpId { get; set; } + + /// + /// The Relying Party's origin (e.g., "https://example.com"). + /// + public string Origin { get; set; } + + /// + /// A list of PublicKeyCredentialDescriptor objects representing public key credentials acceptable to the caller, + /// in descending order of the caller’s preference (the first item in the list is the most preferred credential, + /// and so on down the list). + /// + public PublicKeyCredentialDescriptor[] AllowCredentials { get; set; } = []; + + /// + /// The Relying Party's requirements regarding user verification for the get() operation. + /// + public string UserVerification { get; set; } = "preferred"; + + /// + /// This time, in milliseconds, that the caller is willing to wait for the call to complete. + /// This is treated as a hint, and MAY be overridden by the client. + /// + /// + /// This is not currently supported. + /// + public int? Timeout { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2ClientAssertCredentialResult.cs b/src/Core/Utilities/Fido2/Fido2ClientAssertCredentialResult.cs new file mode 100644 index 000000000..a80a76d95 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2ClientAssertCredentialResult.cs @@ -0,0 +1,42 @@ +namespace Bit.Core.Utilities.Fido2 +{ + /// + /// The result of asserting a credential. + /// + /// See: https://www.w3.org/TR/webauthn-2/#publickeycredential + /// + public class Fido2ClientAssertCredentialResult + { + /// + /// Base64url encoding of the credential identifer. + /// + public required string Id { get; set; } + + /// + /// The credential identifier. + /// + public required byte[] RawId { get; set; } + + /// + /// The JSON-compatible serialization of client datapassed to the authenticator by the client in + /// order to generate this assertion. + /// + public required byte[] ClientDataJSON { get; set; } + + /// + /// The authenticator data returned by the authenticator. + /// + public required byte[] AuthenticatorData { get; set; } + + /// + /// The raw signature returned from the authenticator. + /// + public required byte[] Signature { get; set; } + + /// + /// The user handle returned from the authenticator, or null if the authenticator did not + /// return a user handle. + /// + public byte[]? UserHandle { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2ClientAuthenticatorAssertionResponse.cs b/src/Core/Utilities/Fido2/Fido2ClientAuthenticatorAssertionResponse.cs new file mode 100644 index 000000000..ad343f1dc --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2ClientAuthenticatorAssertionResponse.cs @@ -0,0 +1,35 @@ +namespace Bit.Core.Utilities.Fido2 +{ + /// + /// This class represents an authenticator's response to a client's request for generation of a + /// new authentication assertion given the WebAuthn Relying Party's challenge. + /// This response contains a cryptographic signature proving possession of the credential private key, + /// and optionally evidence of user consent to a specific transaction. + /// + /// See: https://www.w3.org/TR/webauthn-2/#iface-authenticatorassertionresponse + /// + public class Fido2ClientAuthenticatorAssertionResponse + { + /// + /// The JSON-compatible serialization of client data passed to the authenticator by the client + /// in order to generate this assertion. The exact JSON serialization MUST be preserved, as the + /// hash of the serialized client data has been computed over it. + /// + public required byte[] ClientDataJSON { get; set; } + + /// + /// The authenticator data returned by the authenticator. + /// + public required byte[] AuthenticatorData { get; set; } + + /// + /// Raw signature returned from the authenticator. + /// + public required byte[] Signature { get; set; } + + /// + /// The user handle returned from the authenticator, or null if the authenticator did not return a user handle. + /// + public byte[] UserHandle { get; set; } = null; + } +} diff --git a/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialParams.cs b/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialParams.cs new file mode 100644 index 000000000..52adbfff0 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialParams.cs @@ -0,0 +1,75 @@ +namespace Bit.Core.Utilities.Fido2 +{ + #nullable enable + + /// + /// Parameters for creating a new credential. + /// + public class Fido2ClientCreateCredentialParams + { + /// + /// The Relaying Parties origin, see: https://html.spec.whatwg.org/multipage/browsers.html#concept-origin + /// + public required string Origin { get; set; } + + /// + /// A value which is true if and only if the caller’s environment settings object is same-origin with its ancestors. + /// It is false if caller is cross-origin. + /// + public bool SameOriginWithAncestors { get; set; } + + /// + /// The Relying Party's preference for attestation conveyance + /// + public string? Attestation { get; set; } = "none"; + + /// + /// The Relying Party's requirements of the authenticator used in the creation of the credential. + /// + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } + + /// + /// Challenge intended to be used for generating the newly created credential's attestation object. + /// + public required byte[] Challenge { get; set; } // base64url encoded + + /// + /// This member is intended for use by Relying Parties that wish to limit the creation of multiple credentials for + /// the same account on a single authenticator. The client is requested to return an error if the new credential would + /// be created on an authenticator that also contains one of the credentials enumerated in this parameter. + /// + public PublicKeyCredentialDescriptor[]? ExcludeCredentials { get; set; } + + /// + /// This member contains additional parameters requesting additional processing by the client and authenticator. + /// Not currently supported. + /// + public object? Extensions { get; set; } + + /// + /// This member contains information about the desired properties of the credential to be created. + /// The sequence is ordered from most preferred to least preferred. + /// The client makes a best-effort to create the most preferred credential that it can. + /// + public required PublicKeyCredentialParameters[] PubKeyCredParams { get; set; } + + /// + /// Data about the Relying Party responsible for the request. + /// + public required PublicKeyCredentialRpEntity Rp { get; set; } + + /// + /// Data about the user account for which the Relying Party is requesting attestation. + /// + public required PublicKeyCredentialUserEntity User { get; set; } + + /// + /// This member specifies a time, in milliseconds, that the caller is willing to wait for the call to complete. + /// This is treated as a hint, and MAY be overridden by the client. + /// + /// + /// This is not currently supported. + /// + public int? Timeout { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialResult.cs b/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialResult.cs new file mode 100644 index 000000000..21cd90357 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialResult.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Utilities.Fido2 +{ + /// + /// The result of creating a new credential. + /// + /// This class is an extended version of the WebAuthn struct: + /// https://www.w3.org/TR/webauthn-3/#credentialcreationdata-attestationobjectresult + /// + public class Fido2ClientCreateCredentialResult + { + public byte[] CredentialId { get; set; } + public byte[] ClientDataJSON { get; set; } + public byte[] AttestationObject { get; set; } + public byte[] AuthData { get; set; } + public byte[] PublicKey { get; set; } + public int PublicKeyAlgorithm { get; set; } + public string[] Transports { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2ClientException.cs b/src/Core/Utilities/Fido2/Fido2ClientException.cs new file mode 100644 index 000000000..8b3412d1c --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2ClientException.cs @@ -0,0 +1,25 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public class Fido2ClientException : Exception + { + public enum ErrorCode + { + NotAllowedError, + TypeError, + SecurityError, + UriBlockedError, + NotSupportedError, + InvalidStateError, + UnknownError + } + + public ErrorCode Code { get; } + public string Reason { get; } + + public Fido2ClientException(ErrorCode code, string reason) : base($"{code} ({reason})") + { + Code = code; + Reason = reason; + } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2DomainUtils.cs b/src/Core/Utilities/Fido2/Fido2DomainUtils.cs new file mode 100644 index 000000000..db5a84985 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2DomainUtils.cs @@ -0,0 +1,40 @@ +using System.Text.RegularExpressions; + +namespace Bit.Core.Utilities.Fido2 +{ + public class Fido2DomainUtils + { + // Loosely based on: + // https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to + public static bool IsValidRpId(string rpId, string origin) + { + if (rpId == null || rpId == "" || origin == null) + { + return false; + } + + // We only care about the domain part of the origin, not the protocol or port so we remove them here, + // while still keeping ipv6 intact. + // https is enforced in the client, so we don't need to worry about that here + var originWithoutProtocolOrPort = Regex.Replace(origin, @"(https?://)?([^:/]+)(:\d+)?(/.*)?", "$2$4"); + if (Uri.CheckHostName(rpId) != UriHostNameType.Dns || Uri.CheckHostName(originWithoutProtocolOrPort) != UriHostNameType.Dns) + { + return false; + } + + if (rpId == originWithoutProtocolOrPort) + { + return true; + } + + if (!DomainName.TryParse(rpId, out var parsedRpId) || !DomainName.TryParse(originWithoutProtocolOrPort, out var parsedOrgin)) + { + return false; + } + + return parsedOrgin.Tld == parsedRpId.Tld && + parsedOrgin.Domain == parsedRpId.Domain && + (parsedOrgin.SubDomain == parsedRpId.SubDomain || parsedOrgin.SubDomain.EndsWith(parsedRpId.SubDomain)); + } + } +} diff --git a/src/Core/Utilities/Fido2/Fido2GetAssertionUserInterface.cs b/src/Core/Utilities/Fido2/Fido2GetAssertionUserInterface.cs new file mode 100644 index 000000000..372ac04e5 --- /dev/null +++ b/src/Core/Utilities/Fido2/Fido2GetAssertionUserInterface.cs @@ -0,0 +1,52 @@ +using Bit.Core.Abstractions; + +namespace Bit.Core.Utilities.Fido2 +{ + /// + /// This implementation is used when all interactions are handled by the operating system. + /// Most often the user has already picked a credential by the time the Authenticator is called, + /// so this class just returns those values. + /// + /// This class has no corresponding attestation variant, because that operation requires that the + /// user interacts with the app directly. + /// + public class Fido2GetAssertionUserInterface : IFido2GetAssertionUserInterface + { + private readonly string _cipherId; + private readonly bool _userVerified = false; + private readonly Func _ensureUnlockedVaultCallback; + private readonly Func> _verifyUserCallback; + + /// The cipherId for the credential that the user has already picker + /// True if the user has already been verified by the operating system + public Fido2GetAssertionUserInterface(string cipherId, bool userVerified, Func ensureUnlockedVaultCallback, Func> verifyUserCallback) + { + _cipherId = cipherId; + _userVerified = userVerified; + _ensureUnlockedVaultCallback = ensureUnlockedVaultCallback; + _verifyUserCallback = verifyUserCallback; + } + + public async Task<(string CipherId, bool UserVerified)> PickCredentialAsync(Fido2GetAssertionUserInterfaceCredential[] credentials) + { + if (credentials.Length == 0 || !credentials.Any(c => c.CipherId == _cipherId)) + { + throw new NotAllowedError(); + } + + var credential = credentials.First(c => c.CipherId == _cipherId); + var verified = _userVerified; + if (credential.RequireUserVerification && !verified) + { + verified = await _verifyUserCallback(); + } + + return (CipherId: _cipherId, UserVerified: verified); + } + + public Task EnsureUnlockedVaultAsync() + { + return _ensureUnlockedVaultCallback(); + } + } +} diff --git a/src/Core/Utilities/Fido2/PublicKeyCredentialAlgorithmDescriptor.cs b/src/Core/Utilities/Fido2/PublicKeyCredentialAlgorithmDescriptor.cs new file mode 100644 index 000000000..47c0b60fc --- /dev/null +++ b/src/Core/Utilities/Fido2/PublicKeyCredentialAlgorithmDescriptor.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public class PublicKeyCredentialAlgorithmDescriptor { + public byte[] Id {get; set;} + public string[] Transports; + public string Type; + public int Algorithm; + } +} diff --git a/src/Core/Utilities/Fido2/PublicKeyCredentialDescriptor.cs b/src/Core/Utilities/Fido2/PublicKeyCredentialDescriptor.cs new file mode 100644 index 000000000..16fa9e698 --- /dev/null +++ b/src/Core/Utilities/Fido2/PublicKeyCredentialDescriptor.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public class PublicKeyCredentialDescriptor { + public byte[] Id { get; set; } + public string[] Transports { get; set; } + public string Type { get; set; } + } +} + diff --git a/src/Core/Utilities/Fido2/PublicKeyCredentialParameters.cs b/src/Core/Utilities/Fido2/PublicKeyCredentialParameters.cs new file mode 100644 index 000000000..af3e00d23 --- /dev/null +++ b/src/Core/Utilities/Fido2/PublicKeyCredentialParameters.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.Utilities.Fido2 +{ + /// + /// A description of a key type and algorithm. + /// + public class PublicKeyCredentialParameters + { + public string Type { get; set; } + + /// + /// Cose algorithm identifier, e.g. -7 for ES256. + /// + public int Alg { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/PublicKeyCredentialRpEntity.cs b/src/Core/Utilities/Fido2/PublicKeyCredentialRpEntity.cs new file mode 100644 index 000000000..abbf52cb0 --- /dev/null +++ b/src/Core/Utilities/Fido2/PublicKeyCredentialRpEntity.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public class PublicKeyCredentialRpEntity + { + public string Id { get; set; } + + public string Name { get; set; } + } +} diff --git a/src/Core/Utilities/Fido2/PublicKeyUserCredentialUserEntity.cs b/src/Core/Utilities/Fido2/PublicKeyUserCredentialUserEntity.cs new file mode 100644 index 000000000..d931d1a85 --- /dev/null +++ b/src/Core/Utilities/Fido2/PublicKeyUserCredentialUserEntity.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Utilities.Fido2 +{ + public class PublicKeyCredentialUserEntity { + public byte[] Id { get; set; } + public string Name { get; set; } + public string DisplayName { get; set; } + public string Icon { get; set; } + } +} diff --git a/src/Core/Utilities/GuidExtensions.cs b/src/Core/Utilities/GuidExtensions.cs new file mode 100644 index 000000000..0198ea1fd --- /dev/null +++ b/src/Core/Utilities/GuidExtensions.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Bit.Core.Utilities +{ + /// + /// Extension methods for converting between standard and raw GUID formats. + /// + /// Note: Not optimized for performance. Don't use in performance-critical code. + /// + public static class GuidExtensions + { + public static byte[] GuidToRawFormat(this string guidString) + { + if (guidString == null) + { + throw new ArgumentException("GUID parameter is null", nameof(guidString)); + } + + if (!IsValidGuid(guidString)) { + throw new FormatException("GUID parameter is invalid"); + } + + var arr = new byte[16]; + + arr[0] = byte.Parse(guidString.Substring(0, 2), NumberStyles.HexNumber); // Parse ##......-....-....-....-............ + arr[1] = byte.Parse(guidString.Substring(2, 2), NumberStyles.HexNumber); // Parse ..##....-....-....-....-............ + arr[2] = byte.Parse(guidString.Substring(4, 2), NumberStyles.HexNumber); // Parse ....##..-....-....-....-............ + arr[3] = byte.Parse(guidString.Substring(6, 2), NumberStyles.HexNumber); // Parse ......##-....-....-....-............ + + arr[4] = byte.Parse(guidString.Substring(9, 2), NumberStyles.HexNumber); // Parse ........-##..-....-....-............ + arr[5] = byte.Parse(guidString.Substring(11, 2), NumberStyles.HexNumber); // Parse ........-..##-....-....-............ + + arr[6] = byte.Parse(guidString.Substring(14, 2), NumberStyles.HexNumber); // Parse ........-....-##..-....-............ + arr[7] = byte.Parse(guidString.Substring(16, 2), NumberStyles.HexNumber); // Parse ........-....-..##-....-............ + + arr[8] = byte.Parse(guidString.Substring(19, 2), NumberStyles.HexNumber); // Parse ........-....-....-##..-............ + arr[9] = byte.Parse(guidString.Substring(21, 2), NumberStyles.HexNumber); // Parse ........-....-....-..##-............ + + arr[10] = byte.Parse(guidString.Substring(24, 2), NumberStyles.HexNumber); // Parse ........-....-....-....-##.......... + arr[11] = byte.Parse(guidString.Substring(26, 2), NumberStyles.HexNumber); // Parse ........-....-....-....-..##........ + arr[12] = byte.Parse(guidString.Substring(28, 2), NumberStyles.HexNumber); // Parse ........-....-....-....-....##...... + arr[13] = byte.Parse(guidString.Substring(30, 2), NumberStyles.HexNumber); // Parse ........-....-....-....-......##.... + arr[14] = byte.Parse(guidString.Substring(32, 2), NumberStyles.HexNumber); // Parse ........-....-....-....-........##.. + arr[15] = byte.Parse(guidString.Substring(34, 2), NumberStyles.HexNumber); // Parse ........-....-....-....-..........## + + return arr; + } + + public static string GuidToStandardFormat(this byte[] guidBytes) + { + if (guidBytes == null) + { + throw new ArgumentException("GUID parameter is null", nameof(guidBytes)); + } + + if (guidBytes.Length != 16) + { + throw new ArgumentException("Invalid raw GUID format", nameof(guidBytes)); + } + + return Convert.ToHexString(guidBytes).ToLower().Insert(8, "-").Insert(13, "-").Insert(18, "-").Insert(23, "-" ); + } + + public static bool IsValidGuid(string guid) + { + return Regex.IsMatch(guid, @"^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$", RegexOptions.ECMAScript); + } + } +} diff --git a/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs b/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs index 3e06749ac..4cbac99f1 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs @@ -33,22 +33,22 @@ namespace Bit.iOS.Autofill return; } - // TODO: Generate the credential Signature and Auth data accordingly - var fido2AssertionResult = await _fido2AuthService.Value.GetAssertionAsync(new Bit.Core.Utilities.Fido2.Fido2AuthenticatorGetAssertionParams - { - RpId = cipherView.Login.MainFido2Credential.RpId, - Counter = cipherView.Login.MainFido2Credential.Counter, - CredentialId = cipherView.Login.MainFido2Credential.CredentialId - }); + // // TODO: Generate the credential Signature and Auth data accordingly + // var fido2AssertionResult = await _fido2AuthService.Value.GetAssertionAsync(new Bit.Core.Utilities.Fido2.Fido2AuthenticatorGetAssertionParams + // { + // RpId = cipherView.Login.MainFido2Credential.RpId, + // Counter = cipherView.Login.MainFido2Credential.Counter, + // CredentialId = cipherView.Login.MainFido2Credential.CredentialId + // }); - CompleteAssertionRequest(new ASPasskeyAssertionCredential( - cipherView.Login.MainFido2Credential.UserHandle, - cipherView.Login.MainFido2Credential.RpId, - NSData.FromArray(fido2AssertionResult.Signature), - _context.PasskeyCredentialRequest?.ClientDataHash, - NSData.FromArray(fido2AssertionResult.AuthenticatorData), - cipherView.Login.MainFido2Credential.CredentialId - )); + // CompleteAssertionRequest(new ASPasskeyAssertionCredential( + // cipherView.Login.MainFido2Credential.UserHandle, + // cipherView.Login.MainFido2Credential.RpId, + // NSData.FromArray(fido2AssertionResult.Signature), + // _context.PasskeyCredentialRequest?.ClientDataHash, + // NSData.FromArray(fido2AssertionResult.AuthenticatorData), + // cipherView.Login.MainFido2Credential.CredentialId + // )); } public void CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential) diff --git a/src/iOS.Core/Utilities/ASHelpers.cs b/src/iOS.Core/Utilities/ASHelpers.cs index 4072ad5fa..0f2a0fdf0 100644 --- a/src/iOS.Core/Utilities/ASHelpers.cs +++ b/src/iOS.Core/Utilities/ASHelpers.cs @@ -139,7 +139,7 @@ namespace Bit.iOS.Core.Utilities return ToPasswordCredentialIdentity(cipher); } - if (!cipher.Login.MainFido2Credential.IsDiscoverable) + if (!cipher.Login.MainFido2Credential.DiscoverableValue) { return null; } diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index 6062ea90c..3e99ced0e 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -13,6 +13,7 @@ + all diff --git a/test/Core.Test/Services/Fido2AuthenticatorGetAssertionTests.cs b/test/Core.Test/Services/Fido2AuthenticatorGetAssertionTests.cs new file mode 100644 index 000000000..96964ba20 --- /dev/null +++ b/test/Core.Test/Services/Fido2AuthenticatorGetAssertionTests.cs @@ -0,0 +1,343 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Services; +using Bit.Core.Models.Domain; +using Bit.Core.Models.View; +using Bit.Core.Enums; +using Bit.Core.Utilities.Fido2; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using Bit.Core.Utilities; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; + +namespace Bit.Core.Test.Services +{ + public class Fido2AuthenticatorGetAssertionTests : IDisposable + { + private readonly string _rpId = "bitwarden.com"; + private readonly SutProvider _sutProvider = new SutProvider().Create(); + private readonly IFido2GetAssertionUserInterface _userInterface = Substitute.For(); + + private List _credentialIds; + private List _rawCredentialIds; + private List _ciphers; + private Fido2AuthenticatorGetAssertionParams _params; + private CipherView _selectedCipher; + private string _selectedCipherCredentialId; + private byte[] _selectedCipherRawCredentialId; + + /// + /// Sets up a working environment for the tests. + /// + public Fido2AuthenticatorGetAssertionTests() + { + _credentialIds = [ + "2a346a27-02c5-4967-ae9e-8a090a1a8ef3", + "924e812b-540e-445f-a2fc-b392a1bf9f27", + "547d7aea-0d0e-493c-bf86-d8587e730dc1", + "c07c71c4-030f-4e24-b284-c853aad72e2b" + ]; + _rawCredentialIds = [ + [0x2a, 0x34, 0x6a, 0x27, 0x02, 0xc5, 0x49, 0x67, 0xae, 0x9e, 0x8a, 0x09, 0x0a, 0x1a, 0x8e, 0xf3], + [0x92, 0x4e, 0x81, 0x2b, 0x54, 0x0e, 0x44, 0x5f, 0xa2, 0xfc, 0xb3, 0x92, 0xa1, 0xbf, 0x9f, 0x27], + [0x54, 0x7d, 0x7a, 0xea, 0x0d, 0x0e, 0x49, 0x3c, 0xbf, 0x86, 0xd8, 0x58, 0x7e, 0x73, 0x0d, 0xc1], + [0xc0, 0x7c, 0x71, 0xc4, 0x03, 0x0f, 0x4e, 0x24, 0xb2, 0x84, 0xc8, 0x53, 0xaa, 0xd7, 0x2e, 0x2b] + ]; + _ciphers = [ + CreateCipherView(_credentialIds[0].ToString(), _rpId, false, false), + CreateCipherView(_credentialIds[1].ToString(), _rpId, true, true), + ]; + _selectedCipher = _ciphers[0]; + _selectedCipherCredentialId = _credentialIds[0]; + _selectedCipherRawCredentialId = _rawCredentialIds[0]; + _params = CreateParams( + rpId: _rpId, + allowCredentialDescriptorList: [ + new PublicKeyCredentialDescriptor { + Id = _rawCredentialIds[0], + Type = Constants.DefaultFido2CredentialType + }, + new PublicKeyCredentialDescriptor { + Id = _rawCredentialIds[1], + Type = Constants.DefaultFido2CredentialType + }, + ], + requireUserVerification: false + ); + _sutProvider.GetDependency().GetAllDecryptedAsync().Returns(_ciphers); + _userInterface.PickCredentialAsync(Arg.Any()).Returns((_ciphers[0].Id, false)); + } + + public void Dispose() + { + } + + #region missing non-discoverable credential + + [Fact] + // Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. + public async Task GetAssertionAsync_ThrowsNotAllowed_NoCredentialsExists() + { + // Arrange + _ciphers.Clear(); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface)); + } + + [Fact] + public async Task GetAssertionAsync_ThrowsNotAllowed_CredentialExistsButRpIdDoesNotMatch() + { + // Arrange + _params.RpId = "mismatch-rpid"; + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface)); + } + + #endregion + + #region vault contains credential + + [Fact] + public async Task GetAssertionAsync_AsksForAllCredentials_ParamsContainsAllowedCredentialsList() + { + // Arrange + _params.AllowCredentialDescriptorList = [ + new PublicKeyCredentialDescriptor { + Id = _rawCredentialIds[0], + Type = Constants.DefaultFido2CredentialType + }, + new PublicKeyCredentialDescriptor { + Id = _rawCredentialIds[1], + Type = Constants.DefaultFido2CredentialType + }, + ]; + + // Act + await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface); + + // Assert + await _userInterface.Received().PickCredentialAsync(Arg.Is( + (credentials) => credentials.Select(c => c.CipherId).SequenceEqual(_ciphers.Select((c) => c.Id)) + )); + } + + [Fact] + public async Task GetAssertionAsync_AsksForDiscoverableCredentials_ParamsDoesNotContainAllowedCredentialsList() + { + // Arrange + _params.AllowCredentialDescriptorList = null; + var discoverableCiphers = _ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList(); + _userInterface.PickCredentialAsync(Arg.Any()).Returns((discoverableCiphers[0].Id, true)); + + // Act + await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface); + + // Assert + await _userInterface.Received().PickCredentialAsync(Arg.Is( + (credentials) => credentials.Select(c => c.CipherId).SequenceEqual(discoverableCiphers.Select((c) => c.Id)) + )); + } + + [Fact] + // Spec: Prompt the user to select a public key credential source `selectedCredential` from `credentialOptions`. + // If requireUserVerification is true, the authorization gesture MUST include user verification. + public async Task GetAssertionAsync_RequestsUserVerification_ParamsRequireUserVerification() { + // Arrange + _params.RequireUserVerification = true; + _userInterface.PickCredentialAsync(Arg.Any()).Returns((_ciphers[0].Id, true)); + + // Act + await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface); + + // Assert + await _userInterface.Received().PickCredentialAsync(Arg.Is( + (credentials) => credentials.All((c) => c.RequireUserVerification == true) + )); + } + + [Fact] + // Spec: Prompt the user to select a public key credential source `selectedCredential` from `credentialOptions`. + // If `requireUserPresence` is true, the authorization gesture MUST include a test of user presence. + // Comment: User presence is implied by the UI returning a credential. + // Extension: UserVerification is required if the cipher requires reprompting. + public async Task GetAssertionAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification() { + // Arrange + _params.RequireUserVerification = false; + + // Act + await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface); + + // Assert + await _userInterface.Received().PickCredentialAsync(Arg.Is( + (credentials) => credentials.Select(c => c.RequireUserVerification).SequenceEqual(_ciphers.Select((c) => c.Reprompt == CipherRepromptType.Password)) + )); + } + + [Fact] + // Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. + public async Task GetAssertionAsync_ThrowsNotAllowed_UserDoesNotConsent() { + // Arrange + _userInterface.PickCredentialAsync(Arg.Any()).Returns((null, false)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface)); + } + + [Fact] + // Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. + public async Task GetAssertionAsync_ThrowsNotAllowed_NoUserVerificationWhenRequired() { + // Arrange + _params.RequireUserVerification = true; + _userInterface.PickCredentialAsync(Arg.Any()).Returns((_selectedCipher.Id, false)); + + // Act and assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface)); + } + + [Fact] + // Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. + public async Task GetAssertionAsync_ThrowsNotAllowed_NoUserVerificationForCipherWithReprompt() { + // Arrange + _selectedCipher.Reprompt = CipherRepromptType.Password; + _params.RequireUserVerification = false; + _userInterface.PickCredentialAsync(Arg.Any()).Returns((_selectedCipher.Id, false)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface)); + } + + #endregion + + #region assertion of credential + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] + // Spec: Increment the credential associated signature counter + public async Task GetAssertionAsync_IncrementsCounter_CounterIsLargerThanZero(Cipher encryptedCipher) { + // Arrange + _selectedCipher.Login.MainFido2Credential.CounterValue = 9000; + _sutProvider.GetDependency().EncryptAsync(_selectedCipher).Returns(encryptedCipher); + + // Act + await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface); + + // Assert + await _sutProvider.GetDependency().Received().SaveWithServerAsync(encryptedCipher); + await _sutProvider.GetDependency().Received().EncryptAsync(Arg.Is( + (cipher) => cipher.Login.MainFido2Credential.CounterValue == 9001 + )); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] + // Spec: Increment the credential associated signature counter + public async Task GetAssertionAsync_DoesNotIncrementsCounter_CounterIsZero(Cipher encryptedCipher) { + // Arrange + _selectedCipher.Login.MainFido2Credential.CounterValue = 0; + _sutProvider.GetDependency().EncryptAsync(_selectedCipher).Returns(encryptedCipher); + + // Act + await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface); + + // Assert + await _sutProvider.GetDependency().Received().SaveWithServerAsync(encryptedCipher); + await _sutProvider.GetDependency().Received().EncryptAsync(Arg.Is( + (cipher) => cipher.Login.MainFido2Credential.CounterValue == 0 + )); + } + + [Fact] + public async Task GetAssertionAsync_ReturnsAssertion() { + // Arrange + var keyPair = GenerateKeyPair(); + var rpIdHashMock = RandomBytes(32); + _params.Hash = RandomBytes(32); + _params.RequireUserVerification = true; + _selectedCipher.Login.MainFido2Credential.CounterValue = 9000; + _selectedCipher.Login.MainFido2Credential.KeyValue = CoreHelpers.Base64UrlEncode(keyPair.ExportPkcs8PrivateKey()); + _sutProvider.GetDependency().HashAsync(_params.RpId, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock); + _userInterface.PickCredentialAsync(Arg.Any()).Returns((_selectedCipher.Id, true)); + + // Act + var result = await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface); + + // Assert + var authData = result.AuthenticatorData; + var rpIdHash = authData.Take(32); + var flags = authData.Skip(32).Take(1); + var counter = authData.Skip(33).Take(4); + + Assert.Equal(_selectedCipherRawCredentialId, result.SelectedCredential.Id); + Assert.Equal(CoreHelpers.Base64UrlDecode(_selectedCipher.Login.MainFido2Credential.UserHandle), result.SelectedCredential.UserHandle); + Assert.Equal(rpIdHashMock, rpIdHash); + Assert.Equal(new byte[] { 0b00011101 }, flags); // UP = true, UV = true, BS = true, BE = true + Assert.Equal(new byte[] { 0, 0, 0x23, 0x29 }, counter); // 9001 in binary big-endian format + Assert.True(keyPair.VerifyData(authData.Concat(_params.Hash).ToArray(), result.Signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence), "Signature verification failed"); + } + + [Fact] + public async Task GetAssertionAsync_ThrowsUnknownError_SaveFails() { + // Arrange + _sutProvider.GetDependency().SaveWithServerAsync(Arg.Any()).Throws(new Exception()); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface)); + } + + #endregion + + private byte[] RandomBytes(int length) + { + var bytes = new byte[length]; + new Random().NextBytes(bytes); + return bytes; + } + + private ECDsa GenerateKeyPair() + { + var dsa = ECDsa.Create(); + dsa.GenerateKey(ECCurve.NamedCurves.nistP256); + + return dsa; + } + + #nullable enable + private CipherView CreateCipherView(string credentialId, string? rpId, bool? discoverable, bool reprompt = false) + { + return new CipherView { + Type = CipherType.Login, + Id = Guid.NewGuid().ToString(), + Reprompt = reprompt ? CipherRepromptType.Password : CipherRepromptType.None, + Login = new LoginView { + Fido2Credentials = new List { + new Fido2CredentialView { + CredentialId = credentialId, + RpId = rpId ?? "bitwarden.com", + Discoverable = discoverable.HasValue ? discoverable.ToString() : "true", + UserHandleValue = RandomBytes(32), + KeyValue = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgO4wC7AlY4eJP7uedRUJGYsAIJAd6gN1Vp7uJh6xXAp6hRANCAARGvr56F_t27DEG1Tzl-qJRhrTUtC7jOEbasAEEZcE3TiMqoWCan0sxKDPylhRYk-1qyrBC_feN1UtGWH57sROa" + } + } + } + }; + } + + private Fido2AuthenticatorGetAssertionParams CreateParams(string? rpId = null, byte[]? hash = null, PublicKeyCredentialDescriptor[]? allowCredentialDescriptorList = null, bool? requireUserPresence = null, bool? requireUserVerification = null) + { + return new Fido2AuthenticatorGetAssertionParams { + RpId = rpId ?? "bitwarden.com", + Hash = hash ?? RandomBytes(32), + AllowCredentialDescriptorList = allowCredentialDescriptorList ?? null, + RequireUserVerification = requireUserPresence ?? false + }; + } + } +} diff --git a/test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs b/test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs new file mode 100644 index 000000000..5e7141097 --- /dev/null +++ b/test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs @@ -0,0 +1,390 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Services; +using Bit.Core.Models.Domain; +using Bit.Core.Models.View; +using Bit.Core.Enums; +using Bit.Core.Utilities.Fido2; +using Bit.Test.Common.AutoFixture; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using Bit.Core.Utilities; +using System.Collections.Generic; +using System.Linq; +using System.Formats.Cbor; + +namespace Bit.Core.Test.Services +{ + public class Fido2AuthenticatorMakeCredentialTests : IDisposable + { + private readonly string _rpId = "bitwarden.com"; + private readonly SutProvider _sutProvider = new SutProvider().Create(); + private readonly IFido2MakeCredentialUserInterface _userInterface = Substitute.For(); + + private Fido2AuthenticatorMakeCredentialParams _params; + private List _credentialIds; + private List _rawCredentialIds; + private List _ciphers; + private Cipher _encryptedSelectedCipher; + private CipherView _selectedCipherView; + private string _selectedCipherCredentialId; + private byte[] _selectedCipherRawCredentialId; + + public Fido2AuthenticatorMakeCredentialTests() { + _credentialIds = new List { "21d6aa04-92bd-4def-bf81-33f046924599", "f70c01ca-d1bf-4704-86e1-b07573aa17fa" }; + _rawCredentialIds = [ + [0x21, 0xd6, 0xaa, 0x04, 0x92, 0xbd, 0x4d, 0xef, 0xbf, 0x81, 0x33, 0xf0, 0x46, 0x92, 0x45, 0x99], + [0xf7, 0x0c, 0x01, 0xca, 0xd1, 0xbf, 0x47, 0x04, 0x86, 0xe1, 0xb0, 0x75, 0x73, 0xaa, 0x17, 0xfa] + ]; + _ciphers = [ + CreateCipherView(true, _credentialIds[0], "bitwarden.com", false), + CreateCipherView(true, _credentialIds[1], "bitwarden.com", true) + ]; + _selectedCipherView = _ciphers[0]; + _selectedCipherCredentialId = _credentialIds[0]; + _selectedCipherRawCredentialId = _rawCredentialIds[0]; + _encryptedSelectedCipher = CreateCipher(); + _encryptedSelectedCipher.Id = _selectedCipherView.Id; + _params = new Fido2AuthenticatorMakeCredentialParams { + UserEntity = new PublicKeyCredentialUserEntity { + Id = RandomBytes(32), + Name = "test" + }, + RpEntity = new PublicKeyCredentialRpEntity { + Id = _rpId, + Name = "Bitwarden" + }, + CredTypesAndPubKeyAlgs = [ + new PublicKeyCredentialParameters { + Type = Constants.DefaultFido2CredentialType, + Alg = (int) Fido2AlgorithmIdentifier.ES256 + } + ], + RequireResidentKey = false, + RequireUserVerification = false, + ExcludeCredentialDescriptorList = null + }; + + _sutProvider.GetDependency().GetAllDecryptedAsync().Returns(_ciphers); + _sutProvider.GetDependency().EncryptAsync(Arg.Any()).Returns(_encryptedSelectedCipher); + _sutProvider.GetDependency().GetAsync(Arg.Is(_encryptedSelectedCipher.Id)).Returns(_encryptedSelectedCipher); + _userInterface.ConfirmNewCredentialAsync(Arg.Any()).Returns((_selectedCipherView.Id, false)); + + var cryptoServiceMock = Substitute.For(); + ServiceContainer.Register(typeof(CryptoService), cryptoServiceMock); + } + + public void Dispose() + { + ServiceContainer.Reset(); + } + + #region invalid input parameters + + [Fact] + // Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation. + public async Task MakeCredentialAsync_ThrowsNotSupported_NoSupportedAlgorithm() + { + // Arrange + _params.CredTypesAndPubKeyAlgs = [ + new PublicKeyCredentialParameters { + Type = Constants.DefaultFido2CredentialType, + Alg = -257 // RS256 which we do not support + } + ]; + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface)); + } + + #endregion + + #region vault contains excluded credential + + [Fact] + // Spec: collect an authorization gesture confirming user consent for creating a new credential. + // Deviation: Consent is not asked and the user is simply informed of the situation. + public async Task MakeCredentialAsync_InformsUser_ExcludedCredentialFound() + { + // Arrange + _params.ExcludeCredentialDescriptorList = [ + new PublicKeyCredentialDescriptor { + Type = Constants.DefaultFido2CredentialType, + Id = _rawCredentialIds[0] + } + ]; + + // Act + try + { + await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface); + } + catch {} + + // Assert + await _userInterface.Received().InformExcludedCredentialAsync(Arg.Is( + (c) => c.SequenceEqual(new string[] { _ciphers[0].Id }) + )); + } + + [Fact] + // Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. + public async Task MakeCredentialAsync_ThrowsNotAllowed_ExcludedCredentialFound() + { + _params.ExcludeCredentialDescriptorList = [ + new PublicKeyCredentialDescriptor { + Type = Constants.DefaultFido2CredentialType, + Id = _rawCredentialIds[0] + } + ]; + + await Assert.ThrowsAsync(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface)); + } + + [Fact] + // Deviation: Organization ciphers are not checked against excluded credentials, even if the user has access to them. + public async Task MakeCredentialAsync_DoesNotInformAboutExcludedCredential_ExcludedCredentialBelongsToOrganization() + { + _ciphers[0].OrganizationId = "someOrganizationId"; + _params.ExcludeCredentialDescriptorList = [ + new PublicKeyCredentialDescriptor { + Type = Constants.DefaultFido2CredentialType, + Id = _rawCredentialIds[0] + } + ]; + + await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface); + + await _userInterface.DidNotReceive().InformExcludedCredentialAsync(Arg.Any()); + } + + #endregion + + #region credential creation + + [Fact] + public async Task MakeCredentialAsync_RequestsUserVerification_ParamsRequireUserVerification() + { + // Arrange + _params.RequireUserVerification = true; + _userInterface.ConfirmNewCredentialAsync(Arg.Any()).Returns((_selectedCipherView.Id, true)); + + // Act + await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface); + + // Assert + await _userInterface.Received().ConfirmNewCredentialAsync(Arg.Is( + (p) => p.UserVerification == true + )); + } + + [Fact] + public async Task MakeCredentialAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification() + { + // Arrange + _params.RequireUserVerification = false; + + // Act + await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface); + + // Assert + await _userInterface.Received().ConfirmNewCredentialAsync(Arg.Is( + (p) => p.UserVerification == false + )); + } + + [Fact] + public async Task MakeCredentialAsync_SavesNewCredential_RequestConfirmedByUser() + { + // Arrange + _params.RequireResidentKey = true; + + // Act + await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface); + + // Assert + await _sutProvider.GetDependency().Received().EncryptAsync(Arg.Is( + (c) => + c.Login.MainFido2Credential.KeyType == Constants.DefaultFido2CredentialType && + c.Login.MainFido2Credential.KeyAlgorithm == Constants.DefaultFido2CredentialAlgorithm && + c.Login.MainFido2Credential.KeyCurve == Constants.DefaultFido2CredentialCurve && + c.Login.MainFido2Credential.RpId == _params.RpEntity.Id && + c.Login.MainFido2Credential.RpName == _params.RpEntity.Name && + c.Login.MainFido2Credential.UserHandle == CoreHelpers.Base64UrlEncode(_params.UserEntity.Id) && + c.Login.MainFido2Credential.UserName == _params.UserEntity.Name && + c.Login.MainFido2Credential.CounterValue == 0 && + // c.Login.MainFido2Credential.UserDisplayName == _params.UserEntity.DisplayName && + c.Login.MainFido2Credential.DiscoverableValue == true + )); + await _sutProvider.GetDependency().Received().SaveWithServerAsync(_encryptedSelectedCipher); + } + + [Fact] + // Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. + public async Task MakeCredentialAsync_ThrowsNotAllowed_RequestNotConfirmedByUser() + { + // Arrange + _userInterface.ConfirmNewCredentialAsync(Arg.Any()).Returns((null, false)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface)); + } + + [Fact] + public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationWhenRequiredByParams() + { + // Arrange + _params.RequireUserVerification = true; + _userInterface.ConfirmNewCredentialAsync(Arg.Any()).Returns((_encryptedSelectedCipher.Id, false)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface)); + } + + [Fact] + public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationForCipherWithReprompt() + { + // Arrange + _params.RequireUserVerification = false; + _encryptedSelectedCipher.Reprompt = CipherRepromptType.Password; + _userInterface.ConfirmNewCredentialAsync(Arg.Any()).Returns((_encryptedSelectedCipher.Id, false)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface)); + } + + [Fact] + public async Task MakeCredentialAsync_ThrowsUnknownError_SavingCipherFails() + { + // Arrange + _sutProvider.GetDependency().SaveWithServerAsync(Arg.Any()).Throws(new Exception("Error")); + + // Act & Assert + await Assert.ThrowsAsync(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface)); + } + + [Fact] + public async Task MakeCredentialAsync_ReturnsAttestation() + { + // Arrange + var rpIdHashMock = RandomBytes(32); + _sutProvider.GetDependency().HashAsync(_params.RpEntity.Id, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock); + CipherView generatedCipherView = null; + _sutProvider.GetDependency().EncryptAsync(Arg.Any()).Returns((call) => { + generatedCipherView = call.Arg(); + return _encryptedSelectedCipher; + }); + + // Act + var result = await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface); + + // Assert + var credentialIdBytes = generatedCipherView.Login.MainFido2Credential.CredentialId.GuidToRawFormat(); + var attestationObject = DecodeAttestationObject(result.AttestationObject); + Assert.Equal("none", attestationObject.Fmt); + + var authData = attestationObject.AuthData; + var rpIdHash = authData.Take(32).ToArray(); + var flags = authData.Skip(32).Take(1).ToArray(); + var counter = authData.Skip(33).Take(4).ToArray(); + var aaguid = authData.Skip(37).Take(16).ToArray(); + var credentialIdLength = authData.Skip(53).Take(2).ToArray(); + var credentialId = authData.Skip(55).Take(16).ToArray(); + // Unsure how to test public key + // const publicKey = authData.Skip(71).ToArray(); // Key data is 77 bytes long + + Assert.Equal(71 + 77, authData.Length); + Assert.Equal(rpIdHashMock, rpIdHash); + Assert.Equal([0b01011001], flags); // UP = true, AD = true, BS = true, BE = true + Assert.Equal([0, 0, 0, 0], counter); + Assert.Equal(Fido2AuthenticatorService.AAGUID, aaguid); + Assert.Equal([0, 16], credentialIdLength); // 16 bytes because we're using GUIDs + Assert.Equal(credentialIdBytes, credentialId); + } + + #endregion + + private byte[] RandomBytes(int length) + { + var bytes = new byte[length]; + new Random().NextBytes(bytes); + return bytes; + } + + #nullable enable + private CipherView CreateCipherView(bool? withFido2Credential, string credentialId, string? rpId = null, bool? discoverable = null) + { + return new CipherView { + Type = CipherType.Login, + Id = Guid.NewGuid().ToString(), + Reprompt = CipherRepromptType.None, + Login = new LoginView { + Fido2Credentials = withFido2Credential.HasValue && withFido2Credential.Value ? new List { + new Fido2CredentialView { + CredentialId = credentialId, + RpId = rpId ?? "bitwarden.com", + Discoverable = discoverable.HasValue ? discoverable.ToString() : "true", + UserHandleValue = RandomBytes(32) + } + } : null + } + }; + } + + private Cipher CreateCipher() + { + return new Cipher { + Id = Guid.NewGuid().ToString(), + Type = CipherType.Login, + Key = null, + Attachments = [], + Login = new Login {}, + }; + } + + private struct AttestationObject + { + public string? Fmt { get; set; } + public object? AttStmt { get; set; } + public byte[]? AuthData { get; set; } + } + + private AttestationObject DecodeAttestationObject(byte[] attestationObject) + { + string? fmt = null; + object? attStmt = null; + byte[]? authData = null; + + var reader = new CborReader(attestationObject, CborConformanceMode.Ctap2Canonical); + reader.ReadStartMap(); + + while (reader.BytesRemaining != 0) + { + var key = reader.ReadTextString(); + switch (key) + { + case "fmt": + fmt = reader.ReadTextString(); + break; + case "attStmt": + reader.ReadStartMap(); + reader.ReadEndMap(); + break; + case "authData": + authData = reader.ReadByteString(); + break; + default: + throw new Exception("Unknown key"); + } + } + + return new AttestationObject { + Fmt = fmt, + AttStmt = attStmt, + AuthData = authData + }; + } + } +} diff --git a/test/Core.Test/Services/Fido2AuthenticatorSilentCredentialDiscoveryTests.cs b/test/Core.Test/Services/Fido2AuthenticatorSilentCredentialDiscoveryTests.cs new file mode 100644 index 000000000..b55ba6a3d --- /dev/null +++ b/test/Core.Test/Services/Fido2AuthenticatorSilentCredentialDiscoveryTests.cs @@ -0,0 +1,124 @@ +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; +using Bit.Core.Utilities; + +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 = Constants.DefaultFido2CredentialType, + Id = c.Login.MainFido2Credential.CredentialId.GuidToRawFormat(), + 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); + } + } +} diff --git a/test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs b/test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs new file mode 100644 index 000000000..19b658c6f --- /dev/null +++ b/test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; +using Bit.Test.Common.AutoFixture; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Services +{ + public class Fido2ClientAssertCredentialTests : IDisposable + { + private readonly SutProvider _sutProvider = new SutProvider().Create(); + + private Fido2ClientAssertCredentialParams _params; + + public Fido2ClientAssertCredentialTests() + { + _params = new Fido2ClientAssertCredentialParams { + Origin = "https://bitwarden.com", + Challenge = RandomBytes(32), + RpId = "bitwarden.com", + UserVerification = "required", + AllowCredentials = [ + new PublicKeyCredentialDescriptor { + Id = RandomBytes(32), + Type = Constants.DefaultFido2CredentialType + } + ], + Timeout = 60000, + }; + + _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns([]); + _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(true); + } + + public void Dispose() + { + } + + [Fact(Skip = "Not sure how to check this, or if it matters.")] + // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. + public Task AssertCredentialAsync_ThrowsNotAllowedError_OriginIsOpaque() => throw new NotImplementedException(); + + [Fact] + // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, + // then return a DOMException whose name is "SecurityError" and terminate this algorithm. + public async Task AssertCredentialAsync_ThrowsSecurityError_OriginIsNotValidDomain() + { + // Arrange + _params.Origin = "invalid-domain-name"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); + } + + [Fact] + // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, + // return a DOMException whose name is "SecurityError", and terminate this algorithm. + public async Task AssertCredentialAsync_ThrowsSecurityError_RpIdIsNotValidForOrigin() + { + // Arrange + _params.Origin = "https://passwordless.dev"; + _params.RpId = "bitwarden.com"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); + } + + [Fact] + // Spec: The origin's scheme must be https. + public async Task AssertCredentialAsync_ThrowsSecurityError_OriginIsNotHttps() + { + // Arrange + _params.Origin = "http://bitwarden.com"; + _params.RpId = "bitwarden.com"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); + } + + [Fact] + // Spec: If the origin's hostname is a blocked uri, then return UriBlockedError. + public async Task AssertCredentialAsync_ThrowsUriBlockedError_OriginIsBlocked() + { + // Arrange + _params.Origin = "https://sub.bitwarden.com"; + _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns([ + "sub.bitwarden.com" + ]); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code); + } + + [Fact] + public async Task AssertCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError() + { + // Arrange + _sutProvider.GetDependency() + .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) + .Throws(new InvalidStateError()); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); + } + + [Fact] + // This keeps sensetive information form leaking + public async Task AssertCredentialAsync_ThrowsUnknownError_AuthenticatorThrowsUnknownError() + { + // Arrange + _sutProvider.GetDependency() + .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) + .Throws(new Exception("unknown error")); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code); + } + + [Fact] + public async Task AssertCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut() + { + // Arrange + _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(false); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); + } + + [Fact] + public async Task AssertCredentialAsync_ThrowsNotAllowedError_OriginIsBitwardenVault() + { + // Arrange + _params.Origin = "https://vault.bitwarden.com"; + _sutProvider.GetDependency().GetWebVaultUrl().Returns("https://vault.bitwarden.com"); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); + } + + [Fact] + public async Task AssertCredentialAsync_ReturnsAssertion() + { + // Arrange + _params.UserVerification = "required"; + var authenticatorResult = new Fido2AuthenticatorGetAssertionResult { + AuthenticatorData = RandomBytes(32), + SelectedCredential = new Fido2AuthenticatorGetAssertionSelectedCredential { + Id = RandomBytes(16), + UserHandle = RandomBytes(32) + }, + Signature = RandomBytes(32) + }; + _sutProvider.GetDependency() + .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) + .Returns(authenticatorResult); + + // Act + var result = await _sutProvider.Sut.AssertCredentialAsync(_params); + + // Assert + await _sutProvider.GetDependency() + .Received() + .GetAssertionAsync( + Arg.Is(x => + x.RpId == _params.RpId && + x.RequireUserVerification == true && + 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()); + } + + private byte[] RandomBytes(int length) + { + var bytes = new byte[length]; + new Random().NextBytes(bytes); + return bytes; + } + } +} diff --git a/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs b/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs new file mode 100644 index 000000000..0c8dd595b --- /dev/null +++ b/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs @@ -0,0 +1,308 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; +using Bit.Test.Common.AutoFixture; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Services +{ + public class Fido2ClientCreateCredentialTests : IDisposable + { + private readonly SutProvider _sutProvider = new SutProvider().Create(); + + private Fido2ClientCreateCredentialParams _params; + + public Fido2ClientCreateCredentialTests() + { + _params = new Fido2ClientCreateCredentialParams { + Origin = "https://bitwarden.com", + SameOriginWithAncestors = true, + Attestation = "none", + Challenge = RandomBytes(32), + PubKeyCredParams = [ + new PublicKeyCredentialParameters { + Type = Constants.DefaultFido2CredentialType, + Alg = (int) Fido2AlgorithmIdentifier.ES256 + } + ], + Rp = new PublicKeyCredentialRpEntity { + Id = "bitwarden.com", + Name = "Bitwarden" + }, + User = new PublicKeyCredentialUserEntity { + Id = RandomBytes(32), + Name = "user@bitwarden.com", + DisplayName = "User" + } + }; + + _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns([]); + _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(true); + } + + public void Dispose() + { + } + + [Fact] + // Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException. + public async Task CreateCredentialAsync_ThrowsNotAllowedError_SameOriginWithAncestorsIsFalse() + { + // Arrange + _params.SameOriginWithAncestors = false; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); + } + + [Fact] + // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. + public async Task CreateCredentialAsync_ThrowsTypeError_UserIdIsTooSmall() + { + // Arrange + _params.User.Id = RandomBytes(0); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code); + } + + [Fact] + // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. + public async Task CreateCredentialAsync_ThrowsTypeError_UserIdIsTooLarge() + { + // Arrange + _params.User.Id = RandomBytes(65); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code); + } + + [Fact(Skip = "Not sure how to check this, or if it matters.")] + // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. + public Task CreateCredentialAsync_ThrowsNotAllowedError_OriginIsOpaque() => throw new NotImplementedException(); + + [Fact] + // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, + // then return a DOMException whose name is "SecurityError" and terminate this algorithm. + public async Task CreateCredentialAsync_ThrowsSecurityError_OriginIsNotValidDomain() + { + // Arrange + _params.Origin = "invalid-domain-name"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); + } + + [Fact] + // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, + // return a DOMException whose name is "SecurityError", and terminate this algorithm. + public async Task CreateCredentialAsync_ThrowsSecurityError_RpIdIsNotValidForOrigin() + { + // Arrange + _params.Origin = "https://passwordless.dev"; + _params.Rp.Id = "bitwarden.com"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); + } + + [Fact] + // Spec: The origin's scheme must be https. + public async Task CreateCredentialAsync_ThrowsSecurityError_OriginIsNotHttps() + { + // Arrange + _params.Origin = "http://bitwarden.com"; + _params.Rp.Id = "bitwarden.com"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); + } + + [Fact] + // Spec: If the origin's hostname is a blocked uri, then return UriBlockedError. + public async Task CreateCredentialAsync_ThrowsUriBlockedError_OriginIsBlocked() + { + // Arrange + _params.Origin = "https://sub.bitwarden.com"; + _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns([ + "sub.bitwarden.com" + ]); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code); + } + + [Fact] + // Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm. + public async Task CreateCredentialAsync_ThrowsNotSupportedError_CredTypesAndPubKeyAlgsIsEmpty() + { + // Arrange + _params.PubKeyCredParams = [ + new PublicKeyCredentialParameters { + Type = "not-supported", + Alg = (int) Fido2AlgorithmIdentifier.ES256 + }, + new PublicKeyCredentialParameters { + Type = Constants.DefaultFido2CredentialType, + Alg = -9001 + } + ]; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.NotSupportedError, exception.Code); + } + + [Fact(Skip = "Not implemented")] + // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. + public Task CreateCredentialAsync_ThrowsAbortError_AbortedByCaller() => throw new NotImplementedException(); + + [Fact] + public async Task CreateCredentialAsync_ReturnsNewCredential() + { + // 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); + + // Act + var result = await _sutProvider.Sut.CreateCredentialAsync(_params); + + // Assert + await _sutProvider.GetDependency() + .Received() + .MakeCredentialAsync( + Arg.Is(x => + x.RequireResidentKey == true && + x.RequireUserVerification == true && + 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(["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()); + } + + [Fact] + public async Task CreateCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError() + { + // Arrange + _params.AuthenticatorSelection = new AuthenticatorSelectionCriteria { + ResidentKey = "required", + UserVerification = "required" + }; + _sutProvider.GetDependency() + .MakeCredentialAsync(Arg.Any(), _sutProvider.GetDependency()) + .Throws(new InvalidStateError()); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); + } + + [Fact] + // This keeps sensetive information form leaking + public async Task CreateCredentialAsync_ThrowsUnknownError_AuthenticatorThrowsUnknownError() + { + // Arrange + _sutProvider.GetDependency() + .MakeCredentialAsync(Arg.Any(), _sutProvider.GetDependency()) + .Throws(new Exception("unknown error")); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code); + } + + [Fact] + public async Task CreateCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut() + { + // Arrange + _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(false); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); + } + + [Fact] + public async Task CreateCredentialAsync_ThrowsNotAllowedError_OriginIsBitwardenVault() + { + // Arrange + _params.Origin = "https://vault.bitwarden.com"; + _sutProvider.GetDependency().GetWebVaultUrl().Returns("https://vault.bitwarden.com"); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); + } + + private byte[] RandomBytes(int length) + { + var bytes = new byte[length]; + new Random().NextBytes(bytes); + return bytes; + } + } +} diff --git a/test/Core.Test/Utilities/Fido2/Fido2DomainUtilsTests.cs b/test/Core.Test/Utilities/Fido2/Fido2DomainUtilsTests.cs new file mode 100644 index 000000000..67a777d68 --- /dev/null +++ b/test/Core.Test/Utilities/Fido2/Fido2DomainUtilsTests.cs @@ -0,0 +1,45 @@ +using Bit.Core.Utilities.Fido2; +using Xunit; + +namespace Bit.Core.Test.Utilities.Fido2 +{ + public class Fido2DomainUtilsTests + { + [Theory] + // From https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to + // [InlineData("0.0.0.0", "0.0.0.0", true)] // IP-addresses not allowed by WebAuthn spec + // [InlineData("0x10203", "0.1.2.3", true)] + // [InlineData("[0::1]", "::1", true)] + [InlineData("example.com", "example.com", true)] + [InlineData("example.com", "example.com.", false)] + [InlineData("example.com.", "example.com", false)] + [InlineData("example.com", "www.example.com", true)] + [InlineData("com", "example.com", false)] + [InlineData("example", "example", true)] + [InlineData("compute.amazonaws.com", "example.compute.amazonaws.com", false)] + [InlineData("example.compute.amazonaws.com", "www.example.compute.amazonaws.com", false)] + [InlineData("amazonaws.com", "www.example.compute.amazonaws.com", false)] + [InlineData("amazonaws.com", "test.amazonaws.com", true)] + // Overrides by the WebAuthn spec + [InlineData("0.0.0.0", "0.0.0.0", false)] // IPs not allowed + [InlineData("0x10203", "0.1.2.3", false)] + [InlineData("[0::1]", "::1", false)] + [InlineData("127.0.0.1", "127.0.0.1", false)] + [InlineData("", "", false)] + // Custom tests + [InlineData("sub.login.bitwarden.com", "https://login.bitwarden.com:1337", false)] + [InlineData("passwordless.dev", "https://login.bitwarden.com:1337", false)] + [InlineData("login.passwordless.dev", "https://login.bitwarden.com:1337", false)] + [InlineData("bitwarden", "localhost", false)] + [InlineData("bitwarden", "bitwarden", true)] + [InlineData("localhost", "https://localhost:8080", true)] + [InlineData("bitwarden.com", "https://bitwarden.com", true)] + [InlineData("bitwarden.com", "https://login.bitwarden.com:1337", true)] + [InlineData("login.bitwarden.com", "https://login.bitwarden.com:1337", true)] + [InlineData("login.bitwarden.com", "https://sub.login.bitwarden.com:1337", true)] + public void ValidateRpId(string rpId, string origin, bool isValid) + { + Assert.Equal(isValid, Fido2DomainUtils.IsValidRpId(rpId, origin)); + } + } +} diff --git a/test/Core.Test/Utilities/Fido2/Fido2GetAssertionUserInterfaceTests.cs b/test/Core.Test/Utilities/Fido2/Fido2GetAssertionUserInterfaceTests.cs new file mode 100644 index 000000000..b4ce23bc9 --- /dev/null +++ b/test/Core.Test/Utilities/Fido2/Fido2GetAssertionUserInterfaceTests.cs @@ -0,0 +1,109 @@ +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Utilities.Fido2; +using Xunit; + +namespace Bit.Core.Test.Utilities.Fido2 +{ + public class Fido2GetAssertionUserInterfaceTests + { + [Fact] + public async Task PickCredentialAsync_ThrowsNotAllowed_PrePickedCredentialDoesNotMatch() + { + // Arrange + var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, null); + + // Act & Assert + await Assert.ThrowsAsync(() => userInterface.PickCredentialAsync([CreateCredential("notMatching", false)])); + } + + [Fact] + public async Task PickCredentialAsync_ReturnPrePickedCredential_CredentialsMatch() + { + // Arrange + var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, null); + + // Act + var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", false), CreateCredential("cipherId2", true)]); + + // Assert + Assert.Equal("cipherId", result.CipherId); + Assert.False(result.UserVerified); + } + + [Fact] + public async Task PickCredentialAsync_CallsUserVerificationCallback_UserIsAlreadyVerified() + { + // Arrange + var called = false; + var callback = () => { called = true; return Task.FromResult(true); }; + var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, callback); + + // Act + var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]); + + // Assert + Assert.Equal("cipherId", result.CipherId); + Assert.True(result.UserVerified); + Assert.True(called); + } + + [Fact] + public async Task PickCredentialAsync_DoesNotCallUserVerificationCallback_UserVerificationIsAlreadyPerformed() + { + // Arrange + var called = false; + var callback = () => { called = true; return Task.FromResult(true); }; + var userInterface = new Fido2GetAssertionUserInterface("cipherId2", true, null, callback); + + // Act + var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]); + + // Assert + Assert.Equal("cipherId2", result.CipherId); + Assert.True(result.UserVerified); + Assert.False(called); + } + + [Fact] + public async Task PickCredentialAsync_DoesNotCallUserVerificationCallback_UserVerificationIsNotRequired() + { + // Arrange + var called = false; + var callback = () => { called = true; return Task.FromResult(true); }; + var userInterface = new Fido2GetAssertionUserInterface("cipherId2", false, null, callback); + + // Act + var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]); + + // Assert + Assert.Equal("cipherId2", result.CipherId); + Assert.False(result.UserVerified); + Assert.False(called); + } + + [Fact] + public async Task EnsureUnlockedVaultAsync_CallsCallback() + { + // Arrange + var called = false; + var callback = () => { called = true; return Task.CompletedTask; }; + var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, callback, null); + + // Act + await userInterface.EnsureUnlockedVaultAsync(); + + // Assert + Assert.True(called); + } + + private Fido2GetAssertionUserInterfaceCredential CreateCredential(string cipherId, bool requireUserVerification) + { + return new Fido2GetAssertionUserInterfaceCredential + { + CipherId = cipherId, + RequireUserVerification = requireUserVerification + }; + } + } +} diff --git a/test/Core.Test/Utilities/GuidExtensionsTests.cs b/test/Core.Test/Utilities/GuidExtensionsTests.cs new file mode 100644 index 000000000..996bddd51 --- /dev/null +++ b/test/Core.Test/Utilities/GuidExtensionsTests.cs @@ -0,0 +1,81 @@ +using System; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities.Fido2 +{ + public class GuidExtensionsTests + { + [Theory] + [InlineData("59788da2-4221-4725-8503-52fea66df0b2", new byte[] {0x59, 0x78, 0x8d, 0xa2, 0x42, 0x21, 0x47, 0x25, 0x85, 0x03, 0x52, 0xfe, 0xa6, 0x6d, 0xf0, 0xb2})] + [InlineData("e7895b55-2149-4cad-9e53-989192320a8a", new byte[] {0xe7, 0x89, 0x5b, 0x55, 0x21, 0x49, 0x4c, 0xad, 0x9e, 0x53, 0x98, 0x91, 0x92, 0x32, 0x0a, 0x8a})] + [InlineData("d12f1371-5c89-4d20-a72f-0522674bdec7", new byte[] {0xd1, 0x2f, 0x13, 0x71, 0x5c, 0x89, 0x4d, 0x20, 0xa7, 0x2f, 0x05, 0x22, 0x67, 0x4b, 0xde, 0xc7})] + [InlineData("040b76e4-aff1-4090-aaa2-7f781eb1f1ac", new byte[] {0x04, 0x0b, 0x76, 0xe4, 0xaf, 0xf1, 0x40, 0x90, 0xaa, 0xa2, 0x7f, 0x78, 0x1e, 0xb1, 0xf1, 0xac})] + [InlineData("bda63808-9bf6-427b-97b6-37f3b8d8f0ea", new byte[] {0xbd, 0xa6, 0x38, 0x08, 0x9b, 0xf6, 0x42, 0x7b, 0x97, 0xb6, 0x37, 0xf3, 0xb8, 0xd8, 0xf0, 0xea})] + [InlineData("5dfb0c92-0243-4c39-bf2b-29ffea097b96", new byte[] {0x5d, 0xfb, 0x0c, 0x92, 0x02, 0x43, 0x4c, 0x39, 0xbf, 0x2b, 0x29, 0xff, 0xea, 0x09, 0x7b, 0x96})] + [InlineData("5a65a8aa-6b88-4c72-bc11-a8a80ba9431e", new byte[] {0x5a, 0x65, 0xa8, 0xaa, 0x6b, 0x88, 0x4c, 0x72, 0xbc, 0x11, 0xa8, 0xa8, 0x0b, 0xa9, 0x43, 0x1e})] + [InlineData("76e7c061-892a-4740-a33c-2a52ea7ccb57", new byte[] {0x76, 0xe7, 0xc0, 0x61, 0x89, 0x2a, 0x47, 0x40, 0xa3, 0x3c, 0x2a, 0x52, 0xea, 0x7c, 0xcb, 0x57})] + [InlineData("322d5ade-6f81-4d7e-ab9c-8155d9a6a50f", new byte[] {0x32, 0x2d, 0x5a, 0xde, 0x6f, 0x81, 0x4d, 0x7e, 0xab, 0x9c, 0x81, 0x55, 0xd9, 0xa6, 0xa5, 0x0f})] + [InlineData("51927742-4e17-40af-991c-d958514ceedb", new byte[] {0x51, 0x92, 0x77, 0x42, 0x4e, 0x17, 0x40, 0xaf, 0x99, 0x1c, 0xd9, 0x58, 0x51, 0x4c, 0xee, 0xdb})] + public void GuidToRawFormat_ReturnsRawFormat_GivenCorrectlyFormattedGuid(string standardFormat, byte[] rawFormat) + { + var result = GuidExtensions.GuidToRawFormat(standardFormat); + + Assert.Equal(rawFormat, result); + } + + [Theory] + [InlineData("59788da-4221-4725-8503-52fea66df0b2")] + [InlineData("e7895b552-149-4cad-9e53-989192320a8a")] + [InlineData("x12f1371-5c89-4d20-a72f-0522674bdec7")] + [InlineData("040b76e4-aff1-4090-Aaa2-7f781eb1f1ac")] + [InlineData("bda63808-9bf6-427b-97b63-7f3b8d8f0ea")] + [InlineData("")] + public void GuidToRawFormat_ThrowsFormatException_IncorrectlyFormattedGuid(string standardFormat) + { + Assert.Throws(() => GuidExtensions.GuidToRawFormat(standardFormat)); + } + + [Fact] + public void GuidToRawFormat_ThrowsArgumentException_NullArgument() + { + Assert.Throws(() => GuidExtensions.GuidToRawFormat(null)); + } + + [Theory] + [InlineData(new byte[] {0x59, 0x78, 0x8d, 0xa2, 0x42, 0x21, 0x47, 0x25, 0x85, 0x03, 0x52, 0xfe, 0xa6, 0x6d, 0xf0, 0xb2}, "59788da2-4221-4725-8503-52fea66df0b2")] + [InlineData(new byte[] {0xe7, 0x89, 0x5b, 0x55, 0x21, 0x49, 0x4c, 0xad, 0x9e, 0x53, 0x98, 0x91, 0x92, 0x32, 0x0a, 0x8a}, "e7895b55-2149-4cad-9e53-989192320a8a")] + [InlineData(new byte[] {0xd1, 0x2f, 0x13, 0x71, 0x5c, 0x89, 0x4d, 0x20, 0xa7, 0x2f, 0x05, 0x22, 0x67, 0x4b, 0xde, 0xc7}, "d12f1371-5c89-4d20-a72f-0522674bdec7")] + [InlineData(new byte[] {0x04, 0x0b, 0x76, 0xe4, 0xaf, 0xf1, 0x40, 0x90, 0xaa, 0xa2, 0x7f, 0x78, 0x1e, 0xb1, 0xf1, 0xac}, "040b76e4-aff1-4090-aaa2-7f781eb1f1ac")] + [InlineData(new byte[] {0xbd, 0xa6, 0x38, 0x08, 0x9b, 0xf6, 0x42, 0x7b, 0x97, 0xb6, 0x37, 0xf3, 0xb8, 0xd8, 0xf0, 0xea}, "bda63808-9bf6-427b-97b6-37f3b8d8f0ea")] + [InlineData(new byte[] {0x5d, 0xfb, 0x0c, 0x92, 0x02, 0x43, 0x4c, 0x39, 0xbf, 0x2b, 0x29, 0xff, 0xea, 0x09, 0x7b, 0x96}, "5dfb0c92-0243-4c39-bf2b-29ffea097b96")] + [InlineData(new byte[] {0x5a, 0x65, 0xa8, 0xaa, 0x6b, 0x88, 0x4c, 0x72, 0xbc, 0x11, 0xa8, 0xa8, 0x0b, 0xa9, 0x43, 0x1e}, "5a65a8aa-6b88-4c72-bc11-a8a80ba9431e")] + [InlineData(new byte[] {0x76, 0xe7, 0xc0, 0x61, 0x89, 0x2a, 0x47, 0x40, 0xa3, 0x3c, 0x2a, 0x52, 0xea, 0x7c, 0xcb, 0x57}, "76e7c061-892a-4740-a33c-2a52ea7ccb57")] + [InlineData(new byte[] {0x32, 0x2d, 0x5a, 0xde, 0x6f, 0x81, 0x4d, 0x7e, 0xab, 0x9c, 0x81, 0x55, 0xd9, 0xa6, 0xa5, 0x0f}, "322d5ade-6f81-4d7e-ab9c-8155d9a6a50f")] + [InlineData(new byte[] {0x51, 0x92, 0x77, 0x42, 0x4e, 0x17, 0x40, 0xaf, 0x99, 0x1c, 0xd9, 0x58, 0x51, 0x4c, 0xee, 0xdb}, "51927742-4e17-40af-991c-d958514ceedb")] + public void GuidToStandardFormat(byte[] rawFormat, string standardFormat) + { + var result = GuidExtensions.GuidToStandardFormat(rawFormat); + + Assert.Equal(standardFormat, result); + } + + [Fact] + public void GuidToStandardFormat_ThrowsArgumentException_NullArgument() + { + Assert.Throws(() => GuidExtensions.GuidToStandardFormat(null)); + } + + [Fact] + public void GuidToStandardFormat_ThrowsArgumentException_TooLarge() + { + Assert.Throws(() => GuidExtensions.GuidToStandardFormat(new byte[17])); + } + + [Fact] + public void GuidToStandardFormat_ThrowsArgumentException_TooShort() + { + Assert.Throws(() => GuidExtensions.GuidToStandardFormat(new byte[15])); + } + } +}