From c07bcddf3bacf4e84f1ae3f281932a7273eb2d90 Mon Sep 17 00:00:00 2001 From: Ben Olden-Cooligan Date: Wed, 27 Mar 2024 19:36:46 -0700 Subject: [PATCH] Escl: HTTPS support, security policies, and HTTPS->HTTP fallback #338 --- NAPS2.Escl.Server/EsclApiController.cs | 16 ++- NAPS2.Escl.Server/EsclServer.cs | 45 ++++-- NAPS2.Escl.Server/MdnsAdvertiser.cs | 130 +++++++++++++++--- NAPS2.Escl.Tests/ClientServerTests.cs | 9 ++ NAPS2.Escl.Usb/EsclUsbContext.cs | 1 + NAPS2.Escl/Client/EsclClient.cs | 121 ++++++++++++---- NAPS2.Escl/Client/EsclService.cs | 7 +- NAPS2.Escl/Client/EsclServiceLocator.cs | 34 +++-- NAPS2.Escl/EsclSecurityPolicy.cs | 24 ++++ .../EsclSecurityPolicyViolationException.cs | 3 + NAPS2.Escl/Server/EsclDeviceConfig.cs | 2 + NAPS2.Escl/Server/IEsclServer.cs | 3 + NAPS2.Lib/Config/CommonConfig.cs | 8 +- NAPS2.Lib/Config/ConfigSerializer.cs | 2 + NAPS2.Lib/Config/ObsoleteTypes/AppConfigV0.cs | 5 + NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs | 19 ++- NAPS2.Lib/Remoting/Server/SharedDevice.cs | 1 + .../Remoting/Server/SharedDeviceManager.cs | 30 +++- NAPS2.Lib/Scan/ScanPerformer.cs | 4 + NAPS2.Sdk.Tests/BinaryResources.Designer.cs | 10 ++ NAPS2.Sdk.Tests/BinaryResources.resx | 3 + .../Remoting/FallbackScanServerTests.cs | 48 +++++++ ...IntegrationTests.cs => ScanServerTests.cs} | 56 +++----- .../Remoting/ScanServerTestsBase.cs | 53 +++++++ .../Remoting/TlsScanServerTests.cs | 55 ++++++++ NAPS2.Sdk.Tests/Resources/testcert.crt | 19 +++ NAPS2.Sdk.Tests/Resources/testcert.csr | 16 +++ NAPS2.Sdk.Tests/Resources/testcert.key | 28 ++++ NAPS2.Sdk.Tests/Resources/testcert.pfx | Bin 0 -> 2531 bytes NAPS2.Sdk/Remoting/Server/ScanServer.cs | 29 +++- NAPS2.Sdk/Remoting/Server/ScanServerDevice.cs | 1 + NAPS2.Sdk/Scan/EsclOptions.cs | 9 +- .../Scan/Internal/Escl/EsclScanDriver.cs | 2 + NAPS2.Setup/appsettings.xml | 2 + 34 files changed, 678 insertions(+), 117 deletions(-) create mode 100644 NAPS2.Escl/EsclSecurityPolicy.cs create mode 100644 NAPS2.Escl/EsclSecurityPolicyViolationException.cs create mode 100644 NAPS2.Sdk.Tests/Remoting/FallbackScanServerTests.cs rename NAPS2.Sdk.Tests/Remoting/{ScanServerIntegrationTests.cs => ScanServerTests.cs} (79%) create mode 100644 NAPS2.Sdk.Tests/Remoting/ScanServerTestsBase.cs create mode 100644 NAPS2.Sdk.Tests/Remoting/TlsScanServerTests.cs create mode 100644 NAPS2.Sdk.Tests/Resources/testcert.crt create mode 100644 NAPS2.Sdk.Tests/Resources/testcert.csr create mode 100644 NAPS2.Sdk.Tests/Resources/testcert.key create mode 100644 NAPS2.Sdk.Tests/Resources/testcert.pfx diff --git a/NAPS2.Escl.Server/EsclApiController.cs b/NAPS2.Escl.Server/EsclApiController.cs index a6bf35365..aba9ed2df 100644 --- a/NAPS2.Escl.Server/EsclApiController.cs +++ b/NAPS2.Escl.Server/EsclApiController.cs @@ -14,12 +14,15 @@ internal class EsclApiController : WebApiController private readonly EsclDeviceConfig _deviceConfig; private readonly EsclServerState _serverState; + private readonly EsclSecurityPolicy _securityPolicy; private readonly ILogger _logger; - internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState, ILogger logger) + internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState, + EsclSecurityPolicy securityPolicy, ILogger logger) { _deviceConfig = deviceConfig; _serverState = serverState; + _securityPolicy = securityPolicy; _logger = logger; } @@ -27,7 +30,8 @@ internal class EsclApiController : WebApiController public async Task GetScannerCapabilities() { var caps = _deviceConfig.Capabilities; - var iconUri = caps.IconPng != null ? $"http://naps2-{caps.Uuid}.local.:{_deviceConfig.Port}/eSCL/icon.png" : ""; + var protocol = _securityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) ? "https" : "http"; + var iconUri = caps.IconPng != null ? $"{protocol}://naps2-{caps.Uuid}.local.:{_deviceConfig.Port}/eSCL/icon.png" : ""; var doc = EsclXmlHelper.CreateDocAsString( new XElement(ScanNs + "ScannerCapabilities", @@ -169,7 +173,13 @@ internal class EsclApiController : WebApiController _serverState.IsProcessing = true; var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings)); _serverState.AddJob(jobInfo); - Response.Headers.Add("Location", $"{Request.Url}/{jobInfo.Id}"); + var uri = Request.Url; + if (Request.IsSecureConnection) + { + // Fix https://github.com/unosquare/embedio/issues/593 + uri = new UriBuilder(uri) { Scheme = "https" }.Uri; + } + Response.Headers.Add("Location", $"{uri}/{jobInfo.Id}"); Response.StatusCode = 201; // Created } diff --git a/NAPS2.Escl.Server/EsclServer.cs b/NAPS2.Escl.Server/EsclServer.cs index 6e545881f..e8022b8d5 100644 --- a/NAPS2.Escl.Server/EsclServer.cs +++ b/NAPS2.Escl.Server/EsclServer.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography.X509Certificates; using EmbedIO; using EmbedIO.WebApi; using Microsoft.Extensions.Logging; @@ -11,11 +12,15 @@ public class EsclServer : IEsclServer { Swan.Logging.Logger.NoLogging(); } - + private readonly Dictionary _devices = new(); private bool _started; private CancellationTokenSource? _cts; + public EsclSecurityPolicy SecurityPolicy { get; set; } + + public X509Certificate2? Certificate { get; set; } + public ILogger Logger { get; set; } = NullLogger.Instance; public void AddDevice(EsclDeviceConfig deviceConfig) @@ -45,6 +50,11 @@ public class EsclServer : IEsclServer { return Task.CompletedTask; } + if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) && Certificate == null) + { + throw new EsclSecurityPolicyViolationException( + $"EsclSecurityPolicy of {SecurityPolicy} needs a certificate to be specified"); + } _started = true; _cts = new CancellationTokenSource(); @@ -63,24 +73,39 @@ public class EsclServer : IEsclServer var cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, deviceCtx.Cts.Token).Token; // Try to run the server with the port specified in the EsclDeviceConfig first. If that fails, try random ports // instead, and store the actually-used port back in EsclDeviceConfig so it can be advertised correctly. - await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.Port, async port => + bool hasHttp = !SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps); + bool hasHttps = Certificate != null; + if (hasHttp) { - await StartServer(deviceCtx, port, cancelToken); - deviceCtx.Config.Port = port; - }, cancelToken); - deviceCtx.Advertiser.AdvertiseDevice(deviceCtx.Config); + await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.Port, async port => + { + await StartServer(deviceCtx, port, false, cancelToken); + deviceCtx.Config.Port = port; + }, cancelToken); + } + if (hasHttps) + { + await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.TlsPort, async tlsPort => + { + await StartServer(deviceCtx, tlsPort, true, cancelToken); + deviceCtx.Config.TlsPort = tlsPort; + }, cancelToken); + } + deviceCtx.Advertiser.AdvertiseDevice(deviceCtx.Config, hasHttp, hasHttps); } - private async Task StartServer(DeviceContext deviceCtx, int port, CancellationToken cancelToken) + private async Task StartServer(DeviceContext deviceCtx, int port, bool tls, CancellationToken cancelToken) { - var url = $"http://+:{port}/"; + var protocol = tls ? "https" : "http"; + var url = $"{protocol}://+:{port}/"; deviceCtx.ServerState = new EsclServerState(); var server = new WebServer(o => o .WithMode(HttpListenerMode.EmbedIO) - .WithUrlPrefix(url)) + .WithUrlPrefix(url) + .WithCertificate((tls ? Certificate : null)!)) .HandleUnhandledException(UnhandledServerException) .WithWebApi("/eSCL", - m => m.WithController(() => new EsclApiController(deviceCtx.Config, deviceCtx.ServerState, Logger))); + m => m.WithController(() => new EsclApiController(deviceCtx.Config, deviceCtx.ServerState, SecurityPolicy, Logger))); await server.StartAsync(cancelToken); } diff --git a/NAPS2.Escl.Server/MdnsAdvertiser.cs b/NAPS2.Escl.Server/MdnsAdvertiser.cs index c49392344..3b7a762ce 100644 --- a/NAPS2.Escl.Server/MdnsAdvertiser.cs +++ b/NAPS2.Escl.Server/MdnsAdvertiser.cs @@ -1,4 +1,5 @@ using Makaretu.Dns; +using Makaretu.Dns.Resolving; namespace NAPS2.Escl.Server; @@ -6,50 +7,141 @@ public class MdnsAdvertiser : IDisposable { private readonly ServiceDiscovery _sd; private readonly Dictionary _serviceProfiles = new(); + private readonly Dictionary _serviceProfiles2 = new(); public MdnsAdvertiser() { _sd = new ServiceDiscovery(); } - public void AdvertiseDevice(EsclDeviceConfig deviceConfig) + public void AdvertiseDevice(EsclDeviceConfig deviceConfig, bool hasHttp, bool hasHttps) { var caps = deviceConfig.Capabilities; if (caps.Uuid == null) { throw new ArgumentException("UUID must be specified"); } + if (!hasHttp && !hasHttps) + { + return; + } var name = caps.MakeAndModel; - var service = new ServiceProfile(name, "_uscan._tcp", (ushort) deviceConfig.Port); + + // HTTP+HTTPS should be handled by responding with the relevant records for both _uscan and _uscans when either + // is queried. This isn't handled out-of-the-box by the MDNS library so we need to do some extra work. + var httpProfile = new ServiceProfile(name, "_uscan._tcp", (ushort) deviceConfig.Port); + var httpsProfile = new ServiceProfile(name, "_uscans._tcp", (ushort) deviceConfig.TlsPort); + // If only one of HTTP or HTTPS is enabled, then we use that as the service. If both are enabled, we use the + // HTTP service as a baseline and then hack in the HTTPS records later. + var service = hasHttp ? httpProfile : httpsProfile; + var domain = $"naps2-{caps.Uuid}"; - service.HostName = DomainName.Join(domain, service.Domain); - service.AddProperty("txtvers", "1"); - service.AddProperty("Vers", "2.0"); // TODO: verify + var hostName = DomainName.Join(domain, service.Domain); + + // Replace the default TXT record with the first TXT record (HTTP if used, HTTPS otherwise) + service.Resources.RemoveAll(x => x is TXTRecord); + service.Resources.Add(CreateTxtRecord(deviceConfig, hasHttp, service, caps, name)); + + // NSEC records are recommended by RFC6762 to annotate that there's no more info for this host + service.Resources.Add(new NSECRecord + { Name = hostName, NextOwnerName = hostName, Types = [DnsType.A, DnsType.AAAA] }); + + if (hasHttp && hasHttps) + { + // If both HTTP and HTTPS are enabled, we add the extra HTTPS records here + service.Resources.Add(new PTRRecord + { + Name = httpsProfile.QualifiedServiceName, + DomainName = httpsProfile.FullyQualifiedName + }); + service.Resources.Add(new SRVRecord + { + Name = httpsProfile.FullyQualifiedName, + Port = (ushort) deviceConfig.TlsPort + }); + service.Resources.Add(CreateTxtRecord(deviceConfig, false, httpsProfile, caps, name)); + } + + // The default HostName isn't correct, it should be "naps2-uuid.local" (the actual host) instead of + // "name._uscan.local" (the service name) + service.HostName = hostName; + + // Send the full set of HTTP/HTTPS records to anyone currently listening + _sd.Announce(service); + + // Set up to respond to _uscan/_uscans queries with our records. + _sd.Advertise(service); + if (hasHttp && hasHttps) + { + // Add _uscans to the available services (_uscan was already mapped in Advertise()) + _sd.NameServer.Catalog[ServiceDiscovery.ServiceName].Resources.Add(new PTRRecord + { Name = ServiceDiscovery.ServiceName, DomainName = httpsProfile.QualifiedServiceName }); + // Cross-reference _uscan to the HTTPS records + _sd.NameServer.Catalog[httpProfile.QualifiedServiceName].Resources.Add(new PTRRecord + { Name = httpsProfile.QualifiedServiceName, DomainName = httpsProfile.FullyQualifiedName }); + // Add a _uscans reference with both HTTP and HTTPS records + _sd.NameServer.Catalog[httpsProfile.QualifiedServiceName] = new Node + { + Name = httpsProfile.QualifiedServiceName, Authoritative = true, Resources = + { + new PTRRecord + { Name = httpProfile.QualifiedServiceName, DomainName = httpProfile.FullyQualifiedName }, + new PTRRecord + { Name = httpsProfile.QualifiedServiceName, DomainName = httpsProfile.FullyQualifiedName } + } + }; + } + + // Persist the profiles so they can be unadvertised later + _serviceProfiles.Add(caps.Uuid, service); + if (hasHttp && hasHttps) + { + _serviceProfiles2.Add(caps.Uuid, httpsProfile); + } + } + + private static TXTRecord CreateTxtRecord(EsclDeviceConfig deviceConfig, bool http, ServiceProfile service, + EsclCapabilities caps, string? name) + { + var record = new TXTRecord(); + record.Name = service.FullyQualifiedName; + record.Strings.Add("txtvers=1"); + record.Strings.Add("Vers=2.0"); // TODO: verify if (deviceConfig.Capabilities.IconPng != null) { - service.AddProperty("representation", $"http://naps2-{caps.Uuid}.local.:{deviceConfig.Port}/eSCL/icon.png"); + record.Strings.Add( + http + ? $"representation=http://naps2-{caps.Uuid}.local.:{deviceConfig.Port}/eSCL/icon.png" + : $"representation=https://naps2-{caps.Uuid}.local.:{deviceConfig.TlsPort}/eSCL/icon.png"); } - service.AddProperty("rs", "eSCL"); - service.AddProperty("ty", name); - service.AddProperty("pdl", "application/pdf,image/jpeg,image/png"); + record.Strings.Add("rs=eSCL"); + record.Strings.Add($"ty={name}"); + record.Strings.Add("pdl=application/pdf,image/jpeg,image/png"); // TODO: Actual adf/duplex, etc. - service.AddProperty("uuid", caps.Uuid); - service.AddProperty("cs", "color,grayscale,binary"); - service.AddProperty("is", "platen"); // and ,adf - service.AddProperty("duplex", "F"); - _sd.Announce(service); - _sd.Advertise(service); - _serviceProfiles.Add(caps.Uuid, service); + record.Strings.Add($"uuid={caps.Uuid}"); + record.Strings.Add("cs=color,grayscale,binary"); + record.Strings.Add("is=platen"); // and ,adf + record.Strings.Add("duplex=F"); + return record; } public void UnadvertiseDevice(EsclDeviceConfig deviceConfig) { - if (deviceConfig.Capabilities.Uuid == null) + var uuid = deviceConfig.Capabilities.Uuid; + if (uuid == null) { throw new ArgumentException("UUID must be specified"); } - _sd.Unadvertise(_serviceProfiles[deviceConfig.Capabilities.Uuid]); - _serviceProfiles.Remove(deviceConfig.Capabilities.Uuid); + if (_serviceProfiles.ContainsKey(uuid)) + { + _sd.Unadvertise(_serviceProfiles[uuid]); + _serviceProfiles.Remove(uuid); + } + if (_serviceProfiles2.ContainsKey(uuid)) + { + _sd.Unadvertise(_serviceProfiles2[uuid]); + _serviceProfiles2.Remove(uuid); + } } public void Dispose() diff --git a/NAPS2.Escl.Tests/ClientServerTests.cs b/NAPS2.Escl.Tests/ClientServerTests.cs index 02e04371e..d353264ec 100644 --- a/NAPS2.Escl.Tests/ClientServerTests.cs +++ b/NAPS2.Escl.Tests/ClientServerTests.cs @@ -34,6 +34,7 @@ public class ClientServerTests Host = $"[{IPAddress.IPv6Loopback}]", RemoteEndpoint = IPAddress.IPv6Loopback, Port = deviceConfig.Port, + TlsPort = deviceConfig.TlsPort, RootUrl = "eSCL", Tls = false, Uuid = uuid @@ -43,4 +44,12 @@ public class ClientServerTests Assert.Equal("HP Blah", caps.MakeAndModel); Assert.Equal("123abc", caps.SerialNumber); } + + [Fact] + public async Task StartTlsServerWithoutCertificate() + { + using var server = new EsclServer(); + server.SecurityPolicy = EsclSecurityPolicy.RequireHttps; + await Assert.ThrowsAsync(() => server.Start()); + } } \ No newline at end of file diff --git a/NAPS2.Escl.Usb/EsclUsbContext.cs b/NAPS2.Escl.Usb/EsclUsbContext.cs index e0bf30cc6..6067c1e0f 100644 --- a/NAPS2.Escl.Usb/EsclUsbContext.cs +++ b/NAPS2.Escl.Usb/EsclUsbContext.cs @@ -52,6 +52,7 @@ public class EsclUsbContext : IDisposable Host = IPAddress.Loopback.ToString(), RemoteEndpoint = IPAddress.Loopback, Port = port, + TlsPort = 0, RootUrl = "eSCL", Tls = false, Uuid = Guid.Empty.ToString("D") diff --git a/NAPS2.Escl/Client/EsclClient.cs b/NAPS2.Escl/Client/EsclClient.cs index 16a394560..6171f2d50 100644 --- a/NAPS2.Escl/Client/EsclClient.cs +++ b/NAPS2.Escl/Client/EsclClient.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Net; using System.Net.Http; +using System.Security.Authentication; using System.Text; using System.Xml.Linq; using Microsoft.Extensions.Logging; @@ -13,7 +14,13 @@ public class EsclClient private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs; private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs; - private static readonly HttpClientHandler HttpClientHandler = new() + // Clients that verify HTTPS certificates + private static readonly HttpClient VerifiedHttpClient = new(); + private static readonly HttpClient VerifiedProgressHttpClient = new(); + private static readonly HttpClient VerifiedDocumentHttpClient = new(); + + // Clients that don't verify HTTPS certificates + private static readonly HttpClientHandler UnverifiedHttpClientHandler = new() { // ESCL certificates are generally self-signed - we aren't trying to verify server authenticity, just ensure // that the connection is encrypted and protect against passive interception. @@ -21,21 +28,38 @@ public class EsclClient }; // Sadly as we're still using .NET Framework on Windows, we're stuck with the old HttpClient implementation, which // has trouble with concurrency. So we use a separate client for long running requests (Progress/NextDocument). - private static readonly HttpClient HttpClient = new(HttpClientHandler); - private static readonly HttpClient ProgressHttpClient = new(HttpClientHandler); - private static readonly HttpClient DocumentHttpClient = new(HttpClientHandler); + private static readonly HttpClient UnverifiedHttpClient = new(UnverifiedHttpClientHandler); + private static readonly HttpClient UnverifiedProgressHttpClient = new(UnverifiedHttpClientHandler); + private static readonly HttpClient UnverifiedDocumentHttpClient = new(UnverifiedHttpClientHandler); private readonly EsclService _service; + private bool _httpFallback; public EsclClient(EsclService service) { _service = service; } + public EsclSecurityPolicy SecurityPolicy { get; set; } + public ILogger Logger { get; set; } = NullLogger.Instance; public CancellationToken CancelToken { get; set; } + private HttpClient HttpClient => SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate) + ? VerifiedHttpClient + : UnverifiedHttpClient; + + private HttpClient ProgressHttpClient => + SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate) + ? VerifiedProgressHttpClient + : UnverifiedProgressHttpClient; + + private HttpClient DocumentHttpClient => + SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate) + ? VerifiedDocumentHttpClient + : UnverifiedDocumentHttpClient; + public async Task GetCapabilities() { var doc = await DoRequest("ScannerCapabilities"); @@ -95,9 +119,13 @@ public class EsclClient OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor), new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat))); var content = new StringContent(doc, Encoding.UTF8, "text/xml"); - var url = GetUrl($"/{_service.RootUrl}/ScanJobs"); - Logger.LogDebug("ESCL POST {Url}", url); - var response = await HttpClient.PostAsync(url, content); + var response = await WithHttpFallback( + () => GetUrl($"/{_service.RootUrl}/ScanJobs"), + url => + { + Logger.LogDebug("ESCL POST {Url}", url); + return HttpClient.PostAsync(url, content); + }); response.EnsureSuccessStatusCode(); Logger.LogDebug("POST OK"); @@ -142,9 +170,13 @@ public class EsclClient try { // TODO: Maybe check Content-Location on the response header to ensure no duplicate document? - var url = GetUrl($"{job.UriPath}/NextDocument"); - Logger.LogDebug("ESCL GET {Url}", url); - var response = await DocumentHttpClient.GetAsync(url); + var response = await WithHttpFallback( + () => GetUrl($"{job.UriPath}/NextDocument"), + url => + { + Logger.LogDebug("ESCL GET {Url}", url); + return DocumentHttpClient.GetAsync(url); + }); if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) { // NotFound = end of scan, Gone = canceled @@ -170,9 +202,13 @@ public class EsclClient public async Task ErrorDetails(EsclJob job) { - var url = GetUrl($"{job.UriPath}/ErrorDetails"); - Logger.LogDebug("ESCL GET {Url}", url); - var response = await HttpClient.GetAsync(url); + var response = await WithHttpFallback( + () => GetUrl($"{job.UriPath}/ErrorDetails"), + url => + { + Logger.LogDebug("ESCL GET {Url}", url); + return HttpClient.GetAsync(url); + }); response.EnsureSuccessStatusCode(); Logger.LogDebug("GET OK"); return await response.Content.ReadAsStringAsync(); @@ -180,9 +216,13 @@ public class EsclClient public async Task CancelJob(EsclJob job) { - var url = GetUrl(job.UriPath); - Logger.LogDebug("ESCL DELETE {Url}", url); - var response = await HttpClient.DeleteAsync(url); + var response = await WithHttpFallback( + () => GetUrl(job.UriPath), + url => + { + Logger.LogDebug("ESCL DELETE {Url}", url); + return HttpClient.DeleteAsync(url); + }); if (!response.IsSuccessStatusCode) { Logger.LogDebug("DELETE failed: {Status}", response.StatusCode); @@ -195,9 +235,13 @@ public class EsclClient private async Task DoRequest(string endpoint) { // TODO: Retry logic - var url = GetUrl($"/{_service.RootUrl}/{endpoint}"); - Logger.LogDebug("ESCL GET {Url}", url); - var response = await HttpClient.GetAsync(url, CancelToken); + var response = await WithHttpFallback( + () => GetUrl($"/{_service.RootUrl}/{endpoint}"), + url => + { + Logger.LogDebug("ESCL GET {Url}", url); + return HttpClient.GetAsync(url, CancelToken); + }); response.EnsureSuccessStatusCode(); Logger.LogDebug("GET OK"); var text = await response.Content.ReadAsStringAsync(); @@ -205,20 +249,47 @@ public class EsclClient return doc; } - private string GetUrl(string endpoint) + private async Task WithHttpFallback(Func urlFunc, Func> func) { - var protocol = _service.Tls || _service.Port == 443 ? "https" : "http"; - return $"{protocol}://{GetHostAndPort()}{endpoint}"; + string url = urlFunc(); + try + { + return await func(url); + } + catch (HttpRequestException ex) when (!SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttps) && + !_httpFallback && + url.StartsWith("https://") && ( + ex.InnerException is AuthenticationException || + ex.InnerException?.InnerException is AuthenticationException)) + { + Logger.LogDebug(ex, "TLS authentication error; falling back to HTTP"); + _httpFallback = true; + url = urlFunc(); + return await func(url); + } } - private string GetHostAndPort() + private string GetUrl(string endpoint) { - var host = new IPEndPoint(_service.RemoteEndpoint, _service.Port).ToString(); + bool tls = (_service.Tls || _service.Port == 443) && !_httpFallback; + if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttps) && !tls) + { + throw new EsclSecurityPolicyViolationException( + $"EsclSecurityPolicy of {SecurityPolicy} doesn't allow HTTP connections"); + } + var protocol = tls ? "https" : "http"; + return $"{protocol}://{GetHostAndPort(_service.Tls && !_httpFallback)}{endpoint}"; + } + + private string GetHostAndPort(bool tls) + { + var port = tls ? _service.TlsPort : _service.Port; + var host = new IPEndPoint(_service.RemoteEndpoint, port).ToString(); #if NET6_0_OR_GREATER if (OperatingSystem.IsMacOS()) { // Using the mDNS hostname is more reliable on Mac (but doesn't work at all on Windows) - host = $"{_service.Host}:{_service.Port}"; + host = $"{_service.Host}:{port}"; } #endif return host; diff --git a/NAPS2.Escl/Client/EsclService.cs b/NAPS2.Escl/Client/EsclService.cs index f44f5cc22..6f277fd90 100644 --- a/NAPS2.Escl/Client/EsclService.cs +++ b/NAPS2.Escl/Client/EsclService.cs @@ -25,10 +25,15 @@ public class EsclService public required IPAddress RemoteEndpoint { get; init; } /// - /// The port of the ESCL service. + /// The HTTP port of the ESCL service. /// public required int Port { get; init; } + /// + /// The HTTPS port of the ESCL service. + /// + public required int TlsPort { get; init; } + /// /// Whether to use HTTPS for the connection. /// diff --git a/NAPS2.Escl/Client/EsclServiceLocator.cs b/NAPS2.Escl/Client/EsclServiceLocator.cs index 0126cdaeb..e9022884f 100644 --- a/NAPS2.Escl/Client/EsclServiceLocator.cs +++ b/NAPS2.Escl/Client/EsclServiceLocator.cs @@ -19,15 +19,24 @@ public class EsclServiceLocator : IDisposable { try { - var service = ParseService(args); - var serviceKey = new ServiceKey(service.ScannerName, service.Uuid, service.Port, service.IpV4, service.IpV6); - if (!_locatedServices.Add(serviceKey)) + if (args.ServiceInstanceName.Labels[1] is not ("_uscan" or "_uscans")) { - // Don't callback for duplicates return; } - Logger.LogDebug("Discovered ESCL Service: {Name}, instance {Instance}, endpoint {Endpoint}, ipv4 {Ipv4}, ipv6 {IpV6}, host {Host}, port {Port}, uuid {Uuid}", - service.ScannerName, args.ServiceInstanceName, args.RemoteEndPoint, service.IpV4, service.IpV6, service.Host, service.Port, service.Uuid); + var service = ParseService(args); + // TODO: Does the IP really make the device distinct? Not that it should matter in practice, but still. + // TODO: We definitely want to de-duplicate HTTP/HTTPS, but I'm not sure how to do that. Remind me how + var serviceKey = new ServiceKey(service.ScannerName, service.Uuid, service.Port, service.IpV4, service.IpV6); + lock (_locatedServices) + { + if (!_locatedServices.Add(serviceKey)) + { + // Don't callback for duplicates + return; + } + } + Logger.LogDebug("Discovered ESCL Service: {Name}, instance {Instance}, endpoint {Endpoint}, ipv4 {Ipv4}, ipv6 {IpV6}, host {Host}, port {Port}, tlsPort {Port}, uuid {Uuid}", + service.ScannerName, args.ServiceInstanceName, args.RemoteEndPoint, service.IpV4, service.IpV6, service.Host, service.Port, service.TlsPort, service.Uuid); serviceCallback(service); } catch (Exception ex) @@ -79,6 +88,7 @@ public class EsclServiceLocator : IDisposable bool isTls = false; IPAddress? ipv4 = null, ipv6 = null; int port = -1; + int tlsPort = -1; string? host = null; var props = new Dictionary(); foreach (var record in args.Message.AdditionalRecords) @@ -95,10 +105,17 @@ public class EsclServiceLocator : IDisposable if (record is SRVRecord srv) { bool recordIsTls = srv.Name.IsSubdomainOf(DomainName.Join("_uscans", "_tcp", "local")); + if (recordIsTls) + { + tlsPort = srv.Port; + } + else + { + port = srv.Port; + } if (host == null || recordIsTls) { // HTTPS overrides HTTP but not the other way around - port = srv.Port; host = srv.Target.ToString(); isTls = recordIsTls; } @@ -116,7 +133,7 @@ public class EsclServiceLocator : IDisposable } } string? uuid = Get(props, "uuid"); - if ((ipv4 == null && ipv6 == null) || port == -1 || host == null || uuid == null) + if ((ipv4 == null && ipv6 == null) || (port == -1 && tlsPort == -1) || host == null || uuid == null) { throw new ArgumentException("Missing host/IP/port/uuid"); } @@ -128,6 +145,7 @@ public class EsclServiceLocator : IDisposable Host = host, RemoteEndpoint = args.RemoteEndPoint.Address, Port = port, + TlsPort = tlsPort, Tls = isTls, Uuid = uuid, ScannerName = props["ty"], diff --git a/NAPS2.Escl/EsclSecurityPolicy.cs b/NAPS2.Escl/EsclSecurityPolicy.cs new file mode 100644 index 000000000..d238b30f6 --- /dev/null +++ b/NAPS2.Escl/EsclSecurityPolicy.cs @@ -0,0 +1,24 @@ +namespace NAPS2.Escl; + +[Flags] +public enum EsclSecurityPolicy +{ + /// + /// Allow both HTTP and HTTPS connections. + /// + None = 0, + + ServerRequireHttps = 1, + ClientRequireHttps = 2, + ClientRequireHttpOrTrustedCertificate = 4, + + /// + /// Only allow HTTPS connections, but clients will accept self-signed certificates. + /// + RequireHttps = ServerRequireHttps | ClientRequireHttps, + + /// + /// Only allow HTTPS connections, and clients will only accept trusted certificates. + /// + RequireTrustedCertificate = RequireHttps | ClientRequireHttpOrTrustedCertificate +} \ No newline at end of file diff --git a/NAPS2.Escl/EsclSecurityPolicyViolationException.cs b/NAPS2.Escl/EsclSecurityPolicyViolationException.cs new file mode 100644 index 000000000..43bdc58af --- /dev/null +++ b/NAPS2.Escl/EsclSecurityPolicyViolationException.cs @@ -0,0 +1,3 @@ +namespace NAPS2.Escl; + +public class EsclSecurityPolicyViolationException(string message) : Exception(message); \ No newline at end of file diff --git a/NAPS2.Escl/Server/EsclDeviceConfig.cs b/NAPS2.Escl/Server/EsclDeviceConfig.cs index 4cc8bcebf..87a54ed7b 100644 --- a/NAPS2.Escl/Server/EsclDeviceConfig.cs +++ b/NAPS2.Escl/Server/EsclDeviceConfig.cs @@ -7,4 +7,6 @@ public class EsclDeviceConfig public required Func CreateJob { get; init; } public int Port { get; set; } + + public int TlsPort { get; set; } } \ No newline at end of file diff --git a/NAPS2.Escl/Server/IEsclServer.cs b/NAPS2.Escl/Server/IEsclServer.cs index 07423a8a0..9d9c915a5 100644 --- a/NAPS2.Escl/Server/IEsclServer.cs +++ b/NAPS2.Escl/Server/IEsclServer.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; namespace NAPS2.Escl.Server; @@ -8,5 +9,7 @@ public interface IEsclServer : IDisposable void RemoveDevice(EsclDeviceConfig deviceConfig); Task Start(); Task Stop(); + public EsclSecurityPolicy SecurityPolicy { get; set; } + public X509Certificate2? Certificate { get; set; } ILogger Logger { get; set; } } \ No newline at end of file diff --git a/NAPS2.Lib/Config/CommonConfig.cs b/NAPS2.Lib/Config/CommonConfig.cs index 8b83e9473..d927abbb0 100644 --- a/NAPS2.Lib/Config/CommonConfig.cs +++ b/NAPS2.Lib/Config/CommonConfig.cs @@ -1,10 +1,10 @@ using System.Collections.Immutable; using NAPS2.Config.Model; +using NAPS2.Escl; using NAPS2.ImportExport.Email; using NAPS2.ImportExport.Images; using NAPS2.Pdf; using NAPS2.Ocr; -using NAPS2.Remoting.Server; using NAPS2.Scan; using NAPS2.Scan.Batch; @@ -147,6 +147,12 @@ public class CommonConfig [Common] public DockStyle ProfilesToolStripDock { get; set; } + [Common] + public EsclSecurityPolicy EsclSecurityPolicy { get; set; } + + [Common] + public string? EsclServerCertificatePath { get; set; } + [App] public EventType EventLogging { get; set; } diff --git a/NAPS2.Lib/Config/ConfigSerializer.cs b/NAPS2.Lib/Config/ConfigSerializer.cs index 9c1b2c5da..8b513c103 100644 --- a/NAPS2.Lib/Config/ConfigSerializer.cs +++ b/NAPS2.Lib/Config/ConfigSerializer.cs @@ -120,6 +120,8 @@ public class ConfigSerializer : VersionedSerializer> storage.Set(x => x.OcrLanguageCode, c.OcrDefaultLanguage); storage.Set(x => x.OcrMode, c.OcrDefaultMode); storage.Set(x => x.OcrAfterScanning, c.OcrDefaultAfterScanning); + storage.Set(x => x.EsclSecurityPolicy, c.EsclSecurityPolicy); + storage.Set(x => x.EsclServerCertificatePath, c.EsclServerCertificatePath); storage.Set(x => x.EventLogging, c.EventLogging); storage.Set(x => x.KeyboardShortcuts, c.KeyboardShortcuts ?? new KeyboardShortcuts()); return storage; diff --git a/NAPS2.Lib/Config/ObsoleteTypes/AppConfigV0.cs b/NAPS2.Lib/Config/ObsoleteTypes/AppConfigV0.cs index 5c47feafc..fadbf335c 100644 --- a/NAPS2.Lib/Config/ObsoleteTypes/AppConfigV0.cs +++ b/NAPS2.Lib/Config/ObsoleteTypes/AppConfigV0.cs @@ -1,4 +1,5 @@ using System.Xml.Serialization; +using NAPS2.Escl; using NAPS2.Ocr; using NAPS2.Pdf; using NAPS2.Scan; @@ -84,6 +85,10 @@ public class AppConfigV0 public PdfCompat ForcePdfCompat { get; set; } + public EsclSecurityPolicy EsclSecurityPolicy { get; set; } + + public string? EsclServerCertificatePath { get; set; } + public EventType EventLogging { get; set; } public KeyboardShortcuts? KeyboardShortcuts { get; set; } diff --git a/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs b/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs index acd4740f5..19e76b3be 100644 --- a/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs +++ b/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs @@ -3,7 +3,6 @@ using Eto.Forms; using NAPS2.EtoForms.Layout; using NAPS2.Remoting.Server; using NAPS2.Scan; -using NAPS2.Scan.Exceptions; using NAPS2.Scan.Internal; namespace NAPS2.EtoForms.Ui; @@ -11,6 +10,7 @@ namespace NAPS2.EtoForms.Ui; public class SharedDeviceForm : EtoDialogBase { private const int BASE_PORT = 9801; + private const int BASE_TLS_PORT = 9901; private readonly IScanPerformer _scanPerformer; private readonly ErrorOutput _errorOutput; @@ -121,6 +121,8 @@ public class SharedDeviceForm : EtoDialogBase private int Port { get; set; } + private int TlsPort { get; set; } + private Driver DeviceDriver { get => _twainDriver.Checked ? Driver.Twain @@ -159,6 +161,7 @@ public class SharedDeviceForm : EtoDialogBase _displayName.Text = SharedDevice?.Name ?? ""; CurrentDevice ??= SharedDevice?.Device; Port = SharedDevice?.Port ?? 0; + TlsPort = SharedDevice?.TlsPort ?? 0; DeviceDriver = SharedDevice?.Device.Driver ?? ScanOptionsValidator.SystemDefaultDriver; @@ -187,7 +190,8 @@ public class SharedDeviceForm : EtoDialogBase { Name = _displayName.Text, Device = CurrentDevice!, - Port = Port == 0 ? NextPort() : Port + Port = Port == 0 ? NextPort() : Port, + TlsPort = TlsPort == 0 ? NextTlsPort() : TlsPort }; } @@ -202,6 +206,17 @@ public class SharedDeviceForm : EtoDialogBase return port; } + private int NextTlsPort() + { + var devices = _sharedDeviceManager.SharedDevices; + int tlsPort = BASE_TLS_PORT; + while (devices.Any(x => x.TlsPort == tlsPort)) + { + tlsPort++; + } + return tlsPort; + } + private void Ok_Click(object? sender, EventArgs e) { if (_displayName.Text == "") diff --git a/NAPS2.Lib/Remoting/Server/SharedDevice.cs b/NAPS2.Lib/Remoting/Server/SharedDevice.cs index 7cf25475f..345795588 100644 --- a/NAPS2.Lib/Remoting/Server/SharedDevice.cs +++ b/NAPS2.Lib/Remoting/Server/SharedDevice.cs @@ -7,6 +7,7 @@ public record SharedDevice public required string Name { get; init; } public required ScanDevice Device { get; init; } public int Port { get; init; } + public int TlsPort { get; init; } public virtual bool Equals(SharedDevice? other) => other is not null && Name == other.Name && Device == other.Device; diff --git a/NAPS2.Lib/Remoting/Server/SharedDeviceManager.cs b/NAPS2.Lib/Remoting/Server/SharedDeviceManager.cs index ede6096b5..6a1c08e6d 100644 --- a/NAPS2.Lib/Remoting/Server/SharedDeviceManager.cs +++ b/NAPS2.Lib/Remoting/Server/SharedDeviceManager.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; +using System.Security.Cryptography.X509Certificates; using System.Threading; +using Microsoft.Extensions.Logging; using NAPS2.Config.Model; using NAPS2.Escl.Server; using NAPS2.Scan; @@ -10,6 +12,7 @@ public class SharedDeviceManager : ISharedDeviceManager { private const int STARTUP_RETRY_INTERVAL = 10_000; + private readonly ILogger _logger; private readonly Naps2Config _config; private readonly FileConfigScope _scope; private readonly ScanServer _server; @@ -19,11 +22,30 @@ public class SharedDeviceManager : ISharedDeviceManager public SharedDeviceManager(ScanningContext scanningContext, Naps2Config config, string sharedDevicesConfigPath) { + _logger = scanningContext.Logger; _config = config; _scope = ConfigScope.File(sharedDevicesConfigPath, new ConfigStorageSerializer(), ConfigScopeMode.ReadWrite); _server = new ScanServer(scanningContext, new EsclServer()); _server.SetDefaultIcon(Icons.scanner_128); + _server.SecurityPolicy = config.Get(c => c.EsclSecurityPolicy); + if (config.Get(c => c.EsclServerCertificatePath) is { } certPath && !string.IsNullOrWhiteSpace(certPath)) + { + try + { + _server.Certificate = new X509Certificate2(File.ReadAllBytes(certPath)); + if (!_server.Certificate.HasPrivateKey) + { + _logger.LogDebug( + $"Certificate has no private key. Make sure it's installed in the local computer's certificate store. \"{certPath}\""); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + $"Could not read X509 certificate from EsclServerCertificatePath \"{certPath}\""); + } + } _server.InstanceId = _scope.GetOrDefault(c => c.InstanceId) ?? Guid.NewGuid(); RegisterDevicesFromConfig(); } @@ -54,7 +76,8 @@ public class SharedDeviceManager : ISharedDeviceManager if (_userStarted && SharedDevices.Any() && TakeLock()) { ResetStartTimer(); - _server.Start(); + _server.Start().ContinueWith(t => + _logger.LogError(t.Exception, "Error starting ScanServer"), TaskContinuationOptions.OnlyOnFaulted); return true; } return false; @@ -67,7 +90,8 @@ public class SharedDeviceManager : ISharedDeviceManager { _userStarted = false; ResetStartTimer(); - _server.Stop(); + _server.Stop().ContinueWith(t => + _logger.LogError(t.Exception, "Error starting ScanServer"), TaskContinuationOptions.OnlyOnFaulted); ReleaseLock(); } } @@ -162,7 +186,7 @@ public class SharedDeviceManager : ISharedDeviceManager } private void RegisterOnServer(SharedDevice device) => - _server.RegisterDevice(device.Device, device.Name, device.Port); + _server.RegisterDevice(device.Device, device.Name, device.Port, device.TlsPort); private void UnregisterOnServer(SharedDevice device) => _server.UnregisterDevice(device.Device, device.Name); diff --git a/NAPS2.Lib/Scan/ScanPerformer.cs b/NAPS2.Lib/Scan/ScanPerformer.cs index 46c191f01..cd9d9ee06 100644 --- a/NAPS2.Lib/Scan/ScanPerformer.cs +++ b/NAPS2.Lib/Scan/ScanPerformer.cs @@ -242,6 +242,10 @@ internal class ScanPerformer : IScanPerformer // We use a worker process for SANE so we should clean up after each operation KeepInitialized = false }, + EsclOptions = + { + SecurityPolicy = _config.Get(c => c.EsclSecurityPolicy) + }, KeyValueOptions = scanProfile.KeyValueOptions != null ? new KeyValueScanOptions(scanProfile.KeyValueOptions) : new KeyValueScanOptions(), diff --git a/NAPS2.Sdk.Tests/BinaryResources.Designer.cs b/NAPS2.Sdk.Tests/BinaryResources.Designer.cs index b061f60eb..1886c23ab 100644 --- a/NAPS2.Sdk.Tests/BinaryResources.Designer.cs +++ b/NAPS2.Sdk.Tests/BinaryResources.Designer.cs @@ -138,5 +138,15 @@ namespace NAPS2.Sdk.Tests { return ((byte[])(obj)); } } + + /// + /// Looks up a localized resource of type System.Byte[]. + /// + internal static byte[] testcert { + get { + object obj = ResourceManager.GetObject("testcert", resourceCulture); + return ((byte[])(obj)); + } + } } } diff --git a/NAPS2.Sdk.Tests/BinaryResources.resx b/NAPS2.Sdk.Tests/BinaryResources.resx index 8002ce885..586fefe37 100644 --- a/NAPS2.Sdk.Tests/BinaryResources.resx +++ b/NAPS2.Sdk.Tests/BinaryResources.resx @@ -142,4 +142,7 @@ Resources\ocr_test_hebrew.jpg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Resources\testcert.pfx;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Remoting/FallbackScanServerTests.cs b/NAPS2.Sdk.Tests/Remoting/FallbackScanServerTests.cs new file mode 100644 index 000000000..1de79cd7d --- /dev/null +++ b/NAPS2.Sdk.Tests/Remoting/FallbackScanServerTests.cs @@ -0,0 +1,48 @@ +using System.Net.Http; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using NAPS2.Escl; +using NAPS2.Scan; +using NAPS2.Sdk.Tests.Asserts; +using Xunit; +using Xunit.Abstractions; + +namespace NAPS2.Sdk.Tests.Remoting; + +public class FallbackScanServerTests(ITestOutputHelper testOutputHelper) : ScanServerTestsBase(testOutputHelper, + EsclSecurityPolicy.None, new X509Certificate2(BinaryResources.testcert)) +{ + [Fact] + public async Task ScanFallbackFromHttpsToHttp() + { + _bridge.MockOutput = CreateScannedImages(ImageResources.dog); + var images = await _client.Scan(new ScanOptions + { + Device = _clientDevice, + EsclOptions = + { + // This policy makes sure HTTPS will fail due to an untrusted certificate, which simulates the case + // where we're failing due to the server only supporting obsolete TLS versions. + SecurityPolicy = EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate + } + }).ToListAsync(); + Assert.Single(images); + ImageAsserts.Similar(ImageResources.dog, images[0]); + } + + [Fact] + public async Task ScanPreventedByTrustedCertificateSecurityPolicy() + { + var scanResult = _client.Scan(new ScanOptions + { + Device = _clientDevice, + EsclOptions = + { + SecurityPolicy = EsclSecurityPolicy.RequireTrustedCertificate + } + }); + var exception = await Assert.ThrowsAsync(async () => await scanResult.ToListAsync()); + Assert.True(exception.InnerException is AuthenticationException || + exception.InnerException?.InnerException is AuthenticationException); + } +} \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs b/NAPS2.Sdk.Tests/Remoting/ScanServerTests.cs similarity index 79% rename from NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs rename to NAPS2.Sdk.Tests/Remoting/ScanServerTests.cs index 4ff4440c2..eb428e58f 100644 --- a/NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs +++ b/NAPS2.Sdk.Tests/Remoting/ScanServerTests.cs @@ -1,53 +1,15 @@ -using Microsoft.Extensions.Logging; -using NAPS2.Escl.Server; -using NAPS2.Remoting.Server; +using NAPS2.Escl; using NAPS2.Scan; using NAPS2.Scan.Exceptions; -using NAPS2.Scan.Internal; using NAPS2.Sdk.Tests.Asserts; -using NAPS2.Sdk.Tests.Mocks; using NSubstitute; using Xunit; using Xunit.Abstractions; namespace NAPS2.Sdk.Tests.Remoting; -public class ScanServerIntegrationTests : ContextualTests +public class ScanServerTests(ITestOutputHelper testOutputHelper) : ScanServerTestsBase(testOutputHelper) { - private readonly ScanServer _server; - private readonly MockScanBridge _bridge; - private readonly ScanController _client; - private readonly ScanDevice _clientDevice; - - public ScanServerIntegrationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) - { - _server = new ScanServer(ScanningContext, new EsclServer()); - - // Set up a server connecting to a mock scan backend - _bridge = new MockScanBridge(); - var scanBridgeFactory = Substitute.For(); - scanBridgeFactory.Create(Arg.Any()).Returns(_bridge); - _server.ScanController = new ScanController(ScanningContext, scanBridgeFactory); - - // Initialize the server with a single device with a unique ID for the test - var displayName = $"testName-{Guid.NewGuid()}"; - ScanningContext.Logger.LogDebug("Display name: {Name}", displayName); - var serverDevice = new ScanDevice(ScanOptionsValidator.SystemDefaultDriver, "testID", "testName"); - _server.RegisterDevice(serverDevice, displayName); - _server.Start().Wait(); - - // Set up a client ScanController for scanning through EsclScanDriver -> network -> ScanServer - _client = new ScanController(ScanningContext); - var uuid = new ScanServerDevice { Device = serverDevice, Name = displayName }.GetUuid(_server.InstanceId); - _clientDevice = new ScanDevice(Driver.Escl, uuid, displayName); - } - - public override void Dispose() - { - _server.Stop().Wait(); - base.Dispose(); - } - [Fact] public async Task FindDevice() { @@ -212,4 +174,18 @@ public class ScanServerIntegrationTests : ContextualTests pageStartMock.Received()(Arg.Any(), Arg.Is(args => args.PageNumber == 2)); pageProgressMock.Received()(Arg.Any(), Arg.Is(args => args.PageNumber == 2 && args.Progress == 0.5)); } + + [Fact] + public async Task ScanPreventedByHttpsSecurityPolicy() + { + var scanResult = _client.Scan(new ScanOptions + { + Device = _clientDevice, + EsclOptions = + { + SecurityPolicy = EsclSecurityPolicy.RequireHttps + } + }); + await Assert.ThrowsAsync(async () => await scanResult.ToListAsync()); + } } \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Remoting/ScanServerTestsBase.cs b/NAPS2.Sdk.Tests/Remoting/ScanServerTestsBase.cs new file mode 100644 index 000000000..c35401bf3 --- /dev/null +++ b/NAPS2.Sdk.Tests/Remoting/ScanServerTestsBase.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using NAPS2.Escl; +using NAPS2.Escl.Server; +using NAPS2.Remoting.Server; +using NAPS2.Scan; +using NAPS2.Scan.Internal; +using NAPS2.Sdk.Tests.Mocks; +using NSubstitute; +using Xunit.Abstractions; + +namespace NAPS2.Sdk.Tests.Remoting; + +public class ScanServerTestsBase : ContextualTests +{ + protected readonly ScanServer _server; + private protected readonly MockScanBridge _bridge; + protected readonly ScanController _client; + protected readonly ScanDevice _clientDevice; + + public ScanServerTestsBase(ITestOutputHelper testOutputHelper, + EsclSecurityPolicy securityPolicy = EsclSecurityPolicy.None, + X509Certificate2 certificate = null) : base(testOutputHelper) + { + _server = new ScanServer(ScanningContext, new EsclServer()); + + // Set up a server connecting to a mock scan backend + _bridge = new MockScanBridge(); + var scanBridgeFactory = Substitute.For(); + scanBridgeFactory.Create(Arg.Any()).Returns(_bridge); + _server.ScanController = new ScanController(ScanningContext, scanBridgeFactory); + _server.SecurityPolicy = securityPolicy; + _server.Certificate = certificate; + + // Initialize the server with a single device with a unique ID for the test + var displayName = $"testName-{Guid.NewGuid()}"; + ScanningContext.Logger.LogDebug("Display name: {Name}", displayName); + var serverDevice = new ScanDevice(ScanOptionsValidator.SystemDefaultDriver, "testID", "testName"); + _server.RegisterDevice(serverDevice, displayName); + _server.Start().Wait(); + + // Set up a client ScanController for scanning through EsclScanDriver -> network -> ScanServer + _client = new ScanController(ScanningContext); + var uuid = new ScanServerDevice { Device = serverDevice, Name = displayName }.GetUuid(_server.InstanceId); + _clientDevice = new ScanDevice(Driver.Escl, uuid, displayName); + } + + public override void Dispose() + { + _server.Stop().Wait(); + base.Dispose(); + } +} \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Remoting/TlsScanServerTests.cs b/NAPS2.Sdk.Tests/Remoting/TlsScanServerTests.cs new file mode 100644 index 000000000..868d34470 --- /dev/null +++ b/NAPS2.Sdk.Tests/Remoting/TlsScanServerTests.cs @@ -0,0 +1,55 @@ +using System.Net.Http; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using NAPS2.Escl; +using NAPS2.Scan; +using NAPS2.Sdk.Tests.Asserts; +using Xunit; +using Xunit.Abstractions; + +namespace NAPS2.Sdk.Tests.Remoting; + +public class TlsScanServerTests(ITestOutputHelper testOutputHelper) : ScanServerTestsBase(testOutputHelper, + EsclSecurityPolicy.RequireHttps, new X509Certificate2(BinaryResources.testcert)) +{ + [Fact] + public async Task FindDevice() + { + var devices = await _client.GetDeviceList(Driver.Escl); + // The device name is suffixed with the IP so we just check the prefix matches + Assert.Contains(devices, + device => device.Name.StartsWith(_clientDevice.Name) && device.ID == _clientDevice.ID); + } + + [Fact] + public async Task Scan() + { + _bridge.MockOutput = CreateScannedImages(ImageResources.dog); + var images = await _client.Scan(new ScanOptions + { + Device = _clientDevice, + EsclOptions = + { + SecurityPolicy = EsclSecurityPolicy.RequireHttps + } + }).ToListAsync(); + Assert.Single(images); + ImageAsserts.Similar(ImageResources.dog, images[0]); + } + + [Fact] + public async Task ScanPreventedByTrustedCertificateSecurityPolicy() + { + var scanResult = _client.Scan(new ScanOptions + { + Device = _clientDevice, + EsclOptions = + { + SecurityPolicy = EsclSecurityPolicy.RequireTrustedCertificate + } + }); + var exception = await Assert.ThrowsAsync(async () => await scanResult.ToListAsync()); + Assert.True(exception.InnerException is AuthenticationException || + exception.InnerException?.InnerException is AuthenticationException); + } +} \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Resources/testcert.crt b/NAPS2.Sdk.Tests/Resources/testcert.crt new file mode 100644 index 000000000..4a5235e17 --- /dev/null +++ b/NAPS2.Sdk.Tests/Resources/testcert.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEzCCAfsCFBbfet3dXy6Z774903bxJvhounivMA0GCSqGSIb3DQEBCwUAMEUx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjQwMzI3MDI1MDQxWhgPMjEyNDAzMDMw +MjUwNDFaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYD +VQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQDBIPIafttfxb8cwSQEld5YRd7SX1AD14p6wBZ32qfY+NkG +/lKKhhDK2mFucO+6prhllYrutrXt9F8myyap7RbRGFLytZahgOLK9BajIUQCXKHA +6VHehCdZHVdMVTzZk+VGsgctvCxX1pIGWeiR42uiPu6T7FijGHZrpUNIMheAATi3 +4LSVmDY+jxHRvBpDXVBdsoHzIrfYUA+GVGVpTpbzmQmooMH0c5bj3SNidiy7Mhx0 +EuuwPSATQ6E2aG6ckhn9tjzTIJWA+3RvoUU9zqHkAj2J4+xXYl09TzqsRAwZ0w+r +JNMQ1hOKualIyMnnQP74a4skZKJg+D3+R6R9qjshAgMBAAEwDQYJKoZIhvcNAQEL +BQADggEBAA9nzTygpYaAbCBI+pfscOAnF2kKn8tAyCy7R2LbEa2zPFV+2ZJUCFZt +E47jvpzFVrhMbd1sgmxup2P3Reeff718YIMFB3HAEDXmCUHd+Jh2HnoUfcNQVoUv +HSIskPpWK0PueZxRbPA72uTBpEQcwZ06kPREMEmiKkoWh9db2tMpjdiF0ci8XZdg +2qCMgJUrTVw3wtIufSPu8LWklnHM8T2uHtNQlppxSE5a0Sa9IU12dTWaA96GCO+X +AdQm7PvVSdaocRKhrsnJ5pxtvJFSYuqP2bMxstagkfqPpOJYO6gp/efBq+vqfJg1 +VTgLVJgjTFNwEdOytQJ9ZPrlpBjupyI= +-----END CERTIFICATE----- diff --git a/NAPS2.Sdk.Tests/Resources/testcert.csr b/NAPS2.Sdk.Tests/Resources/testcert.csr new file mode 100644 index 000000000..e8ee897dd --- /dev/null +++ b/NAPS2.Sdk.Tests/Resources/testcert.csr @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICijCCAXICAQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx +ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAMEg8hp+21/FvxzBJASV3lhF3tJfUAPXinrAFnfa +p9j42Qb+UoqGEMraYW5w77qmuGWViu62te30XybLJqntFtEYUvK1lqGA4sr0FqMh +RAJcocDpUd6EJ1kdV0xVPNmT5UayBy28LFfWkgZZ6JHja6I+7pPsWKMYdmulQ0gy +F4ABOLfgtJWYNj6PEdG8GkNdUF2ygfMit9hQD4ZUZWlOlvOZCaigwfRzluPdI2J2 +LLsyHHQS67A9IBNDoTZobpySGf22PNMglYD7dG+hRT3OoeQCPYnj7FdiXT1POqxE +DBnTD6sk0xDWE4q5qUjIyedA/vhriyRkomD4Pf5HpH2qOyECAwEAAaAAMA0GCSqG +SIb3DQEBCwUAA4IBAQB7eR0vqyWCuf0EUSBYYngHfewJM/dBUR+C+ZRloEwYBkwU +ma06L/3uSV50+L81x2ZbOi93Ee6WrukdYMq0r82LlizHDAVeWz6FkuDCobVyWnbX +QvoUbvPAHvBmw172Zkzs7pGCbq3h0gejqzMOT6lVnZOMsHRXDVVvM7afatSNMf6w +EnIpbil4bQ9XQoj4bF1f81d28E9O4w4saB7WLDvbjukeQC81qRhXu7FXAsLP9ZA1 +Pq0wuqCIMmfF6BVh9reZ8nVR9RtrFGSOT6+rVgztjuuFETq7p83xawdABQwYTE1M +icvlO9gXI1Gey4CkS9uTGjrH1JU5zLOHNL0RwDWe +-----END CERTIFICATE REQUEST----- diff --git a/NAPS2.Sdk.Tests/Resources/testcert.key b/NAPS2.Sdk.Tests/Resources/testcert.key new file mode 100644 index 000000000..125a320de --- /dev/null +++ b/NAPS2.Sdk.Tests/Resources/testcert.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDBIPIafttfxb8c +wSQEld5YRd7SX1AD14p6wBZ32qfY+NkG/lKKhhDK2mFucO+6prhllYrutrXt9F8m +yyap7RbRGFLytZahgOLK9BajIUQCXKHA6VHehCdZHVdMVTzZk+VGsgctvCxX1pIG +WeiR42uiPu6T7FijGHZrpUNIMheAATi34LSVmDY+jxHRvBpDXVBdsoHzIrfYUA+G +VGVpTpbzmQmooMH0c5bj3SNidiy7Mhx0EuuwPSATQ6E2aG6ckhn9tjzTIJWA+3Rv +oUU9zqHkAj2J4+xXYl09TzqsRAwZ0w+rJNMQ1hOKualIyMnnQP74a4skZKJg+D3+ +R6R9qjshAgMBAAECggEAEjYtHlqADVP0ZZ3A673GLcTI8kWSogodQN4EQGEaGte8 +f3BUEEP8KWTWczerI4q9MLcdVs1b8ohswJe/mZ6F3EnS6Jg/EBO7TzAdQlzMsPxT +NIHL+pOzsi+WH9iZ2Fqd8ECxdJqeA9p0Aq1PxRIRAEe277QF17tiz1vSMGio1qUc +4c59mMXArApDNWLmphqsG6scQf5JyY8HHdjA/m4kfltiimlhId0Z7vJ7IuCndI7w +tr3kj5meJFqmmzl05U0a47WXiF4bpbZ/Io6Hk5Zu/40xgqBKVByPIv7nuwbJj5bA +ev0wd5+iVNErFKIw+xCI+wln0imQSuyQXtBjz3Cw0QKBgQDmaYbkVxLjmxRHHR/3 +GmgET73QFmjlkL6L/truOfxbrLDBx8nDEp3vhq1u9+qou0ODYbU5dPdMgWNULpEH +M9aj+4LxeTOoAr2tZkVANiCJNeZDNfQt95puH822skXIz6Y8DLAfZ55DOIlmwhN/ +4+gdGHeql5MCUwdW33VI1UcZWwKBgQDWk3jSwpt3WUrFavQez22AJtezeTPywyTK +FFI+Yo6W4tUF22ALgaN00yUDkAA8CZvc7/WAoGwyGbbBGw89Ct2WHq4bN+PQVo0c +U4WvxlAKLq5osFH2Mm0p9KkA/zDxAZZfOnjd4TgQbTe4bU3T/FM/LeWCSX0vMgb4 +NnwWZ2nqMwKBgQDJjvKzeQBLHvQkKXQ3A2COtPsEtzXX7EDj0nPOBeeegni1a4Iy +JW0Hhbbd5f3e0MIEgkq4Envq7xznHT09IbnYBULM3gu0I4Gt2FMoErFvljjx/pa2 +R21OfH/GHDkzq4Jt8WN4dXpar3By9b99Fu+L1EWKc8HkPKGk+yFsLzZdFQKBgQDU +VHvj+sTCli5CKnLFJjdR753UsCPynp4CBZfYucglkPKA6DMjT7ZCvUlMPCuvPUbp +mt3R2W0XKpDIh5FNsznP+i4JKwYYu/zIwfFxHYlIeicF2yxPtliFgt/V57AzXIHD +W+YMkXfb8WeI7Uhtc6ugwjbw9O2WTSfOaIPj25NYNwKBgQCRH/OqDClcO9IKkiUU +yl5XtXWLqobGsuSIivesQ2k8+83yLoYY8IaUr1IBYlQQAwvCRTvzXqQtkOL9uTi7 +xzY6+wGphQvJjEweMggBbY4XRdfip7XdZU9CDDog1GyaUgfqR0zov6FLHVf0/EVA +4OrmR6DYSJhAZSgpahZGlZEuNw== +-----END PRIVATE KEY----- diff --git a/NAPS2.Sdk.Tests/Resources/testcert.pfx b/NAPS2.Sdk.Tests/Resources/testcert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..022a670b6446da0d390d134db65a839b248ab87f GIT binary patch literal 2531 zcmai$X*3jy8^+Dbj2TO|WXUf3*s|Vx2{G0zSu)u}mbg(OTqe7rWk&W$gGjpDcjhIv1CnM_x%5-`|*Bw&wHNdIq!Kty}$FKu&^m00EEKAvY;^eB(tPFB!CrA zjD;nEu`uk39gD((w*HNv#aIyI#5x&bIccN+CIFPvPXR%q%u#axN(4$6%qAc|TO}s# ziva@JSWpNk+yAx!VGtIS2o&a#WCpkf0t4j1Ty2?Dt7r(f?oKDUE-KE`FAfU|t)&}P zJon*=u6@r2O9WMvvt`wDNTq9j=)ORH`MV}xhqNFql9bj2x^m?hTkmm&zj`o(*VhS` z+;LkeUBT!^5DCB$7o`{DL`gX&N(|q>Zkca7btIu&(IJ7;wofwjW)oekZno+sE*E@6 zerGbgri+{&+i*#)90Un-mW(z%YdM}+wPhd8(Xozwlr^G>_t)gJjNe@8nl?_lk-9xyE<}kCi~@KZ7WfEByfrt0XgTK{eu4H&7THe4Dd_o zD|tSf$krmuEZTZIzu%;nqKpZ@KQrJBEC+SB+?Lla^~K8CVxG_C21^3E6h!4+p7k>Ae)hSr0%JPyx_6=ko?hs4q4Qv&j54+!J-csFV z&v^Htw$ON`nPhPJqTTrKs}XT=)04&h41}F%l8$UGbC|0VSQ$K;^-}C=?@k?0|1B7< zki)5sd7Fa#qg)Lf`RjRY2aS2jM8`+!{E&;SBKDm;-`KNu73J;r_Y?5Klov83T0Wan zS;sHKMbSQTK@$FZ+;cT0;WF*P9)gZMt}9F&ZTFr2p+JSTrtZssVShnAD^9Zs-D)jWi0_#f=L=6i{?Q_imcLy{IcE1y%t@W>UdKG- zni=_XV@wCHwPpl^w-DLIpQb9*)S>LEmjIr!hSq)Mbj)-`rh+(Op%I0JB>cSJe}DkF z1;#>tKe2;P>N=GD|Exg30lr>eV)kxZYFdy{Hash*96xYL{E|B-u>a|Wl z<7CncKxMSQXps$n^;th97@Ekn(mKs1T?RBTt+xV1#Aoc;L=+f)UXVbKr&$C<=zG@VvlC{2Rrc*jN zN!cpht!eMJ_4=k`uQ`$8{Scw3nKxQ-%m&8_n@+p0DRu4i=wOY3dIg z?s-PVq!qqrPp&v-ug`7)?+6h()9Ly0RB_~&7ubD0k_u%J-mY4PY zAFZQno`l)V!84?q9cTD5R^;9dd72OuBv1#$xD7Bo)tq29dw&og=#} zzP#z+Xjz(HIa6YwnHnunu*Q*ARY_HMg!!`2H6g9QpgHDrL_)70OC;sTE|qhTHVBIJ z^p;SngaxewR%~7g1EK_5%?!2Y7xqag)i6&Q|7>RG2VCae$gTp{oJ3mc<)`E8dW#R+ zg=@BgWF(hFI6BYd5vzNKJa6d2(x=+3qa^1pbr7_^wDZYdlB$03w!jh25uWL^F}hRQ zVl6}HhkU)JCC4&x4^f!quFKT!?vo)YoHaR!$~}6`rk6~YKZWTE%xp={lt@+~+Ko{h z>$bHemVV)S*N`lGENuoajwLedP}k>skvhF!OvV`t>fK^UMWahdr8X*V}>>UQ;_an zIDZS9`;&3G*x!U=>Qw0iDL;1FRCDi=XPXlIId{9~|4?$Avb!DQg@&63^Tc(&%g_A{ z5CeBd<0C~~VZgw{d)ZAA;R~RWr~K;Xw#ELq2;dzafd58KU(8ebCDB+#u=xFK-rcad zJM;0(R{%qS`KxMfVM{iY8rH6 /// A unique ID that is used to help derive the UUIDs for shared scanners. If you expect to have multiple shared /// scanners with the same name/model on the same network it may be useful to set this to a unique value. /// public Guid InstanceId { get; set; } + /// + /// The security policy to use for the ESCL server. + /// + public EsclSecurityPolicy SecurityPolicy + { + get => _esclServer.SecurityPolicy; + set => _esclServer.SecurityPolicy = value; + } + + /// + /// If non-null, enables TLS connections to the server with the given certificate. + /// + public X509Certificate2? Certificate + { + get => _esclServer.Certificate; + set => _esclServer.Certificate = value; + } + + internal ScanController ScanController { get; set; } + public void SetDefaultIcon(IMemoryImage icon) => SetDefaultIcon(icon.SaveToMemoryStream(ImageFileFormat.Png).ToArray()); public void SetDefaultIcon(byte[] iconPng) => _defaultIconPng = iconPng; - public void RegisterDevice(ScanDevice device, string? displayName = null, int port = 0) => - RegisterDevice(new ScanServerDevice { Device = device, Name = displayName ?? device.Name, Port = port }); + public void RegisterDevice(ScanDevice device, string? displayName = null, int port = 0, int tlsPort = 0) => + RegisterDevice(new ScanServerDevice + { Device = device, Name = displayName ?? device.Name, Port = port, TlsPort = tlsPort }); private void RegisterDevice(ScanServerDevice sharedDevice) { @@ -60,6 +80,7 @@ public class ScanServer : IDisposable return new EsclDeviceConfig { Port = device.Port, + TlsPort = device.TlsPort, Capabilities = new EsclCapabilities { MakeAndModel = device.Name, diff --git a/NAPS2.Sdk/Remoting/Server/ScanServerDevice.cs b/NAPS2.Sdk/Remoting/Server/ScanServerDevice.cs index 6948542e8..3f30abd7e 100644 --- a/NAPS2.Sdk/Remoting/Server/ScanServerDevice.cs +++ b/NAPS2.Sdk/Remoting/Server/ScanServerDevice.cs @@ -9,6 +9,7 @@ internal record ScanServerDevice public required string Name { get; init; } public required ScanDevice Device { get; init; } public int Port { get; init; } + public int TlsPort { get; init; } public string GetUuid(Guid instanceId) { diff --git a/NAPS2.Sdk/Scan/EsclOptions.cs b/NAPS2.Sdk/Scan/EsclOptions.cs index 044c8e724..fcb652f5a 100644 --- a/NAPS2.Sdk/Scan/EsclOptions.cs +++ b/NAPS2.Sdk/Scan/EsclOptions.cs @@ -1,4 +1,6 @@ -namespace NAPS2.Scan; +using NAPS2.Escl; + +namespace NAPS2.Scan; /// /// Scanning options specific to the ESCL driver. @@ -9,4 +11,9 @@ public class EsclOptions /// The maximum time (in ms) to search for ESCL devices when calling GetDevices or at the start of a scan. /// public int SearchTimeout { get; set; } = 5000; + + /// + /// The security policy to use for ESCL connections. + /// + public EsclSecurityPolicy SecurityPolicy { get; set; } } \ No newline at end of file diff --git a/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs b/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs index 22d909617..9b7370a3f 100644 --- a/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs +++ b/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs @@ -46,6 +46,7 @@ internal class EsclScanDriver : IScanDriver var localIPsTask = options.ExcludeLocalIPs ? LocalIPsHelper.Get() : null; using var locator = new EsclServiceLocator(service => { + // TODO: Consider limiting available devices by security policy var ip = service.IpV4 ?? service.IpV6!; if (options.ExcludeLocalIPs && localIPsTask!.Result.Contains(ip.ToString())) { @@ -83,6 +84,7 @@ internal class EsclScanDriver : IScanDriver var client = new EsclClient(service) { + SecurityPolicy = options.EsclOptions.SecurityPolicy, Logger = _logger, CancelToken = cancelToken }; diff --git a/NAPS2.Setup/appsettings.xml b/NAPS2.Setup/appsettings.xml index 19237c2f1..35b58047f 100644 --- a/NAPS2.Setup/appsettings.xml +++ b/NAPS2.Setup/appsettings.xml @@ -37,6 +37,8 @@ Fast true Default + None + None