// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Text.Json;
using JsonSignature;
using System.Net.Http.Json;
using Duplicati.Library.Logging;
namespace Duplicati.Proprietary.LicenseChecker;
///
/// Class for permitting license checks
///
public static class LicenseChecker
{
///
/// The log tag for license checker
///
public static string LOGTAG = Log.LogTagFromType(typeof(LicenseChecker));
///
/// The license server URL
///
public static string ServerUrl
{
get
{
#if DEBUG
var envUrl = Environment.GetEnvironmentVariable("DUPLICATI_LICENSE_SERVER_URL");
if (!string.IsNullOrWhiteSpace(envUrl))
return envUrl;
#endif
return "https://licenses.duplicati.com/obtain-license";
}
}
///
/// The license request data sent to the license server
///
/// The license key
private sealed record LicenseRequestData(string LicenseKey);
///
/// The JSON options
///
private static readonly JsonSerializerOptions DefaultJsonOptions
= new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
///
/// Ensures that the license includes the required features
///
/// The license data
/// The required features
public static void EnsureFeatures(this LicenseData license, params string[] requiredFeatures)
{
var features = new Dictionary(license.Features ?? new Dictionary(), StringComparer.OrdinalIgnoreCase);
var missing = requiredFeatures.Where(x => !features.ContainsKey(x)).ToList();
if (missing.Count == 0)
return;
throw new InvalidLicenseException($"License does not include required features: {string.Join(", ", missing)}");
}
///
/// Obtains the license data for the given license key
///
/// The key to check
/// The cancellation token
/// The license data if the license is valid
public static async Task ObtainLicenseAsync(string licenseKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(licenseKey))
throw new ArgumentException("License key cannot be null or empty", nameof(licenseKey));
if (licenseKey.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var filePath = licenseKey[5..];
if (string.IsNullOrWhiteSpace(filePath))
throw new ArgumentException("License file path cannot be null or empty", nameof(licenseKey));
if (!File.Exists(filePath))
throw new FileNotFoundException("License file not found", Path.GetFullPath(filePath));
await using var fileStream = File.OpenRead(filePath);
return await CheckLicenseAsync(fileStream, PublicKey, cancellationToken);
}
else if (licenseKey.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
{
var base64Data = licenseKey[7..];
if (string.IsNullOrWhiteSpace(base64Data))
throw new ArgumentException("License base64 data cannot be null or empty", nameof(licenseKey));
byte[] decodedData;
try
{
decodedData = Convert.FromBase64String(base64Data);
}
catch (FormatException ex)
{
throw new ArgumentException("License base64 data is not valid base64", nameof(licenseKey), ex);
}
await using var memoryStream = new MemoryStream(decodedData);
return await CheckLicenseAsync(memoryStream, PublicKey, cancellationToken);
}
else
{
using var httpClient = new HttpClient();
using var content = JsonContent.Create(new LicenseRequestData(licenseKey));
using var response = await httpClient.PostAsync(ServerUrl, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(message))
throw new InvalidLicenseException($"Failed to obtain license: {message}");
throw new InvalidLicenseException($"Failed to obtain license: {response.StatusCode} {response.ReasonPhrase}");
}
return await CheckLicenseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), PublicKey, cancellationToken);
}
}
///
/// Gets the embedded public key used to verify licenses
///
private static string PublicKey => LazyPublicKey.Value;
///
/// Gets the embedded public key used to verify licenses
///
private static Lazy LazyPublicKey { get; } = new Lazy(() =>
{
#if DEBUG
var envKey = Environment.GetEnvironmentVariable("DUPLICATI_LICENSE_PUBLIC_KEY");
if (!string.IsNullOrWhiteSpace(envKey))
return envKey;
#endif
var assembly = typeof(LicenseChecker).Assembly;
var name = assembly.GetManifestResourceNames().FirstOrDefault(x => x.EndsWith(".PublicKey.xml", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(name))
throw new InvalidOperationException("Embedded public key resource not found");
using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException("Embedded public key resource not found");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
});
///
/// Checks the license from the provided stream using the given public key
///
/// The license stream
/// The public key to verify the license
/// A cancellation token
/// The license data if the license is valid
private static async Task CheckLicenseAsync(Stream source, string publicKey, CancellationToken cancellationToken)
{
if (source == null)
throw new ArgumentException("Source cannot be null or empty", nameof(source));
if (string.IsNullOrWhiteSpace(publicKey))
throw new ArgumentException("Public key cannot be null or empty", nameof(publicKey));
try
{
var isValid = JSONSignature.VerifyAtLeastOne(source, [new JSONSignature.VerifyOperation(
Algorithm: JSONSignature.RSA_SHA256, PublicKey: publicKey
)]);
if (!isValid)
throw new InvalidLicenseException("Invalid license signature");
var licenseData = await JsonSerializer.DeserializeAsync(source, DefaultJsonOptions, cancellationToken);
if (licenseData == null)
throw new InvalidLicenseException("Failed to deserialize license data");
if (!licenseData.IsValidNow && !licenseData.IsInGracePeriod)
throw new InvalidLicenseException("License is not valid at the current date");
if (licenseData.IsInGracePeriod)
Log.WriteWarningMessage(LOGTAG, "LicenseExpired", null, "License expired on {ValidTo:yyyy-MM-dd}, but is within the grace period until {ExpirationWithGrace:yyyy-MM-dd}.", licenseData.ValidTo, licenseData.ValidToWithGrace);
return licenseData;
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to validate license", ex);
}
}
}