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