1
0
mirror of https://github.com/bitwarden/mobile synced 2025-12-05 23:53:33 +00:00

Compare commits

...

54 Commits

Author SHA1 Message Date
Andreas Coroiu
da051b8fc1 Add example of delegated UI class 2024-02-19 16:44:15 +01:00
Andreas Coroiu
c801b2fc3a Fix incompatible GUID conversions 2024-02-19 15:35:17 +01:00
Federico Maccaroni
41161864db PM-5154 [Passkeys iOS] Fix Credential ID handling on bytes and string formats. Fix Discoverable to be lowercase on set so it doesn't break parsing on clients. Added UserDisplayName on Fido2 entities. Extracted the Guid Standard/Raw format helpers to a extensions class. 2024-02-16 19:30:33 -03:00
Andreas Coroiu
a1c9ebf01f fix wrong signature format 2024-02-16 10:57:44 +01:00
Federico Maccaroni
7381d5278a PM-5154 Fixed select passkey flow and started implementing create passkey on iOS 2024-02-15 21:16:54 -03:00
Federico Maccaroni
8b5a7b257d Merge branch 'PM-5731-create-c-web-authn-authenticator-to-support-maui-apps' into temp-passkeys-ios-fede 2024-02-15 21:09:09 -03:00
mpbw2
62e0626648 Merge branch 'feature/maui-migration-passkeys' into PM-5731-create-c-web-authn-authenticator-to-support-maui-apps 2024-02-15 14:42:53 -05:00
Andreas Coroiu
3c848a3dcc [PM-5731] feat: implement credential assertion in client 2024-02-12 13:45:41 +01:00
Federico Maccaroni
18beb4e5f4 Added iOS passkeys integration, warning this branch has lots of logs to ease "debugging" extensions. 2024-02-09 18:15:25 -03:00
Federico Maccaroni
e9d1792dd7 Merge branch 'feature/maui-migration-passkeys' into PM-5731-create-c-web-authn-authenticator-to-support-maui-apps 2024-02-08 12:33:33 -03:00
Andreas Coroiu
b8c5ef501a [PM-5731] feat: implement fido2 client createCredential 2024-02-08 14:41:08 +01:00
Andreas Coroiu
2075f8168c [PM-5731] chore: document uri helpers 2024-02-08 14:41:07 +01:00
Andreas Coroiu
0f5df0f6b0 [PM-5731] feat: add incomplete rpId verification 2024-02-08 14:41:07 +01:00
Andreas Coroiu
ad8faec200 [PM-5731] feat: add sameOriginWithAncestor and user id length checks 2024-02-08 14:41:07 +01:00
Andreas Coroiu
3223ceb9a8 [PM-5731] fix: failing test 2024-02-08 14:41:06 +01:00
Federico Maccaroni
7469dad35a PM-5731 Fix build updating discoverable flag 2024-02-07 15:31:21 -03:00
Federico Maccaroni
eae84ded42 Merge branch 'feature/maui-migration-passkeys' into PM-5731-create-c-web-authn-authenticator-to-support-maui-apps 2024-02-07 14:00:00 -03:00
Federico Maccaroni
563210a74e Merge branch 'PM-5731-create-c-web-authn-authenticator-to-support-maui-apps' of https://github.com/bitwarden/mobile into PM-5731-create-c-web-authn-authenticator-to-support-maui-apps 2024-02-07 13:59:53 -03:00
Andreas Coroiu
70db27b750 [PM-5731] feat: scaffold fido2 client 2024-02-06 14:20:52 +01:00
Andreas Coroiu
00cff182b4 [PM-5731] chore: add user presence todo comment 2024-02-06 08:38:38 +01:00
Federico Maccaroni
dc5e90436b Merge branch 'feature/maui-migration-passkeys' into PM-5731-create-c-web-authn-authenticator-to-support-maui-apps 2024-02-05 14:32:00 -03:00
Andreas Coroiu
b787a6c840 [PM-5731] chore: clean up and refactor attestation tests 2024-02-01 13:57:38 +01:00
Andreas Coroiu
a6c4bc9273 [PM-5731] chore: clean up and refactor assertion tests 2024-02-01 09:23:30 +01:00
Andreas Coroiu
4988dbea72 [PM-5731] feat: ensure unlocked vault 2024-01-30 14:40:48 +01:00
Andreas Coroiu
ca250c53ad [PM-5731] feat: add support for specifying user presence requirement 2024-01-30 14:19:41 +01:00
Andreas Coroiu
6bb724ff06 [PM-5731] feat: add support for silent discoverability 2024-01-30 13:10:09 +01:00
Andreas Coroiu
f3c64a89eb [PM-5731] chore: add Async to method names 2024-01-30 10:15:58 +01:00
Andreas Coroiu
aa71ebc634 [PM-5731] chore: use primary constructor 2024-01-30 10:14:08 +01:00
Andreas Coroiu
d0bb7f0a54 [PM-5731] feat: remove logging 2024-01-30 10:01:11 +01:00
Andreas Coroiu
5d5d113369 [PM-5731] feat: implement signing 2024-01-29 14:43:14 +01:00
Andreas Coroiu
7ca9e61e93 [PM-5731] feat: return public key in DER format 2024-01-29 13:50:30 +01:00
Andreas Coroiu
da7326b0cc [PM-5731] feat: implement key generation 2024-01-29 11:27:24 +01:00
Andreas Coroiu
c87728027e [PM-5731] feat: partial attestation implementation 2024-01-26 14:57:44 +01:00
Andreas Coroiu
e1908d8eef [PM-5731] chore: clean up unusued params 2024-01-26 10:45:23 +01:00
Andreas Coroiu
8be604feac [PM-5731] feat: add unknown error handling 2024-01-26 10:44:39 +01:00
Andreas Coroiu
c90ed74faa [PM-5731] feat: add user verification checks 2024-01-26 10:30:31 +01:00
Andreas Coroiu
32c43afae2 [PM-5731] feat: implement credential creation 2024-01-25 16:29:26 +01:00
Andreas Coroiu
44b2443554 [PM-5731] feat: add new credential confirmaiton 2024-01-25 10:49:23 +01:00
Andreas Coroiu
f0dde7eb82 [PM-5731] feat: implement credential exclusion 2024-01-24 14:18:27 +01:00
Andreas Coroiu
19639b61c3 [PM-5731] feat: start implementing attestation 2024-01-24 11:04:37 +01:00
Andreas Coroiu
ce550fee74 [PM-5731] feat: scaffold make credential 2024-01-23 14:29:20 +01:00
Andreas Coroiu
f0841eb8b2 [PM-5731] chore: minor clean up 2024-01-23 10:37:04 +01:00
Andreas Coroiu
b23d58c0b1 [PM-5732] feat: finish authenticator assertion implementation
note: CryptoFunctionService still needs Sign implemenation
2024-01-23 10:28:00 +01:00
Andreas Coroiu
e8f6c37c06 [PM-5731] feat: implement assertion without signature 2024-01-22 16:08:15 +01:00
Andreas Coroiu
d0e0f0ecdb [PM-5731] feat: add support for counter 2024-01-22 13:36:53 +01:00
Andreas Coroiu
ad80defa40 [PM-5731] fix: tests a bit, needed some additional "arrange" steps 2024-01-19 16:42:24 +01:00
Andreas Coroiu
0dc281edc1 [PM-5731] feat: check for UV when reprompt is active 2024-01-19 15:47:06 +01:00
Andreas Coroiu
378551e2d5 [PM-5731] feat: add user does not consent test 2024-01-19 15:18:46 +01:00
Andreas Coroiu
dbe4110027 [PM-5731] feat: add tests for successful UV requests 2024-01-19 11:36:34 +01:00
Andreas Coroiu
a08466d220 [PM-5731] feat: find discoverable credentials 2024-01-19 11:23:56 +01:00
Andreas Coroiu
66a01e30d3 [PM-5731] feat: ask for credentials when found 2024-01-19 10:45:03 +01:00
Andreas Coroiu
cc89b6a5d5 [PM-5731] feat: add rp mismatch test 2024-01-18 10:15:21 +01:00
Andreas Coroiu
32c2f2aac4 [PM-5731] feat: add first test 2024-01-18 09:23:06 +01:00
Andreas Coroiu
f9b4e30b0b [PM-5731] feat: implement get assertion params object 2024-01-18 09:23:05 +01:00
52 changed files with 3951 additions and 135 deletions

View File

@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<MauiVersion>8.0.4-nightly.*</MauiVersion>
<MauiVersion>8.0.7-nightly.*</MauiVersion>
<ReleaseCodesignProvision>Automatic:AppStore</ReleaseCodesignProvision>
<ReleaseCodesignKey>iPhone Distribution</ReleaseCodesignKey>
<IncludeBitwardeniOSExtensions>True</IncludeBitwardeniOSExtensions>

View File

@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Models.Domain;
namespace Bit.Core.Abstractions
{

View File

@@ -0,0 +1,13 @@
using Bit.Core.Utilities.Fido2;
namespace Bit.Core.Abstractions
{
public interface IFido2AuthenticatorService
{
void Init(IFido2UserInterface userInterface);
Task<Fido2AuthenticatorMakeCredentialResult> MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams);
Task<Fido2AuthenticatorGetAssertionResult> GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams);
// TODO: Should this return a List? Or maybe IEnumerable?
Task<Fido2AuthenticatorDiscoverableCredentialMetadata[]> SilentCredentialDiscoveryAsync(string rpId);
}
}

View File

@@ -0,0 +1,35 @@
using Bit.Core.Utilities.Fido2;
namespace Bit.Core.Abstractions
{
/// <summary>
/// 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.
/// </summary>
public interface IFido2ClientService
{
/// <summary>
/// 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
/// </summary>
/// <param name="createCredentialParams">The parameters for the credential creation operation</param>
/// <returns>The new credential</returns>
Task<Fido2ClientCreateCredentialResult> CreateCredentialAsync(Fido2ClientCreateCredentialParams createCredentialParams);
/// <summary>
/// Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the users 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
/// </summary>
/// <param name="assertCredentialParams">The parameters for the credential assertion operation</param>
/// <returns>The asserted credential</returns>
Task<Fido2ClientAssertCredentialResult> AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams);
}
}

View File

@@ -0,0 +1,100 @@
using Bit.Core.Utilities.Fido2;
namespace Bit.Core.Abstractions
{
/// <summary>
/// Parameters used to ask the user to pick a credential from a list of existing credentials.
/// </summary>
public struct Fido2PickCredentialParams
{
/// <summary>
/// The IDs of the credentials that the user can pick from.
/// </summary>
public string[] CipherIds { get; set; }
/// <summary>
/// Whether or not the user must be verified before completing the operation.
/// </summary>
public bool UserVerification { get; set; }
}
/// <summary>
/// The result of asking the user to pick a credential from a list of existing credentials.
/// </summary>
public struct Fido2PickCredentialResult
{
/// <summary>
/// The ID of the cipher that contains the credentials the user picked.
/// </summary>
public string CipherId { get; set; }
/// <summary>
/// Whether or not the user was verified before completing the operation.
/// </summary>
public bool UserVerified { get; set; }
}
public struct Fido2ConfirmNewCredentialParams
{
///<summary>
/// The name of the credential.
///</summary>
public string CredentialName { get; set; }
///<summary>
/// The name of the user.
///</summary>
public string UserName { get; set; }
/// <summary>
/// Whether or not the user must be verified before completing the operation.
/// </summary>
public bool UserVerification { get; set; }
public string RpId { get; set; }
}
public struct Fido2ConfirmNewCredentialResult
{
/// <summary>
/// The name of the user.
/// </summary>
public string CipherId { get; set; }
/// <summary>
/// Whether or not the user was verified.
/// </summary>
public bool UserVerified { get; set; }
}
public interface IFido2UserInterface
{
/// <summary>
/// Ask the user to pick a credential from a list of existing credentials.
/// </summary>
/// <param name="pickCredentialParams">The parameters to use when asking the user to pick a credential.</param>
/// <returns>The ID of the cipher that contains the credentials the user picked.</returns>
Task<Fido2PickCredentialResult> PickCredentialAsync(Fido2PickCredentialParams pickCredentialParams);
/// <summary>
/// Inform the user that the operation was cancelled because their vault contains excluded credentials.
/// </summary>
/// <param name="existingCipherIds">The IDs of the excluded credentials.</param>
/// <returns>When user has confirmed the message</returns>
Task InformExcludedCredential(string[] existingCipherIds);
/// <summary>
/// Ask the user to confirm the creation of a new credential.
/// </summary>
/// <param name="confirmNewCredentialParams">The parameters to use when asking the user to confirm the creation of a new credential.</param>
/// <returns>The ID of the cipher where the new credential should be saved.</returns>
Task<Fido2ConfirmNewCredentialResult> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams);
/// <summary>
/// Make sure that the vault is unlocked.
/// This should open a window and ask the user to login or unlock the vault if necessary.
/// </summary>
/// <returns>When vault has been unlocked.</returns>
Task EnsureUnlockedVaultAsync();
}
}

View File

