diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..8f88aa8 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,40 @@ +name: .NET Core Desktop Continuous Integration + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + +jobs: + build: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore dependencies + run: dotnet restore WinCertInstaller.sln + + - name: Build + run: dotnet build WinCertInstaller.sln --no-restore -c Release + + - name: Test + run: dotnet test WinCertInstaller.sln --no-build -c Release --verbosity normal + + - name: Publish WinCertInstaller + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + run: dotnet publish WinCertInstaller/WinCertInstaller/WinCertInstaller.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o ./publish + + - name: Upload Artifact + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + uses: actions/upload-artifact@v4 + with: + name: WinCertInstaller-Win64 + path: ./publish/WinCertInstaller.exe diff --git a/README.md b/README.md index 8e091ff..3571b11 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,87 @@ -# WinCertInstaller +# WinCertInstaller 🛡️ -This repository contains a c# application which install ITI and MPF certificates in Windows Trusted Root Store +[![.NET](https://github.com/fernandoribeiro/WinCertInstaller/actions/workflows/dotnet.yml/badge.svg)](https://github.com/fernandoribeiro/WinCertInstaller/actions/workflows/dotnet.yml) -*Needs Administrator privileges to run.* +WinCertInstaller is an enterprise-grade C# (.NET 10) utility designed to automate the download and installation of official Root and Intermediate Certificates from **ITI (ICP-Brasil)** and **MPF** into the Windows Certificate Store. -*Use at your own risk.* +> [!IMPORTANT] +> **Administrator Privileges Required**: This application requires elevated privileges to write certificates to the `LocalMachine` X509 store. It includes an `app.manifest` to automatically request UAC elevation if not already running as Administrator. -## TODO +## 🚀 Key Features -It makes sense to reconstruct a minimally-working original code +* **Automated Certificate Fetching**: Downloads the latest `.zip` (ITI) and `.p7b` (MPF) bundles directly from official repositories. +* **Robust Decoding**: Handles various formats, including **PEM-encoded PKCS#7** payloads (MPF) and nested ZIP archives (ITI). +* **Intelligent Validation**: + * Filters for Certificate Authorities (CA). + * Distinguishes between Root CAs (installed in `Trusted Root`) and Intermediate CAs (installed in `Intermediate Certification Authorities`). + * Checks for expiration and activation dates. +* **Idempotency**: Detects and skips certificates already present in the store to avoid duplication. +* **Modern CLI Experience**: Features clean, color-coded console output using a custom `ILogger` formatter. +* **Dry-Run Mode**: Validate the entire download and extraction process without modifying the system state. -What is missing right now: -* Better exceptions catch +## 🏗️ Architecture (SOLID & Enterprise) +The application has been refactored to follow modern .NET best practices: +* **Microsoft.Extensions.Hosting**: Uses the Generic Host pattern for dependency injection, logging, and configuration management. +* **Dynamic Configuration**: All certificate URLs and settings are managed via `appsettings.json`. +* **Structured Logging**: Utilizes `ILogger` for clean, maintainable logging that can be easily redirected to cloud providers or files. +* **Dependency Injection**: Decoupled services for Downloading, Validation, and Installation, making the codebase highly testable and maintainable. -*Feel free to make pull-requests :)* +## 💻 Compatibility + +* **Runtime**: .NET 10.0+ (Windows-specific payload). +* **Operating Systems**: + * **Windows 10 / 11** (Fully supported, native target). + * **Windows Server 2016 / 2019 / 2022**. + * *Note: Requires administrator access for LocalMachine store operations.* + +## 🛠️ Usage + +```console +Usage: WinCertInstaller [options] + +Options: + --iti Install ITI certificates + --mpf Install MPF certificates + --all Install both ITI and MPF certificates (default) + --dry-run Simulate installation without writing to the store + -q Quiet mode (suppress exit prompt) + -h,--help Show this help message +``` + +### Examples + +**Standard Installation (All Sources):** +```powershell +WinCertInstaller.exe +``` + +**Dry-Run of ITI Source:** +```powershell +WinCertInstaller.exe --iti --dry-run +``` + +**Quiet Installation (Script Mode):** +```powershell +WinCertInstaller.exe --all -q +``` + +## 🧪 Development + +### Configuration +Edit `appsettings.json` to update certificate URLs or tune logging behavior without recompiling. + +### Build & Test +```powershell +# Restore and build the solution +dotnet build WinCertInstaller.sln + +# Run unit tests +dotnet test WinCertInstaller.sln +``` + +### CI/CD +A GitHub Actions workflow is included (`.github/workflows/dotnet.yml`) that automatically builds, tests, and publishes a self-contained, single-file executable on every push to `main`/`master`. + +--- +*Maintained with ❤️ for secure Windows environments.* diff --git a/WinCertInstaller.Tests/ProgramTests.cs b/WinCertInstaller.Tests/ProgramTests.cs new file mode 100644 index 0000000..633a1d0 --- /dev/null +++ b/WinCertInstaller.Tests/ProgramTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Xunit; +using WinCertInstaller.Models; +using WinCertInstaller.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace WinCertInstaller.Tests +{ + public class ProgramTests + { + [Fact] + public void ParseArguments_DefaultsToAll() + { + var result = WinCertInstaller.Program.ParseArguments(Array.Empty()); + + Assert.Equal(CertSource.All, result.source); + Assert.False(result.dryRun); + Assert.False(result.quiet); + Assert.False(result.showHelp); + } + + [Fact] + public void ParseArguments_MpfAndDryRunAndQuiet_SetCorrectFlags() + { + var result = WinCertInstaller.Program.ParseArguments(new[] { "--mpf", "--dry-run", "-q" }); + + Assert.Equal(CertSource.MPF, result.source); + Assert.True(result.dryRun); + Assert.True(result.quiet); + Assert.False(result.showHelp); + } + + [Fact] + public void ParseArguments_HelpSet_ShowHelpTrue() + { + var result = WinCertInstaller.Program.ParseArguments(new[] { "--help" }); + + Assert.Equal(CertSource.None, result.source); + Assert.True(result.showHelp); + } + + [Fact] + public void ParseArguments_InvalidArgument_ThrowsArgumentException() + { + Assert.Throws(() => WinCertInstaller.Program.ParseArguments(new[] { "--unknown" })); + } + + [Fact] + public void IsCertificateAuthorityAndSelfSigned_TrueForSelfSignedCA() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=TestCA", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + var validator = new CertificateValidator(NullLogger.Instance); + + Assert.True(validator.IsCertificateAuthority(certificate)); + Assert.True(validator.IsSelfSigned(certificate)); + } + } +} diff --git a/WinCertInstaller.Tests/WinCertInstaller.Tests.csproj b/WinCertInstaller.Tests/WinCertInstaller.Tests.csproj new file mode 100644 index 0000000..dfb5ecd --- /dev/null +++ b/WinCertInstaller.Tests/WinCertInstaller.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0-windows7.0 + enable + enable + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + \ No newline at end of file diff --git a/WinCertInstaller.sln b/WinCertInstaller.sln index b0b044d..bc2cd24 100644 --- a/WinCertInstaller.sln +++ b/WinCertInstaller.sln @@ -10,16 +10,42 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinCertInstaller.Tests", "WinCertInstaller.Tests\WinCertInstaller.Tests.csproj", "{9C5EF0B3-D039-4278-BA67-F47AFB11364D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C22FA746-778E-4806-BD82-F4E0FCE53763}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C22FA746-778E-4806-BD82-F4E0FCE53763}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Debug|x64.ActiveCfg = Debug|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Debug|x64.Build.0 = Debug|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Debug|x86.ActiveCfg = Debug|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Debug|x86.Build.0 = Debug|Any CPU {C22FA746-778E-4806-BD82-F4E0FCE53763}.Release|Any CPU.ActiveCfg = Release|Any CPU {C22FA746-778E-4806-BD82-F4E0FCE53763}.Release|Any CPU.Build.0 = Release|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Release|x64.ActiveCfg = Release|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Release|x64.Build.0 = Release|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Release|x86.ActiveCfg = Release|Any CPU + {C22FA746-778E-4806-BD82-F4E0FCE53763}.Release|x86.Build.0 = Release|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Debug|x64.Build.0 = Debug|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Debug|x86.Build.0 = Debug|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Release|Any CPU.Build.0 = Release|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Release|x64.ActiveCfg = Release|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Release|x64.Build.0 = Release|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Release|x86.ActiveCfg = Release|Any CPU + {9C5EF0B3-D039-4278-BA67-F47AFB11364D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/WinCertInstaller/Configuration/AppSettings.cs b/WinCertInstaller/Configuration/AppSettings.cs new file mode 100644 index 0000000..4817c5d --- /dev/null +++ b/WinCertInstaller/Configuration/AppSettings.cs @@ -0,0 +1,21 @@ +namespace WinCertInstaller.Configuration +{ + /// + /// Application settings containing default URLs for certificate downloads. + /// Mapped directly from appsettings.json via IOptions. + /// + public class AppSettings + { + public const string Position = "CertificateSources"; + + /// + /// The URL to the ZIP file containing ITI (ICP-Brasil) certificates. + /// + public string ITICertUrl { get; set; } = string.Empty; + + /// + /// The URL to the P7B (PKCS #7) file containing MPF certificates. + /// + public string MPFCertUrl { get; set; } = string.Empty; + } +} diff --git a/WinCertInstaller/Logging/CleanConsoleFormatter.cs b/WinCertInstaller/Logging/CleanConsoleFormatter.cs new file mode 100644 index 0000000..d953341 --- /dev/null +++ b/WinCertInstaller/Logging/CleanConsoleFormatter.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace WinCertInstaller.Logging +{ + public class CleanConsoleFormatter : ConsoleFormatter + { + public CleanConsoleFormatter() : base("CleanLayout") { } + + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + string message = logEntry.Formatter(logEntry.State, logEntry.Exception); + + if (string.IsNullOrEmpty(message)) return; + + // Define cores baseadas no nível de log + var color = logEntry.LogLevel switch + { + LogLevel.Error => "\u001b[31m", // Red + LogLevel.Warning => "\u001b[33m", // Yellow + LogLevel.Information => "", // Default + _ => "" + }; + + var reset = "\u001b[0m"; + + if (logEntry.LogLevel >= LogLevel.Warning) + { + textWriter.WriteLine($"{color}{logEntry.LogLevel.ToString().ToUpper()}: {message}{reset}"); + } + else + { + textWriter.WriteLine(message); + } + + if (logEntry.Exception != null) + { + textWriter.WriteLine(logEntry.Exception.ToString()); + } + } + } +} diff --git a/WinCertInstaller/Models/CertSource.cs b/WinCertInstaller/Models/CertSource.cs new file mode 100644 index 0000000..9c56979 --- /dev/null +++ b/WinCertInstaller/Models/CertSource.cs @@ -0,0 +1,24 @@ +using System; + +namespace WinCertInstaller.Models +{ + /// + /// Represents the sources from which certificates should be installed. + /// This enumeration supports bitwise combinations (Flags). + /// + [Flags] + public enum CertSource + { + /// No source selected. + None = 0, + + /// Installs certificates from ITI (Instituto Nacional de Tecnologia da Informação). + ITI = 1, + + /// Installs certificates from MPF (Ministério Público Federal). + MPF = 2, + + /// Installs certificates from all available sources. + All = ITI | MPF + } +} diff --git a/WinCertInstaller/Program.cs b/WinCertInstaller/Program.cs index c17b829..dbda2d2 100644 --- a/WinCertInstaller/Program.cs +++ b/WinCertInstaller/Program.cs @@ -1,190 +1,193 @@ -using System; -using System.Net; -using System.IO; -using System.IO.Compression; +using System; +using System.Threading; +using System.Threading.Tasks; using System.Security.Cryptography.X509Certificates; -using System.Linq; +using WinCertInstaller.Models; +using WinCertInstaller.Configuration; +using WinCertInstaller.Services; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; +using WinCertInstaller.Logging; namespace WinCertInstaller { - class Program + public class Program { - static Stream DownloadFile(String url) + static void PrintUsage() { - WebClient client = new WebClient(); - Stream stream = null; - try - { - stream = client.OpenRead(url); - } catch (System.Net.WebException ex) - { - Console.WriteLine("ERROR: Unable to download certificates."); - Console.WriteLine("ERROR: {0}", ex.Message); - } - - return stream; + Console.WriteLine("Usage: WinCertInstaller [options]"); + Console.WriteLine("Options:"); + Console.WriteLine(" --iti Install ITI certificates"); + Console.WriteLine(" --mpf Install MPF certificates"); + Console.WriteLine(" --all Install both ITI and MPF certificates (default)"); + Console.WriteLine(" --dry-run Simulate installation without writing to the store"); + Console.WriteLine(" -q Quiet mode (suppress exit prompt)"); + Console.WriteLine(" -h,--help Show this help message"); + Console.WriteLine("Example: WinCertInstaller --iti --dry-run"); } - static X509Certificate2Collection GetZIPCertificates(String url) + public static (CertSource source, bool dryRun, bool quiet, bool showHelp) ParseArguments(string[] args) { - X509Certificate2Collection certCollection = new X509Certificate2Collection(); - Console.WriteLine("Getting certificates from {0} please wait.", url); - Stream stream = DownloadFile(url); - - if (stream != null) { - ZipArchive archive = new ZipArchive(stream); + bool quiet = false; + bool dryRun = false; + CertSource selectedSources = CertSource.None; + bool showHelp = false; - foreach (ZipArchiveEntry certificate in archive.Entries) + if (args.Length == 0) + { + selectedSources = CertSource.All; + } + else + { + foreach (string arg in args) { - Stream certStrean = certificate.Open(); - MemoryStream ms = new MemoryStream(); - X509Certificate2 cert = new X509Certificate2(); - certStrean.CopyTo(ms); - cert.Import(ms.ToArray()); - certCollection.Add(cert); + switch (arg.ToLowerInvariant()) + { + case "-q": + quiet = true; + break; + case "--dry-run": + dryRun = true; + break; + case "--iti": + selectedSources |= CertSource.ITI; + break; + case "--mpf": + selectedSources |= CertSource.MPF; + break; + case "--all": + selectedSources = CertSource.All; + break; + case "-h": + case "--help": + showHelp = true; + break; + default: + throw new ArgumentException($"Wrong parameter: {arg}"); + } } - Console.WriteLine("{0} certificates found.", certCollection.Count); } - return certCollection; - } - static X509Certificate2Collection GetP7BCertificates(String url) - { - X509Certificate2Collection certCollection = new X509Certificate2Collection(); - Console.WriteLine("Getting certificates from {0} please wait.", url); - Stream stream = DownloadFile(url); - if (stream != null) { - MemoryStream ms = new MemoryStream(); - stream.CopyTo(ms); - certCollection.Import(ms.ToArray()); - Console.WriteLine("{0} certificates found.", certCollection.Count); + if (selectedSources == CertSource.None && !showHelp) + { + throw new ArgumentException("No certificate source selected."); } - return certCollection; + + return (selectedSources, dryRun, quiet, showHelp); } - static void Add(X509Certificate2Collection certificates, StoreName storeName, StoreLocation location) + static void DisposeCertificates(X509Certificate2Collection collection) { - X509Store store = new X509Store(storeName, location); - store.Open(OpenFlags.MaxAllowed); - Console.WriteLine("Installing certificates."); - store.AddRange(certificates); - Console.WriteLine("Added {0} certificates to {1}.", certificates.Count, storeName); - store.Close(); + if (collection == null) return; + foreach (var cert in collection) + { + cert.Dispose(); + } } - static void InstallCertificates(X509Certificate2Collection certificates) { - X509Certificate2Collection CACertificates = new X509Certificate2Collection(); - X509Certificate2Collection CAIntermediateCertificates = new X509Certificate2Collection(); - - foreach (X509Certificate2 cert in certificates) + static async Task Main(string[] args) + { + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (s, e) => { - bool isCA = IsCertificateAuthority(cert); - bool isSelfSigned = IsSelfSigned(cert); - if (isCA) - { - if (isSelfSigned) - { - CACertificates.Add(cert); - } - else - { - CAIntermediateCertificates.Add(cert); - } - } - else - { - Console.WriteLine("{0} is not a CA. Ignoring.", cert.Subject); - } - } + Console.WriteLine("\nCanceling..."); + e.Cancel = true; + cts.Cancel(); + }; try { - if (CACertificates.Count > 0) - { - Add(CACertificates, StoreName.Root, StoreLocation.LocalMachine); - } else - { - Console.WriteLine("No CA certificates to import."); - } + var (selectedSources, dryRun, quiet, showHelp) = ParseArguments(args); - if (CAIntermediateCertificates.Count > 0) + if (showHelp) { - Add(CAIntermediateCertificates, StoreName.CertificateAuthority, StoreLocation.LocalMachine); + PrintUsage(); + return 0; } - else + + if (dryRun) { - Console.WriteLine("No Intermediate CA certificates to import."); + Console.WriteLine("Dry run enabled: No changes will be made to the certificate store."); } - } - catch (System.Security.Cryptography.CryptographicException ex) - { - Console.WriteLine("Error: {0}", ex.Message); - System.Environment.Exit(-1); - } - } - - public static bool IsCertificateAuthority(X509Certificate2 certificate) - { - foreach (X509BasicConstraintsExtension basic_constraints in certificate.Extensions.OfType()) - { - if (basic_constraints.CertificateAuthority) + var host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(options => options.FormatterName = "CleanLayout") + .AddConsoleFormatter(); + }) + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection(AppSettings.Position)); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + }) + .Build(); + + var downloader = host.Services.GetRequiredService(); + var validator = host.Services.GetRequiredService(); + var installer = host.Services.GetRequiredService(); + var appSettings = host.Services.GetRequiredService>().Value; + var logger = host.Services.GetRequiredService>(); + + if (selectedSources.HasFlag(CertSource.ITI)) { - return true; + logger.LogInformation("====================== ITI ======================"); + X509Certificate2Collection itiCertificates = await downloader.GetZIPCertificatesAsync(appSettings.ITICertUrl, cts.Token); + if (itiCertificates.Count > 0) + { + installer.InstallCertificates(itiCertificates, dryRun); + DisposeCertificates(itiCertificates); + } } - } - return false; - } - - public static bool IsSelfSigned(X509Certificate2 certificate) - { - if (certificate.Issuer == certificate.Subject) - { - return true; - } - return false; - } - static void Main(string[] args) - { - bool quiet = false; - if(args.Length > 0) - { - if (args[0] == "-q") - { - quiet = true; - } else + if (selectedSources.HasFlag(CertSource.MPF)) { - Console.WriteLine("Wrong parameter. Aborting."); - Console.WriteLine("Valid parameters are only: -q"); - System.Environment.Exit(-1); + logger.LogInformation("====================== MPF ======================"); + X509Certificate2Collection mpfCertificates = await downloader.GetP7BCertificatesAsync(appSettings.MPFCertUrl, cts.Token); + if (mpfCertificates.Count > 0) + { + installer.InstallCertificates(mpfCertificates, dryRun); + DisposeCertificates(mpfCertificates); + } } - } - Console.WriteLine("====================== ITI ======================"); - X509Certificate2Collection ITIcertificates = GetZIPCertificates("http://acraiz.icpbrasil.gov.br/credenciadas/CertificadosAC-ICP-Brasil/ACcompactado.zip"); - if (ITIcertificates.Count > 0) { - InstallCertificates(ITIcertificates); - } + logger.LogInformation("================================================="); + logger.LogInformation("Installation process completed."); - Console.WriteLine("====================== MPF ======================"); - X509Certificate2Collection MPFCertificates = GetP7BCertificates("http://repositorio.acinterna.mpf.mp.br/ejbca/ra/downloads/ACIMPF-cadeia-completa.p7b"); + WaitForKeyPress(quiet); - if (MPFCertificates.Count > 0) + return 0; + } + catch (ArgumentException ex) + { + Console.WriteLine("Error: {0}", ex.Message); + PrintUsage(); + WaitForKeyPress(quiet: false); + return -1; + } + catch (Exception ex) { - InstallCertificates(MPFCertificates); + Console.WriteLine("Unexpected error: {0}", ex.Message); + WaitForKeyPress(quiet: false); + return -1; } - - Console.WriteLine("================================================="); - Console.WriteLine("Finished!"); + } + static void WaitForKeyPress(bool quiet) + { if (!quiet) { - Console.WriteLine("To run in quiet mode use -q parameters."); - Console.WriteLine("Press any key to exit."); + Console.WriteLine(); + Console.WriteLine("Use the -q parameter to run without this prompt."); + Console.WriteLine("Press any key to exit..."); Console.ReadKey(true); } - } } } diff --git a/WinCertInstaller/Properties/AssemblyInfo.cs b/WinCertInstaller/Properties/AssemblyInfo.cs deleted file mode 100644 index f45b945..0000000 --- a/WinCertInstaller/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - - -[assembly: AssemblyTitle("WinCertInstaller")] -[assembly: AssemblyDescription("Install ITI and MPF Certificates on Windows Trusted Certificate Store")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("WinCertInstaller")] -[assembly: AssemblyCopyright("Copyright © 2018")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - - -[assembly: ComVisible(false)] -[assembly: Guid("c22fa746-778e-4806-bd82-f4e0fce53763")] -[assembly: AssemblyVersion("1.1.0.0")] -[assembly: AssemblyFileVersion("1.1.0.0")] diff --git a/WinCertInstaller/Properties/Settings.Designer.cs b/WinCertInstaller/Properties/Settings.Designer.cs deleted file mode 100644 index b7bc3f6..0000000 --- a/WinCertInstaller/Properties/Settings.Designer.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// O código foi gerado por uma ferramenta. -// Versão de Tempo de Execução:4.0.30319.42000 -// -// As alterações ao arquivo poderão causar comportamento incorreto e serão perdidas se -// o código for gerado novamente. -// -//------------------------------------------------------------------------------ - -namespace WinCertInstaller.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "15.7.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - } -} diff --git a/WinCertInstaller/Properties/Settings.settings b/WinCertInstaller/Properties/Settings.settings deleted file mode 100644 index 049245f..0000000 --- a/WinCertInstaller/Properties/Settings.settings +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/WinCertInstaller/Services/CertificateDownloader.cs b/WinCertInstaller/Services/CertificateDownloader.cs new file mode 100644 index 0000000..dc0fe85 --- /dev/null +++ b/WinCertInstaller/Services/CertificateDownloader.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Security.Cryptography.Pkcs; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +namespace WinCertInstaller.Services +{ + public class CertificateDownloader : ICertificateDownloader + { + private readonly ILogger _logger; + private static readonly HttpClient HttpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + + public CertificateDownloader(ILogger logger) + { + _logger = logger; + } + + private async Task DownloadFileAsync(string url, CancellationToken cancellationToken = default, int maxAttempts = 3, TimeSpan? delayBetweenAttempts = null) + { + delayBetweenAttempts ??= TimeSpan.FromSeconds(2); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + using var response = await HttpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + var memoryStream = new MemoryStream(); + await response.Content.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + return memoryStream; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Download canceled."); + return null; + } + catch (Exception ex) when (attempt < maxAttempts) + { + if (cancellationToken.IsCancellationRequested) return null; + _logger.LogWarning(ex, "Attempt {Attempt} failed for {Url}: {Message}", attempt, url, ex.Message); + await Task.Delay(delayBetweenAttempts.Value, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to download certificates from {Url} after {Attempt} attempts. Error: {Message}", url, attempt, ex.Message); + } + } + + return null; + } + + public async Task GetZIPCertificatesAsync(string url, CancellationToken cancellationToken = default) + { + X509Certificate2Collection certCollection = new X509Certificate2Collection(); + _logger.LogInformation("Downloading certificates from {Url}. Please wait...", url); + + using MemoryStream? stream = await DownloadFileAsync(url, cancellationToken); + + if (stream != null) + { + using var archive = new ZipArchive(stream); + + foreach (ZipArchiveEntry certificate in archive.Entries) + { + if (certificate.Length == 0 || !(certificate.FullName.EndsWith(".cer", StringComparison.OrdinalIgnoreCase) || certificate.FullName.EndsWith(".crt", StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + try + { + using Stream certStream = certificate.Open(); + using MemoryStream ms = new MemoryStream(); + certStream.CopyTo(ms); + + var cert = X509CertificateLoader.LoadCertificate(ms.ToArray()); + certCollection.Add(cert); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load certificate from zip entry {FullName}: {Message}", certificate.FullName, ex.Message); + } + } + _logger.LogInformation("Found {Count} certificate(s) in ZIP archive.", certCollection.Count); + } + return certCollection; + } + + public async Task GetP7BCertificatesAsync(string url, CancellationToken cancellationToken = default) + { + X509Certificate2Collection certCollection = new X509Certificate2Collection(); + _logger.LogInformation("Downloading certificates from {Url}. Please wait...", url); + + using MemoryStream? stream = await DownloadFileAsync(url, cancellationToken); + + if (stream != null) + { + byte[] rawBytes = stream.ToArray(); + string text = System.Text.Encoding.UTF8.GetString(rawBytes); + + // Check if the PKCS#7 is PEM encoded and decode it to raw DER format + if (text.Contains("-----BEGIN PKCS7-----")) + { + string base64 = text.Replace("-----BEGIN PKCS7-----", "") + .Replace("-----END PKCS7-----", "") + .Replace("\r", "") + .Replace("\n", "") + .Trim(); + rawBytes = Convert.FromBase64String(base64); + } + + var signedCms = new SignedCms(); + signedCms.Decode(rawBytes); + + foreach (var cert in signedCms.Certificates) + { + certCollection.Add(cert); + } + + _logger.LogInformation("Extracted {Count} certificate(s) from P7B/PKCS7 payload.", certCollection.Count); + } + return certCollection; + } + } +} diff --git a/WinCertInstaller/Services/CertificateInstaller.cs b/WinCertInstaller/Services/CertificateInstaller.cs new file mode 100644 index 0000000..b037b16 --- /dev/null +++ b/WinCertInstaller/Services/CertificateInstaller.cs @@ -0,0 +1,128 @@ +using System; +using System.Security.Cryptography.X509Certificates; + +using Microsoft.Extensions.Logging; + +namespace WinCertInstaller.Services +{ + public class CertificateInstaller : ICertificateInstaller + { + private readonly ICertificateValidator _validator; + private readonly ILogger _logger; + + public CertificateInstaller(ICertificateValidator validator, ILogger logger) + { + _validator = validator; + _logger = logger; + } + + private bool IsCertificateInStore(X509Certificate2 certificate, X509Store store) + { + foreach (var existingCert in store.Certificates) + { + if (string.Equals(existingCert.Thumbprint, certificate.Thumbprint, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + private void Add(X509Certificate2Collection certificates, StoreName storeName, StoreLocation location, bool dryRun) + { + using var store = new X509Store(storeName, location); + store.Open(dryRun ? OpenFlags.ReadOnly : OpenFlags.ReadWrite); + + _logger.LogInformation("Installing certificates into the {StoreName} store...", storeName); + + int added = 0; + int skipped = 0; + int invalid = 0; + + foreach (X509Certificate2 certificate in certificates) + { + if (!_validator.IsCertificateValidForInstall(certificate)) + { + invalid++; + continue; + } + + if (IsCertificateInStore(certificate, store)) + { + skipped++; + continue; + } + + if (dryRun) + { + _logger.LogInformation("Dry-run: Would add {Subject} to {StoreName}", certificate.Subject, storeName); + added++; + continue; + } + + store.Add(certificate); + _logger.LogInformation("Added: {Subject} to {StoreName}", certificate.Subject, storeName); + added++; + } + + _logger.LogInformation("Successfully processed {Added} certificate(s) for {StoreName}.", added, storeName); + if (skipped > 0) _logger.LogInformation("Skipped {Skipped} certificate(s) already present in the store.", skipped); + if (invalid > 0) _logger.LogWarning("Ignored {Invalid} invalid or ineligible certificate(s).", invalid); + + store.Close(); + } + + public void InstallCertificates(X509Certificate2Collection certificates, bool dryRun) + { + X509Certificate2Collection caCertificates = new X509Certificate2Collection(); + X509Certificate2Collection caIntermediateCertificates = new X509Certificate2Collection(); + + foreach (X509Certificate2 cert in certificates) + { + bool isCA = _validator.IsCertificateAuthority(cert); + bool isSelfSigned = _validator.IsSelfSigned(cert); + if (isCA) + { + if (isSelfSigned) + { + caCertificates.Add(cert); + } + else + { + caIntermediateCertificates.Add(cert); + } + } + else + { + // Ignora certificados que não são identificados como Autoridade Certificadora + } + } + + try + { + if (caCertificates.Count > 0) + { + Add(caCertificates, StoreName.Root, StoreLocation.LocalMachine, dryRun); + } + else + { + _logger.LogInformation("No CA certificates to import."); + } + + if (caIntermediateCertificates.Count > 0) + { + Add(caIntermediateCertificates, StoreName.CertificateAuthority, StoreLocation.LocalMachine, dryRun); + } + else + { + _logger.LogInformation("No Intermediate CA certificates to import."); + } + } + catch (System.Security.Cryptography.CryptographicException ex) + { + _logger.LogError(ex, "Error accessing Windows certificate stores: {Message}", ex.Message); + _logger.LogWarning("HINT: Ensure you are running this application as 'Administrator' to manage LocalMachine certificates."); + } + } + } +} diff --git a/WinCertInstaller/Services/CertificateValidator.cs b/WinCertInstaller/Services/CertificateValidator.cs new file mode 100644 index 0000000..aade6c1 --- /dev/null +++ b/WinCertInstaller/Services/CertificateValidator.cs @@ -0,0 +1,43 @@ +using System; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; + +namespace WinCertInstaller.Services +{ + public class CertificateValidator : ICertificateValidator + { + private readonly ILogger _logger; + + public CertificateValidator(ILogger logger) + { + _logger = logger; + } + public bool IsCertificateValidForInstall(X509Certificate2 certificate) + { + if (certificate.NotBefore > DateTime.UtcNow) + { + _logger.LogWarning("Certificate {Subject} not active yet. NotBefore={NotBefore}", certificate.Subject, certificate.NotBefore); + return false; + } + + if (certificate.NotAfter < DateTime.UtcNow) + { + _logger.LogWarning("Certificate {Subject} expired. NotAfter={NotAfter}", certificate.Subject, certificate.NotAfter); + return false; + } + + return true; + } + + public bool IsCertificateAuthority(X509Certificate2 certificate) + { + return certificate.Extensions.OfType().Any(ext => ext.CertificateAuthority); + } + + public bool IsSelfSigned(X509Certificate2 certificate) + { + return certificate.SubjectName.RawData.SequenceEqual(certificate.IssuerName.RawData); + } + } +} diff --git a/WinCertInstaller/Services/ICertificateDownloader.cs b/WinCertInstaller/Services/ICertificateDownloader.cs new file mode 100644 index 0000000..7633a7f --- /dev/null +++ b/WinCertInstaller/Services/ICertificateDownloader.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; + +namespace WinCertInstaller.Services +{ + /// + /// Service responsible for downloading and extracting certificates from remote sources. + /// + public interface ICertificateDownloader + { + /// + /// Downloads a ZIP archive and extracts all its certificates (.cer or .crt). + /// + /// The URL of the ZIP file. + /// Token to cancel the download operation. + /// A collection of extracted certificates. + Task GetZIPCertificatesAsync(string url, CancellationToken cancellationToken = default); + + /// + /// Downloads a PKCS #7 (.p7b) file and extracts its certificates. + /// + /// The URL of the P7B file. + /// Token to cancel the download operation. + /// A collection of extracted certificates. + Task GetP7BCertificatesAsync(string url, CancellationToken cancellationToken = default); + } +} diff --git a/WinCertInstaller/Services/ICertificateInstaller.cs b/WinCertInstaller/Services/ICertificateInstaller.cs new file mode 100644 index 0000000..7c73261 --- /dev/null +++ b/WinCertInstaller/Services/ICertificateInstaller.cs @@ -0,0 +1,18 @@ +using System.Security.Cryptography.X509Certificates; + +namespace WinCertInstaller.Services +{ + /// + /// Service that handles writing certificates into the Windows Local Machine X509 Store. + /// + public interface ICertificateInstaller + { + /// + /// Iterates through a collection and installs certificates into their respective Windows Store + /// (Root for self-signed CAs or CertificateAuthority for Intermediate CAs). + /// + /// The collection of certificates to evaluate and install. + /// If true, validates and logs actions without making actual changes to the machine store. + void InstallCertificates(X509Certificate2Collection certificates, bool dryRun); + } +} diff --git a/WinCertInstaller/Services/ICertificateValidator.cs b/WinCertInstaller/Services/ICertificateValidator.cs new file mode 100644 index 0000000..721c2df --- /dev/null +++ b/WinCertInstaller/Services/ICertificateValidator.cs @@ -0,0 +1,25 @@ +using System.Security.Cryptography.X509Certificates; + +namespace WinCertInstaller.Services +{ + /// + /// Validates X509 certificates ensuring they are valid for installation and determines their roles in a PKI. + /// + public interface ICertificateValidator + { + /// + /// Checks if a certificate is currently active and has not expired based on its NotBefore/NotAfter dates. + /// + bool IsCertificateValidForInstall(X509Certificate2 certificate); + + /// + /// Verifies whether the provided certificate is a Certificate Authority (CA) via its Basic Constraints extension. + /// + bool IsCertificateAuthority(X509Certificate2 certificate); + + /// + /// Scans if the certificate's Subject matches its Issuer, indicating it is a Self-Signed Root Certificate. + /// + bool IsSelfSigned(X509Certificate2 certificate); + } +} diff --git a/WinCertInstaller/WinCertInstaller.csproj b/WinCertInstaller/WinCertInstaller.csproj index 6af74b9..7f9a51f 100644 --- a/WinCertInstaller/WinCertInstaller.csproj +++ b/WinCertInstaller/WinCertInstaller.csproj @@ -1,96 +1,24 @@ - - - + - Debug - AnyCPU - {C22FA746-778E-4806-BD82-F4E0FCE53763} Exe + net10.0-windows + false + false + enable + enable WinCertInstaller - WinCertInstaller - v4.5 - 512 - - false - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - true - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - false - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - false - - - WinCertInstaller.Program - - - LocalIntranet - - - false - - - Properties\WinCertInstaller.manifest + false + app.manifest - - - - - + + + - - - - True - True - Settings.settings - - - - - - - SettingsSingleFileGenerator - Settings.Designer.cs + + + PreserveNewest - - - - - False - .NET Framework 3.5 SP1 - false - - \ No newline at end of file diff --git a/WinCertInstaller/app.config b/WinCertInstaller/app.config deleted file mode 100644 index 51278a4..0000000 --- a/WinCertInstaller/app.config +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/WinCertInstaller/Properties/WinCertInstaller.manifest b/WinCertInstaller/app.manifest similarity index 56% rename from WinCertInstaller/Properties/WinCertInstaller.manifest rename to WinCertInstaller/app.manifest index 7d36b29..94dc0ec 100644 --- a/WinCertInstaller/Properties/WinCertInstaller.manifest +++ b/WinCertInstaller/app.manifest @@ -1,15 +1,17 @@ - + - + - - - - - \ No newline at end of file + + + + + + + diff --git a/WinCertInstaller/appsettings.json b/WinCertInstaller/appsettings.json new file mode 100644 index 0000000..522d697 --- /dev/null +++ b/WinCertInstaller/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "CertificateSources": { + "ITICertUrl": "http://acraiz.icpbrasil.gov.br/credenciadas/CertificadosAC-ICP-Brasil/ACcompactado.zip", + "MPFCertUrl": "http://repositorio.acinterna.mpf.mp.br/ejbca/ra/downloads/ACIMPF-cadeia-completa.p7b" + } +}