1
0
mirror of https://github.com/bitwarden/mobile synced 2026-01-16 07:23:29 +00:00

[PM-5731] Create C# WebAuthn authenticator to support maui apps (#2951)

* [PM-5731] feat: implement get assertion params object

* [PM-5731] feat: add first test

* [PM-5731] feat: add rp mismatch test

* [PM-5731] feat: ask for credentials when found

* [PM-5731] feat: find discoverable credentials

* [PM-5731] feat: add tests for successful UV requests

* [PM-5731] feat: add user does not consent test

* [PM-5731] feat: check for UV when reprompt is active

* [PM-5731] fix: tests a bit, needed some additional "arrange" steps

* [PM-5731] feat: add support for counter

* [PM-5731] feat: implement assertion without signature

* [PM-5732] feat: finish authenticator assertion implementation

note: CryptoFunctionService still needs Sign implemenation

* [PM-5731] chore: minor clean up

* [PM-5731] feat: scaffold make credential

* [PM-5731] feat: start implementing attestation

* [PM-5731] feat: implement credential exclusion

* [PM-5731] feat: add new credential confirmaiton

* [PM-5731] feat: implement credential creation

* [PM-5731] feat: add user verification checks

* [PM-5731] feat: add unknown error handling

* [PM-5731] chore: clean up unusued params

* [PM-5731] feat: partial attestation implementation

* [PM-5731] feat: implement key generation

* [PM-5731] feat: return public key in DER format

* [PM-5731] feat: implement signing

* [PM-5731] feat: remove logging

* [PM-5731] chore: use primary constructor

* [PM-5731] chore: add Async to method names

* [PM-5731] feat: add support for silent discoverability

* [PM-5731] feat: add support for specifying user presence requirement

* [PM-5731] feat: ensure unlocked vault

* [PM-5731] chore: clean up and refactor assertion tests

* [PM-5731] chore: clean up and refactor attestation tests

* [PM-5731] chore: add user presence todo comment

* [PM-5731] feat: scaffold fido2 client

* PM-5731 Fix build updating discoverable flag

* [PM-5731] fix: failing test

* [PM-5731] feat: add sameOriginWithAncestor and user id length checks

* [PM-5731] feat: add incomplete rpId verification

* [PM-5731] chore: document uri helpers

* [PM-5731] feat: implement fido2 client createCredential

* [PM-5731] feat: implement credential assertion in client

* fix wrong signature format

(cherry picked from commit a1c9ebf01f)

* [PM-5731] fix: issues after cherry-pick

* Fix incompatible GUID conversions

(cherry picked from commit c801b2fc3a)

* [PM-5731] chore: remove default constructor

* [PM-5731] feat: refactor user interface to increase flexibility

* [PM-5731] feat: implement generic assertion user interface class

* [PM-5731] feat: remove ability to make user presence optional

* [PM-5731] chore: remove logging comments

* [PM-5731] feat: add native reprompt support to the authenticator

* [PM-5731] feat: allow pre and post UV

* [PM-5731] chore: add `Async` to method name. Remove `I` from struct

* [PM-5731] fix: discoverable string repr lowercase

* [PM-5731] chore: don't use C# 12 features

* [PM-5731] fix: replace magic strings and numbers with contants and enums

* [PM-5731] fix: use UTC creation date

* [PM-5731] fix: formatting

* [PM-5731] chore: use properties for public fields

* [PM-5731] chore: remove TODO

* [PM-5731] fix: IsValidRpId

