/* * Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts * File: AccountSecProvider.cs * * AccountSecProvider.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/. */ /* * Implements the IAccountSecurityProvider interface to provide the shared * service to the host application for securing user/account based connections * via authorization. * * This system is technically configurable and optionally loadable */ using System; using System.Text.Json; using System.Security.Cryptography; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using FluentValidation; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; using VNLib.Utils; using VNLib.Net.Http; using VNLib.Utils.Memory; using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Sessions; 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; //Session entry keys private const string PUBLIC_KEY_SIG_KEY_ENTRY = "acnt.pbsk"; private const HashAlg ClientTokenHmacType = HashAlg.SHA256; /// /// The client data encryption padding. /// public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; private readonly AccountSecConfig _config; public AccountSecProvider(PluginBase plugin) { //Setup default config _config = new(); } public AccountSecProvider(PluginBase pbase, IConfigScope config) { //Parse config if defined _config = config.DeserialzeAndValidate(); } #region Interface Impl IClientAuthorization IAccountSecurityProvider.AuthorizeClient(HttpEntity entity, IClientSecInfo clientInfo, IUser user) { //Validate client info _ = clientInfo ?? throw new ArgumentNullException(nameof(clientInfo)); _ = clientInfo.PublicKey ?? throw new ArgumentException(nameof(clientInfo.PublicKey)); _ = clientInfo.ClientId ?? throw new ArgumentException(nameof(clientInfo.ClientId)); //Validate user _ = user ?? throw new ArgumentNullException(nameof(user)); if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web) { throw new ArgumentException("The session is no configured for authorization"); } //Generate the new client token for the client's public key ClientSecurityToken authTokens = GenerateToken(clientInfo.PublicKey); /* * Create thet login cookie value, we need to pass the initial user account * status for the user cookie. This is not required if the user is already * logged in */ string loginCookie = SetLoginCookie(entity, user.IsLocalAccount()); //Store the login hash in the user's session entity.Session.LoginHash = loginCookie; //Store the server token in the session entity.Session.Token = authTokens.ServerToken; /* * The user's public key will be stored via a jwt cookie * signed by this specific signing key, we will save the signing key * in the session */ string base32Key = SetPublicKeyCookie(entity, clientInfo.PublicKey); entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = base32Key; //Return the new authorzation return new Authorization() { LoginSecurityString = loginCookie, SecurityToken = authTokens, }; } void IAccountSecurityProvider.InvalidateLogin(HttpEntity entity) { //Client should also destroy the session ExpireCookies(entity); //Clear known security keys entity.Session.Token = null!; entity.Session.LoginHash = null!; entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = null!; } bool IAccountSecurityProvider.IsClientAuthorized(HttpEntity entity, AuthorzationCheckLevel level) { //Session must be loaded and not-new for an authorization to exist if(!entity.Session.IsSet || entity.Session.IsNew) { return false; } //Reconcile cookies on request ReconcileCookies(entity); return level switch { //Accept the client token or the cookie as any/medium AuthorzationCheckLevel.Any or AuthorzationCheckLevel.Medium => VerifyLoginCookie(entity) || VerifyClientToken(entity), //Critical requires that the client cookie is set and the token is set AuthorzationCheckLevel.Critical => VerifyLoginCookie(entity) && VerifyClientToken(entity), //Default to false condition _ => false, }; } IClientAuthorization IAccountSecurityProvider.ReAuthorizeClient(HttpEntity entity) { //Confirm session is configured if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web) { throw new InvalidOperationException ("The session is not configured for authorization"); } //recover the client's public key if(!TryGetPublicKey(entity, out string? pubKey)) { throw new InvalidOperationException("The user does not have the required public key token stored"); } //Try to generate a new authorization ClientSecurityToken authTokens = GenerateToken(pubKey); //Set login cookies with stored session data string loginCookie = SetLoginCookie(entity); //Update the public key cookie string signingKey = SetPublicKeyCookie(entity, pubKey); //Store signing key entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = signingKey; //Update token/login entity.Session.LoginHash = loginCookie; entity.Session.Token = authTokens.ServerToken; //Return the new authorzation return new Authorization() { LoginSecurityString = loginCookie, SecurityToken = authTokens, }; } ERRNO IAccountSecurityProvider.TryEncryptClientData(HttpEntity entity, ReadOnlySpan data, Span outputBuffer) { //Recover the signed public key, already does session checks return TryGetPublicKey(entity, out string? pubKey) ? TryEncryptClientData(pubKey, data, outputBuffer) : ERRNO.E_FAIL; } ERRNO IAccountSecurityProvider.TryEncryptClientData(IClientSecInfo entity, ReadOnlySpan data, Span outputBuffer) { //Use the public key supplied by the csecinfo return TryEncryptClientData(entity.PublicKey, data, outputBuffer); } #endregion #region Security Tokens /* * A client token was an older term used for a single random token generated * by the server and sent by the client. * * The latest revision generates a keypair on authorization, the public key * is stored id the client's session, and the private key gets encrypted * and sent to the client. The client uses this ECDSA key to sign one time use * JWT tokens * */ private ClientSecurityToken GenerateToken(ReadOnlySpan publicKey) { static ReadOnlySpan PublicKey(ReadOnlySpan publicKey, Span buffer) { ERRNO result = VnEncoding.TryFromBase64Chars(publicKey, buffer); return buffer.Slice(0, result); } //Alloc buffer for encode/decode using IMemoryHandle buffer = MemoryUtil.SafeAllocNearestPage(4000, true); try { using RSA rsa = RSA.Create(); //Import the client's public key rsa.ImportSubjectPublicKeyInfo(PublicKey(publicKey, buffer.Span), out _); Span secretBuffer = buffer.Span[.._config.TokenKeySize]; Span outputBuffer = buffer.Span[_config.TokenKeySize..]; //Computes a random shared key RandomHash.GetRandomBytes(secretBuffer); //Encyrpt the private key to send to client if (!rsa.TryEncrypt(secretBuffer, outputBuffer, ClientEncryptonPadding, out int bytesEncrypted)) { throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small"); } //Convert the tokens to base64 encoding and return the new cst return new() { //Client token is the encrypted private key ClientToken = Convert.ToBase64String(outputBuffer[..bytesEncrypted]), //Store public key as the server token ServerToken = VnEncoding.ToBase32String(secretBuffer) }; } finally { //Zero buffer when complete MemoryUtil.InitializeBlock(buffer.Span); } } private bool VerifyClientToken(HttpEntity entity) { //Get the token from the client header, the client should always sent this string? signedMessage = entity.Server.Headers[_config.TokenHeaderName]; //Make sure a session is loaded if (!entity.Session.IsSet || entity.Session.IsNew || string.IsNullOrWhiteSpace(signedMessage)) { return false; } //Get the stored shared symetric key string sharedKey = entity.Session.Token; if (string.IsNullOrWhiteSpace(sharedKey)) { return false; } /* * The clients signed message is a json web token that includes basic information * Clients may send bad data, so we should swallow exceptions and return false */ bool isValid = true; try { //Parse the client jwt signed message using JsonWebToken jwt = JsonWebToken.Parse(signedMessage); using (UnsafeMemoryHandle decodeBuffer = MemoryUtil.UnsafeAllocNearestPage(_config.TokenKeySize, true)) { //Recover the key from base32 ERRNO count = VnEncoding.TryFromBase32Chars(sharedKey, decodeBuffer.Span); if (!count) { return false; } //Verity the jwt against the store symmetric key isValid &= jwt.Verify(decodeBuffer.AsSpan(0, count), ClientTokenHmacType); } //Get the message payload using JsonDocument data = jwt.GetPayload(); //Get iat time if (data.RootElement.TryGetProperty("iat", out JsonElement iatEl)) { //Try to get iat in uning seconds isValid &= iatEl.TryGetInt64(out long iatSec); //Recover dto from seconds DateTimeOffset iat = DateTimeOffset.FromUnixTimeSeconds(iatSec); //Verify iat against current time with allowed disparity isValid &= iat.Add(_config.SignedTokenTimeDiff) > entity.RequestedTimeUtc; //Message is too far into the future! isValid &= iat.Subtract(_config.SignedTokenTimeDiff) < entity.RequestedTimeUtc; } else { //No time element provided isValid = false; } } catch (FormatException) { //we may catch the format exception for a malformatted jwt isValid = false; } return isValid; } #endregion #region Cookies private void ReconcileCookies(HttpEntity entity) { //Only handle cookies if session is loaded and is a web based session if (!entity.Session.IsSet || entity.Session.SessionType != SessionType.Web) { return; } //If the session is new, or not supposed to be logged in, clear the login cookies if they were set if (entity.Session.IsNew || string.IsNullOrEmpty(entity.Session.LoginHash) || string.IsNullOrEmpty(entity.Session.Token)) { ExpireCookies(entity); } } private bool VerifyLoginCookie(HttpEntity entity) { //Sessions must be loaded if (!entity.Session.IsSet || entity.Session.IsNew) { return false; } //Try to get the login string from the request cookies if (!entity.Server.RequestCookies.TryGetNonEmptyValue(_config.LoginCookieName, out string? cookie)) { return false; } //Make sure a login hash is stored if (string.IsNullOrWhiteSpace(entity.Session.LoginHash)) { return false; } //Alloc buffer for decoding the base64 signatures using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAllocNearestPage(2 * _config.LoginCookieSize, true); //Slice up buffers Span cookieBuffer = buffer.Span[.._config.LoginCookieSize]; Span sessionBuffer = buffer.AsSpan(_config.LoginCookieSize, _config.LoginCookieSize); //Convert cookie and session hash value if (Convert.TryFromBase64Chars(cookie, cookieBuffer, out int cookieBytesWriten) && Convert.TryFromBase64Chars(entity.Session.LoginHash, sessionBuffer, out int hashBytesWritten)) { //Do a fixed time equal (probably overkill, but should not matter too much) if (CryptographicOperations.FixedTimeEquals(cookieBuffer[..cookieBytesWriten], sessionBuffer[..hashBytesWritten])) { return true; } } //Clear login cookie if failed ExpireCookies(entity); return false; } private void ExpireCookies(HttpEntity entity) { //Expire login cookie if set if (entity.Server.RequestCookies.ContainsKey(_config.LoginCookieName)) { HttpCookie pkCookie = new(_config.LoginCookieName, string.Empty) { Domain = _config.CookieDomain, Path = _config.CookiePath, ValidFor = TimeSpan.Zero, SameSite = CookieSameSite.SameSite, HttpOnly = true, Secure = true }; entity.Server.SetCookie(in pkCookie); } //Expire the LI cookie if set if (entity.Server.RequestCookies.ContainsKey(_config.ClientStatusCookieName)) { HttpCookie pkCookie = new(_config.ClientStatusCookieName, string.Empty) { Domain = _config.CookieDomain, Path = _config.CookiePath, ValidFor = TimeSpan.Zero, SameSite = CookieSameSite.SameSite, HttpOnly = true, Secure = true }; entity.Server.SetCookie(in pkCookie); } //Expire pupkey cookie if (entity.Server.RequestCookies.ContainsKey(_config.PubKeyCookieName)) { //Init exipiration cookie HttpCookie pkCookie = new(_config.PubKeyCookieName, string.Empty) { Domain = _config.CookieDomain, Path = _config.CookiePath, ValidFor = TimeSpan.Zero, SameSite = CookieSameSite.SameSite, HttpOnly = true, Secure = true }; entity.Server.SetCookie(in pkCookie); } } #endregion #region Data Encryption /// /// Tries to encrypt the specified data using the specified public key /// /// A base64 encoded public key used to encrypt client data /// Data to encrypt /// The buffer to store encrypted data in /// /// The number of encrypted bytes written to the output buffer, /// or false (0) if the operation failed, or if no credential is /// specified. /// /// private static ERRNO TryEncryptClientData(ReadOnlySpan base64PubKey, ReadOnlySpan data, Span outputBuffer) { if (base64PubKey.IsEmpty) { return false; } //Alloc a buffer for decoding the public key using UnsafeMemoryHandle pubKeyBuffer = MemoryUtil.UnsafeAllocNearestPage(base64PubKey.Length, true); //Decode the public key ERRNO pbkBytesWritten = VnEncoding.TryFromBase64Chars(base64PubKey, pubKeyBuffer.Span); //Try to encrypt the data return pbkBytesWritten ? TryEncryptClientData(pubKeyBuffer.Span[..(int)pbkBytesWritten], data, outputBuffer) : ERRNO.E_FAIL; } /// /// Tries to encrypt the specified data using the specified public key /// /// The raw SKI public key /// Data to encrypt /// The buffer to store encrypted data in /// /// The number of encrypted bytes written to the output buffer, /// or false (0) if the operation failed, or if no credential is /// specified. /// /// private static ERRNO TryEncryptClientData(ReadOnlySpan rawPubKey, ReadOnlySpan data, Span outputBuffer) { if (rawPubKey.IsEmpty) { return false; } //Setup new empty rsa using RSA rsa = RSA.Create(); //Import the public key rsa.ImportSubjectPublicKeyInfo(rawPubKey, out _); //Encrypt data with OaepSha256 as configured in the browser return rsa.TryEncrypt(data, outputBuffer, ClientEncryptonPadding, out int bytesWritten) ? bytesWritten : ERRNO.E_FAIL; } #endregion /// /// Stores the login key as a cookie in the current session as long as the session exists /// / /// The event to log-in /// Does the session belong to a local user account private string SetLoginCookie(HttpEntity ev, bool? localAccount = null) { //Get the new random cookie value string loginString = RandomHash.GetRandomBase64(_config.LoginCookieSize); //Configure the login cookie HttpCookie loginCookie = new(_config.LoginCookieName, loginString) { Domain = _config.CookieDomain, Path = _config.CookiePath, ValidFor = _config.AuthorizationValidFor, SameSite = CookieSameSite.SameSite, HttpOnly = true, Secure = true }; //Set login cookie and session login hash ev.Server.SetCookie(in loginCookie); //If not set get from session storage localAccount ??= ev.Session.HasLocalAccount(); //setup status cookie HttpCookie statusCookie = new(_config.ClientStatusCookieName, localAccount.Value ? "1" : "2") { Domain = _config.CookieDomain, Path = _config.CookiePath, ValidFor = _config.AuthorizationValidFor, SameSite = CookieSameSite.SameSite, Secure = true, //Allowed to be http HttpOnly = false }; //Set the client identifier cookie to a value indicating a local account ev.Server.SetCookie(in statusCookie); return loginString; } #region Client Encryption Key /* * Stores the public key the client provided as a signed JWT a and sets * it as a cookie in the user's browser. * * The signing key is randomly generated and stored in the client's session * so it cannot "stolen" * * This was done mostly to save session storage space */ private string SetPublicKeyCookie(HttpEntity entity, string pubKey) { //generate a random nonce string nonce = RandomHash.GetRandomHex(PUB_KEY_JWT_NONCE_SIZE); //Generate signing key using JsonWebToken jwt = new(); //No header to write, we know the format //add the clients public key and set iat/exp jwt.InitPayloadClaim() .AddClaim("sub", pubKey) .AddClaim("iat", entity.RequestedTimeUtc.ToUnixTimeSeconds()) .AddClaim("exp", entity.RequestedTimeUtc.Add(_config.AuthorizationValidFor).ToUnixTimeSeconds()) .AddClaim("nonce", nonce) .CommitClaims(); //genreate random signing key to store in the user's session byte[] signingKey = RandomHash.GetRandomBytes(_config.PubKeySigningKeySize); //Sign jwt jwt.Sign(signingKey, ClientTokenHmacType); //base32 encode the signing key string base32SigningKey = VnEncoding.ToBase32String(signingKey, false); //Zero signing key now were done using it MemoryUtil.InitializeBlock(signingKey.AsSpan()); //Compile the jwt for the cookie value string jwtValue = jwt.Compile(); //Setup cookie the same as login cookies HttpCookie cookie = new(_config.PubKeyCookieName, jwtValue) { Domain = _config.CookieDomain, Path = _config.CookiePath, SameSite = CookieSameSite.SameSite, ValidFor = _config.AuthorizationValidFor, HttpOnly = true, Secure = true, }; //set the cookie entity.Server.SetCookie(in cookie); //Return the signing key return base32SigningKey; } private bool TryGetPublicKey(HttpEntity entity, [NotNullWhen(true)] out string? pubKey) { pubKey = null; //Check session is valid for use if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web) { return false; } //Get the jwt cookie if (!entity.Server.GetCookie(_config.PubKeyCookieName, out string? pubKeyJwt)) { return false; } //Get the client signature string? base32Sig = entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY]; if (string.IsNullOrWhiteSpace(base32Sig)) { return false; } //Parse the jwt using JsonWebToken jwt = JsonWebToken.Parse(pubKeyJwt); //Recover the signing key bytes byte[] signingKey = VnEncoding.FromBase32String(base32Sig)!; //verify the client signature if (!jwt.Verify(signingKey, ClientTokenHmacType)) { return false; } //Verify expiration using JsonDocument payload = jwt.GetPayload(); //Get the expiration time from the jwt long expTimeSec = payload.RootElement.GetProperty("exp").GetInt64(); DateTimeOffset expired = DateTimeOffset.FromUnixTimeSeconds(expTimeSec); //Check if expired if (expired.Ticks < entity.RequestedTimeUtc.Ticks) { return false; } //Store the public key pubKey = payload.RootElement.GetProperty("sub").GetString()!; return true; } #endregion private sealed class AccountSecConfig : IOnConfigValidation { private static IValidator _validator { get; } = GetValidator(); private static IValidator GetValidator() { InlineValidator val = new(); val.RuleFor(c => c.LoginCookieName) .Length(1, 50) .IllegalCharacters(); val.RuleFor(c => c.LoginCookieSize) .InclusiveBetween(8, 4096) .WithMessage("The login cookie size must be a sensable value between 8 bytes and 4096 bytes long"); //Cookie domain may be null/emmpty val.RuleFor(c => c.CookieDomain); //Cookie path may be empty or null val.RuleFor(c => c.CookiePath); val.RuleFor(c => c.AuthorizationValidFor) .GreaterThan(TimeSpan.FromMinutes(1)) .WithMessage("The authorization should be valid for at-least 1 minute"); val.RuleFor(C => C.ClientStatusCookieName) .Length(1, 50) .AlphaNumericOnly(); //header name is required, but not allowed to contain "illegal" chars val.RuleFor(c => c.TokenHeaderName) .NotEmpty() .IllegalCharacters(); val.RuleFor(c => c.PubKeyCookieName) .Length(1, 50) .IllegalCharacters(); //Signing keys are base32 encoded and stored in the session, we dont want to take up too much space val.RuleFor(c => c.PubKeySigningKeySize) .InclusiveBetween(8, 512) .WithMessage("Your public key signing key should be between 8 and 512 bytes"); //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; } /// /// The name of the random security cookie /// [JsonPropertyName("login_cookie_name")] public string LoginCookieName { get; set; } = "VNLogin"; /// /// The size (in bytes) of the randomly generated security cookie /// [JsonPropertyName("login_cookie_size")] public int LoginCookieSize { get; set; } = 64; /// /// The domain all authoization cookies will be set for /// [JsonPropertyName("cookie_domain")] public string CookieDomain { get; set; } = ""; /// /// The path all authorization cookies will be set for /// [JsonPropertyName("cookie_path")] public string? CookiePath { get; set; } = "/"; /// /// The amount if time new authorizations are valid for. This also /// sets the duration of client cookies. /// [JsonIgnore] internal TimeSpan AuthorizationValidFor { get; set; } = TimeSpan.FromMinutes(60); /// /// The name of the cookie used to set the client's login status message /// [JsonPropertyName("status_cookie_name")] public string ClientStatusCookieName { get; set; } = "li"; /// /// The name of the header used by the client to send the one-time use /// authorization token /// [JsonPropertyName("otp_header_name")] public string TokenHeaderName { get; set; } = "X-Web-Token"; /// /// The size (in bytes) of the symmetric key used /// by the client to sign token messages /// [JsonPropertyName("otp_key_size")] public int TokenKeySize { get; set; } = 64; /// /// The name of the cookie that stores the user's signed public encryption key /// [JsonPropertyName("pubkey_cookie_name")] public string PubKeyCookieName { get; set; } = "client_id"; /// /// The size (in bytes) of the randomly generated key /// used to sign the user's public key /// [JsonPropertyName("pubkey_signing_key_size")] public int PubKeySigningKeySize { get; set; } = 32; /// /// The allowed time difference in the issuance time of the client's signed /// one time use tokens /// [JsonIgnore] internal TimeSpan SignedTokenTimeDiff { get; set; } = TimeSpan.FromSeconds(30); [JsonPropertyName("otp_time_diff_sec")] public uint SigTokenTimeDifSeconds { get => (uint)SignedTokenTimeDiff.TotalSeconds; set => SignedTokenTimeDiff = TimeSpan.FromSeconds(value); } void IOnConfigValidation.Validate() { //Validate the current instance _validator.ValidateAndThrow(this); } } private sealed class Authorization : IClientAuthorization { public string? LoginSecurityString { get; init; } public ClientSecurityToken SecurityToken { get; init; } } } }