aboutsummaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2024-05-31 15:22:19 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2024-05-31 15:22:19 -0400
commite8548467d945ccb286da595a02c816abb596439d (patch)
tree918bfc190e8c72f496676e5ce909e90c4bbb4ea1 /plugins
parent1ff86b7540b8de9ae5a0acb80dd5c79ea6c51823 (diff)
feat: Adding fido as an mfa type
Diffstat (limited to 'plugins')
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs21
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs282
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs178
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs74
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs3
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs29
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json12
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs62
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs50
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs50
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs40
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs85
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs44
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs181
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs43
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs13
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs136
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs43
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs126
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs248
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs (renamed from plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs)8
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs (renamed from plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs)10
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs167
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs100
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs186
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs84
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs99
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs490
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj1
32 files changed, 2091 insertions, 786 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
index 219239e..31b4180 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
@@ -38,6 +38,9 @@ using VNLib.Plugins.Essentials.Accounts.SecurityProvider;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using VNLib.Plugins.Extensions.Loading.Routing;
+using VNLib.Plugins.Essentials.Accounts.MFA.Otp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
namespace VNLib.Plugins.Essentials.Accounts
{
@@ -49,6 +52,7 @@ namespace VNLib.Plugins.Essentials.Accounts
private bool SetupMode => HostArgs.HasArgument("--account-setup");
+ /// <inheritdoc/>
protected override void OnLoad()
{
//Add optional endpoint routing
@@ -84,6 +88,11 @@ namespace VNLib.Plugins.Essentials.Accounts
this.Route<PkiLoginEndpoint>();
}
+ if (this.HasConfigForType<FidoEndpoint>())
+ {
+ this.Route<FidoEndpoint>();
+ }
+
//Only export the account security service if the configuration element is defined
if (this.HasConfigForType<AccountSecProvider>())
{
@@ -257,7 +266,11 @@ Commands:
break;
}
- user.MFADisable();
+ //Disable all mfa methods
+ user.TotpDisable();
+ //user.OtpDisable();
+ user.FidoDisable();
+
await user.ReleaseAsync();
Log.Information("Successfully disabled MFA for {id}", username);
@@ -293,7 +306,7 @@ Commands:
}
//Update the totp secret and flush changes
- user.MFASetTOTPSecret(secret);
+ user.TotpSetSecret(secret);
await user.ReleaseAsync();
Log.Information("Successfully set TOTP secret for {id}", username);
@@ -328,7 +341,7 @@ Commands:
break;
}
- PkiAuthPublicKey? pubkey = JsonSerializer.Deserialize<PkiAuthPublicKey>(pubkeyJwk);
+ OtpAuthPublicKey? pubkey = JsonSerializer.Deserialize<OtpAuthPublicKey>(pubkeyJwk);
if (pubkey == null)
{
Log.Error("You public key is not a JSON object");
@@ -345,7 +358,7 @@ Commands:
//Add/update the public key and flush changes
- user.PKIAddPublicKey(pubkey);
+ user.OtpAddPublicKey(pubkey);
await user.ReleaseAsync();
Log.Information("Successfully set TOTP secret for {id}", username);
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs
new file mode 100644
index 0000000..779d8c9
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs
@@ -0,0 +1,282 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoEndpoint.cs
+*
+* FidoEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+using System.Net;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+using FluentValidation;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Hashing;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Essentials.Extensions;
+
+using VNLib.Plugins.Essentials.Accounts.MFA;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.Endpoints
+{
+ /// <summary>
+ /// <para>
+ /// This enpdoint requires Fido to be enabled in the MFA configuration.
+ /// </para>
+ /// </summary>
+ [ConfigurationName("fido_endpoint")]
+ internal sealed class FidoEndpoint : ProtectedWebEndpoint
+ {
+ private static readonly FidoResponseValidator ResponseValidator = new();
+ private static readonly FidoClientDataJsonValidtor ClientDataValidator = new();
+
+ private readonly IUserManager _users;
+ private readonly FidoConfig _fidoConfig;
+ private readonly FidoPubkeyAlgorithm[] _supportedAlgs;
+
+ public FidoEndpoint(PluginBase plugin, IConfigScope config)
+ {
+ _users = plugin.GetOrCreateSingleton<UserManager>();
+ _fidoConfig = plugin.GetConfigElement<MFAConfig>().FIDOConfig
+ ?? throw new ConfigurationValidationException("Fido configuration was not set, but Fido endpoint was enabled");
+
+ InitPathAndLog(
+ path: config.GetRequiredProperty("path", p => p.GetString()!),
+ log: plugin.Log.CreateScope("Fido-Endpoint")
+ );
+
+ /*
+ * For now hard-code supported algorithms,
+ * ECDSA is easiest for the time being
+ */
+
+ _supportedAlgs =
+ [
+ new FidoPubkeyAlgorithm(algId: -7), //ES256
+ new FidoPubkeyAlgorithm(algId: -35), //ES384
+ new FidoPubkeyAlgorithm(algId: -36), //ES512
+ ];
+ }
+
+ protected override VfReturnType Get(HttpEntity entity)
+ {
+ return VirtualOk(entity);
+ }
+
+ protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);
+
+ if (webm.Assert(user != null, "User not found"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.NotFound);
+ }
+
+ //TODO: Store challenge in user session
+ string challenge = RandomHash.GetRandomBase64(16);
+
+ webm.Result = new FidoRegistrationMessage
+ {
+ AttestationType = _fidoConfig.AttestationType,
+ AuthSelection = _fidoConfig.FIDOAuthSelection,
+ RelyingParty = new FidoRelyingParty
+ {
+ Id = entity.Server.RequestUri.DnsSafeHost,
+ Name = _fidoConfig.SiteName
+ },
+ User = new FidoUserData
+ {
+ UserId = user.UserID,
+ UserName = user.EmailAddress,
+ DisplayName = user.EmailAddress,
+ },
+ Timeout = _fidoConfig.Timeout,
+ PubKeyCredParams = _supportedAlgs,
+ Base64Challenge = challenge,
+ };
+
+ webm.Success = true;
+
+ return VirtualOk(entity, webm);
+ }
+
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ using JsonDocument? doc = await entity.GetJsonFromFileAsync();
+
+ if(webm.Assert(doc != null, "Missing entity message"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if(doc.RootElement.TryGetProperty("response", out JsonElement deviceResponse))
+ {
+ //complete registation of new device
+ FidoAuthenticatorResponse? res = deviceResponse.Deserialize<FidoAuthenticatorResponse>();
+
+ if(webm.Assert(res != null, "Mising registation response object"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if(!ResponseValidator.Validate(res, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ return await RegisterDeviceAsync(entity, res);
+ }
+
+ return VfReturnType.NotFound;
+ }
+
+ private async ValueTask<VfReturnType> RegisterDeviceAsync(
+ HttpEntity entity,
+ FidoAuthenticatorResponse response
+ )
+ {
+ ValErrWebMessage webm = new();
+
+ bool isAlgSupported = _supportedAlgs.Any(p => p.AlgId == response.CoseAlgorithmNumber);
+
+ if(webm.Assert(isAlgSupported, "Authenticator does not support the same algorithms as the server"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ FidoClientDataJson? clientData = FidoBase64Util.DeserialzeJson<FidoClientDataJson>(response.Base64ClientData!);
+
+ if(webm.Assert(clientData != null, "Client data json is not valid"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if(!ClientDataValidator.Validate(clientData, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ return VirtualOk(entity);
+ }
+
+ }
+
+ internal sealed class FidoBase64Util
+ {
+
+ /// <summary>
+ /// Takes a base64url encoded JSON string and deserializes it into a
+ /// given object.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="base64Url">The base64url encoded JSON string to decode</param>
+ /// <returns>The instance of the object if it could be decoded</returns>
+ /// <exception cref="JsonException"></exception>
+ public static T? DeserialzeJson<T>(string base64Url)
+ {
+ /*
+ * We just need to transform the base64 encoded chars back to
+ * utf8 bytes and then deserialize the object
+ *
+ * The length is assumed to be validated before deserialization
+ */
+
+ using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base64Url.Length);
+
+ ERRNO count = VnEncoding.Base64UrlDecode(base64Url, buffer.Span, System.Text.Encoding.UTF8);
+
+ if (count < 1)
+ {
+ throw new JsonException("Failed to decode base64url");
+ }
+
+ return JsonSerializer.Deserialize<T>(buffer.AsSpan(0, count));
+ }
+ }
+
+ internal sealed class FidoResponseValidator : AbstractValidator<FidoAuthenticatorResponse>
+ {
+ public FidoResponseValidator()
+ {
+ RuleFor(c => c.DeviceId)
+ .NotEmpty()
+ .WithMessage("Fido 'device_id' must be provided")
+ .MaximumLength(256);
+
+ RuleFor(c => c.Base64PublicKey)
+ .NotEmpty()
+ .WithMessage("Fido 'public_key' must be provided");
+
+ RuleFor(c => c.CoseAlgorithmNumber)
+ .NotNull()
+ .WithMessage("Fido 'public_key_algorithm' number must be provided in a valid COSE algorithm number");
+
+ RuleFor(c => c.Base64ClientData)
+ .NotEmpty()
+ .WithMessage("Fido 'client_data' must be provided")
+ .MaximumLength(4096);
+
+ RuleFor(c => c.Base64AuthenticatorData)
+ .NotEmpty()
+ .WithMessage("Fido 'authenticator_data' must be provided")
+ .MaximumLength(4096);
+
+ RuleFor(c => c.Base64Attestation)
+ .NotEmpty()
+ .WithMessage("Fido 'attestation' must be provided")
+ .MaximumLength(4096);
+
+ }
+
+ }
+
+ internal sealed class FidoClientDataJsonValidtor : AbstractValidator<FidoClientDataJson>
+ {
+ public FidoClientDataJsonValidtor()
+ {
+ RuleFor(c => c.Base64Challenge)
+ .NotEmpty()
+ .WithMessage("Fido 'challenge' must be provided")
+ .MaximumLength(4096);
+
+ RuleFor(c => c.Origin)
+ .NotEmpty()
+ .WithMessage("Fido 'origin' must be provided")
+ .MaximumLength(1024);
+
+ RuleFor(c => c.Type)
+ .NotEmpty()
+ .WithMessage("Fido 'type' must be provided")
+ .MaximumLength(64);
+ }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
index 6e3653e..faad34b 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
@@ -27,6 +27,7 @@ using System.Net;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
@@ -44,6 +45,8 @@ using VNLib.Plugins.Essentials.Accounts.Validators;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using static VNLib.Plugins.Essentials.Statics;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
/*
@@ -73,7 +76,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private static readonly LoginMessageValidation LmValidator = new();
- private readonly MFAConfig MultiFactor;
+ private readonly MfaAuthManager MultiFactor;
private readonly IUserManager Users;
private readonly FailedLoginLockout _lockout;
@@ -84,10 +87,25 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
uint maxLogins = config["max_login_attempts"].GetUInt32();
InitPathAndLog(path, pbase.Log);
-
+
+ MFAConfig conf = pbase.GetConfigElement<MFAConfig>();
Users = pbase.GetOrCreateSingleton<UserManager>();
- MultiFactor = pbase.GetConfigElement<MFAConfig>();
_lockout = new(maxLogins, duration);
+
+
+ List<IMfaProcessor> proc = [];
+
+ if(conf.TOTPEnabled)
+ {
+ proc.Add(new TotpAuthProcessor(conf.TOTPConfig!));
+ }
+
+ if(conf.FIDOEnabled)
+ {
+ proc.Add(new FidoMfaProcessor(conf.FIDOConfig!));
+ }
+
+ MultiFactor = new(conf, [.. proc]);
}
protected override ERRNO PreProccess(HttpEntity entity)
@@ -104,14 +122,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualClose(entity, HttpStatusCode.Conflict);
}
- //If mfa is enabled, allow processing via mfa
- if (MultiFactor.FIDOEnabled || MultiFactor.TOTPEnabled)
+ /*
+ * To continue an mfa upgrade, the client must send
+ * an mfa query argument to continue the upgrade process
+ */
+ if (MultiFactor.Armed)
{
if (entity.QueryArgs.ContainsKey("mfa"))
{
return await ProcessMfaAsync(entity);
}
}
+
return await ProccesLoginAsync(entity);
}
@@ -165,7 +187,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//If login return true, the response has been set and we should return
- if (LoginUser(entity, loginMessage, user, webm))
+ if (LoginOrMfaConnection(entity, loginMessage, user, webm))
{
goto Cleanup;
}
@@ -191,12 +213,11 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
//Validate password against store
ERRNO valResult = await Users.ValidatePasswordAsync(user, login.Password!, PassValidateFlags.None, cancellation);
-
- //Valid results are greater than 0;
+
return valResult == UserPassValResult.Success;
}
- private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm)
+ private bool LoginOrMfaConnection(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm)
{
//Only allow active users
if (user.Status != UserStatus.Active)
@@ -211,22 +232,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
try
{
- //get the new upgrade jwt string
- MfaUpgradeMessage? message = user.MFAGetUpgradeIfEnabled(MultiFactor, loginMessage);
-
/*
- * Mfa is essentially indempodent, the session stores the last upgrade key, so
- * if this method is continually called, new mfa tokens will be generated.
+ * Determine if the user uses MFA to guard their account. If so
+ * force an MFA upgrade before allowing the user to login
*/
-
- //if message is null, mfa was not enabled or could not be prepared
- if (message.HasValue)
+ if (MultiFactor.HasMfaEnabled(user))
{
- //Store the base64 signature
- entity.Session.MfaUpgradeSecret(message.Value.SessionKey);
-
- //send challenge message to client
- webm.Result = message.Value.ClientJwt;
+ //the upgrade message
+ webm.Result = MultiFactor.GetChallengeMessage(entity, user, loginMessage);
webm.MultiFactorUpgrade = true;
}
else
@@ -278,31 +291,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
- //Recover upgrade jwt
- string? upgradeJwt = request.RootElement.GetPropString("upgrade");
-
- if (webm.Assert(upgradeJwt != null, "Missing required upgrade data"))
- {
- return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
- }
-
- //Recover stored signature
- string? storedSig = entity.Session.MfaUpgradeSecret();
-
- if(webm.Assert(!string.IsNullOrWhiteSpace(storedSig), MFA_ERROR_MESSAGE))
- {
- return VirtualOk(entity, webm);
- }
-
- //Recover upgrade data from upgrade message
- MFAUpgrade? upgrade = MultiFactor!.RecoverUpgrade(upgradeJwt, storedSig);
+ MfaChallenge? upgrade = MultiFactor.GetChallengeData(entity, request);
+ /*
+ * Upgrade may be null if it is not valid, not correctly formatted,
+ * expired, and so on. We cannot leak information about the upgrade
+ * request to the client, so return a generic error message
+ */
if (webm.Assert(upgrade != null, MFA_ERROR_MESSAGE))
{
return VirtualOk(entity, webm);
}
- //recover user account
using IUser? user = await Users.GetUserFromUsernameAsync(upgrade.UserName!);
if (webm.Assert(user != null, MFA_ERROR_MESSAGE))
@@ -311,81 +311,55 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
bool locked = _lockout.CheckOrClear(user, entity.RequestedTimeUtc);
-
- //Make sure the account has not been locked out
+
if (webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE))
{
//Locked, so clear stored signature
- entity.Session.MfaUpgradeSecret(null);
+ MultiFactor.InvalidateUpgrade(entity);
+ }
+ else if (MultiFactor.VerifyResponse(entity, upgrade, user, request))
+ {
+ /*
+ * ###################################################
+ *
+ * AUTHORIZATION ZONE
+ *
+ * Connection will be elevated to authorized
+ * this is a successful login!
+ *
+ * ####################################################
+ */
+
+ MultiFactor.InvalidateUpgrade(entity);
+
+ /*
+ * Time to authorize the user now. This will cause state changs
+ * to the client session, and user account. The user
+ * is now authorized to use the session.
+ */
+ entity.GenerateAuthorization(upgrade, user, webm);
+
+ webm.Result = new AccountData()
+ {
+ EmailAddress = user.EmailAddress,
+ };
+
+ webm.Success = true;
+
+ Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
}
else
{
- //process mfa login
- LoginMfa(entity, user, request, upgrade, webm);
+ webm.Result = "Please check your input and try again.";
}
- //Update user on clean process
+ //Flush any changes to the user store
await user.ReleaseAsync();
//Close rseponse
return VirtualOk(entity, webm);
}
- private void LoginMfa(HttpEntity entity, IUser user, JsonDocument request, MFAUpgrade upgrade, MfaUpgradeWebm webm)
- {
- //Recover the user's local time
- if(!request.RootElement.TryGetProperty("localtime", out JsonElement ltEl)
- && ltEl.TryGetDateTimeOffset(out DateTimeOffset localTime))
- {
- webm.Result = MFA_ERROR_MESSAGE;
- return;
- }
-
- //Check mode
- switch (upgrade.Type)
- {
- case MFAType.TOTP:
- {
- //get totp code from request
- uint code = request.RootElement.GetProperty("code").GetUInt32();
-
- //Verify totp code
- if (!MultiFactor.VerifyTOTP(user, code))
- {
- webm.Result = "Please check your code.";
-
- //Increment flc and update the user in the store
- _lockout.Increment(user, entity.RequestedTimeUtc);
- return;
- }
- //Valid, complete
- }
- break;
- default:
- webm.Result = MFA_ERROR_MESSAGE;
- return;
- }
-
- //SUCCESSFUL LOGIN
-
- //Wipe session signature
- entity.Session.MfaUpgradeSecret(null);
-
- //Elevate the login status of the session to reflect the user's status
- entity.GenerateAuthorization(upgrade, user, webm);
-
- //Send the Username (since they already have it)
- webm.Result = new AccountData()
- {
- EmailAddress = user.EmailAddress,
- };
-
- webm.Success = true;
-
- //Write to log
- Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
- }
-
private sealed class MfaUpgradeWebm : ValErrWebMessage
{
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
index 2e102a3..f31334c 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -40,9 +40,13 @@ using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Essentials.Accounts.MFA.Otp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
+
[ConfigurationName("mfa_endpoint")]
internal sealed class MFAEndpoint : ProtectedWebEndpoint
{
@@ -50,12 +54,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private const string CHECK_PASSWORD = "Please check your password";
private readonly IUserManager Users;
- private readonly MFAConfig? MultiFactor;
+ private readonly MFAConfig MultiFactor;
public MFAEndpoint(PluginBase pbase, IConfigScope config)
- {
- string? path = config["path"].GetString();
- InitPathAndLog(path, pbase.Log);
+ {
+ InitPathAndLog(
+ path: config.GetRequiredProperty("path", p => p.GetString()!),
+ log: pbase.Log.CreateScope("Mfa-Endpoint")
+ );
Users = pbase.GetOrCreateSingleton<UserManager>();
MultiFactor = pbase.GetConfigElement<MFAConfig>();
@@ -67,21 +73,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//Load the MFA entry for the user
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
-
- //Set the TOTP flag if set
- if (user?.MFATotpEnabled() == true)
+
+ if (user?.TotpEnabled() == true)
{
enabledModes[0] = "totp";
}
-
- //TODO Set fido flag if enabled
- if (!string.IsNullOrWhiteSpace(""))
+
+ if (user?.FidoEnabled() == true)
{
enabledModes[1] = "fido";
}
-
- //PKI enabled
- if (user?.PKIEnabled() == true)
+
+ if (user?.OtpAuthEnabled() == true)
{
enabledModes[2] = "pki";
}
@@ -116,12 +119,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualOk(entity, webm);
}
- //Make sure mfa is loaded
- if (webm.Assert(MultiFactor != null, "MFA is not enabled on this server"))
- {
- return VirtualOk(entity, webm);
- }
-
//Get the user entry
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
@@ -183,18 +180,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//get the request
using JsonDocument? request = await entity.GetJsonFromFileAsync();
- if (webm.Assert(request != null, "Invalid request."))
+ if (webm.Assert(request != null, "Invalid request"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
string? mfaType = request.RootElement.GetProperty("type").GetString();
-
- //get the user
+
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
- if (user == null)
+
+ if (webm.Assert(user != null, "User does not exist"))
{
- return VfReturnType.NotFound;
+ return VirtualClose(entity, webm, HttpStatusCode.NotFound);
}
/*
@@ -218,30 +215,35 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Check for totp disable
- if ("totp".Equals(mfaType, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals("totp", mfaType, StringComparison.OrdinalIgnoreCase))
{
- //Clear the TOTP secret to disable it
- user.MFASetTOTPSecret(null);
-
- //write changes
- await user.ReleaseAsync();
+ user.TotpDisable();
+
webm.Result = "Successfully disabled your TOTP authentication";
webm.Success = true;
}
- else if ("fido".Equals(mfaType, StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals("fido", mfaType, StringComparison.OrdinalIgnoreCase))
{
- //Clear webauthn changes
-
- //write changes
- await user.ReleaseAsync();
+ user.FidoDisable();
+
webm.Result = "Successfully disabled your FIDO authentication";
webm.Success = true;
}
+ else if(string.Equals("pkotp", mfaType, StringComparison.OrdinalIgnoreCase))
+ {
+ user.OtpDisable();
+
+ webm.Result = "Successfully disabled your OTP authentication";
+ webm.Success = true;
+ }
else
{
webm.Result = "Invalid MFA type";
}
+ //write changes (will do nothing if no changes were made)
+ await user.ReleaseAsync();
+
//Must write response while password is in scope
return VirtualOk(entity, webm);
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
index 60c99e3..b274f5f 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
@@ -38,6 +38,7 @@ using VNLib.Plugins.Essentials.Accounts.MFA;
using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
@@ -134,7 +135,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Check if totp is enabled
- if (mFAConfig.TOTPEnabled && user.MFATotpEnabled())
+ if (mFAConfig.TOTPEnabled && user.TotpEnabled())
{
//TOTP code is required
if (webm.Assert(pwReset.TotpCode.HasValue, "TOTP is enabled on this user account, you must enter your TOTP code."))
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
index b88dc11..bda5898 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
@@ -43,13 +43,14 @@ using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Extensions.Validation;
-using VNLib.Plugins.Essentials.Accounts.MFA;
using VNLib.Plugins.Essentials.Accounts.Validators;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Essentials.Accounts.MFA.Otp;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
+
[ConfigurationName("pki_auth_endpoint")]
internal sealed class PkiLoginEndpoint : UnprotectedWebEndpoint
{
@@ -66,9 +67,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private static IValidator<AuthenticationInfo> AuthValidator { get; } = AuthenticationInfo.GetValidator();
/// <summary>
- /// A validator used to validate <see cref="PkiAuthPublicKey"/> instances
+ /// A validator used to validate <see cref="OtpAuthPublicKey"/> instances
/// </summary>
- public static IValidator<PkiAuthPublicKey> UserJwkValidator { get; } = GetKeyValidator();
+ public static IValidator<OtpAuthPublicKey> UserJwkValidator { get; } = GetKeyValidator();
private readonly JwtEndpointConfig _config;
private readonly IUserManager _users;
@@ -176,7 +177,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Now we can verify the signed message against the stored key
- if (webm.Assert(user.PKIVerifyUserJWT(jwt, authInfo.KeyId!) == true, INVALID_MESSAGE))
+ if (webm.Assert(user.OtpVerifyUserJWT(jwt, authInfo.KeyId!) == true, INVALID_MESSAGE))
{
//increment flc on invalid signature
_lockout.Increment(user, entity.RequestedTimeUtc);
@@ -254,7 +255,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Get the uesr's stored keys
- webm.Result = user.PkiGetAllPublicKeys();
+ webm.Result = user.OtpGetAllPublicKeys();
webm.Success = true;
return VirtualOk(entity, webm);
@@ -276,7 +277,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
ValErrWebMessage webm = new();
//Get the request body
- PkiAuthPublicKey? pubKey = await entity.GetJsonFromFileAsync<PkiAuthPublicKey>();
+ OtpAuthPublicKey? pubKey = await entity.GetJsonFromFileAsync<OtpAuthPublicKey>();
if(webm.Assert(pubKey != null, "The request message is not valid"))
{
@@ -304,6 +305,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualOk(entity, webm);
}
+ //Make sure there is enough room to store another key
+ if(webm.Assert(user.OtpCanAddKey(), "Cannot add another public key to your account"))
+ {
+ return VirtualOk(entity, webm);
+ }
+
try
{
//Try to get the ECDA instance to confirm the key data could be recovered properly
@@ -323,7 +330,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Update user's key, or add it if it doesn't exist
- user.PKIAddPublicKey(pubKey);
+ user.OtpAddPublicKey(pubKey);
//publish changes
await user.ReleaseAsync();
@@ -368,13 +375,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
if(entity.QueryArgs.TryGetValue("id", out string? keyId))
{
//Remove only the specified key
- user.PKIRemovePublicKey(keyId);
+ user.OtpRemovePublicKey(keyId);
webm.Result = "You have successfully removed the key from your account";
}
else
{
//Delete all keys
- user.PKISetPublicKeys(null);
+ user.OtpSetPublicKeys(null);
webm.Result = "You have successfully disabled PKI login";
}
@@ -535,9 +542,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
}
- private static IValidator<PkiAuthPublicKey> GetKeyValidator()
+ private static IValidator<OtpAuthPublicKey> GetKeyValidator()
{
- InlineValidator<PkiAuthPublicKey> val = new();
+ InlineValidator<OtpAuthPublicKey> val = new();
val.RuleFor(a => a.KeyType)
.NotEmpty()
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json b/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json
index 8a345c5..173ed5b 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json
@@ -42,6 +42,10 @@
"enable_key_update": true
},
+ "fido_endpoint": {
+ "path": "/account/fido"
+ },
+
//If mfa is defined, configures mfa enpoints and enables mfa logins
"mfa": {
"upgrade_expires_secs": 180,
@@ -59,14 +63,14 @@
"fido": {
"challenge_size": 64,
- "attestation": "none",
"timeout": 60000,
- "site_name": "vaughnnugent.com",
+ "attestation_type": "none",
+ "site_name": "localhost",
- "authenticatorSelection": {
+ "authenticator_selection": {
"authenticatorAttachment": "cross-platform",
"requireResidentKey": false,
- "userVerification": "required"
+ "userVerification": "preferred"
}
}
},
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs
new file mode 100644
index 0000000..e0160e1
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs
@@ -0,0 +1,62 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: CoseEncodings.cs
+*
+* CoseEncodings.cs is part of VNLib.Plugins.Essentials.Accounts which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+ internal static class CoseEncodings
+ {
+ public static int GetCodeFromAlg(string algName)
+ {
+ return algName switch
+ {
+ "ES256" => -7,
+ "ES384" => -35,
+ "ES512" => -36,
+ _ => 0
+ };
+ }
+
+ public static string GetAlgFromCode(int code)
+ {
+ return code switch
+ {
+ -7 => "ES256",
+ -35 => "ES384",
+ -36 => "ES512",
+ _ => string.Empty
+ };
+ }
+
+ public static string GetCurveFromCode(int code)
+ {
+ return code switch
+ {
+ -7 => "P-256",
+ -35 => "P-384",
+ -36 => "P-521",
+ _ => string.Empty
+ };
+ }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs
new file mode 100644
index 0000000..aedb2a7
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs
@@ -0,0 +1,50 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+
+ internal sealed class FidoAuthenticatedRequest
+ {
+ /// <summary>
+ /// Base64 encoded device ID
+ /// </summary>
+ [JsonPropertyName("id")]
+ public string Base64Id { get; set; } = string.Empty;
+
+ /// <summary>
+ /// The device attachment type
+ /// </summary>
+ [JsonPropertyName("authenticatorAttachment")]
+ public string? Attachment { get; set; }
+
+ /// <summary>
+ /// The device registration response data
+ /// </summary>
+ [JsonPropertyName("response")]
+ public FidoAuthenticatorResponse? Response { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs
new file mode 100644
index 0000000..8f0ac7f
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs
@@ -0,0 +1,50 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoAuthenticatorResponse
+ {
+ [JsonPropertyName("id")]
+ public string DeviceId { get; set; } = string.Empty;
+
+ [JsonPropertyName("publicKey")]
+ public string? Base64PublicKey { get; set; }
+
+ [JsonPropertyName("publicKeyAlgorithm")]
+ public int? CoseAlgorithmNumber { get; set; }
+
+ [JsonPropertyName("clientDataJSON")]
+ public string? Base64ClientData { get; set; }
+
+ [JsonPropertyName("authenticatorData")]
+ public string? Base64AuthenticatorData { get; set; }
+
+ [JsonPropertyName("attestationObject")]
+ public string? Base64Attestation { get; set; }
+
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs
index 301113c..8699537 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs
@@ -28,8 +28,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
{
internal sealed class FidoAuthenticatorSelection
{
- [JsonPropertyName("requireResidentKey")]
- public bool RequireResidentKey { get; set; } = false;
+ [JsonPropertyName("residentKey")]
+ public string? RequireResidentKey { get; set; } = "discouraged";
[JsonPropertyName("authenticatorAttachment")]
public string? AuthenticatorAttachment { get; set; } = "cross-platform";
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs
new file mode 100644
index 0000000..e6b567d
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs
@@ -0,0 +1,40 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoClientDataJson
+ {
+ [JsonPropertyName("challenge")]
+ public string? Base64Challenge { get; set; }
+
+ [JsonPropertyName("origin")]
+ public string? Origin { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs
new file mode 100644
index 0000000..3f3e930
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs
@@ -0,0 +1,85 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+
+ internal sealed class FidoConfig
+ {
+
+ [JsonPropertyName("challenge_size")]
+ public int ChallangeSize { get; set; }
+
+ [JsonPropertyName("timeout")]
+ public int Timeout { get; set; }
+
+ [JsonPropertyName("site_name")]
+ public string? SiteName { get; set; }
+
+ [JsonPropertyName("attestation_type")]
+ public string? AttestationType { get; set; }
+
+ [JsonPropertyName("authenticator_selection")]
+ public FidoAuthenticatorSelection? FIDOAuthSelection { get; set; }
+
+ [JsonPropertyName("transport")]
+ public string[] Transports { get; set; } = ["usb", "nfc", "ble"];
+
+ internal static IValidator<FidoConfig> GetValidator()
+ {
+ InlineValidator<FidoConfig> val = new();
+
+ val.RuleFor(c => c.ChallangeSize)
+ .InclusiveBetween(1, 4096)
+ .WithMessage("Fido 'challenge_size' must be between 1 and 4096 bytes");
+
+ val.RuleFor(c => c.Timeout)
+ .InclusiveBetween(1, int.MaxValue)
+ .WithMessage("Fido 'timeout' must be between 1 and 600 seconds");
+
+ val.RuleFor(c => c.SiteName)
+ .NotEmpty()
+ .WithMessage("Fido 'site_name' must be provided");
+
+ val.RuleFor(c => c.AttestationType)
+ .NotEmpty()
+ .WithMessage("Fido 'attestation_type' must be provided");
+
+ val.RuleFor(c => c.FIDOAuthSelection)
+ .NotNull()
+ .WithMessage("Fido 'authenticator_selection' must be provided");
+
+ val.RuleFor(c => c.Transports)
+ .NotEmpty()
+ .ForEach(p => p.NotEmpty())
+ .WithMessage("Fido 'transport' must be provided");
+
+ return val;
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs
new file mode 100644
index 0000000..2d7e01e
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs
@@ -0,0 +1,44 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoDeviceCredential.cs
+*
+* FidoDeviceCredential.cs is part of VNLib.Plugins.Essentials.Accounts which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ public sealed class FidoDeviceCredential
+ {
+ [JsonPropertyName("n")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("id")]
+ public string Base64UrlId { get; set; }
+
+ [JsonPropertyName("pk")]
+ public string Base64PublicKey { get; set; }
+
+ [JsonPropertyName("alg")]
+ public int CoseAlgId { get; set; }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs
new file mode 100644
index 0000000..63aa46e
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs
@@ -0,0 +1,181 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: LoginEndpoint.cs
+*
+* LoginEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Hashing.IdentityUtility;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Hashing;
+using VNLib.Utils.Extensions;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoMfaProcessor(FidoConfig conf) : IMfaProcessor
+ {
+ const string JwtClaimKey = "fido";
+
+ ///<inheritdoc/>
+ public MFAType Type => MFAType.TOTP;
+
+ ///<inheritdoc/>
+ public void ExtendUpgradePayload(in JwtPayload message, IUser user)
+ {
+ FidoDeviceCredential[]? devices = user.FidoGetAllCredentials();
+
+ if(devices == null || devices.Length == 0)
+ {
+ return;
+ }
+
+ using UnsafeMemoryHandle<byte> challBuffer = MemoryUtil.UnsafeAlloc(conf.ChallangeSize, true);
+
+ RandomHash.GetRandomBytes(challBuffer.Span);
+
+ message.AddClaim(
+ claim: JwtClaimKey,
+ value: GetChallengeData(challBuffer.Span, devices)
+ );
+ }
+
+ ///<inheritdoc/>
+ public bool MethodEnabledForUser(IUser user) => user.FidoEnabled();
+
+ ///<inheritdoc/>
+ public bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result)
+ {
+ FidoUpgradeResponse? fidoResponse = result.RootElement.GetProperty("fido")
+ .Deserialize<FidoUpgradeResponse>();
+
+ if (fidoResponse is null)
+ {
+ return false;
+ }
+
+
+
+ return false;
+ }
+
+ private ERRNO RecoverFidoChallenge(JsonDocument chalUpgrade, Span<byte> outBuffer)
+ {
+ /*
+ * When this function is called it must be assumed that the mfa token signature
+ * was verified so it doesn't need to be checked again.
+ *
+ * The only data we need to recover from the upgrade is the fido challenge data.
+ * to verify it's signature.
+ */
+
+ string? chalJwtData = chalUpgrade.RootElement.GetPropString("mfa");
+ if (string.IsNullOrWhiteSpace(chalJwtData))
+ {
+ return 0;
+ }
+
+ using JsonWebToken jwt = JsonWebToken.Parse(chalJwtData);
+
+ using JsonDocument chalDoc = jwt.GetPayload();
+
+ string challenge = chalDoc.RootElement.GetProperty(JwtClaimKey)
+ .GetProperty("challenge")
+ .GetString()!;
+
+ return VnEncoding.Base64UrlDecode(challenge, outBuffer);
+ }
+
+ private FidoDevUpgradeJson GetChallengeData(ReadOnlySpan<byte> challenge, FidoDeviceCredential[] devices)
+ {
+ return new FidoDevUpgradeJson
+ {
+ Base64UrlChallange = VnEncoding.ToBase64UrlSafeString(challenge, false),
+
+ Timeout = conf.Timeout,
+
+ Credentials = devices.Select(p => new CredentialInfoJson
+ {
+ Base64UrlId = p.Base64UrlId,
+ Transports = conf.Transports,
+ Type = "public-key"
+ }).ToArray(),
+ };
+ }
+
+ sealed class FidoDevUpgradeJson
+ {
+ [JsonPropertyName("challenge")]
+ public string Base64UrlChallange { get; set; } = string.Empty;
+
+ [JsonPropertyName("allowCredentials")]
+ public CredentialInfoJson[] Credentials { get; set; } = Array.Empty<CredentialInfoJson>();
+
+ [JsonPropertyName("timeout")]
+ public int Timeout { get; set; }
+ }
+
+ sealed class CredentialInfoJson
+ {
+ [JsonPropertyName("id")]
+ public string Base64UrlId { get; set; } = string.Empty;
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "public-key";
+
+ [JsonPropertyName("transports")]
+ public string[] Transports { get; set; } = Array.Empty<string>();
+ }
+ }
+
+ internal sealed class FidoUpgradeResponse
+ {
+ [JsonPropertyName("id")]
+ public string Base64UrlId { get; set; } = string.Empty;
+
+ [JsonPropertyName("authenticatorAttachment")]
+ public string? Attachment { get; set; }
+
+ [JsonPropertyName("response")]
+ public FidoAuthenticatorAssertionResponse? Response { get; set; }
+ }
+
+ internal sealed class FidoAuthenticatorAssertionResponse
+ {
+ [JsonPropertyName("authenticatorData")]
+ public string Base64UrlAuthData { get; set; } = string.Empty;
+
+ [JsonPropertyName("clientDataJSON")]
+ public string Base64UrlClientData { get; set; } = string.Empty;
+
+ [JsonPropertyName("signature")]
+ public string Base64UrlSignature { get; set; } = string.Empty;
+
+ [JsonPropertyName("userHandle")]
+ public string? Base64UrlUserHandle { get; set; }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs
index 0bdd563..8875e12 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs
@@ -26,10 +26,10 @@ using System.Text.Json.Serialization;
namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
{
- internal sealed class FidoPubkeyAlgorithm
+ internal sealed class FidoPubkeyAlgorithm(int algId)
{
[JsonPropertyName("alg")]
- public int AlgId { get; set; }
+ public int AlgId { get; set; } = algId;
[JsonPropertyName("type")]
public string Type { get; set; } = "public-key";
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs
index 4dfa036..9df1461 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs
@@ -42,7 +42,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
public FidoRelyingParty RelyingParty { get; set; } = new();
[JsonPropertyName("attestation")]
- public string AttestationType { get; set; } = "none";
+ public string? AttestationType { get; set; } = "none";
[JsonPropertyName("user")]
public FidoUserData User { get; set; } = new();
@@ -51,6 +51,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
public FidoPubkeyAlgorithm[]? PubKeyCredParams { get; set; }
[JsonPropertyName("authenticatorSelection")]
- public FidoAuthenticatorSelection AuthSelection { get; set; } = new();
+ public FidoAuthenticatorSelection? AuthSelection { get; set; } = new();
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs
new file mode 100644
index 0000000..8c605dc
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs
@@ -0,0 +1,43 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoUserCredential
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("tp")]
+ public string Algorithm { get; set; } = string.Empty;
+
+ [JsonPropertyName("pk")]
+ public string PublicKey { get; set; } = string.Empty;
+
+ [JsonPropertyName("alg")]
+ public int AlgorithmId { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs
index aadef29..3c64c7d 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs
@@ -22,23 +22,10 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
-using System;
-using System.Buffers.Binary;
-using System.Formats.Cbor;
using System.Text.Json.Serialization;
-using VNLib.Hashing.IdentityUtility;
-
namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
{
- internal sealed class FidoAuthenticatorResponse
- {
- [JsonPropertyName("client_data")]
- public string? Base64ClientDataJson { get; set; }
-
- [JsonPropertyName("attestation_object")]
- public string? Base64AttestationObject { get; set; }
- }
internal sealed class FidoUserData
{
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs
new file mode 100644
index 0000000..f6bb748
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs
@@ -0,0 +1,136 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: UserFidoMfaExtensions.cs
+*
+* UserFidoMfaExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+
+using VNLib.Plugins.Essentials.Users;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ /// <summary>
+ /// Provides Fido/Webauthn authentication extension methods for users
+ /// </summary>
+ public static class UserFidoMfaExtensions
+ {
+ public const string FidoUserStoreKey = "mfa.fido";
+
+ public const int MaxEncodedSize = 1200; //Aribtrary size limit for the user account object
+ public const int AssumedKeySize = 320; //Based on a p384 key base64 encoded
+
+ /// <summary>
+ /// Gets a value that determines if the user has PKI enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has a PKI key stored in their user account</returns>
+ public static bool FidoEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[FidoUserStoreKey]);
+
+ /// <summary>
+ /// Disables all Fido authentication for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ public static void FidoDisable(this IUser user) => user[FidoUserStoreKey] = null!;
+
+ /// <summary>
+ /// Attempts to determine if another fido key can be encoded and stored in the
+ /// user's account object. This assumes that the key is roughly 320 bytes
+ /// when encoded.
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if there is enough key space to store another key, false otherwise</returns>
+ public static bool FidoCanAddKey(this IUser user)
+ {
+ string rawData = user[FidoUserStoreKey];
+ if (string.IsNullOrWhiteSpace(rawData))
+ {
+ return true;
+ }
+
+ return rawData.Length + AssumedKeySize < MaxEncodedSize;
+ }
+
+ /// <summary>
+ /// Stores an array of public keys in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="creds">The array of device credentials to store for the user</param>
+ public static void FidoSetCredentials(this IUser user, FidoDeviceCredential[]? creds)
+ => UserEnocdedData.Encode(user, FidoUserStoreKey, creds);
+
+ /// <summary>
+ /// Gets all public keys stored in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>The array of device credentials if they exist</returns>
+ public static FidoDeviceCredential[]? FidoGetAllCredentials(this IUser user)
+ => UserEnocdedData.Decode<FidoDeviceCredential[]>(user, FidoUserStoreKey);
+
+ /// <summary>
+ /// Removes a single pki key by it's id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="credId">The id of the credential to remove</param>
+ public static void FidoRemoveCredential(this IUser user, string credId)
+ {
+ FidoDeviceCredential[]? keys = user.FidoGetAllCredentials();
+ if (keys == null)
+ {
+ return;
+ }
+
+ //Remove the key and store a new array without it
+
+ FidoSetCredentials(
+ user: user,
+ creds: keys.Where(k => !string.Equals(credId, k.Base64UrlId, StringComparison.Ordinal)).ToArray()
+ );
+ }
+
+ /// <summary>
+ /// Adds a single pki key to the user's account object, or overwrites
+ /// and existing key with the same id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="key">The key to add to the list of user-keys</param>
+ public static void FidoAddCredential(this IUser user, FidoDeviceCredential key)
+ {
+ FidoDeviceCredential[]? keys = user.FidoGetAllCredentials();
+
+ if (keys == null)
+ {
+ //Add a single key if none exist
+ keys = [key];
+ }
+ else
+ {
+ //remove the key if it already exists, then append the new key
+ keys = keys.Where(k => !string.Equals(key.Base64UrlId, k.Base64UrlId, StringComparison.Ordinal))
+ .Append(key)
+ .ToArray();
+ }
+
+ user.FidoSetCredentials(keys);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs
new file mode 100644
index 0000000..95679c7
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs
@@ -0,0 +1,43 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: IMfaProcessor.cs
+*
+* IMfaProcessor.cs is part of VNLib.Plugins.Essentials.Accounts which is
+* part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json;
+
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Plugins.Essentials.Users;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+ internal interface IMfaProcessor
+ {
+ MFAType Type { get; }
+
+ bool MethodEnabledForUser(IUser user);
+
+ void ExtendUpgradePayload(in JwtPayload message, IUser user);
+
+ bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result);
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
index 9dfd183..e44006a 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -27,9 +27,9 @@ using System.Text.Json.Serialization;
using FluentValidation;
-using VNLib.Hashing;
-using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Extensions.Loading;
namespace VNLib.Plugins.Essentials.Accounts.MFA
{
@@ -53,6 +53,14 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
.GreaterThanOrEqualTo(8)
.WithMessage("You must configure a signing key size of 8 bytes or larger");
+ val.RuleFor(c => c.FIDOConfig)
+ .SetValidator(FidoConfig.GetValidator()!)
+ .When(c => c.FIDOConfig != null);
+
+ val.RuleFor(c => c.TOTPConfig)
+ .SetValidator(TOTPConfig.GetValidator()!)
+ .When(c => c.TOTPConfig != null);
+
return val;
}
@@ -61,18 +69,9 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
[JsonPropertyName("totp")]
public TOTPConfig? TOTPConfig { get; set; }
- [JsonIgnore]
- public bool TOTPEnabled => TOTPConfig?.IssuerName != null;
-
[JsonPropertyName("fido")]
public FidoConfig? FIDOConfig { get; set; }
- [JsonIgnore]
- public bool FIDOEnabled => FIDOConfig?.FIDOSiteName != null;
-
- [JsonIgnore]
- public TimeSpan UpgradeValidFor { get; private set; } = TimeSpan.FromSeconds(120);
-
[JsonPropertyName("upgrade_expires_secs")]
public int UpgradeExpSeconds
{
@@ -82,113 +81,20 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
[JsonPropertyName("nonce_size")]
public int NonceLenBytes { get; set; } = 16;
+
[JsonPropertyName("upgrade_size")]
public int UpgradeKeyBytes { get; set; } = 32;
-
-
- public void Validate()
- {
- //Validate the current confige before child configs
- _validator.ValidateAndThrow(this);
-
- TOTPConfig?.Validate();
- FIDOConfig?.Validate();
- }
- }
-
- internal class TOTPConfig : IOnConfigValidation
- {
- private static IValidator<TOTPConfig> GetValidator()
- {
- InlineValidator<TOTPConfig> val = new();
-
- val.RuleFor(c => c.IssuerName)
- .NotEmpty();
-
- val.RuleFor(c => c.PeriodSec)
- .InclusiveBetween(1, 600);
-
- val.RuleFor(c => c.TOTPAlg)
- .Must(a => a != HashAlg.None)
- .WithMessage("TOTP Algorithim name must not be NONE");
-
- val.RuleFor(c => c.TOTPDigits)
- .GreaterThan(1)
- .WithMessage("You should have more than 1 digit for a totp code");
-
- //We dont neet to check window steps, the user may want to configure 0 or more
- val.RuleFor(c => c.TOTPTimeWindowSteps);
-
- val.RuleFor(c => c.TOTPSecretBytes)
- .GreaterThan(8)
- .WithMessage("You should configure a larger TOTP secret size for better security");
-
- return val;
- }
[JsonIgnore]
- private static IValidator<TOTPConfig> _validator { get; } = GetValidator();
+ public bool TOTPEnabled => TOTPConfig?.Enabled == true;
- [JsonPropertyName("issuer")]
- public string? IssuerName { get; set; }
-
- [JsonPropertyName("period_sec")]
- public int PeriodSec
- {
- get => (int)TOTPPeriod.TotalSeconds;
- set => TOTPPeriod = TimeSpan.FromSeconds(value);
- }
[JsonIgnore]
- public TimeSpan TOTPPeriod { get; set; } = TimeSpan.FromSeconds(30);
-
+ public bool FIDOEnabled => FIDOConfig != null;
- [JsonPropertyName("algorithm")]
- public string AlgName
- {
- get => TOTPAlg.ToString();
- set => TOTPAlg = Enum.Parse<HashAlg>(value.ToUpper(null));
- }
[JsonIgnore]
- public HashAlg TOTPAlg { get; set; } = HashAlg.SHA1;
-
- [JsonPropertyName("digits")]
- public int TOTPDigits { get; set; } = 6;
-
- [JsonPropertyName("secret_size")]
- public int TOTPSecretBytes { get; set; } = 32;
-
- [JsonPropertyName("window_size")]
- public int TOTPTimeWindowSteps { get; set; } = 1;
-
- public void Validate()
- {
- //Validate the current instance on the
- _validator.ValidateAndThrow(this);
- }
- }
-
- internal class FidoConfig : IOnConfigValidation
- {
- private static IValidator<FidoConfig> GetValidator()
- {
- InlineValidator<FidoConfig> val = new();
-
-
- return val;
- }
-
- private static IValidator<FidoConfig> _validator { get; } = GetValidator();
+ public TimeSpan UpgradeValidFor { get; private set; } = TimeSpan.FromSeconds(120);
-
- public int FIDOChallangeSize { get; }
- public int FIDOTimeout { get; }
- public string? FIDOSiteName { get; }
- public string? FIDOAttestationType { get; }
- public FidoAuthenticatorSelection? FIDOAuthSelection { get; }
- public void Validate()
- {
- _validator.ValidateAndThrow(this);
- }
+ public void Validate() => _validator.ValidateAndThrow(this);
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs
new file mode 100644
index 0000000..dc52c59
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs
@@ -0,0 +1,248 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: MfaAuthManager.cs
+*
+* MfaAuthManager.cs is part of VNLib.Plugins.Essentials.Accounts which is
+* part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Collections.Generic;
+
+using FluentValidation;
+
+using VNLib.Utils;
+using VNLib.Hashing;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Essentials.Sessions;
+using VNLib.Plugins.Extensions.Loading;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+
+ internal sealed class MfaAuthManager(MFAConfig config, IMfaProcessor[] processors)
+ {
+
+ public const string SESSION_SIG_KEY = "mfa.sig";
+ private const HashAlg SigAlg = HashAlg.SHA256;
+ private static readonly byte[] UpgradeHeader = CompileJwtHeader();
+
+ public bool Armed => processors.Length > 0;
+
+ /// <summary>
+ /// Determines if the user has any MFA methods enabled and
+ /// should continue with an MFA upgrade
+ /// </summary>
+ /// <param name="user">The user to upgrade the mfa request on</param>
+ /// <returns>True if the user has any MFA methods enabled</returns>
+ public bool HasMfaEnabled(IUser user)
+ {
+ return processors.Any(p => p.MethodEnabledForUser(user));
+ }
+
+ /// <summary>
+ /// Gets the upgrade message to send back to the client to
+ /// continue the MFA upgrade process
+ /// </summary>
+ /// <param name="entity">The connection to upgrade</param>
+ /// <param name="user">The user wishing to upgrade MFA methods</param>
+ /// <param name="login">The login message containing required client authentication data</param>
+ /// <returns>The encoded upgrade message to send to the client</returns>
+ public string GetChallengeMessage(HttpEntity entity, IUser user, LoginMessage login)
+ {
+ string clientJwt = string.Empty, secret = string.Empty;
+
+ /*
+ * Upgrade tells the client what methods are suppoted by
+ * the server specific to a user. The client may choose
+ * to use any of the methods.
+ */
+ MfaChallenge upgrade = new()
+ {
+ //Set totp upgrade type
+ Types = GetEnbaledTypesForUser(user),
+
+ //Store login message details
+ UserName = login.UserName,
+ ClientId = login.ClientId,
+ PublicKey = login.ClientPublicKey,
+ ClientLocalLanguage = login.LocalLanguage,
+ };
+
+ GetUpgradeMessage(upgrade, ref clientJwt, ref secret);
+
+ //Store the upgrade message in the session
+ SetUpgradeSecret(in entity.Session, secret);
+
+ return clientJwt;
+ }
+
+ /// <summary>
+ /// Recovers and validates a previously signed challenge message from the client
+ /// </summary>
+ /// <param name="entity">The entity requesting the completation</param>
+ /// <param name="result">The client's result of an mfa upgrade operation</param>
+ /// <returns>The </returns>
+ public MfaChallenge? GetChallengeData(HttpEntity entity, JsonDocument result)
+ {
+ //Recover upgrade jwt
+ string? upgradeJwt = result.RootElement.GetPropString("upgrade");
+ string? storedSecret = GetUpgradeSecret(in entity.Session);
+
+ if (string.IsNullOrEmpty(upgradeJwt) || string.IsNullOrEmpty(storedSecret))
+ {
+ return null;
+ }
+
+ //Recover upgrade data from upgrade message
+ return RecoverChallange(entity.RequestedTimeUtc, upgradeJwt, storedSecret);
+ }
+
+ /// <summary>
+ /// Verifies the response from the client to the MFA upgrade request
+ /// and determines if the upgrade was successful
+ /// </summary>
+ /// <param name="entity"></param>
+ /// <param name="upgrade">The validated upgrade message returned by the client</param>
+ /// <param name="user">The user account to validate against</param>
+ /// <param name="result">The client's result message from the upgrade challenge</param>
+ /// <returns>True if the client successfully validated</returns>
+ public bool VerifyResponse(HttpEntity entity, MfaChallenge upgrade, IUser user, JsonDocument result)
+ {
+ string? desiredMfaType = entity.QueryArgs.GetValueOrDefault("mfa");
+
+ if (!Enum.TryParse(desiredMfaType, true, out MFAType desiredType))
+ {
+ return false;
+ }
+
+ //See if upgrade allows the desired type
+ if (!upgrade.Types.Contains(desiredType))
+ {
+ return false;
+ }
+
+ //Get the processor for the desired type
+ IMfaProcessor? processor = processors.FirstOrDefault(p => p.Type == desiredType);
+
+ if (processor == null)
+ {
+ return false;
+ }
+
+ //Verify the response using the desired processor
+ return processor.VerifyResponse(upgrade, user, result);
+ }
+
+ public void InvalidateUpgrade(HttpEntity entity)
+ {
+ SetUpgradeSecret(in entity.Session, null);
+ }
+
+ private MFAType[] GetEnbaledTypesForUser(IUser user)
+ {
+ return processors.Where(p => p.MethodEnabledForUser(user))
+ .Select(static p => p.Type)
+ .ToArray();
+ }
+
+ private static void SetUpgradeSecret(ref readonly SessionInfo session, string? base32Signature)
+ => session[SESSION_SIG_KEY] = base32Signature!;
+
+ private static string? GetUpgradeSecret(ref readonly SessionInfo session)
+ => session[SESSION_SIG_KEY];
+
+ private MfaChallenge? RecoverChallange(DateTimeOffset now, string upgradeJwtString, string base32Secret)
+ {
+ using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString);
+
+ byte[] secret = VnEncoding.FromBase32String(base32Secret)!;
+
+ try
+ {
+ if (!jwt.Verify(secret, SigAlg))
+ {
+ return null;
+ }
+ }
+ finally
+ {
+ //Erase secret
+ MemoryUtil.InitializeBlock(secret);
+ }
+
+ using JsonDocument doc = jwt.GetPayload();
+
+ //Recover issued at time
+ long iatMs = doc.RootElement.GetProperty("iat").GetInt64();
+ DateTimeOffset iat = DateTimeOffset.FromUnixTimeMilliseconds(iatMs);
+
+ if (iat.Add(config.UpgradeValidFor) < now)
+ {
+ //expired
+ return null;
+ }
+
+ //Recover the upgrade message
+ return doc.RootElement.GetProperty("upgrade").Deserialize<MfaChallenge>();
+ }
+
+ private void GetUpgradeMessage(MfaChallenge upgrade, ref string clientMessage, ref string secret)
+ {
+ //Add some random entropy to the upgrade message, to help prevent forgery
+ string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes);
+ byte[] sigKey = RandomHash.GetRandomBytes(config.UpgradeKeyBytes);
+
+ using JsonWebToken upgradeJwt = new();
+
+ upgradeJwt.WriteHeader(UpgradeHeader);
+
+ string[] mfaTypes = upgrade.Types.Select(static t => t.ToString().ToLower(null)).ToArray();
+
+ upgradeJwt.InitPayloadClaim()
+ .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
+ .AddClaim("upgrade", upgrade)
+ .AddClaim("capabilities", mfaTypes)
+ .AddClaim("expires", config.UpgradeValidFor.TotalSeconds)
+ .AddClaim("a", entropy)
+ .CommitClaims();
+
+ upgradeJwt.Sign(sigKey, SigAlg);
+
+ clientMessage = upgradeJwt.Compile();
+ secret = VnEncoding.ToBase32String(sigKey);
+ }
+
+ private static byte[] CompileJwtHeader()
+ {
+ Dictionary<string, string> header = new()
+ {
+ { "alg","HS256" },
+ { "typ", "JWT" }
+ };
+ return JsonSerializer.SerializeToUtf8Bytes(header);
+ }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs
index e69088a..e06b78c 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -26,7 +26,7 @@ using System.Text.Json.Serialization;
namespace VNLib.Plugins.Essentials.Accounts.MFA
{
- internal class MFAUpgrade : IClientSecInfo
+ internal class MfaChallenge : IClientSecInfo
{
/// <summary>
/// The login's client id specifier
@@ -41,8 +41,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
/// <summary>
/// The <see cref="MFAType"/> of the upgrade request
/// </summary>
- [JsonPropertyName("type")]
- public MFAType Type { get; set; }
+ [JsonPropertyName("types")]
+ public MFAType[] Types { get; set; }
/// <summary>
/// The a base64 encoded string of the user's
/// public key
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs
index a941852..2fd507d 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs
@@ -1,11 +1,11 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
-* File: PkiAuthPublicKey.cs
+* File: OtpAuthPublicKey.cs
*
-* PkiAuthPublicKey.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* OtpAuthPublicKey.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
@@ -26,12 +26,12 @@ using System.Text.Json.Serialization;
using VNLib.Hashing.IdentityUtility;
-namespace VNLib.Plugins.Essentials.Accounts.MFA
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Otp
{
/// <summary>
/// A json serializable JWK format public key for PKI authentication
/// </summary>
- public record class PkiAuthPublicKey : IJsonWebKey
+ public record class OtpAuthPublicKey : IJsonWebKey
{
[JsonPropertyName("kid")]
public string? KeyId { get; set; }
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs
new file mode 100644
index 0000000..97b1807
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs
@@ -0,0 +1,167 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: UserPkiMfaExtensions.cs
+*
+* UserPkiMfaExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Buffers;
+
+using VNLib.Utils.IO;
+using VNLib.Utils.Extensions;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Plugins.Essentials.Users;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Otp
+{
+
+ /// <summary>
+ /// Provides user extension methods for PKI specific MFA operations
+ /// </summary>
+ public static class UserOtpMfaExtensions
+ {
+ /// <summary>
+ /// The key used to store the user's encoded Otp public
+ /// keys in their account object
+ /// </summary>
+ public const string OtpUserStoreKey = "mfa.pki";
+
+ public const int MaxEncodedSize = 1200; //Aribtrary size limit for the user account object
+ public const int AssumedKeySize = 320; //Based on a p384 key base64 encoded
+
+ /// <summary>
+ /// Gets a value that determines if the user has PKI enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has a PKI key stored in their user account</returns>
+ public static bool OtpAuthEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[OtpUserStoreKey]);
+
+ /// <summary>
+ /// Disables PKI authentication for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ public static void OtpDisable(this IUser user) => user[OtpUserStoreKey] = null!;
+
+ /// <summary>
+ /// Attempts to determine if another key can be encoded and stored in the
+ /// user's account object. This assumes that the key is roughly 320 bytes
+ /// when encoded.
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if there is enough key space to store another key, false otherwise</returns>
+ public static bool OtpCanAddKey(this IUser user)
+ {
+ string rawData = user[OtpUserStoreKey];
+ if (string.IsNullOrWhiteSpace(rawData))
+ {
+ return true;
+ }
+
+ return rawData.Length + AssumedKeySize < MaxEncodedSize;
+ }
+
+ /// <summary>
+ /// Verifies a PKI login JWT against the user's stored login key data
+ /// </summary>
+ /// <param name="user">The user requesting a login</param>
+ /// <param name="jwt">The login jwt to verify</param>
+ /// <param name="keyId">The id of the key that generated the request, it must match the id of the stored key</param>
+ /// <returns>True if the user has PKI enabled, the key was recovered, the key id matches, and the JWT signature is verified</returns>
+ public static bool OtpVerifyUserJWT(this IUser user, JsonWebToken jwt, string keyId)
+ {
+ /*
+ * Since multiple keys can be stored, we need to recover the key that matches the desired key id
+ */
+ OtpAuthPublicKey? pub = user.OtpGetAllPublicKeys()?.FirstOrDefault(p => string.Equals(keyId, p.KeyId, StringComparison.Ordinal));
+
+ if (pub == null)
+ {
+ return false;
+ }
+
+ //verify the jwt
+ return jwt.VerifyFromJwk(pub);
+ }
+
+ /// <summary>
+ /// Stores an array of public keys in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="authKeys">The array of jwk format keys to store for the user</param>
+ public static void OtpSetPublicKeys(this IUser user, OtpAuthPublicKey[]? authKeys)
+ => UserEnocdedData.Encode(user, OtpUserStoreKey, authKeys);
+
+ /// <summary>
+ /// Gets all public keys stored in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>The array of public keys if the exist</returns>
+ public static OtpAuthPublicKey[]? OtpGetAllPublicKeys(this IUser user)
+ => UserEnocdedData.Decode<OtpAuthPublicKey[]>(user, OtpUserStoreKey);
+
+ /// <summary>
+ /// Removes a single pki key by it's id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="keyId">The id of the key to remove</param>
+ public static void OtpRemovePublicKey(this IUser user, string keyId)
+ {
+ OtpAuthPublicKey[]? keys = user.OtpGetAllPublicKeys();
+ if (keys == null)
+ {
+ return;
+ }
+
+ //Remove the key and store a new array without it
+
+ user.OtpSetPublicKeys(
+ authKeys: keys.Where(k => !string.Equals(keyId, k.KeyId, StringComparison.Ordinal)).ToArray()
+ );
+ }
+
+ /// <summary>
+ /// Adds a single pki key to the user's account object, or overwrites
+ /// and existing key with the same id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="key">The key to add to the list of user-keys</param>
+ public static void OtpAddPublicKey(this IUser user, OtpAuthPublicKey key)
+ {
+ OtpAuthPublicKey[]? keys = user.OtpGetAllPublicKeys();
+
+ if (keys == null)
+ {
+ //Add a single key if none exist
+ keys = [key];
+ }
+ else
+ {
+ //remove the key if it already exists, then append the new key
+ keys = keys.Where(k => !string.Equals(key.KeyId, k.KeyId, StringComparison.Ordinal))
+ .Append(key)
+ .ToArray();
+ }
+
+ user.OtpSetPublicKeys(keys);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs
new file mode 100644
index 0000000..eba54fe
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs
@@ -0,0 +1,100 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: TOTPConfig.cs
+*
+* TOTPConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+using VNLib.Hashing;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp
+{
+ internal sealed class TOTPConfig
+ {
+ [JsonPropertyName("issuer")]
+ public string? IssuerName { get; set; }
+
+ [JsonPropertyName("period_sec")]
+ public int PeriodSec
+ {
+ get => (int)TOTPPeriod.TotalSeconds;
+ set => TOTPPeriod = TimeSpan.FromSeconds(value);
+ }
+
+ [JsonPropertyName("algorithm")]
+ public string AlgName
+ {
+ get => TOTPAlg.ToString();
+ set => TOTPAlg = Enum.Parse<HashAlg>(value.ToUpper(null));
+ }
+
+ [JsonPropertyName("digits")]
+ public int TOTPDigits { get; set; } = 6;
+
+ [JsonPropertyName("secret_size")]
+ public int TOTPSecretBytes { get; set; } = 32;
+
+ [JsonPropertyName("window_size")]
+ public int TOTPTimeWindowSteps { get; set; } = 1;
+
+ [JsonIgnore]
+ public bool Enabled => IssuerName != null;
+
+ [JsonIgnore]
+ public HashAlg TOTPAlg { get; set; } = HashAlg.SHA1;
+
+ [JsonIgnore]
+ public TimeSpan TOTPPeriod { get; set; } = TimeSpan.FromSeconds(30);
+
+ internal static IValidator<TOTPConfig> GetValidator()
+ {
+ InlineValidator<TOTPConfig> val = new();
+
+ val.RuleFor(c => c.IssuerName)
+ .NotEmpty();
+
+ val.RuleFor(c => c.PeriodSec)
+ .InclusiveBetween(1, 600);
+
+ val.RuleFor(c => c.TOTPAlg)
+ .Must(a => a != HashAlg.None)
+ .WithMessage("TOTP Algorithim name must not be NONE");
+
+ val.RuleFor(c => c.TOTPDigits)
+ .GreaterThan(1)
+ .WithMessage("You should have more than 1 digit for a totp code");
+
+ //We dont neet to check window steps, the user may want to configure 0 or more
+ val.RuleFor(c => c.TOTPTimeWindowSteps);
+
+ val.RuleFor(c => c.TOTPSecretBytes)
+ .GreaterThan(8)
+ .WithMessage("You should configure a larger TOTP secret size for better security");
+
+ return val;
+ }
+
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs
new file mode 100644
index 0000000..393a745
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs
@@ -0,0 +1,186 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: LoginEndpoint.cs
+*
+* LoginEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Diagnostics;
+
+using VNLib.Utils;
+using VNLib.Hashing;
+using VNLib.Utils.Memory;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Hashing.IdentityUtility;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp
+{
+ internal sealed class TotpAuthProcessor(TOTPConfig config) : IMfaProcessor
+ {
+ ///<inheritdoc/>
+ public MFAType Type => MFAType.TOTP;
+
+ ///<inheritdoc/>
+ public bool MethodEnabledForUser(IUser user) => user.TotpEnabled();
+
+ ///<inheritdoc/>
+ public bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result)
+ {
+ if (!result.RootElement.TryGetProperty("code", out JsonElement codeEl)
+ || codeEl.ValueKind != JsonValueKind.Number)
+ {
+ return false;
+ }
+
+ return VerifyTOTP(user, codeEl.GetUInt32());
+ }
+
+ /// <summary>
+ /// Verfies the supplied TOTP code against the current user's totp codes
+ /// This method should not be used for verifying TOTP codes for authentication
+ /// </summary>
+ /// <param name="user">The user account to verify the TOTP code against</param>
+ /// <param name="code">The code to verify</param>
+ /// <returns>True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise</returns>
+ /// <exception cref="FormatException"></exception>
+ /// <exception cref="OutOfMemoryException"></exception>
+ internal bool VerifyTOTP(IUser user, uint code)
+ {
+ //Get the base32 TOTP secret for the user and make sure its actually set
+ string base32Secret = user.TotpGetSecret();
+
+ if (string.IsNullOrWhiteSpace(base32Secret))
+ {
+ return false;
+ }
+
+ int length = base32Secret.Length;
+ bool isValid;
+
+ if (length > 256)
+ {
+ using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base32Secret.Length, true);
+
+ ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer.Span);
+
+ //Verify the TOTP using the decrypted secret
+ isValid = count && VerifyTotpCode(code, buffer.AsSpan(0, count));
+
+ MemoryUtil.InitializeBlock(
+ ref buffer.GetReference(),
+ buffer.IntLength
+ );
+ }
+ else
+ {
+ Span<byte> buffer = stackalloc byte[base32Secret.Length];
+
+ ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer);
+
+ //Verify the TOTP using the decrypted secret
+ isValid = count && VerifyTotpCode(code, buffer[..(int)count]);
+
+ MemoryUtil.InitializeBlock(buffer);
+ }
+
+ return isValid;
+ }
+
+ private bool VerifyTotpCode(uint totpCode, ReadOnlySpan<byte> userSecret)
+ {
+ /*
+ * A basic attempt at a constant time TOTP verification, run
+ * the calculation a fixed number of times, regardless of the resutls
+ */
+ bool codeMatches = false;
+
+ //cache current time
+ DateTimeOffset currentUtc = DateTimeOffset.UtcNow;
+
+ //Start the current window with the minimum window
+ int currenStep = -config.TOTPTimeWindowSteps;
+
+ Span<byte> stepBuffer = stackalloc byte[sizeof(long)];
+ Span<byte> hashBuffer = stackalloc byte[(int)config.TOTPAlg];
+
+ //Run the loop at least once to allow a 0 step tight window
+ do
+ {
+ //Calculate the window by multiplying the window by the current step, then add it to the current time offset to produce a new window
+ DateTimeOffset window = currentUtc.Add(config.TOTPPeriod.Multiply(currenStep));
+
+ //calculate the time step
+ long timeStep = (long)Math.Floor(window.ToUnixTimeSeconds() / config.TOTPPeriod.TotalSeconds);
+
+ //try to compute the hash, must always be storable in the buffer
+ bool writeResult = BitConverter.TryWriteBytes(stepBuffer, timeStep);
+ Debug.Assert(writeResult, "Failed to format the time step buffer because the buffer size was not large enough");
+
+ //If platform is little endian, reverse the byte order
+ if (BitConverter.IsLittleEndian)
+ {
+ stepBuffer.Reverse();
+ }
+
+ ERRNO result = ManagedHash.ComputeHmac(userSecret, stepBuffer, hashBuffer, config.TOTPAlg);
+
+ if (result < 1)
+ {
+ throw new InternalBufferTooSmallException("Failed to compute TOTP time step hash because the buffer was too small");
+ }
+
+ codeMatches |= totpCode == CalcTOTPCode(config.TOTPDigits, hashBuffer[..(int)result]);
+
+ currenStep++;
+
+ } while (currenStep <= config.TOTPTimeWindowSteps);
+
+ return codeMatches;
+ }
+
+ private static uint CalcTOTPCode(int digits, ReadOnlySpan<byte> hash)
+ {
+ //Calculate the offset, RFC defines, the lower 4 bits of the last byte in the hash output
+ byte offset = (byte)(hash[^1] & 0x0Fu);
+
+ uint TOTPCode;
+ if (BitConverter.IsLittleEndian)
+ {
+ //Store the code components
+ TOTPCode = (hash[offset] & 0x7Fu) << 24 | (hash[offset + 1] & 0xFFu) << 16 | (hash[offset + 2] & 0xFFu) << 8 | hash[offset + 3] & 0xFFu;
+ }
+ else
+ {
+ //Store the code components (In reverse order for big-endian machines)
+ TOTPCode = (hash[offset + 3] & 0x7Fu) << 24 | (hash[offset + 2] & 0xFFu) << 16 | (hash[offset + 1] & 0xFFu) << 8 | hash[offset] & 0xFFu;
+ }
+ //calculate the modulus value
+ TOTPCode %= (uint)Math.Pow(10, digits);
+ return TOTPCode;
+ }
+
+ public void ExtendUpgradePayload(in JwtPayload message, IUser user)
+ { }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs
new file mode 100644
index 0000000..c500a7e
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs
@@ -0,0 +1,84 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: UserTotpMfaExtensions.cs
+*
+* UserTotpMfaExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is
+* part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Plugins.Essentials.Users;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp
+{
+ public static class UserTotpMfaExtensions
+ {
+ public const string TOTP_KEY_ENTRY = "mfa.totp";
+
+ /// <summary>
+ /// Recovers the base32 encoded TOTP secret for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>The base32 encoded TOTP secret, or an emtpy string (user spec) if not set</returns>
+ public static string TotpGetSecret(this IUser user) => user[TOTP_KEY_ENTRY];
+
+ /// <summary>
+ /// Stores or removes the current user's TOTP secret, stored in base32 format
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="secret">The base32 encoded TOTP secret</param>
+ public static void TotpSetSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!;
+
+ /// <summary>
+ /// Determines if the user account has TOTP enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has totp enabled, false otherwise</returns>
+ public static bool TotpEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]);
+
+ /// <summary>
+ /// Disables TOTP for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ public static void TotpDisable(this IUser user) => user[TOTP_KEY_ENTRY] = null!;
+
+ /// <summary>
+ /// Generates/overwrites the current user's TOTP secret entry and returns a
+ /// byte array of the generated secret bytes
+ /// </summary>
+ /// <param name="config">The system MFA configuration</param>
+ /// <returns>The raw secret that was encrypted and stored in the user's object</returns>
+ /// <exception cref="OutOfMemoryException"></exception>
+ internal static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config)
+ {
+ _ = config.TOTPConfig ?? throw new NotSupportedException("The loaded configuration does not support TOTP");
+ //Generate a random key
+ byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes);
+ //Store secret in user storage
+ user.TotpSetSecret(VnEncoding.ToBase32String(newSecret, false));
+ //return the raw secret bytes
+ return newSecret;
+ }
+
+
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs
new file mode 100644
index 0000000..8711fb9
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs
@@ -0,0 +1,99 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: MfaEncodedData.cs
+*
+* MfaEncodedData.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text.Json;
+
+using VNLib.Utils;
+using VNLib.Utils.IO;
+using VNLib.Utils.Memory;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+ internal static class UserEnocdedData
+ {
+ /// <summary>
+ /// Recovers encoded items from the user's account object
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="store">The data store to read encoded data from</param>
+ /// <param name="index">The property index in the user fields to recover the objects from</param>
+ /// <returns>The encoded properties from the desired user index</returns>
+ public static T? Decode<T>(IIndexable<string, string> store, string index) where T : class
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentException.ThrowIfNullOrWhiteSpace(index);
+
+ string? encodedData = store[index];
+
+ if (string.IsNullOrWhiteSpace(encodedData))
+ {
+ return null;
+ }
+
+ //Output buffer will always be smaller than actual input data due to base64 encoding
+ using UnsafeMemoryHandle<byte> binBuffer = MemoryUtil.UnsafeAllocNearestPage(encodedData.Length, true);
+
+ ERRNO bytes = VnEncoding.Base64UrlDecode(encodedData, binBuffer.Span);
+
+ if (!bytes)
+ {
+ return null;
+ }
+
+ //Deserialize the objects directly from binary data
+ return JsonSerializer.Deserialize<T>(
+ utf8Json: binBuffer.AsSpan(0, bytes),
+ options: Statics.SR_OPTIONS
+ );
+ }
+
+ /// <summary>
+ /// Writes a set of items to the user's account object, encoded in base64
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="store"></param>
+ /// <param name="index">The store index to write the encoded string data to</param>
+ /// <param name="instance">The object instance to encode and store</param>
+ public static void Encode<T>(IIndexable<string, string> store, string index, T? instance) where T : class
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentException.ThrowIfNullOrWhiteSpace(index);
+
+ if (instance == null)
+ {
+ store[index] = null!;
+ return;
+ }
+
+ //Use a memory stream to serialize the items safely
+ using VnMemoryStream ms = new(MemoryUtil.Shared, 1024, false);
+
+ JsonSerializer.Serialize(ms, instance, Statics.SR_OPTIONS);
+
+ store[index] = VnEncoding.ToBase64UrlSafeString(ms.AsSpan(), false);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
deleted file mode 100644
index 9bf04c8..0000000
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
+++ /dev/null
@@ -1,490 +0,0 @@
-/*
-* Copyright (c) 2023 Vaughn Nugent
-*
-* Library: VNLib
-* Package: VNLib.Plugins.Essentials.Accounts
-* File: UserMFAExtensions.cs
-*
-* UserMFAExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
-* VNLib collection of libraries and utilities.
-*
-* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
-* it under the terms of the GNU Affero General Public License as
-* published by the Free Software Foundation, either version 3 of the
-* License, or (at your option) any later version.
-*
-* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
-* but WITHOUT ANY WARRANTY; without even the implied warranty of
-* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-* GNU Affero General Public License for more details.
-*
-* You should have received a copy of the GNU Affero General Public License
-* along with this program. If not, see https://www.gnu.org/licenses/.
-*/
-
-using System;
-using System.Linq;
-using System.Buffers;
-using System.Text.Json;
-using System.Diagnostics;
-using System.Collections.Generic;
-
-using VNLib.Hashing;
-using VNLib.Utils;
-using VNLib.Utils.Memory;
-using VNLib.Utils.Extensions;
-using VNLib.Hashing.IdentityUtility;
-using VNLib.Plugins.Essentials.Users;
-using VNLib.Plugins.Essentials.Sessions;
-
-namespace VNLib.Plugins.Essentials.Accounts.MFA
-{
-
- public static class UserMFAExtensions
- {
- public const string WEBAUTHN_KEY_ENTRY = "mfa.fido";
- public const string TOTP_KEY_ENTRY = "mfa.totp";
- public const string SESSION_SIG_KEY = "mfa.sig";
- public const string USER_PKI_ENTRY = "mfa.pki";
-
- /// <summary>
- /// Determines if the user account has an
- /// </summary>
- /// <param name="user"></param>
- /// <returns>True if any form of MFA is enabled for the user account</returns>
- public static bool MFAEnabled(this IUser user)
- {
- return !(string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]) && string.IsNullOrWhiteSpace(user[WEBAUTHN_KEY_ENTRY]));
- }
-
- /// <summary>
- /// Disables all forms of MFA for the current user
- /// </summary>
- /// <param name="user"></param>
- public static void MFADisable(this IUser user)
- {
- user[TOTP_KEY_ENTRY] = null!;
- user[WEBAUTHN_KEY_ENTRY] = null!;
- }
-
- #region totp
-
- /// <summary>
- /// Recovers the base32 encoded TOTP secret for the current user
- /// </summary>
- /// <param name="user"></param>
- /// <returns>The base32 encoded TOTP secret, or an emtpy string (user spec) if not set</returns>
- public static string MFAGetTOTPSecret(this IUser user) => user[TOTP_KEY_ENTRY];
-
- /// <summary>
- /// Stores or removes the current user's TOTP secret, stored in base32 format
- /// </summary>
- /// <param name="user"></param>
- /// <param name="secret">The base32 encoded TOTP secret</param>
- public static void MFASetTOTPSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!;
-
- /// <summary>
- /// Determines if the user account has TOTP enabled
- /// </summary>
- /// <param name="user"></param>
- /// <returns>True if the user has totp enabled, false otherwise</returns>
- public static bool MFATotpEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]);
-
- /// <summary>
- /// Generates/overwrites the current user's TOTP secret entry and returns a
- /// byte array of the generated secret bytes
- /// </summary>
- /// <param name="entry">The <see cref="MFAEntry"/> to modify the TOTP configuration of</param>
- /// <returns>The raw secret that was encrypted and stored in the <see cref="MFAEntry"/>, to send to the client</returns>
- /// <exception cref="OutOfMemoryException"></exception>
- internal static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config)
- {
- _ = config.TOTPConfig ?? throw new NotSupportedException("The loaded configuration does not support TOTP");
- //Generate a random key
- byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes);
- //Store secret in user storage
- user.MFASetTOTPSecret(VnEncoding.ToBase32String(newSecret, false));
- //return the raw secret bytes
- return newSecret;
- }
-
- /// <summary>
- /// Verfies the supplied TOTP code against the current user's totp codes
- /// This method should not be used for verifying TOTP codes for authentication
- /// </summary>
- /// <param name="user">The user account to verify the TOTP code against</param>
- /// <param name="code">The code to verify</param>
- /// <param name="config">A readonly referrence to the MFA configuration structure</param>
- /// <returns>True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise</returns>
- /// <exception cref="FormatException"></exception>
- /// <exception cref="OutOfMemoryException"></exception>
- internal static bool VerifyTOTP(this MFAConfig config, IUser user, uint code)
- {
- //Get the base32 TOTP secret for the user and make sure its actually set
- string base32Secret = user.MFAGetTOTPSecret();
- if (!config.TOTPEnabled || string.IsNullOrWhiteSpace(base32Secret))
- {
- return false;
- }
-
- int length = base32Secret.Length;
- bool isValid;
-
- if (length > 256)
- {
- //Alloc buffer with zero o
- using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base32Secret.Length, true);
-
- ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer.Span);
- //Verify the TOTP using the decrypted secret
- isValid = count && VerifyTOTP(code, buffer.AsSpan(0, count), config.TOTPConfig);
- //Zero out the buffer
- MemoryUtil.InitializeBlock(ref buffer.GetReference(), buffer.IntLength);
- }
- else
- {
- //stack alloc buffer
- Span<byte> buffer = stackalloc byte[base32Secret.Length];
-
- ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer);
- //Verify the TOTP using the decrypted secret
- isValid = count && VerifyTOTP(code, buffer[..(int)count], config.TOTPConfig);
- //Zero out the buffer
- MemoryUtil.InitializeBlock(buffer);
- }
-
- return isValid;
- }
-
- private static bool VerifyTOTP(uint totpCode, ReadOnlySpan<byte> userSecret, TOTPConfig config)
- {
- //A basic attempt at a constant time TOTP verification, run the calculation a fixed number of times, regardless of the resutls
- bool codeMatches = false;
-
- //cache current time
- DateTimeOffset currentUtc = DateTimeOffset.UtcNow;
- //Start the current window with the minimum window
- int currenStep = -config.TOTPTimeWindowSteps;
- Span<byte> stepBuffer = stackalloc byte[sizeof(long)];
- Span<byte> hashBuffer = stackalloc byte[(int)config.TOTPAlg];
- //Run the loop at least once to allow a 0 step tight window
- do
- {
- //Calculate the window by multiplying the window by the current step, then add it to the current time offset to produce a new window
- DateTimeOffset window = currentUtc.Add(config.TOTPPeriod.Multiply(currenStep));
- //calculate the time step
- long timeStep = (long)Math.Floor(window.ToUnixTimeSeconds() / config.TOTPPeriod.TotalSeconds);
- //try to compute the hash, must always be storable in the buffer
- bool writeResult = BitConverter.TryWriteBytes(stepBuffer, timeStep);
- Debug.Assert(writeResult, "Failed to format the time step buffer because the buffer size was not large enough");
- //If platform is little endian, reverse the byte order
- if (BitConverter.IsLittleEndian)
- {
- stepBuffer.Reverse();
- }
- ERRNO result = ManagedHash.ComputeHmac(userSecret, stepBuffer, hashBuffer, config.TOTPAlg);
- //try to compute the hash of the time step
- if (result < 1)
- {
- throw new InternalBufferTooSmallException("Failed to compute TOTP time step hash because the buffer was too small");
- }
- //Hash bytes
- ReadOnlySpan<byte> hash = hashBuffer[..(int)result];
- //compute the TOTP code and compare it to the supplied, then store the result
- codeMatches |= (totpCode == CalcTOTPCode(hash, config));
- //next step
- currenStep++;
- } while (currenStep <= config.TOTPTimeWindowSteps);
-
- return codeMatches;
- }
-
- private static uint CalcTOTPCode(ReadOnlySpan<byte> hash, TOTPConfig config)
- {
- //Calculate the offset, RFC defines, the lower 4 bits of the last byte in the hash output
- byte offset = (byte)(hash[^1] & 0x0Fu);
-
- uint TOTPCode;
- if (BitConverter.IsLittleEndian)
- {
- //Store the code components
- TOTPCode = (hash[offset] & 0x7Fu) << 24 | (hash[offset + 1] & 0xFFu) << 16 | (hash[offset + 2] & 0xFFu) << 8 | hash[offset + 3] & 0xFFu;
- }
- else
- {
- //Store the code components (In reverse order for big-endian machines)
- TOTPCode = (hash[offset + 3] & 0x7Fu) << 24 | (hash[offset + 2] & 0xFFu) << 16 | (hash[offset + 1] & 0xFFu) << 8 | hash[offset] & 0xFFu;
- }
- //calculate the modulus value
- TOTPCode %= (uint)Math.Pow(10, config.TOTPDigits);
- return TOTPCode;
- }
-
- #endregion
-
- #region PKI
-
- /// <summary>
- /// Gets a value that determines if the user has PKI enabled
- /// </summary>
- /// <param name="user"></param>
- /// <returns>True if the user has a PKI key stored in their user account</returns>
- public static bool PKIEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[USER_PKI_ENTRY]);
-
- /// <summary>
- /// Verifies a PKI login JWT against the user's stored login key data
- /// </summary>
- /// <param name="user">The user requesting a login</param>
- /// <param name="jwt">The login jwt to verify</param>
- /// <param name="keyId">The id of the key that generated the request, it must match the id of the stored key</param>
- /// <returns>True if the user has PKI enabled, the key was recovered, the key id matches, and the JWT signature is verified</returns>
- public static bool PKIVerifyUserJWT(this IUser user, JsonWebToken jwt, string keyId)
- {
- /*
- * Since multiple keys can be stored, we need to recover the key that matches the desired key id
- */
- PkiAuthPublicKey? pub = PkiGetAllPublicKeys(user)?.FirstOrDefault(p => keyId.Equals(p.KeyId, StringComparison.Ordinal));
-
- if(pub == null)
- {
- return false;
- }
-
- //verify the jwt
- return jwt.VerifyFromJwk(pub);
- }
-
- /// <summary>
- /// Stores an array of public keys in the user's account object
- /// </summary>
- /// <param name="user"></param>
- /// <param name="authKeys">The array of jwk format keys to store for the user</param>
- public static void PKISetPublicKeys(this IUser user, PkiAuthPublicKey[]? authKeys)
- {
- if(authKeys == null || authKeys.Length == 0)
- {
- user[USER_PKI_ENTRY] = null!;
- return;
- }
-
- //Serialize the key data
- byte[] keyData = JsonSerializer.SerializeToUtf8Bytes(authKeys, Statics.SR_OPTIONS);
-
- //convert to base64 string before writing user data
- user[USER_PKI_ENTRY] = VnEncoding.ToBase64UrlSafeString(keyData, false);
- }
-
- /// <summary>
- /// Gets all public keys stored in the user's account object
- /// </summary>
- /// <param name="user"></param>
- /// <returns>The array of public keys if the exist</returns>
- public static PkiAuthPublicKey[]? PkiGetAllPublicKeys(this IUser user)
- {
- string? keyData = user[USER_PKI_ENTRY];
-
- if(string.IsNullOrEmpty(keyData))
- {
- return null;
- }
-
- //Alloc bin buffer for base64 conversion
- using UnsafeMemoryHandle<byte> binBuffer = MemoryUtil.UnsafeAllocNearestPage(keyData.Length, true);
-
- //Recover base64 bytes from key data
- ERRNO bytes = VnEncoding.Base64UrlDecode(keyData, binBuffer.Span);
- if (!bytes)
- {
- return null;
- }
-
- //Deserialize the the key array
- return JsonSerializer.Deserialize<PkiAuthPublicKey[]>(binBuffer.AsSpan(0, bytes), Statics.SR_OPTIONS);
- }
-
- /// <summary>
- /// Removes a single pki key by it's id
- /// </summary>
- /// <param name="user"></param>
- /// <param name="keyId">The id of the key to remove</param>
- public static void PKIRemovePublicKey(this IUser user, string keyId)
- {
- //get all keys
- PkiAuthPublicKey[]? keys = PkiGetAllPublicKeys(user);
- if(keys == null)
- {
- return;
- }
-
- //remove the key
- keys = keys.Where(k => !keyId.Equals(k.KeyId, StringComparison.Ordinal)).ToArray();
-
- //store the new key array
- PKISetPublicKeys(user, keys);
- }
-
- /// <summary>
- /// Adds a single pki key to the user's account object, or overwrites
- /// and existing key with the same id
- /// </summary>
- /// <param name="user"></param>
- /// <param name="key">The key to add to the list of user-keys</param>
- public static void PKIAddPublicKey(this IUser user, PkiAuthPublicKey key)
- {
- //get all keys
- PkiAuthPublicKey[]? keys = PkiGetAllPublicKeys(user);
-
- if (keys == null)
- {
- keys = new PkiAuthPublicKey[] { key };
- }
- else
- {
- //remove the key if it already exists, then append the new key
- keys = keys.Where(k => !key.KeyId.Equals(k.KeyId, StringComparison.Ordinal))
- .Append(key)
- .ToArray();
- }
-
- //store the new key array
- PKISetPublicKeys(user, keys);
- }
-
- #endregion
-
- #region webauthn
-
- #endregion
-
- private static HashAlg SigingAlg { get; } = HashAlg.SHA256;
-
- private static ReadOnlyMemory<byte> UpgradeHeader { get; } = CompileJwtHeader();
-
- private static byte[] CompileJwtHeader()
- {
- Dictionary<string, string> header = new()
- {
- { "alg","HS256" },
- { "typ", "JWT" }
- };
- return JsonSerializer.SerializeToUtf8Bytes(header);
- }
-
- /// <summary>
- /// Recovers a signed MFA upgrade JWT and verifies its authenticity, and confrims its not expired,
- /// then recovers the upgrade mssage
- /// </summary>
- /// <param name="config"></param>
- /// <param name="upgradeJwtString">The signed JWT upgrade message</param>
- /// <param name="base32Secret">The stored base64 encoded signature from the session that requested an upgrade</param>
- /// <returns>True if the upgrade was verified, not expired, and was recovered from the signed message, false otherwise</returns>
- internal static MFAUpgrade? RecoverUpgrade(this MFAConfig config, string upgradeJwtString, string base32Secret)
- {
- //Parse jwt
- using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString);
-
- //Recover the secret key
- byte[] secret = VnEncoding.FromBase32String(base32Secret)!;
- try
- {
- //Verify the signature
- if (!jwt.Verify(secret, SigingAlg))
- {
- return null;
- }
- }
- finally
- {
- //Erase secret
- MemoryUtil.InitializeBlock(secret.AsSpan());
- }
- //Valid
-
- //get request body
- using JsonDocument doc = jwt.GetPayload();
-
- //Recover issued at time
- DateTimeOffset iat = DateTimeOffset.FromUnixTimeMilliseconds(doc.RootElement.GetProperty("iat").GetInt64());
-
- //Verify its not timed out
- if (iat.Add(config.UpgradeValidFor) < DateTimeOffset.UtcNow)
- {
- //expired
- return null;
- }
-
- //Recover the upgrade message
- return doc.RootElement.GetProperty("upgrade").Deserialize<MFAUpgrade>();
- }
-
-
- /// <summary>
- /// Generates an upgrade for the requested user, using the highest prirotiy method
- /// </summary>
- /// <param name="login">The message from the user requesting the login</param>
- /// <returns>A signed upgrade message the client will pass back to the server after the MFA verification</returns>
- /// <exception cref="InvalidOperationException"></exception>
- internal static MfaUpgradeMessage? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login)
- {
- //Webauthn config
-
-
- //Search for totp secret entry
- string base32Secret = user.MFAGetTOTPSecret();
-
- //Check totp entry
- if (!string.IsNullOrWhiteSpace(base32Secret))
- {
-
- //setup the upgrade
- MFAUpgrade upgrade = new()
- {
- //Set totp upgrade type
- Type = MFAType.TOTP,
- //Store login message details
- UserName = login.UserName,
- ClientId = login.ClientId,
- PublicKey = login.ClientPublicKey,
- ClientLocalLanguage = login.LocalLanguage,
- };
-
- //Init jwt for upgrade
- return GetUpgradeMessage(upgrade, conf);
- }
- return null;
- }
-
- private static MfaUpgradeMessage GetUpgradeMessage(MFAUpgrade upgrade, MFAConfig config)
- {
- //Add some random entropy to the upgrade message, to help prevent forgery
- string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes);
- //Init jwt
- using JsonWebToken upgradeJwt = new();
- //Add header
- upgradeJwt.WriteHeader(UpgradeHeader.Span);
- //Write claims
- upgradeJwt.InitPayloadClaim()
- .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
- .AddClaim("upgrade", upgrade)
- .AddClaim("type", upgrade.Type.ToString().ToLower(null))
- .AddClaim("expires", config.UpgradeValidFor.TotalSeconds)
- .AddClaim("a", entropy)
- .CommitClaims();
-
- //Generate a new random secret
- byte[] secret = RandomHash.GetRandomBytes(config.UpgradeKeyBytes);
-
- //sign jwt
- upgradeJwt.Sign(secret, SigingAlg);
-
- //compile and return jwt upgrade
- return new(upgradeJwt.Compile(), VnEncoding.ToBase32String(secret));
- }
-
- internal static void MfaUpgradeSecret(this in SessionInfo session, string? base32Signature) => session[SESSION_SIG_KEY] = base32Signature!;
-
- internal static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY];
- }
-
- readonly record struct MfaUpgradeMessage(string ClientJwt, string SessionKey);
-}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj
index 8e37899..99867b5 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj
@@ -50,6 +50,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
+ <PackageReference Include="System.Formats.Cbor" Version="8.0.0" />
</ItemGroup>
<ItemGroup>