---------

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>
Co-authored-by: mpbw2 <59324545+mpbw2@users.noreply.github.com>
This commit is contained in:
Andreas Coroiu
2024-02-21 16:12:52 +01:00
committed by GitHub
parent d339514d9a
commit 71de3bedf4
45 changed files with 3166 additions and 21 deletions

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,12 @@
using Bit.Core.Utilities.Fido2;
namespace Bit.Core.Abstractions
{
public interface IFido2AuthenticatorService
{
Task<Fido2AuthenticatorMakeCredentialResult> MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams, IFido2MakeCredentialUserInterface userInterface);
Task<Fido2AuthenticatorGetAssertionResult> GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams, IFido2GetAssertionUserInterface userInterface);
// 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,18 @@
namespace Bit.Core.Abstractions
{
public struct Fido2GetAssertionUserInterfaceCredential
{
public string CipherId { get; set; }
public bool RequireUserVerification { get; set; }
}
public interface IFido2GetAssertionUserInterface : IFido2UserInterface
{
/// <summary>
/// Ask the user to pick a credential from a list of existing credentials.
/// </summary>
/// <param name="credentials">The credentials that the user can pick from, and if the user must be verified before completing the operation</param>
/// <returns>The ID of the cipher that contains the credentials the user picked, and if the user was verified before completing the operation</returns>
Task<(string CipherId, bool UserVerified)> PickCredentialAsync(Fido2GetAssertionUserInterfaceCredential[] credentials);
}
}

View File

@@ -0,0 +1,37 @@
namespace Bit.Core.Abstractions
{
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 interface IFido2MakeCredentialUserInterface : IFido2UserInterface
{
/// <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 InformExcludedCredentialAsync(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, and if the user was verified before completing the operation</returns>
Task<(string CipherId, bool UserVerified)> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams);
}
}

View File