@@ -34,6 +34,7 @@
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="LiteDB" Version="5.0.17" />
<PackageReference Include="PCLCrypto" Version="2.1.40-alpha" />
<PackageReference Include="System.Formats.Cbor" Version="8.0.0" />
<PackageReference Include="zxcvbn-core" Version="7.0.92" />
<PackageReference Include="MessagePack.MSBuild.Tasks" Version="2.5.124">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -1,5 +1,4 @@
using System;
using Bit.Core.Models.Domain;
using Bit.Core.Models.Domain;
namespace Bit.Core.Models.Api
{
@@ -21,6 +20,7 @@ namespace Bit.Core.Models.Api
RpName = fido2Key.RpName?.EncryptedString;
UserHandle = fido2Key.UserHandle?.EncryptedString;
UserName = fido2Key.UserName?.EncryptedString;
UserDisplayName = fido2Key.UserDisplayName?.EncryptedString;
Counter = fido2Key.Counter?.EncryptedString;
CreationDate = fido2Key.CreationDate;
}
@@ -35,6 +35,7 @@ namespace Bit.Core.Models.Api
public string RpName { get; set; }
public string UserHandle { get; set; }
public string UserName { get; set; }
public string UserDisplayName { get; set; }
public string Counter { get; set; }
public DateTime CreationDate { get; set; }
}

View File

@@ -19,6 +19,7 @@ namespace Bit.Core.Models.Data
RpName = apiData.RpName;
UserHandle = apiData.UserHandle;
UserName = apiData.UserName;
UserDisplayName = apiData.UserDisplayName;
Counter = apiData.Counter;
CreationDate = apiData.CreationDate;
}
@@ -33,6 +34,7 @@ namespace Bit.Core.Models.Data
public string RpName { get; set; }
public string UserHandle { get; set; }
public string UserName { get; set; }
public string UserDisplayName { get; set; }
public string Counter { get; set; }
public DateTime CreationDate { get; set; }
}

View File

@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data;
using Bit.Core.Models.View;
namespace Bit.Core.Models.Domain
@@ -21,6 +17,7 @@ namespace Bit.Core.Models.Domain
nameof(RpName),
nameof(UserHandle),
nameof(UserName),
nameof(UserDisplayName),
nameof(Counter)
};
@@ -48,6 +45,7 @@ namespace Bit.Core.Models.Domain
public EncString RpName { get; set; }
public EncString UserHandle { get; set; }
public EncString UserName { get; set; }
public EncString UserDisplayName { get; set; }
public EncString Counter { get; set; }
public DateTime CreationDate { get; set; }

View File

@@ -1,5 +1,7 @@
using Bit.Core.Enums;
using System.Text.Json.Serialization;
using Bit.Core.Enums;
using Bit.Core.Models.Domain;
using Bit.Core.Utilities;
namespace Bit.Core.Models.View
{
@@ -24,13 +26,40 @@ namespace Bit.Core.Models.View
public string RpName { get; set; }
public string UserHandle { get; set; }
public string UserName { get; set; }
public string UserDisplayName { get; set; }
public string Counter { get; set; }
public DateTime CreationDate { get; set; }
[JsonIgnore]
public int CounterValue {
get => int.TryParse(Counter, out var counter) ? counter : 0;
set => Counter = value.ToString();
}
[JsonIgnore]
public byte[] UserHandleValue {
get => UserHandle == null ? null : CoreHelpers.Base64UrlDecode(UserHandle);
set => UserHandle = value == null ? null : CoreHelpers.Base64UrlEncode(value);
}
[JsonIgnore]
public byte[] KeyBytes {
get => KeyValue == null ? null : CoreHelpers.Base64UrlDecode(KeyValue);
set => KeyValue = value == null ? null : CoreHelpers.Base64UrlEncode(value);
}
[JsonIgnore]
public bool DiscoverableValue {
get => bool.TryParse(Discoverable, out var discoverable) && discoverable;
set => Discoverable = value.ToString().ToLower(); // must be lowercase so it can be parsed in the current version of clients
}
[JsonIgnore]
public override string SubTitle => UserName;
public override List<KeyValuePair<string, LinkedIdType>> LinkedFieldOptions => new List<KeyValuePair<string, LinkedIdType>>();
public bool IsDiscoverable => !string.IsNullOrWhiteSpace(Discoverable);
[JsonIgnore]
public bool CanLaunch => !string.IsNullOrEmpty(RpId);
[JsonIgnore]
public string LaunchUri => $"https://{RpId}";
public bool IsUniqueAgainst(Fido2CredentialView fido2View) => fido2View?.RpId != RpId || fido2View?.UserName != UserName;

View File

@@ -0,0 +1,574 @@
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;
private IFido2UserInterface _userInterface;
public Fido2AuthenticatorService(ICipherService cipherService, ISyncService syncService, ICryptoFunctionService cryptoFunctionService)
{
_cipherService = cipherService;
_syncService = syncService;
_cryptoFunctionService = cryptoFunctionService;
}
public void Init(IFido2UserInterface userInterface)
{
_userInterface = userInterface;
}
public async Task<Fido2AuthenticatorMakeCredentialResult> MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams)
{
if (makeCredentialParams.CredTypesAndPubKeyAlgs.All((p) => p.Alg != (int) Fido2AlgorithmIdentifier.ES256))
{
// var requestedAlgorithms = string.Join(", ", makeCredentialParams.CredTypesAndPubKeyAlgs.Select((p) => p.Algorithm).ToArray());
// _logService.Warning(
// $"[Fido2Authenticator] No compatible algorithms found, RP requested: {requestedAlgorithms}"
// );
ClipLogger.Log("[Fido2Authenticator] No compatible algorithms found, RP requested: {requestedAlgorithms}");
throw new NotSupportedError();
}
await _userInterface.EnsureUnlockedVaultAsync();
await _syncService.FullSyncAsync(false);
var existingCipherIds = await FindExcludedCredentialsAsync(
makeCredentialParams.ExcludeCredentialDescriptorList
);
if (existingCipherIds.Length > 0) {
// _logService.Info(
// "[Fido2Authenticator] Aborting due to excluded credential found in vault."
// );
ClipLogger.Log("[Fido2Authenticator] Aborting due to excluded credential found in vault");
await _userInterface.InformExcludedCredential(existingCipherIds);
throw new NotAllowedError();
}
var response = await _userInterface.ConfirmNewCredentialAsync(new Fido2ConfirmNewCredentialParams {
CredentialName = makeCredentialParams.RpEntity.Name,
UserName = makeCredentialParams.UserEntity.Name,
UserVerification = makeCredentialParams.RequireUserVerification,
RpId = makeCredentialParams.RpEntity.Id
});
var cipherId = response.CipherId;
var userVerified = response.UserVerified;
string credentialId;
if (cipherId == null) {
// _logService.Info(
// "[Fido2Authenticator] Aborting because user confirmation was not recieved."
// );
ClipLogger.Log("[Fido2Authenticator] Aborting because user confirmation was not recieved");
throw new NotAllowedError();
}
try {
var keyPair = GenerateKeyPair();
var fido2Credential = CreateCredentialView(makeCredentialParams, keyPair.privateKey);
ClipLogger.Log($"[Fido2Authenticator] IsDiscoverable {fido2Credential.Discoverable} - {fido2Credential.DiscoverableValue}");
var encrypted = await _cipherService.GetAsync(cipherId);
var cipher = await encrypted.DecryptAsync();
if (!userVerified && (makeCredentialParams.RequireUserVerification || cipher.Reprompt != CipherRepromptType.None)) {
// _logService.Info(
// "[Fido2Authenticator] Aborting because user verification was unsuccessful."
// );
ClipLogger.Log("[Fido2Authenticator] Aborting because user verification was unsuccessful");
throw new NotAllowedError();
}
cipher.Login.Fido2Credentials = new List<Fido2CredentialView> { fido2Credential };
var reencrypted = await _cipherService.EncryptAsync(cipher);
await _cipherService.SaveWithServerAsync(reencrypted);
credentialId = fido2Credential.CredentialId;
ClipLogger.Log($"[Fido2Authenticator] IsDiscoverable {cipher.Login.MainFido2Credential.Discoverable} - {cipher.Login.MainFido2Credential.DiscoverableValue}");
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 e) {
// _logService.Error(
// $"[Fido2Authenticator] Unknown error occured during attestation: {e.Message}"
// );
ClipLogger.Log("[Fido2Authenticator] Unknown error occured during attestation: {e.Message}");
throw new UnknownError();
}
}
public async Task<Fido2AuthenticatorGetAssertionResult> GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams)
{
List<CipherView> cipherOptions;
await _userInterface.EnsureUnlockedVaultAsync();
await _syncService.FullSyncAsync(false);
if (assertionParams.AllowCredentialDescriptorList?.Length > 0) {
ClipLogger.Log("[Fido2Authenticator] Finding credentials with credential descriptor list");
cipherOptions = await FindCredentialsByIdAsync(
assertionParams.AllowCredentialDescriptorList,
assertionParams.RpId
);
} else
{
ClipLogger.Log("[Fido2Authenticator] Finding credentials with RP");
cipherOptions = await FindCredentialsByRpAsync(assertionParams.RpId);
}
if (cipherOptions.Count == 0) {
// _logService.Info(
// "[Fido2Authenticator] Aborting because no matching credentials were found in the vault."
// );
ClipLogger.Log("[Fido2Authenticator] Aborting because no matching credentials were found in the vault");
throw new NotAllowedError();
}
string selectedCipherId;
bool userVerified;
bool userPresence;
// TODO: We might want reconsider allowing user presence to be optional
if (assertionParams.AllowCredentialDescriptorList?.Length == 1 && assertionParams.RequireUserPresence == false)
{
ClipLogger.Log("[Fido2Authenticator] AllowCredentialDescriptorList + RequireUserPresence false");
selectedCipherId = cipherOptions[0].Id;
userVerified = false;
userPresence = false;
}
else
{
ClipLogger.Log("[Fido2Authenticator] PickCredentialAsync");
var response = await _userInterface.PickCredentialAsync(new Fido2PickCredentialParams {
CipherIds = cipherOptions.Select((cipher) => cipher.Id).ToArray(),
UserVerification = assertionParams.RequireUserVerification
});
selectedCipherId = response.CipherId;
userVerified = response.UserVerified;
userPresence = true;
}
var selectedCipher = cipherOptions.FirstOrDefault((c) => c.Id == selectedCipherId);
if (selectedCipher == null) {
// _logService.Info(
// "[Fido2Authenticator] Aborting because the selected credential could not be found."
// );
ClipLogger.Log("[Fido2Authenticator] Aborting because the selected credential could not be found");
throw new NotAllowedError();
}
if (!userPresence && assertionParams.RequireUserPresence) {
// _logService.Info(
// "[Fido2Authenticator] Aborting because user presence was required but not detected."
// );
ClipLogger.Log("[Fido2Authenticator] Aborting because user presence was required but not detected");
throw new NotAllowedError();
}
// TODO: Remove this hardcoding
userVerified = true;
if (!userVerified && (assertionParams.RequireUserVerification || selectedCipher.Reprompt != CipherRepromptType.None)) {
// _logService.Info(
// "[Fido2Authenticator] Aborting because user verification was unsuccessful."
// );
ClipLogger.Log("[Fido2Authenticator] Aborting because user verification was unsuccessful");
throw new NotAllowedError();
}
try
{
var selectedFido2Credential = selectedCipher.Login.MainFido2Credential;
var selectedCredentialId = selectedFido2Credential.CredentialId;
ClipLogger.Log($"[Fido2Authenticator] Selected fido2 cred {selectedFido2Credential.CredentialId}");
if (selectedFido2Credential.CounterValue != 0) {
++selectedFido2Credential.CounterValue;
}
await _cipherService.UpdateLastUsedDateAsync(selectedCipher.Id);
var encrypted = await _cipherService.EncryptAsync(selectedCipher);
await _cipherService.SaveWithServerAsync(encrypted);
ClipLogger.Log($"[Fido2Authenticator] Selected fido2 cred RPID {selectedFido2Credential.RpId}");
ClipLogger.Log($"[Fido2Authenticator] param RpId {assertionParams.RpId}");
var authenticatorData = await GenerateAuthDataAsync(
rpId: selectedFido2Credential.RpId,
userPresence: true,
userVerification: true,
counter: selectedFido2Credential.CounterValue
);
ClipLogger.Log($"authenticatorData base64 from bytes: {Convert.ToBase64String(authenticatorData, Base64FormattingOptions.None)}");
ClipLogger.Log($"ClientDataHash base64 from bytes: {Convert.ToBase64String(assertionParams.Hash, Base64FormattingOptions.None)}");
ClipLogger.Log($"selectedFido2Credential.KeyBytes base64 from bytes: {Convert.ToBase64String(selectedFido2Credential.KeyBytes, Base64FormattingOptions.None)}");
var signature = GenerateSignature(
authData: authenticatorData,
clientDataHash: assertionParams.Hash,
privateKey: selectedFido2Credential.KeyBytes
);
ClipLogger.Log($"signature base64 from bytes: {Convert.ToBase64String(signature, Base64FormattingOptions.None)}");
return new Fido2AuthenticatorGetAssertionResult
{
SelectedCredential = new Fido2AuthenticatorGetAssertionSelectedCredential
{
Id = selectedCredentialId.GuidToRawFormat(),
UserHandle = selectedFido2Credential.UserHandleValue
},
AuthenticatorData = authenticatorData,
Signature = signature
};
} catch (Exception e) {
// _logService.Error(
// $"[Fido2Authenticator] Unknown error occured during assertion: {e.Message}"
// );
ClipLogger.Log($"[Fido2Authenticator] Unknown error occured during assertion: {e.Message}");
throw new UnknownError();
}
}
public async Task<Fido2AuthenticatorDiscoverableCredentialMetadata[]> SilentCredentialDiscoveryAsync(string rpId)
{
var credentials = (await FindCredentialsByRpAsync(rpId)).Select(cipher => new Fido2AuthenticatorDiscoverableCredentialMetadata {
Type = "public-key",
Id = cipher.Login.MainFido2Credential.CredentialId.GuidToRawFormat(),
RpId = cipher.Login.MainFido2Credential.RpId,
UserHandle = cipher.Login.MainFido2Credential.UserHandleValue,
UserName = cipher.Login.MainFido2Credential.UserName
}).ToArray();
return credentials;
}
/// <summary>
/// Finds existing crendetials and returns the `CipherId` for each one
/// </summary>
private async Task<string[]> FindExcludedCredentialsAsync(
PublicKeyCredentialDescriptor[] credentials
) {
if (credentials == null || credentials.Length == 0) {
return Array.Empty<string>();
}
var ids = new List<string>();
foreach (var credential in credentials)
{
try
{
ids.Add(credential.Id.GuidToStandardFormat());
} catch {}
}
if (ids.Count == 0) {
return Array.Empty<string>();
}
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<List<CipherView>> FindCredentialsByIdAsync(PublicKeyCredentialDescriptor[] credentials, string rpId)
{
var ids = new List<string>();
foreach (var credential in credentials)
{
try
{
ClipLogger.Log($"[Fido2Authenticator] FindCredentialsByIdAsync -> Converting Guid byte length: {credential.Id.Length}");
ids.Add(credential.Id.GuidToStandardFormat());
}
catch(Exception ex)
{
ClipLogger.Log($"[Fido2Authenticator] FindCredentialsByIdAsync -> Converting Guid ex {ex}");
}
}
ClipLogger.Log($"[Fido2Authenticator] FindCredentialsByIdAsync -> {credentials.Length} vs {ids.Count}");
if (ids.Count == 0)
{
return new List<CipherView>();
}
ClipLogger.Log($"[Fido2Authenticator] FindCredentialsByIdAsync -> {ids[0]}");
var ciphers = await _cipherService.GetAllDecryptedAsync();
ClipLogger.Log($"[Fido2Authenticator] FindCredentialsByIdAsync -> ciphers count: {ciphers?.Count}");
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<List<CipherView>> 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.Now
};
}
private async Task<byte[]> GenerateAuthDataAsync(
string rpId,
bool userVerification,
bool userPresence,
int counter,
byte[] credentialId = null,
PublicKey publicKey = null
) {
var isAttestation = credentialId != null && publicKey != null;
List<byte> authData = new List<byte>();
var rpIdHash = await _cryptoFunctionService.HashAsync(rpId, CryptoHashAlgorithm.Sha256);
authData.AddRange(rpIdHash);
ClipLogger.Log($"[Fido2Authenticator] GenerateAuthDataAsync -> rpIdHash: {rpIdHash}");
ClipLogger.Log($"[Fido2Authenticator] GenerateAuthDataAsync -> ad: {isAttestation} - uv: {userVerification} - up: {userPresence}");
var flags = AuthDataFlags(
extensionData: false,
attestationData: isAttestation,
userVerification: userVerification,
userPresence: userPresence
);
authData.Add(flags);
ClipLogger.Log($"[Fido2Authenticator] GenerateAuthDataAsync -> flags: {flags}");
ClipLogger.Log($"[Fido2Authenticator] GenerateAuthDataAsync -> counter: {counter}");
authData.AddRange(new List<byte> {
(byte)(counter >> 24),
(byte)(counter >> 16),
(byte)(counter >> 8),
(byte)counter
});
if (isAttestation)
{
var attestedCredentialData = new List<byte>();
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());
ClipLogger.Log($"[Fido2Authenticator] GenerateAuthDataAsync -> adding attestedCD: {attestedCredentialData}");
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 (backupEligibility)
{
flags |= 0b00001000;
}
if (backupState)
{
flags |= 0b00010000;
}
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(-7);
// 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();
}
}
}
}

