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

View File

@ -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<EsclDeviceConfig, DeviceContext> _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);
}

View File

@ -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<string, ServiceProfile> _serviceProfiles = new();
private readonly Dictionary<string, ServiceProfile> _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()

View File

@ -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<EsclSecurityPolicyViolationException>(() => server.Start());
}
}

View File

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

View File

@ -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<EsclCapabilities> 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<string> 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<XDocument> 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<T> WithHttpFallback<T>(Func<string> urlFunc, Func<string, Task<T>> 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;

View File

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

View File

@ -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<string, string>();
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"],

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 int Port { get; set; }
public int TlsPort { get; set; }
}

View File

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

View File

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

View File

@ -120,6 +120,8 @@ public class ConfigSerializer : VersionedSerializer<ConfigStorage<CommonConfig>>
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;

View File

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

View File

@ -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 == "")

View File

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

View File

@ -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<SharingConfig> _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<SharingConfig>(),
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);

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
KeepInitialized = false
},
EsclOptions =
{
SecurityPolicy = _config.Get(c => c.EsclSecurityPolicy)
},
KeyValueOptions = scanProfile.KeyValueOptions != null
? new KeyValueScanOptions(scanProfile.KeyValueOptions)
: new KeyValueScanOptions(),

View File

@ -138,5 +138,15 @@ namespace NAPS2.Sdk.Tests {
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">
<value>Resources\ocr_test_hebrew.jpg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</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>

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.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<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]
public async Task FindDevice()
{
@ -212,4 +174,18 @@ public class ScanServerIntegrationTests : ContextualTests
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));
}
[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.Server;
using NAPS2.Scan;
@ -22,21 +23,40 @@ public class ScanServer : IDisposable
ScanController = new ScanController(scanningContext);
}
internal ScanController ScanController { get; set; }
/// <summary>
/// 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.
/// </summary>
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) =>
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,

View File

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

View File

@ -1,4 +1,6 @@
namespace NAPS2.Scan;
using NAPS2.Escl;
namespace NAPS2.Scan;
/// <summary>
/// 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.
/// </summary>
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;
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
};

View File

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