@@ -0,0 +1,12 @@
namespace Bit.Core.Abstractions
{
public interface IFido2UserInterface
{
/// <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,6 @@
using Bit.Core.Enums;
using Bit.Core.Models.Domain;
using Bit.Core.Utilities;
namespace Bit.Core.Models.View
{
@@ -27,9 +28,28 @@ namespace Bit.Core.Models.View
public string Counter { get; set; }
public DateTime CreationDate { get; set; }
public int CounterValue {
get => int.TryParse(Counter, out var counter) ? counter : 0;
set => Counter = value.ToString();
}
public byte[] UserHandleValue {
get => UserHandle == null ? null : CoreHelpers.Base64UrlDecode(UserHandle);
set => UserHandle = value == null ? null : CoreHelpers.Base64UrlEncode(value);
}
public byte[] KeyBytes {
get => KeyValue == null ? null : CoreHelpers.Base64UrlDecode(KeyValue);
set => KeyValue = value == null ? null : CoreHelpers.Base64UrlEncode(value);
}
public bool DiscoverableValue {
get => bool.TryParse(Discoverable, out var discoverable) && discoverable;
set => Discoverable = value.ToString().ToLower();
}
public override string SubTitle => UserName;
public override List<KeyValuePair<string, LinkedIdType>> LinkedFieldOptions => new List<KeyValuePair<string, LinkedIdType>>();
public bool IsDiscoverable => !string.IsNullOrWhiteSpace(Discoverable);
public bool CanLaunch => !string.IsNullOrEmpty(RpId);
public string LaunchUri => $"https://{RpId}";

View File

@@ -0,0 +1,456 @@
using Bit.Core.Abstractions;
using Bit.Core.Models.View;
using Bit.Core.Enums;
using Bit.Core.Utilities.Fido2;
using Bit.Core.Utilities;
using System.Formats.Cbor;
using System.Security.Cryptography;
namespace Bit.Core.Services
{
public class Fido2AuthenticatorService : IFido2AuthenticatorService
{
// AAGUID: d548826e-79b4-db40-a3d8-11116f7e8349
public static readonly byte[] AAGUID = new byte[] { 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49 };
private readonly ICipherService _cipherService;
private readonly ISyncService _syncService;
private readonly ICryptoFunctionService _cryptoFunctionService;
public Fido2AuthenticatorService(ICipherService cipherService, ISyncService syncService, ICryptoFunctionService cryptoFunctionService)
{
_cipherService = cipherService;
_syncService = syncService;
_cryptoFunctionService = cryptoFunctionService;
}
public async Task<Fido2AuthenticatorMakeCredentialResult> MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams, IFido2MakeCredentialUserInterface userInterface)
{
if (makeCredentialParams.CredTypesAndPubKeyAlgs.All((p) => p.Alg != (int) Fido2AlgorithmIdentifier.ES256))
{
throw new NotSupportedError();
}
await userInterface.EnsureUnlockedVaultAsync();
await _syncService.FullSyncAsync(false);
var existingCipherIds = await FindExcludedCredentialsAsync(
makeCredentialParams.ExcludeCredentialDescriptorList
);
if (existingCipherIds.Length > 0) {
await userInterface.InformExcludedCredentialAsync(existingCipherIds);
throw new NotAllowedError();
}
var response = await userInterface.ConfirmNewCredentialAsync(new Fido2ConfirmNewCredentialParams {
CredentialName = makeCredentialParams.RpEntity.Name,
UserName = makeCredentialParams.UserEntity.Name,
UserVerification = makeCredentialParams.RequireUserVerification
});
var cipherId = response.CipherId;
var userVerified = response.UserVerified;
string credentialId;
if (cipherId == null) {
throw new NotAllowedError();
}
try {
var keyPair = GenerateKeyPair();
var fido2Credential = CreateCredentialView(makeCredentialParams, keyPair.privateKey);
var encrypted = await _cipherService.GetAsync(cipherId);
var cipher = await encrypted.DecryptAsync();
if (!userVerified && (makeCredentialParams.RequireUserVerification || cipher.Reprompt != CipherRepromptType.None)) {
throw new NotAllowedError();
}
cipher.Login.Fido2Credentials = [fido2Credential];
var reencrypted = await _cipherService.EncryptAsync(cipher);
await _cipherService.SaveWithServerAsync(reencrypted);
credentialId = fido2Credential.CredentialId;
var authData = await GenerateAuthDataAsync(
rpId: makeCredentialParams.RpEntity.Id,
counter: fido2Credential.CounterValue,
userPresence: true,
userVerification: userVerified,
credentialId: credentialId.GuidToRawFormat(),
publicKey: keyPair.publicKey
);
return new Fido2AuthenticatorMakeCredentialResult
{
CredentialId = credentialId.GuidToRawFormat(),
AttestationObject = EncodeAttestationObject(authData),
AuthData = authData,
PublicKey = keyPair.publicKey.ExportDer(),
PublicKeyAlgorithm = (int) Fido2AlgorithmIdentifier.ES256,
};
} catch (NotAllowedError) {
throw;
} catch (Exception) {
throw new UnknownError();
}
}
public async Task<Fido2AuthenticatorGetAssertionResult> GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams, IFido2GetAssertionUserInterface userInterface)
{
List<CipherView> cipherOptions;
await userInterface.EnsureUnlockedVaultAsync();
await _syncService.FullSyncAsync(false);
if (assertionParams.AllowCredentialDescriptorList?.Length > 0) {
cipherOptions = await FindCredentialsByIdAsync(
assertionParams.AllowCredentialDescriptorList,
assertionParams.RpId
);
} else {
cipherOptions = await FindCredentialsByRpAsync(assertionParams.RpId);
}
if (cipherOptions.Count == 0) {
throw new NotAllowedError();
}
var response = await userInterface.PickCredentialAsync(
cipherOptions.Select((cipher) => new Fido2GetAssertionUserInterfaceCredential {
CipherId = cipher.Id,
RequireUserVerification = assertionParams.RequireUserVerification || cipher.Reprompt != CipherRepromptType.None
}).ToArray()
);
var selectedCipherId = response.CipherId;
var userVerified = response.UserVerified;
var selectedCipher = cipherOptions.FirstOrDefault((c) => c.Id == selectedCipherId);
if (selectedCipher == null) {
throw new NotAllowedError();
}
if (!userVerified && (assertionParams.RequireUserVerification || selectedCipher.Reprompt != CipherRepromptType.None)) {
throw new NotAllowedError();
}
try {
var selectedFido2Credential = selectedCipher.Login.MainFido2Credential;
var selectedCredentialId = selectedFido2Credential.CredentialId;
if (selectedFido2Credential.CounterValue != 0) {
++selectedFido2Credential.CounterValue;
}
await _cipherService.UpdateLastUsedDateAsync(selectedCipher.Id);
var encrypted = await _cipherService.EncryptAsync(selectedCipher);
await _cipherService.SaveWithServerAsync(encrypted);
var authenticatorData = await GenerateAuthDataAsync(
rpId: selectedFido2Credential.RpId,
userPresence: true,
userVerification: userVerified,
counter: selectedFido2Credential.CounterValue
);
var signature = GenerateSignature(
authData: authenticatorData,
clientDataHash: assertionParams.Hash,
privateKey: selectedFido2Credential.KeyBytes
);
return new Fido2AuthenticatorGetAssertionResult
{
SelectedCredential = new Fido2AuthenticatorGetAssertionSelectedCredential
{
Id = selectedCredentialId.GuidToRawFormat(),
UserHandle = selectedFido2Credential.UserHandleValue
},
AuthenticatorData = authenticatorData,
Signature = signature
};
} catch (Exception) {
throw new UnknownError();
}
}
public async Task<Fido2AuthenticatorDiscoverableCredentialMetadata[]> SilentCredentialDiscoveryAsync(string rpId)
{
var credentials = (await FindCredentialsByRpAsync(rpId)).Select(cipher => new Fido2AuthenticatorDiscoverableCredentialMetadata {
Type = Constants.DefaultFido2CredentialType,
Id = cipher.Login.MainFido2Credential.CredentialId.GuidToRawFormat(),
RpId = cipher.Login.MainFido2Credential.RpId,
UserHandle = cipher.Login.MainFido2Credential.UserHandleValue,
UserName = cipher.Login.MainFido2Credential.UserName
}).ToArray();
return credentials;
}
/// <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
{
ids.Add(credential.Id.GuidToStandardFormat());
}
catch {}
}
if (ids.Count == 0)
{
return new List<CipherView>();
}
var ciphers = await _cipherService.GetAllDecryptedAsync();
return ciphers.FindAll((cipher) =>
!cipher.IsDeleted &&
cipher.Type == CipherType.Login &&
cipher.Login.HasFido2Credentials &&
cipher.Login.MainFido2Credential.RpId == rpId &&
ids.Contains(cipher.Login.MainFido2Credential.CredentialId)
);
}
private async Task<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.UtcNow
};
}
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);
var flags = AuthDataFlags(
extensionData: false,
attestationData: isAttestation,
userVerification: userVerification,
userPresence: userPresence
);
authData.Add(flags);
authData.AddRange([
(byte)(counter >> 24),
(byte)(counter >> 16),
(byte)(counter >> 8),
(byte)counter
]);
if (isAttestation)
{
var attestedCredentialData = new List<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());
authData.AddRange(attestedCredentialData);
}
return authData.ToArray();
}
private byte AuthDataFlags(bool extensionData, bool attestationData, bool userVerification, bool userPresence, bool backupEligibility = true, bool backupState = true) {
byte flags = 0;
if (extensionData) {
flags |= 0b1000000;
}
if (attestationData) {
flags |= 0b01000000;
}
if (backupState)
{
flags |= 0b00010000;
}
if (backupEligibility)
{
flags |= 0b00001000;
}
if (userVerification) {
flags |= 0b00000100;
}
if (userPresence) {
flags |= 0b00000001;
}
return flags;
}
private byte[] EncodeAttestationObject(byte[] authData) {
var attestationObject = new CborWriter(CborConformanceMode.Ctap2Canonical);
attestationObject.WriteStartMap(3);
attestationObject.WriteTextString("fmt");
attestationObject.WriteTextString("none");
attestationObject.WriteTextString("attStmt");
attestationObject.WriteStartMap(0);
attestationObject.WriteEndMap();
attestationObject.WriteTextString("authData");
attestationObject.WriteByteString(authData);
attestationObject.WriteEndMap();
return attestationObject.Encode();
}
// TODO: Move this to a separate service
private byte[] GenerateSignature(byte[] authData, byte[] clientDataHash, byte[] privateKey)
{
var sigBase = authData.Concat(clientDataHash).ToArray();
var dsa = ECDsa.Create();
dsa.ImportPkcs8PrivateKey(privateKey, out var bytesRead);
if (bytesRead == 0)
{
throw new Exception("Failed to import private key");
}
return dsa.SignData(sigBase, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
}
private class PublicKey
{
private readonly ECDsa _dsa;
public PublicKey(ECDsa dsa) {
_dsa = dsa;
}
public byte[] X => _dsa.ExportParameters(false).Q.X;
public byte[] Y => _dsa.ExportParameters(false).Q.Y;
public byte[] ExportDer()
{
return _dsa.ExportSubjectPublicKeyInfo();
}
public byte[] ExportCose()
{
var result = new CborWriter(CborConformanceMode.Ctap2Canonical);
result.WriteStartMap(5);
// kty = EC2
result.WriteInt32(1);
result.WriteInt32(2);
// alg = ES256
result.WriteInt32(3);
result.WriteInt32((int) Fido2AlgorithmIdentifier.ES256);
// crv = P-256
result.WriteInt32(-1);
result.WriteInt32(1);
// x
result.WriteInt32(-2);
result.WriteByteString(X);
// y
result.WriteInt32(-3);
result.WriteByteString(Y);
result.WriteEndMap();
return result.Encode();
}
}
}
}