View File

@@ -0,0 +1,248 @@
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;
public Fido2ClientService(
IStateService stateService,
IEnvironmentService environmentService,
ICryptoFunctionService cryptoFunctionService,
IFido2AuthenticatorService fido2AuthenticatorService)
{
_stateService = stateService;
_environmentService = environmentService;
_cryptoFunctionService = cryptoFunctionService;
_fido2AuthenticatorService = fido2AuthenticatorService;
}
public async Task<Fido2ClientCreateCredentialResult> 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)
{
// TODO: Should we use ArgumentException here instead?
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 == -7 && kp.Type == "public-key")
.ToArray();
}
else
{
// Assign default algorithms
credTypesAndPubKeyAlgs = [
new PublicKeyCredentialParameters { Alg = -7, Type = "public-key" },
new PublicKeyCredentialParameters { Alg = -257, Type = "public-key" }
];
}
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);
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<Fido2ClientAssertCredentialResult> 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);
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,
RequireUserPresence = true,
RequireUserVerification = requireUserVerification,
Hash = cliendDataHash
};
}
}
}

View File

@@ -0,0 +1,61 @@
using System.Runtime.CompilerServices;
using System.Text;
using Bit.Core.Abstractions;
#if IOS
using UIKit;
#endif
namespace Bit.Core.Services
{
public class ClipLogger : ILogger
{
private static readonly StringBuilder _currentBreadcrumbs = new StringBuilder();
static ILogger _instance;
public static ILogger Instance
{
get
{
if (_instance is null)
{
_instance = new ClipLogger();
}
return _instance;
}
}
protected ClipLogger()
{
}
public static void Log(string breadcrumb)
{
_currentBreadcrumbs.AppendLine($"{DateTime.Now.ToShortTimeString()}: {breadcrumb}");
#if IOS
UIPasteboard.General.String = _currentBreadcrumbs.ToString();
#endif
}
public void Error(string message, IDictionary<string, string> extraData = null, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
var classAndMethod = $"{Path.GetFileNameWithoutExtension(sourceFilePath)}.{memberName}";
var filePathAndLineNumber = $"{Path.GetFileName(sourceFilePath)}:{sourceLineNumber}";
var properties = new Dictionary<string, string>
{
["File"] = filePathAndLineNumber,
["Method"] = memberName
};
Log(message ?? $"Error found in: {classAndMethod}, {filePathAndLineNumber}");
}
public void Exception(Exception ex) => Log(ex?.ToString());
public Task InitAsync() => Task.CompletedTask;
public Task<bool> IsEnabled() => Task.FromResult(true);
public Task SetEnabled(bool value) => Task.CompletedTask;
}
}

View File

@@ -1,5 +1,5 @@
using System;
using Bit.Core.Abstractions;
using Bit.Core.Abstractions;
using Bit.Core.Services;
using Bit.Core.Utilities;
namespace Bit.Core.Services
@@ -22,10 +22,23 @@ namespace Bit.Core.Services
#if !FDROID
// just in case the caller throws the exception in a moment where the logger can't be resolved
// we need to track the error as well
Microsoft.AppCenter.Crashes.Crashes.TrackError(ex);
//Microsoft.AppCenter.Crashes.Crashes.TrackError(ex);
ClipLogger.Log(ex?.ToString());
#endif
}
}
public static void LogBreadcrumb(string breadcrumb)
{
if (ServiceContainer.Resolve<ILogger>("logger", true) is ILogger logger)
{
logger.Error(breadcrumb);
}
else
{
ClipLogger.Log(breadcrumb);
}
}
}
}

View File

@@ -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;

View File

@@ -38,12 +38,38 @@ namespace Bit.Core.Utilities
#endif
}
/// <summary>
/// Returns the host (and not port) of the given uri.
/// Does not support plain hostnames without a protocol.
///
/// Input => Output examples:
/// <para>https://bitwarden.com => bitwarden.com</para>
/// <para>https://login.bitwarden.com:1337 => login.bitwarden.com</para>
/// <para>https://sub.login.bitwarden.com:1337 => sub.login.bitwarden.com</para>
/// <para>https://localhost:8080 => localhost</para>
/// <para>localhost => null</para>
/// <para>bitwarden => null</para>
/// <para>127.0.0.1 => 127.0.0.1</para>
/// </summary>
public static string GetHostname(string uriString)
{
var uri = GetUri(uriString);
return string.IsNullOrEmpty(uri?.Host) ? null : uri.Host;
}
/// <summary>
/// Returns the host and port of the given uri.
/// Does not support plain hostnames without
///
/// Input => Output examples:
/// <para>https://bitwarden.com => bitwarden.com</para>
/// <para>https://login.bitwarden.com:1337 => login.bitwarden.com:1337</para>
/// <para>https://sub.login.bitwarden.com:1337 => sub.login.bitwarden.com:1337</para>
/// <para>https://localhost:8080 => localhost:8080</para>
/// <para>localhost => null</para>
/// <para>bitwarden => null</para>
/// <para>127.0.0.1 => 127.0.0.1</para>
/// </summary>
public static string GetHost(string uriString)
{
var uri = GetUri(uriString);
@@ -61,6 +87,19 @@ namespace Bit.Core.Utilities
return null;
}
/// <summary>
/// Returns the second and top level domain of the given uri.
/// Does not support plain hostnames without
///
/// Input => Output examples:
/// <para>https://bitwarden.com => bitwarden.com</para>
/// <para>https://login.bitwarden.com:1337 => bitwarden.com</para>
/// <para>https://sub.login.bitwarden.com:1337 => bitwarden.com</para>
/// <para>https://localhost:8080 => localhost</para>
/// <para>localhost => null</para>
/// <para>bitwarden => null</para>
/// <para>127.0.0.1 => 127.0.0.1</para>
/// </summary>
public static string GetDomain(string uriString)
{
var uri = GetUri(uriString);

View File

@@ -0,0 +1,18 @@
namespace Bit.Core.Utilities.Fido2
{
#nullable enable
/// <summary>
/// The Relying Party's requirements of the authenticator used in the creation of the credential.
/// </summary>
public class AuthenticatorSelectionCriteria
{
public bool? RequireResidentKey { get; set; }
public string? ResidentKey { get; set; }
public string UserVerification { get; set; } = "preferred";
/// <summary>
/// This member is intended for use by Relying Parties that wish to select the appropriate authenticators to participate in the create() operation.
/// </summary>
// public AuthenticatorAttachment? AuthenticatorAttachment { get; set; } // not used
}
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Core.Utilities.Fido2 {
public enum Fido2AlgorithmIdentifier : int {
ES256 = -7,
RS256 = -257,
}
}

View File

@@ -0,0 +1,16 @@
/// <summary>
/// Represents the metadata of a discoverable credential for a FIDO2 authenticator.
/// See: https://www.w3.org/TR/webauthn-3/#sctn-op-silent-discovery
/// </summary>
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; }
}

View File

@@ -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")
{
}
}
}

View File

