Escl: HTTPS support, security policies, and HTTPS->HTTP fallback

#338
This commit is contained in:
Ben Olden-Cooligan 2024-03-27 19:36:46 -07:00
parent 79bba70370
commit c07bcddf3b
34 changed files with 678 additions and 117 deletions

View File

@ -14,12 +14,15 @@ internal class EsclApiController : WebApiController
private readonly EsclDeviceConfig _deviceConfig; private readonly EsclDeviceConfig _deviceConfig;
private readonly EsclServerState _serverState; private readonly EsclServerState _serverState;
private readonly EsclSecurityPolicy _securityPolicy;
private readonly ILogger _logger; private readonly ILogger _logger;
internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState, ILogger logger) internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState,
EsclSecurityPolicy securityPolicy, ILogger logger)
{ {
_deviceConfig = deviceConfig; _deviceConfig = deviceConfig;
_serverState = serverState; _serverState = serverState;
_securityPolicy = securityPolicy;
_logger = logger; _logger = logger;
} }
@ -27,7 +30,8 @@ internal class EsclApiController : WebApiController
public async Task GetScannerCapabilities() public async Task GetScannerCapabilities()
{ {
var caps = _deviceConfig.Capabilities; 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 = var doc =
EsclXmlHelper.CreateDocAsString( EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerCapabilities", new XElement(ScanNs + "ScannerCapabilities",
@ -169,7 +173,13 @@ internal class EsclApiController : WebApiController
_serverState.IsProcessing = true; _serverState.IsProcessing = true;
var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings)); var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
_serverState.AddJob(jobInfo); _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 Response.StatusCode = 201; // Created
} }

View File

@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using EmbedIO; using EmbedIO;
using EmbedIO.WebApi; using EmbedIO.WebApi;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -11,11 +12,15 @@ public class EsclServer : IEsclServer
{ {
Swan.Logging.Logger.NoLogging(); Swan.Logging.Logger.NoLogging();
} }
private readonly Dictionary<EsclDeviceConfig, DeviceContext> _devices = new(); private readonly Dictionary<EsclDeviceConfig, DeviceContext> _devices = new();
private bool _started; private bool _started;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
public EsclSecurityPolicy SecurityPolicy { get; set; }
public X509Certificate2? Certificate { get; set; }
public ILogger Logger { get; set; } = NullLogger.Instance; public ILogger Logger { get; set; } = NullLogger.Instance;
public void AddDevice(EsclDeviceConfig deviceConfig) public void AddDevice(EsclDeviceConfig deviceConfig)
@ -45,6 +50,11 @@ public class EsclServer : IEsclServer
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) && Certificate == null)
{
throw new EsclSecurityPolicyViolationException(
$"EsclSecurityPolicy of {SecurityPolicy} needs a certificate to be specified");
}
_started = true; _started = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
@ -63,24 +73,39 @@ public class EsclServer : IEsclServer
var cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, deviceCtx.Cts.Token).Token; 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 // 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. // 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); await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.Port, async port =>
deviceCtx.Config.Port = port; {
}, cancelToken); await StartServer(deviceCtx, port, false, cancelToken);
deviceCtx.Advertiser.AdvertiseDevice(deviceCtx.Config); 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(); deviceCtx.ServerState = new EsclServerState();
var server = new WebServer(o => o var server = new WebServer(o => o
.WithMode(HttpListenerMode.EmbedIO) .WithMode(HttpListenerMode.EmbedIO)
.WithUrlPrefix(url)) .WithUrlPrefix(url)
.WithCertificate((tls ? Certificate : null)!))
.HandleUnhandledException(UnhandledServerException) .HandleUnhandledException(UnhandledServerException)
.WithWebApi("/eSCL", .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); await server.StartAsync(cancelToken);
} }

View File

