From 551066ed9a255bd47c1c5789ec1998fda64bd5aa Mon Sep 17 00:00:00 2001 From: vnugent Date: Thu, 12 Jan 2023 17:47:40 -0500 Subject: Large project reorder and consolidation --- .../src/MFA/UserMFAExtensions.cs | 384 +++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs') diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs new file mode 100644 index 0000000..1ec9953 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs @@ -0,0 +1,384 @@ +/* +* Copyright (c) 2022 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.Text.Json; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text.Json.Serialization; +using System.Diagnostics.CodeAnalysis; + +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; +using VNLib.Plugins.Extensions.Loading; + +namespace VNLib.Plugins.Essentials.Accounts.MFA +{ + + internal static class UserMFAExtensions + { + public const string WEBAUTHN_KEY_ENTRY = "mfa.fido"; + public const string TOTP_KEY_ENTRY = "mfa.totp"; + public const string PGP_PUB_KEY = "mfa.pgpp"; + public const string SESSION_SIG_KEY = "mfa.sig"; + + /// + /// Determines if the user account has an + /// + /// + /// True if any form of MFA is enabled for the user account + public static bool MFAEnabled(this IUser user) + { + return !(string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]) && string.IsNullOrWhiteSpace(user[WEBAUTHN_KEY_ENTRY])); + } + + #region totp + + /// + /// Recovers the base32 encoded TOTP secret for the current user + /// + /// + /// The base32 encoded TOTP secret, or an emtpy string (user spec) if not set + public static string MFAGetTOTPSecret(this IUser user) => user[TOTP_KEY_ENTRY]; + + /// + /// Stores or removes the current user's TOTP secret, stored in base32 format + /// + /// + /// The base32 encoded TOTP secret + public static void MFASetTOTPSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!; + + + /// + /// Generates/overwrites the current user's TOTP secret entry and returns a + /// byte array of the generated secret bytes + /// + /// The to modify the TOTP configuration of + /// The raw secret that was encrypted and stored in the , to send to the client + /// + public static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config) + { + //Generate a random key + byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPSecretBytes); + //Store secret in user storage + user.MFASetTOTPSecret(VnEncoding.ToBase32String(newSecret, false)); + //return the raw secret bytes + return newSecret; + } + + /// + /// Verfies the supplied TOTP code against the current user's totp codes + /// This method should not be used for verifying TOTP codes for authentication + /// + /// The user account to verify the TOTP code against + /// The code to verify + /// A readonly referrence to the MFA configuration structure + /// True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise + /// + /// + public 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 (string.IsNullOrWhiteSpace(base32Secret)) + { + return false; + } + //Alloc buffer with zero o + using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(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); + } + + private static bool VerifyTOTP(uint totpCode, ReadOnlySpan userSecret, MFAConfig 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 stepBuffer = stackalloc byte[sizeof(long)]; + Span 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 + _ = BitConverter.TryWriteBytes(stepBuffer, timeStep) ? 0 : throw new InternalBufferTooSmallException("Failed to format TOTP time step"); + //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 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 hash, MFAConfig 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 loading + + const string MFA_CONFIG_KEY = "mfa"; + + /// + /// Gets the plugins ambient if loaded, or loads it if required. This class will + /// be unloaded when the plugin us unloaded. + /// + /// + /// The ambient + /// + /// + /// + public static MFAConfig? GetMfaConfig(this PluginBase plugin) + { + static MFAConfig? LoadMfaConfig(PluginBase pbase) + { + //Try to get the configuration object + IReadOnlyDictionary? 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.DeferTask(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 + { + [JsonPropertyName("p")] + public string? SpkiPublicKey { get; set; } + + [JsonPropertyName("c")] + public string? CurveFriendlyName { get; set; } + } + + + /// + /// Gets the stored PGP public key for the user + /// + /// + /// The stored PGP signature key + public static string MFAGetPGPPubKey(this IUser user) => user[PGP_PUB_KEY]; + + public static void MFASetPGPPubKey(this IUser user, string? pubKey) => user[PGP_PUB_KEY] = pubKey!; + + public static void VerifySignedData(string data) + { + + } + + #endregion + + #region webauthn + + #endregion + + /// + /// Recovers a signed MFA upgrade JWT and verifies its authenticity, and confrims its not expired, + /// then recovers the upgrade mssage + /// + /// + /// The signed JWT upgrade message + /// The recovered upgrade + /// The stored base64 encoded signature from the session that requested an upgrade + /// True if the upgrade was verified, not expired, and was recovered from the signed message, false otherwise + public static bool RecoverUpgrade(this MFAConfig config, ReadOnlySpan upgradeJwtString, ReadOnlySpan base64sessionSig, [NotNullWhen(true)] out MFAUpgrade? upgrade) + { + //Verifies a jwt stored signature against the actual signature + static bool VerifyStoredSig(ReadOnlySpan base64string, ReadOnlySpan signature) + { + using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(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)) + { + return false; + } + + if(!VerifyStoredSig(base64sessionSig, jwt.SignatureData)) + { + return false; + } + + //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 false; + } + + //Recover the upgrade message + upgrade = doc.RootElement.GetProperty("upgrade").Deserialize(); + return upgrade != null; + } + + + /// + /// Generates an upgrade for the requested user, using the highest prirotiy method + /// + /// The message from the user requesting the login + /// A signed upgrade message the client will pass back to the server after the MFA verification + /// + public static Tuple? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login, string pwClientData) + { + //Webauthn config + + + //Search for totp secret entry + string base32Secret = user.MFAGetTOTPSecret(); + + //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() + { + //Set totp upgrade type + Type = MFAType.TOTP, + //Store login message details + UserName = login.UserName, + ClientID = login.ClientID, + Base64PubKey = login.ClientPublicKey, + ClientLocalLanguage = login.LocalLanguage, + PwClientData = pwClientData + }; + + //Init jwt for upgrade + return GetUpgradeMessage(upgrade, conf.MFASecret, conf.UpgradeValidFor); + } + return null; + } + + private static Tuple GetUpgradeMessage(MFAUpgrade upgrade, ReadOnlyJsonWebKey secret, TimeSpan expires) + { + //Add some random entropy to the upgrade message, to help prevent forgery + string entropy = RandomHash.GetRandomBase32(16); + //Init jwt + using JsonWebToken upgradeJwt = new(); + upgradeJwt.WriteHeader(secret.JwtHeader); + //Write claims + upgradeJwt.InitPayloadClaim() + .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) + .AddClaim("upgrade", upgrade) + .AddClaim("type", upgrade.Type.ToString().ToLower()) + .AddClaim("expires", expires.TotalSeconds) + .AddClaim("a", entropy) + .CommitClaims(); + + //Sign with jwk + upgradeJwt.SignFromJwk(secret); + + //compile and return jwt upgrade + return new(upgradeJwt.Compile(), Convert.ToBase64String(upgradeJwt.SignatureData)); + } + + public static void MfaUpgradeSignature(this in SessionInfo session, string? base64Signature) => session[SESSION_SIG_KEY] = base64Signature!; + + public static string? MfaUpgradeSignature(this in SessionInfo session) => session[SESSION_SIG_KEY]; + } +} -- cgit