@@ -2,11 +2,30 @@
{
public class Fido2AuthenticatorGetAssertionParams
{
/** The callers 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; }
/// <summary>
/// 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.
/// </summary>
public bool RequireUserVerification { get; set; }
/// <summary>
/// Instructs the authenticator to require user consent to complete the operation.
/// </summary>
public bool RequireUserPresence { get; set; }
/// <summary>
/// The challenge to be signed by the authenticator.
/// </summary>
public byte[] Challenge { get; set; }
public object Extensions { get; set; }
}
}

View File

@@ -1,11 +1,24 @@
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 override string ToString()
{
return $"AD: {AuthenticatorData.Length}; Sig: {Signature.Length}; SC: {SelectedCredential?.Id?.Length}; {SelectedCredential?.UserHandle?.Length}";
}
}
public class Fido2AuthenticatorGetAssertionSelectedCredential {
public byte[] Id { get; set; }
#nullable enable
public byte[]? UserHandle { get; set; }
}
}

View File

@@ -0,0 +1,53 @@
namespace Bit.Core.Utilities.Fido2
{
public class Fido2AuthenticatorMakeCredentialParams
{
/// <summary>
/// The Relying Party's PublicKeyCredentialRpEntity.
/// </summary>
public PublicKeyCredentialRpEntity RpEntity { get; set; }
/// <summary>
/// The Relying Party's PublicKeyCredentialRpEntity.
/// </summary>
public PublicKeyCredentialUserEntity UserEntity { get; set; }
/// <summary>
/// The hash of the serialized client data, provided by the client.
/// </summary>
public byte[] Hash { get; set; }
/// <summary>
/// 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.
/// </summary>
public PublicKeyCredentialParameters[] CredTypesAndPubKeyAlgs { get; set; }
/// <summary>
///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.
/// </summary>
public PublicKeyCredentialDescriptor[] ExcludeCredentialDescriptorList { get; set; }
/// <summary>
/// The effective resident key requirement for credential creation, a Boolean value determined by the client. Resident is synonymous with discoverable. */
/// </summary>
public bool RequireResidentKey { get; set; }
/// <summary>
/// The effective user verification requirement for assertion, a Boolean value provided by the client.
/// </summary>
public bool RequireUserVerification { get; set; }
/// <summary>
/// CTAP2 authenticators support setting this to false, but we only support the WebAuthn authenticator model which does not have that option.
/// </summary>
// public bool RequireUserPresence { get; set; } // Always required
/// <summary>
/// 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.
/// </summary>
// public string AttestationPreference { get; set; }
public object Extensions { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,57 @@
namespace Bit.Core.Utilities.Fido2
{
#nullable enable
/// <summary>
/// Parameters for asserting a credential.
///
/// This class is an extended version of the WebAuthn struct:
/// https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialrequestoptions
/// </summary>
public class Fido2ClientAssertCredentialParams
{
/// <summary>
/// A value which is true if and only if the callers environment settings object is same-origin with its ancestors.
/// It is false if caller is cross-origin.
/// </summary>
public bool SameOriginWithAncestors { get; set; }
/// <summary>
/// The challenge that the selected authenticator signs, along with other data, when producing an authentication
/// assertion.
/// </summary>
public required byte[] Challenge { get; set; }
/// <summary>
/// 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.
/// </summary>
public string RpId { get; set; }
/// <summary>
/// The Relying Party's origin (e.g., "https://example.com").
/// </summary>
public string Origin { get; set; }
/// <summary>
/// A list of PublicKeyCredentialDescriptor objects representing public key credentials acceptable to the caller,
/// in descending order of the callers preference (the first item in the list is the most preferred credential,
/// and so on down the list).
/// </summary>
public PublicKeyCredentialDescriptor[] AllowCredentials { get; set; } = [];
/// <summary>
/// The Relying Party's requirements regarding user verification for the get() operation.
/// </summary>
public string UserVerification { get; set; } = "preferred";
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This is not currently supported.
/// </remarks>
public int? Timeout { get; set; }
}
}

View File

@@ -0,0 +1,42 @@
namespace Bit.Core.Utilities.Fido2
{
/// <summary>
/// The result of asserting a credential.
///
/// See: https://www.w3.org/TR/webauthn-2/#publickeycredential
/// </summary>
public class Fido2ClientAssertCredentialResult
{
/// <summary>
/// Base64url encoding of the credential identifer.
/// </summary>
public required string Id { get; set; }
/// <summary>
/// The credential identifier.
/// </summary>
public required byte[] RawId { get; set; }
/// <summary>
/// The JSON-compatible serialization of client datapassed to the authenticator by the client in
/// order to generate this assertion.
/// </summary>
public required byte[] ClientDataJSON { get; set; }
/// <summary>
/// The authenticator data returned by the authenticator.
/// </summary>
public required byte[] AuthenticatorData { get; set; }
/// <summary>
/// The raw signature returned from the authenticator.
/// </summary>
public required byte[] Signature { get; set; }
/// <summary>
/// The user handle returned from the authenticator, or null if the authenticator did not
/// return a user handle.
/// </summary>
public byte[]? UserHandle { get; set; }
}
}

View File

@@ -0,0 +1,35 @@
namespace Bit.Core.Utilities.Fido2
{
/// <summary>
/// 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
/// </summary>
public class Fido2ClientAuthenticatorAssertionResponse
{
/// <summary>
/// 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.
/// </summary>
public required byte[] ClientDataJSON { get; set; }
/// <summary>
/// The authenticator data returned by the authenticator.
/// </summary>
public required byte[] AuthenticatorData { get; set; }
/// <summary>
/// Raw signature returned from the authenticator.
/// </summary>
public required byte[] Signature { get; set; }
/// <summary>
/// The user handle returned from the authenticator, or null if the authenticator did not return a user handle.
/// </summary>
public byte[] UserHandle { get; set; } = null;
}
}

View File

@@ -0,0 +1,75 @@
namespace Bit.Core.Utilities.Fido2
{
#nullable enable
/// <summary>
/// Parameters for creating a new credential.
/// </summary>
public class Fido2ClientCreateCredentialParams
{
/// <summary>
/// The Relaying Parties origin, see: https://html.spec.whatwg.org/multipage/browsers.html#concept-origin
/// </summary>
public required string Origin { get; set; }
/// <summary>
/// A value which is true if and only if the callers environment settings object is same-origin with its ancestors.
/// It is false if caller is cross-origin.
/// </summary>
public bool SameOriginWithAncestors { get; set; }
/// <summary>
/// The Relying Party's preference for attestation conveyance
/// </summary>
public string? Attestation { get; set; } = "none";
/// <summary>
/// The Relying Party's requirements of the authenticator used in the creation of the credential.
/// </summary>
public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; }
/// <summary>
/// Challenge intended to be used for generating the newly created credential's attestation object.
/// </summary>
public required byte[] Challenge { get; set; } // base64url encoded
/// <summary>
/// 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.
/// </summary>
public PublicKeyCredentialDescriptor[]? ExcludeCredentials { get; set; }
/// <summary>
/// This member contains additional parameters requesting additional processing by the client and authenticator.
/// Not currently supported.
/// </summary>
public object? Extensions { get; set; }
/// <summary>
/// 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.
/// </summary>
public required PublicKeyCredentialParameters[] PubKeyCredParams { get; set; }
/// <summary>
/// Data about the Relying Party responsible for the request.
/// </summary>
public required PublicKeyCredentialRpEntity Rp { get; set; }
/// <summary>
/// Data about the user account for which the Relying Party is requesting attestation.
/// </summary>
public required PublicKeyCredentialUserEntity User { get; set; }
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This is not currently supported.
/// </remarks>
public int? Timeout { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
namespace Bit.Core.Utilities.Fido2
{
/// <summary>
/// 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
/// </summary>
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; }
}
}

View File

@@ -0,0 +1,25 @@
namespace Bit.Core.Utilities.Fido2
{
public class Fido2ClientException : Exception
{
public enum ErrorCode
{
NotAllowedError,
TypeError,
SecurityError,
UriBlockedError,
NotSupportedError,
InvalidStateError,
UnknownError
}
public readonly ErrorCode Code;
public readonly string Reason;
public Fido2ClientException(ErrorCode code, string reason) : base($"{code} ({reason})")
{
Code = code;
Reason = reason;
}
}
}

View File

@@ -0,0 +1,65 @@
using Bit.Core.Abstractions;
namespace Bit.Core.Utilities.Fido2
{
/// <summary>
/// This implementation is used when all interactions are delegated to the operating system.
/// Most often these decisions have already been made by the time the Authenticator is called.
///
/// This is only supported for assertion operations. Attestation requires the user to interact
/// with the app directly.
/// </summary>
public class Fido2DelegatedUserInterface : IFido2UserInterface
{
private string _cipherId = null;
private bool _userVerified = false;
private Func<Task> _ensureUnlockedVaultAsyncCallback;
/// <summary>
/// Indicates that the user has already picked a credential from a list of existing credentials.
/// Picking a credential also assumes user presence.
/// </summary>
public Fido2DelegatedUserInterface UserPickedCredential(string cipherId)
{
_cipherId = cipherId;
return this;
}
/// <summary>
/// Indicates that the user was verified by the OS, e.g. by a fingerprint or face scan.
/// </summary>
public Fido2DelegatedUserInterface UserIsVerified()
{
_userVerified = true;
return this;
}
public Fido2DelegatedUserInterface WithEnsureUnlockedVaultAsyncCallback(Func<Task> callback)
{
_ensureUnlockedVaultAsyncCallback = callback;
return this;
}
public Task<Fido2PickCredentialResult> PickCredentialAsync(Fido2PickCredentialParams parameters)
{
return Task.FromResult(new Fido2PickCredentialResult
{
CipherId = _cipherId,
UserVerified = _userVerified
});
}
public Task EnsureUnlockedVaultAsync()
{
if (_ensureUnlockedVaultAsyncCallback != null)
{
return _ensureUnlockedVaultAsyncCallback();
}
throw new Exception("No callback provided to ensure the vault is unlocked");
}
public Task InformExcludedCredential(string[] existingCipherIds) => throw new NotImplementedException();
public Task<Fido2ConfirmNewCredentialResult> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams parameters) => throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
namespace Bit.Core.Utilities.Fido2
{
public class Fido2DomainUtils
{
// TODO: This is a basic implementation of the domain validation logic, and is probably not correct.
// It doesn't support IP-adresses, and it doesn't follow the algorithm in the spec:
// 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 || origin == null)
{
return false;
}
// TODO: DomainName doesn't like it when we give it a URL with a protocol or port
// So we remove the protocol and port here, while still supporting ipv6 shortform
// 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 (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));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,15 @@
namespace Bit.Core.Utilities.Fido2
{
/// <summary>
/// A description of a key type and algorithm.
///</example>
public class PublicKeyCredentialParameters
{
public string Type { get; set; }
/// <summary>
/// Cose algorithm identifier, e.g. -7 for ES256.
/// </summary>
public int Alg { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
namespace Bit.Core.Utilities.Fido2
{
public class PublicKeyCredentialRpEntity
{
public string Id { get; set; }
public string Name { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,70 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Bit.Core.Utilities
{
/// <summary>
/// Extension methods for converting between standard and raw GUID formats.
///
/// Note: Not optimized for performance. Don't use in performance-critical code.
/// </summary>
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);
}
}
}

View File

@@ -1,31 +1,183 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AuthenticationServices;
using Bit.App.Abstractions;
using Bit.Core.Abstractions;
using Bit.Core.Models.View;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Core.Utilities.Fido2;
using Bit.iOS.Core.Utilities;
using Foundation;
using Microsoft.Maui.ApplicationModel;
using ObjCRuntime;
using UIKit;
namespace Bit.iOS.Autofill
{
public partial class CredentialProviderViewController : ASCredentialProviderViewController, IAccountsManagerHost
public partial class CredentialProviderViewController : ASCredentialProviderViewController, IAccountsManagerHost, IFido2UserInterface
{
private readonly LazyResolve<ICipherService> _cipherService = new LazyResolve<ICipherService>();
private readonly Fido2DelegatedUserInterface _userInterface = new Fido2DelegatedUserInterface();
private IFido2AuthenticatorService _fido2AuthService;
private IFido2AuthenticatorService Fido2AuthService
{
get
{
if (_fido2AuthService is null)
{
_fido2AuthService = ServiceContainer.Resolve<IFido2AuthenticatorService>();
_fido2AuthService.Init(_userInterface);
}
return _fido2AuthService;
}
}
public override async void PrepareInterfaceForPasskeyRegistration(IASCredentialRequest registrationRequest)
{
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
return;
}
ClipLogger.Log($"PIFPR(IASC)");
try
{
switch (registrationRequest?.Type)
{
case ASCredentialRequestType.PasskeyAssertion:
ClipLogger.Log($"PIFPR(IASC) -> Passkey");
var passkeyRegistrationRequest = Runtime.GetNSObject<ASPasskeyCredentialRequest>(registrationRequest.GetHandle());
await PrepareInterfaceForPasskeyRegistrationAsync(passkeyRegistrationRequest);
break;
default:
ClipLogger.Log($"PIFPR(IASC) -> Type not PA");
CancelRequest(ASExtensionErrorCode.Failed);
break;
}
}
catch (Exception ex)
{
OnProvidingCredentialException(ex);
}
}
private async Task PrepareInterfaceForPasskeyRegistrationAsync(ASPasskeyCredentialRequest passkeyRegistrationRequest)
{
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0) || passkeyRegistrationRequest?.CredentialIdentity is null)
{
ClipLogger.Log($"PIFPR Not iOS 17 or null passkey request/identity");
return;
}
InitAppIfNeeded();
if (!await IsAuthed())
{
ClipLogger.Log($"PIFPR Not Authed");
await _accountsManager.NavigateOnAccountChangeAsync(false);
return;
}
_context.PasskeyCredentialRequest = passkeyRegistrationRequest;
_context.IsCreatingPasskey = true;
var credIdentity = Runtime.GetNSObject<ASPasskeyCredentialIdentity>(passkeyRegistrationRequest.CredentialIdentity.GetHandle());
ClipLogger.Log($"PIFPR MakeCredentialAsync");
ClipLogger.Log($"PIFPR MakeCredentialAsync RpID: {credIdentity.RelyingPartyIdentifier}");
ClipLogger.Log($"PIFPR MakeCredentialAsync UserName: {credIdentity.UserName}");
ClipLogger.Log($"PIFPR MakeCredentialAsync UVP: {passkeyRegistrationRequest.UserVerificationPreference}");
ClipLogger.Log($"PIFPR MakeCredentialAsync SA: {passkeyRegistrationRequest.SupportedAlgorithms?.Select(a => (int)a)}");
ClipLogger.Log($"PIFPR MakeCredentialAsync UH: {credIdentity.UserHandle.GetBase64EncodedString(NSDataBase64EncodingOptions.None)}");
var result = await Fido2AuthService.MakeCredentialAsync(new Bit.Core.Utilities.Fido2.Fido2AuthenticatorMakeCredentialParams
{
Hash = passkeyRegistrationRequest.ClientDataHash.ToArray(),
CredTypesAndPubKeyAlgs = GetCredTypesAndPubKeyAlgs(passkeyRegistrationRequest.SupportedAlgorithms),
RequireUserVerification = passkeyRegistrationRequest.UserVerificationPreference == "required",
RequireResidentKey = true,
RpEntity = new PublicKeyCredentialRpEntity
{
Id = credIdentity.RelyingPartyIdentifier,
Name = credIdentity.RelyingPartyIdentifier
},
UserEntity = new PublicKeyCredentialUserEntity
{
Id = credIdentity.UserHandle.ToArray(),
Name = credIdentity.UserName,
DisplayName = credIdentity.UserName
}
});
await ASHelpers.ReplaceAllIdentitiesAsync();
ClipLogger.Log($"PIFPR Completing");
ClipLogger.Log($"PIFPR Completing - RpId: {credIdentity.RelyingPartyIdentifier}");
ClipLogger.Log($"PIFPR Completing - CDH: {passkeyRegistrationRequest.ClientDataHash.GetBase64EncodedString(NSDataBase64EncodingOptions.None)}");
ClipLogger.Log($"PIFPR Completing - CID: {Convert.ToBase64String(result.CredentialId, Base64FormattingOptions.None)}");
ClipLogger.Log($"PIFPR Completing - AO: {Convert.ToBase64String(result.AttestationObject, Base64FormattingOptions.None)}");
var expired = await ExtensionContext.CompleteRegistrationRequestAsync(new ASPasskeyRegistrationCredential(
credIdentity.RelyingPartyIdentifier,
passkeyRegistrationRequest.ClientDataHash,
NSData.FromArray(result.CredentialId),
NSData.FromArray(result.AttestationObject)));
ClipLogger.Log($"CompleteRegistrationRequestAsync: {expired}");
}
private PublicKeyCredentialParameters[] GetCredTypesAndPubKeyAlgs(NSNumber[] supportedAlgorithms)
{
if (supportedAlgorithms?.Any() != true)
{
return new PublicKeyCredentialParameters[]
{
new PublicKeyCredentialParameters
{
Type = Bit.Core.Constants.DefaultFido2CredentialType,
Alg = (int)Fido2AlgorithmIdentifier.ES256
},
new PublicKeyCredentialParameters
{
Type = Bit.Core.Constants.DefaultFido2CredentialType,
Alg = (int)Fido2AlgorithmIdentifier.RS256
}
};
}
return supportedAlgorithms
.Where(alg => (int)alg == (int)Fido2AlgorithmIdentifier.ES256)
.Select(alg => new PublicKeyCredentialParameters
{
Type = Bit.Core.Constants.DefaultFido2CredentialType,
Alg = (int)alg
}).ToArray();
}
public class UserInteractionRequiredException : Exception {}
private async Task ProvideCredentialWithoutUserInteractionAsync(ASPasskeyCredentialRequest passkeyCredentialRequest)
{
InitAppIfNeeded();
await _stateService.Value.SetPasswordRepromptAutofillAsync(false);
await _stateService.Value.SetPasswordVerifiedAutofillAsync(false);
if (!await IsAuthed() || await IsLocked())
{
CancelRequest(ASExtensionErrorCode.UserInteractionRequired);
return;
}
_context.PasskeyCredentialRequest = passkeyCredentialRequest;
await ProvideCredentialAsync(false);
try {
await ProvideCredentialAsync(false);
} catch (UserInteractionRequiredException) {
CancelRequest(ASExtensionErrorCode.UserInteractionRequired);
}
}
public async Task CompleteAssertionRequestAsync(CipherView cipherView)
public async Task CompleteAssertionRequestAsync(string rpId, NSData userHandleData, NSData credentialIdData, string cipherId)
{
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
@@ -33,44 +185,196 @@ 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
if (_context.PasskeyCredentialRequest is null)
{
RpId = cipherView.Login.MainFido2Credential.RpId,
Counter = cipherView.Login.MainFido2Credential.Counter,
CredentialId = cipherView.Login.MainFido2Credential.CredentialId
});
OnProvidingCredentialException(new InvalidOperationException("Trying to complete assertion request without a PasskeyCredentialRequest"));
return;
}
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
));
try
{
ClipLogger.Log($"ClientDataHash: {_context.PasskeyCredentialRequest.ClientDataHash}");
ClipLogger.Log($"ClientDataHash BA: {_context.PasskeyCredentialRequest.ClientDataHash.ToByteArray()}");
ClipLogger.Log($"ClientDataHash base64: {_context.PasskeyCredentialRequest.ClientDataHash.GetBase64EncodedString(NSDataBase64EncodingOptions.None)}");
ClipLogger.Log($"ClientDataHash base64 from bytes: {Convert.ToBase64String(_context.PasskeyCredentialRequest.ClientDataHash.ToByteArray(), Base64FormattingOptions.None)}");
var fido2AssertionResult = await Fido2AuthService.GetAssertionAsync(new Bit.Core.Utilities.Fido2.Fido2AuthenticatorGetAssertionParams
{
RpId = rpId,
Hash = _context.PasskeyCredentialRequest.ClientDataHash.ToArray(),
RequireUserVerification = _context.PasskeyCredentialRequest.UserVerificationPreference == "required",
RequireUserPresence = false,
AllowCredentialDescriptorList = new Bit.Core.Utilities.Fido2.PublicKeyCredentialDescriptor[]
{
new Bit.Core.Utilities.Fido2.PublicKeyCredentialDescriptor
{
Id = credentialIdData.ToArray()
}
}
});
_userInterface.UserPickedCredential(cipherId);
// if (os.PerformedFaceID) {
_userInterface.UserIsVerified();
//}
ClipLogger.Log("fido2AssertionResult:" + fido2AssertionResult);
var selectedUserHandleData = fido2AssertionResult.SelectedCredential != null
? NSData.FromArray(fido2AssertionResult.SelectedCredential.UserHandle)
: (NSData)userHandleData;
ClipLogger.Log("selectedUserHandleData:" + selectedUserHandleData);
var selectedCredentialIdData = fido2AssertionResult.SelectedCredential != null
? NSData.FromArray(fido2AssertionResult.SelectedCredential.Id)
: credentialIdData;
ClipLogger.Log("selectedCredentialIdData:" + selectedCredentialIdData);
await CompleteAssertionRequest(new ASPasskeyAssertionCredential(
selectedUserHandleData,
rpId,
NSData.FromArray(fido2AssertionResult.Signature),
_context.PasskeyCredentialRequest.ClientDataHash,
NSData.FromArray(fido2AssertionResult.AuthenticatorData),
selectedCredentialIdData
));
}
catch (InvalidOperationException)
{
ClipLogger.Log("CompleteAssertionRequestAsync -> InvalidOperationException NoOp");
return;
}
}
public void CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential)
public async Task CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential)
{
if (_context == null)
try
{
ClipLogger.Log("CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential");
if (assertionCredential is null)
{
ClipLogger.Log("CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential -> assertionCredential is null");
ServiceContainer.Reset();
CancelRequest(ASExtensionErrorCode.UserCanceled);
return;
}
NSRunLoop.Main.BeginInvokeOnMainThread(() =>
{
//NSRunLoop.Main.BeginInvokeOnMainThread(() =>
//{
ServiceContainer.Reset();
ASExtensionContext?.CompleteAssertionRequest(assertionCredential, null);
});
#pragma warning disable CA1416 // Validate platform compatibility
ClipLogger.Log("CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential -> completing");
var expired = await ExtensionContext.CompleteAssertionRequestAsync(assertionCredential);
//ExtensionContext.CompleteAssertionRequest(assertionCredential, expired =>
//{
// ClipLogger.Log($"ASExtensionContext?.CompleteAssertionRequest: {expired}");
//});
ClipLogger.Log($"CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential -> Completed {expired}");
#pragma warning restore CA1416 // Validate platform compatibility
//});
}
catch (Exception ex)
{
ClipLogger.Log($"CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential -> failed {ex}");
}
}
private bool CanProvideCredentialOnPasskeyRequest(CipherView cipherView)
{
return _context.PasskeyCredentialRequest != null && !cipherView.Login.HasFido2Credentials;
}
// IFido2UserInterface
public Task<Fido2PickCredentialResult> PickCredentialAsync(Fido2PickCredentialParams pickCredentialParams)
{
return Task.FromResult(new Fido2PickCredentialResult());
}
public Task InformExcludedCredential(string[] existingCipherIds)
{
return Task.CompletedTask;
}
public async Task<Fido2ConfirmNewCredentialResult> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams)
{
// TODO: Show interface so the user can choose whether to create a new passkey or select one to add the passkey to.
var newCipher = new CipherView
{
Name = confirmNewCredentialParams.RpId,
Type = Bit.Core.Enums.CipherType.Login,
Login = new LoginView
{
Uris = new List<LoginUriView>
{
new LoginUriView
{
Uri = confirmNewCredentialParams.RpId
}
}
},
Card = new CardView(),
Identity = new IdentityView(),
SecureNote = new SecureNoteView
{
Type = Bit.Core.Enums.SecureNoteType.Generic
},
Reprompt = Bit.Core.Enums.CipherRepromptType.None
};
var encryptedCipher = await _cipherService.Value.EncryptAsync(newCipher);
await _cipherService.Value.SaveWithServerAsync(encryptedCipher);
return new Fido2ConfirmNewCredentialResult
{
CipherId = encryptedCipher.Id,
UserVerified = true
};
}
public async Task EnsureUnlockedVaultAsync()
{
if (_context.IsCreatingPasskey)
{
ClipLogger.Log($"EnsureUnlockedVaultAsync creating passkey");
if (!await IsLocked())
{
ClipLogger.Log($"EnsureUnlockedVaultAsync not locked");
return;
}
_context._unlockVaultTcs?.SetCanceled();
_context._unlockVaultTcs = new TaskCompletionSource<bool>();
MainThread.BeginInvokeOnMainThread(() =>
{
try
{
ClipLogger.Log($"EnsureUnlockedVaultAsync performing lock segue");
PerformSegue("lockPasswordSegue", this);
}
catch (Exception ex)
{
ClipLogger.Log($"EnsureUnlockedVaultAsync {ex}");
}
});
ClipLogger.Log($"EnsureUnlockedVaultAsync awaiting for unlock");
await _context._unlockVaultTcs.Task;
return;
}
ClipLogger.Log($"EnsureUnlockedVaultAsync Passkey selection");
if (!await IsAuthed() || await IsLocked())
{
CancelRequest(ASExtensionErrorCode.UserInteractionRequired);
throw new InvalidOperationException("Not authed or locked");
}
}
}
}