@ -1,4 +1,5 @@
using Makaretu.Dns; using Makaretu.Dns;
using Makaretu.Dns.Resolving;
namespace NAPS2.Escl.Server; namespace NAPS2.Escl.Server;
@ -6,50 +7,141 @@ public class MdnsAdvertiser : IDisposable
{ {
private readonly ServiceDiscovery _sd; private readonly ServiceDiscovery _sd;
private readonly Dictionary<string, ServiceProfile> _serviceProfiles = new(); private readonly Dictionary<string, ServiceProfile> _serviceProfiles = new();
private readonly Dictionary<string, ServiceProfile> _serviceProfiles2 = new();
public MdnsAdvertiser() public MdnsAdvertiser()
{ {
_sd = new ServiceDiscovery(); _sd = new ServiceDiscovery();
} }
public void AdvertiseDevice(EsclDeviceConfig deviceConfig) public void AdvertiseDevice(EsclDeviceConfig deviceConfig, bool hasHttp, bool hasHttps)
{ {
var caps = deviceConfig.Capabilities; var caps = deviceConfig.Capabilities;
if (caps.Uuid == null) if (caps.Uuid == null)
{ {
throw new ArgumentException("UUID must be specified"); throw new ArgumentException("UUID must be specified");
} }
if (!hasHttp && !hasHttps)
{
return;
}
var name = caps.MakeAndModel; 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}"; var domain = $"naps2-{caps.Uuid}";
service.HostName = DomainName.Join(domain, service.Domain); var hostName = DomainName.Join(domain, service.Domain);
service.AddProperty("txtvers", "1");
service.AddProperty("Vers", "2.0"); // TODO: verify // 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) 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"); record.Strings.Add("rs=eSCL");
service.AddProperty("ty", name); record.Strings.Add($"ty={name}");
service.AddProperty("pdl", "application/pdf,image/jpeg,image/png"); record.Strings.Add("pdl=application/pdf,image/jpeg,image/png");
// TODO: Actual adf/duplex, etc. // TODO: Actual adf/duplex, etc.
service.AddProperty("uuid", caps.Uuid); record.Strings.Add($"uuid={caps.Uuid}");
service.AddProperty("cs", "color,grayscale,binary"); record.Strings.Add("cs=color,grayscale,binary");
service.AddProperty("is", "platen"); // and ,adf record.Strings.Add("is=platen"); // and ,adf
service.AddProperty("duplex", "F"); record.Strings.Add("duplex=F");
_sd.Announce(service); return record;
_sd.Advertise(service);
_serviceProfiles.Add(caps.Uuid, service);
} }
public void UnadvertiseDevice(EsclDeviceConfig deviceConfig) 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"); throw new ArgumentException("UUID must be specified");
} }
_sd.Unadvertise(_serviceProfiles[deviceConfig.Capabilities.Uuid]); if (_serviceProfiles.ContainsKey(uuid))
_serviceProfiles.Remove(deviceConfig.Capabilities.Uuid); {
_sd.Unadvertise(_serviceProfiles[uuid]);
_serviceProfiles.Remove(uuid);
}
if (_serviceProfiles2.ContainsKey(uuid))
{
_sd.Unadvertise(_serviceProfiles2[uuid]);
_serviceProfiles2.Remove(uuid);
}
} }
public void Dispose() public void Dispose()

View File

@ -34,6 +34,7 @@ public class ClientServerTests
Host = $"[{IPAddress.IPv6Loopback}]", Host = $"[{IPAddress.IPv6Loopback}]",
RemoteEndpoint = IPAddress.IPv6Loopback, RemoteEndpoint = IPAddress.IPv6Loopback,
Port = deviceConfig.Port, Port = deviceConfig.Port,
TlsPort = deviceConfig.TlsPort,
RootUrl = "eSCL", RootUrl = "eSCL",
Tls = false, Tls = false,
Uuid = uuid Uuid = uuid
@ -43,4 +44,12 @@ public class ClientServerTests
Assert.Equal("HP Blah", caps.MakeAndModel); Assert.Equal("HP Blah", caps.MakeAndModel);
Assert.Equal("123abc", caps.SerialNumber); Assert.Equal("123abc", caps.SerialNumber);
} }
[Fact]
public async Task StartTlsServerWithoutCertificate()
{
using var server = new EsclServer();
server.SecurityPolicy = EsclSecurityPolicy.RequireHttps;
await Assert.ThrowsAsync<EsclSecurityPolicyViolationException>(() => server.Start());
}
} }

View File

@ -52,6 +52,7 @@ public class EsclUsbContext : IDisposable
Host = IPAddress.Loopback.ToString(), Host = IPAddress.Loopback.ToString(),
RemoteEndpoint = IPAddress.Loopback, RemoteEndpoint = IPAddress.Loopback,
Port = port, Port = port,
TlsPort = 0,
RootUrl = "eSCL", RootUrl = "eSCL",
Tls = false, Tls = false,
Uuid = Guid.Empty.ToString("D") Uuid = Guid.Empty.ToString("D")

View File

