/* * Copyright (c) 2024 Vaughn Nugent * * Package: CMNext.Cli * File: Program.cs * * CMNext.Cli is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published * by the Free Software Foundation, either version 2 of the License, * or (at your option) any later version. * * CMNext.Cli 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 * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. */ using System; using System.IO; using System.Net; using System.Linq; using System.Text.Json; using System.Security.Cryptography; using System.Security.Authentication; using RestSharp; using FluentValidation; using VNLib.Utils; using VNLib.Utils.Memory; using VNLib.Utils.Extensions; using VNLib.Plugins; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; using VNLib.Net.Rest.Client.Construction; using CMNext.Cli.Storage; namespace CMNext.Cli.Security { internal sealed class WebAuthenticator(Uri SiteBaseAddress) : VnDisposeable, IAuthAdapter, IWebAuthenticator, IStorable { const string OtpHeaderName = "X-Web-Token"; const string StatusCookieName = "li"; public CookieContainer Cookies { get; } = new(); private LoginSession _session; /// /// Determines whether the current session has a valid login /// or needs to re-authenticate /// /// public bool HasValidLogin() { //Find the status cookie and see if its still valid Cookie? statusCookie = Cookies.GetAllCookies() .Where(c => !c.Expired) .Where(c => c.Name == StatusCookieName) .FirstOrDefault(); //Only if we have session data an a valid status cookie return _session.Initialized && statusCookie != null; } /// protected override void Free() { _session.Destroy(); } /// public void SetModifiersForEndpoint(IRestRequestBuilder builder) { //Set cookies for the request builder.WithModifier((_, req) => req.CookieContainer = Cookies); //Also add the auth token builder.WithModifier((_, req) => req.AddHeader(OtpHeaderName, ComputeOtp(req))); //Set origin header to be safe builder.WithHeader("Origin", SiteBaseAddress.GetLeftPart(UriPartial.Authority)); } public void Destroy() { //Expire all cookies Cookies.GetAllCookies().TryForeach(static c => c.Expired = true); _session.Destroy(); } private string ComputeOtp(RestRequest request) { if (!_session.Initialized) { return string.Empty; } //Get the origin and path from the server uri string nonce = RandomHash.GetRandomBase32(16); using JsonWebToken jwt = new(); jwt.InitPayloadClaim(4) .AddClaim("nonce", nonce) .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds()) .AddClaim("aud", SiteBaseAddress.GetLeftPart(UriPartial.Authority)) .AddClaim("path", request.Resource) .CommitClaims(); //Sign the jwt with the session data _session.ComputeSignature(jwt); return jwt.Compile(); } /// public ISecurityCredential PrepareLogin() => Credential.Create(); /// public void FinalizeLogin(ISecurityCredential credential, WebMessage message) { if (credential is not Credential cred) { throw new ArgumentException("The provided credential is not a valid credential", nameof(credential)); } //Create a new login session from the credential and store it _session = LoginSession.FromCredential(cred, message); } /// public void Save(Stream stream) { using Utf8JsonWriter writer = new(stream); writer.WriteStartObject(); //Write cookies to stream { writer.WriteStartArray("cookies"); foreach (Cookie c in Cookies.GetAllCookies().Where(c => !c.Expired)) { writer.WriteStartObject(); writer.WriteString("name", c.Name); writer.WriteString("value", c.Value); writer.WriteString("domain", c.Domain); writer.WriteString("path", c.Path); writer.WriteBoolean("http_only", c.HttpOnly); writer.WriteBoolean("secure", c.Secure); writer.WriteNumber("expires", new DateTimeOffset(c.Expires).ToUnixTimeMilliseconds()); writer.WriteEndObject(); } writer.WriteEndArray(); } //Write secret data _session.Save(writer); writer.WriteEndObject(); writer.Flush(); } /// public bool Load(Stream stream) { if (stream.Length == 0) { return false; } using JsonDocument doc = JsonDocument.Parse(stream); //Get cookies element Cookie[] cookies = doc.RootElement.GetProperty("cookies") .EnumerateArray() .Select(c => new Cookie { Name = c.GetPropString("name")!, Value = c.GetPropString("value"), Domain = c.GetPropString("domain"), Path = c.GetPropString("path"), HttpOnly = c.GetProperty("http_only").GetBoolean(), Secure = c.GetProperty("secure").GetBoolean(), }) .ToArray(); //Add cookies back to the collection Array.ForEach(cookies, Cookies.Add); //recover session data _session = LoginSession.Load(doc.RootElement); return _session.Initialized; } private sealed class Credential : ISecurityCredential { private readonly RSA _alg; private Credential(RSA alg) => _alg = alg; /// public string PublicKey { get; set; } = string.Empty; /// public string ClientId { get; set; } = string.Empty; public static Credential Create() { //Init a fresh credential RSA rsa = RSA.Create(); byte[] publicKey = rsa.ExportSubjectPublicKeyInfo(); return new(rsa) { ClientId = RandomHash.GetRandomHex(16), PublicKey = Convert.ToBase64String(publicKey) }; } public byte[] ExporPrivateKeyAndErase() { byte[] key = _alg.ExportRSAPrivateKey(); _alg.Dispose(); return key; } public byte[] DecryptSharedKey(WebMessage response) { //Alloc temp buffer for decoding using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAllocNearestPage(4000, true); using UnsafeMemoryHandle decryptBuffer = MemoryUtil.UnsafeAllocNearestPage(4000, true); //recover base64 encoded shared key ERRNO read = VnEncoding.TryFromBase64Chars(response.Token, buffer.Span); if (read < 1) { throw new AuthenticationException("Failed to decode server's shared data"); } if (!_alg.TryDecrypt(buffer.AsSpan(0, read), decryptBuffer.Span, RSAEncryptionPadding.OaepSHA256, out int written)) { throw new AuthenticationException("Failed to decrypt the server's shared data"); } //Return the decrypted data return decryptBuffer.AsSpan(0, written).ToArray(); } } private readonly struct LoginSession { const string SecretsKey = "secrets"; /// /// Gets whether the login session has been initialized /// public readonly bool Initialized => _privateLKey != null; private readonly byte[] _privateLKey; private readonly byte[] _sharedKey; private LoginSession(byte[] privateLKey, byte[] sharedKey) { _privateLKey = privateLKey; _sharedKey = sharedKey; } /// /// Writes the login session to the provided json writer /// /// /// public readonly void Save(Utf8JsonWriter writer) { if (!Initialized) { return; } //Create a secrets element and write the keys to it writer.WriteStartObject(SecretsKey); writer.WriteBase64String("private_key", _privateLKey); writer.WriteBase64String("shared_key", _sharedKey); writer.WriteEndObject(); } /// /// Destroys any secret data /// public readonly void Destroy() { //Clean up keys if (Initialized) { MemoryUtil.InitializeBlock(_privateLKey); MemoryUtil.InitializeBlock(_sharedKey); } } /* * This function will be used to sign on-time passwords * that are sent in the header fields for authentication * by the Essentials.Accounts plugin. */ /// public readonly void ComputeSignature(JsonWebToken jwt) { if (!Initialized) { throw new InvalidOperationException("Cannot compute signature from an uninitialized login session"); } /* * This much match the server's implementation. Currently configured * for HMAC-SHA256. */ jwt.Sign(_sharedKey, HashAlg.SHA256); } /// /// Gets a SPKI encoded public key from the stored private key which matches the /// server format /// /// The base64 encoded public key string /// public readonly AsymmetricAlgorithm GetAlgorithm() { if (!Initialized) { throw new InvalidOperationException("Cannot get public key from an uninitialized login session"); } //Create RSA and import the stored private key RSA rsa = RSA.Create(); rsa.ImportRSAPrivateKey(_privateLKey, out _); return rsa; } /// /// Loads the secret session data from the provided json element /// /// The previously stored json data object that contains the secrets element /// The recovered public static LoginSession Load(JsonElement data) { //Get secrets element if(!data.TryGetProperty(SecretsKey, out JsonElement secretsEl)) { return default; } //Recover keys from element byte[] privateKey = secretsEl.GetProperty("private_key").GetBytesFromBase64(); byte[] sharedKey = secretsEl.GetProperty("shared_key").GetBytesFromBase64(); return new LoginSession(privateKey, sharedKey); } /// /// Creates a new login session from the provided credential server /// response web message /// /// The existing credential that initiated the login /// The webmessage server response /// The new structure containing secret data public static LoginSession FromCredential(Credential credential, WebMessage webm) { //Recover the shared key and private key from the credential byte[] sharedKey = credential.DecryptSharedKey(webm); byte[] privKey = credential.ExporPrivateKeyAndErase(); //Init new session from keys return new LoginSession(privKey, sharedKey); } } } }