// 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); } } }