mirror of
https://github.com/bitwarden/android.git
synced 2026-05-21 11:56:35 -05:00
474 lines
18 KiB
C#
474 lines
18 KiB
C#
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(ICipherService _cipherService, ISyncService _syncService, ICryptoFunctionService _cryptoFunctionService, IFido2UserInterface _userInterface) : IFido2AuthenticatorService
|
|
{
|
|
// AAGUID: d548826e-79b4-db40-a3d8-11116f7e8349
|
|
public static readonly byte[] AAGUID = [ 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49 ];
|
|
|
|
public async Task<Fido2AuthenticatorMakeCredentialResult> MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams)
|
|
{
|
|
if (makeCredentialParams.CredTypesAndPubKeyAlgs.All((p) => p.Algorithm != (int) Fido2AlgorithmIdentifier.ES256))
|
|
{
|
|
// var requestedAlgorithms = string.Join(", ", makeCredentialParams.CredTypesAndPubKeyAlgs.Select((p) => p.Algorithm).ToArray());
|
|
// _logService.Warning(
|
|
// $"[Fido2Authenticator] No compatible algorithms found, RP requested: {requestedAlgorithms}"
|
|
// );
|
|
throw new NotSupportedError();
|
|
}
|
|
|
|
// await _userInterface.EnsureUnlockedVault();
|
|
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."
|
|
// );
|
|
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
|
|
});
|
|
|
|
var cipherId = response.CipherId;
|
|
var userVerified = response.UserVerified;
|
|
string credentialId;
|
|
if (cipherId == null) {
|
|
// _logService.Info(
|
|
// "[Fido2Authenticator] Aborting because user confirmation was not recieved."
|
|
// );
|
|
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)) {
|
|
// _logService.Info(
|
|
// "[Fido2Authenticator] Aborting because user verification was unsuccessful."
|
|
// );
|
|
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: GuidToRawFormat(credentialId),
|
|
publicKey: keyPair.publicKey
|
|
);
|
|
|
|
return new Fido2AuthenticatorMakeCredentialResult
|
|
{
|
|
CredentialId = GuidToRawFormat(credentialId),
|
|
AttestationObject = EncodeAttestationObject(authData),
|
|
AuthData = authData,
|
|
PublicKey = keyPair.publicKey.ExportDer(),
|
|
PublicKeyAlgorithm = (int) Fido2AlgorithmIdentifier.ES256,
|
|
};
|
|
} catch (NotAllowedError) {
|
|
throw;
|
|
} catch (Exception) {
|
|
// _logService.Error(
|
|
// $"[Fido2Authenticator] Unknown error occured during attestation: {e.Message}"
|
|
// );
|
|
|
|
throw new UnknownError();
|
|
}
|
|
}
|
|
|
|
public async Task<Fido2AuthenticatorGetAssertionResult> GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams)
|
|
{
|
|
List<CipherView> cipherOptions;
|
|
|
|
// TODO: Unlock vault somehow
|
|
// await userInterfaceSession.ensureUnlockedVault();
|
|
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) {
|
|
// _logService.Info(
|
|
// "[Fido2Authenticator] Aborting because no matching credentials were found in the vault."
|
|
// );
|
|
|
|
throw new NotAllowedError();
|
|
}
|
|
|
|
var response = await _userInterface.PickCredentialAsync(new Fido2PickCredentialParams {
|
|
CipherIds = cipherOptions.Select((cipher) => cipher.Id).ToArray(),
|
|
UserVerification = assertionParams.RequireUserVerification
|
|
});
|
|
var selectedCipherId = response.CipherId;
|
|
var userVerified = response.UserVerified;
|
|
var selectedCipher = cipherOptions.FirstOrDefault((c) => c.Id == selectedCipherId);
|
|
|
|
if (selectedCipher == null) {
|
|
// _logService.Info(
|
|
// "[Fido2Authenticator] Aborting because the selected credential could not be found."
|
|
// );
|
|
|
|
throw new NotAllowedError();
|
|
}
|
|
|
|
if (!userVerified && (assertionParams.RequireUserVerification || selectedCipher.Reprompt != CipherRepromptType.None)) {
|
|
// _logService.Info(
|
|
// "[Fido2Authenticator] Aborting because user verification was unsuccessful."
|
|
// );
|
|
|
|
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 = GuidToRawFormat(selectedCredentialId),
|
|
UserHandle = selectedFido2Credential.UserHandleValue
|
|
},
|
|
AuthenticatorData = authenticatorData,
|
|
Signature = signature
|
|
};
|
|
} catch (Exception) {
|
|
// _logService.Error(
|
|
// $"[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 = GuidToRawFormat(cipher.Login.MainFido2Credential.CredentialId),
|
|
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
|
|
) {
|
|
var ids = new List<string>();
|
|
|
|
foreach (var credential in credentials)
|
|
{
|
|
try
|
|
{
|
|
ids.Add(GuidToStandardFormat(credential.Id));
|
|
} catch {}
|
|
}
|
|
|
|
if (ids.Count == 0) {
|
|
return [];
|
|
}
|
|
|
|
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(GuidToStandardFormat(credential.Id));
|
|
}
|
|
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 = "public-key",
|
|
KeyAlgorithm = "ECDSA",
|
|
KeyCurve = "P-256",
|
|
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);
|
|
|
|
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) {
|
|
byte flags = 0;
|
|
|
|
if (extensionData) {
|
|
flags |= 0b1000000;
|
|
}
|
|
|
|
if (attestationData) {
|
|
flags |= 0b01000000;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private string GuidToStandardFormat(byte[] bytes)
|
|
{
|
|
return new Guid(bytes).ToString();
|
|
}
|
|
|
|
private byte[] GuidToRawFormat(string guid)
|
|
{
|
|
return Guid.Parse(guid).ToByteArray();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|