diff options
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src')
7 files changed, 782 insertions, 145 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs index 9c91b69..e2304c0 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs @@ -86,6 +86,11 @@ namespace VNLib.Plugins.Essentials.Accounts this.Route<MFAEndpoint>(); } + if (this.HasConfigForType<PkiLoginEndpoint>()) + { + this.Route<PkiLoginEndpoint>(); + } + //Only export the account security service if the configuration element is defined if (this.HasConfigForType<AccountSecProvider>()) { diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs index e78d2da..ea6bab1 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs @@ -29,6 +29,8 @@ using System.Threading.Tasks; using System.Security.Cryptography; using System.Text.Json.Serialization; +using FluentValidation; + using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; @@ -42,7 +44,6 @@ using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Users; using static VNLib.Plugins.Essentials.Statics; - namespace VNLib.Plugins.Essentials.Accounts.Endpoints { @@ -142,7 +143,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Make sure the account has not been locked out - if (webm.Assert(!UserLoginLocked(user), LOCKED_ACCOUNT_MESSAGE)) + if (webm.Assert(!UserLoginLocked(user, entity.RequestedTimeUtc), LOCKED_ACCOUNT_MESSAGE)) { goto Cleanup; } @@ -302,7 +303,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VfReturnType.VirtualSkip; } - bool locked = UserLoginLocked(user); + bool locked = UserLoginLocked(user, entity.RequestedTimeUtc); //Make sure the account has not been locked out if (!webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE)) @@ -383,27 +384,27 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints webm.Success = true; //Write to log Log.Verbose("Successful login for user {uid}...", user.UserID[..8]); - } + } - public bool UserLoginLocked(IUser user) + public bool UserLoginLocked(IUser user, DateTimeOffset now) { //Recover last counter value TimestampedCounter flc = user.FailedLoginCount(); - - if(flc.Count < MaxFailedLogins) + + if (flc.Count < MaxFailedLogins) { //Period exceeded return false; } - + //See if the flc timeout period has expired - if (flc.LastModified.Add(FailedCountTimeout) < DateTimeOffset.UtcNow) + if (flc.LastModified.Add(FailedCountTimeout) < now) { //clear flc flag user.FailedLoginCount(0); return false; } - + //Count has been exceeded, and has not timed out yet return true; } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs index 0b015a4..998cee4 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs @@ -78,21 +78,27 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity) { - List<string> enabledModes = new(2); + string[] enabledModes = new string[3]; //Load the MFA entry for the user using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); //Set the TOTP flag if set - if (!string.IsNullOrWhiteSpace(user?.MFAGetTOTPSecret())) + if (user?.MFATotpEnabled() == true) { - enabledModes.Add("totp"); + enabledModes[0] = "totp"; } //TODO Set fido flag if enabled if (!string.IsNullOrWhiteSpace("")) { - enabledModes.Add("fido"); + enabledModes[1] = "fido"; + } + + //PKI enabled + if (user?.PKIEnabled() == true) + { + enabledModes[2] = "pki"; } //Return mfa modes as an array @@ -176,45 +182,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VfReturnType.VirtualSkip; } - //generate a new secret (passing the buffer which will get copied to an array because the pw bytes can be modified during encryption) - byte[] secretBuffer = user.MFAGenreateTOTPSecret(MultiFactor); - //Alloc output buffer - UnsafeMemoryHandle<byte> outputBuffer = MemoryUtil.UnsafeAlloc<byte>(4096, true); - - try - { - //Encrypt the secret for the client - ERRNO count = entity.TryEncryptClientData(secretBuffer, outputBuffer.Span); - - if (!count) - { - webm.Result = "There was an error updating your credentials"; - //If this code is running, the client should have a valid public key stored, but log it anyway - Log.Warn("TOTP secret encryption failed, for requested user {uid}", entity.Session.UserID); - break; - } - - webm.Result = new TOTPUpdateMessage() - { - Issuer = MultiFactor.TOTPConfig.IssuerName, - Digits = MultiFactor.TOTPConfig.TOTPDigits, - Period = (int)MultiFactor.TOTPConfig.TOTPPeriod.TotalSeconds, - Algorithm = MultiFactor.TOTPConfig.TOTPAlg.ToString(), - //Convert the secret to base64 string to send to client - Base64EncSecret = Convert.ToBase64String(outputBuffer.Span[..(int)count]) - }; - - //set success flag - webm.Success = true; - } - finally - { - //dispose the output buffer - outputBuffer.Dispose(); - MemoryUtil.InitializeBlock(secretBuffer.AsSpan()); - } - //Only write changes to the db of operation was successful - await user.ReleaseAsync(); + //Update TOTP secret for user + await UpdateUserTotp(entity, user, webm); } break; default: @@ -313,5 +282,51 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VfReturnType.BadRequest; } } + + private async Task UpdateUserTotp(HttpEntity entity, IUser user, WebMessage webm) + { + //generate a new secret (passing the buffer which will get copied to an array because the pw bytes can be modified during encryption) + byte[] secretBuffer = user.MFAGenreateTOTPSecret(MultiFactor); + //Alloc output buffer + UnsafeMemoryHandle<byte> outputBuffer = MemoryUtil.UnsafeAlloc<byte>(4096, true); + + try + { + //Encrypt the secret for the client + ERRNO count = entity.TryEncryptClientData(secretBuffer, outputBuffer.Span); + + if (!count) + { + webm.Result = "There was an error updating your credentials"; + + //If this code is running, the client should have a valid public key stored, but log it anyway + Log.Warn("TOTP secret encryption failed, for requested user {uid}", entity.Session.UserID); + } + else + { + webm.Result = new TOTPUpdateMessage() + { + Issuer = MultiFactor.TOTPConfig.IssuerName, + Digits = MultiFactor.TOTPConfig.TOTPDigits, + Period = (int)MultiFactor.TOTPConfig.TOTPPeriod.TotalSeconds, + Algorithm = MultiFactor.TOTPConfig.TOTPAlg.ToString(), + //Convert the secret to base64 string to send to client + Base64EncSecret = Convert.ToBase64String(outputBuffer.Span[..(int)count]) + }; + + //set success flag + webm.Success = true; + + //Only write changes to the db of operation was successful + await user.ReleaseAsync(); + } + } + finally + { + //dispose the output buffer + outputBuffer.Dispose(); + MemoryUtil.InitializeBlock(secretBuffer.AsSpan()); + } + } } }
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs new file mode 100644 index 0000000..06ccd60 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs @@ -0,0 +1,577 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: PkiLoginEndpoint.cs +* +* PkiLoginEndpoint.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.Net; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text.Json.Serialization; + +using FluentValidation; + +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Hashing.IdentityUtility; +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; + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + [ConfigurationName("pki_auth_endpoint")] + internal sealed class PkiLoginEndpoint : UnprotectedWebEndpoint + { + public const string INVALID_MESSAGE = "Your assertion is invalid, please regenerate and try again"; + + private static readonly ImmutableArray<string> AllowedCurves = new string[3] { "P-256", "P-384", "P-521"}.ToImmutableArray(); + private static readonly ImmutableArray<string> AllowedAlgs = new string[3] { "ES256", "ES384", "ES512" }.ToImmutableArray(); + + private static JwtLoginValidator LwValidator { get; } = new(); + private static IValidator<AuthenticationInfo> AuthValidator { get; } = AuthenticationInfo.GetValidator(); + private static IValidator<ReadOnlyJsonWebKey> UserJwkValidator { get; } = GetKeyValidator(); + + private readonly JwtEndpointConfig _config; + private readonly IUserManager _users; + + + /* + * Default protections sessions should be fine (most strict) + * No cross-site/cross origin/bad referrer etc + */ + //protected override ProtectionSettings EndpointProtectionSettings { get; } = new(); + + + public PkiLoginEndpoint(PluginBase plugin, IConfigScope config) + { + string? path = config["path"].GetString(); + InitPathAndLog(path, plugin.Log); + + //Load config + _config = config.DeserialzeAndValidate<JwtEndpointConfig>(); + _users = plugin.GetOrCreateSingleton<UserManager>(); + + Log.Verbose("PKI endpoint enabled"); + } + + protected override ERRNO PreProccess(HttpEntity entity) + { + return base.PreProccess(entity) && !entity.Session.IsNew; + } + + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + //Conflict if user is logged in + if (entity.IsClientAuthorized(AuthorzationCheckLevel.Any)) + { + entity.CloseResponse(HttpStatusCode.Conflict); + return VfReturnType.VirtualSkip; + } + + ValErrWebMessage webm = new(); + + //Get the login message from the client + JwtLoginMessage? login = await entity.GetJsonFromFileAsync<JwtLoginMessage>(); + + if(webm.Assert(login != null, INVALID_MESSAGE)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Validate login message + if(!LwValidator.Validate(login, webm)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + IUser? user = null; + JsonWebToken jwt; + try + { + //We can try to recover the jwt data + jwt = JsonWebToken.Parse(login.LoginJwt); + } + catch (KeyNotFoundException) + { + webm.Result = INVALID_MESSAGE; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + catch (FormatException) + { + webm.Result = INVALID_MESSAGE; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + try + { + AuthenticationInfo authInfo; + + //Get the signed payload message + using (JsonDocument payload = jwt.GetPayload()) + { + long unixSec = payload.RootElement.GetProperty("iat").GetInt64(); + + DateTimeOffset clientIat = DateTimeOffset.FromUnixTimeSeconds(unixSec); + + if (clientIat.Add(_config.MaxJwtTimeDifference) < entity.RequestedTimeUtc) + { + webm.Result = INVALID_MESSAGE; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + if (clientIat.Subtract(_config.MaxJwtTimeDifference) > entity.RequestedTimeUtc) + { + webm.Result = INVALID_MESSAGE; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Recover the authenticator information + authInfo = new() + { + EmailAddress = payload.RootElement.GetPropString("sub"), + KeyId = payload.RootElement.GetPropString("keyid"), + SerialNumber = payload.RootElement.GetPropString("serial"), + }; + } + + //Validate auth info + if (!AuthValidator.Validate(authInfo, webm)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Get the user from the email address + user = await _users.GetUserFromEmailAsync(authInfo.EmailAddress!, entity.EventCancellation); + + if (webm.Assert(user != null, INVALID_MESSAGE)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Check failed login count + if(webm.Assert(UserLoginLocked(user, entity.RequestedTimeUtc) == false, INVALID_MESSAGE)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Now we can verify the signed message against the stored key + if (webm.Assert(user.PKIVerifyUserJWT(jwt, authInfo.KeyId) == true, INVALID_MESSAGE)) + { + //increment flc on invalid signature + user.FailedLoginIncrement(); + await user.ReleaseAsync(); + + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Account status must be active + if(webm.Assert(user.Status == UserStatus.Active, INVALID_MESSAGE)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Must be local account + if (webm.Assert(user.IsLocalAccount(), INVALID_MESSAGE)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //User is has been authenticated + + //Authorize the user + entity.GenerateAuthorization(login, user, webm); + + //Write user data + await user.ReleaseAsync(); + + webm.Success = true; + + //Return user data + webm.Result = new AccountData() + { + EmailAddress = user.EmailAddress, + }; + + //Close response, user is now logged-in + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + catch + { + entity.InvalidateLogin(); + throw; + } + finally + { + user?.Dispose(); + jwt.Dispose(); + } + } + + /* + * This endpoint also enables + */ + protected override async ValueTask<VfReturnType> PatchAsync(HttpEntity entity) + { + //Check for config flag + if (!_config.EnableKeyUpdate) + { + return VfReturnType.Forbidden; + } + //This endpoint requires valid authorization + if (!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) + { + entity.CloseResponse(HttpStatusCode.Unauthorized); + return VfReturnType.VirtualSkip; + } + + ValErrWebMessage webm = new(); + + //Get the request body + using JsonDocument? request = await entity.GetJsonFromFileAsync(); + + if(webm.Assert(request != null, "The request message is not valid")) + { + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + + //Get the jwk from the request body + using ReadOnlyJsonWebKey jwk = new(request.RootElement); + + //Validate the user's jwk + if(!UserJwkValidator.Validate(jwk, webm)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Get the user account + using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation); + + //Confirm not null, this should only happen if user is removed from table while still logged in + if(webm.Assert(user != null, "You may not configure PKI authentication")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Local account is required + if (webm.Assert(user.IsLocalAccount(), "You do not have a local account, you may not configure PKI authentication")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + try + { + //Try to get the ECDA instance to confirm the key data could be recovered properly + using ECDsa? testAlg = jwk.GetECDsaPublicKey(); + + if (webm.Assert(testAlg != null, "Your JWK is not valid")) + { + webm.Result = "Your JWK is not valid"; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + } + catch(Exception ex) + { + Log.Debug(ex); + webm.Result = "Your JWK is not valid"; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Extract the user's EC key minimum parameters + IReadOnlyDictionary<string, string> keyParams = ExtractKeyData(jwk); + + //Update user's key params + user.PKISetUserKey(keyParams); + + //publish changes + await user.ReleaseAsync(); + + webm.Result = "Successfully updated your PKI authentication method"; + webm.Success = true; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask<VfReturnType> DeleteAsync(HttpEntity entity) + { + //Check for config flag + if (!_config.EnableKeyUpdate) + { + return VfReturnType.Forbidden; + } + + //This endpoint requires valid authorization + if (!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) + { + entity.CloseResponse(HttpStatusCode.Unauthorized); + return VfReturnType.VirtualSkip; + } + + ValErrWebMessage webm = new(); + + //Get the user account + using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation); + + //Confirm not null, this should only happen if user is removed from table while still logged in + if (webm.Assert(user != null, "You may not configure PKI authentication")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Local account is required + if (webm.Assert(user.IsLocalAccount(), "You do not have a local account, you may not configure PKI authentication")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Remove the key + user.PKISetUserKey(null); + await user.ReleaseAsync(); + + webm.Result = "You have successfully disabled PKI login"; + webm.Success = true; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + public bool UserLoginLocked(IUser user, DateTimeOffset now) + { + //Recover last counter value + TimestampedCounter flc = user.FailedLoginCount(); + + if (flc.Count < _config.MaxFailedLogins) + { + //Period exceeded + return false; + } + + //See if the flc timeout period has expired + if (flc.LastModified.AddSeconds(_config.FailedCountTimeoutSec) < now) + { + //clear flc flag + user.FailedLoginCount(0); + return false; + } + + //Count has been exceeded, and has not timed out yet + return true; + } + + private static IReadOnlyDictionary<string, string> ExtractKeyData(ReadOnlyJsonWebKey key) + { + Dictionary<string, string> keyData = new(); + + keyData["kty"] = key.KeyType!; + keyData["use"] = "sig"; + keyData["crv"] = key.GetKeyProperty("crv")!; + keyData["kid"] = key.KeyId!; + keyData["alg"] = key.Algorithm!; + keyData["x"] = key.GetKeyProperty("x")!; + keyData["y"] = key.GetKeyProperty("y")!; + + return keyData; + } + + private sealed class JwtLoginValidator : ClientSecurityMessageValidator<JwtLoginMessage> + { + public JwtLoginValidator() :base() + { + //Basic jwt validator + RuleFor(l => l.LoginJwt) + .NotEmpty() + .MinimumLength(50) + .IllegalCharacters(); + } + } + + sealed class JwtLoginMessage : IClientSecInfo + { + [JsonPropertyName("pubkey")] + public string? PublicKey { get; set; } + [JsonPropertyName("clientid")] + public string? ClientId { get; set; } + [JsonPropertyName("login")] + public string? LoginJwt { get; set; } + } + + sealed class JwtEndpointConfig : IOnConfigValidation + { + [JsonIgnore] + public TimeSpan MaxJwtTimeDifference { get; set; } = TimeSpan.FromSeconds(30); + + [JsonPropertyName("jwt_time_dif_sec")] + public uint TimeDiffSeconds + { + get => (uint)MaxJwtTimeDifference.TotalSeconds; + set => TimeSpan.FromSeconds(value); + } + + [JsonPropertyName("enable_key_update")] + public bool EnableKeyUpdate { get; set; } = true; + + [JsonPropertyName("max_login_attempts")] + public int MaxFailedLogins { get; set; } = 10; + + [JsonPropertyName("failed_attempt_timeout_sec")] + public double FailedCountTimeoutSec { get; set; } = 300; + + public void Validate() + { + Validator.ValidateAndThrow(this); + } + + private static IValidator<JwtEndpointConfig> Validator { get; } = GetValidator(); + private static IValidator<JwtEndpointConfig> GetValidator() + { + InlineValidator<JwtEndpointConfig> val = new(); + + val.RuleFor(c => c.TimeDiffSeconds) + .GreaterThan((uint)1) + .WithMessage("You must specify a JWT IAT time difference greater than 0 seconds"); + + val.RuleFor(c => c.MaxFailedLogins) + .GreaterThan(0); + + val.RuleFor(c => c.FailedCountTimeoutSec) + .GreaterThan(0); + + + return val; + } + + } + + readonly record struct AuthenticationInfo + { + public readonly string? EmailAddress { get; init; } + + public readonly string? KeyId { get; init; } + + public readonly string? SerialNumber { get; init; } + + public static IValidator<AuthenticationInfo> GetValidator() + { + InlineValidator<AuthenticationInfo> val = new(); + + val.RuleFor(l => l.EmailAddress) + .NotEmpty() + .Length(5, 100) + .EmailAddress(); + + val.RuleFor(l => l.SerialNumber) + .NotEmpty() + .Length(5, 50) + .AlphaNumericOnly(); + + val.RuleFor(l => l.KeyId) + .NotEmpty() + .Length(2, 50) + .AlphaNumericOnly(); + + return val; + } + } + + private static IValidator<ReadOnlyJsonWebKey> GetKeyValidator() + { + InlineValidator<ReadOnlyJsonWebKey> val = new(); + + val.RuleFor(a => a.KeyType) + .NotEmpty() + .Must(kt => "EC".Equals(kt, StringComparison.Ordinal)) + .WithMessage("The supplied key is not an EC curve key"); + + val.RuleFor(a => a.Use) + .NotEmpty() + .Must(u => "sig".Equals(u, StringComparison.OrdinalIgnoreCase)) + .WithMessage("Your key must be configured for signatures"); + + val.RuleFor(a => a.GetKeyProperty("crv")) + .NotEmpty() + .WithName("crv") + .Must(p => AllowedCurves.Contains(p, StringComparer.Ordinal)) + .WithMessage("Your key's curve is not supported"); + + val.RuleFor(c => c.KeyId) + .NotEmpty() + .Length(10, 100) + .IllegalCharacters(); + + val.RuleFor(a => a.Algorithm) + .NotEmpty() + .Must(a => AllowedAlgs.Contains(a, StringComparer.Ordinal)) + .WithMessage("Your key's signature algorithm is not supported"); + + //Confirm the x axis parameter is valid + val.RuleFor(a => a.GetKeyProperty("x")) + .NotEmpty() + .WithName("x") + .Length(10, 200) + .WithMessage("Your key's X EC point public key parameter is not valid") + .IllegalCharacters() + .WithMessage("Your key's X EC point public key parameter conatins invaid characters"); + + + //Confirm the y axis point is valid + val.RuleFor(a => a.GetKeyProperty("y")) + .NotEmpty() + .WithName("y") + .Length(10, 200) + .WithMessage("Your key's Y EC point public key parameter is not valid") + .IllegalCharacters() + .WithMessage("Your key's Y EC point public key parameter conatins invaid characters"); + + return val; + } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs index 28eb804..079c904 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs @@ -23,7 +23,9 @@ */ using System; +using System.Text; using System.Linq; +using System.Buffers; using System.Text.Json; using System.Collections.Generic; using System.Security.Cryptography; @@ -47,6 +49,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA public const string PGP_PUB_KEY = "mfa.pgpp"; 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> @@ -188,6 +192,75 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA #endregion + #region PKI + const int JWK_KEY_BUFFER_SIZE = 2048; + + /// <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) + { + //Recover key data from user, it may not be enabled + using ReadOnlyJsonWebKey? jwk = RecoverKey(user); + + if(jwk == null) + { + return false; + } + + //Confim the key id matches + if(!keyId.Equals(jwk.KeyId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + //verify the jwt + return jwt.VerifyFromJwk(jwk); + } + + public static void PKISetUserKey(this IUser user, IReadOnlyDictionary<string, string>? keyData) + { + //Store key data + user.SetObject(USER_PKI_ENTRY, keyData); + } + + private static ReadOnlyJsonWebKey? RecoverKey(IUser user) + { + string? keyData = user[USER_PKI_ENTRY]; + + if(string.IsNullOrEmpty(keyData)) + { + return null; + } + + //Get buffer to recover the key data from + byte[] buffer = ArrayPool<byte>.Shared.Rent(JWK_KEY_BUFFER_SIZE); + try + { + //Recover bytes and get the jwk from the data + int encoded = Encoding.UTF8.GetBytes(keyData, buffer); + return new ReadOnlyJsonWebKey(buffer.AsSpan(0, encoded)); + } + finally + { + MemoryUtil.InitializeBlock(buffer.AsSpan()); + ArrayPool<byte>.Shared.Return(buffer); + } + } + + #endregion + #region pgp private class PgpMfaCred diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs index ded7709..728bc42 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs @@ -32,7 +32,6 @@ */ using System; -using System.Text; using System.Text.Json; using System.Security.Cryptography; using System.Diagnostics.CodeAnalysis; @@ -52,32 +51,26 @@ using VNLib.Plugins.Essentials.Extensions; using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Validation; - namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { [ConfigurationName("account_security", Required = false)] internal class AccountSecProvider : IAccountSecurityProvider { private const int PUB_KEY_JWT_NONCE_SIZE = 16; - private const int PUB_KEY_ENCODE_BUFFER_SIZE = 128; //Session entry keys private const string CLIENT_PUB_KEY_ENTRY = "acnt.pbk"; private const string PUBLIC_KEY_SIG_KEY_ENTRY = "acnt.pbsk"; + private const HashAlg ClientTokenHmacType = HashAlg.SHA256; + /// <summary> /// The client data encryption padding. /// </summary> public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; - /* - * Using the P-256 curve for message signing - */ - private static readonly ECCurve DefaultCurv = ECCurve.NamedCurves.nistP256; - private static readonly HashAlgorithmName DefaultHashAlg = HashAlgorithmName.SHA256; - - private static HMAC GetPubKeySigningAlg(byte[] key) => new HMACSHA256(key); + //private static HMAC GetPubKeySigningAlg(byte[] key) => new HMACSHA256(key); private readonly AccountSecConfig _config; @@ -255,54 +248,25 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { ERRNO result = VnEncoding.TryFromBase64Chars(publicKey, buffer); return buffer.Slice(0, result); - } - - static string EpxortPubKey(ECDsa alg) - { - //Stack buffer - Span<byte> buffer = stackalloc byte[PUB_KEY_ENCODE_BUFFER_SIZE]; - - if(!alg.TryExportSubjectPublicKeyInfo(buffer, out int written)) - { - throw new InternalBufferTooSmallException("Failed to export the public key because the internal buffer is too small"); - } - - //Convert to base64 - string base64 = Convert.ToBase64String(buffer[..written]); - MemoryUtil.InitializeBlock(buffer); - return base64; - } + } //Alloc buffer for encode/decode - using IMemoryHandle<byte> buffer = MemoryUtil.SafeAllocNearestPage<byte>(8000, true); + using IMemoryHandle<byte> buffer = MemoryUtil.SafeAllocNearestPage<byte>(4000, true); try { using RSA rsa = RSA.Create(); //Import the client's public key - rsa.ImportSubjectPublicKeyInfo(PublicKey(publicKey, buffer.Span), out _); + rsa.ImportSubjectPublicKeyInfo(PublicKey(publicKey, buffer.Span), out _); - string pubKey; + Span<byte> secretBuffer = buffer.Span[.._config.TokenKeySize]; + Span<byte> outputBuffer = buffer.Span[_config.TokenKeySize..]; - Span<byte> privKeyBuffer = buffer.Span[..512]; - Span<byte> outputBuffer = buffer.Span[512..]; - - //Init the ecdsa keypair for message signing - using (ECDsa keypair = ECDsa.Create(DefaultCurv)) - { - //Export private key - pubKey = EpxortPubKey(keypair); - //Export private key to buffer - if(!keypair.TryExportPkcs8PrivateKey(privKeyBuffer, out int written)) - { - throw new InternalBufferTooSmallException("Failed to export the client's new private key"); - } - //resize the buffe - privKeyBuffer = privKeyBuffer[0..written]; - } + //Computes a random shared key + RandomHash.GetRandomBytes(secretBuffer); //Encyrpt the private key to send to client - if (!rsa.TryEncrypt(privKeyBuffer, outputBuffer, ClientEncryptonPadding, out int bytesEncrypted)) + if (!rsa.TryEncrypt(secretBuffer, outputBuffer, ClientEncryptonPadding, out int bytesEncrypted)) { throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small"); } @@ -313,7 +277,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider //Client token is the encrypted private key ClientToken = Convert.ToBase64String(outputBuffer[..bytesEncrypted]), //Store public key as the server token - ServerToken = pubKey + ServerToken = VnEncoding.ToBase32String(secretBuffer) }; } finally @@ -325,18 +289,6 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider private bool VerifyClientToken(HttpEntity entity) { - static void InitPubKey(string privKey, ECDsa alg) - { - Span<byte> buffer = stackalloc byte[PUB_KEY_ENCODE_BUFFER_SIZE]; - if(!Convert.TryFromBase64Chars(privKey, buffer, out int bytes)) - { - throw new InternalBufferTooSmallException("The decoding buffer is too small to store the public key"); - } - - //Import private key - alg.ImportSubjectPublicKeyInfo(buffer[..bytes], out _); - } - //Get the token from the client header, the client should always sent this string? signedMessage = entity.Server.Headers[_config.TokenHeaderName]; @@ -346,9 +298,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider return false; } - //Get the stored public key - string publicKey = entity.Session.Token; - if (string.IsNullOrWhiteSpace(publicKey)) + //Get the stored shared symetric key + string sharedKey = entity.Session.Token; + if (string.IsNullOrWhiteSpace(sharedKey)) { return false; } @@ -364,15 +316,19 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { //Parse the client jwt signed message using JsonWebToken jwt = JsonWebToken.Parse(signedMessage); - - //It should be verifiable from the stored public key - using(ECDsa alg = ECDsa.Create(DefaultCurv)) + + using (UnsafeMemoryHandle<byte> decodeBuffer = MemoryUtil.UnsafeAllocNearestPage<byte>(_config.TokenKeySize, true)) { - //Import public key - InitPubKey(publicKey, alg); + //Recover the key from base32 + ERRNO count = VnEncoding.TryFromBase32Chars(sharedKey, decodeBuffer.Span); - //Verify jwt - isValid &= jwt.Verify(alg, in DefaultHashAlg); + if (!count) + { + return false; + } + + //Verity the jwt against the store symmetric key + isValid &= jwt.Verify(decodeBuffer.AsSpan(0, count), ClientTokenHmacType); } //Get the message payload @@ -613,12 +569,8 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider //genreate random signing key to store in the user's session byte[] signingKey = RandomHash.GetRandomBytes(_config.PubKeySigningKeySize); - //Sign using hmac 256 - using (HMAC hmac = GetPubKeySigningAlg(signingKey)) - { - //Sign jwt - jwt.Sign(hmac); - } + //Sign jwt + jwt.Sign(signingKey, ClientTokenHmacType); //base32 encode the signing key string base32SigningKey = VnEncoding.ToBase32String(signingKey, false); @@ -652,6 +604,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { pubKey = null; + //Check session is valid for use if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web) { return false; @@ -678,12 +631,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider byte[] signingKey = VnEncoding.FromBase32String(base32Sig)!; //verify the client signature - using (HMAC hmac = GetPubKeySigningAlg(signingKey)) + if (!jwt.Verify(signingKey, ClientTokenHmacType)) { - if (!jwt.Verify(hmac)) - { - return false; - } + return false; } //Verify expiration @@ -756,6 +706,10 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider //Time difference doesnt need to be validated, it may be 0 to effectively disable it val.RuleFor(c => c.SignedTokenTimeDiff); + val.RuleFor(c => c.TokenKeySize) + .InclusiveBetween(8, 512) + .WithMessage("You should choose an OTP symmetric key size between 8 and 512 bytes"); + return val; } @@ -803,7 +757,12 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider [JsonPropertyName("otp_header_name")] public string TokenHeaderName { get; set; } = "X-Web-Token"; - public int PasswordChallengeKeySize { get; set; } = 128; + /// <summary> + /// The size (in bytes) of the symmetric key used + /// by the client to sign token messages + /// </summary> + [JsonPropertyName("otp_key_size")] + public int TokenKeySize { get; set; } = 64; /// <summary> /// The name of the cookie that stores the user's signed public encryption key diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs index dbb778f..4cdf51b 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs @@ -31,18 +31,10 @@ using VNLib.Plugins.Extensions.Validation; namespace VNLib.Plugins.Essentials.Accounts.Validators { - internal class LoginMessageValidation : AbstractValidator<LoginMessage> + internal class LoginMessageValidation : ClientSecurityMessageValidator<LoginMessage> { - public LoginMessageValidation() + public LoginMessageValidation() :base() { - RuleFor(static t => t.ClientId) - .Length(min: 10, max: 100) - .WithMessage(errorMessage: "Your browser is not sending required security information"); - - RuleFor(static t => t.ClientPublicKey) - .NotEmpty() - .Length(min: 50, max: 1000) - .WithMessage(errorMessage: "Your browser is not sending required security information"); /* Rules for user-input on passwords, set max length to avoid DOS */ RuleFor(static t => t.Password) @@ -67,4 +59,19 @@ namespace VNLib.Plugins.Essentials.Accounts.Validators .WithMessage(errorMessage: "Please check your system clock"); } } + + internal class ClientSecurityMessageValidator<T> : AbstractValidator<T> where T: IClientSecInfo + { + public ClientSecurityMessageValidator() + { + RuleFor(static t => t.ClientId) + .Length(min: 10, max: 100) + .WithMessage(errorMessage: "Your browser is not sending required security information"); + + RuleFor(static t => t.PublicKey) + .NotEmpty() + .Length(min: 50, max: 1000) + .WithMessage(errorMessage: "Your browser is not sending required security information"); + } + } } |