View File

@@ -20,7 +20,10 @@ using Foundation;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Platform;
using ObjCRuntime;
using UIKit;
using static CoreFoundation.DispatchSource;
using static Microsoft.Maui.ApplicationModel.Permissions;
namespace Bit.iOS.Autofill
{
@@ -32,7 +35,6 @@ namespace Bit.iOS.Autofill
private IAccountsManager _accountsManager;
private readonly LazyResolve<IStateService> _stateService = new LazyResolve<IStateService>();
private readonly LazyResolve<IFido2AuthenticationService> _fido2AuthService = new LazyResolve<IFido2AuthenticationService>();
public CredentialProviderViewController(IntPtr handle)
: base(handle)
@@ -46,8 +48,12 @@ namespace Bit.iOS.Autofill
{
try
{
ClipLogger.Log("ViewDidLoad");
InitAppIfNeeded();
ClipLogger.Log("Inited");
base.ViewDidLoad();
Logo.Image = new UIImage(ThemeHelpers.LightTheme ? "logo.png" : "logo_white.png");
@@ -57,9 +63,11 @@ namespace Bit.iOS.Autofill
ExtContext = ExtensionContext
};
ClipLogger.Log("ViewDidLoad completed");
}
catch (Exception ex)
{
ClipLogger.Log($"ViewDidLoad ex: {ex}");
OnProvidingCredentialException(ex);
}
}
@@ -68,6 +76,7 @@ namespace Bit.iOS.Autofill
{
try
{
ClipLogger.Log("PrepareCredentialList(ASCredentialServiceIdentifier[] serviceIdentifiers");
InitAppIfNeeded();
_context.ServiceIdentifiers = serviceIdentifiers;
if (serviceIdentifiers.Length > 0)
@@ -105,22 +114,109 @@ namespace Bit.iOS.Autofill
}
}
public override async void ProvideCredentialWithoutUserInteraction(IASCredentialRequest credentialRequest)
[Export("prepareCredentialListForServiceIdentifiers:requestParameters:")]
public override async void PrepareCredentialList(ASCredentialServiceIdentifier[] serviceIdentifiers, ASPasskeyCredentialRequestParameters requestParameters)
{
try
{
switch (credentialRequest)
ClipLogger.Log("PrepareCredentialList(ASCredentialServiceIdentifier[] serviceIdentifiers, ASPasskeyCredentialRequestParameters requestParameters");
InitAppIfNeeded();
_context.ServiceIdentifiers = serviceIdentifiers;
if (serviceIdentifiers.Length > 0)
{
case ASPasswordCredentialRequest passwordRequest:
await ProvideCredentialWithoutUserInteractionAsync(passwordRequest.CredentialIdentity as ASPasswordCredentialIdentity);
var uri = serviceIdentifiers[0].Identifier;
if (serviceIdentifiers[0].Type == ASCredentialServiceIdentifierType.Domain)
{
uri = string.Concat("https://", uri);
}
_context.UrlString = uri;
}
if (!await IsAuthed())
{
await _accountsManager.NavigateOnAccountChangeAsync(false);
}
else if (await IsLocked())
{
PerformSegue("lockPasswordSegue", this);
}
else
{
if (_context.ServiceIdentifiers == null || _context.ServiceIdentifiers.Length == 0)
{
PerformSegue("loginSearchSegue", this);
}
else
{
PerformSegue("loginListSegue", this);
}
}
}
catch (Exception ex)
{
OnProvidingCredentialException(ex);
}
}
[Export("provideCredentialWithoutUserInteractionForRequest:")]
public override async void ProvideCredentialWithoutUserInteraction(IASCredentialRequest credentialRequest)
{
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
return;
}
try
{
ClipLogger.Log("ProvideCredentialWithoutUserInteraction(IASCredentialRequest credentialRequest");
//ClipLogger.Log($"PCWUI(IASC) -> R: {credentialRequest?.GetType().FullName}");
//ClipLogger.Log($"PCWUI(IASC) -> I: {credentialRequest?.CredentialIdentity?.GetType().FullName}");
//ClipLogger.Log($"PCWUI(IASC) -> R k: {asPasskeyCredentialRequest?.GetType().FullName}");
//ClipLogger.Log($"PCWUI(IASC) -> I k: {asPasskeyCredentialRequest?.CredentialIdentity?.GetType().FullName}");
//var crType = asPasskeyCredentialRequest.GetType();
//foreach (var item in crType.GetProperties())
//{
// ClipLogger.Log($"PCWUI(IASC) -> R -> {item.Name} -- {item.PropertyType}");
//}
//var ciType = asPasskeyCredentialRequest.CredentialIdentity.GetType();
//foreach (var item in ciType.GetProperties())
//{
// ClipLogger.Log($"PCWUI(IASC) -> I -> {item.Name} -- {item.PropertyType}");
//}
switch (credentialRequest?.Type)
{
case ASCredentialRequestType.Password:
var passwordCredentialIdentity = Runtime.GetNSObject<ASPasswordCredentialIdentity>(credentialRequest.CredentialIdentity.GetHandle());
ClipLogger.Log($"PCWUI(IASC) -> Type P {passwordCredentialIdentity}");
await ProvideCredentialWithoutUserInteractionAsync(passwordCredentialIdentity);
break;
case ASPasskeyCredentialRequest passkeyRequest:
await ProvideCredentialWithoutUserInteractionAsync(passkeyRequest);
case ASCredentialRequestType.PasskeyAssertion:
var asPasskeyCredentialRequest = Runtime.GetNSObject<ASPasskeyCredentialRequest>(credentialRequest.GetHandle());
await ProvideCredentialWithoutUserInteractionAsync(asPasskeyCredentialRequest);
break;
default:
ClipLogger.Log($"PCWUI(IASC) -> Type not P nor PA");
CancelRequest(ASExtensionErrorCode.Failed);
break;
}
//switch (credentialRequest)
//{
// case ASPasswordCredentialRequest passwordRequest:
// await ProvideCredentialWithoutUserInteractionAsync(passwordRequest.CredentialIdentity as ASPasswordCredentialIdentity);
// break;
// case ASPasskeyCredentialRequest passkeyRequest:
// await ProvideCredentialWithoutUserInteractionAsync(passkeyRequest);
// break;
// default:
// CancelRequest(ASExtensionErrorCode.Failed);
// break;
//}
}
catch (Exception ex)
{
@@ -128,86 +224,87 @@ namespace Bit.iOS.Autofill
}
}
public override async void ProvideCredentialWithoutUserInteraction(ASPasswordCredentialIdentity credentialIdentity)
{
try
{
await ProvideCredentialWithoutUserInteractionAsync(credentialIdentity);
}
catch (Exception ex)
{
OnProvidingCredentialException(ex);
}
}
private async Task ProvideCredentialWithoutUserInteractionAsync(ASPasswordCredentialIdentity credentialIdentity)
{
InitAppIfNeeded();
await _stateService.Value.SetPasswordRepromptAutofillAsync(false);
await _stateService.Value.SetPasswordVerifiedAutofillAsync(false);
if (!await IsAuthed() || await IsLocked())
{
var err = new NSError(new NSString("ASExtensionErrorDomain"),
Convert.ToInt32(ASExtensionErrorCode.UserInteractionRequired), null);
ExtensionContext.CancelRequest(err);
return;
}
_context.PasswordCredentialIdentity = credentialIdentity;
await ProvideCredentialAsync(false);
}
//public override async void ProvideCredentialWithoutUserInteraction(ASPasswordCredentialIdentity credentialIdentity)
//{
// try
// {
// ClipLogger.Log("ProvideCredentialWithoutUserInteraction(ASPasswordCredentialIdentity credentialIdentity");
// await ProvideCredentialWithoutUserInteractionAsync(credentialIdentity);
// }
// catch (Exception ex)
// {
// OnProvidingCredentialException(ex);
// }
//}
[Export("prepareInterfaceToProvideCredentialForRequest:")]
public override async void PrepareInterfaceToProvideCredential(IASCredentialRequest credentialRequest)
{
try
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
switch (credentialRequest)
{
case ASPasswordCredentialRequest passwordRequest:
PrepareInterfaceToProvideCredential(passwordRequest.CredentialIdentity as ASPasswordCredentialIdentity);
break;
case ASPasskeyCredentialRequest passkeyRequest:
await PrepareInterfaceToProvideCredentialAsync(c => c.PasskeyCredentialRequest = passkeyRequest);
break;
default:
ExtensionContext?.CancelRequest(new NSError(ASExtensionErrorCodeExtensions.GetDomain(ASExtensionErrorCode.Failed), (int)ASExtensionErrorCode.Failed));
break;
}
}
catch (Exception ex)
{
OnProvidingCredentialException(ex);
}
}
public override async void PrepareInterfaceToProvideCredential(ASPasswordCredentialIdentity credentialIdentity)
{
try
{
await PrepareInterfaceToProvideCredentialAsync(c => c.PasswordCredentialIdentity = credentialIdentity);
}
catch (Exception ex)
{
OnProvidingCredentialException(ex);
}
}
private async Task PrepareInterfaceToProvideCredentialAsync(Action<Context> updateContext)
{
InitAppIfNeeded();
if (!await IsAuthed())
{
await _accountsManager.NavigateOnAccountChangeAsync(false);
return;
}
updateContext(_context);
await CheckLockAsync(async () => await ProvideCredentialAsync());
try
{
ClipLogger.Log("PrepareInterfaceToProvideCredential(IASCredentialRequest credentialRequest");
switch (credentialRequest?.Type)
{
case ASCredentialRequestType.Password:
var passwordCredentialIdentity = Runtime.GetNSObject<ASPasswordCredentialIdentity>(credentialRequest.CredentialIdentity.GetHandle());
ClipLogger.Log($"PITPC(IASCR) -> Type P {credentialRequest.CredentialIdentity}");
await PrepareInterfaceToProvideCredentialAsync(c => c.PasswordCredentialIdentity = passwordCredentialIdentity);
break;
case ASCredentialRequestType.PasskeyAssertion:
var asPasskeyCredentialRequest = Runtime.GetNSObject<ASPasskeyCredentialRequest>(credentialRequest.GetHandle());
await PrepareInterfaceToProvideCredentialAsync(c => c.PasskeyCredentialRequest = asPasskeyCredentialRequest);
break;
default:
ClipLogger.Log($"PITPC(IASCR) -> Type not P nor PA");
CancelRequest(ASExtensionErrorCode.Failed);
break;
}
//switch (credentialRequest)
//{
// case ASPasswordCredentialRequest passwordRequest:
// //PrepareInterfaceToProvideCredential(passwordRequest.CredentialIdentity as ASPasswordCredentialIdentity);
// await PrepareInterfaceToProvideCredentialAsync(c => c.PasswordCredentialIdentity = passwordRequest.CredentialIdentity as ASPasswordCredentialIdentity);
// break;
// case ASPasskeyCredentialRequest passkeyRequest:
// await PrepareInterfaceToProvideCredentialAsync(c => c.PasskeyCredentialRequest = passkeyRequest);
// break;
// default:
// CancelRequest(ASExtensionErrorCode.Failed);
// break;
//}
}
catch (Exception ex)
{
OnProvidingCredentialException(ex);
}
}
//public override async void PrepareInterfaceToProvideCredential(ASPasswordCredentialIdentity credentialIdentity)
//{
// try
// {
// ClipLogger.Log("PrepareInterfaceToProvideCredential(ASPasswordCredentialIdentity credentialIdentity");
// await PrepareInterfaceToProvideCredentialAsync(c => c.PasswordCredentialIdentity = credentialIdentity);
// }
// catch (Exception ex)
// {
// OnProvidingCredentialException(ex);
// }
//}
public override async void PrepareInterfaceForExtensionConfiguration()
{
try
{
ClipLogger.Log("PrepareInterfaceForExtensionConfiguration");
InitAppIfNeeded();
_context.Configuring = true;
if (!await IsAuthed())
@@ -222,10 +319,41 @@ namespace Bit.iOS.Autofill
OnProvidingCredentialException(ex);
}
}
private async Task ProvideCredentialWithoutUserInteractionAsync(ASPasswordCredentialIdentity credentialIdentity)
{
ClipLogger.Log("ProvideCredentialWithoutUserInteractionAsync(ASPasswordCredentialIdentity credentialIdentity");
InitAppIfNeeded();
await _stateService.Value.SetPasswordRepromptAutofillAsync(false);
await _stateService.Value.SetPasswordVerifiedAutofillAsync(false);
if (!await IsAuthed() || await IsLocked())
{
var err = new NSError(new NSString("ASExtensionErrorDomain"),
Convert.ToInt32(ASExtensionErrorCode.UserInteractionRequired), null);
ExtensionContext.CancelRequest(err);
return;
}
_context.PasswordCredentialIdentity = credentialIdentity;
await ProvideCredentialAsync(false);
}
private async Task PrepareInterfaceToProvideCredentialAsync(Action<Context> updateContext)
{
ClipLogger.Log("PrepareInterfaceToProvideCredentialAsync(Action<Context> updateContext");
InitAppIfNeeded();
if (!await IsAuthed())
{
await _accountsManager.NavigateOnAccountChangeAsync(false);
return;
}
updateContext(_context);
await CheckLockAsync(async () => await ProvideCredentialAsync());
}
public void CompleteRequest(string id = null, string username = null,
string password = null, string totp = null)
{
ClipLogger.Log("CompleteRequest");
if ((_context?.Configuring ?? true) && string.IsNullOrWhiteSpace(password))
{
ServiceContainer.Reset();
@@ -262,13 +390,13 @@ namespace Bit.iOS.Autofill
private void OnProvidingCredentialException(Exception ex)
{
//LoggerHelper.LogEvenIfCantBeResolved(ex);
UIPasteboard.General.String = ex.ToString();
LoggerHelper.LogEvenIfCantBeResolved(ex);
CancelRequest(ASExtensionErrorCode.Failed);
}
private void CancelRequest(ASExtensionErrorCode code)
{
ClipLogger.Log("CancelRequest" + code);
//var err = new NSError(new NSString("ASExtensionErrorDomain"), Convert.ToInt32(code), null);
var err = new NSError(ASExtensionErrorCodeExtensions.GetDomain(code), (int)code);
ExtensionContext?.CancelRequest(err);
@@ -278,6 +406,7 @@ namespace Bit.iOS.Autofill
{
try
{
ClipLogger.Log("Preparing for Segue");
if (segue.DestinationViewController is UINavigationController navController)
{
if (navController.TopViewController is LoginListViewController listLoginController)
@@ -316,13 +445,15 @@ namespace Bit.iOS.Autofill
}
}
public async void DismissLockAndContinue()
public void DismissLockAndContinue()
{
ClipLogger.Log("DismissLockAndContinue");
DismissViewController(false, async () => await OnLockDismissedAsync());
}
private void NavigateToPage(ContentPage page)
{
ClipLogger.Log("NavigateToPage" + page.GetType().FullName);
var navigationPage = new NavigationPage(page);
var uiController = navigationPage.ToUIViewController(MauiContextSingleton.Instance.MauiContext);
uiController.ModalPresentationStyle = UIModalPresentationStyle.FullScreen;
@@ -334,23 +465,36 @@ namespace Bit.iOS.Autofill
{
try
{
if (_context.PasswordCredentialIdentity != null)
ClipLogger.Log("OnLockDismissedAsync");
if (_context.IsCreatingPasskey)
{
ClipLogger.Log("OnLockDismissedAsync -> IsCreatingPasskey");
_context._unlockVaultTcs.SetResult(true);
return;
}
if (_context.PasswordCredentialIdentity != null || _context.IsPasskey)
{
ClipLogger.Log("OnLockDismissedAsync -> ProvideCredentialAsync");
await MainThread.InvokeOnMainThreadAsync(() => ProvideCredentialAsync());
return;
}
if (_context.Configuring)
{
ClipLogger.Log("OnLockDismissedAsync -> Configuring");
await MainThread.InvokeOnMainThreadAsync(() => PerformSegue("setupSegue", this));
return;
}
if (_context.ServiceIdentifiers == null || _context.ServiceIdentifiers.Length == 0)
{
ClipLogger.Log("OnLockDismissedAsync -> loginSearchSegue");
await MainThread.InvokeOnMainThreadAsync(() => PerformSegue("loginSearchSegue", this));
}
else
{
ClipLogger.Log("OnLockDismissedAsync -> loginListSegue");
await MainThread.InvokeOnMainThreadAsync(() => PerformSegue("loginListSegue", this));
}
}
@@ -362,16 +506,48 @@ namespace Bit.iOS.Autofill
private async Task ProvideCredentialAsync(bool userInteraction = true)
{
_userInterface.WithEnsureUnlockedVaultAsyncCallback(async () => {
if (!userInteraction && (!await IsAuthed() || await IsLocked())) {
throw new UserInteractionRequiredException();
}
await EnsureUnlockedVaultAsync();
});
try
{
ClipLogger.Log("ProvideCredentialAsync");
if (_context.IsPasskey && UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
if (_context.PasskeyCredentialIdentity is null)
{
ClipLogger.Log("ProvideCredentialAsync -> IsPasskey failed");
CancelRequest(ASExtensionErrorCode.Failed);
}
ClipLogger.Log("ProvideCredentialAsync -> IsPasskey");
ClipLogger.Log($"ProvideCredentialAsync -> IsPasskey - RP: {_context.PasskeyCredentialIdentity.RelyingPartyIdentifier}");
ClipLogger.Log($"ProvideCredentialAsync -> IsPasskey - UH: {_context.PasskeyCredentialIdentity.UserHandle}");
ClipLogger.Log($"ProvideCredentialAsync -> IsPasskey - CID: {_context.PasskeyCredentialIdentity.CredentialId}");
ClipLogger.Log($"ProvideCredentialAsync -> IsPasskey - RI: {_context.RecordIdentifier}");
await CompleteAssertionRequestAsync(_context.PasskeyCredentialIdentity.RelyingPartyIdentifier,
_context.PasskeyCredentialIdentity.UserHandle,
_context.PasskeyCredentialIdentity.CredentialId,
_context.RecordIdentifier);
return;
}
if (!ServiceContainer.TryResolve<ICipherService>(out var cipherService)
||
_context.RecordIdentifier == null)
{
ClipLogger.Log("ProvideCredentialAsync -> CredentialIdentityNotFound");
CancelRequest(ASExtensionErrorCode.CredentialIdentityNotFound);
return;
}
ClipLogger.Log("ProvideCredentialAsync -> IsPassword");
var cipher = await cipherService.GetAsync(_context.RecordIdentifier);
if (cipher?.Login is null || cipher.Type != CipherType.Login)
{
@@ -411,12 +587,6 @@ namespace Bit.iOS.Autofill
}
}
if (_context.IsPasskey)
{
await CompleteAssertionRequestAsync(decCipher);
return;
}
string totpCode = null;
if (await _stateService.Value.GetDisableAutoTotpCopyAsync() != true)
{
@@ -438,6 +608,7 @@ namespace Bit.iOS.Autofill
private async Task CheckLockAsync(Action notLockedAction)
{
ClipLogger.Log("CheckLockAsync");
if (await IsLocked() || await _stateService.Value.GetPasswordRepromptAutofillAsync())
{
DispatchQueue.MainQueue.DispatchAsync(() => PerformSegue("lockPasswordSegue", this));
@@ -461,6 +632,7 @@ namespace Bit.iOS.Autofill
private void LogoutIfAuthed()
{
ClipLogger.Log("LogoutIfAuthed");
NSRunLoop.Main.BeginInvokeOnMainThread(async () =>
{
try
@@ -483,12 +655,14 @@ namespace Bit.iOS.Autofill
private void InitApp()
{
ClipLogger.Log("InitApp");
iOSCoreHelpers.InitApp(this, Bit.Core.Constants.iOSAutoFillClearCiphersCacheKey,
_nfcSession, out _nfcDelegate, out _accountsManager);
}
private void InitAppIfNeeded()
{
ClipLogger.Log("InitAppIfNeeded");
if (ServiceContainer.RegisteredServices == null || ServiceContainer.RegisteredServices.Count == 0)
{
InitApp();
@@ -686,6 +860,7 @@ namespace Bit.iOS.Autofill
public void Navigate(NavigationTarget navTarget, INavigationParams navParams = null)
{
ClipLogger.Log("Navigate" + navTarget);
switch (navTarget)
{
case NavigationTarget.HomeLogin:

View File

@@ -1,6 +1,8 @@
using AuthenticationServices;
using System.Threading.Tasks;
using AuthenticationServices;
using Bit.iOS.Core.Models;
using Foundation;
using ObjCRuntime;
using UIKit;
namespace Bit.iOS.Autofill.Models
@@ -12,14 +14,16 @@ namespace Bit.iOS.Autofill.Models
public ASPasswordCredentialIdentity PasswordCredentialIdentity { get; set; }
public ASPasskeyCredentialRequest PasskeyCredentialRequest { get; set; }
public bool Configuring { get; set; }
public bool IsCreatingPasskey { get; set; }
public TaskCompletionSource<bool> _unlockVaultTcs { get; set; }
public ASPasskeyCredentialIdentity PasskeyCredentialIdentity
{
get
{
if (UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
if (PasskeyCredentialRequest != null && UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
return PasskeyCredentialRequest?.CredentialIdentity as ASPasskeyCredentialIdentity;
return Runtime.GetNSObject<ASPasskeyCredentialIdentity>(PasskeyCredentialRequest.CredentialIdentity.GetHandle());
}
return null;
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models.View;
using Bit.Core.Utilities;
using Foundation;
using UIKit;
namespace Bit.iOS.Core.Utilities
@@ -139,14 +140,14 @@ namespace Bit.iOS.Core.Utilities
return ToPasswordCredentialIdentity(cipher);
}
if (!cipher.Login.MainFido2Credential.IsDiscoverable)
if (!cipher.Login.MainFido2Credential.DiscoverableValue)
{
return null;
}
return new ASPasskeyCredentialIdentity(cipher.Login.MainFido2Credential.RpId,
cipher.Login.MainFido2Credential.UserName,
cipher.Login.MainFido2Credential.CredentialId,
NSData.FromArray(cipher.Login.MainFido2Credential.CredentialId.GuidToRawFormat()),
cipher.Login.MainFido2Credential.UserHandle,
cipher.Id);
}

View File

@@ -0,0 +1,15 @@
using System.Runtime.InteropServices;
using Foundation;
namespace Bit.iOS.Core.Utilities
{
public static class NSDataExtensions
{
public static byte[] ToByteArray(this NSData data)
{
var bytes = new byte[data.Length];
Marshal.Copy(data.Bytes, bytes, 0, Convert.ToInt32(data.Length));
return bytes;
}
}
}

View File

@@ -123,7 +123,7 @@ namespace Bit.iOS.Core.Utilities
else
{
#if DEBUG
logger = DebugLogger.Instance;
logger = ClipLogger.Instance;
#else
logger = Logger.Instance;
#endif
@@ -138,6 +138,7 @@ namespace Bit.iOS.Core.Utilities
logger!.Exception(nreAppGroupContainer);
throw nreAppGroupContainer;
}
var liteDbStorage = new LiteDbStorageService(
Path.Combine(appGroupContainer.Path, "Library", "bitwarden.db"));
var localizeService = new LocalizeService();
@@ -189,6 +190,11 @@ namespace Bit.iOS.Core.Utilities
public static void RegisterFinallyBeforeBootstrap()
{
ServiceContainer.Register<IFido2AuthenticatorService>(new Fido2AuthenticatorService(
ServiceContainer.Resolve<ICipherService>(),
ServiceContainer.Resolve<ISyncService>(),
ServiceContainer.Resolve<ICryptoFunctionService>()));
ServiceContainer.Register<IWatchDeviceService>(new WatchDeviceService(ServiceContainer.Resolve<ICipherService>(),
ServiceContainer.Resolve<IEnvironmentService>(),
ServiceContainer.Resolve<IStateService>(),

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="NSubstitute" Version="5.0.0" />
<PackageReference Include="System.Formats.Cbor" Version="8.0.0" />
<PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,373 @@
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<Fido2AuthenticatorService> _sutProvider = new SutProvider<Fido2AuthenticatorService>().Create();
private readonly IFido2UserInterface _userInterface = Substitute.For<IFido2UserInterface>();
private List<Guid> _credentialIds;
private List<CipherView> _ciphers;
private Fido2AuthenticatorGetAssertionParams _params;
private CipherView _selectedCipher;
/// <summary>
/// Sets up a working environment for the tests.
/// </summary>
public Fido2AuthenticatorGetAssertionTests()
{
_credentialIds = [ Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() ];
_ciphers = [
CreateCipherView(_credentialIds[0].ToString(), _rpId, false),
CreateCipherView(_credentialIds[1].ToString(), _rpId, true),
];
_selectedCipher = _ciphers[0];
_params = CreateParams(
rpId: _rpId,
allowCredentialDescriptorList: [
new PublicKeyCredentialDescriptor {
Id = _credentialIds[0].ToByteArray(),
Type = "public-key"
},
new PublicKeyCredentialDescriptor {
Id = _credentialIds[1].ToByteArray(),
Type = "public-key"
},
],
requireUserVerification: false
);
_sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(_ciphers);
_userInterface.PickCredentialAsync(Arg.Any<Fido2PickCredentialParams>()).Returns(new Fido2PickCredentialResult {
CipherId = _ciphers[0].Id,
UserVerified = false
});
_sutProvider.Sut.Init(_userInterface);
}
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<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params));
}
[Fact]
public async Task GetAssertionAsync_ThrowsNotAllowed_CredentialExistsButRpIdDoesNotMatch()
{
// Arrange
_params.RpId = "mismatch-rpid";
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params));
}
#endregion
#region vault contains credential
[Fact]
public async Task GetAssertionAsync_AsksForAllCredentials_ParamsContainsAllowedCredentialsList()
{
// Arrange
_params.AllowCredentialDescriptorList = [
new PublicKeyCredentialDescriptor {
Id = _credentialIds[0].ToByteArray(),
Type = "public-key"
},
new PublicKeyCredentialDescriptor {
Id = _credentialIds[1].ToByteArray(),
Type = "public-key"
},
];
// Act
await _sutProvider.Sut.GetAssertionAsync(_params);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2PickCredentialParams>(
(pickCredentialParams) => pickCredentialParams.CipherIds.SequenceEqual(_ciphers.Select((cipher) => cipher.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<Fido2PickCredentialParams>()).Returns(new Fido2PickCredentialResult {
CipherId = discoverableCiphers[0].Id,
UserVerified = false
});
// Act
await _sutProvider.Sut.GetAssertionAsync(_params);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2PickCredentialParams>(
(pickCredentialParams) => pickCredentialParams.CipherIds.SequenceEqual(discoverableCiphers.Select((cipher) => cipher.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<Fido2PickCredentialParams>()).Returns(new Fido2PickCredentialResult {
CipherId = _ciphers[0].Id,
UserVerified = true
});
// Act
await _sutProvider.Sut.GetAssertionAsync(_params);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2PickCredentialParams>(
(pickCredentialParams) => pickCredentialParams.UserVerification == 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.
public async Task GetAssertionAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification() {
// Arrange
_params.RequireUserVerification = false;
// Act
await _sutProvider.Sut.GetAssertionAsync(_params);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2PickCredentialParams>(
(pickCredentialParams) => pickCredentialParams.UserVerification == false
));
}
[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<Fido2PickCredentialParams>()).Returns(new Fido2PickCredentialResult {
CipherId = null,
UserVerified = false
});
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params));
}
[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<Fido2PickCredentialParams>()).Returns(new Fido2PickCredentialResult {
CipherId = _selectedCipher.Id,
UserVerified = false
});
// Act and assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params));
}
[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<Fido2PickCredentialParams>()).Returns(new Fido2PickCredentialResult {
CipherId = _selectedCipher.Id,
UserVerified = false
});
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params));
}
#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<ICipherService>().EncryptAsync(_selectedCipher).Returns(encryptedCipher);
// Act
await _sutProvider.Sut.GetAssertionAsync(_params);
// Assert
await _sutProvider.GetDependency<ICipherService>().Received().SaveWithServerAsync(encryptedCipher);
await _sutProvider.GetDependency<ICipherService>().Received().EncryptAsync(Arg.Is<CipherView>(
(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<ICipherService>().EncryptAsync(_selectedCipher).Returns(encryptedCipher);
// Act
await _sutProvider.Sut.GetAssertionAsync(_params);
// Assert
await _sutProvider.GetDependency<ICipherService>().Received().SaveWithServerAsync(encryptedCipher);
await _sutProvider.GetDependency<ICipherService>().Received().EncryptAsync(Arg.Is<CipherView>(
(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<ICryptoFunctionService>().HashAsync(_params.RpId, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock);
_userInterface.PickCredentialAsync(Arg.Any<Fido2PickCredentialParams>()).Returns(new Fido2PickCredentialResult {
CipherId = _selectedCipher.Id,
UserVerified = true
});
// Act
var result = await _sutProvider.Sut.GetAssertionAsync(_params);
// 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(Guid.Parse(_selectedCipher.Login.MainFido2Credential.CredentialId).ToByteArray(), 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_DoesNotAskForConfirmation_ParamsContainsOneAllowedCredentialAndUserPresenceIsFalse()
{
// Arrange
var rpIdHashMock = RandomBytes(32);
_params.RequireUserPresence = false;
_params.AllowCredentialDescriptorList = [
new PublicKeyCredentialDescriptor {
Id = _credentialIds[0].ToByteArray(),
Type = "public-key"
},
];
_sutProvider.GetDependency<ICryptoFunctionService>().HashAsync(_params.RpId, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock);
// Act
var result = await _sutProvider.Sut.GetAssertionAsync(_params);
// Assert
await _userInterface.DidNotReceive().PickCredentialAsync(Arg.Any<Fido2PickCredentialParams>());
var authData = result.AuthenticatorData;
var flags = authData.Skip(32).Take(1);
Assert.Equal(new byte[] { 0b00011000 }, flags); // UP = false, UV = false, BE = true, BS = true
}
[Fact]
public async Task GetAssertionAsync_ThrowsUnknownError_SaveFails() {
// Arrange
_sutProvider.GetDependency<ICipherService>().SaveWithServerAsync(Arg.Any<Cipher>()).Throws(new Exception());
// Act & Assert
await Assert.ThrowsAsync<UnknownError>(() => _sutProvider.Sut.GetAssertionAsync(_params));
}
#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)
{
return new CipherView {
Type = CipherType.Login,
Id = Guid.NewGuid().ToString(),
Reprompt = CipherRepromptType.None,
Login = new LoginView {
Fido2Credentials = new List<Fido2CredentialView> {
new Fido2CredentialView {
CredentialId = credentialId ?? Guid.NewGuid().ToString(),
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,
RequireUserPresence = requireUserPresence ?? true,
RequireUserVerification = requireUserPresence ?? false
};
}
}
}

View File

@@ -0,0 +1,396 @@
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<Fido2AuthenticatorService> _sutProvider = new SutProvider<Fido2AuthenticatorService>().Create();
private Cipher _encryptedSelectedCipher;
private CipherView _selectedCipherView;
private Fido2AuthenticatorMakeCredentialParams _params;
private List<Guid> _credentialIds;
private List<CipherView> _ciphers;
public Fido2AuthenticatorMakeCredentialTests() {
_credentialIds = [ Guid.NewGuid(), Guid.NewGuid() ];
_ciphers = [
CreateCipherView(true, _credentialIds[0].ToString(), "bitwarden.com", false),
CreateCipherView(true, _credentialIds[1].ToString(), "bitwarden.com", true)
];
_selectedCipherView = _ciphers[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 = "public-key",
Alg = -7 // ES256
}
],
RequireResidentKey = false,
RequireUserVerification = false,
ExcludeCredentialDescriptorList = null
};
_sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(_ciphers);
_sutProvider.GetDependency<ICipherService>().EncryptAsync(Arg.Any<CipherView>()).Returns(_encryptedSelectedCipher);
_sutProvider.GetDependency<ICipherService>().GetAsync(Arg.Is(_encryptedSelectedCipher.Id)).Returns(_encryptedSelectedCipher);
_sutProvider.GetDependency<IFido2UserInterface>().ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns(new Fido2ConfirmNewCredentialResult {
CipherId = _selectedCipherView.Id,
UserVerified = false
});
var cryptoServiceMock = Substitute.For<ICryptoService>();
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 = "public-key",
Alg = -257 // RS256 which we do not support
}
];
// Act & Assert
await Assert.ThrowsAsync<NotSupportedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params));
}
#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 = "public-key",
Id = _credentialIds[0].ToByteArray()
}
];
// Act
try
{
await _sutProvider.Sut.MakeCredentialAsync(_params);
}
catch {}
// Assert
await _sutProvider.GetDependency<IFido2UserInterface>().Received().InformExcludedCredential(Arg.Is<string[]>(
(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 = "public-key",
Id = _credentialIds[0].ToByteArray()
}
];
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params));
}
[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 = "public-key",
Id = _credentialIds[0].ToByteArray()
}
];
await _sutProvider.Sut.MakeCredentialAsync(_params);
await _sutProvider.GetDependency<IFido2UserInterface>().DidNotReceive().InformExcludedCredential(Arg.Any<string[]>());
}
#endregion
#region credential creation
[Fact]
public async Task MakeCredentialAsync_RequestsUserVerification_ParamsRequireUserVerification()
{
// Arrange
_params.RequireUserVerification = true;
_sutProvider.GetDependency<IFido2UserInterface>().ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns(new Fido2ConfirmNewCredentialResult {
CipherId = _selectedCipherView.Id,
UserVerified = true
});
// Act
await _sutProvider.Sut.MakeCredentialAsync(_params);
// Assert
await _sutProvider.GetDependency<IFido2UserInterface>().Received().ConfirmNewCredentialAsync(Arg.Is<Fido2ConfirmNewCredentialParams>(
(p) => p.UserVerification == true
));
}
[Fact]
public async Task MakeCredentialAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification()
{
// Arrange
_params.RequireUserVerification = false;
// Act
await _sutProvider.Sut.MakeCredentialAsync(_params);
// Assert
await _sutProvider.GetDependency<IFido2UserInterface>().Received().ConfirmNewCredentialAsync(Arg.Is<Fido2ConfirmNewCredentialParams>(
(p) => p.UserVerification == false
));
}
[Fact]
public async Task MakeCredentialAsync_SavesNewCredential_RequestConfirmedByUser()
{
// Arrange
_params.RequireResidentKey = true;
// Act
await _sutProvider.Sut.MakeCredentialAsync(_params);
// Assert
await _sutProvider.GetDependency<ICipherService>().Received().EncryptAsync(Arg.Is<CipherView>(
(c) =>
c.Login.MainFido2Credential.KeyType == "public-key" &&
c.Login.MainFido2Credential.KeyAlgorithm == "ECDSA" &&
c.Login.MainFido2Credential.KeyCurve == "P-256" &&
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<ICipherService>().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
_sutProvider.GetDependency<IFido2UserInterface>().ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns(new Fido2ConfirmNewCredentialResult {
CipherId = null,
UserVerified = false
});
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params));
}
[Fact]
public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationWhenRequiredByParams()
{
// Arrange
_params.RequireUserVerification = true;
_sutProvider.GetDependency<IFido2UserInterface>().ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns(new Fido2ConfirmNewCredentialResult {
CipherId = _encryptedSelectedCipher.Id,
UserVerified = false
});
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params));
}
[Fact]
public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationForCipherWithReprompt()
{
// Arrange
_params.RequireUserVerification = false;
_encryptedSelectedCipher.Reprompt = CipherRepromptType.Password;
_sutProvider.GetDependency<IFido2UserInterface>().ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns(new Fido2ConfirmNewCredentialResult {
CipherId = _encryptedSelectedCipher.Id,
UserVerified = false
});
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params));
}
[Fact]
public async Task MakeCredentialAsync_ThrowsUnknownError_SavingCipherFails()
{
// Arrange
_sutProvider.GetDependency<ICipherService>().SaveWithServerAsync(Arg.Any<Cipher>()).Throws(new Exception("Error"));
// Act & Assert
await Assert.ThrowsAsync<UnknownError>(() => _sutProvider.Sut.MakeCredentialAsync(_params));
}
[Fact]
public async Task MakeCredentialAsync_ReturnsAttestation()
{
// Arrange
var rpIdHashMock = RandomBytes(32);
_sutProvider.GetDependency<ICryptoFunctionService>().HashAsync(_params.RpEntity.Id, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock);
CipherView generatedCipherView = null;
_sutProvider.GetDependency<ICipherService>().EncryptAsync(Arg.Any<CipherView>()).Returns((call) => {
generatedCipherView = call.Arg<CipherView>();
return _encryptedSelectedCipher;
});
// Act
var result = await _sutProvider.Sut.MakeCredentialAsync(_params);
// Assert
var credentialIdBytes = Guid.Parse(generatedCipherView.Login.MainFido2Credential.CredentialId).ToByteArray();
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([0b01000001], flags); // UP = true, AD = 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 = null, 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<Fido2CredentialView> {
new Fido2CredentialView {
CredentialId = credentialId ?? Guid.NewGuid().ToString(),
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
};
}
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
using Bit.Core.Services;
using Bit.Core.Models.View;
using Bit.Core.Enums;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics.CodeAnalysis;
namespace Bit.Core.Test.Services
{
public class Fido2AuthenticatorSilentCredentialDiscoveryTests
{
[Theory]
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_NoCredentialsExist(SutProvider<Fido2AuthenticatorService> sutProvider)
{
sutProvider.GetDependency<ICipherService>().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<Fido2AuthenticatorService> sutProvider)
{
sutProvider.GetDependency<ICipherService>().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<Fido2AuthenticatorService> sutProvider)
{
sutProvider.GetDependency<ICipherService>().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<Fido2AuthenticatorService> sutProvider)
{
var matchingCredentials = new List<CipherView> {
CreateCipherView("bitwarden.com", true),
CreateCipherView("bitwarden.com", true)
};
var nonMatchingCredentials = new List<CipherView> {
CreateCipherView("example.com", true)
};
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(
matchingCredentials.Concat(nonMatchingCredentials).ToList()
);
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
Assert.True(
result.SequenceEqual(matchingCredentials.Select(c => new Fido2AuthenticatorDiscoverableCredentialMetadata {
Type = "public-key",
Id = Guid.Parse(c.Login.MainFido2Credential.CredentialId).ToByteArray(),
RpId = "bitwarden.com",
UserHandle = c.Login.MainFido2Credential.UserHandleValue,
UserName = c.Login.MainFido2Credential.UserName
}), new MetadataComparer())
);
}
private byte[] RandomBytes(int length)
{
var bytes = new byte[length];
new Random().NextBytes(bytes);
return bytes;
}
#nullable enable
private CipherView CreateCipherView(string rpId, bool discoverable)
{
return new CipherView {
Type = CipherType.Login,
Id = Guid.NewGuid().ToString(),
Reprompt = CipherRepromptType.None,
Login = new LoginView {
Fido2Credentials = new List<Fido2CredentialView> {
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<Fido2AuthenticatorDiscoverableCredentialMetadata>
{
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);
}
}
}

View File

@@ -0,0 +1,222 @@
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<Fido2ClientService> _sutProvider = new SutProvider<Fido2ClientService>().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 = "public-key"
}
],
Timeout = 60000,
};
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([]);
_sutProvider.GetDependency<IStateService>().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 callerOrigins 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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([
"sub.bitwarden.com"
]);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.AssertCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code);
}
[Fact]
public async Task AssertCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError()
{
// Arrange
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.GetAssertionAsync(Arg.Any<Fido2AuthenticatorGetAssertionParams>())
.Throws(new InvalidStateError());
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IFido2AuthenticatorService>()
.GetAssertionAsync(Arg.Any<Fido2AuthenticatorGetAssertionParams>())
.Throws(new Exception("unknown error"));
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.AssertCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code);
}
[Fact]
public async Task AssertCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut()
{
// Arrange
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(false);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IEnvironmentService>().GetWebVaultUrl().Returns("https://vault.bitwarden.com");
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IFido2AuthenticatorService>()
.GetAssertionAsync(Arg.Any<Fido2AuthenticatorGetAssertionParams>())
.Returns(authenticatorResult);
// Act
var result = await _sutProvider.Sut.AssertCredentialAsync(_params);
// Assert
await _sutProvider.GetDependency<IFido2AuthenticatorService>()
.Received()
.GetAssertionAsync(Arg.Is<Fido2AuthenticatorGetAssertionParams>(x =>
x.RpId == _params.RpId &&
x.RequireUserPresence == true &&
x.RequireUserVerification == true &&
x.AllowCredentialDescriptorList.Length == 1 &&
x.AllowCredentialDescriptorList[0].Id == _params.AllowCredentials[0].Id
));
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<JsonObject>(Encoding.UTF8.GetString(result.ClientDataJSON));
Assert.Equal("webauthn.get", clientDataJSON["type"].GetValue<string>());
Assert.Equal(CoreHelpers.Base64UrlEncode(_params.Challenge), clientDataJSON["challenge"].GetValue<string>());
Assert.Equal(_params.Origin, clientDataJSON["origin"].GetValue<string>());
Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue<bool>());
}
private byte[] RandomBytes(int length)
{
var bytes = new byte[length];
new Random().NextBytes(bytes);
return bytes;
}
}
}

