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 readonly IUserVerificationMediatorService _userVerificationMediatorService; public Fido2AuthenticatorService(ICipherService cipherService, ISyncService syncService, ICryptoFunctionService cryptoFunctionService, IUserVerificationMediatorService userVerificationMediatorService) { _cipherService = cipherService; _syncService = syncService; _cryptoFunctionService = cryptoFunctionService; _userVerificationMediatorService = userVerificationMediatorService; } public async Task MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams, IFido2MakeCredentialUserInterface userInterface) { if (makeCredentialParams.CredTypesAndPubKeyAlgs.All((p) => p.Alg != (int)Fido2AlgorithmIdentifier.ES256)) { throw new NotSupportedError(); } string cipherId = null; var userVerified = false; var accountSwitched = false; do { try { accountSwitched = false; await userInterface.EnsureUnlockedVaultAsync(); await _syncService.FullSyncAsync(false); var existingCipherIds = await FindExcludedCredentialsAsync( makeCredentialParams.ExcludeCredentialDescriptorList ); if (existingCipherIds.Length > 0) { await userInterface.InformExcludedCredentialAsync(existingCipherIds); throw new NotAllowedError(); } (cipherId, userVerified) = await userInterface.ConfirmNewCredentialAsync(new Fido2ConfirmNewCredentialParams { CredentialName = makeCredentialParams.RpEntity.Name, UserName = makeCredentialParams.UserEntity.Name, UserVerificationPreference = makeCredentialParams.UserVerificationPreference, RpId = makeCredentialParams.RpEntity.Id }); } catch (AccountSwitchedException) { accountSwitched = true; } } while (accountSwitched); 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 && await _userVerificationMediatorService.ShouldEnforceFido2RequiredUserVerificationAsync(new Fido2UserVerificationOptions( cipher.Reprompt != CipherRepromptType.None, makeCredentialParams.UserVerificationPreference, userInterface.HasVaultBeenUnlockedInThisTransaction))) { throw new NotAllowedError(); } cipher.Login.Fido2Credentials = new List { 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 ex) { LoggerHelper.LogEvenIfCantBeResolved(ex); throw new UnknownError(); } } public async Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams, IFido2GetAssertionUserInterface userInterface) { List cipherOptions = new List(); string selectedCipherId = null; var userVerified = false; var accountSwitched = false; do { try { accountSwitched = false; 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(); } (selectedCipherId, userVerified) = await userInterface.PickCredentialAsync( cipherOptions.Select((cipher) => new Fido2GetAssertionUserInterfaceCredential { CipherId = cipher.Id, UserVerificationPreference = Fido2UserVerificationPreferenceExtensions.GetUserVerificationPreferenceFrom(assertionParams.UserVerificationPreference, cipher.Reprompt) }).ToArray() ); } catch (AccountSwitchedException) { accountSwitched = true; } } while (accountSwitched); var selectedCipher = cipherOptions.FirstOrDefault((c) => c.Id == selectedCipherId); if (selectedCipher == null) { throw new NotAllowedError(); } if (!userVerified && await _userVerificationMediatorService.ShouldEnforceFido2RequiredUserVerificationAsync(new Fido2UserVerificationOptions( selectedCipher.Reprompt != CipherRepromptType.None, assertionParams.UserVerificationPreference, userInterface.HasVaultBeenUnlockedInThisTransaction))) { throw new NotAllowedError(); } try { var selectedFido2Credential = selectedCipher.Login.MainFido2Credential; var selectedCredentialId = selectedFido2Credential.CredentialId; await _cipherService.UpdateLastUsedDateAsync(selectedCipher.Id); if (selectedFido2Credential.CounterValue != 0) { ++selectedFido2Credential.CounterValue; 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 Fido2SelectedCredential { Id = selectedCredentialId.GuidToRawFormat(), UserHandle = selectedFido2Credential.UserHandleValue, Cipher = selectedCipher }, AuthenticatorData = authenticatorData, Signature = signature }; } catch (Exception ex) { LoggerHelper.LogEvenIfCantBeResolved(ex); throw new UnknownError(); } } public async Task SilentCredentialDiscoveryAsync(string rpId) { var credentials = (await FindCredentialsByRpAsync(rpId)).Select(cipher => new Fido2AuthenticatorDiscoverableCredentialMetadata { Type = Constants.DefaultFido2CredentialType, Id = cipher.Login.MainFido2Credential.CredentialId.GuidToRawFormat(), RpId = cipher.Login.MainFido2Credential.RpId, UserHandle = cipher.Login.MainFido2Credential.UserHandleValue, UserName = cipher.Login.MainFido2Credential.UserName, CipherId = cipher.Id, }).ToArray(); return credentials; } /// /// Finds existing crendetials and returns the `CipherId` for each one /// private async Task FindExcludedCredentialsAsync( PublicKeyCredentialDescriptor[] credentials ) { if (credentials == null || credentials.Length == 0) { return Array.Empty(); } var ids = new List(); foreach (var credential in credentials) { try { ids.Add(credential.Id.GuidToStandardFormat()); } catch { } } if (ids.Count == 0) { return Array.Empty(); } var ciphers = await _cipherService.GetAllDecryptedAsync(); return ciphers .FindAll( (cipher) => !cipher.IsDeleted && cipher.OrganizationId == null && cipher.Type == CipherType.Login && cipher.Login.HasFido2Credentials && ids.Contains(cipher.Login.MainFido2Credential.CredentialId) ) .Select((cipher) => cipher.Id) .ToArray(); } private async Task> FindCredentialsByIdAsync(PublicKeyCredentialDescriptor[] credentials, string rpId) { var ids = new List(); foreach (var credential in credentials) { try { ids.Add(credential.Id.GuidToStandardFormat()); } catch { } } if (ids.Count == 0) { return new List(); } var ciphers = await _cipherService.GetAllDecryptedAsync(); return ciphers.FindAll((cipher) => !cipher.IsDeleted && cipher.Type == CipherType.Login && cipher.Login.HasFido2Credentials && cipher.Login.MainFido2Credential.RpId == rpId && ids.Contains(cipher.Login.MainFido2Credential.CredentialId) ); } private async Task> FindCredentialsByRpAsync(string rpId) { var ciphers = await _cipherService.GetAllDecryptedAsync(); return ciphers.FindAll((cipher) => !cipher.IsDeleted && cipher.Type == CipherType.Login && cipher.Login.HasFido2Credentials && cipher.Login.MainFido2Credential.RpId == rpId && cipher.Login.MainFido2Credential.DiscoverableValue ); } // TODO: Move this to a separate service private (PublicKey publicKey, byte[] privateKey) GenerateKeyPair() { var dsa = ECDsa.Create(); dsa.GenerateKey(ECCurve.NamedCurves.nistP256); var privateKey = dsa.ExportPkcs8PrivateKey(); return (new PublicKey(dsa), privateKey); } private Fido2CredentialView CreateCredentialView(Fido2AuthenticatorMakeCredentialParams makeCredentialsParams, byte[] privateKey) { return new Fido2CredentialView { CredentialId = Guid.NewGuid().ToString(), KeyType = Constants.DefaultFido2CredentialType, KeyAlgorithm = Constants.DefaultFido2CredentialAlgorithm, KeyCurve = Constants.DefaultFido2CredentialCurve, KeyValue = CoreHelpers.Base64UrlEncode(privateKey), RpId = makeCredentialsParams.RpEntity.Id, UserHandle = CoreHelpers.Base64UrlEncode(makeCredentialsParams.UserEntity.Id), UserName = makeCredentialsParams.UserEntity.Name, CounterValue = 0, RpName = makeCredentialsParams.RpEntity.Name, UserDisplayName = makeCredentialsParams.UserEntity.DisplayName, DiscoverableValue = makeCredentialsParams.RequireResidentKey, CreationDate = DateTime.UtcNow }; } private async Task GenerateAuthDataAsync( string rpId, bool userVerification, bool userPresence, int counter, byte[] credentialId = null, PublicKey publicKey = null ) { var isAttestation = credentialId != null && publicKey != null; List authData = new List(); var rpIdHash = await _cryptoFunctionService.HashAsync(rpId, CryptoHashAlgorithm.Sha256); authData.AddRange(rpIdHash); var flags = AuthDataFlags( extensionData: false, attestationData: isAttestation, userVerification: userVerification, userPresence: userPresence ); authData.Add(flags); authData.AddRange(new List { (byte)(counter >> 24), (byte)(counter >> 16), (byte)(counter >> 8), (byte)counter }); if (isAttestation) { var attestedCredentialData = new List(); attestedCredentialData.AddRange(AAGUID); // credentialIdLength (2 bytes) and credential Id var credentialIdLength = new byte[] { (byte)((credentialId.Length - (credentialId.Length & 0xff)) / 256), (byte)(credentialId.Length & 0xff) }; attestedCredentialData.AddRange(credentialIdLength); attestedCredentialData.AddRange(credentialId); attestedCredentialData.AddRange(publicKey.ExportCose()); authData.AddRange(attestedCredentialData); } return authData.ToArray(); } private byte AuthDataFlags(bool extensionData, bool attestationData, bool userVerification, bool userPresence, bool backupEligibility = true, bool backupState = true) { byte flags = 0; if (extensionData) { flags |= 0b1000000; } if (attestationData) { flags |= 0b01000000; } if (backupState) { flags |= 0b00010000; } if (backupEligibility) { flags |= 0b00001000; } if (userVerification) { flags |= 0b00000100; } if (userPresence) { flags |= 0b00000001; } return flags; } private byte[] EncodeAttestationObject(byte[] authData) { var attestationObject = new CborWriter(CborConformanceMode.Ctap2Canonical); attestationObject.WriteStartMap(3); attestationObject.WriteTextString("fmt"); attestationObject.WriteTextString("none"); attestationObject.WriteTextString("attStmt"); attestationObject.WriteStartMap(0); attestationObject.WriteEndMap(); attestationObject.WriteTextString("authData"); attestationObject.WriteByteString(authData); attestationObject.WriteEndMap(); return attestationObject.Encode(); } // TODO: Move this to a separate service private byte[] GenerateSignature(byte[] authData, byte[] clientDataHash, byte[] privateKey) { var sigBase = authData.Concat(clientDataHash).ToArray(); var dsa = ECDsa.Create(); dsa.ImportPkcs8PrivateKey(privateKey, out var bytesRead); if (bytesRead == 0) { throw new Exception("Failed to import private key"); } return dsa.SignData(sigBase, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); } private class PublicKey { private readonly ECDsa _dsa; public PublicKey(ECDsa dsa) { _dsa = dsa; } public byte[] X => _dsa.ExportParameters(false).Q.X; public byte[] Y => _dsa.ExportParameters(false).Q.Y; public byte[] ExportDer() { return _dsa.ExportSubjectPublicKeyInfo(); } public byte[] ExportCose() { var result = new CborWriter(CborConformanceMode.Ctap2Canonical); result.WriteStartMap(5); // kty = EC2 result.WriteInt32(1); result.WriteInt32(2); // alg = ES256 result.WriteInt32(3); result.WriteInt32((int)Fido2AlgorithmIdentifier.ES256); // crv = P-256 result.WriteInt32(-1); result.WriteInt32(1); // x result.WriteInt32(-2); result.WriteByteString(X); // y result.WriteInt32(-3); result.WriteByteString(Y); result.WriteEndMap(); return result.Encode(); } } } }