aboutsummaryrefslogtreecommitdiff
path: root/VNLib.Plugins.Essentials.Accounts/MFA/UserMFAExtensions.cs
diff options
context:
space:
mode:
Diffstat (limited to 'VNLib.Plugins.Essentials.Accounts/MFA/UserMFAExtensions.cs')
-rw-r--r--VNLib.Plugins.Essentials.Accounts/MFA/UserMFAExtensions.cs395
1 files changed, 395 insertions, 0 deletions
diff --git a/VNLib.Plugins.Essentials.Accounts/MFA/UserMFAExtensions.cs b/VNLib.Plugins.Essentials.Accounts/MFA/UserMFAExtensions.cs
new file mode 100644
index 0000000..d719788
--- /dev/null
+++ b/VNLib.Plugins.Essentials.Accounts/MFA/UserMFAExtensions.cs
@@ -0,0 +1,395 @@
+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 System.Runtime.CompilerServices;
+
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Logging;
+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";
+
+ /// <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]));
+ }
+
+ #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>
+ /// 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>
+ 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;
+ }
+
+ /// <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>
+ 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<byte> buffer = Memory.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);
+ }
+
+ private static bool VerifyTOTP(uint totpCode, ReadOnlySpan<byte> userSecret, MFAConfig config)
+ {
+ //Calc hash size for allocating bufffer
+ int hashSize = config.TOTPAlg switch
+ {
+ HashAlg.MD5 => (160 / 8),
+ HashAlg.SHA1 => (160 / 8),
+ HashAlg.SHA512 => (512 / 8),
+ HashAlg.SHA384 => (384 / 8),
+ HashAlg.SHA256 => (256 / 8),
+ _ => throw new ArgumentException("Invalid hash algorithm"),
+ };
+ //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[hashSize];
+ //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 Exception("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 OutOfMemoryException("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, 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";
+
+ private static readonly ConditionalWeakTable<PluginBase, Lazy<MFAConfig?>> _lazyMfaConfigs = new();
+
+ /// <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 Lazy<MFAConfig?> LoadMfaConfig(PluginBase pbase)
+ {
+ //Lazy load func
+ MFAConfig? Load()
+ {
+ //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.TryGetSecretAsync("mfa_secret").ContinueWith(t => {
+
+ if(t.IsFaulted)
+ {
+ pbase.Log.Error(t.Exception!.InnerException, "Failed to load MFA signing secret");
+ }
+ else
+ {
+ mfa.MFASecret = t.Result != null ? Convert.FromBase64String(t.Result) : null;
+ }
+ });
+
+ return mfa;
+ }
+ //Return new lazy for
+ return new(Load);
+ }
+
+ plugin.ThrowIfUnloaded();
+ //Get/load the passwords one time only
+ return _lazyMfaConfigs.GetValue(plugin, LoadMfaConfig).Value;
+ }
+
+ #endregion
+
+ #region pgp
+
+ private class PgpMfaCred
+ {
+ [JsonPropertyName("p")]
+ public string? SpkiPublicKey { get; set; }
+
+ [JsonPropertyName("c")]
+ public string? CurveFriendlyName { get; set; }
+ }
+
+
+ /// <summary>
+ /// Gets the stored PGP public key for the user
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>The stored PGP signature key </returns>
+ 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
+
+ private static readonly IReadOnlyDictionary<string, string> JWTHeader = new Dictionary<string, string>
+ {
+ { "alg", "HS384" },
+ { "typ" , "JWT"}
+ };
+
+ #endregion
+
+ /// <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="upgrade">The recovered upgrade</param>
+ /// <param name="base64sessionSig">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, string upgradeJwtString, string base64sessionSig, [NotNullWhen(true)] out MFAUpgrade? upgrade)
+ {
+ //Verifies a jwt stored signature against the actual signature
+ static bool VerifyStoredSig(string base64string, ReadOnlySpan<byte> signature)
+ {
+ using UnsafeMemoryHandle<byte> buffer = Memory.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);
+ //Verify the upgrade jwt
+ using (HMACSHA384 hmac = new(config.MFASecret))
+ {
+ if (!jwt.Verify(hmac))
+ {
+ 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<MFAUpgrade>();
+ return upgrade != null;
+ }
+
+
+ /// <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>
+ public static Tuple<string, string>? 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<string, string> GetUpgradeMessage(MFAUpgrade upgrade, byte[] 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(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
+ using (HMACSHA384 hmc = new(secret))
+ {
+ upgradeJwt.Sign(hmc);
+ }
+ //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];
+ }
+}