/* * 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]; } }