Ctrl K
ComunidadGuías…

src/ClipDatabaseFile.cs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
using System;
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;

namespace Clipman
{
    internal sealed class DatabasePasswordRequiredException : Exception
    {
        public DatabasePasswordRequiredException(string message) : base(message) { }
    }

    internal static class ClipDatabaseFile
    {
        public const string CompressedExtension = ".clipdb";
        internal const int MaximumDecompressedDatabaseBytes = 256 * 1024 * 1024;
        internal const long MaximumLocalDatabaseFileBytes = 272L * 1024L * 1024L;
        private static readonly byte[] CompressedMagic = Encoding.ASCII.GetBytes("CLIPDB1");
        private static readonly byte[] EncryptedMagic = Encoding.ASCII.GetBytes("CLIPDB2");
        private static readonly object KeyCacheLock = new object();
        private static string cachedKeyId;
        private static KeyPair cachedKeys;

        public static ClipDatabase Load(string path)
        {
            return Load(path, string.Empty);
        }

        public static ClipDatabase Load(string path, string password)
        {
            return Load<ClipDatabase>(path, password);
        }

        public static T Load<T>(string path, string password) where T : new()
        {
            if (!File.Exists(path))
            {
                return new T();
            }

            string text;
            if (IsEncryptedFile(path))
            {
                text = ReadEncryptedText(path, password);
            }
            else
            {
                text = IsCompressedPath(path) ? ReadCompressedText(path) : ReadPlainText(path);
            }
            return JsonUtil.Deserialize<T>(text);
        }

        public static void SaveAtomic(string path, ClipDatabase database)
        {
            SaveAtomic(path, database, string.Empty);
        }

        public static void SaveAtomic(string path, ClipDatabase database, string password)
        {
            SaveAtomic<ClipDatabase>(path, database, password);
        }

        public static void SaveAtomic<T>(string path, T database, string password) where T : new()
        {
            SaveAtomic(path, database, password, null);
        }

        /// <summary>
        /// Saves a container that shares its PBKDF2 salt with <paramref name="saltSourcePath"/> when it
        /// is created for the first time, so one key derivation serves the history database and every
        /// sync channel beside it (sync-rules-spec.md section 5, "Salt sharing"). An existing file
        /// always keeps its own salt.
        /// </summary>
        public static void SaveAtomic<T>(string path, T database, string password, string saltSourcePath) where T : new()
        {
            if (!string.IsNullOrEmpty(password) && IsCompressedPath(path))
            {
                SaveEncryptedAtomic(path, database, password, saltSourcePath);
                return;
            }

            if (IsCompressedPath(path))
            {
                SaveCompressedAtomic(path, database);
                return;
            }

            SavePlainAtomic(path, database);
        }

        public static bool IsEncryptedFile(string path)
        {
            return HasMagic(path, EncryptedMagic);
        }

        private static bool IsCompressedPath(string path)
        {
            return string.Equals(Path.GetExtension(path), CompressedExtension, StringComparison.OrdinalIgnoreCase);
        }

        private static string ReadCompressedText(string path)
        {
            using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                ValidateLocalDatabaseFileLength(file.Length);
                if (StartsWithMagic(file, CompressedMagic))
                {
                    file.Position = CompressedMagic.Length;
                }
                else
                {
                    file.Position = 0;
                }

                using (var gzip = new GZipStream(file, CompressionMode.Decompress))
                {
                    return DecodeUtf8(ReadBounded(gzip, MaximumDecompressedDatabaseBytes));
                }
            }
        }

