Add OCSP failure handling option

This updates the SSL/TLS validation to optionally ignore failures in OCSP/CRL list. If the revocation servers are offline or unavailable, the SSL requests will fail.

With the new option, `--ignore-revocation-failure`, it is now possible to ignore this class of errors and let the connections work anyway.

By default, this is off so we have the strictest possible security.

As part of this PR, there were a few places that did validation/override of certificates. With this change all calls route into the single `SslCertificateValidator` class so all certificate validations are done the same from ServerUtil, reporting modules and backends.
This commit is contained in:
Kenneth Skovhede
2026-06-27 11:43:28 +02:00
parent e91a7ea7d5
commit 92d6d666ee
15 changed files with 670 additions and 78 deletions
@@ -21,6 +21,7 @@
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
using Duplicati.Library.Utility;
namespace Duplicati.CommandLine.ServerUtil.Commands;
@@ -30,12 +31,21 @@ public static class Health
new Command("health", "Checks the server health endpoint")
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
using var client = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = settings.Insecure
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
: null
});
// The health endpoint only needs to know if the server is reachable.
// When the user has not requested any certificate overrides, the
// default OS validation is used by leaving the callback unset.
// Mirror Connection.cs: --host-cert "*" is treated as accept-all, the same
// as --insecure, so the documented wildcard is honored consistently.
var trustedCertificateHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(settings.AcceptedHostCertificate))
trustedCertificateHashes.UnionWith(settings.AcceptedHostCertificate.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
using var handler = new HttpClientHandler();
var acceptAll = settings.Insecure || trustedCertificateHashes.Contains("*");
if (acceptAll || trustedCertificateHashes.Count > 0 || settings.IgnoreRevocationFailure)
HttpClientHelper.ConfigureHandlerCertificateValidator(handler, acceptAll, acceptAll ? null : [.. trustedCertificateHashes], settings.IgnoreRevocationFailure);
using var client = new HttpClient(handler);
client.BaseAddress = settings.HostUrl;
client.Timeout = TimeSpan.FromSeconds(10);
+48 -19
View File
@@ -20,7 +20,6 @@
// DEALINGS IN THE SOFTWARE.
using System.Net.Http.Json;
using System.Net.Security;
using System.Text.Json;
using Duplicati.Library.AutoUpdater;
using Duplicati.WebserverCore.Middlewares;
@@ -169,20 +168,56 @@ public class Connection
if (!string.IsNullOrWhiteSpace(settings.AcceptedHostCertificate))
trustedCertificateHashes.UnionWith(settings.AcceptedHostCertificate.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
// Configure the client for requests
var client = new HttpClient(new HttpClientHandler
// If we can read the server database and the user has not supplied any certificate
// hashes, discover the server's self-signed certificate hash from the local database.
// Don't try to look at the database if the password is already set.
if (string.IsNullOrWhiteSpace(settings.Password) && settings.HostUrl.Scheme == "https" && trustedCertificateHashes.Count == 0)
{
ServerCertificateCustomValidationCallback = settings.Insecure || trustedCertificateHashes.Contains("*")
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
: ((message, cert, chain, sslPolicyErrors) =>
try
{
if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly), DataFolderManager.SERVER_DATABASE_FILENAME)))
{
if (sslPolicyErrors == SslPolicyErrors.None)
return true;
if (cert == null)
return false;
return trustedCertificateHashes.Contains(cert.GetCertHashString());
})
})
var opts = new Dictionary<string, string>();
if (settings.Key != null)
opts["settings-encryption-key"] = settings.Key.Key;
using (var dbConnection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), opts, true, false))
{
if (dbConnection.ApplicationSettings.ServerSSLCertificate != null)
{
var selfSignedCertHash = dbConnection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString();
if (!string.IsNullOrWhiteSpace(selfSignedCertHash))
trustedCertificateHashes.Add(selfSignedCertHash);
}
}
}
}
catch (Exception ex)
{
if (console != null)
console.AppendExceptionMessage($"Failed to read server certificate from local database: {ex.Message}");
else
Console.WriteLine($"Failed to read server certificate from local database: {ex.Message}");
}
}
// Configure the client for requests using the shared certificate validator.
// The validator handles accept-all, specific hashes and the ignore-revocation-failure
// option in a single place, instead of each call-site replicating the logic.
// Only install the custom callback when the user actually requested a certificate
// override; otherwise leave it unset so the OS default validation (which includes
// revocation/CRL checks) is preserved.
var httpHandler = new HttpClientHandler();
var acceptAll = settings.Insecure || trustedCertificateHashes.Contains("*");
if (acceptAll || trustedCertificateHashes.Count > 0 || settings.IgnoreRevocationFailure)
{
Duplicati.Library.Utility.HttpClientHelper.ConfigureHandlerCertificateValidator(
httpHandler,
acceptAll,
acceptAll ? null : [.. trustedCertificateHashes],
settings.IgnoreRevocationFailure);
}
var client = new HttpClient(httpHandler)
{
BaseAddress = new Uri(settings.HostUrl + "api/v1/")
};
@@ -225,12 +260,6 @@ public class Connection
using (var connection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), opts, true, false))
{
cfg = connection.ApplicationSettings.JWTConfig;
if (settings.HostUrl.Scheme == "https" && connection.ApplicationSettings.ServerSSLCertificate != null && trustedCertificateHashes.Count == 0)
{
var selfSignedCertHash = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString();
if (!string.IsNullOrWhiteSpace(selfSignedCertHash))
trustedCertificateHashes.Add(selfSignedCertHash);
}
}
if (!string.IsNullOrWhiteSpace(cfg))
+7 -3
View File
@@ -44,6 +44,7 @@ namespace Duplicati.CommandLine.ServerUtil;
/// <param name="SecretProvider">The secret provider to use for reading secrets</param>
/// <param name="SecretProviderPattern">The pattern to use for the secret provider</param>
/// <param name="AcceptedHostCertificate">The SHA1 hash of the host certificate to accept</param>
/// <param name="IgnoreRevocationFailure">Whether to ignore certificate revocation check failures</param>
public sealed record Settings(
string? Password,
string? RefreshToken,
@@ -54,7 +55,8 @@ public sealed record Settings(
EncryptedFieldHelper.KeyInstance? Key,
ISecretProvider? SecretProvider,
string SecretProviderPattern,
string? AcceptedHostCertificate
string? AcceptedHostCertificate,
bool IgnoreRevocationFailure
)
{
/// <summary>
@@ -91,8 +93,9 @@ public sealed record Settings(
/// <param name="secretProviderCache">The secret provider cache level to use</param>
/// <param name="secretProviderPattern">The secret provider pattern to use</param>
/// <param name="acceptedHostCertificate">The SHA1 hash of the host certificate to accept</param>
/// <param name="ignoreRevocationFailure">Whether to ignore certificate revocation check failures</param>
/// <returns>The loaded settings</returns>
public static Settings Load(string? password, Uri? hostUrl, string settingsFile, bool insecure, string? settingsPassphrase, string? secretProvider, SecretProviderHelper.CachingLevel secretProviderCache, string secretProviderPattern, string? acceptedHostCertificate)
public static Settings Load(string? password, Uri? hostUrl, string settingsFile, bool insecure, string? settingsPassphrase, string? secretProvider, SecretProviderHelper.CachingLevel secretProviderCache, string secretProviderPattern, string? acceptedHostCertificate, bool ignoreRevocationFailure)
{
hostUrl ??= new Uri($"http://{Utility.IpVersionCompatibleLoopback}:8200");
@@ -135,7 +138,8 @@ public sealed record Settings(
key,
secretInstance,
secretProviderPattern,
acceptedHostCertificate
acceptedHostCertificate,
ignoreRevocationFailure
);
}
@@ -83,12 +83,17 @@ public class SettingsBinder : BinderBase<Settings>
/// </summary>
public static readonly Option<string> acceptedHostCertificateOption = new Option<string>("--host-cert", description: "The SHA1 hash of the host certificate to accept. Use * for any certificate, same as --insecure (dangerous)", getDefaultValue: () => string.Empty);
/// <summary>
/// The ignore revocation failure option.
/// </summary>
public static readonly Option<bool> ignoreRevocationFailureOption = new Option<bool>("--ignore-revocation-failure", description: "Ignore certificate revocation check failures, such as when the revocation server is offline or the status is unknown", getDefaultValue: () => false);
/// <summary>
/// Option to wrap stdout as a json.
/// </summary>
public static readonly Option<bool> jsonOutputOption =
new Option<bool>("--json", description: "Wraps stdout as a json", getDefaultValue: () => false);
/// <summary>
/// Adds global options to the root command.
/// </summary>
@@ -107,6 +112,7 @@ public class SettingsBinder : BinderBase<Settings>
rootCommand.AddGlobalOption(secretProviderCacheOption);
rootCommand.AddGlobalOption(secretProviderPatternOption);
rootCommand.AddGlobalOption(acceptedHostCertificateOption);
rootCommand.AddGlobalOption(ignoreRevocationFailureOption);
rootCommand.AddGlobalOption(jsonOutputOption);
return rootCommand;
}
@@ -126,7 +132,8 @@ public class SettingsBinder : BinderBase<Settings>
bindingContext.ParseResult.GetValueForOption(secretProviderOption),
bindingContext.ParseResult.GetValueForOption(secretProviderCacheOption),
bindingContext.ParseResult.GetValueForOption(secretProviderPatternOption) ?? SecretProviderHelper.DEFAULT_PATTERN,
bindingContext.ParseResult.GetValueForOption(acceptedHostCertificateOption)
bindingContext.ParseResult.GetValueForOption(acceptedHostCertificateOption),
bindingContext.ParseResult.GetValueForOption(ignoreRevocationFailureOption)
);
/// <summary>
@@ -27,7 +27,6 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -138,6 +137,7 @@ namespace Duplicati.GUI.TrayIcon
public HttpServerConnection(IApplicationSettings? applicationSettings,
Program.PasswordSource passwordSource, bool disableTrayIconLogin, string acceptedHostCertificate,
bool ignoreRevocationFailure,
Dictionary<string, string> options,
PasswordStorageHelper passwordStorageHelper)
{
@@ -157,28 +157,19 @@ namespace Duplicati.GUI.TrayIcon
acceptedCertificates.UnionWith(acceptedHostCertificate.Split(new char[] { ',', ';' },
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
HTTPCLIENT = new(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = acceptedCertificates switch
{
{ Count: 0 } or null => null,
// Configure the certificate validator using the shared helper, which handles
// accept-all, specific hashes and the ignore-revocation-failure option in a
// single place instead of replicating the logic here.
var httpHandler = new HttpClientHandler();
var acceptAll = acceptedCertificates.Contains("*");
if (acceptAll || acceptedCertificates.Count > 0 || ignoreRevocationFailure)
Duplicati.Library.Utility.HttpClientHelper.ConfigureHandlerCertificateValidator(
httpHandler,
acceptAll,
acceptAll ? null : [.. acceptedCertificates],
ignoreRevocationFailure);
{ } when acceptedCertificates.Contains("*") => HttpClientHandler
.DangerousAcceptAnyServerCertificateValidator,
_ => (sender, cert, chain, sslPolicyErrors) =>
{
if (sslPolicyErrors == SslPolicyErrors.None)
return true;
if (cert == null)
return false;
var certHash = cert.GetCertHashString();
return acceptedCertificates.Contains(certHash);
}
}
})
HTTPCLIENT = new HttpClient(httpHandler)
{
// Max time a request can be pending, actual requests can set a lower limit
Timeout = Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) + TimeSpan.FromSeconds(10),
@@ -73,6 +73,7 @@ namespace Duplicati.GUI.TrayIcon
private const string NOHOSTEDSERVER_OPTION = "no-hosted-server";
private const string READCONFIGFROMDB_OPTION = "read-config-from-db";
private const string ACCEPTED_SSL_CERTIFICATE = "host-cert-hash";
private const string IGNORE_REVOCATION_FAILURE = "ignore-revocation-failure";
private const string DETACHED_PROCESS = "detached-process";
private const string BROWSER_COMMAND_OPTION = "browser-command";
@@ -150,6 +151,7 @@ namespace Duplicati.GUI.TrayIcon
string password = null;
var passwordSource = PasswordSource.SuppliedPassword;
var acceptedHostCertificate = options.GetValueOrDefault(ACCEPTED_SSL_CERTIFICATE, null);
var ignoreRevocationFailure = Utility.ParseBoolOption(options, IGNORE_REVOCATION_FAILURE);
var detached = Utility.ParseBoolOption(options, NOHOSTEDSERVER_OPTION);
var supportedCommands = BasicSupportedCommands.AsEnumerable();
@@ -254,12 +256,12 @@ namespace Duplicati.GUI.TrayIcon
customUrl = true;
}
StartTray(_args, options, hosted, passwordSource, password, serverURL, customUrl, acceptedHostCertificate);
StartTray(_args, options, hosted, passwordSource, password, serverURL, customUrl, acceptedHostCertificate, ignoreRevocationFailure);
return 0;
}
private static void StartTray(string[] _args, Dictionary<string, string> options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, Uri serverURL, bool customUrl, string acceptedHostCertificate)
private static void StartTray(string[] _args, Dictionary<string, string> options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, Uri serverURL, bool customUrl, string acceptedHostCertificate, bool ignoreRevocationFailure)
{
PasswordStorage = PasswordStorageHelper.CreateAsync(serverURL.ToString(), customUrl, password, passwordSource, options)
.Await();
@@ -275,7 +277,7 @@ namespace Duplicati.GUI.TrayIcon
try
{
using (Connection = new HttpServerConnection(hosted?.applicationSettings, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options, PasswordStorage))
using (Connection = new HttpServerConnection(hosted?.applicationSettings, passwordSource, disableTrayIconLogin, acceptedHostCertificate, ignoreRevocationFailure, options, PasswordStorage))
{
// Make sure we have the latest status, but don't care if it fails
Connection.UpdateStatusAsync().FireAndForget();
@@ -365,6 +367,7 @@ namespace Duplicati.GUI.TrayIcon
new CommandLineArgument(HOSTURL_OPTION, CommandLineArgument.ArgumentType.String, "Selects the url to connect to", "Supply the url that the TrayIcon will connect to and show status for", DEFAULT_HOSTURL),
new CommandLineArgument(BROWSER_COMMAND_OPTION, CommandLineArgument.ArgumentType.String, "Sets the browser command", "Set this option to override the default browser detection"),
new CommandLineArgument(ACCEPTED_SSL_CERTIFICATE, CommandLineArgument.ArgumentType.String, "Accepts a specific SSL certificate", "Set this option to accept a specific SSL certificate, the value should be the hash of the certificate in hexadecimal format. Use * to accept any certificate (dangerous)"),
new CommandLineArgument(IGNORE_REVOCATION_FAILURE, CommandLineArgument.ArgumentType.Boolean, "Ignore certificate revocation check failures", "Set this option to ignore certificate revocation check failures, such as when the revocation server is offline or the revocation status is unknown"),
.. WindowsSupportedCommands
];
+17 -13
View File
@@ -195,6 +195,11 @@ namespace Duplicati.Library.Backend
/// </summary>
private readonly SslOptionsHelper.SslCertificateOptions _sslOptions;
/// <summary>
/// The cached SSL certificate validator built from <see cref="_sslOptions"/>.
/// Reused across certificate validation events to avoid per-handshake allocation.
/// </summary>
private readonly SslCertificateValidator _sslValidator;
/// <summary>
/// The timeout options to use
/// </summary>
private readonly TimeoutOptionsHelper.Timeouts _timeouts;
@@ -247,6 +252,7 @@ namespace Duplicati.Library.Backend
{
// TODO: Remove this constructor once static properties are introduced on IBackend
_sslOptions = null!;
_sslValidator = null!;
_timeouts = null!;
_ftpConfig = null!;
_url = null!;
@@ -261,6 +267,7 @@ namespace Duplicati.Library.Backend
public FTP(string url, Dictionary<string, string?> options)
{
_sslOptions = SslOptionsHelper.Parse(options);
_sslValidator = new SslCertificateValidator(_sslOptions.AcceptAllCertificates, _sslOptions.AcceptSpecificCertificateHashes, _sslOptions.IgnoreRevocationFailure);
var u = new Utility.Uri(url);
u.RequireHost();
@@ -656,25 +663,22 @@ namespace Duplicati.Library.Backend
/// <param name="e">The event arguments.</param>
private void HandleValidateCertificate(BaseFtpClient control, FtpSslValidationEventArgs e)
{
if (e.PolicyErrors == SslPolicyErrors.None || _sslOptions.AcceptAllCertificates)
{
e.Accept = true;
return;
}
e.Accept = false;
// Delegate to the cached shared validator which handles accept-all, specific hashes
// and the ignore-revocation-failure option consistently across backends.
try
{
var certHash = (_sslOptions.AcceptSpecificCertificateHashes != null && _sslOptions.AcceptSpecificCertificateHashes.Length > 0) ? e.Certificate?.GetCertHashString() : null;
if (certHash != null && _sslOptions.AcceptSpecificCertificateHashes != null && _sslOptions.AcceptSpecificCertificateHashes.Any(hash => !string.IsNullOrEmpty(hash) && certHash.Equals(hash, StringComparison.OrdinalIgnoreCase)))
e.Accept = true;
e.Accept = _sslValidator.ValidateServerCertificate(control, e.Certificate, e.Chain, e.PolicyErrors);
}
catch (SslCertificateValidator.InvalidCertificateException)
{
e.Accept = false;
throw;
}
catch
{
e.Accept = false;
throw;
}
if (e.Accept == false && e.Certificate != null)
throw new SslCertificateValidator.InvalidCertificateException(e.Certificate?.GetCertHashString() ?? "<unknown>", e.PolicyErrors);
}
/// <summary>
+1 -1
View File
@@ -367,7 +367,7 @@ namespace Duplicati.Library.Backend
var httpHandler = new HttpClientHandler();
// Custom certificate validation to throw exception on failure
var validator = new SslCertificateValidator(m_certificateOptions.AcceptAllCertificates, m_certificateOptions.AcceptSpecificCertificateHashes);
var validator = new SslCertificateValidator(m_certificateOptions.AcceptAllCertificates, m_certificateOptions.AcceptSpecificCertificateHashes, m_certificateOptions.IgnoreRevocationFailure);
httpHandler.ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) =>
validator.ValidateServerCertificate(sender, cert, chain, sslPolicyErrors);
@@ -115,6 +115,10 @@ namespace Duplicati.Library.Modules.Builtin
/// The option used to accept any SSL certificate
/// </summary>
private const string OPTION_ACCEPT_ANY_CERTIFICATE = "send-http-accept-any-ssl-certificate";
/// <summary>
/// The option used to ignore certificate revocation check failures
/// </summary>
private const string OPTION_IGNORE_REVOCATION_FAILURE = "send-http-ignore-revocation-failure";
/// <summary>
/// The option used to specify the number of retries for sending the HTTP request
@@ -174,6 +178,11 @@ namespace Duplicati.Library.Modules.Builtin
/// </summary>
private string[] m_acceptSpecificCertificates;
/// <summary>
/// Option to ignore certificate revocation check failures
/// </summary>
private bool m_ignoreRevocationFailure;
/// <summary>
/// The number of retries to attempt
/// </summary>
@@ -234,6 +243,7 @@ namespace Duplicati.Library.Modules.Builtin
new CommandLineArgument(OPTION_ACCEPT_ANY_CERTIFICATE, CommandLineArgument.ArgumentType.Boolean, Strings.SendHttpMessage.AcceptAnyCertificateShort, Strings.SendHttpMessage.AcceptAnyCertificateLong),
new CommandLineArgument(OPTION_ACCEPT_SPECIFIED_CERTIFICATE, CommandLineArgument.ArgumentType.String, Strings.SendHttpMessage.AcceptSpecifiedCertificateShort, Strings.SendHttpMessage.AcceptSpecifiedCertificateLong),
new CommandLineArgument(OPTION_IGNORE_REVOCATION_FAILURE, CommandLineArgument.ArgumentType.Boolean, Strings.SendHttpMessage.IgnoreRevocationFailureShort, Strings.SendHttpMessage.IgnoreRevocationFailureLong, "false"),
new CommandLineArgument(OPTION_SEND_HTTP_RETRIES, CommandLineArgument.ArgumentType.Integer, Strings.SendHttpMessage.SendHttpRetriesShort, Strings.SendHttpMessage.SendHttpRetriesLong, DEFAULT_RETRIES.ToString()),
new CommandLineArgument(OPTION_SEND_HTTP_RETRY_DELAY, CommandLineArgument.ArgumentType.Integer, Strings.SendHttpMessage.SendHttpRetryDelayShort, Strings.SendHttpMessage.SendHttpRetryDelayLong, DEFAULT_RETRY_DELAY),
@@ -296,6 +306,7 @@ namespace Duplicati.Library.Modules.Builtin
commandlineOptions.TryGetValue(OPTION_EXTRA_PARAMETERS, out m_extraParameters);
m_acceptAnyCertificate = Utility.Utility.ParseBoolOption(commandlineOptions.AsReadOnly(), OPTION_ACCEPT_ANY_CERTIFICATE);
m_acceptSpecificCertificates = commandlineOptions.ContainsKey(OPTION_ACCEPT_SPECIFIED_CERTIFICATE) ? commandlineOptions[OPTION_ACCEPT_SPECIFIED_CERTIFICATE].Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) : null;
m_ignoreRevocationFailure = Utility.Utility.ParseBoolOption(commandlineOptions.AsReadOnly(), OPTION_IGNORE_REVOCATION_FAILURE);
m_retries = Utility.Utility.ParseIntOption(commandlineOptions.AsReadOnly(), OPTION_SEND_HTTP_RETRIES, DEFAULT_RETRIES);
m_retryDelay = Utility.Utility.ParseTimespanOption(commandlineOptions.AsReadOnly(), OPTION_SEND_HTTP_RETRY_DELAY, DEFAULT_RETRY_DELAY);
@@ -401,7 +412,7 @@ namespace Duplicati.Library.Modules.Builtin
return;
using HttpClientHandler httpHandler = new HttpClientHandler();
HttpClientHelper.ConfigureHandlerCertificateValidator(httpHandler, m_acceptAnyCertificate, m_acceptSpecificCertificates);
HttpClientHelper.ConfigureHandlerCertificateValidator(httpHandler, m_acceptAnyCertificate, m_acceptSpecificCertificates, m_ignoreRevocationFailure);
using var client = HttpClientHelper.CreateClient(httpHandler);
// Explicitly keeping the default 100s timeout
@@ -277,6 +277,8 @@ You can supply multiple options with a comma separator, e.g. ""{0},{1}"". The sp
public static string AcceptAnyCertificateShort { get { return LC.L(@"Accept any server certificate"); } }
public static string AcceptSpecifiedCertificateLong { get { return LC.L(@"If your server certificate is reported as invalid (e.g. with self-signed certificates), you can supply the certificate hash (SHA1) to approve it anyway. The hash value must be entered in hex format without spaces or colons. You can enter multiple hashes separated by commas."); } }
public static string AcceptSpecifiedCertificateShort { get { return LC.L(@"Optionally accept a known SSL certificate"); } }
public static string IgnoreRevocationFailureLong { get { return LC.L(@"Use this option to ignore certificate revocation check failures, such as when the revocation server is offline or the revocation status is unknown."); } }
public static string IgnoreRevocationFailureShort { get { return LC.L(@"Ignore certificate revocation check failures"); } }
public static string SendHttpRetriesLong { get { return LC.L(@"Use this option to set the number of retries to attempt if the HTTP request fails."); } }
public static string SendHttpRetriesShort { get { return LC.L(@"Set the number of retries"); } }
public static string SendHttpRetryDelayLong { get { return LC.L(@"Use this option to set the delay between retries."); } }
@@ -71,14 +71,15 @@ public static class HttpClientHelper
/// Configures the httphandler with the certificate validator, if needed.
///
/// The parameters indicate if it should accept all certificates or only the ones specified in the
/// hashes array.
/// hashes array, and whether revocation check failures should be ignored.
/// </summary>
/// <param name="handler">The HttpClientHandler to be configured</param>
/// <param name="acceptAllCertificates">If all certificates will be considered valid</param>
/// <param name="acceptSpecificCertificateHashes">List of hashes of certificates that will be accepted</param>
public static void ConfigureHandlerCertificateValidator(HttpClientHandler handler, bool acceptAllCertificates, string[]? acceptSpecificCertificateHashes)
/// <param name="ignoreRevocationFailure">If revocation check failures (offline or unknown status) should be ignored</param>
public static void ConfigureHandlerCertificateValidator(HttpClientHandler handler, bool acceptAllCertificates, string[]? acceptSpecificCertificateHashes, bool ignoreRevocationFailure)
{
var validator = new SslCertificateValidator(acceptAllCertificates, acceptSpecificCertificateHashes);
var validator = new SslCertificateValidator(acceptAllCertificates, acceptSpecificCertificateHashes, ignoreRevocationFailure);
handler.ServerCertificateCustomValidationCallback = validator.ValidateServerCertificate;
}
}
@@ -46,6 +46,11 @@ public static class SslOptionsHelper
/// </summary>
public const string AcceptSpecifiedSslHashOption = "accept-specified-ssl-hash";
/// <summary>
/// The name of the ignore revocation failure option without a prefix
/// </summary>
public const string IgnoreRevocationFailureOption = "ignore-revocation-failure";
/// <summary>
/// Gets the SSL certificate options for certificate only
/// </summary>
@@ -54,7 +59,8 @@ public static class SslOptionsHelper
public static CommandLineArgument[] GetCertOnlyOptions(string? prefix = null) =>
[
new CommandLineArgument($"{prefix}{AcceptAnySslCertificateOption}", CommandLineArgument.ArgumentType.Boolean, Strings.SslOptionsHelper.DescriptionAcceptAnyCertificateShort, Strings.SslOptionsHelper.DescriptionAcceptAnyCertificateLong, "false"),
new CommandLineArgument($"{prefix}{AcceptSpecifiedSslHashOption}", CommandLineArgument.ArgumentType.String, Strings.SslOptionsHelper.DescriptionAcceptHashShort, Strings.SslOptionsHelper.DescriptionAcceptHashLong)
new CommandLineArgument($"{prefix}{AcceptSpecifiedSslHashOption}", CommandLineArgument.ArgumentType.String, Strings.SslOptionsHelper.DescriptionAcceptHashShort, Strings.SslOptionsHelper.DescriptionAcceptHashLong),
new CommandLineArgument($"{prefix}{IgnoreRevocationFailureOption}", CommandLineArgument.ArgumentType.Boolean, Strings.SslOptionsHelper.DescriptionIgnoreRevocationFailureShort, Strings.SslOptionsHelper.DescriptionIgnoreRevocationFailureLong, "false")
];
/// <summary>
@@ -90,7 +96,8 @@ public static class SslOptionsHelper
return new SslCertificateOptions(
Utility.ParseBoolOption(options, $"{prefix}{UseSSLOption}"),
Utility.ParseBoolOption(options, $"{prefix}{AcceptAnySslCertificateOption}"),
string.IsNullOrWhiteSpace(acceptSpecificCertificatesString) ? Array.Empty<string>() : acceptSpecificCertificatesString.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
string.IsNullOrWhiteSpace(acceptSpecificCertificatesString) ? Array.Empty<string>() : acceptSpecificCertificatesString.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries),
Utility.ParseBoolOption(options, $"{prefix}{IgnoreRevocationFailureOption}")
);
}
@@ -100,7 +107,8 @@ public static class SslOptionsHelper
/// <param name="UseSSL">Flag to indicate if SSL is used</param>
/// <param name="AcceptAllCertificates">Flag to accept all certificates</param>
/// <param name="AcceptSpecificCertificateHashes">Array of specific certificate hashes to accept</param>
public sealed record SslCertificateOptions(bool UseSSL, bool AcceptAllCertificates, string[] AcceptSpecificCertificateHashes)
/// <param name="IgnoreRevocationFailure">Flag to ignore certificate revocation check failures</param>
public sealed record SslCertificateOptions(bool UseSSL, bool AcceptAllCertificates, string[] AcceptSpecificCertificateHashes, bool IgnoreRevocationFailure)
{
/// <summary>
/// Creates a handler with the SSL certificate options
@@ -119,7 +127,7 @@ public static class SslOptionsHelper
/// <returns>The configured handler</returns>
public HttpClientHandler ConfigureHandler(HttpClientHandler handler)
{
HttpClientHelper.ConfigureHandlerCertificateValidator(handler, AcceptAllCertificates, AcceptSpecificCertificateHashes);
HttpClientHelper.ConfigureHandlerCertificateValidator(handler, AcceptAllCertificates, AcceptSpecificCertificateHashes, IgnoreRevocationFailure);
return handler;
}
}
@@ -28,7 +28,7 @@ using System.Security.Cryptography.X509Certificates;
namespace Duplicati.Library.Utility;
public class SslCertificateValidator(bool acceptAll, string[]? validHashes)
public class SslCertificateValidator(bool acceptAll, string[]? validHashes, bool ignoreRevocationFailure = false)
{
[Serializable]
public class InvalidCertificateException(string certificate, SslPolicyErrors error)
@@ -41,6 +41,13 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes)
public SslPolicyErrors SslError => _mErrors;
}
/// <summary>
/// The chain status flags that are treated as revocation check failures
/// and ignored when <see cref="ignoreRevocationFailure"/> is set.
/// </summary>
private static readonly X509ChainStatusFlags RevocationFailureFlags =
X509ChainStatusFlags.RevocationStatusUnknown | X509ChainStatusFlags.OfflineRevocation;
public bool ValidateServerCertificate(object sender, X509Certificate? cert, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
{
if (acceptAll)
@@ -52,9 +59,11 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes)
using var certificate = cert as X509Certificate2 ?? new X509Certificate2(cert ?? throw new ArgumentNullException(nameof(cert)));
// Validate date range before anything else, reject expired certs
if (!IsDateValid(certificate, now))
return false;
// Check if the certificate is directly approved by comparing the hash
if (validHashes != null)
{
// Check main certificate hash
@@ -67,18 +76,52 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes)
return true;
}
// If requested, ignore revocation check failures (e.g. OCSP server offline or status unknown)
if (ignoreRevocationFailure)
sslPolicyErrors = FilterRevocationErrors(sslPolicyErrors, chain);
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch) || sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
throw new InvalidCertificateException(certificate.GetCertHashString(), sslPolicyErrors);
// If no hash is found, perform the standard validations
return sslPolicyErrors == SslPolicyErrors.None && certificate.Verify();
}
catch (InvalidCertificateException)
{
// Propagate the typed certificate exception directly so that backend call-sites
// can detect it without relying on unwrapping exceptions
throw;
}
catch (Exception ex)
{
throw new Exception(Strings.SslCertificateValidator.VerifyCertificateHashError(ex, sslPolicyErrors), ex);
}
}
/// <summary>
/// Removes the revocation-related chain errors from the reported SSL policy errors
/// when the caller has requested that revocation failures be ignored.
/// </summary>
/// <param name="sslPolicyErrors">The original SSL policy errors</param>
/// <param name="chain">The certificate chain, if any</param>
/// <returns>The filtered SSL policy errors</returns>
private static SslPolicyErrors FilterRevocationErrors(SslPolicyErrors sslPolicyErrors, X509Chain? chain)
{
if (chain?.ChainStatus == null || chain.ChainStatus.Length == 0)
return sslPolicyErrors;
// Strip the revocation flags from each chain status entry. If no entry retains any
// non-revocation status afterwards, the RemoteCertificateChainErrors flag was set
// solely due to revocation check failures and can be cleared. Entries that carry an
// unrelated status (e.g. UntrustedRoot, PartialChain) keep it, so genuine chain
// errors are still reported and the connection is rejected.
if (chain.ChainStatus.All(s => (s.Status & ~RevocationFailureFlags) == 0))
return sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors;
return sslPolicyErrors;
}
private bool IsTrustedHash(string hash) =>
!string.IsNullOrWhiteSpace(hash) &&
validHashes != null &&
+2
View File
@@ -86,6 +86,8 @@ namespace Duplicati.Library.Utility.Strings
public static string DescriptionAcceptHashShort { get { return LC.L(@"Optionally accept a known SSL certificate"); } }
public static string DescriptionUseSSLLong { get { return LC.L(@"Use this option to communicate using Secure Socket Layer (SSL) over http (https)."); } }
public static string DescriptionUseSSLShort { get { return LC.L(@"Instruct Duplicati to use an SSL (https) connection"); } }
public static string DescriptionIgnoreRevocationFailureLong { get { return LC.L(@"Use this option to ignore certificate revocation check failures, such as when the revocation server is offline or the revocation status is unknown."); } }
public static string DescriptionIgnoreRevocationFailureShort { get { return LC.L(@"Ignore certificate revocation check failures"); } }
}
internal static class AuthSettingsHelper
@@ -0,0 +1,477 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Linq;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Duplicati.Library.Certificates;
using Duplicati.Library.Utility;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest;
/// <summary>
/// Unit tests for <see cref="SslCertificateValidator"/>, covering the accept-all,
/// date-validity (checked first), pinned-hash (checked after date) and
/// ignore-revocation-failure code paths.
/// </summary>
[TestFixture]
[Category("Certificates")]
public class SslCertificateValidatorTests
{
/// <summary>
/// Generates an expired self-signed certificate (notAfter in the past) for testing
/// the date-validity-first behaviour and its interaction with pinned hashes.
/// </summary>
private static X509Certificate2 GenerateExpiredCertificate()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest(
"CN=Duplicati Expired Test, O=Duplicati, C=US",
key,
HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, true));
request.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
true));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1", "Server Authentication") },
false));
// Expired a day ago, started two days ago
var notBefore = DateTimeOffset.UtcNow.AddDays(-2);
var notAfter = DateTimeOffset.UtcNow.AddDays(-1);
var certificate = request.CreateSelfSigned(notBefore, notAfter);
return new X509Certificate2(certificate.Export(X509ContentType.Cert));
}
/// <summary>
/// Generates a not-yet-valid self-signed certificate (notBefore in the future).
/// </summary>
private static X509Certificate2 GenerateNotYetValidCertificate()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest(
"CN=Duplicati Future Test, O=Duplicati, C=US",
key,
HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, true));
// Starts tomorrow, expires next year
var notBefore = DateTimeOffset.UtcNow.AddDays(1);
var notAfter = DateTimeOffset.UtcNow.AddDays(365);
var certificate = request.CreateSelfSigned(notBefore, notAfter);
return new X509Certificate2(certificate.Export(X509ContentType.Cert));
}
/// <summary>
/// The validator propagates <see cref="SslCertificateValidator.InvalidCertificateException"/>
/// directly (it is not wrapped by the outer catch-all) so backend call-sites and the
/// remote-operation handler can detect it via its concrete type. This helper asserts that
/// direct throw and returns the exception so tests can inspect the certificate hash and SSL error.
/// </summary>
private static SslCertificateValidator.InvalidCertificateException AssertThrowsInvalidCert(TestDelegate code)
{
var ex = Assert.Throws<SslCertificateValidator.InvalidCertificateException>(code);
Assert.IsNotNull(ex, "Expected an InvalidCertificateException to be thrown.");
return ex!;
}
#region acceptAll
[Test]
public void AcceptAll_ReturnsTrueForAnyCertificate()
{
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(true, null, false);
var result = validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.None);
Assert.IsTrue(result);
}
[Test]
public void AcceptAll_ReturnsTrueEvenForExpiredCertificate()
{
using var expired = GenerateExpiredCertificate();
var validator = new SslCertificateValidator(true, null, false);
var result = validator.ValidateServerCertificate(
null, expired, null, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.IsTrue(result);
}
[Test]
public void AcceptAll_ReturnsTrueEvenForNameMismatch()
{
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(true, null, false);
var result = validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateNameMismatch);
Assert.IsTrue(result);
}
#endregion
#region Untrusted self-signed cert on the default path
[Test]
public void NoHashes_UntrustedSelfSignedCert_NoErrors_ReturnsFalse()
{
// A self-signed CA cert is not in the OS trust store, so certificate.Verify()
// (the final check on the default path) returns false even with no SSL policy errors.
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(false, null, false);
var result = validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.None);
Assert.IsFalse(result);
}
[Test]
public void IgnoreRevocationFailure_UntrustedSelfSignedCert_NoErrors_ReturnsFalse()
{
// The revocation filter only clears RemoteCertificateChainErrors when the chain
// status is revocation-only; it never bypasses the OS trust check. With no SSL
// policy errors the filter is a no-op and the default path still calls
// certificate.Verify(), which rejects an untrusted self-signed CA.
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(false, null, true);
var result = validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.None);
Assert.IsFalse(result);
}
#endregion
#region Pinned hash (checked after date validity)
[Test]
public void PinnedHash_MatchingHash_ReturnsTrueForValidCertificate()
{
var caPair = CertificateGenerator.GenerateCACertificate();
var hash = caPair.Certificate.GetCertHashString();
var validator = new SslCertificateValidator(false, new[] { hash }, false);
var result = validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateNameMismatch);
Assert.IsTrue(result);
}
[Test]
public void PinnedHash_MatchingHash_ExpiredCertificate_ReturnsFalse()
{
// Date validity is checked before the pinned hash, so an expired certificate is
// rejected even when its hash is pinned. Pinning affects trust, not duration.
using var expired = GenerateExpiredCertificate();
var hash = expired.GetCertHashString();
var validator = new SslCertificateValidator(false, new[] { hash }, false);
var result = validator.ValidateServerCertificate(
null, expired, null, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.IsFalse(result);
}
[Test]
public void PinnedHash_NonMatchingHash_ExpiredCertificate_ReturnsFalse()
{
// An expired cert with a pinned hash that does NOT match is rejected by the
// date-validity check, which runs before the pinned-hash check.
using var expired = GenerateExpiredCertificate();
var validator = new SslCertificateValidator(false, new[] { "00FF00FF00FF" }, false);
var result = validator.ValidateServerCertificate(
null, expired, null, SslPolicyErrors.None);
Assert.IsFalse(result);
}
[Test]
public void PinnedHash_CaseInsensitiveMatch()
{
var caPair = CertificateGenerator.GenerateCACertificate();
// GetCertHashString returns uppercase hex; pass lowercase to verify case-insensitivity
var hash = caPair.Certificate.GetCertHashString().ToLowerInvariant();
var validator = new SslCertificateValidator(false, new[] { hash }, false);
var result = validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.None);
Assert.IsTrue(result);
}
[Test]
public void PinnedHash_EmptyHashEntriesAreIgnored()
{
var caPair = CertificateGenerator.GenerateCACertificate();
var realHash = caPair.Certificate.GetCertHashString();
// Empty/whitespace entries must not match anything
var validator = new SslCertificateValidator(false, new[] { "", " ", realHash }, false);
var result = validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.None);
Assert.IsTrue(result);
}
#endregion
#region Date validity (default/un-pinned path)
[Test]
public void NoHashes_ExpiredCertificate_ReturnsFalse()
{
using var expired = GenerateExpiredCertificate();
var validator = new SslCertificateValidator(false, null, false);
var result = validator.ValidateServerCertificate(
null, expired, null, SslPolicyErrors.None);
Assert.IsFalse(result);
}
[Test]
public void NoHashes_NotYetValidCertificate_ReturnsFalse()
{
using var future = GenerateNotYetValidCertificate();
var validator = new SslCertificateValidator(false, null, false);
var result = validator.ValidateServerCertificate(
null, future, null, SslPolicyErrors.None);
Assert.IsFalse(result);
}
#endregion
#region Chain errors throw wrapped InvalidCertificateException
[Test]
public void NameMismatch_WithNoPinnedHash_ThrowsInvalidCertificateException()
{
// Capture the hash before the call: ValidateServerCertificate wraps the cert in a
// `using` and disposes it on the throwing path, invalidating the handle afterwards.
var caPair = CertificateGenerator.GenerateCACertificate();
var expectedHash = caPair.Certificate.GetCertHashString();
var validator = new SslCertificateValidator(false, null, false);
var inner = AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateNameMismatch));
Assert.AreEqual(expectedHash, inner.Certificate);
Assert.AreEqual(SslPolicyErrors.RemoteCertificateNameMismatch, inner.SslError);
}
[Test]
public void ChainErrors_WithNoPinnedHash_ThrowsInvalidCertificateException()
{
// Capture the hash before the call: ValidateServerCertificate wraps the cert in a
// `using` and disposes it on the throwing path, invalidating the handle afterwards.
var caPair = CertificateGenerator.GenerateCACertificate();
var expectedHash = caPair.Certificate.GetCertHashString();
var validator = new SslCertificateValidator(false, null, false);
var inner = AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateChainErrors));
Assert.AreEqual(expectedHash, inner.Certificate);
Assert.AreEqual(SslPolicyErrors.RemoteCertificateChainErrors, inner.SslError);
}
[Test]
public void InvalidCertificateException_PreservesErrorAndCertificate()
{
var certHash = "DEADBEEF";
var error = SslPolicyErrors.RemoteCertificateNameMismatch;
var ex = new SslCertificateValidator.InvalidCertificateException(certHash, error);
Assert.AreEqual(certHash, ex.Certificate);
Assert.AreEqual(error, ex.SslError);
Assert.IsTrue(ex.Message.Contains(certHash));
}
#endregion
#region Null certificate
[Test]
public void NullCertificate_Throws()
{
var validator = new SslCertificateValidator(false, null, false);
// The validator throws ArgumentNullException inside the try block, which the
// outer catch-all wraps in a generic Exception.
var ex = Assert.Throws<Exception>(() =>
validator.ValidateServerCertificate(null, null, null, SslPolicyErrors.None));
Assert.IsNotNull(ex);
Assert.IsInstanceOf<ArgumentNullException>(ex!.InnerException);
}
#endregion
#region Ignore revocation failure
[Test]
public void IgnoreRevocationFailure_NameMismatchStillThrows()
{
// The revocation filter must not clear a name-mismatch error.
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(false, null, true);
AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateNameMismatch));
}
[Test]
public void IgnoreRevocationFailure_NullChain_KeepsChainErrorsAndThrows()
{
// With a null chain the filter is a no-op (guard at the start of
// FilterRevocationErrors), so a chain error must still be rejected.
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(false, null, true);
AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
[Test]
public void IgnoreRevocationFailure_NonRevocationChainStatus_DoesNotClearChainErrors()
{
// Build a real chain for an untrusted self-signed cert. The platform produces a
// non-revocation status (e.g. UntrustedRoot). The filter's strict .All(...) predicate
// must NOT clear RemoteCertificateChainErrors in that case, so the validator rejects.
var caPair = CertificateGenerator.GenerateCACertificate();
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
chain.Build(caPair.Certificate);
// Sanity: the platform must produce at least one non-revocation status for this
// assertion to be meaningful; otherwise the environment does not exercise the
// strict-branch of the filter and we cannot assert deterministically.
var revocationFlags =
X509ChainStatusFlags.RevocationStatusUnknown | X509ChainStatusFlags.OfflineRevocation;
var hasNonRevocationStatus =
chain.ChainStatus.Any(s => (s.Status & ~revocationFlags) != 0);
if (!hasNonRevocationStatus)
{
Assert.Ignore(
"Platform did not produce a non-revocation chain status " +
$"(got: {string.Join(", ", chain.ChainStatus.Select(s => s.Status.ToString()))}). " +
"Cannot deterministically verify the strict branch of the revocation filter.");
}
var validator = new SslCertificateValidator(false, null, true);
// A chain error must still be rejected because the chain status is not revocation-only.
AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, chain, SslPolicyErrors.RemoteCertificateChainErrors));
}
[Test]
public void IgnoreRevocationFailure_EmptyChainStatus_KeepsChainErrorsAndThrows()
{
// When the chain has no status entries at all, FilterRevocationErrors returns early
// (guard at the start) and leaves RemoteCertificateChainErrors untouched, so the
// validator rejects. We build a chain that verifies cleanly to get an empty ChainStatus.
var caPair = CertificateGenerator.GenerateCACertificate();
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags;
chain.ChainPolicy.ExtraStore.Add(caPair.Certificate);
chain.Build(caPair.Certificate);
// If the platform still surfaces status entries despite the relaxed flags, the test
// would not exercise the empty-chain branch; skip in that case.
if (chain.ChainStatus.Length > 0)
{
Assert.Ignore(
$"Platform produced chain status entries despite relaxed flags " +
$"(got: {string.Join(", ", chain.ChainStatus.Select(s => s.Status.ToString()))}). " +
"Cannot deterministically verify the empty-chain branch.");
}
var validator = new SslCertificateValidator(false, null, true);
AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, chain, SslPolicyErrors.RemoteCertificateChainErrors));
}
[Test]
public void IgnoreRevocationFailure_NotEnabled_KeepsChainErrorsAndThrows()
{
// When the option is OFF, a chain error must be rejected even if the chain
// status is revocation-only (the filter only runs when the flag is set).
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(false, null, false);
AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
#endregion
#region Default parameter / construction
[Test]
public void Constructor_OmitsIgnoreRevocationFailure_DefaultsToFalse()
{
// The optional parameter defaults to false, so the revocation filter must
// NOT run and a chain error must be rejected.
var caPair = CertificateGenerator.GenerateCACertificate();
var validator = new SslCertificateValidator(false, null);
AssertThrowsInvalidCert(() =>
validator.ValidateServerCertificate(
null, caPair.Certificate, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
#endregion
}