View File

@@ -0,0 +1,252 @@
using System.Text;
using System.Text.Json;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Core.Utilities.Fido2;
namespace Bit.Core.Services
{
public class Fido2ClientService : IFido2ClientService
{
private readonly IStateService _stateService;
private readonly IEnvironmentService _environmentService;
private readonly ICryptoFunctionService _cryptoFunctionService;
private readonly IFido2AuthenticatorService _fido2AuthenticatorService;
private readonly IFido2GetAssertionUserInterface _getAssertionUserInterface;
private readonly IFido2MakeCredentialUserInterface _makeCredentialUserInterface;
public Fido2ClientService(
IStateService stateService,
IEnvironmentService environmentService,
ICryptoFunctionService cryptoFunctionService,
IFido2AuthenticatorService fido2AuthenticatorService,
IFido2GetAssertionUserInterface getAssertionUserInterface,
IFido2MakeCredentialUserInterface makeCredentialUserInterface)
{
_stateService = stateService;
_environmentService = environmentService;
_cryptoFunctionService = cryptoFunctionService;
_fido2AuthenticatorService = fido2AuthenticatorService;
_getAssertionUserInterface = getAssertionUserInterface;
_makeCredentialUserInterface = makeCredentialUserInterface;
}
public async Task<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)
{
throw new Fido2ClientException(
Fido2ClientException.ErrorCode.TypeError,
"The length of user.id is not between 1 and 64 bytes (inclusive)");
}
if (!createCredentialParams.Origin.StartsWith("https://"))
{
throw new Fido2ClientException(
Fido2ClientException.ErrorCode.SecurityError,
"Origin is not a valid https origin");
}
if (!Fido2DomainUtils.IsValidRpId(createCredentialParams.Rp.Id, createCredentialParams.Origin))
{
throw new Fido2ClientException(
Fido2ClientException.ErrorCode.SecurityError,
"RP ID cannot be used with this origin");
}
PublicKeyCredentialParameters[] credTypesAndPubKeyAlgs;
if (createCredentialParams.PubKeyCredParams?.Length > 0)
{
// Filter out all unsupported algorithms
credTypesAndPubKeyAlgs = createCredentialParams.PubKeyCredParams
.Where(kp => kp.Alg == (int) Fido2AlgorithmIdentifier.ES256 && kp.Type == Constants.DefaultFido2CredentialType)
.ToArray();
}
else
{
// Assign default algorithms
credTypesAndPubKeyAlgs = [
new PublicKeyCredentialParameters { Alg = (int) Fido2AlgorithmIdentifier.ES256, Type = Constants.DefaultFido2CredentialType },
new PublicKeyCredentialParameters { Alg = (int) Fido2AlgorithmIdentifier.RS256, Type = Constants.DefaultFido2CredentialType }
];
}
if (credTypesAndPubKeyAlgs.Length == 0)
{
throw new Fido2ClientException(Fido2ClientException.ErrorCode.NotSupportedError, "No supported algorithms found");
}
var clientDataJSON = JsonSerializer.Serialize(new {
type = "webauthn.create",
challenge = CoreHelpers.Base64UrlEncode(createCredentialParams.Challenge),
origin = createCredentialParams.Origin,
crossOrigin = !createCredentialParams.SameOriginWithAncestors,
// tokenBinding: {} // Not supported
});
var clientDataJSONBytes = Encoding.UTF8.GetBytes(clientDataJSON);
var clientDataHash = await _cryptoFunctionService.HashAsync(clientDataJSONBytes, CryptoHashAlgorithm.Sha256);
var makeCredentialParams = MapToMakeCredentialParams(createCredentialParams, credTypesAndPubKeyAlgs, clientDataHash);
try {
var makeCredentialResult = await _fido2AuthenticatorService.MakeCredentialAsync(makeCredentialParams, _makeCredentialUserInterface);
return new Fido2ClientCreateCredentialResult {
CredentialId = makeCredentialResult.CredentialId,
AttestationObject = makeCredentialResult.AttestationObject,
AuthData = makeCredentialResult.AuthData,
ClientDataJSON = clientDataJSONBytes,
PublicKey = makeCredentialResult.PublicKey,
PublicKeyAlgorithm = makeCredentialResult.PublicKeyAlgorithm,
Transports = createCredentialParams.Rp.Id == "google.com" ? ["internal", "usb"] : ["internal"] // workaround for a bug on Google's side
};
} catch (InvalidStateError) {
throw new Fido2ClientException(Fido2ClientException.ErrorCode.InvalidStateError, "Unknown invalid state encountered");
} catch (Exception) {
throw new Fido2ClientException(Fido2ClientException.ErrorCode.UnknownError, $"An unknown error occurred");
}
}
public async Task<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, _getAssertionUserInterface);
return new Fido2ClientAssertCredentialResult {
AuthenticatorData = getAssertionResult.AuthenticatorData,
ClientDataJSON = clientDataJSONBytes,
Id = CoreHelpers.Base64UrlEncode(getAssertionResult.SelectedCredential.Id),
RawId = getAssertionResult.SelectedCredential.Id,
Signature = getAssertionResult.Signature,
UserHandle = getAssertionResult.SelectedCredential.UserHandle
};
} catch (InvalidStateError) {
throw new Fido2ClientException(Fido2ClientException.ErrorCode.InvalidStateError, "Unknown invalid state encountered");
} catch (Exception) {
throw new Fido2ClientException(Fido2ClientException.ErrorCode.UnknownError, $"An unknown error occurred");
}
throw new NotImplementedException();
}
private Fido2AuthenticatorMakeCredentialParams MapToMakeCredentialParams(
Fido2ClientCreateCredentialParams createCredentialParams,
PublicKeyCredentialParameters[] credTypesAndPubKeyAlgs,
byte[] clientDataHash)
{
var requireResidentKey = createCredentialParams.AuthenticatorSelection?.ResidentKey == "required" ||
createCredentialParams.AuthenticatorSelection?.ResidentKey == "preferred" ||
(createCredentialParams.AuthenticatorSelection?.ResidentKey == null &&
createCredentialParams.AuthenticatorSelection?.RequireResidentKey == true);
var requireUserVerification = createCredentialParams.AuthenticatorSelection?.UserVerification == "required" ||
createCredentialParams.AuthenticatorSelection?.UserVerification == "preferred" ||
createCredentialParams.AuthenticatorSelection?.UserVerification == null;
return new Fido2AuthenticatorMakeCredentialParams {
RequireResidentKey = requireResidentKey,
RequireUserVerification = requireUserVerification,
ExcludeCredentialDescriptorList = createCredentialParams.ExcludeCredentials,
CredTypesAndPubKeyAlgs = credTypesAndPubKeyAlgs,
Hash = clientDataHash,
RpEntity = createCredentialParams.Rp,
UserEntity = createCredentialParams.User,
Extensions = createCredentialParams.Extensions
};
}
private Fido2AuthenticatorGetAssertionParams MapToGetAssertionParams(
Fido2ClientAssertCredentialParams assertCredentialParams,
byte[] cliendDataHash)
{
var requireUserVerification = assertCredentialParams.UserVerification == "required" ||
assertCredentialParams.UserVerification == "preferred" ||
assertCredentialParams.UserVerification == null;
return new Fido2AuthenticatorGetAssertionParams {
RpId = assertCredentialParams.RpId,
Challenge = assertCredentialParams.Challenge,
AllowCredentialDescriptorList = assertCredentialParams.AllowCredentials,
RequireUserVerification = requireUserVerification,
Hash = cliendDataHash
};
}
}
}

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,8 @@
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,25 @@
{
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>
/// The challenge to be signed by the authenticator.
/// </summary>
public byte[] Challenge { get; set; }
public object Extensions { get; set; }
}
}

