Files
duplicati/Duplicati/Library/Encryption/EncryptedFieldHelper.cs
T
Kenneth Skovhede e009a0f3ec Remote source masking and encrypting
This PR adds masking to remote sources so the passwords are not leaked to the browser. The logic works the same as for TargetURL, where the sensitive fields are replaced by a password placeholder before being transmitted via the endpoints.

When data returns, the masking is removed by looking at the original source and the unmasked value is then encrypted before being stored in the database.

Since the sources is an array and not just a single field, the logic here uses the mount point as a "key" for figuring out which new sources map to the original sources. This is robust in the face of re-ordering, deletions and additions.

If there is just a single remote source (assumed most common) this will match even if the prefix does not, making it possible to change the prefix in this case.

For multi-remote sources, it is not possible to change the prefix without also re-typing all masked properties.

If there is a need later, we could extend the matching to look for how many unique protocols are there, and maybe servers as well to try to match better when the prefixes have been renamed.

There is a guard right before saving a source that rejects it if the source contains the placeholder so we ensure that even faulty logic cannot persist an invalid connection string.
2026-01-30 14:36:57 +01:00

177 lines
7.1 KiB
C#

// 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;
/// <summary>
/// Class used to encrypt and decrypt settings in a way that is backwards compatible
/// with previous versions of Duplicati.
/// </summary>
public static class EncryptedFieldHelper
{
/// <summary>
/// Key instance, isolating the current key and its hash
/// </summary>
/// <param name="Key">The key to use</param>
/// <param name="Hash">The key hash</param>
/// <param name="IsBlacklisted">If the key is blacklisted</param>
public sealed record KeyInstance(string Key, string Hash, bool IsBlacklisted)
{
/// <summary>
/// Creates a new key instance
/// </summary>
/// <param name="key">The key to use</param>
/// <returns>The key instance</returns>
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));
}
/// <summary>
/// Creates a key instance if the key is valid
/// </summary>
/// <param name="key">The key to create</param>
/// <returns>The key instance or <c>null</c> if the key is invalid</returns>
public static KeyInstance? CreateKeyIfValid(string? key)
=> string.IsNullOrWhiteSpace(key) ? null : CreateKey(key);
}
/// <summary>
/// Checks if a key is blacklisted
/// </summary>
/// <param name="key">The key to check</param>
/// <returns><c>true</c> if the key is blacklisted; <c>false</c> otherwise</returns>
public static bool IsKeyBlacklisted(string key)
=> DeviceIDHelper.EMPTY_DEVICE_ID_HASHES.Contains(key);
/// <summary>
/// Prefix used to identify an encrypted field
/// </summary>
public const string HEADER_PREFIX = "enc-v1:";
/// <summary>
/// The name of the enviroment variable that holds the encryption key
/// </summary>
public const string ENVIROMENT_VARIABLE_NAME = "SETTINGS_ENCRYPTION_KEY";
/// <summary>
/// Checks if a value is an encrypted string
/// </summary>
/// <param name="value">The value to decrypt</param>
/// <returns><c>true</c> if the string is encrypted; <c>false</c> otherwise</returns>
public static bool IsEncryptedString(string? value)
=> !string.IsNullOrWhiteSpace(value) && value.StartsWith(HEADER_PREFIX);
/// <summary>
/// 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
/// </summary>
/// <param name="value">data from the field</param>
/// <param name="key">The key to use for decryption</param>
/// <returns>Unencrypted data of the field</returns>
[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;
}
/// <summary>
/// Encrypts a value to be stored in the database.
/// </summary>
/// <param name="value"></param>
/// <param name="key">The key to use for encryption</param>
/// <returns>The encrypted string</returns>
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();
}
}