355 lines
14 KiB
C#
355 lines
14 KiB
C#
// 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.Formats.Asn1;
|
|
using System.Net;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
namespace Duplicati.Library.Certificates;
|
|
|
|
/// <summary>
|
|
/// Represents a certificate authority with its certificate and private key.
|
|
/// </summary>
|
|
/// <param name="Certificate">The CA certificate.</param>
|
|
/// <param name="PrivateKey">The CA private key.</param>
|
|
public record CACertificatePair(X509Certificate2 Certificate, ECDsa PrivateKey);
|
|
|
|
/// <summary>
|
|
/// Represents a server certificate with its certificate and private key.
|
|
/// </summary>
|
|
/// <param name="Certificate">The server certificate.</param>
|
|
/// <param name="PrivateKey">The server private key.</param>
|
|
public record ServerCertificatePair(X509Certificate2 Certificate, ECDsa PrivateKey);
|
|
|
|
/// <summary>
|
|
/// Generates certificates for HTTPS server using ECDSA P-256 keys.
|
|
/// </summary>
|
|
public static class CertificateGenerator
|
|
{
|
|
/// <summary>
|
|
/// Time a CA is valid, for now 10 years.
|
|
/// </summary>
|
|
private static readonly TimeSpan CA_VALIDITY = TimeSpan.FromDays(365 * 10);
|
|
/// <summary>
|
|
/// Time a server certificate is valid, for now 90 days to satisfy Browser limits.
|
|
/// </summary>
|
|
private static readonly TimeSpan SERVER_VALIDITY = TimeSpan.FromDays(90);
|
|
/// <summary>
|
|
/// Common name for the CA certificate.
|
|
/// </summary>
|
|
private static readonly string CA_COMMON_NAME = $"{AutoUpdater.AutoUpdateSettings.AppName} Local CA";
|
|
/// <summary>
|
|
/// Common name for the server certificate.
|
|
/// </summary>
|
|
private static readonly string SERVER_COMMON_NAME = $"{AutoUpdater.AutoUpdateSettings.AppName} Server";
|
|
|
|
/// <summary>
|
|
/// Generates a self-signed CA certificate with CA constraints.
|
|
/// </summary>
|
|
/// <returns>A CA certificate pair containing the certificate and private key.</returns>
|
|
public static CACertificatePair GenerateCACertificate()
|
|
{
|
|
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
|
|
var request = new CertificateRequest(
|
|
$"CN={CA_COMMON_NAME}, O=Duplicati, C=US",
|
|
key,
|
|
HashAlgorithmName.SHA256);
|
|
|
|
// Add CA basic constraints with pathLenConstraint=0 (this CA cannot sign other CAs)
|
|
request.CertificateExtensions.Add(
|
|
new X509BasicConstraintsExtension(
|
|
certificateAuthority: true,
|
|
hasPathLengthConstraint: true,
|
|
pathLengthConstraint: 0,
|
|
critical: true));
|
|
|
|
// Key usage for CA
|
|
request.CertificateExtensions.Add(
|
|
new X509KeyUsageExtension(
|
|
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
|
critical: true));
|
|
|
|
// Subject Key Identifier
|
|
request.CertificateExtensions.Add(
|
|
new X509SubjectKeyIdentifierExtension(
|
|
request.PublicKey,
|
|
critical: false));
|
|
|
|
// Not before now, not after validity
|
|
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); // Allow for clock skew
|
|
var notAfter = DateTimeOffset.UtcNow.Add(CA_VALIDITY);
|
|
|
|
// Create self-signed certificate
|
|
var certificate = request.CreateSelfSigned(notBefore, notAfter);
|
|
|
|
// Export and re-import to make it exportable with private key
|
|
// Use a temporary password to avoid unprotected key material in memory
|
|
var tempPassword = CertificateStorageHelper.GeneratePfxPassword();
|
|
var export = certificate.Export(X509ContentType.Pfx, tempPassword);
|
|
var certWithKey = X509CertificateLoader.LoadPkcs12(
|
|
export,
|
|
tempPassword,
|
|
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
|
|
CryptographicOperations.ZeroMemory(export);
|
|
|
|
// Create a new ECDsa instance with the same key parameters for the return value
|
|
// since we're disposing the original key
|
|
var keyParams = key.ExportECPrivateKey();
|
|
var newKey = ECDsa.Create();
|
|
newKey.ImportECPrivateKey(keyParams, out _);
|
|
CryptographicOperations.ZeroMemory(keyParams);
|
|
|
|
return new CACertificatePair(certWithKey, newKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a server certificate signed by the CA.
|
|
/// </summary>
|
|
/// <param name="caCert">The CA certificate.</param>
|
|
/// <param name="caKey">The CA private key.</param>
|
|
/// <param name="hostnames">The hostnames and IP addresses for the Subject Alternative Names (SANs).</param>
|
|
/// <returns>A server certificate pair containing the certificate and private key.</returns>
|
|
public static ServerCertificatePair GenerateServerCertificate(
|
|
X509Certificate2 caCert,
|
|
ECDsa caKey,
|
|
IEnumerable<string> hostnames)
|
|
{
|
|
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
|
|
var request = new CertificateRequest(
|
|
$"CN={SERVER_COMMON_NAME}, O=Duplicati, C=US",
|
|
key,
|
|
HashAlgorithmName.SHA256);
|
|
|
|
// Basic constraints - not a CA
|
|
request.CertificateExtensions.Add(
|
|
new X509BasicConstraintsExtension(
|
|
certificateAuthority: false,
|
|
hasPathLengthConstraint: false,
|
|
pathLengthConstraint: 0,
|
|
critical: true));
|
|
|
|
// Key usage for server
|
|
request.CertificateExtensions.Add(
|
|
new X509KeyUsageExtension(
|
|
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
|
|
critical: true));
|
|
|
|
// Extended key usage for TLS server
|
|
request.CertificateExtensions.Add(
|
|
new X509EnhancedKeyUsageExtension(
|
|
new OidCollection
|
|
{
|
|
new Oid("1.3.6.1.5.5.7.3.1", "Server Authentication")
|
|
},
|
|
critical: false));
|
|
|
|
// Subject Key Identifier
|
|
request.CertificateExtensions.Add(
|
|
new X509SubjectKeyIdentifierExtension(
|
|
request.PublicKey,
|
|
critical: false));
|
|
|
|
// Authority Key Identifier
|
|
// Only need to check for the Subject Key Identifier extension or public key availability
|
|
var skiExtension = caCert.Extensions["2.5.29.14"];
|
|
if (skiExtension != null || caCert.GetECDsaPublicKey() != null)
|
|
{
|
|
request.CertificateExtensions.Add(
|
|
AuthorityKeyIdentifierExtension.Create(
|
|
caCert,
|
|
false));
|
|
}
|
|
|
|
// Subject Alternative Names (SANs)
|
|
var sanBuilder = new SubjectAlternativeNameBuilder();
|
|
foreach (var hostname in hostnames.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
if (IPAddress.TryParse(hostname, out var ipAddress))
|
|
sanBuilder.AddIpAddress(ipAddress);
|
|
else
|
|
sanBuilder.AddDnsName(hostname);
|
|
}
|
|
request.CertificateExtensions.Add(sanBuilder.Build());
|
|
|
|
// Not before now (with clock skew buffer), not after validity
|
|
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
|
|
var notAfter = DateTimeOffset.UtcNow.Add(SERVER_VALIDITY);
|
|
|
|
// Get the CA certificate with private key for signing
|
|
X509Certificate2 caCertWithKey;
|
|
IDisposable? certToDispose = null;
|
|
if (caCert.HasPrivateKey)
|
|
{
|
|
caCertWithKey = caCert;
|
|
}
|
|
else
|
|
{
|
|
caCertWithKey = caCert.CopyWithPrivateKey(caKey);
|
|
certToDispose = caCertWithKey;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Create certificate signed by CA
|
|
var serialNumber = RandomNumberGenerator.GetBytes(8);
|
|
|
|
var certificate = request.Create(
|
|
caCertWithKey,
|
|
notBefore,
|
|
notAfter,
|
|
serialNumber);
|
|
|
|
// Export and re-import with private key
|
|
var certWithKey = certificate.CopyWithPrivateKey(key);
|
|
|
|
// Export to PFX format to get a usable certificate with private key
|
|
// Use a temporary password to avoid unprotected key material in memory
|
|
var tempPassword = CertificateStorageHelper.GeneratePfxPassword();
|
|
var export = certWithKey.Export(X509ContentType.Pfx, tempPassword);
|
|
var finalCert = X509CertificateLoader.LoadPkcs12(
|
|
export,
|
|
tempPassword,
|
|
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
|
|
CryptographicOperations.ZeroMemory(export);
|
|
|
|
// Create a new ECDsa instance with the same key parameters
|
|
var keyParams = key.ExportECPrivateKey();
|
|
var newKey = ECDsa.Create();
|
|
newKey.ImportECPrivateKey(keyParams, out _);
|
|
CryptographicOperations.ZeroMemory(keyParams);
|
|
|
|
return new ServerCertificatePair(finalCert, newKey);
|
|
}
|
|
finally
|
|
{
|
|
certToDispose?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a PFX bundle containing the server certificate with its private key.
|
|
/// </summary>
|
|
/// <param name="serverCert">The server certificate pair (contains the original exportable key).</param>
|
|
/// <param name="caCert">The CA certificate pair (not included in output due to Windows limitations).</param>
|
|
/// <param name="password">The password to protect the PFX file.</param>
|
|
/// <returns>The PFX containing the server certificate with private key.</returns>
|
|
public static byte[] CreatePfxBundle(
|
|
ServerCertificatePair serverCert,
|
|
CACertificatePair caCert,
|
|
string password)
|
|
{
|
|
// On Windows, X509Certificate2Collection.Export fails with ECDSA keys
|
|
// due to CAPI/CNG limitations.
|
|
// To work around this, we manually re-create the certificates
|
|
// so the keys are not bound to CAPI/CNG and then export.
|
|
|
|
// Load just the certificate without any key
|
|
using var certOnly = X509CertificateLoader.LoadCertificate(serverCert.Certificate.RawData);
|
|
using var caCertOnly = X509CertificateLoader.LoadCertificate(caCert.Certificate.RawData);
|
|
|
|
// Create a new certificate with the private key explicitly associated
|
|
// using the original exportable key from the pair
|
|
using var serverCertWithKey = certOnly.CopyWithPrivateKey(serverCert.PrivateKey);
|
|
var collection = new X509Certificate2Collection
|
|
{
|
|
caCertOnly,
|
|
serverCertWithKey
|
|
};
|
|
|
|
return collection.Export(X509ContentType.Pfx, password) ?? throw new Exception("Failed to generate PFX bundle?");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a certificate is expired or will expire within the specified number of days.
|
|
/// </summary>
|
|
/// <param name="certificate">The certificate to check.</param>
|
|
/// <param name="daysBeforeExpiration">Number of days before expiration to consider as expiring.</param>
|
|
/// <returns>True if the certificate is expired or will expire within the specified days.</returns>
|
|
public static bool IsExpiringSoon(X509Certificate2 certificate, int daysBeforeExpiration = 30)
|
|
{
|
|
return certificate.NotAfter < DateTimeOffset.UtcNow.AddDays(daysBeforeExpiration);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper class for creating Authority Key Identifier extensions.
|
|
/// </summary>
|
|
internal static class AuthorityKeyIdentifierExtension
|
|
{
|
|
/// <summary>
|
|
/// Creates an Authority Key Identifier extension from a CA certificate.
|
|
/// </summary>
|
|
/// <param name="caCert">The CA certificate.</param>
|
|
/// <param name="critical">Whether the extension is critical.</param>
|
|
/// <returns>An Authority Key Identifier extension.</returns>
|
|
public static X509Extension Create(X509Certificate2 caCert, bool critical)
|
|
{
|
|
// Calculate the Subject Key Identifier from the CA certificate
|
|
var subjectKeyId = GetSubjectKeyIdentifier(caCert);
|
|
|
|
if (subjectKeyId == null)
|
|
{
|
|
// Return empty extension if no subject key ID available
|
|
return new X509Extension(
|
|
"2.5.29.35",
|
|
new byte[] { 0x30, 0x00 }, // Empty SEQUENCE
|
|
critical);
|
|
}
|
|
|
|
// Build the AuthorityKeyIdentifier ASN.1 structure
|
|
var writer = new AsnWriter(AsnEncodingRules.DER);
|
|
writer.PushSequence();
|
|
|
|
// keyIdentifier [0] IMPLICIT OCTET STRING OPTIONAL
|
|
// Write as context-specific tagged octet string
|
|
writer.WriteOctetString(subjectKeyId, new Asn1Tag(TagClass.ContextSpecific, 0, true));
|
|
|
|
writer.PopSequence();
|
|
|
|
return new X509Extension(
|
|
"2.5.29.35", // Authority Key Identifier OID
|
|
writer.Encode(),
|
|
critical);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the Subject Key Identifier from a certificate.
|
|
/// </summary>
|
|
/// <param name="certificate">The certificate to get the SKI from.</param>
|
|
/// <returns>The Subject Key Identifier as a byte array, or null if not available.</returns>
|
|
private static byte[]? GetSubjectKeyIdentifier(X509Certificate2 certificate)
|
|
{
|
|
var skiExtension = certificate.Extensions["2.5.29.14"];
|
|
if (skiExtension is X509SubjectKeyIdentifierExtension ski && ski.SubjectKeyIdentifier != null)
|
|
return Convert.FromHexString(ski.SubjectKeyIdentifier);
|
|
|
|
// Calculate SKI from public key if not present
|
|
using var key = certificate.GetECDsaPublicKey();
|
|
if (key != null)
|
|
return SHA1.HashData(key.ExportSubjectPublicKeyInfo());
|
|
|
|
return null;
|
|
}
|
|
}
|