View File

@@ -1,11 +1,19 @@
using System;
namespace Bit.Core.Utilities.Fido2
namespace Bit.Core.Utilities.Fido2
{
public class Fido2AuthenticatorGetAssertionResult
{
public byte[] AuthenticatorData { get; set; }
public byte[] Signature { get; set; }
public Fido2AuthenticatorGetAssertionSelectedCredential SelectedCredential { get; set; }
}
public class Fido2AuthenticatorGetAssertionSelectedCredential {
public byte[] Id { get; set; }
#nullable enable
public byte[]? UserHandle { get; set; }
}
}

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 ErrorCode Code { get; }
public string Reason { get; }
public Fido2ClientException(ErrorCode code, string reason) : base($"{code} ({reason})")
{
Code = code;
Reason = reason;
}
}
}

View File

@@ -0,0 +1,40 @@
using System.Text.RegularExpressions;
namespace Bit.Core.Utilities.Fido2
{
public class Fido2DomainUtils
{
// Loosely based on:
// https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to
public static bool IsValidRpId(string rpId, string origin)
{
if (rpId == null || rpId == "" || origin == null)
{
return false;
}
// We only care about the domain part of the origin, not the protocol or port so we remove them here,
// while still keeping ipv6 intact.
// https is enforced in the client, so we don't need to worry about that here
var originWithoutProtocolOrPort = Regex.Replace(origin, @"(https?://)?([^:/]+)(:\d+)?(/.*)?", "$2$4");
if (Uri.CheckHostName(rpId) != UriHostNameType.Dns || Uri.CheckHostName(originWithoutProtocolOrPort) != UriHostNameType.Dns)
{
return false;
}
if (rpId == originWithoutProtocolOrPort)
{
return true;
}
if (!DomainName.TryParse(rpId, out var parsedRpId) || !DomainName.TryParse(originWithoutProtocolOrPort, out var parsedOrgin))
{
return false;
}
return parsedOrgin.Tld == parsedRpId.Tld &&
parsedOrgin.Domain == parsedRpId.Domain &&
(parsedOrgin.SubDomain == parsedRpId.SubDomain || parsedOrgin.SubDomain.EndsWith(parsedRpId.SubDomain));
}
}
}

