diff --git a/Guard.Core/Models/AuthFileData.cs b/Guard.Core/Models/AuthFileData.cs index 2f62a16..5f8358f 100644 --- a/Guard.Core/Models/AuthFileData.cs +++ b/Guard.Core/Models/AuthFileData.cs @@ -1,5 +1,12 @@ namespace Guard.Core.Models { + public class WebauthnDevice + { + public required string Id { get; set; } + public string? EncryptedName { get; set; } + public required string PublicKey { get; set; } + } + public class AuthFileData { public required int Version { get; set; } @@ -24,5 +31,10 @@ public class AuthFileData /// Additionally, this makes it slightly harder to access the sensitive data. /// public string? InsecureMainKey { get; set; } + + /// + /// Stores a list of WebAuthn devices (external FIDO2 security keys, e.g. YubiKey). + /// + public List? WebAuthn { get; set; } } } diff --git a/Guard.Core/Security/Auth.cs b/Guard.Core/Security/Auth.cs index bc043d7..a3c8932 100644 --- a/Guard.Core/Security/Auth.cs +++ b/Guard.Core/Security/Auth.cs @@ -390,5 +390,22 @@ public static string GetInstallationID() ArgumentNullException.ThrowIfNull(authData); return authData.InstallationID; } + + internal static List? GetWebAuthnDevices() + { + ArgumentNullException.ThrowIfNull(authData); + return authData.WebAuthn; + } + + internal static async void AddWebAuthnDevice(WebauthnDevice device) + { + ArgumentNullException.ThrowIfNull(authData); + if (authData.WebAuthn == null) + { + authData.WebAuthn = new(); + } + authData.WebAuthn.Add(device); + await SaveFile(); + } } } diff --git a/Guard.Core/Security/WebAuthn/WebAuthnHelper.cs b/Guard.Core/Security/WebAuthn/WebAuthnHelper.cs index 16f5b8d..7ce6abc 100644 --- a/Guard.Core/Security/WebAuthn/WebAuthnHelper.cs +++ b/Guard.Core/Security/WebAuthn/WebAuthnHelper.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Guard.Core.Security.WebAuthn.entities; using NeoSmart.Utils; -using OtpNet; namespace Guard.Core.Security.WebAuthn { @@ -18,7 +17,7 @@ public static int GetApiVersion() return WebAuthnInterop.GetApiVersion(); } - public static void Register(IntPtr windowHandle) + public static async Task<(bool success, string? error)> Register(IntPtr windowHandle) { if (!IsSupported()) { @@ -27,97 +26,105 @@ public static void Register(IntPtr windowHandle) ); } - RelayingPartyInfo relayingPartyInfo = - new() { Id = "win.2faguard.app", Name = "2FAGuard", }; + var challenge = EncryptionHelper.GetRandomBytes(32); + var extensions = new List + { + new HmacSecretCreationExtension() + }; - UserInfo userInfo = - new() - { - UserId = Encoding.UTF8.GetBytes(Auth.GetInstallationID()), - Name = "2FAGuard", - DisplayName = "2FAGuard" - }; + WindowsHello.FocusSecurityPrompt(); - List coseCredentialParameters = - new() - { - new CoseCredentialParameter - { - Algorithm = CoseAlgorithm.ECDSA_P521_WITH_SHA512 - }, - new CoseCredentialParameter - { - Algorithm = CoseAlgorithm.ECDSA_P384_WITH_SHA384 - }, - new CoseCredentialParameter { Algorithm = CoseAlgorithm.EDDSA }, - new CoseCredentialParameter - { - Algorithm = CoseAlgorithm.ECDSA_P256_WITH_SHA256 - }, - new CoseCredentialParameter - { - Algorithm = CoseAlgorithm.RSASSA_PKCS1_V1_5_WITH_SHA512, - }, - new CoseCredentialParameter { Algorithm = CoseAlgorithm.RSA_PSS_WITH_SHA512 }, - new CoseCredentialParameter - { - Algorithm = CoseAlgorithm.RSASSA_PKCS1_V1_5_WITH_SHA384, - }, - new CoseCredentialParameter { Algorithm = CoseAlgorithm.RSA_PSS_WITH_SHA384 }, - new CoseCredentialParameter + return await Task.Run<(bool success, string? error)>(() => + { + /* + * ToDo: + * - excludeCredentials + * - authenticatorSelection + * - PreferResidentKey + * - cancellation + * - credential protection + */ + var res = WebAuthnInterop.CreateCredential( + windowHandle, + WebAuthnSettings.RelayingPartyInfo, + WebAuthnSettings.UserInfo, + WebAuthnSettings.CoseCredentialParameters, + WebAuthnSettings.GetClientData( + challenge, + WebAuthnSettings.ClientDataType.Create + ), + new AuthenticatorMakeCredentialOptions { - Algorithm = CoseAlgorithm.RSASSA_PKCS1_V1_5_WITH_SHA256, + AuthenticatorAttachment = AuthenticatorAttachment.CrossPlatform, + Extensions = extensions, + UserVerificationRequirement = UserVerificationRequirement.Preferred }, - new CoseCredentialParameter { Algorithm = CoseAlgorithm.RSA_PSS_WITH_SHA256 }, - }; + out var credential + ); + + if (res != WebAuthnHResult.Ok) + { + return (false, res.ToString()); + } + + // Todo check challenge + + + Log.Logger.Information( + "WebAuthn credential registered successfully: {credential}", + credential + ); + + return (true, null); + }); + } + + public static async Task<(bool success, string? error)> Assert(IntPtr windowHandle) + { + if (!IsSupported()) + { + throw new PlatformNotSupportedException( + "WebAuthn API is not available on this platform." + ); + } var challenge = EncryptionHelper.GetRandomBytes(32); - var extensions = new List(); + var extensions = new List + { + new HmacSecretAssertionExtension() + }; + + WindowsHello.FocusSecurityPrompt(); /* - * ToDo: - * - excludeCredentials - * - authenticatorSelection - * - PreferResidentKey - * - cancellation - * - credential protection + * Todos: + * - AllowedCredentials */ - var res = WebAuthnInterop.AuthenticatorMakeCredential( - windowHandle, - relayingPartyInfo, - userInfo, - coseCredentialParameters, - new ClientData() - { - ClientDataJSON = JsonSerializer.SerializeToUtf8Bytes( - new - { - type = "webauthn.create", - challenge = UrlBase64.Encode(challenge), - origin = "win.2faguard.app" - } - ), - HashAlgorithm = HashAlgorithm.Sha512 - }, - new AuthenticatorMakeCredentialOptions - { - AuthenticatorAttachment = AuthenticatorAttachment.CrossPlatform, - Extensions = extensions, - UserVerificationRequirement = UserVerificationRequirement.Preferred - }, - out var credential - ); - - if (res != WebAuthnHResult.Ok) + return await Task.Run<(bool success, string? error)>(() => { - throw new Exception($"Failed to register WebAuthn credential: {res}"); - } + var res = WebAuthnInterop.GetAssertion( + windowHandle, + WebAuthnSettings.Origin, + WebAuthnSettings.GetClientData(challenge, WebAuthnSettings.ClientDataType.Get), + new AuthenticatorGetAssertionOptions + { + UserVerificationRequirement = UserVerificationRequirement.Preferred + }, + out var assertion + ); + + if (res != WebAuthnHResult.Ok) + { + return (false, res.ToString()); + } + + // Todo check challenge + + Log.Logger.Information("WebAuthn assertion successful: {assertion}", assertion); - Log.Logger.Information( - "WebAuthn credential registered successfully: {credential}", - credential - ); + return (true, null); + }); } } } diff --git a/Guard.Core/Security/WebAuthn/WebAuthnInterop.cs b/Guard.Core/Security/WebAuthn/WebAuthnInterop.cs index 98711c2..e08c474 100644 --- a/Guard.Core/Security/WebAuthn/WebAuthnInterop.cs +++ b/Guard.Core/Security/WebAuthn/WebAuthnInterop.cs @@ -74,7 +74,7 @@ private static extern void WebAuthNFreeCredentialAttestation( IntPtr rawCredentialAttestation ); - public static WebAuthnHResult AuthenticatorMakeCredential( + public static WebAuthnHResult CreateCredential( IntPtr window, RelayingPartyInfo rp, UserInfo user, @@ -119,5 +119,52 @@ out var rawCredPtr [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindow(string lpClassName, IntPtr ZeroOnly); + + [DllImport("webauthn.dll", CharSet = CharSet.Unicode)] + private static extern WebAuthnHResult WebAuthNAuthenticatorGetAssertion( + [In] IntPtr hWnd, + [In, Optional] string rpId, + [In] RawClientData rawClientData, + [In, Optional] RawAuthenticatorGetAssertionOptions rawGetAssertionOptions, + [Out] out IntPtr rawAssertionPtr + ); + + [DllImport("webauthn.dll", EntryPoint = "WebAuthNFreeAssertion")] + private static extern void FreeRawAssertion(IntPtr rawAssertion); + + public static WebAuthnHResult GetAssertion( + IntPtr window, + string rpId, + ClientData clientData, + AuthenticatorGetAssertionOptions getOptions, + out Assertion? assertion + ) + { + assertion = null; + + var rawClientData = new RawClientData(clientData); + var rawGetOptions = + getOptions == null ? null : new RawAuthenticatorGetAssertionOptions(getOptions); + + var res = WebAuthNAuthenticatorGetAssertion( + window, + rpId, + rawClientData, + rawGetOptions, + out var rawAsnPtr + ); + + if (rawAsnPtr != IntPtr.Zero) + { + var rawAssertion = Marshal.PtrToStructure(rawAsnPtr); + assertion = rawAssertion?.MarshalToPublic(); + FreeRawAssertion(rawAsnPtr); + } + + rawClientData.Dispose(); + rawGetOptions?.Dispose(); + + return res; + } } } diff --git a/Guard.Core/Security/WebAuthn/WebAuthnSettings.cs b/Guard.Core/Security/WebAuthn/WebAuthnSettings.cs new file mode 100644 index 0000000..84b506a --- /dev/null +++ b/Guard.Core/Security/WebAuthn/WebAuthnSettings.cs @@ -0,0 +1,82 @@ +using System.Text; +using System.Text.Json; +using Guard.Core.Security.WebAuthn.entities; +using NeoSmart.Utils; + +namespace Guard.Core.Security.WebAuthn +{ + internal class WebAuthnSettings + { + public static string Origin = "2faguard.app"; + + public static RelayingPartyInfo RelayingPartyInfo + { + get => new() { Id = Origin, Name = "2FAGuard", }; + } + + public static UserInfo UserInfo + { + get => + new() + { + UserId = Encoding.UTF8.GetBytes(Auth.GetInstallationID()), + Name = "2FAGuard User", + DisplayName = "2FAGuard User" + }; + } + + public static List CoseCredentialParameters + { + get => + new() + { + new CoseCredentialParameter + { + Algorithm = CoseAlgorithm.ECDSA_P521_WITH_SHA512 + }, + new CoseCredentialParameter + { + Algorithm = CoseAlgorithm.ECDSA_P384_WITH_SHA384 + }, + new CoseCredentialParameter { Algorithm = CoseAlgorithm.EDDSA }, + new CoseCredentialParameter + { + Algorithm = CoseAlgorithm.ECDSA_P256_WITH_SHA256 + }, + new CoseCredentialParameter + { + Algorithm = CoseAlgorithm.RSASSA_PKCS1_V1_5_WITH_SHA512, + }, + new CoseCredentialParameter { Algorithm = CoseAlgorithm.RSA_PSS_WITH_SHA512 }, + }; + } + + public enum ClientDataType + { + Create, + Get + } + + public static ClientData GetClientData(byte[] challenge, ClientDataType type) + { + string typeString = type switch + { + ClientDataType.Create => "webauthn.create", + ClientDataType.Get => "webauthn.get", + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + return new ClientData() + { + ClientDataJSON = JsonSerializer.SerializeToUtf8Bytes( + new + { + type = typeString, + challenge = UrlBase64.Encode(challenge), + origin = Origin + } + ), + HashAlgorithm = HashAlgorithm.Sha512 + }; + } + } +} diff --git a/Guard.Core/Security/WebAuthn/entities/AuthenticatorGetAssertionOptions.cs b/Guard.Core/Security/WebAuthn/entities/AuthenticatorGetAssertionOptions.cs new file mode 100644 index 0000000..c093b68 --- /dev/null +++ b/Guard.Core/Security/WebAuthn/entities/AuthenticatorGetAssertionOptions.cs @@ -0,0 +1,248 @@ +using System.Runtime.InteropServices; + +// Based on https://github.com/dbeinder/Yoq.WindowsWebAuthn - Copyright (c) 2019 David Beinder - MIT License + +namespace Guard.Core.Security.WebAuthn.entities +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal class RawAuthenticatorGetAssertionOptions + { + // Version of this structure, to allow for modifications in the future. + protected int StructVersion = 6; + + // Time that the operation is expected to complete within. + // This is used as guidance, and can be overridden by the platform. + public int TimeoutMilliseconds; + + // Allowed Credentials List. + public RawCredentialsList AllowCredentialsList; + + // Optional extensions to parse when performing the operation. + public RawWebAuthnExtensionsOut Extensions; + + // Optional. Platform vs Cross-Platform Authenticators. + public AuthenticatorAttachment AuthenticatorAttachment; + + // User Verification Requirement. + public UserVerificationRequirement UserVerificationRequirement; + + // Flags + public int Flags = 0; + + // The following fields have been added in WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS_VERSION_2 + + // Optional identifier for the U2F AppId. Converted to UTF8 before being hashed. Not lower cased. + public string U2fAppId; + + // If the following is non-NULL, then, set to TRUE if the above pwszU2fAppid was used instead of + // PCWSTR pwszRpId; + internal IntPtr U2fAppIdUsedBoolPtr; //*bool + + // Cancellation Id - Optional - See WebAuthNGetCancellationId + internal IntPtr CancellationId; //*Guid + + // @@ WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS_VERSION_4 (API v1) + + // Allow Credential List. If present, "CredentialList" will be ignored. + internal IntPtr AllowCredentialsExListPtr; //*WEBAUTHN_CREDENTIAL_LIST + + // @@ WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS_VERSION_5 (API v3) + + internal LargeBlobOperation CredLargeBlobOperation; + internal int CredLargeBlobBytes; + internal IntPtr CredLargeBlob; + + // @@ WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS_VERSION_6 (API v4) + + // PRF values which will be converted into HMAC-SECRET values according to WebAuthn Spec. + internal IntPtr HmacSecretSaltValues; + + // Optional. BrowserInPrivate Mode. Defaulting to FALSE. + internal bool BrowserInPrivateMode; + + // ------------ ignored ------------ + private readonly RawCredentialExList _allowedCredentialsExList; + private readonly RawWebAuthnExtensionOut[] _rawExtensions; + private readonly RawWebAuthnExtensionData[] _rawExtensionData; + private readonly RawHmacSecretSaltOut[] _rawHmacSalts; + private readonly RawCredWithHmacSecretSalt[] _rawHmacCredSalts; + private readonly RawHmacSecretSaltValues _rawHmacSecretSaltValues; + + public RawAuthenticatorGetAssertionOptions() { } + + public RawAuthenticatorGetAssertionOptions(AuthenticatorGetAssertionOptions getOptions) + { + AllowCredentialsList = new RawCredentialsList(getOptions.AllowedCredentials); + + if (getOptions.AllowedCredentialsEx?.Count > 0) + { + _allowedCredentialsExList = new RawCredentialExList( + getOptions.AllowedCredentialsEx + ); + AllowCredentialsExListPtr = Marshal.AllocHGlobal( + Marshal.SizeOf() + ); + Marshal.StructureToPtr(_allowedCredentialsExList, AllowCredentialsExListPtr, false); + } + + CancellationId = IntPtr.Zero; + if (getOptions.CancellationId.HasValue) + { + CancellationId = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(getOptions.CancellationId.Value, CancellationId, false); + } + + U2fAppId = getOptions.U2fAppId; + U2fAppIdUsedBoolPtr = getOptions.U2fAppId == null ? StaticBoolFalse : StaticBoolTrue; + + TimeoutMilliseconds = getOptions.TimeoutMilliseconds; + AuthenticatorAttachment = getOptions.AuthenticatorAttachment; + UserVerificationRequirement = getOptions.UserVerificationRequirement; + + var hmac = getOptions.Extensions?.FirstOrDefault(e => + e.Type == ExtensionType.HmacSecret + ); + var ex = getOptions + .Extensions?.Where(e => e.Type != ExtensionType.HmacSecret) + .Select(e => new { e.Type, Data = e.GetExtensionData() }) + .ToList(); + _rawExtensionData = ex?.Select(e => e.Data).ToArray(); + _rawExtensions = ex?.Select(e => new RawWebAuthnExtensionOut(e.Type, e.Data)).ToArray(); + Extensions = new RawWebAuthnExtensionsOut(_rawExtensions); + + if (hmac is HmacSecretAssertionExtension prfReq) + { + var hasGlobalSalt = prfReq.GlobalSalt != null; + var hasSaltsByCredential = prfReq.SaltsByCredential != null; + if (!(hasGlobalSalt ^ hasSaltsByCredential)) + throw new ArgumentException( + "can only use EITHER GlobalSalt or SaltsByCredential" + ); + + if (prfReq.UseRawSalts) + Flags |= (int)GetAssertionFlags.HmacSecretValues; + + RawHmacSecretSaltOut globalSalt = null; + if (hasGlobalSalt) + { + globalSalt = new RawHmacSecretSaltOut( + prfReq.GlobalSalt.First, + prfReq.GlobalSalt.Second + ); + _rawHmacSalts = new[] { globalSalt }; + } + else + { + _rawHmacSalts = new RawHmacSecretSaltOut[prfReq.SaltsByCredential.Count]; + _rawHmacCredSalts = new RawCredWithHmacSecretSalt[ + prfReq.SaltsByCredential.Count + ]; + var n = 0; + foreach (var kv in prfReq.SaltsByCredential) + { + _rawHmacSalts[n] = new RawHmacSecretSaltOut( + kv.Value.First, + kv.Value.Second + ); + _rawHmacCredSalts[n] = new RawCredWithHmacSecretSalt( + kv.Key, + _rawHmacSalts[n] + ); + n++; + } + } + _rawHmacSecretSaltValues = new RawHmacSecretSaltValues( + globalSalt, + _rawHmacCredSalts + ); + HmacSecretSaltValues = Marshal.AllocHGlobal( + Marshal.SizeOf() + ); + Marshal.StructureToPtr(_rawHmacSecretSaltValues, HmacSecretSaltValues, false); + } + + CredLargeBlobOperation = getOptions.LargeBlobOperation; + if (getOptions.LargeBlob != null) + { + CredLargeBlobBytes = getOptions.LargeBlob.Length; + CredLargeBlob = Marshal.AllocHGlobal(CredLargeBlobBytes); + Marshal.Copy(getOptions.LargeBlob, 0, CredLargeBlob, CredLargeBlobBytes); + } + } + + ~RawAuthenticatorGetAssertionOptions() => FreeMemory(); + + protected void FreeMemory() + { + AllowCredentialsList.Dispose(); + _allowedCredentialsExList?.Dispose(); + if (_rawExtensions != null) + foreach (var ext in _rawExtensions) + ext.Dispose(); + if (_rawExtensionData != null) + foreach (var ext in _rawExtensionData) + ext.Dispose(); + if (_rawHmacSalts != null) + foreach (var salt in _rawHmacSalts) + salt.Dispose(); + if (_rawHmacCredSalts != null) + foreach (var cs in _rawHmacSalts) + cs.Dispose(); + _rawHmacSecretSaltValues?.Dispose(); + + Helper.SafeFreeHGlobal(ref HmacSecretSaltValues); + Helper.SafeFreeHGlobal(ref AllowCredentialsExListPtr); + Helper.SafeFreeHGlobal(ref CancellationId); + Helper.SafeFreeHGlobal(ref CredLargeBlob); + } + + public void Dispose() + { + FreeMemory(); + GC.SuppressFinalize(this); + } + + static readonly IntPtr StaticBoolTrue, + StaticBoolFalse; + + static RawAuthenticatorGetAssertionOptions() + { + StaticBoolTrue = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(true, StaticBoolTrue, false); + StaticBoolFalse = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(false, StaticBoolTrue, false); + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal class AuthenticatorGetAssertionOptions + { + // Time that the operation is expected to complete within. + // This is used as guidance, and can be overridden by the platform. + public int TimeoutMilliseconds = 30000; + + // Allowed Credentials List. + public ICollection AllowedCredentials; + + // Allowed CredentialsEx List. If present, "AllowedCredentials" will be ignored. + public ICollection AllowedCredentialsEx; + + // Optional extensions to parse when performing the operation. + public IReadOnlyCollection Extensions; + + // Optional. Platform vs Cross-Platform Authenticators. + public AuthenticatorAttachment AuthenticatorAttachment; + + // User Verification Requirement. + public UserVerificationRequirement UserVerificationRequirement; + + // Optional identifier for the U2F AppId. Converted to UTF8 before being hashed. Not lower cased. + public string U2fAppId; + + // Cancellation Id - Optional - See WebAuthNGetCancellationId + public Guid? CancellationId; + + public LargeBlobOperation LargeBlobOperation; + public byte[] LargeBlob; + } +}