@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Security.Authentication;
using System.Text; using System.Text;
using System.Xml.Linq; using System.Xml.Linq;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -13,7 +14,13 @@ public class EsclClient
private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs; private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs; 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 // 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. // 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 // 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). // 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 UnverifiedHttpClient = new(UnverifiedHttpClientHandler);
private static readonly HttpClient ProgressHttpClient = new(HttpClientHandler); private static readonly HttpClient UnverifiedProgressHttpClient = new(UnverifiedHttpClientHandler);
private static readonly HttpClient DocumentHttpClient = new(HttpClientHandler); private static readonly HttpClient UnverifiedDocumentHttpClient = new(UnverifiedHttpClientHandler);
private readonly EsclService _service; private readonly EsclService _service;
private bool _httpFallback;
public EsclClient(EsclService service) public EsclClient(EsclService service)
{ {
_service = service; _service = service;
} }
public EsclSecurityPolicy SecurityPolicy { get; set; }
public ILogger Logger { get; set; } = NullLogger.Instance; public ILogger Logger { get; set; } = NullLogger.Instance;
public CancellationToken CancelToken { get; set; } 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<EsclCapabilities> GetCapabilities() public async Task<EsclCapabilities> GetCapabilities()
{ {
var doc = await DoRequest("ScannerCapabilities"); var doc = await DoRequest("ScannerCapabilities");
@ -95,9 +119,13 @@ public class EsclClient
OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor), OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor),
new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat))); new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat)));
var content = new StringContent(doc, Encoding.UTF8, "text/xml"); var content = new StringContent(doc, Encoding.UTF8, "text/xml");
var url = GetUrl($"/{_service.RootUrl}/ScanJobs"); var response = await WithHttpFallback(
Logger.LogDebug("ESCL POST {Url}", url); () => GetUrl($"/{_service.RootUrl}/ScanJobs"),
var response = await HttpClient.PostAsync(url, content); url =>
{
Logger.LogDebug("ESCL POST {Url}", url);
return HttpClient.PostAsync(url, content);
});
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
Logger.LogDebug("POST OK"); Logger.LogDebug("POST OK");
@ -142,9 +170,13 @@ public class EsclClient
try try
{ {
// TODO: Maybe check Content-Location on the response header to ensure no duplicate document? // TODO: Maybe check Content-Location on the response header to ensure no duplicate document?
var url = GetUrl($"{job.UriPath}/NextDocument"); var response = await WithHttpFallback(
Logger.LogDebug("ESCL GET {Url}", url); () => GetUrl($"{job.UriPath}/NextDocument"),
var response = await DocumentHttpClient.GetAsync(url); url =>
{
Logger.LogDebug("ESCL GET {Url}", url);
return DocumentHttpClient.GetAsync(url);
});
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
{ {
// NotFound = end of scan, Gone = canceled // NotFound = end of scan, Gone = canceled
@ -170,9 +202,13 @@ public class EsclClient
public async Task<string> ErrorDetails(EsclJob job) public async Task<string> ErrorDetails(EsclJob job)
{ {
var url = GetUrl($"{job.UriPath}/ErrorDetails"); var response = await WithHttpFallback(
Logger.LogDebug("ESCL GET {Url}", url); () => GetUrl($"{job.UriPath}/ErrorDetails"),
var response = await HttpClient.GetAsync(url); url =>
{
Logger.LogDebug("ESCL GET {Url}", url);
return HttpClient.GetAsync(url);
});
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
Logger.LogDebug("GET OK"); Logger.LogDebug("GET OK");
return await response.Content.ReadAsStringAsync(); return await response.Content.ReadAsStringAsync();
@ -180,9 +216,13 @@ public class EsclClient
public async Task CancelJob(EsclJob job) public async Task CancelJob(EsclJob job)
{ {
var url = GetUrl(job.UriPath); var response = await WithHttpFallback(
Logger.LogDebug("ESCL DELETE {Url}", url); () => GetUrl(job.UriPath),
var response = await HttpClient.DeleteAsync(url); url =>
{
Logger.LogDebug("ESCL DELETE {Url}", url);
return HttpClient.DeleteAsync(url);
});
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
Logger.LogDebug("DELETE failed: {Status}", response.StatusCode); Logger.LogDebug("DELETE failed: {Status}", response.StatusCode);
@ -195,9 +235,13 @@ public class EsclClient
private async Task<XDocument> DoRequest(string endpoint) private async Task<XDocument> DoRequest(string endpoint)
{ {
// TODO: Retry logic // TODO: Retry logic
var url = GetUrl($"/{_service.RootUrl}/{endpoint}"); var response = await WithHttpFallback(
Logger.LogDebug("ESCL GET {Url}", url); () => GetUrl($"/{_service.RootUrl}/{endpoint}"),
var response = await HttpClient.GetAsync(url, CancelToken); url =>
{
Logger.LogDebug("ESCL GET {Url}", url);
return HttpClient.GetAsync(url, CancelToken);
});
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
Logger.LogDebug("GET OK"); Logger.LogDebug("GET OK");
var text = await response.Content.ReadAsStringAsync(); var text = await response.Content.ReadAsStringAsync();
@ -205,20 +249,47 @@ public class EsclClient
return doc; return doc;
} }
private string GetUrl(string endpoint) private async Task<T> WithHttpFallback<T>(Func<string> urlFunc, Func<string, Task<T>> func)
{ {
var protocol = _service.Tls || _service.Port == 443 ? "https" : "http"; string url = urlFunc();
return $"{protocol}://{GetHostAndPort()}{endpoint}"; 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 NET6_0_OR_GREATER
if (OperatingSystem.IsMacOS()) if (OperatingSystem.IsMacOS())
{ {
// Using the mDNS hostname is more reliable on Mac (but doesn't work at all on Windows) // 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 #endif
return host; return host;

View File

@ -25,10 +25,15 @@ public class EsclService
public required IPAddress RemoteEndpoint { get; init; } public required IPAddress RemoteEndpoint { get; init; }
/// <summary> /// <summary>
/// The port of the ESCL service. /// The HTTP port of the ESCL service.
/// </summary> /// </summary>
public required int Port { get; init; } public required int Port { get; init; }
/// <summary>
/// The HTTPS port of the ESCL service.
/// </summary>
public required int TlsPort { get; init; }
/// <summary> /// <summary>
/// Whether to use HTTPS for the connection. /// Whether to use HTTPS for the connection.
/// </summary> /// </summary>

View File

@ -19,15 +19,24 @@ public class EsclServiceLocator : IDisposable
{ {
try try
{ {
var service = ParseService(args); if (args.ServiceInstanceName.Labels[1] is not ("_uscan" or "_uscans"))
var serviceKey = new ServiceKey(service.ScannerName, service.Uuid, service.Port, service.IpV4, service.IpV6);
if (!_locatedServices.Add(serviceKey))
{ {
// Don't callback for duplicates
return; return;
} }
Logger.LogDebug("Discovered ESCL Service: {Name}, instance {Instance}, endpoint {Endpoint}, ipv4 {Ipv4}, ipv6 {IpV6}, host {Host}, port {Port}, uuid {Uuid}", var service = ParseService(args);
service.ScannerName, args.ServiceInstanceName, args.RemoteEndPoint, service.IpV4, service.IpV6, service.Host, service.Port, service.Uuid); // 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); serviceCallback(service);
} }
catch (Exception ex) catch (Exception ex)
@ -79,6 +88,7 @@ public class EsclServiceLocator : IDisposable
bool isTls = false; bool isTls = false;
IPAddress? ipv4 = null, ipv6 = null; IPAddress? ipv4 = null, ipv6 = null;
int port = -1; int port = -1;
int tlsPort = -1;
string? host = null; string? host = null;
var props = new Dictionary<string, string>(); var props = new Dictionary<string, string>();
foreach (var record in args.Message.AdditionalRecords) foreach (var record in args.Message.AdditionalRecords)
@ -95,10 +105,17 @@ public class EsclServiceLocator : IDisposable
if (record is SRVRecord srv) if (record is SRVRecord srv)
{ {
bool recordIsTls = srv.Name.IsSubdomainOf(DomainName.Join("_uscans", "_tcp", "local")); bool recordIsTls = srv.Name.IsSubdomainOf(DomainName.Join("_uscans", "_tcp", "local"));
if (recordIsTls)
{
tlsPort = srv.Port;
}
else
{
port = srv.Port;
}
if (host == null || recordIsTls) if (host == null || recordIsTls)
{ {
// HTTPS overrides HTTP but not the other way around // HTTPS overrides HTTP but not the other way around
port = srv.Port;
host = srv.Target.ToString(); host = srv.Target.ToString();
isTls = recordIsTls; isTls = recordIsTls;
} }
@ -116,7 +133,7 @@ public class EsclServiceLocator : IDisposable
} }
} }
string? uuid = Get(props, "uuid"); 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"); throw new ArgumentException("Missing host/IP/port/uuid");
} }
@ -128,6 +145,7 @@ public class EsclServiceLocator : IDisposable
Host = host, Host = host,
RemoteEndpoint = args.RemoteEndPoint.Address, RemoteEndpoint = args.RemoteEndPoint.Address,
Port = port, Port = port,
TlsPort = tlsPort,
Tls = isTls, Tls = isTls,
Uuid = uuid, Uuid = uuid,
ScannerName = props["ty"], ScannerName = props["ty"],

View File

@ -0,0 +1,24 @@
namespace NAPS2.Escl;
[Flags]
public enum EsclSecurityPolicy
{
/// <summary>
/// Allow both HTTP and HTTPS connections.
/// </summary>
None = 0,
ServerRequireHttps = 1,
ClientRequireHttps = 2,
ClientRequireHttpOrTrustedCertificate = 4,
/// <summary>
/// Only allow HTTPS connections, but clients will accept self-signed certificates.
/// </summary>
RequireHttps = ServerRequireHttps | ClientRequireHttps,
/// <summary>
/// Only allow HTTPS connections, and clients will only accept trusted certificates.
/// </summary>
RequireTrustedCertificate = RequireHttps | ClientRequireHttpOrTrustedCertificate
}

View File

@ -0,0 +1,3 @@
namespace NAPS2.Escl;
public class EsclSecurityPolicyViolationException(string message) : Exception(message);

View File

@ -7,4 +7,6 @@ public class EsclDeviceConfig
public required Func<EsclScanSettings, IEsclScanJob> CreateJob { get; init; } public required Func<EsclScanSettings, IEsclScanJob> CreateJob { get; init; }
public int Port { get; set; } public int Port { get; set; }
public int TlsPort { get; set; }
} }

View File

@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace NAPS2.Escl.Server; namespace NAPS2.Escl.Server;
@ -8,5 +9,7 @@ public interface IEsclServer : IDisposable
void RemoveDevice(EsclDeviceConfig deviceConfig); void RemoveDevice(EsclDeviceConfig deviceConfig);
Task Start(); Task Start();
Task Stop(); Task Stop();
public EsclSecurityPolicy SecurityPolicy { get; set; }
public X509Certificate2? Certificate { get; set; }
ILogger Logger { get; set; } ILogger Logger { get; set; }
} }

View File

@ -1,10 +1,10 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using NAPS2.Config.Model; using NAPS2.Config.Model;
using NAPS2.Escl;
using NAPS2.ImportExport.Email; using NAPS2.ImportExport.Email;
using NAPS2.ImportExport.Images; using NAPS2.ImportExport.Images;
using NAPS2.Pdf; using NAPS2.Pdf;
using NAPS2.Ocr; using NAPS2.Ocr;
using NAPS2.Remoting.Server;
using NAPS2.Scan; using NAPS2.Scan;
using NAPS2.Scan.Batch; using NAPS2.Scan.Batch;
@ -147,6 +147,12 @@ public class CommonConfig
[Common] [Common]
public DockStyle ProfilesToolStripDock { get; set; } public DockStyle ProfilesToolStripDock { get; set; }
[Common]
public EsclSecurityPolicy EsclSecurityPolicy { get; set; }
[Common]
public string? EsclServerCertificatePath { get; set; }
[App] [App]
public EventType EventLogging { get; set; } public EventType EventLogging { get; set; }

View File

@ -120,6 +120,8 @@ public class ConfigSerializer : VersionedSerializer<ConfigStorage<CommonConfig>>
storage.Set(x => x.OcrLanguageCode, c.OcrDefaultLanguage); storage.Set(x => x.OcrLanguageCode, c.OcrDefaultLanguage);
storage.Set(x => x.OcrMode, c.OcrDefaultMode); storage.Set(x => x.OcrMode, c.OcrDefaultMode);
storage.Set(x => x.OcrAfterScanning, c.OcrDefaultAfterScanning); 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.EventLogging, c.EventLogging);
storage.Set(x => x.KeyboardShortcuts, c.KeyboardShortcuts ?? new KeyboardShortcuts()); storage.Set(x => x.KeyboardShortcuts, c.KeyboardShortcuts ?? new KeyboardShortcuts());
return storage; return storage;

View File

@ -1,4 +1,5 @@
using System.Xml.Serialization; using System.Xml.Serialization;
using NAPS2.Escl;
using NAPS2.Ocr; using NAPS2.Ocr;
using NAPS2.Pdf; using NAPS2.Pdf;
using NAPS2.Scan; using NAPS2.Scan;
@ -84,6 +85,10 @@ public class AppConfigV0
public PdfCompat ForcePdfCompat { get; set; } public PdfCompat ForcePdfCompat { get; set; }
public EsclSecurityPolicy EsclSecurityPolicy { get; set; }
public string? EsclServerCertificatePath { get; set; }
public EventType EventLogging { get; set; } public EventType EventLogging { get; set; }
public KeyboardShortcuts? KeyboardShortcuts { get; set; } public KeyboardShortcuts? KeyboardShortcuts { get; set; }

View File

@ -3,7 +3,6 @@ using Eto.Forms;
using NAPS2.EtoForms.Layout; using NAPS2.EtoForms.Layout;
using NAPS2.Remoting.Server; using NAPS2.Remoting.Server;
using NAPS2.Scan; using NAPS2.Scan;
using NAPS2.Scan.Exceptions;
using NAPS2.Scan.Internal; using NAPS2.Scan.Internal;
namespace NAPS2.EtoForms.Ui; namespace NAPS2.EtoForms.Ui;
@ -11,6 +10,7 @@ namespace NAPS2.EtoForms.Ui;
public class SharedDeviceForm : EtoDialogBase public class SharedDeviceForm : EtoDialogBase
{ {
private const int BASE_PORT = 9801; private const int BASE_PORT = 9801;
private const int BASE_TLS_PORT = 9901;
private readonly IScanPerformer _scanPerformer; private readonly IScanPerformer _scanPerformer;
private readonly ErrorOutput _errorOutput; private readonly ErrorOutput _errorOutput;
@ -121,6 +121,8 @@ public class SharedDeviceForm : EtoDialogBase
private int Port { get; set; } private int Port { get; set; }
private int TlsPort { get; set; }
private Driver DeviceDriver private Driver DeviceDriver
{ {
get => _twainDriver.Checked ? Driver.Twain get => _twainDriver.Checked ? Driver.Twain
@ -159,6 +161,7 @@ public class SharedDeviceForm : EtoDialogBase
_displayName.Text = SharedDevice?.Name ?? ""; _displayName.Text = SharedDevice?.Name ?? "";
CurrentDevice ??= SharedDevice?.Device; CurrentDevice ??= SharedDevice?.Device;
Port = SharedDevice?.Port ?? 0; Port = SharedDevice?.Port ?? 0;
TlsPort = SharedDevice?.TlsPort ?? 0;
DeviceDriver = SharedDevice?.Device.Driver ?? ScanOptionsValidator.SystemDefaultDriver; DeviceDriver = SharedDevice?.Device.Driver ?? ScanOptionsValidator.SystemDefaultDriver;
@ -187,7 +190,8 @@ public class SharedDeviceForm : EtoDialogBase
{ {
Name = _displayName.Text, Name = _displayName.Text,
Device = CurrentDevice!, 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; 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) private void Ok_Click(object? sender, EventArgs e)
{ {
if (_displayName.Text == "") if (_displayName.Text == "")

View File

@ -7,6 +7,7 @@ public record SharedDevice
public required string Name { get; init; } public required string Name { get; init; }
public required ScanDevice Device { get; init; } public required ScanDevice Device { get; init; }
public int Port { get; init; } public int Port { get; init; }
public int TlsPort { get; init; }
public virtual bool Equals(SharedDevice? other) => public virtual bool Equals(SharedDevice? other) =>
other is not null && Name == other.Name && Device == other.Device; other is not null && Name == other.Name && Device == other.Device;

View File

@ -1,5 +1,7 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Security.Cryptography.X509Certificates;
using System.Threading; using System.Threading;
using Microsoft.Extensions.Logging;
using NAPS2.Config.Model; using NAPS2.Config.Model;
using NAPS2.Escl.Server; using NAPS2.Escl.Server;
using NAPS2.Scan; using NAPS2.Scan;
@ -10,6 +12,7 @@ public class SharedDeviceManager : ISharedDeviceManager
{ {
private const int STARTUP_RETRY_INTERVAL = 10_000; private const int STARTUP_RETRY_INTERVAL = 10_000;
private readonly ILogger _logger;
private readonly Naps2Config _config; private readonly Naps2Config _config;
private readonly FileConfigScope<SharingConfig> _scope; private readonly FileConfigScope<SharingConfig> _scope;
private readonly ScanServer _server; private readonly ScanServer _server;
@ -19,11 +22,30 @@ public class SharedDeviceManager : ISharedDeviceManager
public SharedDeviceManager(ScanningContext scanningContext, Naps2Config config, string sharedDevicesConfigPath) public SharedDeviceManager(ScanningContext scanningContext, Naps2Config config, string sharedDevicesConfigPath)
{ {
_logger = scanningContext.Logger;
_config = config; _config = config;
_scope = ConfigScope.File(sharedDevicesConfigPath, new ConfigStorageSerializer<SharingConfig>(), _scope = ConfigScope.File(sharedDevicesConfigPath, new ConfigStorageSerializer<SharingConfig>(),
ConfigScopeMode.ReadWrite); ConfigScopeMode.ReadWrite);
_server = new ScanServer(scanningContext, new EsclServer()); _server = new ScanServer(scanningContext, new EsclServer());
_server.SetDefaultIcon(Icons.scanner_128); _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(); _server.InstanceId = _scope.GetOrDefault(c => c.InstanceId) ?? Guid.NewGuid();
RegisterDevicesFromConfig(); RegisterDevicesFromConfig();
} }
@ -54,7 +76,8 @@ public class SharedDeviceManager : ISharedDeviceManager
if (_userStarted && SharedDevices.Any() && TakeLock()) if (_userStarted && SharedDevices.Any() && TakeLock())
{ {
ResetStartTimer(); ResetStartTimer();
_server.Start(); _server.Start().ContinueWith(t =>
_logger.LogError(t.Exception, "Error starting ScanServer"), TaskContinuationOptions.OnlyOnFaulted);
return true; return true;
} }
return false; return false;
@ -67,7 +90,8 @@ public class SharedDeviceManager : ISharedDeviceManager
{ {
_userStarted = false; _userStarted = false;
ResetStartTimer(); ResetStartTimer();
_server.Stop(); _server.Stop().ContinueWith(t =>
_logger.LogError(t.Exception, "Error starting ScanServer"), TaskContinuationOptions.OnlyOnFaulted);
ReleaseLock(); ReleaseLock();
} }
} }
@ -162,7 +186,7 @@ public class SharedDeviceManager : ISharedDeviceManager
} }
private void RegisterOnServer(SharedDevice device) => 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) => private void UnregisterOnServer(SharedDevice device) =>
_server.UnregisterDevice(device.Device, device.Name); _server.UnregisterDevice(device.Device, device.Name);

View File

@ -242,6 +242,10 @@ internal class ScanPerformer : IScanPerformer
// We use a worker process for SANE so we should clean up after each operation // We use a worker process for SANE so we should clean up after each operation
KeepInitialized = false KeepInitialized = false
}, },
EsclOptions =
{
SecurityPolicy = _config.Get(c => c.EsclSecurityPolicy)
},
KeyValueOptions = scanProfile.KeyValueOptions != null KeyValueOptions = scanProfile.KeyValueOptions != null
? new KeyValueScanOptions(scanProfile.KeyValueOptions) ? new KeyValueScanOptions(scanProfile.KeyValueOptions)
: new KeyValueScanOptions(), : new KeyValueScanOptions(),

View File

@ -138,5 +138,15 @@ namespace NAPS2.Sdk.Tests {
return ((byte[])(obj)); return ((byte[])(obj));
} }
} }
/// <summary>
/// Looks up a localized resource of type System.Byte[].
/// </summary>
internal static byte[] testcert {
get {
object obj = ResourceManager.GetObject("testcert", resourceCulture);
return ((byte[])(obj));
}
}
} }
} }