View File

@@ -0,0 +1,52 @@
using Bit.Core.Abstractions;
namespace Bit.Core.Utilities.Fido2
{
/// <summary>
/// This implementation is used when all interactions are handled by the operating system.
/// Most often the user has already picked a credential by the time the Authenticator is called,
/// so this class just returns those values.
///
/// This class has no corresponding attestation variant, because that operation requires that the
/// user interacts with the app directly.
/// </summary>
public class Fido2GetAssertionUserInterface : IFido2GetAssertionUserInterface
{
private readonly string _cipherId;
private readonly bool _userVerified = false;
private readonly Func<Task> _ensureUnlockedVaultCallback;
private readonly Func<Task<bool>> _verifyUserCallback;
/// <param name="cipherId">The cipherId for the credential that the user has already picker</param>
/// <param name="userVerified">True if the user has already been verified by the operating system</param>
public Fido2GetAssertionUserInterface(string cipherId, bool userVerified, Func<Task> ensureUnlockedVaultCallback, Func<Task<bool>> verifyUserCallback)
{
_cipherId = cipherId;
_userVerified = userVerified;
_ensureUnlockedVaultCallback = ensureUnlockedVaultCallback;
_verifyUserCallback = verifyUserCallback;
}
public async Task<(string CipherId, bool UserVerified)> PickCredentialAsync(Fido2GetAssertionUserInterfaceCredential[] credentials)
{
if (credentials.Length == 0 || !credentials.Any(c => c.CipherId == _cipherId))
{
throw new NotAllowedError();
}
var credential = credentials.First(c => c.CipherId == _cipherId);
var verified = _userVerified;
if (credential.RequireUserVerification && !verified)
{
verified = await _verifyUserCallback();
}
return (CipherId: _cipherId, UserVerified: verified);
}
public Task EnsureUnlockedVaultAsync()
{
return _ensureUnlockedVaultCallback();
}
}
}

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