diff --git a/CortexRemote.csproj b/CortexRemote.csproj index e13128a..714a4cd 100644 --- a/CortexRemote.csproj +++ b/CortexRemote.csproj @@ -5,20 +5,23 @@ net9.0-windows enable enable + true true true - 9e3a541b-7d5a-407c-b1e6-96c24aa9c872 + + + - + @@ -28,9 +31,4 @@ - - - - - diff --git a/Endpoints/ErrorResponse.cs b/Endpoints/ErrorResponse.cs new file mode 100644 index 0000000..0745803 --- /dev/null +++ b/Endpoints/ErrorResponse.cs @@ -0,0 +1,3 @@ +namespace CortexRemote.Endpoints; + +public abstract record ErrorResponse(int StatusCode, string Message); \ No newline at end of file diff --git a/Endpoints/MapRemoteControlEndpoints.cs b/Endpoints/MapRemoteControlEndpoints.cs new file mode 100644 index 0000000..c397e3b --- /dev/null +++ b/Endpoints/MapRemoteControlEndpoints.cs @@ -0,0 +1,106 @@ +using System.Runtime.InteropServices; +using CortexRemote.Windows.Enums; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using Serilog; + +namespace CortexRemote.Endpoints; + +public static partial class MapRemoteControlEndpoints +{ + [LibraryImport("user32.dll", SetLastError = true)] + private static partial void LockWorkStation(); + + [LibraryImport("user32.dll", SetLastError = true)] + private static partial void ExitWindowsEx( + ShutdownTypes uExitCode, + uint dwReason = SHTDN_REASON_FLAG_PLANNED); + + // https://learn.microsoft.com/en-us/windows/win32/shutdown/system-shutdown-reason-codes + private const uint SHTDN_REASON_FLAG_PLANNED = 0x80000000; + + public static IEndpointConventionBuilder MapCommands(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var routeGroup = endpoints.MapGroup("/command"); + + routeGroup.MapPost("/shutdown", Results, InternalServerError>() => + { + Log.Information("Command: System Shutdown Command Received at {time}", + DateTime.Now); + + try + { + ExitWindowsEx(ShutdownTypes.EWX_HYBRID_SHUTDOWN + | ShutdownTypes.EWX_SHUTDOWN); + return TypedResults.Ok( + "System Shutdown Command Received"); + } + catch (Exception exception) + { + Log.Error("Shutdown Error: {exception}", exception); + return TypedResults.InternalServerError(); + } + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .WithName("Shutdown System") + .WithTags("Commands") + .WithSummary("Turn your computer off"); + + routeGroup.MapPost("/restart", Results, InternalServerError> () => + { + Log.Information("Command: System Restart Command Received at {time}", + DateTime.Now); + + try + { + ExitWindowsEx(ShutdownTypes.EWX_REBOOT); + return TypedResults.Ok( + "System Restart Command Received"); + } + catch (Exception exception) + { + Log.Error("Restart Error: {exception}", exception); + return TypedResults.InternalServerError(); + } + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .WithName("Restarts System") + .WithTags("Commands") + .WithSummary("Restart your computer"); + + + routeGroup.MapPost("/lock", Results, InternalServerError> () => + { + Log.Information("Command: System Lock Command Received at {time}", + DateTime.Now); + + try + { + LockWorkStation(); + return TypedResults.Ok( + "System Lock Command Received"); + } + catch (Exception exception) + { + Log.Error("Lock Error: {exception}", exception); + return TypedResults.InternalServerError(); + } + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .WithName("Lock System") + .WithTags("Commands") + .WithSummary("Lock your computer"); + + return routeGroup; + } +} \ No newline at end of file diff --git a/Endpoints/MapSecurityEndpoints.cs b/Endpoints/MapSecurityEndpoints.cs new file mode 100644 index 0000000..79d4e96 --- /dev/null +++ b/Endpoints/MapSecurityEndpoints.cs @@ -0,0 +1,60 @@ +using System.IdentityModel.Tokens.Jwt; +using CortexRemote.Endpoints.TokenModel; +using CortexRemote.Windows; +using CortexRemote.Windows.Security; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using Microsoft.IdentityModel.Tokens; + +namespace CortexRemote.Endpoints; + +public static class MapSecurityEndpoints +{ + public static void MapAuthentication(this IEndpointRouteBuilder endpoints, IKeyHandler keyHandler) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var routeGroup = endpoints.MapGroup("/auth"); + + routeGroup.MapPost("/refresh", + Results, UnauthorizedHttpResult> (AuthenticationRequest authenticationRequest) => + { + var storedToken = Convert.ToBase64String( + keyHandler.ImportRefreshToken()); + + if (!storedToken.Equals(authenticationRequest.RefreshToken)) + return TypedResults.Unauthorized(); + + var key = new RsaSecurityKey( + keyHandler.ImportAsymmetricKey(KeyHandler.SigningKeyFileName)); + var credentials = new SigningCredentials( + key: key, + algorithm: SecurityAlgorithms.RsaSha512); + + var token = new JwtSecurityToken( + issuer: DirectoryManager.APPLICATION_NAME_FOLDER, + audience: DirectoryManager.APPLICATION_NAME_FOLDER + + Environment.MachineName, + expires: DateTime.UtcNow.AddSeconds(30), + signingCredentials: credentials); + + var JwtTokenBody = new JwtSecurityTokenHandler() + .WriteToken(token); + + return TypedResults.Ok( + new TokenResponse + { + AccessToken = JwtTokenBody, + Expiration = token.ValidTo.ToLocalTime(), + Type = "Bearer" + }); + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .WithName("Authorization") + .WithTags("Validation") + .WithSummary("Request new access token"); + } +} \ No newline at end of file diff --git a/Endpoints/TokenModel/AuthenticationRequest.cs b/Endpoints/TokenModel/AuthenticationRequest.cs new file mode 100644 index 0000000..ad0ecb1 --- /dev/null +++ b/Endpoints/TokenModel/AuthenticationRequest.cs @@ -0,0 +1,9 @@ +using JetBrains.Annotations; + +namespace CortexRemote.Endpoints.TokenModel; + +[UsedImplicitly] +public class AuthenticationRequest +{ + public required string RefreshToken { get; init; } +} \ No newline at end of file diff --git a/Endpoints/TokenModel/TokenResponse.cs b/Endpoints/TokenModel/TokenResponse.cs new file mode 100644 index 0000000..c8a0119 --- /dev/null +++ b/Endpoints/TokenModel/TokenResponse.cs @@ -0,0 +1,8 @@ +namespace CortexRemote.Endpoints.TokenModel; + +public class TokenResponse +{ + public string AccessToken { get; set; } = null!; + public string Type { get; set; } = null!; + public DateTime Expiration { get; set; } +} diff --git a/Extensions/ApiEndpointExtensionMethod/RemoteEndpointExtension.cs b/Extensions/ApiEndpointExtensionMethod/RemoteEndpointExtension.cs deleted file mode 100644 index 850af03..0000000 --- a/Extensions/ApiEndpointExtensionMethod/RemoteEndpointExtension.cs +++ /dev/null @@ -1,39 +0,0 @@ -using CortexRemote.Interfaces; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; - -namespace CortexRemote.Extensions.ApiEndpointExtensionMethod; - -public static class RemoteEndpointExtension -{ - public static IEndpointConventionBuilder MapRemote(this IEndpointRouteBuilder endpoints) - { - ArgumentNullException.ThrowIfNull(endpoints); - - var executionCommands = endpoints.ServiceProvider.GetRequiredService(); - - var routeGroup = endpoints.MapGroup("/command"); - - routeGroup.MapPost("/shutdown", async context => - { - await executionCommands.ShutdownCommand(); - context.Response.StatusCode = StatusCodes.Status200OK; - }); - - routeGroup.MapPost("/restart", async context => - { - await executionCommands.RestartCommand(); - context.Response.StatusCode = StatusCodes.Status200OK; - }); - - routeGroup.MapPost("/lock", async context => - { - await executionCommands.LockCommand(); - context.Response.StatusCode = StatusCodes.Status200OK; - }); - - return routeGroup; - } -} \ No newline at end of file diff --git a/Interfaces/IExecutionCommands.cs b/Interfaces/IExecutionCommands.cs deleted file mode 100644 index 3f840c4..0000000 --- a/Interfaces/IExecutionCommands.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CortexRemote.Interfaces; - -public interface IExecutionCommands -{ - public Task ShutdownCommand(); - public Task LockCommand(); - public Task RestartCommand(); -} \ No newline at end of file diff --git a/Middleware/BearerSecuritySchemeTransformer.cs b/Middleware/BearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000..7efc48c --- /dev/null +++ b/Middleware/BearerSecuritySchemeTransformer.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace CortexRemote.Middleware; + +internal sealed class BearerSecuritySchemeTransformer( + IAuthenticationSchemeProvider authenticationSchemeProvider + ) : IOpenApiDocumentTransformer +{ + public async Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) + { + var requirements = new Dictionary + { + ["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + In = ParameterLocation.Header, + BearerFormat = "Json Web Token" + } + }; + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = requirements; + } + } +} \ No newline at end of file diff --git a/Middleware/JsonDateTimeConverter.cs b/Middleware/JsonDateTimeConverter.cs new file mode 100644 index 0000000..47f88a3 --- /dev/null +++ b/Middleware/JsonDateTimeConverter.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CortexRemote.Middleware; + +public class JsonDateTimeConverter : JsonConverter +{ + private const string TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => DateTime.Parse(reader.GetString() ?? ""); + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToLocalTime().ToString(TIME_FORMAT)); +} \ No newline at end of file diff --git a/Middleware/StartupConfiguration.cs b/Middleware/StartupConfiguration.cs new file mode 100644 index 0000000..4a24e39 --- /dev/null +++ b/Middleware/StartupConfiguration.cs @@ -0,0 +1,31 @@ +using CortexRemote.Windows.Security; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CortexRemote.Middleware; + +internal sealed class StartupConfiguration : IHostedService +{ + private readonly ILogger _logger; + private readonly IKeyHandler _keyHandler; + + public StartupConfiguration( + ILogger logger, + IKeyHandler keyHandler + ) + { + _logger = logger; + _keyHandler = keyHandler; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index ecbb3ce..84c7b26 100644 --- a/Program.cs +++ b/Program.cs @@ -1,66 +1,104 @@ -using CortexRemote.Extensions.ApiEndpointExtensionMethod; -using CortexRemote.Interfaces; -using CortexRemote.WindowsRemoteControl; -using CortexRemote.WindowsRemoteControl.Security; -using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Threading.RateLimiting; +using CortexRemote.Endpoints; +using CortexRemote.Middleware; +using CortexRemote.Windows; +using CortexRemote.Windows.Security; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; +using Scalar.AspNetCore; using Serilog; -var builder = WebApplication.CreateBuilder(args); +var builder = WebApplication.CreateBuilder(); builder.Host.UseSerilog((context, loggerConfiguration) => loggerConfiguration.ReadFrom.Configuration(context.Configuration)); -builder.Services.AddAuthentication(options => +var configuration = builder.Services.BuildServiceProvider().GetRequiredService(); + +builder.Services.AddSingleton(); +var rsa = builder.Services.BuildServiceProvider().GetRequiredService(); + +builder.WebHost.ConfigureKestrel(config => { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; -}).AddJwtBearer(options => + config.ListenAnyIP(configuration.GetValue("Configuration:Port")); +}); + +builder.Services.Configure(options => { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = GetWindowsTokenParameters.ApplicationName, - ValidAudience = "localhost", - IssuerSigningKey = new RsaSecurityKey(GetWindowsTokenParameters.CreateOrGetRSA()) - }; + options.SerializerOptions.Converters.Add(new JsonDateTimeConverter()); }); -builder.WebHost.ConfigureKestrel(config => +builder.Services.AddOpenApi(options => { - config.ListenAnyIP(5000); + options.AddDocumentTransformer(); }); +builder.Services.AddAuthentication() + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = DirectoryManager.APPLICATION_NAME_FOLDER, + ValidAudience = DirectoryManager.APPLICATION_NAME_FOLDER + + Environment.MachineName, + ClockSkew = TimeSpan.Zero, + IssuerSigningKey = new RsaSecurityKey( + rsa.ImportAsymmetricKey(KeyHandler.SigningKeyFileName)) + }; + }); builder.Services.AddAuthorization(); -builder.Services.AddHostedService(); -builder.Services.AddHostedService(); - -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddRateLimiter(rateLimiterOptions => +{ + rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + rateLimiterOptions.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.User.Identity?.Name ?? + httpContext.Request.Headers.Host.ToString(), + factory: _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = 36, + QueueLimit = 0, + Window = TimeSpan.FromMinutes(1) + })); +}); -builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { - app.UseSwagger() - .UseSwaggerUI(); + app.MapOpenApi(); + app.MapScalarApiReference(options => + { + options + .WithTitle("Cortex Remote") + .WithTheme(ScalarTheme.DeepSpace) + .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient); + }); } -app.UseAuthentication() - .UseAuthorization() - .UseHttpsRedirection(); +app.UseHttpsRedirection(); + +app.MapCommands() + .RequireAuthorization(); + +app.MapAuthentication(rsa); -app.MapRemote(); - //.RequireAuthorization(); +app.UseRateLimiter(); await app.RunAsync(); \ No newline at end of file diff --git a/README.md b/README.md index ead3d0b..1ec3b76 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,2 @@ # CortexRemote - -**CortexRemote** is a side project designed to remotely control your PC via a Cloudflare tunnel using the Windows API. The application aims to provide a secure, cross-platform solution to control a PC like an IoT device. Cross-platform support is currently a work in progress. - ---- - -## Features -- Remote shutdown, restart, and log-off functionality via CLI -- Cloudflare tunnel integration for secure remote access (can be disabled) -- Minimal configuration using JSON settings -- Partial cross-platform design (Linux support planned) - ---- - -## Requirements -- [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) -- [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/) -- A configured `appsettings.json` file - -## Configuration (Does not work currently) -Create a file named `appsettings.json` in the root directory: - -### Manual Tunnel Configuration (Self-Hosted) -```json -{ - "Cloudflare": { - "TunnelId": "your-tunnel-id", - "AuthToken": "your-auth-token", - "ListeningPort": 4999, - "RemoteAccessEnabled": true - } -} -``` - -### Auto Tunnel (Fast-Tunnel) -```json -{ - "Cloudflare": { - "ListeningPort": 4999, - "RemoteAccessEnabled": true - } -} -``` - -## Usage (Work in Progress) -CortexRemoteControl API is currently under development. - -- Commands are received via Http POST. -- A Swagger UI is available at `http://localhost:/swagger/index.html` when running in Development mode. -- Authentication is not yet finalized. (Just Token verification at the moment) -- The token is currently read from `appsettings.json`, which is **not secure** and will be replaced in future versions. - -### ⚠️⚠️⚠️ DO NOT EXPOSE THIS SERVICE TO THE INTERNET YET ⚠️⚠️⚠️ - -## Installation - -1. Clone the repo: -```bash -git clone https://github.com/Kuarma/CortexRemote.git -cd CortexRemote -``` -2. Restore packages: -```bash -dotnet restore -``` -3. Build project: -```bash -dotnet build -``` -4. Run the project: -```bash -dotnet run -``` +Readme coming soon... diff --git a/Windows/DirectoryManager.cs b/Windows/DirectoryManager.cs new file mode 100644 index 0000000..7d276e7 --- /dev/null +++ b/Windows/DirectoryManager.cs @@ -0,0 +1,70 @@ +namespace CortexRemote.Windows; + +internal static class DirectoryManager +{ + public const string APPLICATION_NAME_FOLDER = "CortexRemote"; + public const string KEY_FOLDER = "Keys"; + + public static bool TryGetFolder(string filename, string folderName, + Environment.SpecialFolder specialFolder, + out string? folderPath) + { + if (string.IsNullOrWhiteSpace(folderName) || + string.IsNullOrWhiteSpace(filename) || + !TryGetEnvironmentPath(specialFolder, + out var specialFolderPath)) + { + folderPath = null; + return false; + } + + folderPath = Path.Combine( + path1: specialFolderPath!, + path2: APPLICATION_NAME_FOLDER, + path3: folderName, + path4: filename); + + return true; + } + + public static bool TryGetOrCreateFolder(string filename, string folderName, + Environment.SpecialFolder specialFolder, + out string? folderPath) + { + if (string.IsNullOrWhiteSpace(folderName) || + string.IsNullOrWhiteSpace(filename) || + !TryGetEnvironmentPath(specialFolder, + out var specialFolderPath)) + { + folderPath = null; + return false; + } + + folderPath = Path.Combine( + path1: specialFolderPath!, + path2: APPLICATION_NAME_FOLDER, + path3: folderName); + + if (!Directory.Exists(folderPath)) + Directory.CreateDirectory(folderPath); + + folderPath = Path.Combine( + folderPath, filename); + + return true; + } + + private static bool TryGetEnvironmentPath(Environment.SpecialFolder specialFolder, + out string? folderPath) + { + var specialFolderPath = Environment.GetFolderPath(specialFolder); + if (!Directory.Exists(specialFolderPath)) + { + folderPath = null; + return false; + } + + folderPath = specialFolderPath; + return true; + } +} \ No newline at end of file diff --git a/WindowsRemoteControl/WindowsApi/Enums/Privileges.cs b/Windows/Enums/Privileges.cs similarity index 60% rename from WindowsRemoteControl/WindowsApi/Enums/Privileges.cs rename to Windows/Enums/Privileges.cs index 9599b53..08d3e93 100644 --- a/WindowsRemoteControl/WindowsApi/Enums/Privileges.cs +++ b/Windows/Enums/Privileges.cs @@ -1,4 +1,4 @@ -namespace CortexRemote.WindowsRemoteControl.WindowsApi.Enums; +namespace CortexRemote.Windows.Enums; [Flags] public enum Privileges : uint diff --git a/WindowsRemoteControl/WindowsApi/Enums/ShutdownTypes.cs b/Windows/Enums/ShutdownTypes.cs similarity index 93% rename from WindowsRemoteControl/WindowsApi/Enums/ShutdownTypes.cs rename to Windows/Enums/ShutdownTypes.cs index 0f13a0f..a846f6c 100644 --- a/WindowsRemoteControl/WindowsApi/Enums/ShutdownTypes.cs +++ b/Windows/Enums/ShutdownTypes.cs @@ -1,4 +1,4 @@ -namespace CortexRemote.WindowsRemoteControl.WindowsApi.Enums; +namespace CortexRemote.Windows.Enums; // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-exitwindowsex diff --git a/Windows/Security/HardwareIdentifier.cs b/Windows/Security/HardwareIdentifier.cs new file mode 100644 index 0000000..e43d903 --- /dev/null +++ b/Windows/Security/HardwareIdentifier.cs @@ -0,0 +1,38 @@ +using System.Management; +using System.Security.Cryptography; +using System.Text; + +namespace CortexRemote.Windows.Security; + +internal static class HardwareIdentifier +{ + public static byte[] GenerateHardwareHash() + { + var processorId = GetWMISpecificToken( + WMIClass: "Win32_Processor", + propertyName: "ProcessorId"); + + var motherboardId = GetWMISpecificToken( + WMIClass: "Win32_BaseBoard", + propertyName: "SerialNumber"); + + var hashBytes = Encoding.UTF8.GetBytes($"{processorId}{motherboardId}"); + + return SHA256.HashData(hashBytes); + } + + private static string GetWMISpecificToken(string WMIClass, string propertyName) + { + using var packageSpecificToken = new ManagementClass(WMIClass); + using var instances = packageSpecificToken.GetInstances(); + + var hardwareInstances = instances + .OfType() + .Select(o => o[propertyName]?.ToString()) + .FirstOrDefault(); + + return hardwareInstances ?? + throw new Exception( + $"Property '{propertyName}' not found in WMI class {WMIClass}"); + } +} \ No newline at end of file diff --git a/Windows/Security/KeyHandler.cs b/Windows/Security/KeyHandler.cs new file mode 100644 index 0000000..4955ba1 --- /dev/null +++ b/Windows/Security/KeyHandler.cs @@ -0,0 +1,103 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace CortexRemote.Windows.Security; + +internal sealed class KeyHandler : IKeyHandler +{ + public const string SigningKeyFileName = "CortexRemoteSigningKey.key"; + public const string EncryptionKeyFileName = "CortexRemoteEncryptionKey.key"; + public const string RefreshTokenFileName = "CortexRemoteRefreshToken.key"; + + private readonly ILogger _logger; + + public KeyHandler(ILogger logger) + { + _logger = logger; + } + + public void ExportAsymmetricKey( + string keyFileName) + { + var rsa = RSA.Create(); + if (!DirectoryManager.TryGetOrCreateFolder( + filename: keyFileName, + folderName: DirectoryManager.KEY_FOLDER, + specialFolder: Environment.SpecialFolder.ProgramFiles, + out var path)) + throw new Exception("Failed to create the key folder."); + + Encrypt( + encryptedFileLocation: path!, + key: rsa.ExportRSAPrivateKey()); + } + + public RSA ImportAsymmetricKey( + string keyFileName) + { + var rsa = RSA.Create(); + if (!DirectoryManager.TryGetOrCreateFolder( + filename: keyFileName, + folderName: DirectoryManager.KEY_FOLDER, + specialFolder: Environment.SpecialFolder.ProgramFiles, + out var path)) + throw new Exception("Failed to create the key folder."); + + var privateKey = Decrypt( + encryptedFileLocation: path!); + + rsa.ImportRSAPrivateKey(privateKey, out _); + return rsa; + } + + public void ExportRefreshToken(string keyFileName) + { + if (!DirectoryManager.TryGetOrCreateFolder( + filename: keyFileName, + folderName: DirectoryManager.KEY_FOLDER, + specialFolder: Environment.SpecialFolder.ProgramFiles, + out var path)) + throw new Exception("Failed to create the key folder."); + + Encrypt( + encryptedFileLocation: path!, + key: RandomNumberGenerator.GetBytes(32)); + } + + public byte[] ImportRefreshToken(string keyFileName = RefreshTokenFileName) + { + if (!DirectoryManager.TryGetOrCreateFolder( + filename: keyFileName, + folderName: DirectoryManager.KEY_FOLDER, + specialFolder: Environment.SpecialFolder.ProgramFiles, + out var path)) + throw new Exception("Failed to create the key folder."); + + return Decrypt( + encryptedFileLocation: path!); + } + + private static void Encrypt(string encryptedFileLocation, byte[] key) => + File.WriteAllBytes( + path: encryptedFileLocation, + bytes: ProtectedData.Protect( + userData: key, + optionalEntropy: HardwareIdentifier.GenerateHardwareHash(), + scope: DataProtectionScope.CurrentUser)); + + private static byte[] Decrypt(string encryptedFileLocation) => + ProtectedData.Unprotect( + encryptedData: File.ReadAllBytes(encryptedFileLocation), + optionalEntropy: HardwareIdentifier.GenerateHardwareHash(), + scope: DataProtectionScope.CurrentUser); +} + +public interface IKeyHandler +{ + public void ExportAsymmetricKey(string keyFileName); + public RSA ImportAsymmetricKey(string keyFileName); + + public void ExportRefreshToken(string keyFileName); + public byte[] ImportRefreshToken(string keyFileName = KeyHandler.RefreshTokenFileName); +} \ No newline at end of file diff --git a/WindowsRemoteControl/WindowsPrivilegeManager.cs b/Windows/Security/ProcessTokenService.cs similarity index 90% rename from WindowsRemoteControl/WindowsPrivilegeManager.cs rename to Windows/Security/ProcessTokenService.cs index 19b84ae..38b8cce 100644 --- a/WindowsRemoteControl/WindowsPrivilegeManager.cs +++ b/Windows/Security/ProcessTokenService.cs @@ -1,14 +1,14 @@ using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; -using CortexRemote.WindowsRemoteControl.WindowsApi.Enums; -using CortexRemote.WindowsRemoteControl.WindowsApi.Structs; +using CortexRemote.Windows.Enums; +using CortexRemote.Windows.Structs; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace CortexRemote.WindowsRemoteControl; +namespace CortexRemote.Windows.Security; -internal partial class WindowsPrivilegeManager : IHostedService +internal partial class ProcessTokenService : IHostedService { [LibraryImport("advapi32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] @@ -43,10 +43,10 @@ private static partial bool LookupPrivilegeValueW( private const int SE_PRIVILEGES_COUNT = 1; private IntPtr _tokenHandle; - private readonly ILogger _logger; + private readonly ILogger _logger; - public WindowsPrivilegeManager( - ILogger logger) + public ProcessTokenService( + ILogger logger) { _logger = logger; } diff --git a/WindowsRemoteControl/WindowsApi/Structs/LUID.cs b/Windows/Structs/LUID.cs similarity index 70% rename from WindowsRemoteControl/WindowsApi/Structs/LUID.cs rename to Windows/Structs/LUID.cs index 674a04d..6082e73 100644 --- a/WindowsRemoteControl/WindowsApi/Structs/LUID.cs +++ b/Windows/Structs/LUID.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace CortexRemote.WindowsRemoteControl.WindowsApi.Structs; +namespace CortexRemote.Windows.Structs; [StructLayout(LayoutKind.Sequential)] public struct LUID diff --git a/WindowsRemoteControl/WindowsApi/Structs/LuIdAttributes.cs b/Windows/Structs/LuIdAttributes.cs similarity index 71% rename from WindowsRemoteControl/WindowsApi/Structs/LuIdAttributes.cs rename to Windows/Structs/LuIdAttributes.cs index 44433ae..747e2b3 100644 --- a/WindowsRemoteControl/WindowsApi/Structs/LuIdAttributes.cs +++ b/Windows/Structs/LuIdAttributes.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace CortexRemote.WindowsRemoteControl.WindowsApi.Structs; +namespace CortexRemote.Windows.Structs; [StructLayout(LayoutKind.Sequential)] public struct LuIdAttributes diff --git a/WindowsRemoteControl/WindowsApi/Structs/TokenPrivileges.cs b/Windows/Structs/TokenPrivileges.cs similarity index 74% rename from WindowsRemoteControl/WindowsApi/Structs/TokenPrivileges.cs rename to Windows/Structs/TokenPrivileges.cs index b74a478..e3b67d3 100644 --- a/WindowsRemoteControl/WindowsApi/Structs/TokenPrivileges.cs +++ b/Windows/Structs/TokenPrivileges.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace CortexRemote.WindowsRemoteControl.WindowsApi.Structs; +namespace CortexRemote.Windows.Structs; [StructLayout(LayoutKind.Sequential)] public struct TokenPrivileges diff --git a/WindowsRemoteControl/Security/GetWindowsTokenParameters.cs b/WindowsRemoteControl/Security/GetWindowsTokenParameters.cs deleted file mode 100644 index 2469e57..0000000 --- a/WindowsRemoteControl/Security/GetWindowsTokenParameters.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Reflection; -using System.Security.Cryptography; - -namespace CortexRemote.WindowsRemoteControl.Security; - -public static class GetWindowsTokenParameters -{ - private const string PRIVATE_KEY_FILE = "privateKey.bin"; - private const string REFRESH_TOKEN_FILE = "refreshToken.bin"; - - public static readonly string ApplicationName = Assembly.GetExecutingAssembly().GetName().Name!; - - public static RSA CreateOrGetRSA() - { - var encryptedFile = GetFilePathFromAppData(PRIVATE_KEY_FILE); - - var rsa = RSA.Create(2048); - - if (File.Exists(encryptedFile)) - { - var decryptedPrivateKey = WindowsDataProtection.Decrypt(encryptedFile); - rsa.ImportRSAPrivateKey(decryptedPrivateKey, out _); - } - else - { - var privateKey = rsa.ExportRSAPrivateKey(); - WindowsDataProtection.Encrypt(encryptedFile, privateKey); - } - - return rsa; - } - - public static byte[] CreateOrGetRefreshToken(out bool firstRun) - { - var encryptedFile = GetFilePathFromAppData(REFRESH_TOKEN_FILE); - - if (File.Exists(encryptedFile)) - { - firstRun = false; - return WindowsDataProtection.Decrypt(encryptedFile); - } - - var bytes = CreateRefreshToken(); - WindowsDataProtection.Encrypt(encryptedFile, bytes); - firstRun = true; - return bytes; - } - - private static string GetFilePathFromAppData(string fileName) - { - var path = Path.Combine( - path1: Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - path2: Assembly.GetExecutingAssembly().GetName().Name!, - path3: fileName); - - var directory = Path.GetDirectoryName(path); - - if (!Directory.Exists(directory)) - Directory.CreateDirectory(directory!); - - return path; - } - - private static byte[] CreateRefreshToken() - { - return RandomNumberGenerator.GetBytes(64); - } -} \ No newline at end of file diff --git a/WindowsRemoteControl/Security/RefreshTokenManager.cs b/WindowsRemoteControl/Security/RefreshTokenManager.cs deleted file mode 100644 index 586049b..0000000 --- a/WindowsRemoteControl/Security/RefreshTokenManager.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.Extensions.Hosting; -using QRCoder; - -namespace CortexRemote.WindowsRemoteControl.Security; - -public class RefreshTokenManager : IHostedService -{ - public Task StartAsync(CancellationToken cancellationToken) - { - var refreshToken = Convert.ToBase64String( - GetWindowsTokenParameters.CreateOrGetRefreshToken(out var firstRun)); - - try - { - if (!firstRun) - return Task.CompletedTask; - - using var qrGenerator = new QRCodeGenerator(); - using var qrCodeData = qrGenerator.CreateQrCode(refreshToken, QRCodeGenerator.ECCLevel.Q); - using var qrCode = new QRCode(qrCodeData); - var qrCodeBitmap = qrCode.GetGraphic(10); - - var uiThread = new Thread(() => - { - var form = new Form() - { - Width = qrCodeBitmap.Width + 20, - Height = qrCodeBitmap.Height + 20, - FormBorderStyle = FormBorderStyle.FixedDialog, - StartPosition = FormStartPosition.CenterScreen, - Text = "Your Refresh Token. Do not share this token with anyone!!!" - }; - - var pictureBox = new PictureBox() - { - Dock = DockStyle.Fill, - Image = qrCodeBitmap, - SizeMode = PictureBoxSizeMode.StretchImage - }; - - form.Controls.Add(pictureBox); - - Application.Run(form); - }); - - uiThread.SetApartmentState(ApartmentState.STA); - uiThread.Start(); - } - finally - { - refreshToken = null; // Remove ref so the string can be garbage-collected. - } - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/WindowsRemoteControl/Security/WindowsDataProtection.cs b/WindowsRemoteControl/Security/WindowsDataProtection.cs deleted file mode 100644 index 3671b94..0000000 --- a/WindowsRemoteControl/Security/WindowsDataProtection.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Management; -using System.Security.Cryptography; -using System.Text; - -namespace CortexRemote.WindowsRemoteControl.Security; - -public static class WindowsDataProtection -{ - public static byte[] Decrypt(string encryptedFilePath) - { - var encryptedFile = File.ReadAllBytes(encryptedFilePath); - var decryptedData = ProtectedData.Unprotect( - encryptedData: encryptedFile, - optionalEntropy: GenerateHardwareHash(), - scope: DataProtectionScope.CurrentUser); - - return decryptedData; - } - - public static void Encrypt(string encryptedFileLocation, byte[] value) - { - var encryptedKey = ProtectedData.Protect( - userData: value, - optionalEntropy: GenerateHardwareHash(), - scope: DataProtectionScope.CurrentUser); - - File.WriteAllBytes( - path: encryptedFileLocation, - bytes: encryptedKey); - } - - private static string GetWMISpecificToken(string WMIClass, string propertyName) - { - using var packageSpecificToken = new ManagementClass(WMIClass); - using var instances = packageSpecificToken.GetInstances(); - - var hardwareInstances = instances - .OfType() - .Select(o => o[propertyName]?.ToString()) - .FirstOrDefault(); - - if (hardwareInstances is null) - throw new Exception($"Property '{propertyName}' not found in WMI class {WMIClass}"); //Todo: Create exception handling middleware - - return hardwareInstances; - } - - private static byte[] GenerateHardwareHash() - { - var processorId = GetWMISpecificToken("Win32_Processor", "ProcessorId") ?? - throw new Exception("GetWMISpecificToken: Failed to get ProcessorId"); // Todo: Create exception handling middleware - var motherboardId = GetWMISpecificToken("Win32_BaseBoard", "SerialNumber") ?? - throw new Exception("GetWMISpecificToken: Failed to get motherboard ID"); // Todo: Create exception handling middleware - - var hashBytes = Encoding.UTF8.GetBytes($"{processorId}{motherboardId}"); - - return SHA256.HashData(hashBytes); - } -} \ No newline at end of file diff --git a/WindowsRemoteControl/WindowsExecutionCommands.cs b/WindowsRemoteControl/WindowsExecutionCommands.cs deleted file mode 100644 index 0989d7c..0000000 --- a/WindowsRemoteControl/WindowsExecutionCommands.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Runtime.InteropServices; -using CortexRemote.Interfaces; -using CortexRemote.WindowsRemoteControl.WindowsApi.Enums; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace CortexRemote.WindowsRemoteControl; - -public partial class WindowsExecutionCommands : IExecutionCommands -{ - [LibraryImport("user32.dll", SetLastError = true)] - private static partial void LockWorkStation(); - - [LibraryImport("user32.dll", SetLastError = true)] - private static partial void ExitWindowsEx( - ShutdownTypes uExitCode, - uint dwReason = SHTDN_REASON_FLAG_PLANNED); - - // https://learn.microsoft.com/en-us/windows/win32/shutdown/system-shutdown-reason-codes - private const uint SHTDN_REASON_FLAG_PLANNED = 0x80000000; - - private readonly ILogger _logger; - - public WindowsExecutionCommands( - ILogger logger) - { - _logger = logger; - } - - public Task ShutdownCommand() - { - ExitWindowsEx(ShutdownTypes.EWX_HYBRID_SHUTDOWN | ShutdownTypes.EWX_SHUTDOWN); - return Task.FromResult(Results.Ok()); - } - - public Task LockCommand() - { - LockWorkStation(); - return Task.FromResult(Results.Ok()); - } - - public Task RestartCommand() - { - ExitWindowsEx(ShutdownTypes.EWX_REBOOT); - return Task.FromResult(Results.Ok()); - } -} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json index 4337f88..b0c260c 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,23 +1,34 @@ { - "Serilog": { - "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File"], - "MinimumLevel": { - "Default": "Information" - }, - "WriteTo": [ - { - "Name": "Console" - }, - { - "Name": "File", - "Args": { - "path": "logs/log-.json", - "rollingInterval": "Day", - "rollOnFileSizeLimit": true, - "formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog" - } - } - ], - "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] - } + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File" + ], + "MinimumLevel": { + "Default": "Information" + }, + "WriteTo": [ + { + "Name": "Console" + }, + { + "Name": "File", + "Args": { + "path": "logs/log-.json", + "rollingInterval": "Day", + "rollOnFileSizeLimit": true, + "formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "Configuration": { + "Port": 4000, + "RemoteAccessEnabled": false + } } \ No newline at end of file