diff options
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src/MFA')
-rw-r--r-- | plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs | 212 | ||||
-rw-r--r-- | plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs | 170 |
2 files changed, 222 insertions, 160 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs index 03d5a20..bb86a3f 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) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts @@ -23,81 +23,171 @@ */ using System; -using System.Linq; -using System.Text.Json; -using System.Collections.Generic; +using System.Text.Json.Serialization; + +using FluentValidation; using VNLib.Hashing; -using VNLib.Utils.Extensions; -using VNLib.Hashing.IdentityUtility; +using VNLib.Plugins.Extensions.Loading; namespace VNLib.Plugins.Essentials.Accounts.MFA -{ - internal class MFAConfig +{ + + [ConfigurationName("mfa")] + internal class MFAConfig : IOnConfigValidation + { + private static IValidator<MFAConfig> GetValidator() + { + InlineValidator<MFAConfig> val = new(); + + val.RuleFor(c => c.UpgradeExpSeconds) + .GreaterThan(1) + .WithMessage("You must configure a non-zero upgrade expiration timeout"); + + val.RuleFor(c => c.NonceLenBytes) + .GreaterThanOrEqualTo(8) + .WithMessage("You must configure a nonce size of 8 bytes or larger"); + + val.RuleFor(c => c.UpgradeKeyBytes) + .GreaterThanOrEqualTo(8) + .WithMessage("You must configure a signing key size of 8 bytes or larger"); + + return val; + } + + private static IValidator<MFAConfig> _validator { get; } = GetValidator(); + + [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 + { + get => (int)UpgradeValidFor.TotalSeconds; + set => UpgradeValidFor = TimeSpan.FromSeconds(value); + } + + [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 { - public ReadOnlyJsonWebKey? MFASecret { get; set; } + private static IValidator<TOTPConfig> GetValidator() + { + InlineValidator<TOTPConfig> val = new(); + + val.RuleFor(c => c.IssuerName) + .NotEmpty(); + + val.RuleFor(c => c.PeriodSec) + .InclusiveBetween(1, 600); - public bool TOTPEnabled { get; } - public string? IssuerName { get; } - public TimeSpan TOTPPeriod { get; } - public HashAlg TOTPAlg { get; } - public int TOTPDigits { get; } - public int TOTPSecretBytes { get; } - public int TOTPTimeWindowSteps { get; } + 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"); - public bool FIDOEnabled { get; } + //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(); + + [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); + + + [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 int FIDOChallangeSize { get; } public int FIDOTimeout { get; } public string? FIDOSiteName { get; } public string? FIDOAttestationType { get; } public FidoAuthenticatorSelection? FIDOAuthSelection { get; } - public TimeSpan UpgradeValidFor { get; } - public int NonceLenBytes { get; } - - public MFAConfig(IReadOnlyDictionary<string, JsonElement> conf) + public void Validate() { - UpgradeValidFor = conf["upgrade_expires_secs"].GetTimeSpan(TimeParseType.Seconds); - NonceLenBytes = conf["nonce_size"].GetInt32(); - string siteName = conf["site_name"].GetString() ?? throw new KeyNotFoundException("Missing required key 'site_name' in 'mfa' config"); - - //Totp setup - if (conf.TryGetValue("totp", out JsonElement totpEl)) - { - IReadOnlyDictionary<string, JsonElement> totp = totpEl.EnumerateObject().ToDictionary(k => k.Name, k => k.Value); - - //Get totp config - IssuerName = siteName; - //Get alg name - string TOTPAlgName = totp["algorithm"].GetString()?.ToUpper() ?? throw new KeyNotFoundException("Missing required key 'algorithm' in plugin 'mfa' config"); - //Parse from enum string - TOTPAlg = Enum.Parse<HashAlg>(TOTPAlgName); - - - TOTPDigits = totp["digits"].GetInt32(); - TOTPPeriod = TimeSpan.FromSeconds(totp["period_secs"].GetInt32()); - TOTPSecretBytes = totp["secret_size"].GetInt32(); - TOTPTimeWindowSteps = totp["window_size"].GetInt32(); - //Set enabled flag - TOTPEnabled = true; - } - //Fido setup - if(conf.TryGetValue("fido", out JsonElement fidoEl)) - { - IReadOnlyDictionary<string, JsonElement> fido = fidoEl.EnumerateObject().ToDictionary(k => k.Name, k => k.Value); - FIDOChallangeSize = fido["challenge_size"].GetInt32(); - FIDOAttestationType = fido["attestation"].GetString(); - FIDOTimeout = fido["timeout"].GetInt32(); - FIDOSiteName = siteName; - //Deserailze a - if(fido.TryGetValue("authenticatorSelection", out JsonElement authSel)) - { - FIDOAuthSelection = authSel.Deserialize<FidoAuthenticatorSelection>(); - } - //Set enabled flag - FIDOEnabled = true; - } + _validator.ValidateAndThrow(this); } } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs index f8d322b..f683a89 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts @@ -37,7 +37,6 @@ using VNLib.Utils.Extensions; using VNLib.Hashing.IdentityUtility; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Sessions; -using VNLib.Plugins.Extensions.Loading; namespace VNLib.Plugins.Essentials.Accounts.MFA { @@ -73,8 +72,14 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// </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!; - + 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 @@ -85,8 +90,9 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// <exception cref="OutOfMemoryException"></exception> public 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.TOTPSecretBytes); + byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes); //Store secret in user storage user.MFASetTOTPSecret(VnEncoding.ToBase32String(newSecret, false)); //return the raw secret bytes @@ -107,7 +113,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA { //Get the base32 TOTP secret for the user and make sure its actually set string base32Secret = user.MFAGetTOTPSecret(); - if (string.IsNullOrWhiteSpace(base32Secret)) + if (!config.TOTPEnabled || string.IsNullOrWhiteSpace(base32Secret)) { return false; } @@ -115,10 +121,10 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(base32Secret.Length, true); ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer); //Verify the TOTP using the decrypted secret - return count && VerifyTOTP(code, buffer.AsSpan(0, count), config); + return count && VerifyTOTP(code, buffer.AsSpan(0, count), config.TOTPConfig); } - private static bool VerifyTOTP(uint totpCode, ReadOnlySpan<byte> userSecret, MFAConfig config) + 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; @@ -160,7 +166,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA return codeMatches; } - private static uint CalcTOTPCode(ReadOnlySpan<byte> hash, MFAConfig config) + 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); @@ -183,50 +189,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA #endregion - #region loading - - const string MFA_CONFIG_KEY = "mfa"; - - /// <summary> - /// Gets the plugins ambient <see cref="PasswordHashing"/> if loaded, or loads it if required. This class will - /// be unloaded when the plugin us unloaded. - /// </summary> - /// <param name="plugin"></param> - /// <returns>The ambient <see cref="PasswordHashing"/></returns> - /// <exception cref="OverflowException"></exception> - /// <exception cref="KeyNotFoundException"></exception> - /// <exception cref="ObjectDisposedException"></exception> - public static MFAConfig? GetMfaConfig(this PluginBase plugin) - { - static MFAConfig? LoadMfaConfig(PluginBase pbase) - { - //Try to get the configuration object - IReadOnlyDictionary<string, JsonElement>? conf = pbase.TryGetConfig(MFA_CONFIG_KEY); - - if (conf == null) - { - return null; - } - //Init mfa config - MFAConfig mfa = new(conf); - - //Recover secret from config and dangerous 'lazy load' - _ = pbase.ObserveTask(async () => - { - mfa.MFASecret = await pbase.TryGetSecretAsync("mfa_secret").ToJsonWebKey(); - - }, 50); - - return mfa; - } - - plugin.ThrowIfUnloaded(); - //Get/load the passwords one time only - return LoadingExtensions.GetOrCreateSingleton(plugin, LoadMfaConfig); - } - - #endregion - #region pgp private class PgpMfaCred @@ -259,6 +221,20 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA #endregion + private static HMAC GetSigningAlg(byte[] key) => new HMACSHA256(key); + + 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 @@ -266,39 +242,31 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// <param name="config"></param> /// <param name="upgradeJwtString">The signed JWT upgrade message</param> /// <param name="upgrade">The recovered upgrade</param> - /// <param name="base64sessionSig">The stored base64 encoded signature from the session that requested an upgrade</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> - public static bool RecoverUpgrade(this MFAConfig config, ReadOnlySpan<char> upgradeJwtString, ReadOnlySpan<char> base64sessionSig, [NotNullWhen(true)] out MFAUpgrade? upgrade) + public static MFAUpgrade? RecoverUpgrade(this MFAConfig config, string upgradeJwtString, string base32Secret) { - //Verifies a jwt stored signature against the actual signature - static bool VerifyStoredSig(ReadOnlySpan<char> base64string, ReadOnlySpan<byte> signature) - { - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(base64string.Length, true); - - //Recover base64 - ERRNO count = VnEncoding.TryFromBase64Chars(base64string, buffer.Span); - - //Compare - return CryptographicOperations.FixedTimeEquals(signature, buffer.Span[..(int)count]); - } - - //Verify config secret - _ = config.MFASecret ?? throw new InvalidOperationException("MFA config is missing required upgrade signing key"); - - upgrade = null; - //Parse jwt using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString); - - if (!jwt.VerifyFromJwk(config.MFASecret)) + + //Recover the secret key + byte[] secret = VnEncoding.FromBase32String(base32Secret)!; + try { - return false; - } + //Verify the + using HMAC hmac = GetSigningAlg(secret); - if(!VerifyStoredSig(base64sessionSig, jwt.SignatureData)) + if (!jwt.Verify(hmac)) + { + return null; + } + } + finally { - return false; + //Erase secret + MemoryUtil.InitializeBlock(secret.AsSpan()); } + //Valid //get request body using JsonDocument doc = jwt.GetPayload(); @@ -310,12 +278,11 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA if (iat.Add(config.UpgradeValidFor) < DateTimeOffset.UtcNow) { //expired - return false; + return null; } //Recover the upgrade message - upgrade = doc.RootElement.GetProperty("upgrade").Deserialize<MFAUpgrade>(); - return upgrade != null; + return doc.RootElement.GetProperty("upgrade").Deserialize<MFAUpgrade>(); } @@ -325,7 +292,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// <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> - public static Tuple<string, string>? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login, string pwClientData) + public static Tuple<string, string>? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login) { //Webauthn config @@ -336,8 +303,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA //Check totp entry if (!string.IsNullOrWhiteSpace(base32Secret)) { - //Verify config secret - _ = conf?.MFASecret ?? throw new InvalidOperationException("MFA config is missing required upgrade signing key"); //setup the upgrade MFAUpgrade upgrade = new() @@ -346,43 +311,50 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA Type = MFAType.TOTP, //Store login message details UserName = login.UserName, - ClientID = login.ClientID, + ClientID = login.ClientId, Base64PubKey = login.ClientPublicKey, ClientLocalLanguage = login.LocalLanguage, - PwClientData = pwClientData }; //Init jwt for upgrade - return GetUpgradeMessage(upgrade, conf.MFASecret, conf.UpgradeValidFor); + return GetUpgradeMessage(upgrade, conf); } return null; } - private static Tuple<string, string> GetUpgradeMessage(MFAUpgrade upgrade, ReadOnlyJsonWebKey secret, TimeSpan expires) + private static Tuple<string, string> GetUpgradeMessage(MFAUpgrade upgrade, MFAConfig config) { //Add some random entropy to the upgrade message, to help prevent forgery - string entropy = RandomHash.GetRandomBase32(16); + string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes); //Init jwt using JsonWebToken upgradeJwt = new(); - upgradeJwt.WriteHeader(secret.JwtHeader); + //Add header + upgradeJwt.WriteHeader(UpgradeHeader.Span); //Write claims upgradeJwt.InitPayloadClaim() .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) .AddClaim("upgrade", upgrade) - .AddClaim("type", upgrade.Type.ToString().ToLower()) - .AddClaim("expires", expires.TotalSeconds) + .AddClaim("type", upgrade.Type.ToString().ToLower(null)) + .AddClaim("expires", config.UpgradeValidFor.TotalSeconds) .AddClaim("a", entropy) .CommitClaims(); - - //Sign with jwk - upgradeJwt.SignFromJwk(secret); - + + //Generate a new random secret + byte[] secret = RandomHash.GetRandomBytes(config.UpgradeKeyBytes); + + //Init alg + using(HMAC alg = GetSigningAlg(secret)) + { + //sign jwt + upgradeJwt.Sign(alg); + } + //compile and return jwt upgrade - return new(upgradeJwt.Compile(), Convert.ToBase64String(upgradeJwt.SignatureData)); + return new(upgradeJwt.Compile(), VnEncoding.ToBase32String(secret)); } - public static void MfaUpgradeSignature(this in SessionInfo session, string? base64Signature) => session[SESSION_SIG_KEY] = base64Signature!; + public static void MfaUpgradeSecret(this in SessionInfo session, string? base32Signature) => session[SESSION_SIG_KEY] = base32Signature!; - public static string? MfaUpgradeSignature(this in SessionInfo session) => session[SESSION_SIG_KEY]; + public static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY]; } } |