View File

@ -142,4 +142,7 @@
<data name="ocr_test_hebrew" type="System.Resources.ResXFileRef, System.Windows.Forms"> <data name="ocr_test_hebrew" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>Resources\ocr_test_hebrew.jpg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>Resources\ocr_test_hebrew.jpg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data> </data>
<data name="testcert" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>Resources\testcert.pfx;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data>
</root> </root>

View File

@ -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<HttpRequestException>(async () => await scanResult.ToListAsync());
Assert.True(exception.InnerException is AuthenticationException ||
exception.InnerException?.InnerException is AuthenticationException);
}
}

View File

@ -1,53 +1,15 @@
using Microsoft.Extensions.Logging; using NAPS2.Escl;
using NAPS2.Escl.Server;
using NAPS2.Remoting.Server;
using NAPS2.Scan; using NAPS2.Scan;
using NAPS2.Scan.Exceptions; using NAPS2.Scan.Exceptions;
using NAPS2.Scan.Internal;
using NAPS2.Sdk.Tests.Asserts; using NAPS2.Sdk.Tests.Asserts;
using NAPS2.Sdk.Tests.Mocks;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
namespace NAPS2.Sdk.Tests.Remoting; 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<IScanBridgeFactory>();
scanBridgeFactory.Create(Arg.Any<ScanOptions>()).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] [Fact]
public async Task FindDevice() public async Task FindDevice()
{ {
@ -212,4 +174,18 @@ public class ScanServerIntegrationTests : ContextualTests
pageStartMock.Received()(Arg.Any<object>(), Arg.Is<PageStartEventArgs>(args => args.PageNumber == 2)); pageStartMock.Received()(Arg.Any<object>(), Arg.Is<PageStartEventArgs>(args => args.PageNumber == 2));
pageProgressMock.Received()(Arg.Any<object>(), Arg.Is<PageProgressEventArgs>(args => args.PageNumber == 2 && args.Progress == 0.5)); pageProgressMock.Received()(Arg.Any<object>(), Arg.Is<PageProgressEventArgs>(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<EsclSecurityPolicyViolationException>(async () => await scanResult.ToListAsync());
}
} }

View File

@ -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<IScanBridgeFactory>();
scanBridgeFactory.Create(Arg.Any<ScanOptions>()).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();
}
}

