// 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. #nullable enable using System; using System.Diagnostics.CodeAnalysis; using System.Text; using Duplicati.Library.Interface; using Duplicati.Library.Utility; namespace Duplicati.Library.Encryption; /// /// Class used to encrypt and decrypt settings in a way that is backwards compatible /// with previous versions of Duplicati. /// public static class EncryptedFieldHelper { /// /// Key instance, isolating the current key and its hash /// /// The key to use /// The key hash /// If the key is blacklisted public sealed record KeyInstance(string Key, string Hash, bool IsBlacklisted) { /// /// Creates a new key instance /// /// The key to use /// The key instance public static KeyInstance CreateKey(string key) { if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key), Strings.EncryptedFieldHelper.KeyEmptyError); if (key.Length < 8) throw new ArgumentException(Strings.EncryptedFieldHelper.KeyTooShortError, nameof(key)); using var hasher = HashFactory.CreateHasher(HashFactory.SHA256); return new KeyInstance(key, key.ComputeHashToHex(hasher), IsKeyBlacklisted(key)); } /// /// Creates a key instance if the key is valid /// /// The key to create /// The key instance or null if the key is invalid public static KeyInstance? CreateKeyIfValid(string? key) => string.IsNullOrWhiteSpace(key) ? null : CreateKey(key); } /// /// Checks if a key is blacklisted /// /// The key to check /// true if the key is blacklisted; false otherwise public static bool IsKeyBlacklisted(string key) => DeviceIDHelper.EMPTY_DEVICE_ID_HASHES.Contains(key); /// /// Prefix used to identify an encrypted field /// public const string HEADER_PREFIX = "enc-v1:"; /// /// The name of the enviroment variable that holds the encryption key /// public const string ENVIROMENT_VARIABLE_NAME = "SETTINGS_ENCRYPTION_KEY"; /// /// Checks if a value is an encrypted string /// /// The value to decrypt /// true if the string is encrypted; false otherwise public static bool IsEncryptedString(string value) => !string.IsNullOrWhiteSpace(value) && value.StartsWith(HEADER_PREFIX); /// /// Decrypts a value from the database, if it is not encrypted, it will be returned as is. /// /// If the value is encrypted, it will be decrypted using the key obtained from ActiveKey. /// /// The check for encryption is done by checking the prefix of the string. /// An additional check is done by hashing the content and comparing it to the hash /// /// data from the field /// The key to use for decryption /// Unencrypted data of the field [return: NotNullIfNotNull("value")] public static string? Decrypt(string? value, KeyInstance? key) { // If the value is not encrypted, it will be returned as is. if (string.IsNullOrEmpty(value) || !value.StartsWith(HEADER_PREFIX)) return value; if (key == null) throw new SettingsEncryptionKeyMissingException(); value = value.Substring(HEADER_PREFIX.Length); using var hasher = HashFactory.CreateHasher(HashFactory.SHA256); // For clarity, HashSize is size in bits / 8 for bytes, then times two because an encrypted field // is prefixed with two hashes before var hashSizeInBytes = hasher.HashSize / 8 * 2; // Value may be encrypted, to ensure, we will parse everything after // the mark of hashesCombinedSize as content, hash it and check if matches prefix. var contentHash = value.Substring(0, hashSizeInBytes); var keyHash = value.Substring(hashSizeInBytes, hashSizeInBytes); var content = value.Substring(hashSizeInBytes * 2); if (contentHash == content.ComputeHashToHex(hasher)) { // Content hashes match therefore it is probed as encrypted, the next // step is to verify the encryption keys hashes match. if (keyHash != key.Hash) throw new SettingsEncryptionKeyMismatchException(); // Lets then decrypt it. return AESStringEncryption.DecryptFromHex(key.Key, content); } // if the hashes don't match, the lenght criteria can be ignored, // and it will be returned as is. return value; } /// /// Encrypts a value to be stored in the database. /// /// /// The key to use for encryption /// The encrypted string public static string Encrypt(string value, KeyInstance? key) { if (key == null) throw new SettingsEncryptionKeyMissingException(); if (key.IsBlacklisted) throw new InvalidOperationException(Strings.EncryptedFieldHelper.KeyBlacklistedError); using var hasher = HashFactory.CreateHasher(HashFactory.SHA256); var encrypted = AESStringEncryption.EncryptToHex(key.Key, value); var sb = new StringBuilder(); sb.Append(HEADER_PREFIX); sb.Append(encrypted.ComputeHashToHex(hasher)); sb.Append(key.Hash); sb.Append(encrypted); return sb.ToString(); } }