Files
duplicati/Duplicati/UnitTest/CertificateTests.cs
T

461 lines
16 KiB
C#

// Copyright (C) 2025, 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;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Duplicati.Library.Certificates;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest;
/// <summary>
/// Unit tests for the certificate generation and storage functionality.
/// </summary>
[TestFixture]
[Category("Certificates")]
public class CertificateTests
{
#region CertificateGenerator Tests
[Test]
public void GenerateCACertificate_CreatesValidCA()
{
// Act
var caPair = CertificateGenerator.GenerateCACertificate();
// Assert
Assert.IsNotNull(caPair);
Assert.IsNotNull(caPair.Certificate);
Assert.IsNotNull(caPair.PrivateKey);
var cert = caPair.Certificate;
Assert.IsTrue(cert.HasPrivateKey);
Assert.AreEqual("CN=Duplicati Local CA, O=Duplicati, C=US", cert.Subject);
// Check basic constraints (must be CA)
var basicConstraints = cert.Extensions["2.5.29.19"] as X509BasicConstraintsExtension;
Assert.IsNotNull(basicConstraints);
Assert.IsTrue(basicConstraints!.CertificateAuthority);
Assert.IsTrue(basicConstraints.HasPathLengthConstraint);
Assert.AreEqual(0, basicConstraints.PathLengthConstraint);
// Check key usage
var keyUsage = cert.Extensions["2.5.29.15"] as X509KeyUsageExtension;
Assert.IsNotNull(keyUsage);
Assert.IsTrue((keyUsage!.KeyUsages & X509KeyUsageFlags.KeyCertSign) == X509KeyUsageFlags.KeyCertSign);
Assert.IsTrue((keyUsage.KeyUsages & X509KeyUsageFlags.CrlSign) == X509KeyUsageFlags.CrlSign);
// Check validity (approximately 10 years)
var validity = cert.NotAfter - cert.NotBefore;
Assert.That(validity.TotalDays, Is.GreaterThan(365 * 9));
Assert.That(validity.TotalDays, Is.LessThan(365 * 11));
// Check that it's self-signed
Assert.AreEqual(cert.Subject, cert.Issuer);
}
[Test]
public void GenerateServerCertificate_CreatesValidServerCert()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var hostnames = new[] { "localhost", "127.0.0.1", "test.example.com" };
// Act
var serverPair = CertificateGenerator.GenerateServerCertificate(
caPair.Certificate,
caPair.PrivateKey,
hostnames);
// Assert
Assert.IsNotNull(serverPair);
Assert.IsNotNull(serverPair.Certificate);
Assert.IsNotNull(serverPair.PrivateKey);
var cert = serverPair.Certificate;
Assert.IsTrue(cert.HasPrivateKey);
Assert.AreEqual("CN=Duplicati Server, O=Duplicati, C=US", cert.Subject);
Assert.AreEqual(caPair.Certificate.Subject, cert.Issuer);
// Check basic constraints (must NOT be CA)
var basicConstraints = cert.Extensions["2.5.29.19"] as X509BasicConstraintsExtension;
Assert.IsNotNull(basicConstraints);
Assert.IsFalse(basicConstraints!.CertificateAuthority);
// Check key usage
var keyUsage = cert.Extensions["2.5.29.15"] as X509KeyUsageExtension;
Assert.IsNotNull(keyUsage);
Assert.IsTrue((keyUsage!.KeyUsages & X509KeyUsageFlags.DigitalSignature) == X509KeyUsageFlags.DigitalSignature);
// Check extended key usage
var enhancedKeyUsage = cert.Extensions["2.5.29.37"] as X509EnhancedKeyUsageExtension;
Assert.IsNotNull(enhancedKeyUsage);
Assert.That(enhancedKeyUsage!.EnhancedKeyUsages, Has.Some.Matches<Oid>(oid => oid.Value == "1.3.6.1.5.5.7.3.1"));
// Check validity (approximately 90 days for server certificates)
var validity = cert.NotAfter - cert.NotBefore;
Assert.That(validity.TotalDays, Is.GreaterThan(85));
Assert.That(validity.TotalDays, Is.LessThan(95));
}
[Test]
public void GenerateServerCertificate_IncludesAllSANs()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var hostnames = new[] { "localhost", "127.0.0.1", "::1", "test.example.com", "192.168.1.100" };
// Act
var serverPair = CertificateGenerator.GenerateServerCertificate(
caPair.Certificate,
caPair.PrivateKey,
hostnames);
// Assert
var sanExtension = serverPair.Certificate.Extensions["2.5.29.17"] as X509SubjectAlternativeNameExtension;
Assert.IsNotNull(sanExtension);
var dnsNames = sanExtension!.EnumerateDnsNames().ToList();
var ipAddresses = sanExtension.EnumerateIPAddresses().ToList();
Assert.That(dnsNames, Contains.Item("localhost"));
Assert.That(dnsNames, Contains.Item("test.example.com"));
Assert.That(ipAddresses, Has.Some.Matches<IPAddress>(ip => ip.ToString() == "127.0.0.1"));
Assert.That(ipAddresses, Has.Some.Matches<IPAddress>(ip => ip.ToString() == "::1"));
Assert.That(ipAddresses, Has.Some.Matches<IPAddress>(ip => ip.ToString() == "192.168.1.100"));
}
[Test]
public void GenerateServerCertificate_ChainVerifies()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var hostnames = new[] { "localhost" };
var serverPair = CertificateGenerator.GenerateServerCertificate(
caPair.Certificate,
caPair.PrivateKey,
hostnames);
// Act & Assert - Build chain
using var chain = new X509Chain();
chain.ChainPolicy.ExtraStore.Add(caPair.Certificate);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
var chainBuilt = chain.Build(serverPair.Certificate);
Assert.IsTrue(chainBuilt, "Certificate chain should verify");
Assert.That(chain.ChainElements, Has.Count.GreaterThan(1));
Assert.AreEqual(caPair.Certificate.Thumbprint, chain.ChainElements[1].Certificate.Thumbprint);
}
[Test]
[Platform(Exclude = "MacOsX", Reason = "macOS keychain export limitations")]
public void CreatePfxBundle_CreatesValidPfx()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var hostnames = new[] { "localhost" };
var serverPair = CertificateGenerator.GenerateServerCertificate(
caPair.Certificate,
caPair.PrivateKey,
hostnames);
var password = "testpassword123";
// Act
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, password);
// Assert
Assert.IsNotNull(pfxBytes);
Assert.That(pfxBytes.Length, Is.GreaterThan(0));
// Try to load the PFX
var loadedCert = X509CertificateLoader.LoadPkcs12(pfxBytes, password);
Assert.IsNotNull(loadedCert);
Assert.AreEqual(serverPair.Certificate.Thumbprint, loadedCert.Thumbprint);
}
[Test]
public void IsExpiringSoon_ReturnsTrueForExpiredCertificate()
{
// Create a certificate that expired yesterday
var caPair = CertificateGenerator.GenerateCACertificate();
Assert.IsFalse(CertificateGenerator.IsExpiringSoon(caPair.Certificate, 30));
}
#endregion
#region HostnameDetector Tests
[Test]
public void DetectHostnames_IncludesAlwaysIncludedHostnames()
{
// Act
var hostnames = HostnameDetector.DetectHostnames();
// Assert
Assert.That(hostnames, Contains.Item("localhost"));
Assert.That(hostnames, Contains.Item("127.0.0.1"));
Assert.That(hostnames, Contains.Item("::1"));
}
[Test]
public void DetectHostnames_IncludesMachineName()
{
// Act
var hostnames = HostnameDetector.DetectHostnames();
// Assert
Assert.That(hostnames, Contains.Item(Environment.MachineName));
}
[Test]
public void DetectHostnames_NoDuplicates()
{
// Act
var hostnames = HostnameDetector.DetectHostnames();
// Assert
var distinctCount = hostnames.Distinct(StringComparer.OrdinalIgnoreCase).Count();
Assert.AreEqual(distinctCount, hostnames.Count);
}
[Test]
public void ValidateHostnames_AcceptsValidHostnames()
{
// Arrange
var input = new[] { "localhost", "example.com", "192.168.1.1", "::1" };
// Act
var result = HostnameDetector.ValidateHostnames(input);
// Assert
Assert.That(result, Contains.Item("localhost"));
Assert.That(result, Contains.Item("example.com"));
Assert.That(result, Contains.Item("192.168.1.1"));
Assert.That(result, Contains.Item("::1"));
}
[Test]
public void ValidateHostnames_RejectsInvalidHostnames()
{
// Arrange
var input = new[] { "", " ", "not valid", "also@invalid" };
// Act
var result = HostnameDetector.ValidateHostnames(input);
// Assert
Assert.IsEmpty(result);
}
[Test]
public void ValidateHostnames_TrimsWhitespace()
{
// Arrange
var input = new[] { " localhost ", " example.com " };
// Act
var result = HostnameDetector.ValidateHostnames(input);
// Assert
Assert.That(result, Contains.Item("localhost"));
Assert.That(result, Contains.Item("example.com"));
}
#endregion
#region CertificateStorageHelper Tests
[Test]
public void SerializeDeserializeCertificate_RoundTripsCorrectly()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var originalCert = caPair.Certificate;
// Act
var serialized = CertificateStorageHelper.SerializeCertificate(originalCert);
var deserialized = CertificateStorageHelper.DeserializeCertificate(serialized);
// Assert
Assert.AreEqual(originalCert.Thumbprint, deserialized.Thumbprint);
Assert.AreEqual(originalCert.Subject, deserialized.Subject);
Assert.AreEqual(originalCert.Issuer, deserialized.Issuer);
}
[Test]
[Platform(Exclude = "MacOsX", Reason = "macOS keychain export limitations")]
public void SerializeDeserializeCertificateWithKey_RoundTripsCorrectly()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var password = "testpassword123";
// Act
var serialized = CertificateStorageHelper.SerializeCertificateWithKey(caPair.Certificate, password);
var deserialized = CertificateStorageHelper.DeserializeCertificateWithKey(serialized, password);
// Assert
Assert.AreEqual(caPair.Certificate.Thumbprint, deserialized.Thumbprint);
Assert.IsTrue(deserialized.HasPrivateKey);
}
[Test]
public void EncryptDecryptPrivateKey_RoundTripsCorrectly()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var originalKey = caPair.PrivateKey;
var password = "encryptionpassword";
// Act
var encrypted = CertificateStorageHelper.EncryptPrivateKey(originalKey, password);
var decrypted = CertificateStorageHelper.DecryptPrivateKey(encrypted, password);
// Assert
var originalParams = originalKey.ExportParameters(true);
var decryptedParams = decrypted.ExportParameters(true);
Assert.AreEqual(Convert.ToBase64String(originalParams.D!),
Convert.ToBase64String(decryptedParams.D!));
}
[Test]
public void SerializeDeserializeCACertificatePair_RoundTripsCorrectly()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var password = "testpassword123";
// Act
var (certBase64, encryptedKey) = CertificateStorageHelper.SerializeCACertificatePair(caPair, password);
var deserialized = CertificateStorageHelper.DeserializeCACertificatePair(certBase64, encryptedKey, password);
// Assert
Assert.AreEqual(caPair.Certificate.Thumbprint, deserialized.Certificate.Thumbprint);
var originalParams = caPair.PrivateKey.ExportParameters(true);
var deserializedParams = deserialized.PrivateKey.ExportParameters(true);
Assert.AreEqual(Convert.ToBase64String(originalParams.D!),
Convert.ToBase64String(deserializedParams.D!));
}
[Test]
public void SerializeDeserializeServerCertificatePair_RoundTripsCorrectly()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var hostnames = new[] { "localhost" };
var serverPair = CertificateGenerator.GenerateServerCertificate(
caPair.Certificate,
caPair.PrivateKey,
hostnames);
var password = "testpassword123";
// Act
var serialized = CertificateStorageHelper.SerializeServerCertificatePair(serverPair, password);
var deserialized = CertificateStorageHelper.DeserializeServerCertificatePair(serialized, password);
// Assert
Assert.AreEqual(serverPair.Certificate.Thumbprint, deserialized.Certificate.Thumbprint);
Assert.IsTrue(deserialized.Certificate.HasPrivateKey);
}
[Test]
public void ExportImportPem_RoundTripsCorrectly()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var originalCert = caPair.Certificate;
// Act
var pem = CertificateStorageHelper.ExportToPem(originalCert);
var imported = CertificateStorageHelper.ImportFromPem(pem);
// Assert
Assert.AreEqual(originalCert.Thumbprint, imported.Thumbprint);
Assert.IsTrue(pem.StartsWith("-----BEGIN CERTIFICATE-----"));
Assert.IsTrue(pem.EndsWith("-----END CERTIFICATE-----" + Environment.NewLine));
}
[Test]
[Platform(Exclude = "MacOsX", Reason = "macOS keychain export limitations")]
public void SerializeCertificateWithKey_WrongPassword_ThrowsException()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var correctPassword = "correctpassword";
var wrongPassword = "wrongpassword";
var serialized = CertificateStorageHelper.SerializeCertificateWithKey(caPair.Certificate, correctPassword);
// Act & Assert
Assert.Throws<CryptographicException>(() =>
CertificateStorageHelper.DeserializeCertificateWithKey(serialized, wrongPassword));
}
[Test]
public void DecryptPrivateKey_WrongPassword_ThrowsException()
{
// Arrange
var caPair = CertificateGenerator.GenerateCACertificate();
var correctPassword = "correctpassword";
var wrongPassword = "wrongpassword";
var encrypted = CertificateStorageHelper.EncryptPrivateKey(caPair.PrivateKey, correctPassword);
// Act & Assert
Assert.Throws<SharpAESCrypt.WrongPasswordException>(() =>
CertificateStorageHelper.DecryptPrivateKey(encrypted, wrongPassword));
}
#endregion
#region CATrustInstallerFactory Tests
[Test]
public void CreateInstaller_ReturnsInstaller()
{
// Act
var installer = CATrustInstallerFactory.CreateInstaller(null, null, null);
// Assert
Assert.IsNotNull(installer);
Assert.IsTrue(installer.CanInstall());
}
[Test]
public void IsPlatformSupported_ReturnsTrue()
{
// Act & Assert
Assert.IsTrue(CATrustInstallerFactory.IsPlatformSupported());
}
#endregion
}