diff --git a/Duplicati.Library.RestAPI/Database/Database schema/8. Encrypted fields.sql b/Duplicati.Library.RestAPI/Database/Database schema/8. Encrypted fields.sql new file mode 100644 index 000000000..61249c621 --- /dev/null +++ b/Duplicati.Library.RestAPI/Database/Database schema/8. Encrypted fields.sql @@ -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"; + diff --git a/Duplicati.Library.RestAPI/Database/Database schema/Schema.sql b/Duplicati.Library.RestAPI/Database/Database schema/Schema.sql index 5b692931d..75b024632 100644 --- a/Duplicati.Library.RestAPI/Database/Database schema/Schema.sql +++ b/Duplicati.Library.RestAPI/Database/Database schema/Schema.sql @@ -164,5 +164,5 @@ CREATE TABLE "TokenFamily" ( "LastUpdated" INTEGER NOT NULL ); -INSERT INTO "Version" ("Version") VALUES (7); +INSERT INTO "Version" ("Version") VALUES (8); diff --git a/Duplicati/Library/Encryption/EncryptedFieldHelper.cs b/Duplicati/Library/Encryption/EncryptedFieldHelper.cs index 62c7887cd..7bce51de1 100644 --- a/Duplicati/Library/Encryption/EncryptedFieldHelper.cs +++ b/Duplicati/Library/Encryption/EncryptedFieldHelper.cs @@ -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 { /// - /// 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 /// - private static readonly string ActiveKey = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY")) - ? DeviceIDHelper.GetDeviceIDHash() - : Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY"); - - /// - /// Hash of the key to be used for encryption - /// - private static readonly string KeyHash; - - /// - /// Static constructor to compute the hash of the key - /// - static EncryptedFieldHelper() + /// The key to use + /// The key hash + public sealed record KeyInstance(string Key, string Hash) { - using var hasher = HashFactory.CreateHasher(HashFactory.SHA256); - KeyHash = ActiveKey.ComputeHashToHex(hasher); + /// + /// 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)); + 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)); + } } + /// + /// The default key to be used for encryption + /// + private static readonly KeyInstance DefaultKey = KeyInstance.CreateKey( + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY")) + ? DeviceIDHelper.GetDeviceIDHash() + : Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY") + ); + /// /// Prefix used to identify an encrypted field /// @@ -79,6 +89,20 @@ public static class EncryptedFieldHelper /// data from the field /// Unencrypted data of the field public static string Decrypt(string? value) + => Decrypt(value, DefaultKey); + + /// + /// 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 + 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 /// /// The encrypted string public static string Encrypt(string value) + => Encrypt(value, DefaultKey); + + /// + /// 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) { 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(); diff --git a/Duplicati/UnitTest/EncryptedFieldHelperTests.cs b/Duplicati/UnitTest/EncryptedFieldHelperTests.cs index 74dc907bd..9e85a8b2f 100644 --- a/Duplicati/UnitTest/EncryptedFieldHelperTests.cs +++ b/Duplicati/UnitTest/EncryptedFieldHelperTests.cs @@ -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)