View File

@ -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<HttpRequestException>(async () => await scanResult.ToListAsync());
Assert.True(exception.InnerException is AuthenticationException ||
exception.InnerException?.InnerException is AuthenticationException);
}
}

View File

@ -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-----

View File

@ -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-----

View File

@ -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-----

Binary file not shown.

View File

@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using NAPS2.Escl; using NAPS2.Escl;
using NAPS2.Escl.Server; using NAPS2.Escl.Server;
using NAPS2.Scan; using NAPS2.Scan;
@ -22,21 +23,40 @@ public class ScanServer : IDisposable
ScanController = new ScanController(scanningContext); ScanController = new ScanController(scanningContext);
} }
internal ScanController ScanController { get; set; }
/// <summary> /// <summary>
/// A unique ID that is used to help derive the UUIDs for shared scanners. If you expect to have multiple shared /// 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. /// scanners with the same name/model on the same network it may be useful to set this to a unique value.
/// </summary> /// </summary>
public Guid InstanceId { get; set; } public Guid InstanceId { get; set; }
/// <summary>
/// The security policy to use for the ESCL server.
/// </summary>
public EsclSecurityPolicy SecurityPolicy
{
get => _esclServer.SecurityPolicy;
set => _esclServer.SecurityPolicy = value;
}
/// <summary>
/// If non-null, enables TLS connections to the server with the given certificate.
/// </summary>
public X509Certificate2? Certificate
{
get => _esclServer.Certificate;
set => _esclServer.Certificate = value;
}
internal ScanController ScanController { get; set; }
public void SetDefaultIcon(IMemoryImage icon) => public void SetDefaultIcon(IMemoryImage icon) =>
SetDefaultIcon(icon.SaveToMemoryStream(ImageFileFormat.Png).ToArray()); SetDefaultIcon(icon.SaveToMemoryStream(ImageFileFormat.Png).ToArray());
public void SetDefaultIcon(byte[] iconPng) => _defaultIconPng = iconPng; public void SetDefaultIcon(byte[] iconPng) => _defaultIconPng = iconPng;
public void RegisterDevice(ScanDevice device, string? displayName = null, int port = 0) => 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 }); RegisterDevice(new ScanServerDevice
{ Device = device, Name = displayName ?? device.Name, Port = port, TlsPort = tlsPort });
private void RegisterDevice(ScanServerDevice sharedDevice) private void RegisterDevice(ScanServerDevice sharedDevice)
{ {
@ -60,6 +80,7 @@ public class ScanServer : IDisposable
return new EsclDeviceConfig return new EsclDeviceConfig
{ {
Port = device.Port, Port = device.Port,
TlsPort = device.TlsPort,
Capabilities = new EsclCapabilities Capabilities = new EsclCapabilities
{ {
MakeAndModel = device.Name, MakeAndModel = device.Name,

View File

@ -9,6 +9,7 @@ internal record ScanServerDevice
public required string Name { get; init; } public required string Name { get; init; }
public required ScanDevice Device { get; init; } public required ScanDevice Device { get; init; }
public int Port { get; init; } public int Port { get; init; }
public int TlsPort { get; init; }
public string GetUuid(Guid instanceId) public string GetUuid(Guid instanceId)
{ {

View File

@ -1,4 +1,6 @@
namespace NAPS2.Scan; using NAPS2.Escl;
namespace NAPS2.Scan;
/// <summary> /// <summary>
/// Scanning options specific to the ESCL driver. /// 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. /// The maximum time (in ms) to search for ESCL devices when calling GetDevices or at the start of a scan.
/// </summary> /// </summary>
public int SearchTimeout { get; set; } = 5000; public int SearchTimeout { get; set; } = 5000;
/// <summary>
/// The security policy to use for ESCL connections.
/// </summary>
public EsclSecurityPolicy SecurityPolicy { get; set; }
} }

View File

@ -46,6 +46,7 @@ internal class EsclScanDriver : IScanDriver
var localIPsTask = options.ExcludeLocalIPs ? LocalIPsHelper.Get() : null; var localIPsTask = options.ExcludeLocalIPs ? LocalIPsHelper.Get() : null;
using var locator = new EsclServiceLocator(service => using var locator = new EsclServiceLocator(service =>
{ {
// TODO: Consider limiting available devices by security policy
var ip = service.IpV4 ?? service.IpV6!; var ip = service.IpV4 ?? service.IpV6!;
if (options.ExcludeLocalIPs && localIPsTask!.Result.Contains(ip.ToString())) if (options.ExcludeLocalIPs && localIPsTask!.Result.Contains(ip.ToString()))
{ {
@ -83,6 +84,7 @@ internal class EsclScanDriver : IScanDriver
var client = new EsclClient(service) var client = new EsclClient(service)
{ {
SecurityPolicy = options.EsclOptions.SecurityPolicy,
Logger = _logger, Logger = _logger,
CancelToken = cancelToken CancelToken = cancelToken
}; };

View File

@ -37,6 +37,8 @@
<OcrDefaultMode>Fast</OcrDefaultMode> <OcrDefaultMode>Fast</OcrDefaultMode>
<OcrDefaultAfterScanning>true</OcrDefaultAfterScanning> <OcrDefaultAfterScanning>true</OcrDefaultAfterScanning>
<ForcePdfCompat>Default</ForcePdfCompat> <ForcePdfCompat>Default</ForcePdfCompat>
<EsclSecurityPolicy>None</EsclSecurityPolicy>
<EsclServerCertificatePath></EsclServerCertificatePath>
<EventLogging>None</EventLogging> <EventLogging>None</EventLogging>
<DefaultProfileSettings> <DefaultProfileSettings>
<DriverName></DriverName> <DriverName></DriverName>