diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs new file mode 100644 index 00000000..b0bf867b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class MtlsHttpClientFactoryTests + { + private sealed class FakeCertificateResolver : ICertificateResolver + { + private readonly X509Certificate2? _cert; + public int CallCount { get; private set; } + + public FakeCertificateResolver(X509Certificate2? cert) => _cert = cert; + + public X509Certificate2? FindClientCertificate(AgentLogger logger) + { + CallCount++; + return _cert; + } + } + + /// + /// Creates an in-memory self-signed cert so the factory has something real to attach + /// without touching the Windows cert store. is + /// available on net48 (4.7.2+). + /// + private static X509Certificate2 CreateSelfSignedCert() + { + using (var rsa = RSA.Create(2048)) + { + var req = new CertificateRequest( + "CN=apmon-v2-test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(1)); + } + } + + [Fact] + public void CreateHandler_attaches_cert_and_enables_decompression() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + var resolver = new FakeCertificateResolver(cert); + + using var handler = MtlsHttpClientFactory.CreateHandler(resolver, logger); + + Assert.Equal(1, resolver.CallCount); + Assert.Equal( + DecompressionMethods.GZip | DecompressionMethods.Deflate, + handler.AutomaticDecompression); + Assert.Single(handler.ClientCertificates); + Assert.Equal(cert.Thumbprint, ((X509Certificate2)handler.ClientCertificates[0]).Thumbprint); + } + + [Fact] + public void Create_returns_client_with_default_timeout() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + var resolver = new FakeCertificateResolver(cert); + + using var client = MtlsHttpClientFactory.Create(resolver, logger); + + Assert.NotNull(client); + Assert.Equal(MtlsHttpClientFactory.DefaultTimeout, client.Timeout); + } + + [Fact] + public void Create_respects_custom_timeout() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + var resolver = new FakeCertificateResolver(cert); + + using var client = MtlsHttpClientFactory.Create(resolver, logger, TimeSpan.FromSeconds(5)); + + Assert.Equal(TimeSpan.FromSeconds(5), client.Timeout); + } + + [Fact] + public void CreateHandler_throws_when_resolver_returns_null() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + var resolver = new FakeCertificateResolver(null); + + var ex = Assert.Throws( + () => MtlsHttpClientFactory.CreateHandler(resolver, logger)); + + Assert.Contains("no MDM client certificate found", ex.Message); + Assert.Equal(1, resolver.CallCount); + } + + [Fact] + public void Create_rejects_null_dependencies() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + + Assert.Throws( + () => MtlsHttpClientFactory.Create(null!, logger)); + + Assert.Throws( + () => MtlsHttpClientFactory.Create(new FakeCertificateResolver(cert), null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/MtlsHttpClientFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/MtlsHttpClientFactory.cs new file mode 100644 index 00000000..504bdbd7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/MtlsHttpClientFactory.cs @@ -0,0 +1,87 @@ +#nullable enable +using System; +using System.Net; +using System.Net.Http; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Baut einen mit mTLS-Client-Cert für den + /// . Plan §4.x M4.4.5.d. + /// + /// Design: Static Factory + -Seam statt direkte + /// Abhängigkeit an . Tests injizieren einen + /// Fake-Resolver mit einem self-signed Cert; Prod nutzt + /// , der den Windows-Cert-Store durchsucht. + /// + /// + /// Konfiguration (analog Legacy BackendApiClient): + /// + /// + + /// Default-Timeout 30s (override via Parameter) + /// Keine CookieContainer, keine Default-Credentials + /// + /// + /// + public static class MtlsHttpClientFactory + { + /// Plan §4.x M4.4.5.d — Default-Timeout (analog Legacy). + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + /// + /// Baut einen mit attached Client-Cert. Wirft + /// wenn kein + /// Zertifikat liefert — der Agent darf ohne Cert nicht starten (mTLS ist Pflicht). + /// + /// Separat exponiert, damit Tests direkt auf dem Handler asserten können + /// ('s _handler-Feld ist internal und in net48 + /// reflection-unzuverlässig). + /// + /// + public static HttpClientHandler CreateHandler( + ICertificateResolver resolver, + AgentLogger logger) + { + if (resolver == null) throw new ArgumentNullException(nameof(resolver)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + var cert = resolver.FindClientCertificate(logger); + if (cert == null) + { + throw new InvalidOperationException( + "MtlsHttpClientFactory: no MDM client certificate found. " + + "Agent cannot start without a client cert (mTLS is mandatory). " + + "Check cert enrollment status in HKLM\\SOFTWARE\\Microsoft\\Enrollments."); + } + + var handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; + handler.ClientCertificates.Add(cert); + + logger.Info( + $"MtlsHttpClientFactory: handler ready (cert thumbprint={cert.Thumbprint})."); + + return handler; + } + + /// + /// Baut einen fertig konfigurierten — Handler via + /// , Timeout via Parameter (default ). + /// + public static HttpClient Create( + ICertificateResolver resolver, + AgentLogger logger, + TimeSpan? timeout = null) + { + var handler = CreateHandler(resolver, logger); + return new HttpClient(handler) + { + Timeout = timeout ?? DefaultTimeout, + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/ICertificateResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/ICertificateResolver.cs new file mode 100644 index 00000000..46f7b981 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/ICertificateResolver.cs @@ -0,0 +1,38 @@ +#nullable enable +using System.Security.Cryptography.X509Certificates; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Abstraktion über , damit Tests die + /// Zert-Auflösung ohne echten Windows-Cert-Store ersetzen können. Plan §4.x M4.4.5.d. + /// + public interface ICertificateResolver + { + /// + /// Sucht das MDM-Client-Zertifikat. null wenn nichts gefunden — der Caller + /// (typischerweise ) entscheidet dann + /// über Fehlerbehandlung (wirft in Prod, Test-Resolver liefert null für die negative + /// Test-Variante). + /// + X509Certificate2? FindClientCertificate(AgentLogger logger); + } + + /// + /// Produktiver . Delegiert an + /// . + /// + public sealed class DefaultCertificateResolver : ICertificateResolver + { + private readonly string? _thumbprintOverride; + + public DefaultCertificateResolver(string? thumbprintOverride = null) + { + _thumbprintOverride = thumbprintOverride; + } + + public X509Certificate2? FindClientCertificate(AgentLogger logger) => + CertificateHelper.FindMdmCertificate(_thumbprintOverride, logger); + } +}