        private static string ReadPlainText(string path)
        {
            using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                if (file.Length > MaximumDecompressedDatabaseBytes)
                {
                    throw new InvalidDataException("The Clipman JSON database exceeds the 256 MiB safety limit.");
                }
                return DecodeUtf8(ReadBounded(file, MaximumDecompressedDatabaseBytes));
            }
        }

        private static bool HasMagic(string path, byte[] magic)
        {
            if (!File.Exists(path)) return false;
            try
            {
                using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
                {
                    return StartsWithMagic(file, magic);
                }
            }
            catch
            {
                return false;
            }
        }

        private static bool StartsWithMagic(Stream stream, byte[] magic)
        {
            var buffer = new byte[magic.Length];
            if (stream.Read(buffer, 0, buffer.Length) != buffer.Length) return false;
            for (var i = 0; i < magic.Length; i++)
            {
                if (buffer[i] != magic[i]) return false;
            }
            return true;
        }

        private static void WriteCompressedPayload(Stream output, byte[] serialized)
        {
            output.Write(CompressedMagic, 0, CompressedMagic.Length);
            using (var gzip = new GZipStream(output, CompressionMode.Compress, true))
            {
                gzip.Write(serialized, 0, serialized.Length);
            }
        }

        private static void SaveCompressedAtomic<T>(string path, T database) where T : new()
        {
            var dir = Path.GetDirectoryName(path);
            if (!string.IsNullOrEmpty(dir))
            {
                Directory.CreateDirectory(dir);
            }

            var serialized = SerializeBounded(database);
            var temp = path + ".tmp";
            using (var file = File.Create(temp))
            {
                WriteCompressedPayload(file, serialized);
            }
            if (new FileInfo(temp).Length > MaximumLocalDatabaseFileBytes)
            {
                File.Delete(temp);
                throw new InvalidDataException("The Clipman database would exceed the 272 MiB local-container safety limit.");
            }

            ReplaceTemp(temp, path);
        }

        private static void SavePlainAtomic<T>(string path, T database) where T : new()
        {
            var dir = Path.GetDirectoryName(path);
            if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
            var temp = path + ".tmp";
            File.WriteAllBytes(temp, SerializeBounded(database));
            ReplaceTemp(temp, path);
        }

        private static string ReadEncryptedText(string path, string password)
        {
            if (string.IsNullOrEmpty(password))
            {
                throw new DatabasePasswordRequiredException("This Clipman database is encrypted and needs its history password.");
            }

            byte[] bytes;
            using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                ValidateLocalDatabaseFileLength(file.Length);
                bytes = ReadExact(file, checked((int)file.Length));
            }
            if (bytes.Length < EncryptedMagic.Length + 1 + 16 + 16 + 32)
            {
                throw new InvalidDataException("The encrypted Clipman database is incomplete.");
            }

            var offset = EncryptedMagic.Length;
            var version = bytes[offset++];
            if (version != 1)
            {
                throw new InvalidDataException("This encrypted Clipman database uses an unsupported format.");
            }

            var salt = Slice(bytes, offset, 16);
            offset += 16;
            var iv = Slice(bytes, offset, 16);
            offset += 16;
            var hmac = Slice(bytes, bytes.Length - 32, 32);
            var cipherLength = bytes.Length - offset - 32;
            var keys = DeriveKeys(password, salt);
            using (var h = new HMACSHA256(keys.MacKey))
            using (var signed = new MemoryStream(bytes, 0, bytes.Length - 32, false))
            {
                if (!ConstantTimeEquals(h.ComputeHash(signed), hmac))
                {
                    throw new DatabasePasswordRequiredException("The Clipman database password is incorrect.");
                }
            }

            using (var aes = Aes.Create())
            {
                aes.Key = keys.EncryptionKey;
                aes.IV = iv;
                aes.Mode = CipherMode.CBC;
                aes.Padding = PaddingMode.PKCS7;
                using (var decryptor = aes.CreateDecryptor())
                {
                    var plain = decryptor.TransformFinalBlock(bytes, offset, cipherLength);
                    var compressed = DecompressBytes(plain);
                    return DecodeUtf8(compressed);
                }
            }
        }

        private static void SaveEncryptedAtomic<T>(string path, T database, string password, string saltSourcePath) where T : new()
        {
            var dir = Path.GetDirectoryName(path);
            if (!string.IsNullOrEmpty(dir))
            {
                Directory.CreateDirectory(dir);
            }

            var temp = path + ".tmp";
            var salt = ExistingEncryptedSalt(path) ??
                (string.IsNullOrEmpty(saltSourcePath) ? null : ExistingEncryptedSalt(saltSourcePath)) ??
                RandomBytes(16);
            var iv = RandomBytes(16);
            var keys = DeriveKeys(password, salt);
            var plain = SerializeBounded(database);
            var compressed = CompressBytes(plain);
            byte[] cipher;
            using (var aes = Aes.Create())
            {
                aes.Key = keys.EncryptionKey;
                aes.IV = iv;
                aes.Mode = CipherMode.CBC;
                aes.Padding = PaddingMode.PKCS7;
                using (var encryptor = aes.CreateEncryptor())
                {
                    cipher = encryptor.TransformFinalBlock(compressed, 0, compressed.Length);
                }
            }

            using (var memory = new MemoryStream())
            {
                memory.Write(EncryptedMagic, 0, EncryptedMagic.Length);
                memory.WriteByte(1);
                memory.Write(salt, 0, salt.Length);
                memory.Write(iv, 0, iv.Length);
                memory.Write(cipher, 0, cipher.Length);
                using (var h = new HMACSHA256(keys.MacKey))
                {
                    var mac = h.ComputeHash(memory.GetBuffer(), 0, checked((int)memory.Length));
                    memory.Write(mac, 0, mac.Length);
                }
                var payload = memory.ToArray();
                if (payload.LongLength > MaximumLocalDatabaseFileBytes)
                {
                    throw new InvalidDataException("The Clipman database would exceed the 272 MiB local-container safety limit.");
                }
                File.WriteAllBytes(temp, payload);
            }

            ReplaceTemp(temp, path);
        }

        private static byte[] CompressBytes(byte[] value)
        {
            using (var output = new MemoryStream())
            {
                using (var gzip = new GZipStream(output, CompressionMode.Compress))
                {
                    gzip.Write(value, 0, value.Length);
                }
                return output.ToArray();
            }
        }

        private static byte[] SerializeBounded<T>(T database) where T : new()
        {
            var serialized = Encoding.UTF8.GetBytes(JsonUtil.SerializePretty(database));
            if (serialized.LongLength > MaximumDecompressedDatabaseBytes)
            {
                throw new InvalidDataException("The Clipman database would expand beyond the 256 MiB safety limit.");
            }
            return serialized;
        }

        private static byte[] DecompressBytes(byte[] value)
        {
            using (var input = new MemoryStream(value))
            using (var gzip = new GZipStream(input, CompressionMode.Decompress))
            {
                return ReadBounded(gzip, MaximumDecompressedDatabaseBytes);
            }
        }

        internal static byte[] ReadBounded(Stream input, int maximumBytes)
        {
            if (input == null) throw new ArgumentNullException("input");
            if (maximumBytes < 1) throw new ArgumentOutOfRangeException("maximumBytes");
            using (var output = new MemoryStream())
            {
                var buffer = new byte[64 * 1024];
                while (true)
                {
                    var read = input.Read(buffer, 0, buffer.Length);
                    if (read <= 0) break;
                    if (output.Length + read > maximumBytes)
                    {
                        throw new InvalidDataException("The Clipman database expands beyond the 256 MiB safety limit.");
                    }
                    output.Write(buffer, 0, read);
                }
                return output.ToArray();
            }
        }

        internal static void ValidateLocalDatabaseFileLength(long length)
        {
            if (length < 0 || length > MaximumLocalDatabaseFileBytes)
            {
                throw new InvalidDataException("The Clipman database file exceeds the 272 MiB local-container safety limit.");
            }
        }

        internal static byte[] ReadExact(Stream input, int count)
        {
            if (input == null) throw new ArgumentNullException("input");
            if (count < 0 || count > MaximumLocalDatabaseFileBytes) throw new ArgumentOutOfRangeException("count");
            var output = new byte[count];
            var offset = 0;
            while (offset < output.Length)
            {
                var read = input.Read(output, offset, output.Length - offset);
                if (read <= 0) throw new EndOfStreamException("The Clipman database file ended unexpectedly.");
                offset += read;
            }
            return output;
        }

        private static string DecodeUtf8(byte[] value)
        {
            if (value == null || value.Length == 0) return string.Empty;
            var offset = value.Length >= 3 && value[0] == 0xef && value[1] == 0xbb && value[2] == 0xbf ? 3 : 0;
            return Encoding.UTF8.GetString(value, offset, value.Length - offset);
        }

        private static KeyPair DeriveKeys(string password, byte[] salt)
        {
            var cacheId = CacheId(password, salt);
            lock (KeyCacheLock)
            {
                if (cachedKeys != null && string.Equals(cachedKeyId, cacheId, StringComparison.Ordinal))
                {
                    return cachedKeys.Clone();
                }
            }

            using (var derive = new Rfc2898DeriveBytes(password, salt, 150000))
            {
                var keys = new KeyPair
                {
                    EncryptionKey = derive.GetBytes(32),
                    MacKey = derive.GetBytes(32)
                };

                lock (KeyCacheLock)
                {
                    cachedKeyId = cacheId;
                    cachedKeys = keys.Clone();
                }

                return keys;
            }
        }

        private static string CacheId(string password, byte[] salt)
        {
            using (var sha = SHA256.Create())
            {
                return Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes((password ?? string.Empty) + "\0" + Convert.ToBase64String(salt ?? new byte[0]))));
            }
        }

        private static byte[] ExistingEncryptedSalt(string path)
        {
            try
            {
                if (!IsEncryptedFile(path)) return null;
                var buffer = new byte[EncryptedMagic.Length + 1 + 16];
                using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
                {
                    if (file.Read(buffer, 0, buffer.Length) != buffer.Length) return null;
                }
                return Slice(buffer, EncryptedMagic.Length + 1, 16);
            }
            catch
            {
                return null;
            }
        }

        private static byte[] RandomBytes(int length)
        {
            var bytes = new byte[length];
            using (var rng = RandomNumberGenerator.Create())
            {
                rng.GetBytes(bytes);
            }
            return bytes;
        }

        private static byte[] Slice(byte[] source, int offset, int length)
        {
            var result = new byte[length];
            Buffer.BlockCopy(source, offset, result, 0, length);
            return result;
        }

        private static bool ConstantTimeEquals(byte[] left, byte[] right)
        {
            if (left == null || right == null || left.Length != right.Length) return false;
            var diff = 0;
            for (var i = 0; i < left.Length; i++)
            {
                diff |= left[i] ^ right[i];
            }
            return diff == 0;
        }

        private static void ReplaceTemp(string temp, string path)
        {
            if (File.Exists(path))
            {
                File.Replace(temp, path, null);
            }
            else
            {
                File.Move(temp, path);
            }
        }

        private sealed class KeyPair
        {
            public byte[] EncryptionKey { get; set; }
            public byte[] MacKey { get; set; }

            public KeyPair Clone()
            {
                return new KeyPair
                {
                    EncryptionKey = (byte[])EncryptionKey.Clone(),
                    MacKey = (byte[])MacKey.Clone()
                };
            }
        }
    }
}