diff options
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src')
7 files changed, 913 insertions, 659 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs index 318f3ce..219239e 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs @@ -92,7 +92,7 @@ namespace VNLib.Plugins.Essentials.Accounts this.ExportService<IAccountSecurityProvider>(securityProvider); //Also add the middleware array - this.ExportService(new IHttpMiddleware[] { securityProvider }); + this.ExportService<IHttpMiddleware[]>([ securityProvider ]); Log.Information("Configuring the account security provider service"); } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecConfig.cs new file mode 100644 index 0000000..180e30e --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecConfig.cs @@ -0,0 +1,193 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: AccountSecConfig.cs +* +* AccountSecConfig.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.Text.Json.Serialization; + +using FluentValidation; + +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; + +namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider +{ + internal sealed class AccountSecConfig : IOnConfigValidation + { + private static IValidator<AccountSecConfig> _validator { get; } = GetValidator(); + + private static IValidator<AccountSecConfig> GetValidator() + { + InlineValidator<AccountSecConfig> val = new(); + + //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"); + + val.RuleFor(c => c.WebSessionValidForSeconds) + .InclusiveBetween((uint)1, uint.MaxValue) + .WithMessage("You must specify a valid value for a web session timeout in seconds"); + + val.RuleForEach(c => c.AllowedOrigins) + .Matches(@"^https?://[a-z0-9\-\.]+$") + .WithMessage("The allowed origins must be valid http(s) urls"); + + return val; + } + + /// <summary> + /// The domain all authoization cookies will be set for + /// </summary> + [JsonPropertyName("cookie_domain")] + public string CookieDomain { get; set; } = ""; + + /// <summary> + /// The path all authorization cookies will be set for + /// </summary> + [JsonPropertyName("cookie_path")] + public string? CookiePath { get; set; } = "/"; + + /// <summary> + /// The amount if time new authorizations are valid for. This also + /// sets the duration of client cookies. + /// </summary> + [JsonIgnore] + internal TimeSpan AuthorizationValidFor { get; set; } = TimeSpan.FromMinutes(60); + + /// <summary> + /// The name of the cookie used to set the client's login status message + /// </summary> + [JsonPropertyName("status_cookie_name")] + public string ClientStatusCookieName { get; set; } = "li"; + + /// <summary> + /// The name of the header used by the client to send the one-time use + /// authorization token + /// </summary> + [JsonPropertyName("otp_header_name")] + public string TokenHeaderName { get; set; } = "X-Web-Token"; + + /// <summary> + /// The size (in bytes) of the symmetric key used + /// by the client to sign token messages + /// </summary> + [JsonPropertyName("otp_key_size")] + public int TokenKeySize { get; set; } = 64; + + /// <summary> + /// The name of the cookie that stores the user's signed public encryption key + /// </summary> + [JsonPropertyName("pubkey_cookie_name")] + public string PubKeyCookieName { get; set; } = "client_id"; + + /// <summary> + /// The size (in bytes) of the randomly generated key + /// used to sign the user's public key + /// </summary> + [JsonPropertyName("pubkey_signing_key_size")] + public int PubKeySigningKeySize { get; set; } = 32; + + /// <summary> + /// The allowed time difference in the issuance time of the client's signed + /// one time use tokens + /// </summary> + [JsonIgnore] + internal TimeSpan SignedTokenTimeDiff { get; set; } = TimeSpan.FromSeconds(30); + + /// <summary> + /// The amount of time a web session is valid for + /// </summary> + [JsonPropertyName("session_valid_for_sec")] + public uint WebSessionValidForSeconds { get; set; } = 3600; + + [JsonPropertyName("otp_time_diff_sec")] + public uint SigTokenTimeDifSeconds + { + get => (uint)SignedTokenTimeDiff.TotalSeconds; + set => SignedTokenTimeDiff = TimeSpan.FromSeconds(value); + } + + /// <summary> + /// Enforce that the client's token is only valid for the origin + /// it was read from. Will break sites hosted from multiple origins + /// </summary> + [JsonPropertyName("strict_origin")] + public bool EnforceSameOriginToken { get; set; } = true; + + /// <summary> + /// Enable/disable origin verification for the client's token + /// </summary> + [JsonIgnore] + public bool VerifyOrigin => AllowedOrigins != null && AllowedOrigins.Length > 0; + + /// <summary> + /// The list of origins that are allowed to send requests to the server + /// </summary> + [JsonPropertyName("allowed_origins")] + public string[]? AllowedOrigins { get; set; } + + /// <summary> + /// Enforce strict path checking for the client's token + /// </summary> + [JsonPropertyName("strict_path")] + public bool VerifyPath { get; set; } = true; + + void IOnConfigValidation.Validate() + { + //Validate the current instance + _validator.ValidateAndThrow(this); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs index 8770930..2e0c259 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs @@ -32,28 +32,16 @@ */ using System; -using System.Linq; -using System.Text.Json; using System.Threading.Tasks; -using System.Security.Cryptography; -using System.Text.Json.Serialization; -using System.Diagnostics.CodeAnalysis; -using FluentValidation; - -using VNLib.Hashing; -using VNLib.Hashing.IdentityUtility; using VNLib.Net.Http; using VNLib.Utils; -using VNLib.Utils.Memory; using VNLib.Utils.Logging; -using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Sessions; using VNLib.Plugins.Essentials.Middleware; using VNLib.Plugins.Essentials.Extensions; using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Validation; namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { @@ -62,21 +50,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider [MiddlewareImpl(MiddlewareImplOptions.SecurityCritical)] internal sealed class AccountSecProvider : IAccountSecurityProvider, IHttpMiddleware { - 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; - - /// <summary> - /// The client data encryption padding. - /// </summary> - public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; - private readonly AccountSecConfig _config; private readonly SingleCookieController _statusCookie; - private readonly SingleCookieController _pubkeyCookie; + private readonly ClientWebAuthManager _authManager; private readonly ILogProvider _logger; public AccountSecProvider(PluginBase plugin) @@ -105,17 +81,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider Secure = true }; - //Public key cookie handler - _pubkeyCookie = new(_config.PubKeyCookieName, _config.AuthorizationValidFor) - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - SameSite = CookieSameSite.Strict, - HttpOnly = true, - Secure = true - }; - _logger = plugin.Log.CreateScope("Acnt-Sec"); + + _authManager = new(config, _logger); } /* @@ -146,7 +114,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider else { //See if the session might be elevated - if (!string.IsNullOrWhiteSpace(session.Token)) + if (!ClientWebAuthManager.IsSessionElevated(in session)) { //If the session stored a user-agent, make sure it matches the connection if (session.UserAgent != null && !session.UserAgent.Equals(entity.Server.UserAgent, StringComparison.Ordinal)) @@ -159,6 +127,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider //If the session is new, or not supposed to be logged in, clear the login cookies if they were set if (session.IsNew || string.IsNullOrEmpty(session.Token)) { + //Do not force clear cookies (saves bandwidth) ExpireCookies(entity, false); } } @@ -178,14 +147,10 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider if (session.Created.AddSeconds(_config.WebSessionValidForSeconds) < entity.RequestedTimeUtc) { //Invalidate the session, so its technically valid for this request, but will be cleared on this handle close cycle - session.Invalidate(); - - //Clear basic login status now so checks will fail later - session.Token = null!; - session.UserID = null!; - session.Privilages = 0; - session[PUBLIC_KEY_SIG_KEY_ENTRY] = null!; + entity.Session.Invalidate(); + //Clear auth specifc cookies + _authManager.DestroyAuthorization(entity); return true; } @@ -209,7 +174,15 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider throw new ArgumentException("The session is no configured for authorization"); } - return GenerateAuth(entity, clientInfo.PublicKey, user.IsLocalAccount()); + ClientAuthData cad = ClientAuthData.FromSecInfo(clientInfo); + + string clientData = _authManager.AuthorizeConnection(entity, in cad); + + //set client status cookie via handler + _statusCookie.SetCookie(entity, user.IsLocalAccount() ? "1" : "2"); + + //Return the new authorzation + return new EncryptedTokenAuthorization(clientData); } ///<inheritdoc/> @@ -221,13 +194,15 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider throw new InvalidOperationException("The session is not configured for authorization"); } + string clientData = string.Empty; + //recover the client's public key - if (!TryGetPublicKey(entity, out string? pubKey)) + if (!_authManager.TryReAuthorizeConnection(entity, ref clientData)) { throw new InvalidOperationException("The user does not have the required public key token stored"); } - return GenerateAuth(entity, pubKey, entity.Session.HasLocalAccount()); + return new EncryptedTokenAuthorization(clientData); } ///<inheritdoc/> @@ -237,8 +212,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider ExpireCookies(entity, true); //Clear known security keys - entity.Session.Token = null!; - entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = null!; + _authManager.DestroyAuthorization(entity); } ///<inheritdoc/> @@ -253,9 +227,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider return level switch { //Accept the client token or the cookie as any/medium - AuthorzationCheckLevel.Any or AuthorzationCheckLevel.Medium => VerifyClientToken(entity) || TryGetPublicKey(entity, out _), + AuthorzationCheckLevel.Any or AuthorzationCheckLevel.Medium => _authManager.HasMinimalAuthorization(entity), //Critical requires that the client cookie is set and the token is set - AuthorzationCheckLevel.Critical => TryGetPublicKey(entity, out _) && VerifyClientToken(entity), + AuthorzationCheckLevel.Critical => _authManager.VerifyConnectionOTP(entity), //Default to false condition _ => false, }; @@ -264,621 +238,25 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider ///<inheritdoc/> ERRNO IAccountSecurityProvider.TryEncryptClientData(HttpEntity entity, ReadOnlySpan<byte> data, Span<byte> outputBuffer) { + string pubKey = string.Empty; + //Recover the signed public key, already does session checks - return TryGetPublicKey(entity, out string? pubKey) ? TryEncryptClientData(pubKey, data, outputBuffer) : ERRNO.E_FAIL; + return _authManager.TryGetEncryptionPubkey(entity, ref pubKey) ? RsaClientDataEncryption.TryEncrypt(pubKey, data, outputBuffer) : ERRNO.E_FAIL; } ///<inheritdoc/> ERRNO IAccountSecurityProvider.TryEncryptClientData(IClientSecInfo entity, ReadOnlySpan<byte> data, Span<byte> outputBuffer) { //Use the public key supplied by the csecinfo - return TryEncryptClientData(entity.PublicKey, data, outputBuffer); - } - - private IClientAuthorization GenerateAuth(HttpEntity entity, string publicKey, bool localAccount) - { - //Try to generate a new authorization - GenerateToken(publicKey, out string serverToken, out string clientToken); - - /* - * 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 - */ - entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = SetPublicKeyCookie(entity, publicKey); - entity.Session.Token = serverToken; - - //set client status cookie via handler - _statusCookie.SetCookie(entity, localAccount ? "1" : "2"); - - //Return the new authorzation - return new EncryptedTokenAuthorization(clientToken); - } - - #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 void GenerateToken(ReadOnlySpan<char> publicKey, out string serverToken, out string clientToken) - { - //Alloc buffer for encode/decode - using IMemoryHandle<byte> buffer = MemoryUtil.SafeAllocNearestPage(4000, true); - try - { - Span<byte> secretBuffer = buffer.Span[.._config.TokenKeySize]; - Span<byte> outputBuffer = buffer.Span[_config.TokenKeySize..]; - - //Computes a random shared key - RandomHash.GetRandomBytes(secretBuffer); - - ERRNO bytesEncrypted = TryEncryptClientData(publicKey, secretBuffer, outputBuffer); - - //Encyrpt the secret key to send to client - if (!bytesEncrypted) - { - throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small"); - } - - //Client token is the encrypted secret key - clientToken = Convert.ToBase64String(outputBuffer[..(int)bytesEncrypted]); - - //Encode base64 url safe - serverToken = VnEncoding.ToBase64UrlSafeString(secretBuffer, false); - } - finally - { - //Zero buffer when complete - MemoryUtil.InitializeBlock(ref buffer.GetReference(), buffer.GetIntLength()); - } - } - - 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 - */ - - try - { - bool isValid = true; - - //Parse the client jwt signed message - using JsonWebToken jwt = JsonWebToken.Parse(signedMessage); - - using (UnsafeMemoryHandle<byte> decodeBuffer = MemoryUtil.UnsafeAllocNearestPage(_config.TokenKeySize, true)) - { - //Recover the key from base32 - ERRNO count = VnEncoding.Base64UrlDecode(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) - && iatEl.ValueKind == JsonValueKind.Number) - { - //Try to get iat in unint seconds - isValid &= iatEl.TryGetInt64(out long iatSec); - - //Recover dto from unix seconds regardless of int success - 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; - } - - if (_config.VerifyOrigin) - { - //Check the audience matches the request uri - if (data.RootElement.TryGetProperty("aud", out JsonElement tokenOriginEl) - && tokenOriginEl.ValueKind == JsonValueKind.String) - { - string? unsafeUserOrigin = tokenOriginEl.GetString(); - - if(string.IsNullOrWhiteSpace(unsafeUserOrigin)) - { - isValid = false; - } - else if (_config.EnforceSameOriginToken) - { - //enforce strict origin checking - string strictOrigin = entity.Server.RequestUri.GetLeftPart(UriPartial.Authority); - isValid &= string.Equals(unsafeUserOrigin, strictOrigin, StringComparison.OrdinalIgnoreCase); - - if (!isValid) - { - _logger.Debug("Client security OTP JWT origin mismatch from {ip} : strict origin {current} != {token}", - entity.TrustedRemoteIp, - strictOrigin, - unsafeUserOrigin - ); - } - } - else - { - //Verify against allow list - isValid &= _config.AllowedOrigins!.Contains(unsafeUserOrigin, StringComparer.OrdinalIgnoreCase); - - if (!isValid) - { - _logger.Debug("CST origin not allowed {ip} : {token}", - entity.TrustedRemoteIp, - unsafeUserOrigin - ); - } - } - } - else - { - isValid = false; - } - } - - if (_config.VerifyPath) - { - //Check the subject (path) matches the request uri - if (data.RootElement.TryGetProperty("path", out JsonElement tokenPathEl) - && tokenPathEl.ValueKind == JsonValueKind.String) - { - - ReadOnlySpan<char> unsafeUserPath = tokenPathEl.GetString(); - /* - * Query parameters are optional, so we need to check if the path contains a - * query, if so we can compare the entire path and query, otherwise we need to - * compare the path only - */ - if (unsafeUserPath.Contains("?", StringComparison.OrdinalIgnoreCase)) - { - //Compare path and query when possible - string requestPath = entity.Server.RequestUri.PathAndQuery; - - isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); - - if (!isValid && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", - entity.TrustedRemoteIp, - requestPath, - unsafeUserPath.ToString() - ); - } - } - else - { - //Use path only - string requestPath = entity.Server.RequestUri.LocalPath; - - //Compare path only - isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); - - if (!isValid && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", - entity.TrustedRemoteIp, - requestPath, - unsafeUserPath.ToString() - ); - } - } - } - else - { - isValid = false; - } - } + return RsaClientDataEncryption.TryEncrypt(entity.PublicKey, data, outputBuffer); + } - return isValid; - } - catch (FormatException) - { - //we may catch the format exception for a malformatted jwt - _logger.Debug("Client security OTP JWT not valid from {ip}", entity.TrustedRemoteIp); - return false; - } - } - - #endregion - - #region Cookies + #endregion private void ExpireCookies(HttpEntity entity, bool force) { - //Do not force clear cookies (saves bandwidth) _statusCookie.ExpireCookie(entity, force); - _pubkeyCookie.ExpireCookie(entity, force); - } - - #endregion - - #region Data Encryption - - /// <summary> - /// Tries to encrypt the specified data using the specified public key - /// </summary> - /// <param name="base64PubKey">A base64 encoded public key used to encrypt client data</param> - /// <param name="data">Data to encrypt</param> - /// <param name="outputBuffer">The buffer to store encrypted data in</param> - /// <returns> - /// The number of encrypted bytes written to the output buffer, - /// or false (0) if the operation failed, or if no credential is - /// specified. - /// </returns> - /// <exception cref="CryptographicException"></exception> - private static ERRNO TryEncryptClientData(ReadOnlySpan<char> base64PubKey, ReadOnlySpan<byte> data, Span<byte> outputBuffer) - { - if (base64PubKey.IsEmpty) - { - return ERRNO.E_FAIL; - } - - //Alloc a buffer for decoding the public key - using UnsafeMemoryHandle<byte> 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; - } - - /// <summary> - /// Tries to encrypt the specified data using the specified public key - /// </summary> - /// <param name="rawPubKey">The raw SKI public key</param> - /// <param name="data">Data to encrypt</param> - /// <param name="outputBuffer">The buffer to store encrypted data in</param> - /// <returns> - /// The number of encrypted bytes written to the output buffer, - /// or false (0) if the operation failed, or if no credential is - /// specified. - /// </returns> - /// <exception cref="CryptographicException"></exception> - private static ERRNO TryEncryptClientData(ReadOnlySpan<byte> rawPubKey, ReadOnlySpan<byte> data, Span<byte> 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 - - - #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(); - - _pubkeyCookie.SetCookie(entity, jwtValue); - - //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 - string? pubKeyJwt = _pubkeyCookie.GetCookie(entity); - - if (string.IsNullOrWhiteSpace(pubKeyJwt)) - { - return false; - } - - //Get the client signature - string? base32Sig = entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY]; - - if (string.IsNullOrWhiteSpace(base32Sig)) - { - return false; - } - - try - { - - //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; - } - - //Erase the signing key bytes - MemoryUtil.InitializeBlock(signingKey); - - //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; - } - catch (FormatException) - { - //JWT is invalid and could not be parsed - _logger.Debug("Client public key JWT or message body was not valid from {ip}", entity.TrustedRemoteIp); - } - - return false; - } - - #endregion - - - private sealed class AccountSecConfig : IOnConfigValidation - { - private static IValidator<AccountSecConfig> _validator { get; } = GetValidator(); - - private static IValidator<AccountSecConfig> GetValidator() - { - InlineValidator<AccountSecConfig> val = new(); - - //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"); - - val.RuleFor(c => c.WebSessionValidForSeconds) - .InclusiveBetween((uint)1, uint.MaxValue) - .WithMessage("You must specify a valid value for a web session timeout in seconds"); - - val.RuleForEach(c => c.AllowedOrigins) - .Matches(@"^https?://[a-z0-9\-\.]+$") - .WithMessage("The allowed origins must be valid http(s) urls"); - - return val; - } - - /// <summary> - /// The domain all authoization cookies will be set for - /// </summary> - [JsonPropertyName("cookie_domain")] - public string CookieDomain { get; set; } = ""; - - /// <summary> - /// The path all authorization cookies will be set for - /// </summary> - [JsonPropertyName("cookie_path")] - public string? CookiePath { get; set; } = "/"; - - /// <summary> - /// The amount if time new authorizations are valid for. This also - /// sets the duration of client cookies. - /// </summary> - [JsonIgnore] - internal TimeSpan AuthorizationValidFor { get; set; } = TimeSpan.FromMinutes(60); - - /// <summary> - /// The name of the cookie used to set the client's login status message - /// </summary> - [JsonPropertyName("status_cookie_name")] - public string ClientStatusCookieName { get; set; } = "li"; - - /// <summary> - /// The name of the header used by the client to send the one-time use - /// authorization token - /// </summary> - [JsonPropertyName("otp_header_name")] - public string TokenHeaderName { get; set; } = "X-Web-Token"; - - /// <summary> - /// The size (in bytes) of the symmetric key used - /// by the client to sign token messages - /// </summary> - [JsonPropertyName("otp_key_size")] - public int TokenKeySize { get; set; } = 64; - - /// <summary> - /// The name of the cookie that stores the user's signed public encryption key - /// </summary> - [JsonPropertyName("pubkey_cookie_name")] - public string PubKeyCookieName { get; set; } = "client_id"; - - /// <summary> - /// The size (in bytes) of the randomly generated key - /// used to sign the user's public key - /// </summary> - [JsonPropertyName("pubkey_signing_key_size")] - public int PubKeySigningKeySize { get; set; } = 32; - - /// <summary> - /// The allowed time difference in the issuance time of the client's signed - /// one time use tokens - /// </summary> - [JsonIgnore] - internal TimeSpan SignedTokenTimeDiff { get; set; } = TimeSpan.FromSeconds(30); - - /// <summary> - /// The amount of time a web session is valid for - /// </summary> - [JsonPropertyName("session_valid_for_sec")] - public uint WebSessionValidForSeconds { get; set; } = 3600; - - [JsonPropertyName("otp_time_diff_sec")] - public uint SigTokenTimeDifSeconds - { - get => (uint)SignedTokenTimeDiff.TotalSeconds; - set => SignedTokenTimeDiff = TimeSpan.FromSeconds(value); - } - - /// <summary> - /// Enforce that the client's token is only valid for the origin - /// it was read from. Will break sites hosted from multiple origins - /// </summary> - [JsonPropertyName("strict_origin")] - public bool EnforceSameOriginToken { get; set; } = true; - - /// <summary> - /// Enable/disable origin verification for the client's token - /// </summary> - [JsonIgnore] - public bool VerifyOrigin => AllowedOrigins != null && AllowedOrigins.Length > 0; - - /// <summary> - /// The list of origins that are allowed to send requests to the server - /// </summary> - [JsonPropertyName("allowed_origins")] - public string[]? AllowedOrigins { get; set; } - - /// <summary> - /// Enforce strict path checking for the client's token - /// </summary> - [JsonPropertyName("strict_path")] - public bool VerifyPath { get; set; } = true; - - void IOnConfigValidation.Validate() - { - //Validate the current instance - _validator.ValidateAndThrow(this); - } + _authManager.ExpireCookies(entity, force); } private sealed class EncryptedTokenAuthorization(string ClientAuthToken) : IClientAuthorization @@ -889,6 +267,5 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider ///<inheritdoc/> public string GetClientAuthDataString() => ClientAuthToken; } - } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientAuthData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientAuthData.cs new file mode 100644 index 0000000..c2369ec --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientAuthData.cs @@ -0,0 +1,32 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: ClientAuthData.cs +* +* ClientAuthData.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/. +*/ + + +namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider +{ + readonly record struct ClientAuthData(string PublicKey, string ClientData) + { + public static ClientAuthData FromSecInfo(IClientSecInfo secInfo) => new(secInfo.PublicKey, secInfo.ClientId); + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs new file mode 100644 index 0000000..c4b0c26 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs @@ -0,0 +1,554 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: ClientWebAuthManager.cs +* +* ClientWebAuthManager.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.Linq; +using System.Text.Json; + +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Extensions; + +namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider +{ + internal sealed class ClientWebAuthManager(AccountSecConfig config, ILogProvider logger) + { + const string PUBLIC_KEY_SIG_KEY_ENTRY = "acnt.pbsk"; + const string LOGIN_TOKEN_ENTRY = "acnt.lgk"; + const int PUB_KEY_JWT_NONCE_SIZE = 16; + const HashAlg ClientTokenHmacType = HashAlg.SHA256; + + private readonly AccountSecConfig _config = config; + private readonly ILogProvider _logger = logger; + private readonly SingleCookieController _pubkeyCookie = new (config.PubKeyCookieName, config.AuthorizationValidFor) + { + Domain = config.CookieDomain, + Path = config.CookiePath, + SameSite = CookieSameSite.Strict, + HttpOnly = true, + Secure = true + }; + + /// <summary> + /// Destroys the connection's authorization and session + /// </summary> + /// <param name="entity">The connection to destroy authorization data for</param> + public void DestroyAuthorization(HttpEntity entity) + { + entity.Session.UserID = null!; + entity.Session.Privilages = 0; + + SetLoginToken(in entity.Session, null); + SetSigningKey(in entity.Session, null); + + _pubkeyCookie.ExpireCookie(entity, true); + } + + /// <summary> + /// Attempts to regenerate the authorization data for the connection + /// using existing credentials. This function does not test the + /// existing authorization status, it assumes the connection is + /// authorized already. + /// </summary> + /// <param name="entity">The connection to re-authorize</param> + /// <param name="clientAuthData">The authentication data to return to the client</param> + /// <returns>True if the connection could be reauthorized, false otherwise</returns> + public bool TryReAuthorizeConnection(HttpEntity entity, ref string clientAuthData) + { + ClientAuthData cad = default; + + if (!TryGetSavedAuthData(entity, ref cad)) + { + return false; + } + + //Generate the authorization data + clientAuthData = AuthorizeConnection(entity, ref cad); + + return true; + } + + /// <summary> + /// Expires all authorization related cookies for the connection + /// </summary> + /// <param name="entity">The entity to clear cookies from</param> + /// <param name="force">A value that indicates whether the cookie should be sent to the client even if it isnt set</param> + public void ExpireCookies(HttpEntity entity, bool force) => _pubkeyCookie.ExpireCookie(entity, force); + + /// <summary> + /// Verifies the client's connection OTP token header to ensure the connection + /// is authorized. + /// </summary> + /// <param name="entity">The connection to verify</param> + /// <returns>True if the connection is authorized or false otherwise</returns> + public bool VerifyConnectionOTP(HttpEntity entity) + { + ClientAuthData cad = default; + + /* + * When calling TryGetSavedAuthData() it ensures the client + * has a valid, signed, client auth data in its session. + * + * Second we can verify the client's OTP token sent in + * a header to ensure the client. + * + * The header should be a valid JWT signed with the shared + * key sent during authorization + */ + return TryGetSavedAuthData(entity, ref cad) && VerifyConnectionOTPInternal(entity); + } + + /// <summary> + /// Determines if the connection has minimal auhtorization and should be + /// able to check for a higher level of authorization + /// </summary> + /// <param name="entity">The connection to verify</param> + /// <returns>A value that indicates if the connection has a minimal authorization status</returns> + public bool HasMinimalAuthorization(HttpEntity entity) + { + ClientAuthData cad = default; + return TryGetSavedAuthData(entity, ref cad); + } + + /// <summary> + /// Upgrades the desired connection using the provided security information + /// </summary> + /// <param name="entity">The connection to upgrade</param> + /// <param name="authData">The client's security information used for the upgrade</param> + /// <returns>The encoded data to return to the client</returns> + public string AuthorizeConnection(HttpEntity entity, ref readonly ClientAuthData authData) + { + string serverToken = string.Empty; + string clientToken = string.Empty; + string encodedSigKey = string.Empty; + string pubkeyCookieValue = string.Empty; + + //Generate the authorization data + GenerateToken(in authData, ref serverToken, ref clientToken); + GenerateClientAuthCookie(in authData, entity, ref pubkeyCookieValue, ref encodedSigKey); + + //Upgrade the connection and session + SetLoginToken(in entity.Session, serverToken); + SetSigningKey(in entity.Session, encodedSigKey); + SetPubkeyCookie(entity, pubkeyCookieValue); + + return clientToken; + } + + /// <summary> + /// Attempts to recover the client's encryption public key from the connection + /// used to encrypt client data + /// </summary> + /// <param name="entity">The connection to recover the public key from</param> + /// <param name="pubkey">A reference to the public key string</param> + /// <returns>A value that indicates if the public key could be recovered</returns> + public bool TryGetEncryptionPubkey(HttpEntity entity, ref string pubkey) + { + ClientAuthData cad = default; + + if (!TryGetSavedAuthData(entity, ref cad)) + { + return false; + } + + pubkey = cad.PublicKey; + return true; + } + + private void GenerateToken(ref readonly ClientAuthData secInfo, ref string serverToken, ref string clientToken) + { + //Alloc buffer for encode/decode + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(4000, true); + try + { + Span<byte> secretBuffer = buffer.Span[.._config.TokenKeySize]; + Span<byte> outputBuffer = buffer.Span[_config.TokenKeySize..]; + + //Computes a random shared key + RandomHash.GetRandomBytes(secretBuffer); + + ERRNO bytesEncrypted = RsaClientDataEncryption.TryEncrypt(secInfo.PublicKey, secretBuffer, outputBuffer); + + //Encyrpt the secret key to send to client + if (!bytesEncrypted) + { + throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small"); + } + + //Client token is the encrypted secret key + clientToken = Convert.ToBase64String(outputBuffer[..(int)bytesEncrypted]); + + //Encode base64 url safe + serverToken = VnEncoding.ToBase64UrlSafeString(secretBuffer, false); + } + finally + { + //Zero buffer when complete + MemoryUtil.InitializeBlock(ref buffer.GetReference(), buffer.GetIntLength()); + } + } + + /* + * 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 void GenerateClientAuthCookie(ref readonly ClientAuthData secInfo, HttpEntity entity, ref string cookieValue, ref string encodedSigKey) + { + //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", secInfo.ClientData) + .AddClaim("iat", entity.RequestedTimeUtc.ToUnixTimeSeconds()) + .AddClaim("exp", entity.RequestedTimeUtc.Add(_config.AuthorizationValidFor).ToUnixTimeSeconds()) + .AddClaim("nonce", nonce) + .AddClaim("aud", entity.Server.RequestUri.GetLeftPart(UriPartial.Authority)) + .AddClaim("pk", secInfo.PublicKey) + .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 + encodedSigKey = VnEncoding.ToBase32String(signingKey, false); + + //Compile the jwt for the cookie value + cookieValue = jwt.Compile(); + + //Zero signing key now were done using it + MemoryUtil.InitializeBlock(signingKey); + } + + private bool TryGetSavedAuthData(HttpEntity entity, ref ClientAuthData authData) + { + //Check session is valid for use + if (!IsSessionValid(in entity.Session)) + { + return false; + } + + //Get the jwt cookie + string? pubKeyJwt = _pubkeyCookie.GetCookie(entity); + + if (string.IsNullOrWhiteSpace(pubKeyJwt)) + { + return false; + } + + //Get the client signature + string? base32Sig = GetSigningKey(in entity.Session); + + if (string.IsNullOrWhiteSpace(base32Sig)) + { + return false; + } + + try + { + + //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; + } + + MemoryUtil.InitializeBlock(signingKey); + + using JsonDocument payload = jwt.GetPayload(); + + /* + * If the signature is valid we should be able to safely recover the + * propertes we need. We should be able to assume all servers in the + * network assign the same properties to the jwt + */ + + string aud = payload.RootElement.GetProperty("aud").GetString()!; + long exp = payload.RootElement.GetProperty("exp").GetInt64(); + + //Check the audience matches the authority of the connection + if (!string.Equals(aud, entity.Server.RequestUri.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + //Check the expiration time + if (exp < entity.RequestedTimeUtc.ToUnixTimeSeconds()) + { + return false; + } + + authData = new() + { + ClientData = payload.RootElement.GetProperty("sub").GetString()!, + PublicKey = payload.RootElement.GetProperty("pk").GetString()! + }; + + return true; + } + catch (FormatException) + { + //JWT is invalid and could not be parsed + _logger.Debug("Client public key JWT or message body was not valid from {ip}", entity.TrustedRemoteIp); + } + + return false; + } + + private bool VerifyConnectionOTPInternal(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 = GetLoginToken(in entity.Session); + 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 + */ + + try + { + bool isValid = true; + + //Parse the client jwt signed message + using JsonWebToken jwt = JsonWebToken.Parse(signedMessage); + + using (UnsafeMemoryHandle<byte> decodeBuffer = MemoryUtil.UnsafeAllocNearestPage(_config.TokenKeySize, true)) + { + //Recover the key from base32 + ERRNO count = VnEncoding.Base64UrlDecode(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) + && iatEl.ValueKind == JsonValueKind.Number) + { + //Try to get iat in unint seconds + isValid &= iatEl.TryGetInt64(out long iatSec); + + //Recover dto from unix seconds regardless of int success + 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; + } + + if (_config.VerifyOrigin) + { + //Check the audience matches the request uri + if (data.RootElement.TryGetProperty("aud", out JsonElement tokenOriginEl) + && tokenOriginEl.ValueKind == JsonValueKind.String) + { + string? unsafeUserOrigin = tokenOriginEl.GetString(); + + if (string.IsNullOrWhiteSpace(unsafeUserOrigin)) + { + isValid = false; + } + else if (_config.EnforceSameOriginToken) + { + //enforce strict origin checking + string strictOrigin = entity.Server.RequestUri.GetLeftPart(UriPartial.Authority); + isValid &= string.Equals(unsafeUserOrigin, strictOrigin, StringComparison.OrdinalIgnoreCase); + + if (!isValid) + { + _logger.Debug("Client security OTP JWT origin mismatch from {ip} : strict origin {current} != {token}", + entity.TrustedRemoteIp, + strictOrigin, + unsafeUserOrigin + ); + } + } + else + { + //Verify against allow list + isValid &= _config.AllowedOrigins!.Contains(unsafeUserOrigin, StringComparer.OrdinalIgnoreCase); + + if (!isValid) + { + _logger.Debug("CST origin not allowed {ip} : {token}", + entity.TrustedRemoteIp, + unsafeUserOrigin + ); + } + } + } + else + { + isValid = false; + } + } + + if (_config.VerifyPath) + { + //Check the subject (path) matches the request uri + if (data.RootElement.TryGetProperty("path", out JsonElement tokenPathEl) + && tokenPathEl.ValueKind == JsonValueKind.String) + { + + ReadOnlySpan<char> unsafeUserPath = tokenPathEl.GetString(); + /* + * Query parameters are optional, so we need to check if the path contains a + * query, if so we can compare the entire path and query, otherwise we need to + * compare the path only + */ + if (unsafeUserPath.Contains("?", StringComparison.OrdinalIgnoreCase)) + { + //Compare path and query when possible + string requestPath = entity.Server.RequestUri.PathAndQuery; + + isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); + + if (!isValid && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", + entity.TrustedRemoteIp, + requestPath, + unsafeUserPath.ToString() + ); + } + } + else + { + //Use path only + string requestPath = entity.Server.RequestUri.LocalPath; + + //Compare path only + isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); + + if (!isValid && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", + entity.TrustedRemoteIp, + requestPath, + unsafeUserPath.ToString() + ); + } + } + } + else + { + isValid = false; + } + } + + return isValid; + } + catch (FormatException) + { + //we may catch the format exception for a malformatted jwt + _logger.Debug("Client security OTP JWT not valid from {ip}", entity.TrustedRemoteIp); + return false; + } + } + + #region helperFunctions + + /// <summary> + /// A non-secure check to determine if the connection has been elevated + /// </summary> + /// <param name="session">The session to check the status of</param> + /// <returns>True of the session might be elevated</returns> + public static bool IsSessionElevated(ref readonly SessionInfo session) + => string.IsNullOrWhiteSpace(GetLoginToken(in session)) == false; + + private void SetPubkeyCookie(HttpEntity entity, string value) => _pubkeyCookie.SetCookie(entity, value); + + private static void SetSigningKey(ref readonly SessionInfo session, string? value) => session[PUBLIC_KEY_SIG_KEY_ENTRY] = value!; + private static void SetLoginToken(ref readonly SessionInfo session, string? value) => session[LOGIN_TOKEN_ENTRY] = value!; + + private static string? GetSigningKey(ref readonly SessionInfo session) => session[PUBLIC_KEY_SIG_KEY_ENTRY]; + private static string? GetLoginToken(ref readonly SessionInfo session) => session[LOGIN_TOKEN_ENTRY]; + + private static bool IsSessionValid(ref readonly SessionInfo session) => session.IsSet && !session.IsNew && session.SessionType == SessionType.Web; + + #endregion + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/RsaClientDataEncryption.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/RsaClientDataEncryption.cs new file mode 100644 index 0000000..89aaf73 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/RsaClientDataEncryption.cs @@ -0,0 +1,98 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: RsaClientDataEncryption.cs +* +* RsaClientDataEncryption.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.Security.Cryptography; + +using VNLib.Utils; +using VNLib.Utils.Memory; + +namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider +{ + internal static class RsaClientDataEncryption + { + /// <summary> + /// The client data encryption padding. Client library must match this padding + /// </summary> + public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; + + /// <summary> + /// Tries to encrypt the specified data using the specified public key + /// </summary> + /// <param name="base64PubKey">A base64 encoded public key used to encrypt client data</param> + /// <param name="data">Data to encrypt</param> + /// <param name="outputBuffer">The buffer to store encrypted data in</param> + /// <returns> + /// The number of encrypted bytes written to the output buffer, + /// or false (0) if the operation failed, or if no credential is + /// specified. + /// </returns> + /// <exception cref="CryptographicException"></exception> + public static ERRNO TryEncrypt(ReadOnlySpan<char> base64PubKey, ReadOnlySpan<byte> data, Span<byte> outputBuffer) + { + if (base64PubKey.IsEmpty) + { + return ERRNO.E_FAIL; + } + + //Alloc a buffer for decoding the public key + using UnsafeMemoryHandle<byte> pubKeyBuffer = MemoryUtil.UnsafeAllocNearestPage(base64PubKey.Length, true); + + //Decode the public key + ERRNO pbkBytesWritten = VnEncoding.TryFromBase64Chars(base64PubKey, pubKeyBuffer.Span); + + //Try to encrypt the data + return pbkBytesWritten ? TryEncrypt(pubKeyBuffer.Span[..(int)pbkBytesWritten], data, outputBuffer) : ERRNO.E_FAIL; + } + + /// <summary> + /// Tries to encrypt the specified data using the specified public key + /// </summary> + /// <param name="rawPubKey">The raw SKI public key</param> + /// <param name="data">Data to encrypt</param> + /// <param name="outputBuffer">The buffer to store encrypted data in</param> + /// <returns> + /// The number of encrypted bytes written to the output buffer, + /// or false (0) if the operation failed, or if no credential is + /// specified. + /// </returns> + /// <exception cref="CryptographicException"></exception> + public static ERRNO TryEncrypt(ReadOnlySpan<byte> rawPubKey, ReadOnlySpan<byte> data, Span<byte> 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; + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj index 7d30bc4..a9c207a 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj @@ -53,10 +53,10 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\..\..\core\lib\Plugins.Essentials\src\VNLib.Plugins.Essentials.csproj" /> - <ProjectReference Include="..\..\..\..\..\core\lib\Utils\src\VNLib.Utils.csproj" /> - <ProjectReference Include="..\..\..\..\Extensions\lib\VNLib.Plugins.Extensions.Loading\src\VNLib.Plugins.Extensions.Loading.csproj" /> - <ProjectReference Include="..\..\..\..\Extensions\lib\VNLib.Plugins.Extensions.Validation\src\VNLib.Plugins.Extensions.Validation.csproj" /> + <ProjectReference Include="..\..\..\..\core\lib\Plugins.Essentials\src\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\..\..\core\lib\Utils\src\VNLib.Utils.csproj" /> + <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Loading\src\VNLib.Plugins.Extensions.Loading.csproj" /> + <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Validation\src\VNLib.Plugins.Extensions.Validation.csproj" /> </ItemGroup> <ItemGroup> |