View File

@@ -0,0 +1,305 @@
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<Fido2ClientService> _sutProvider = new SutProvider<Fido2ClientService>().Create();
private Fido2ClientCreateCredentialParams _params;
public Fido2ClientCreateCredentialTests()
{
_params = new Fido2ClientCreateCredentialParams {
Origin = "https://bitwarden.com",
SameOriginWithAncestors = true,
Attestation = "none",
Challenge = RandomBytes(32),
PubKeyCredParams = [
new PublicKeyCredentialParameters {
Type = "public-key",
Alg = -7
}
],
Rp = new PublicKeyCredentialRpEntity {
Id = "bitwarden.com",
Name = "Bitwarden"
},
User = new PublicKeyCredentialUserEntity {
Id = RandomBytes(32),
Name = "user@bitwarden.com",
DisplayName = "User"
}
};
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([]);
_sutProvider.GetDependency<IStateService>().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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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 callerOrigins 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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([
"sub.bitwarden.com"
]);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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 = -7
},
new PublicKeyCredentialParameters {
Type = "public-key",
Alg = -9001
}
];
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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 = -7,
};
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.MakeCredentialAsync(Arg.Any<Fido2AuthenticatorMakeCredentialParams>())
.Returns(authenticatorResult);
// Act
var result = await _sutProvider.Sut.CreateCredentialAsync(_params);
// Assert
await _sutProvider.GetDependency<IFido2AuthenticatorService>()
.Received()
.MakeCredentialAsync(Arg.Is<Fido2AuthenticatorMakeCredentialParams>(x =>
x.RequireResidentKey == true &&
x.RequireUserVerification == true &&
x.RpEntity.Id == _params.Rp.Id &&
x.UserEntity.DisplayName == _params.User.DisplayName
));
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<JsonObject>(Encoding.UTF8.GetString(result.ClientDataJSON));
Assert.Equal("webauthn.create", clientDataJSON["type"].GetValue<string>());
Assert.Equal(CoreHelpers.Base64UrlEncode(_params.Challenge), clientDataJSON["challenge"].GetValue<string>());
Assert.Equal(_params.Origin, clientDataJSON["origin"].GetValue<string>());
Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue<bool>());
}
[Fact]
public async Task CreateCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError()
{
// Arrange
_params.AuthenticatorSelection = new AuthenticatorSelectionCriteria {
ResidentKey = "required",
UserVerification = "required"
};
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.MakeCredentialAsync(Arg.Any<Fido2AuthenticatorMakeCredentialParams>())
.Throws(new InvalidStateError());
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IFido2AuthenticatorService>()
.MakeCredentialAsync(Arg.Any<Fido2AuthenticatorMakeCredentialParams>())
.Throws(new Exception("unknown error"));
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code);
}
[Fact]
public async Task CreateCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut()
{
// Arrange
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(false);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IEnvironmentService>().GetWebVaultUrl().Returns("https://vault.bitwarden.com");
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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;
}
}
}

View File

@@ -0,0 +1,40 @@
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)]
// 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("127.0.0.1", "127.0.0.1", false)]
[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));
}
}
}

View File

@@ -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<FormatException>(() => GuidExtensions.GuidToRawFormat(standardFormat));
}
[Fact]
public void GuidToRawFormat_ThrowsArgumentException_NullArgument()
{
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => GuidExtensions.GuidToStandardFormat(null));
}
[Fact]
public void GuidToStandardFormat_ThrowsArgumentException_TooLarge()
{
Assert.Throws<ArgumentException>(() => GuidExtensions.GuidToStandardFormat(new byte[17]));
}
[Fact]
public void GuidToStandardFormat_ThrowsArgumentException_TooShort()
{
Assert.Throws<ArgumentException>(() => GuidExtensions.GuidToStandardFormat(new byte[15]));
}
}
}