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