Merge pull request #2 from duplicati/feature/settingsencryption/update2
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
This update does nothing but the user cannot really downgrade,
|
||||
because the fields that are encrypted cannot be read by the previous version.
|
||||
*/
|
||||
SELECT COUNT(*) FROM "Notification";
|
||||
|
||||
@@ -164,5 +164,5 @@ CREATE TABLE "TokenFamily" (
|
||||
"LastUpdated" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO "Version" ("Version") VALUES (7);
|
||||
INSERT INTO "Version" ("Version") VALUES (8);
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Utility;
|
||||
@@ -34,27 +33,38 @@ namespace Duplicati.Library.Encryption;
|
||||
public static class EncryptedFieldHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds the key to be used for encryption, either from a self computed key
|
||||
/// which uses the deviceid hash, or from an environment variable set by the user
|
||||
/// Key instance, isolating the current key and its hash
|
||||
/// </summary>
|
||||
private static readonly string ActiveKey = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY"))
|
||||
? DeviceIDHelper.GetDeviceIDHash()
|
||||
: Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY");
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the key to be used for encryption
|
||||
/// </summary>
|
||||
private static readonly string KeyHash;
|
||||
|
||||
/// <summary>
|
||||
/// Static constructor to compute the hash of the key
|
||||
/// </summary>
|
||||
static EncryptedFieldHelper()
|
||||
/// <param name="Key">The key to use</param>
|
||||
/// <param name="Hash">The key hash</param>
|
||||
public sealed record KeyInstance(string Key, string Hash)
|
||||
{
|
||||
using var hasher = HashFactory.CreateHasher(HashFactory.SHA256);
|
||||
KeyHash = ActiveKey.ComputeHashToHex(hasher);
|
||||
/// <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));
|
||||
if (key.Length < 8)
|
||||
throw new ArgumentException("Key must be at least 8 characters long", nameof(key));
|
||||
|
||||
using var hasher = HashFactory.CreateHasher(HashFactory.SHA256);
|
||||
return new KeyInstance(key, key.ComputeHashToHex(hasher));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The default key to be used for encryption
|
||||
/// </summary>
|
||||
private static readonly KeyInstance DefaultKey = KeyInstance.CreateKey(
|
||||
string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY"))
|
||||
? DeviceIDHelper.GetDeviceIDHash()
|
||||
: Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY")
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Prefix used to identify an encrypted field
|
||||
/// </summary>
|
||||
@@ -79,6 +89,20 @@ public static class EncryptedFieldHelper
|
||||
/// <param name="value">data from the field</param>
|
||||
/// <returns>Unencrypted data of the field</returns>
|
||||
public static string Decrypt(string? value)
|
||||
=> Decrypt(value, DefaultKey);
|
||||
|
||||
/// <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>
|
||||
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))
|
||||
@@ -104,11 +128,11 @@ public static class EncryptedFieldHelper
|
||||
// Content hashes match therefore it is probed as encrypted, the next
|
||||
// step is to verify the encryption keys hashes match.
|
||||
|
||||
if (keyHash != KeyHash)
|
||||
if (keyHash != key.Hash)
|
||||
throw new SettingsEncryptionKeyMismatchException();
|
||||
|
||||
// Lets then decrypt it.
|
||||
return AESStringEncryption.DecryptFromHex(ActiveKey, content);
|
||||
return AESStringEncryption.DecryptFromHex(key.Key, content);
|
||||
}
|
||||
|
||||
// if the hashes don't match, the lenght criteria can be ignored,
|
||||
@@ -123,14 +147,23 @@ public static class EncryptedFieldHelper
|
||||
/// <param name="value"></param>
|
||||
/// <returns>The encrypted string</returns>
|
||||
public static string Encrypt(string value)
|
||||
=> Encrypt(value, DefaultKey);
|
||||
|
||||
/// <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)
|
||||
{
|
||||
using var hasher = HashFactory.CreateHasher(HashFactory.SHA256);
|
||||
var encrypted = AESStringEncryption.EncryptToHex(ActiveKey, value);
|
||||
var encrypted = AESStringEncryption.EncryptToHex(key.Key, value);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(HEADER_PREFIX);
|
||||
sb.Append(encrypted.ComputeHashToHex(hasher));
|
||||
sb.Append(KeyHash);
|
||||
sb.Append(key.Hash);
|
||||
sb.Append(encrypted);
|
||||
|
||||
return sb.ToString();
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System;
|
||||
using System.CodeDom;
|
||||
using System.Linq;
|
||||
using Duplicati.Library.Encryption;
|
||||
using Duplicati.Library.Interface;
|
||||
@@ -37,17 +36,17 @@ namespace Duplicati.UnitTest
|
||||
{
|
||||
|
||||
// This password was used to compute the encrypted value, so it should not be changed.
|
||||
string encryptionKeyForTest = "long and good password";
|
||||
string sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
string sampleEncryptedTargerURL = "enc-v1:2F9E5DFE5824792C31843AF6B242C40414D1B671B36E70361CF9DB8B0F502310138E362F5B564379CDE40F73BC96D4EAB0B949CC82A592D0194040FA08DD49B241455302000000F9B39F7AF68292D631E05A254E433C2F416D68B57C8DD633B94EBB452A7275585EDC7D71CC5187B083E49FDF9E927C10E6FEA7C925A0BA4E83C7CFC985FD925B011A5AB863532EE6877BE79B6BAA6E0871917822B4DB7456135F982D2E80B94C236CEA5AB41F55D32B4B0AA4981607E2E939A45CF80F3E38603AB1CB8127196F5DB1C869B575BC651D9E2840E5551A9178526BCCDC82867171CD40527FB443E8727B8EBB13F70A9C415D80F8AC649A12C075376F632C4A3408ACEFC7D5C14EBB0D4F91E0AC9DE00A33E42E62B1E03CBE5D1CB5F79609F5CCE4872D8F414485BED8C40DFCE4A3423A09996A477AEB1DB709A437F43FA272B416A8309F0A76C9552CC5BE2C501AC7BB427ABFBF60ADEFA2C7";
|
||||
var encryptionKeyForTest = "long and good password";
|
||||
var sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
var sampleEncryptedTargerURL = "enc-v1:2F9E5DFE5824792C31843AF6B242C40414D1B671B36E70361CF9DB8B0F502310138E362F5B564379CDE40F73BC96D4EAB0B949CC82A592D0194040FA08DD49B241455302000000F9B39F7AF68292D631E05A254E433C2F416D68B57C8DD633B94EBB452A7275585EDC7D71CC5187B083E49FDF9E927C10E6FEA7C925A0BA4E83C7CFC985FD925B011A5AB863532EE6877BE79B6BAA6E0871917822B4DB7456135F982D2E80B94C236CEA5AB41F55D32B4B0AA4981607E2E939A45CF80F3E38603AB1CB8127196F5DB1C869B575BC651D9E2840E5551A9178526BCCDC82867171CD40527FB443E8727B8EBB13F70A9C415D80F8AC649A12C075376F632C4A3408ACEFC7D5C14EBB0D4F91E0AC9DE00A33E42E62B1E03CBE5D1CB5F79609F5CCE4872D8F414485BED8C40DFCE4A3423A09996A477AEB1DB709A437F43FA272B416A8309F0A76C9552CC5BE2C501AC7BB427ABFBF60ADEFA2C7";
|
||||
|
||||
Environment.SetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY", encryptionKeyForTest);
|
||||
var key = EncryptedFieldHelper.KeyInstance.CreateKey(encryptionKeyForTest);
|
||||
|
||||
// Sample URL is not encrypted, so it should not suffer transformation and be returned as is
|
||||
Assert.AreEqual(EncryptedFieldHelper.Decrypt(sampleTargerURL), sampleTargerURL);
|
||||
Assert.AreEqual(EncryptedFieldHelper.Decrypt(sampleTargerURL, key), sampleTargerURL);
|
||||
|
||||
// SampleEncrypted URL is encrypted, so it should be decrypted and returned matching the unencrypted version
|
||||
Assert.AreEqual(EncryptedFieldHelper.Decrypt(sampleEncryptedTargerURL), sampleTargerURL);
|
||||
Assert.AreEqual(EncryptedFieldHelper.Decrypt(sampleEncryptedTargerURL, key), sampleTargerURL);
|
||||
|
||||
}
|
||||
|
||||
@@ -56,17 +55,17 @@ namespace Duplicati.UnitTest
|
||||
public static void TestTamperingFirstHash()
|
||||
{
|
||||
|
||||
string encryptionKeyForTest = "long and good password";
|
||||
string sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
|
||||
Environment.SetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY", encryptionKeyForTest);
|
||||
var encryptionKeyForTest = "long and good password";
|
||||
var sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
|
||||
var sampleEncryptedTargerURL = EncryptedFieldHelper.Encrypt(sampleTargerURL);
|
||||
var key = EncryptedFieldHelper.KeyInstance.CreateKey(encryptionKeyForTest);
|
||||
|
||||
var sampleEncryptedTargerURL = EncryptedFieldHelper.Encrypt(sampleTargerURL, key);
|
||||
// Tampering now with the first bytes of encrypted string, which is the content hash,
|
||||
|
||||
|
||||
var tamperingTest1 = $"{sampleEncryptedTargerURL.Substring(0, 64).Reverse()}{sampleEncryptedTargerURL.Substring(64)}";
|
||||
|
||||
var tamperedDecryption = EncryptedFieldHelper.Decrypt(tamperingTest1);
|
||||
var tamperedDecryption = EncryptedFieldHelper.Decrypt(tamperingTest1, key);
|
||||
|
||||
// Because the hash is tampered, the EncryptedFieldHelper will not perceive the record as a valid encrypted field, and will return
|
||||
// as is, so the returned value should be equal to the tampered value
|
||||
@@ -79,12 +78,12 @@ namespace Duplicati.UnitTest
|
||||
[Category("FieldEncryption")]
|
||||
public static void TestTamperingKeyHash()
|
||||
{
|
||||
string encryptionKeyForTest = "long and good password";
|
||||
string sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
|
||||
Environment.SetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY", encryptionKeyForTest);
|
||||
var encryptionKeyForTest = "long and good password";
|
||||
var sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
|
||||
var sampleEncryptedTargerURL = EncryptedFieldHelper.Encrypt(sampleTargerURL);
|
||||
var key = EncryptedFieldHelper.KeyInstance.CreateKey(encryptionKeyForTest);
|
||||
|
||||
var sampleEncryptedTargerURL = EncryptedFieldHelper.Encrypt(sampleTargerURL, key);
|
||||
|
||||
// Tampering with the encryptionhey hash, this should throw a SettingsEncryptionKeyMismatchException
|
||||
|
||||
@@ -93,12 +92,12 @@ namespace Duplicati.UnitTest
|
||||
// Remove the prefix to tamper with the message structure
|
||||
sampleEncryptedTargerURL = sampleEncryptedTargerURL.Substring(EncryptedFieldHelper.HEADER_PREFIX.Length);
|
||||
|
||||
var tamperingTest = $"{sampleEncryptedTargerURL.Substring(0, 64)}{new string (sampleEncryptedTargerURL.Substring(64, 64).Reverse().ToList().ToArray())}{sampleEncryptedTargerURL.Substring(128)}";
|
||||
var tamperingTest = $"{sampleEncryptedTargerURL.Substring(0, 64)}{new string(sampleEncryptedTargerURL.Substring(64, 64).Reverse().ToList().ToArray())}{sampleEncryptedTargerURL.Substring(128)}";
|
||||
|
||||
// Restore the prefix, in this test, we are specifically triggering the exception by tampering the keyhash
|
||||
tamperingTest = EncryptedFieldHelper.HEADER_PREFIX + tamperingTest;
|
||||
|
||||
var tamperedDecryption = EncryptedFieldHelper.Decrypt(tamperingTest);
|
||||
var tamperedDecryption = EncryptedFieldHelper.Decrypt(tamperingTest, key);
|
||||
|
||||
Assert.Fail("Expected SettingsEncryptionKeyMismatchException, got: " + tamperedDecryption);
|
||||
}
|
||||
@@ -119,14 +118,14 @@ namespace Duplicati.UnitTest
|
||||
public static void EncryptAndDecryptUsingDeviceID()
|
||||
{
|
||||
|
||||
string sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
|
||||
string encrypted = EncryptedFieldHelper.Encrypt(sampleTargerURL);
|
||||
var sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
var key = EncryptedFieldHelper.KeyInstance.CreateKey(DeviceIDHelper.GetDeviceIDHash());
|
||||
var encrypted = EncryptedFieldHelper.Encrypt(sampleTargerURL, key);
|
||||
|
||||
Assert.IsNotNull(encrypted);
|
||||
Assert.IsNotEmpty(encrypted);
|
||||
|
||||
string decrypted = EncryptedFieldHelper.Decrypt(encrypted);
|
||||
var decrypted = EncryptedFieldHelper.Decrypt(encrypted, key);
|
||||
|
||||
Assert.IsNotNull(decrypted);
|
||||
Assert.IsNotEmpty(decrypted);
|
||||
@@ -136,33 +135,31 @@ namespace Duplicati.UnitTest
|
||||
|
||||
[Test]
|
||||
[Category("FieldEncryption")]
|
||||
public static void EncryptAndDecryptUsingEnvironment()
|
||||
public static void EncryptAndDecryptUsingCustomKey()
|
||||
{
|
||||
|
||||
string sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
var sampleTargerURL = "s3://awsid-bucket/folder/?s3-location-constraint=us-east-2&s3-storage-class=&s3-client=aws&auth-username=AWSID&auth-password=AWSACCESSKEY";
|
||||
|
||||
Environment.SetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY", "a good and long password");
|
||||
var key = EncryptedFieldHelper.KeyInstance.CreateKey("a good and long password");
|
||||
|
||||
string encrypted = EncryptedFieldHelper.Encrypt(sampleTargerURL);
|
||||
var encrypted = EncryptedFieldHelper.Encrypt(sampleTargerURL, key);
|
||||
|
||||
Assert.IsNotNull(encrypted);
|
||||
Assert.IsNotEmpty(encrypted);
|
||||
|
||||
string decrypted = EncryptedFieldHelper.Decrypt(encrypted);
|
||||
var decrypted = EncryptedFieldHelper.Decrypt(encrypted, key);
|
||||
|
||||
Assert.IsNotNull(decrypted);
|
||||
Assert.IsNotEmpty(decrypted);
|
||||
Assert.AreEqual(decrypted, sampleTargerURL);
|
||||
|
||||
// So far, this tests does not ensure it is using the environment variable, so lets check that
|
||||
// by changing the environment variable and checking if it still works, it should throw
|
||||
// a SettingsKeymismatchException
|
||||
|
||||
Environment.SetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY", string.Empty);
|
||||
|
||||
try
|
||||
{
|
||||
string secondtest = EncryptedFieldHelper.Decrypt(encrypted);
|
||||
// So far, this tests does not ensure it is using the default key, so lets check that
|
||||
// by using the default key and checking if it still works, it should throw
|
||||
// a SettingsKeymismatchException
|
||||
var secondtest = EncryptedFieldHelper.Decrypt(encrypted);
|
||||
|
||||
}
|
||||
catch (SettingsEncryptionKeyMismatchException)
|
||||
|
||||
Reference in New Issue
Block a user