diff --git a/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs new file mode 100644 index 0000000..f95b90e --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs @@ -0,0 +1,51 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace KeyKeeper.PasswordStore.Crypto; + +public class CompositeKey +{ + public string Password { get; } + public byte[]? Salt + { + get { return salt; } + set + { + if (salt == null) + salt = value; + } + } + + private byte[]? salt; + + public bool CanComputeHash + { + get { return salt != null; } + } + + public CompositeKey(string password, byte[]? salt) + { + if (password == null) + throw new ArgumentNullException("password"); + Password = password; + + if (salt != null && (salt.Length < FileFormatConstants.MIN_MASTER_SALT_LEN || + salt.Length > FileFormatConstants.MAX_MASTER_SALT_LEN)) + throw new ArgumentException("salt"); + this.salt = salt; + } + + public byte[] Hash() + { + if (!CanComputeHash) + throw new InvalidOperationException("salt is not set"); + byte[] passwordBytes = Encoding.UTF8.GetBytes(Password); + byte[] hashedString = new byte[passwordBytes.Length + Salt.Length * 2]; + Salt.CopyTo(hashedString, 0); + passwordBytes.CopyTo(hashedString, Salt.Length); + Salt.CopyTo(hashedString, Salt.Length + passwordBytes.Length); + + return SHA256.HashData(hashedString); + } +} diff --git a/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs b/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs new file mode 100644 index 0000000..1231fb0 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs @@ -0,0 +1,42 @@ +using System; +using System.Security.Cryptography; + +namespace KeyKeeper.PasswordStore.Crypto.KeyDerivation; + +public class AesKdf : MasterKeyDerivationFunction +{ + public const int MIN_ROUNDS = 10; + public const int MAX_ROUNDS = 25_000_000; + public const int SEED_LENGTH = 32; + + private int rounds; + private byte[] seed; + + public AesKdf(int rounds, byte[] seed) + { + if (rounds < MIN_ROUNDS || rounds > MAX_ROUNDS) + throw new ArgumentOutOfRangeException(nameof(rounds)); + if (seed.Length != SEED_LENGTH) + throw new ArgumentException("seed length must be " + SEED_LENGTH); + this.rounds = rounds; + this.seed = seed; + } + + public override byte[] Derive(CompositeKey source, int keySizeBytes) + { + if (keySizeBytes > SEED_LENGTH) + throw new ArgumentOutOfRangeException(nameof(keySizeBytes)); + + byte[] key = source.Hash()[..SEED_LENGTH]; + byte[] nextKey = new byte[SEED_LENGTH]; + Aes cipher = Aes.Create(); + cipher.KeySize = SEED_LENGTH * 8; + for (int i = 0; i < rounds; ++i) + { + cipher.Key = key; + cipher.EncryptEcb(seed, nextKey, PaddingMode.None); + (nextKey, key) = (key, nextKey); + } + return key; + } +} diff --git a/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/MasterKeyDerivationFunction.cs b/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/MasterKeyDerivationFunction.cs new file mode 100644 index 0000000..6118e25 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/MasterKeyDerivationFunction.cs @@ -0,0 +1,6 @@ +namespace KeyKeeper.PasswordStore.Crypto; + +public abstract class MasterKeyDerivationFunction +{ + public abstract byte[] Derive(CompositeKey source, int keySizeBytes); +} diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs new file mode 100644 index 0000000..035215b --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs @@ -0,0 +1,204 @@ +using System; +using System.IO; +using System.Security.Cryptography; + +namespace KeyKeeper.PasswordStore.Crypto; + +public class OuterEncryptionReader : Stream +{ + public override bool CanRead => true; + public override bool CanWrite => false; + public override bool CanSeek => false; + public override bool CanTimeout => false; + public override int ReadTimeout + { + get { throw new InvalidOperationException(); } + set { throw new InvalidOperationException(); } + } + public override int WriteTimeout + { + get { throw new InvalidOperationException(); } + set { throw new InvalidOperationException(); } + } + public override long Position + { + get => position; + set { throw new NotSupportedException(); } + } + public override long Length => throw new NotSupportedException(); + + private FileStream file; + private byte[] key; + private Aes aes; + private ICryptoTransform decryptor; + + /// + /// Последний считанный из файла расшифрованный чанк. Первые + /// байт - уже отданные при вызовах Read, + /// остальные - еще не отданные. + /// + private byte[]? currentChunk; + private int chunkPosition = 0; + /// + /// Порядковый номер чанка, лежащего в . + /// + private int nextChunkOrdinal = 0; + private bool isCurrentChunkLast; + private long position = 0; + + /// + /// Ещё не расшифрованные байты, которые были считаны из файла, но их + /// оказалось меньше, чем вмещает блок AES (менее 16 байт). + /// + private byte[] encryptedRemainder; + /// + /// Количество полезных байт в . + /// + private int encryptedRemainderLength; + + /// + /// Создаёт экземпляр reader, использующий файловый поток для чтения. + /// + /// Файловый поток, указатель которого должен стоять на + /// первом content chunk. + /// Ключ, который будет использован для проверки HMAC + /// и расшифровки содержимого. + public OuterEncryptionReader(FileStream file, byte[] key, byte[] iv) + { + aes = Aes.Create(); + aes.KeySize = 256; + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CFB; + aes.Padding = PaddingMode.None; + decryptor = aes.CreateDecryptor(); + + this.file = file; + this.key = key; + currentChunk = null; + encryptedRemainder = new byte[16]; + encryptedRemainderLength = 0; + LoadAndDecryptNextChunk(); + } + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, + AsyncCallback? callback, object? state) + => throw new NotSupportedException(); + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, + AsyncCallback? callback, object? state) + => throw new NotSupportedException(); + + public override void Flush() + {} + + public override int EndRead(IAsyncResult asyncResult) + => throw new NotSupportedException(); + public override void EndWrite(IAsyncResult asyncResult) + => throw new NotSupportedException(); + + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + => Read(new Span(buffer, offset, count)); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + public override void Write(ReadOnlySpan buffer) + => throw new NotSupportedException(); + + public override int Read(Span buffer) + { + Console.WriteLine("OE read " + buffer.Length); + int toRead = buffer.Length; + int read = 0; + while (toRead > 0) + { + if (currentChunk == null || currentChunk.Length - chunkPosition == 0) + { + if (!isCurrentChunkLast) + { + Console.WriteLine("OER: reading next chunk"); + LoadAndDecryptNextChunk(); + } + else + { + Console.WriteLine("OER: read " + read + " bytes before EOF"); + break; + } + } + byte[] chunk = currentChunk!; + int n = Math.Min(toRead, chunk.Length - chunkPosition); + Console.WriteLine("OER: copy " + n + " bytes chunk+" + chunkPosition + " -> buffer+" + read); + new Span(chunk, chunkPosition, n).CopyTo(buffer.Slice(read)); + read += n; + toRead -= n; + chunkPosition += n; + position += n; + Console.WriteLine(string.Format("read={0} toread={1} pos={2}", read, toRead, chunkPosition)); + } + return read; + } + + private void LoadAndDecryptNextChunk() + { + if (isCurrentChunkLast) + return; + var chunk = PassStoreContentChunk.GetFromStream(file, key, nextChunkOrdinal); + nextChunkOrdinal += 1; + isCurrentChunkLast = chunk.IsLast; + var encryptedData = chunk.GetContent(); + EraseCurrentChunk(); + + int decrypted = 0, read = 0; + if (isCurrentChunkLast) + currentChunk = new byte[encryptedData.Length + encryptedRemainderLength]; + else + currentChunk = new byte[(encryptedData.Length + encryptedRemainderLength) / 16 * 16]; + if (encryptedRemainderLength > 0 && encryptedData.Length >= 16 - encryptedRemainderLength) + { + encryptedData.Slice(0, 16 - encryptedRemainderLength) + .CopyTo(new Span(encryptedRemainder, encryptedRemainderLength, 16 - encryptedRemainderLength)); + decryptor.TransformBlock(encryptedRemainder, 0, 16, currentChunk, 0); + decrypted = 16; + read = 16 - encryptedRemainderLength; + encryptedRemainderLength = 0; + } + if (!isCurrentChunkLast) + { + int wholeBlocksLen = (encryptedData.Length - decrypted) / 16 * 16; + if (wholeBlocksLen > 0) + { + byte[] blocks = new byte[wholeBlocksLen]; + encryptedData.Slice(read, wholeBlocksLen).CopyTo(blocks); + decryptor.TransformBlock(blocks, 0, wholeBlocksLen, currentChunk, decrypted); + decrypted += wholeBlocksLen; + read += wholeBlocksLen; + } + if (read < encryptedData.Length) + { + encryptedRemainderLength = encryptedData.Length - read; + encryptedData.Slice(read, encryptedRemainderLength).CopyTo(encryptedRemainder); + } + } else + { + byte[] finalData = new byte[encryptedData.Length - read]; + encryptedData.Slice(read).CopyTo(finalData); + byte[] decryptedFinalData = decryptor.TransformFinalBlock(finalData, 0, finalData.Length); + decryptedFinalData.CopyTo(currentChunk, decrypted); + Array.Fill(decryptedFinalData, 0); + } + chunkPosition = 0; + } + + private void EraseCurrentChunk() + { + if (currentChunk == null) return; + Array.Fill(currentChunk, 0); + currentChunk = null; + } +} diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs new file mode 100644 index 0000000..8708517 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.PasswordStore.Crypto; + +public static class OuterEncryptionUtil +{ + /// + /// Проверяет корректность заголовка внешнего шифрования, + /// который содержит соль для мастер-ключа и параметры шифрования + + /// генерации ключа. Сдвигает указатель потока f на первый байт после + /// заголовка. + /// + /// Поток, указатель которого стоит на начале заголовка + /// внешнего шифрования. + /// Если заголовок содержит некорректные поля или неполный + public static void CheckOuterEncryptionHeader(FileStream f) + { + BinaryReader rd = new(f); + try + { + byte masterSaltLen = rd.ReadByte(); + + if (masterSaltLen < MIN_MASTER_SALT_LEN || masterSaltLen > MAX_MASTER_SALT_LEN) + { + throw PassStoreFileException.InvalidCryptoHeader; + } + + f.Seek(masterSaltLen, SeekOrigin.Current); + + byte encryptAlgo = rd.ReadByte(); + + if (encryptAlgo == ENCRYPT_ALGO_AES) + { + // пропустить 16 байт вектора инициализации AES + f.Seek(16, SeekOrigin.Current); + } + else + { + throw PassStoreFileException.InvalidCryptoHeader; + } + + byte keyDerivationFunctionType = rd.ReadByte(); + + if (keyDerivationFunctionType == KDF_TYPE_AESKDF) + { + int nRounds; + try + { + nRounds = rd.Read7BitEncodedInt(); + } + catch (FormatException) + { + throw PassStoreFileException.InvalidCryptoHeader; + } + if (nRounds < MIN_AESKDF_ROUNDS || nRounds > MAX_AESKDF_ROUNDS) + { + throw PassStoreFileException.InvalidCryptoHeader; + } + // пропустить 32 байта сида AES-KDF + f.Seek(32, SeekOrigin.Current); + } + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + } +} diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs new file mode 100644 index 0000000..1357acd --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; +using System.Security.Cryptography; + +namespace KeyKeeper.PasswordStore.Crypto; + +public class OuterEncryptionWriter : Stream +{ + public override bool CanRead => false; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override bool CanTimeout => false; + public override int ReadTimeout + { + get { throw new InvalidOperationException(); } + set { throw new InvalidOperationException(); } + } + public override int WriteTimeout + { + get { throw new InvalidOperationException(); } + set { throw new InvalidOperationException(); } + } + public override long Position + { + get => position; + set { throw new NotSupportedException(); } + } + public override long Length => throw new NotSupportedException(); + + private FileStream file; + private byte[] key; + private Aes aes; + private ICryptoTransform encryptor; + + private byte[] currentChunk; + private int currentChunkOrdinal = 0; + private int chunkPosition = 0; + private long position = 0; + + public OuterEncryptionWriter(FileStream file, byte[] key, byte[] iv) + { + if (!file.CanWrite) + throw new ArgumentException("file must be writeable"); + this.file = file; + this.key = key; + + currentChunk = new byte[524288]; + + aes = Aes.Create(); + aes.KeySize = 256; + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CFB; + aes.Padding = PaddingMode.None; + encryptor = aes.CreateEncryptor(); + } + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, + AsyncCallback? callback, object? state) + => throw new NotSupportedException(); + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, + AsyncCallback? callback, object? state) + => throw new NotSupportedException(); + + public override int EndRead(IAsyncResult asyncResult) + => throw new NotSupportedException(); + public override void EndWrite(IAsyncResult asyncResult) + => throw new NotSupportedException(); + + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override int Read(Span buffer) + => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotImplementedException(); + + public override void Write(byte[] buffer, int offset, int count) + => Write(new ReadOnlySpan(buffer, offset, count)); + + public override void Write(ReadOnlySpan buffer) + { + int written = 0; + while (written < buffer.Length) + { + if (chunkPosition == currentChunk.Length) + EncryptAndStoreCurrentFullChunk(); + int n = Math.Min(buffer.Length, currentChunk.Length - chunkPosition); + buffer.Slice(written, n).CopyTo(new Span(currentChunk, chunkPosition, n)); + written += n; + chunkPosition += n; + position += n; + } + } + + /// + /// Шифрует оставшиеся данные и добавляет к файлу чанк с пометкой о том + /// что он последний. Вызов после завершения записи полезных данных + /// обязателен, в противном случае потеря данных неизбежна. + /// + public override void Flush() + { + byte[] encryptedData = encryptor.TransformFinalBlock(currentChunk, 0, chunkPosition); + PassStoreContentChunk chunk = PassStoreContentChunk.FromEncryptedContent(encryptedData, key, currentChunkOrdinal, true); + file.Write(chunk.Chunk); + EraseCurrentChunk(); + chunkPosition = 0; + } + + private void EncryptAndStoreCurrentFullChunk() + { + byte[] encryptedData = new byte[currentChunk.Length]; + encryptor.TransformBlock(currentChunk, 0, currentChunk.Length, encryptedData, 0); + PassStoreContentChunk chunk = PassStoreContentChunk.FromEncryptedContent(encryptedData, key, currentChunkOrdinal, false); + currentChunkOrdinal += 1; + file.Write(chunk.Chunk); + EraseCurrentChunk(); + chunkPosition = 0; + } + + private void EraseCurrentChunk() + { + if (currentChunk == null) return; + Array.Fill(currentChunk, 0); + } +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs new file mode 100644 index 0000000..30efa6d --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs @@ -0,0 +1,156 @@ +using System; +using System.Linq; +using System.Buffers.Binary; +using System.IO; +using System.Security.Cryptography; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.PasswordStore.Crypto; + +/// +/// Класс, представляющий собой обертку над content chunkом, считанным из файла +/// хранилища. Не расшифровывает содержимое, но проверяет целостность и +/// подлинность при создании объекта. +/// +public class PassStoreContentChunk +{ + public bool IsLast { get; } + public byte[] Chunk { get { return chunk; } } + + private byte[] chunk; + private int chunkLen; + + /// + /// Создаёт объект content chunk, считывая массив байт. Бросает исключение + /// в случае, если массив не содержит корректный content chunk или не + /// совпадает HMAC + /// + /// Массив байт, содержащий весь content chunk, включая + /// длину и HMAC + /// Ключ от хранилища. Используется только для + /// проверки HMAC и не хранится в объекте + /// Порядковый номер content chunk'а, начиная + /// с 0 + public PassStoreContentChunk(byte[] chunk, byte[] key, int chunkOrdinal) + { + this.chunk = chunk; + + MemoryStream str = new(chunk); + BinaryReader rd = new(str); + + try + { + byte[] chunkLenBytes = new byte[3]; + if (str.Read(chunkLenBytes) < 3) + throw PassStoreFileException.UnexpectedEndOfFile; + chunkLen = BinaryPrimitives.ReadUInt16LittleEndian(new(chunkLenBytes, 0, 2)); + chunkLen |= chunkLenBytes[2] << 16; + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + + IsLast = (chunkLen & (1 << 23)) != 0; + chunkLen &= ~(1 << 23); + + if (chunk.Length != chunkLen + 3 + HMAC_SIZE) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + + byte[] storedHmac = new byte[HMAC_SIZE]; + str.Read(storedHmac, 0, HMAC_SIZE); + + byte[] dataToHash = chunk[(int)str.Position..]; + byte[] actualHmac = ComputeHmac(dataToHash, key, chunkOrdinal); + + if (!storedHmac.SequenceEqual(actualHmac)) + { + throw PassStoreFileException.ContentHMACMismatch; + } + } + + private PassStoreContentChunk(byte[] chunk, bool isLast) + { + this.chunk = chunk; + this.chunkLen = chunk.Length; + this.IsLast = isLast; + } + + /// + /// Создаёт объект content chunk, считывая байты из потока. Бросает + /// исключение в случае, если массив не содержит корректный content chunk + /// или не совпадает HMAC + /// + /// Массив байт, содержащий весь content chunk, включая + /// длину и HMAC + /// Ключ от хранилища. Используется только для + /// проверки HMAC и не хранится в объекте + /// Порядковый номер content chunk'а, начиная + /// с 0 + public static PassStoreContentChunk GetFromStream(Stream s, byte[] key, int chunkOrdinal) + { + BinaryReader rd = new(s); + int chunkLen; + + byte[] chunkLenBytes = new byte[3]; + if (s.Read(chunkLenBytes) < 3) + throw PassStoreFileException.UnexpectedEndOfFile; + chunkLen = BinaryPrimitives.ReadUInt16LittleEndian(new(chunkLenBytes, 0, 2)); + chunkLen |= chunkLenBytes[2] << 16; + + chunkLen &= ~(1 << 23); // 23 бит имеет специальное значение + byte[] chunk = new byte[3 + HMAC_SIZE + chunkLen]; + Array.Copy(chunkLenBytes, chunk, 3); + if (s.Read(chunk, 3, HMAC_SIZE + chunkLen) < HMAC_SIZE + chunkLen) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + return new PassStoreContentChunk(chunk, key, chunkOrdinal); + } + + public static PassStoreContentChunk FromEncryptedContent(byte[] content, byte[] key, int chunkOrdinal, bool isLast) + { + int chunkLen = content.Length; + byte[] chunk = new byte[chunkLen + HMAC_SIZE + 3]; + BinaryPrimitives.WriteUInt16LittleEndian(new Span(chunk, 0, 2), (ushort)(chunkLen & 0xffff)); + chunk[2] = (byte)(chunkLen >> 16); + if (isLast) chunk[2] |= 1 << 7; + ComputeHmac(content, key, chunkOrdinal).CopyTo(chunk, 3); + content.CopyTo(chunk, 3 + HMAC_SIZE); + return new PassStoreContentChunk(chunk, isLast); + } + + public ReadOnlySpan GetContent() + { + return new ReadOnlySpan(chunk, 3 + HMAC_SIZE, chunkLen); + } + + private static byte[] ComputeHmac(byte[] data, byte[] key, int chunkOrdinal) + { + SHA3_512 hasher = SHA3_512.Create(); + byte[] innerKey = key.Select(x => (byte)(x ^ 0x36)).ToArray(); + byte[] outerKey = key.Select(x => (byte)(x ^ 0x5c)).ToArray(); + + hasher.TransformBlock(innerKey, 0, innerKey.Length, null, 0); + Array.Fill(innerKey, 0); // erase key after use + + hasher.TransformBlock(data, 0, data.Length, null, 0); + + byte[] encodedOrdinal = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(new Span(encodedOrdinal), chunkOrdinal); + + hasher.TransformFinalBlock(encodedOrdinal, 0, encodedOrdinal.Length); + byte[] innerHash = hasher.Hash!; + hasher.Clear(); + + hasher = SHA3_512.Create(); + hasher.TransformBlock(outerKey, 0, outerKey.Length, null, 0); + Array.Fill(outerKey, 0); + hasher.TransformFinalBlock(innerHash, 0, innerHash.Length); + byte[] hmac = hasher.Hash!; + hasher.Clear(); + return hmac; + } +} diff --git a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs new file mode 100644 index 0000000..761f5a0 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; +using KeyKeeper.PasswordStore.Crypto.KeyDerivation; + +namespace KeyKeeper.PasswordStore; + +static class FileFormatConstants +{ + public const int MIN_MASTER_SALT_LEN = 8; + public const int MAX_MASTER_SALT_LEN = 40; + public const int MIN_AESKDF_ROUNDS = AesKdf.MIN_ROUNDS; + public const int MAX_AESKDF_ROUNDS = AesKdf.MAX_ROUNDS; + public const byte ENCRYPT_ALGO_AES = 14; + public const byte KDF_TYPE_AESKDF = 195; + public const int HMAC_SIZE = HMACSHA3_512.HashSizeInBytes; + public const uint FILE_FIELD_BEGIN = 0x7853dbd5; + public const uint FILE_FIELD_INNER_CRYPTO = 0x613e91e4; + public const uint FILE_FIELD_CONFIG = 0xd36a53c0; + public const uint FILE_FIELD_STORE = 0x981f9bc8; + public const uint FILE_FIELD_END = 0x010ba81a; + public const byte ENTRY_PASS_ID = 0x00; + public const byte ENTRY_GROUP_ID = 0x01; + public const byte LOGIN_FIELD_PASSWORD_ID = 0x00; + public const byte LOGIN_FIELD_USERNAME_ID = 0x01; + public const byte LOGIN_FIELD_EMAIL_ID = 0x02; + public const byte LOGIN_FIELD_ACCOUNT_NUMBER_ID = 0x03; + public const byte LOGIN_FIELD_NOTES_ID = 0x04; + public const byte LOGIN_FIELD_CUSTOM_ID = 0xff; // пока не используется + public const byte GROUP_TYPE_ROOT = 0x00; + public const byte GROUP_TYPE_DEFAULT = 0x01; + public const byte GROUP_TYPE_FAVOURITES = 0x02; + public const byte GROUP_TYPE_SIMPLE = 0x03; + public const byte GROUP_TYPE_CUSTOM = 0xff; // пока не используется + public static readonly byte[] BEGIN_MARKER = [0x5f, 0x4f, 0xcf, 0x67, 0xc0, 0x90, 0xd0, 0xe5]; +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/FileFormatUtil.cs b/src/KeyKeeper/PasswordStore/FileFormatUtil.cs new file mode 100644 index 0000000..18ce17c --- /dev/null +++ b/src/KeyKeeper/PasswordStore/FileFormatUtil.cs @@ -0,0 +1,80 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Text; + +namespace KeyKeeper.PasswordStore; + +static class FileFormatUtil +{ + public static int WriteVarUint16(Stream str, ulong number) + { + int size = 0; + do { + ushort portion = (ushort)(number & 0x7fff); + number >>= 15; + if (number != 0) + portion |= 1 << 15; + size += 2; + str.WriteByte((byte)(portion & 0xff)); + str.WriteByte((byte)(portion >> 8)); + } + while (number != 0); + return size; + } + + public static ulong ReadVarUint16(Stream str) + { + ulong number = 0; + int i = 0; + while (true) + { + int b = str.ReadByte(); + if (b == -1) + throw PassStoreFileException.UnexpectedEndOfFile; + number |= (ulong)b << i; + i += 8; + b = str.ReadByte(); + if (b == -1) + throw PassStoreFileException.UnexpectedEndOfFile; + number |= (ulong)(b & 0x7f) << i; + if ((b & 0x80) == 0) + break; + i += 7; + } + return number; + } + + public static int WriteU8TaggedString(Stream str, string s) + { + byte[] b = Encoding.UTF8.GetBytes(s); + if (b.Length > 255) + throw new ArgumentException("string too long"); + str.WriteByte((byte)b.Length); + str.Write(b); + return b.Length + 1; + } + + public static int WriteU16TaggedString(Stream str, string s) + { + byte[] b = Encoding.UTF8.GetBytes(s); + if (b.Length > 65535) + throw new ArgumentException("string too long"); + str.WriteByte((byte)(b.Length & 0xff)); + str.WriteByte((byte)(b.Length >> 8)); + str.Write(b); + return b.Length + 2; + } + + public static string ReadU16TaggedString(Stream str) + { + byte[] lenBytes = new byte[2]; + if (str.Read(lenBytes) < 2) + throw PassStoreFileException.UnexpectedEndOfFile; + int len = BinaryPrimitives.ReadUInt16LittleEndian(lenBytes); + byte[] s = new byte[len]; + if (str.Read(s) < len) + throw PassStoreFileException.UnexpectedEndOfFile; + return Encoding.UTF8.GetString(s); + } +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/IPassStore.cs b/src/KeyKeeper/PasswordStore/IPassStore.cs new file mode 100644 index 0000000..68a1f6f --- /dev/null +++ b/src/KeyKeeper/PasswordStore/IPassStore.cs @@ -0,0 +1,13 @@ +using KeyKeeper.PasswordStore.Crypto; + +namespace KeyKeeper.PasswordStore; + +public interface IPassStore +{ + bool Locked { get; } + + IPassStoreDirectory GetRootDirectory(); + int GetTotalEntryCount(); + void Unlock(CompositeKey key); + void Lock(); +} diff --git a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs new file mode 100644 index 0000000..b5c5acc --- /dev/null +++ b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; + +namespace KeyKeeper.PasswordStore; + +public interface IPassStoreDirectory : IEnumerable +{ +} diff --git a/src/KeyKeeper/PasswordStore/LoginField.cs b/src/KeyKeeper/PasswordStore/LoginField.cs new file mode 100644 index 0000000..e4973d9 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/LoginField.cs @@ -0,0 +1,16 @@ +using System; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.PasswordStore; + +public struct LoginField +{ + public byte Type; + public Guid CustomFieldSubtype; + public required string Value; + + public override string ToString() + { + return string.Format("LoginField(type={0} {1} value={2})", Type, Type == LOGIN_FIELD_CUSTOM_ID ? "customtype=" + CustomFieldSubtype : "", Value); + } +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs new file mode 100644 index 0000000..f9588bf --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using KeyKeeper.PasswordStore.Crypto; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.PasswordStore; + +public abstract class PassStoreEntry +{ + public Guid Id { get; set; } + public DateTime CreationDate { get; protected set; } + public DateTime ModificationDate { get; set; } + public Guid IconType { get; set; } + public string Name { get; set; } + public PassStoreEntryType Type { get; set; } + + public void WriteToStream(Stream str) + { + MemoryStream tmp = new(); + BinaryWriter wr = new(tmp); + wr.Write(Id.ToByteArray()); + ulong timestamp = (ulong) new DateTimeOffset(CreationDate.ToUniversalTime()).ToUnixTimeSeconds(); + FileFormatUtil.WriteVarUint16(tmp, timestamp); + timestamp = (ulong) new DateTimeOffset(ModificationDate.ToUniversalTime()).ToUnixTimeSeconds(); + FileFormatUtil.WriteVarUint16(tmp, timestamp); + wr.Write(IconType.ToByteArray()); + FileFormatUtil.WriteU16TaggedString(tmp, Name); + wr.Write(InnerSerialize()); + byte[] serializedEntry = tmp.ToArray(); + tmp.Dispose(); + + wr = new(str); + wr.Write7BitEncodedInt(serializedEntry.Length); + wr.Write(serializedEntry); + } + + public static PassStoreEntry ReadFromStream(Stream str) + { + BinaryReader rd = new(str); + try + { + rd.Read7BitEncodedInt(); + + byte[] uuidBuffer = new byte[16]; + if (rd.Read(uuidBuffer) < 16) + throw PassStoreFileException.UnexpectedEndOfFile; + Guid id = new Guid(uuidBuffer); + + ulong timestamp = FileFormatUtil.ReadVarUint16(str); + DateTime createdAt = DateTimeOffset.FromUnixTimeSeconds((long)timestamp).UtcDateTime; + timestamp = FileFormatUtil.ReadVarUint16(str); + DateTime modifiedAt = DateTimeOffset.FromUnixTimeSeconds((long)timestamp).UtcDateTime; + + if (rd.Read(uuidBuffer) < 16) + throw PassStoreFileException.UnexpectedEndOfFile; + Guid iconType = new Guid(uuidBuffer); + + string name = FileFormatUtil.ReadU16TaggedString(str); + + byte entryType = rd.ReadByte(); + if (entryType == ENTRY_GROUP_ID) + { + return PassStoreEntryGroup.ReadFromStream(str, id, createdAt, modifiedAt, iconType, name); + } else if (entryType == ENTRY_PASS_ID) + { + return PassStoreEntryPassword.ReadFromStream(str, id, createdAt, modifiedAt, iconType, name); + } else + { + throw PassStoreFileException.InvalidPassStoreEntry; + } + } catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + } + + protected abstract byte[] InnerSerialize(); +} diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs new file mode 100644 index 0000000..9624cec --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.PasswordStore; + +public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory +{ + public byte GroupType { get; set; } + public Guid? CustomGroupSubtype { get; set; } + public List ChildEntries { get; set; } + + public PassStoreEntryGroup(Guid id, DateTime createdAt, DateTime modifiedAt, + Guid iconType, string name, byte groupType, + List? children = null, + Guid? customGroupSubtype = null) + { + Id = id; + CreationDate = createdAt; + ModificationDate = modifiedAt; + IconType = iconType; + Name = name; + GroupType = groupType; + if (GroupType == GROUP_TYPE_CUSTOM && customGroupSubtype == null) + throw new ArgumentNullException("custom group type"); + CustomGroupSubtype = customGroupSubtype; + + ChildEntries = children ?? new(); + } + + public static PassStoreEntry ReadFromStream(Stream str, Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name) + { + BinaryReader rd = new(str); + try + { + byte groupType = rd.ReadByte(); + byte[] guidBuffer = new byte[16]; + Guid? customGroupSubtype = null; + if (groupType == GROUP_TYPE_CUSTOM) + { + if (rd.Read(guidBuffer) < 16) + throw PassStoreFileException.UnexpectedEndOfFile; + customGroupSubtype = new Guid(guidBuffer); + } + + int entryCount = rd.Read7BitEncodedInt(); + List children = new(); + for (int i = 0; i < entryCount; i++) + children.Add(PassStoreEntry.ReadFromStream(str)); + + return new PassStoreEntryGroup( + id, createdAt, modifiedAt, + iconType, name, groupType, children, + customGroupSubtype + ); + } catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ChildEntries.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ChildEntries.GetEnumerator(); + } + + public override string ToString() + { + return string.Format( + "EntryGroup (id={0} name={1} chilren=[{2}])", + Id, Name, string.Join(", ", ChildEntries)); + } + + protected override byte[] InnerSerialize() + { + MemoryStream str = new(); + str.WriteByte(ENTRY_GROUP_ID); + + str.WriteByte(GroupType); + if (GroupType == GROUP_TYPE_CUSTOM) + str.Write(CustomGroupSubtype!.Value.ToByteArray()); + + BinaryWriter wr = new(str); + wr.Write7BitEncodedInt(ChildEntries.Count); + foreach (PassStoreEntry entry in ChildEntries) + entry.WriteToStream(str); + + return str.ToArray(); + } +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs new file mode 100644 index 0000000..09cece9 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.IO; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.PasswordStore; + +public class PassStoreEntryPassword : PassStoreEntry +{ + public LoginField Username { get; set; } + public LoginField Password { get; set; } + public List ExtraFields { get; set; } + + public PassStoreEntryPassword(Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name, LoginField username, LoginField password, List? extras = null) + { + Id = id; + CreationDate = createdAt; + ModificationDate = modifiedAt; + IconType = iconType; + Name = name; + Username = username; + Password = password; + ExtraFields = extras ?? new(); + } + + public static PassStoreEntry ReadFromStream(Stream str, Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name) + { + BinaryReader rd = new(str); + try + { + LoginField username = ReadField(str); + LoginField password = ReadField(str); + PassStoreEntryPassword entry = new(id, createdAt, modifiedAt, iconType, name, username, password); + int extraFieldCount = rd.Read7BitEncodedInt(); + for (; extraFieldCount > 0; extraFieldCount--) + entry.ExtraFields.Add(ReadField(str)); + return entry; + } catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + } + + public override string ToString() + { + return string.Format( + "EntryPassword(id={0} name={1} fields=[first={2} second={3} extra={4}])", + Id, Name, Username, Password, string.Join(", ", ExtraFields)); + } + + protected override byte[] InnerSerialize() + { + MemoryStream str = new(); + str.WriteByte(ENTRY_PASS_ID); + WriteField(str, Username); + WriteField(str, Password); + BinaryWriter wr = new(str); + wr.Write7BitEncodedInt(ExtraFields.Count); + foreach (LoginField field in ExtraFields) + WriteField(str, field); + return str.ToArray(); + } + + private static void WriteField(Stream str, LoginField field) + { + str.WriteByte(field.Type); + if (field.Type == LOGIN_FIELD_CUSTOM_ID) + str.Write(field.CustomFieldSubtype.ToByteArray()); + FileFormatUtil.WriteU16TaggedString(str, field.Value); + } + + private static LoginField ReadField(Stream str) + { + int t = str.ReadByte(); + if (t == -1) + throw PassStoreFileException.UnexpectedEndOfFile; + LoginField field = new() { Value = "" }; + field.Type = (byte)t; + if (t == LOGIN_FIELD_CUSTOM_ID) + { + byte[] uuidBuffer = new byte[16]; + if (str.Read(uuidBuffer) < 16) + throw PassStoreFileException.UnexpectedEndOfFile; + field.CustomFieldSubtype = new Guid(uuidBuffer); + } + field.Value = FileFormatUtil.ReadU16TaggedString(str); + return field; + } +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs new file mode 100644 index 0000000..8be01f8 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs @@ -0,0 +1,7 @@ +namespace KeyKeeper.PasswordStore; + +public enum PassStoreEntryType +{ + Password, + Directory, +} diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs new file mode 100644 index 0000000..8169c34 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using KeyKeeper.PasswordStore.Crypto; +using KeyKeeper.PasswordStore.Crypto.KeyDerivation; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.PasswordStore; + +/// +/// Класс, содержащий реализацию доступа к хранилищу паролей +/// +public class PassStoreFileAccessor : IPassStore +{ + private const ushort FORMAT_VERSION_MAJOR = 0; + private const ushort FORMAT_VERSION_MINOR = 0; + private static readonly byte[] FORMAT_MAGIC = [0xf5, 0x3a, 0xa4, 0xb7, 0xeb, 0xd9, 0xc2, 0x12]; + + private string filename; + private byte[]? key; + private InnerEncryptionInfo? innerCrypto; + private PassStoreEntry? root; + + public PassStoreFileAccessor(string filename, bool create, StoreCreationOptions? createOptions) + { + this.filename = filename; + this.key = null; + if (!create) + { + CheckStoreFile(); + } else if (createOptions != null) + { + CreateNewAndUnlock(createOptions); + } else throw new ArgumentException("createOptions must not be null when creating a new store"); + } + + public bool Locked + { + get { return key == null; } + } + + public IPassStoreDirectory GetRootDirectory() + { + if (Locked) + throw new InvalidOperationException(); + return (IPassStoreDirectory)root!; + } + + public int GetTotalEntryCount() + { + throw new NotImplementedException(); + } + + public void Unlock(CompositeKey key) + { + if (!Locked) return; + + using FileStream file = new(filename, FileMode.Open, FileAccess.Read, FileShare.None); + FileHeader hdr = FileHeader.ReadFrom(file); + + file.Seek((file.Position + 4096 - 1) / 4096 * 4096, SeekOrigin.Begin); + + key.Salt = hdr.PreSalt; + this.key = hdr.KdfInfo.GetKdf().Derive(key, 32); + using OuterEncryptionReader cryptoReader = new(file, this.key, ((OuterAesHeader)hdr.OuterCryptoHeader).InitVector); + using BinaryReader rd = new(cryptoReader); + + { + if (rd.ReadUInt32() != FILE_FIELD_BEGIN) + throw PassStoreFileException.InvalidBeginMarker; + Span marker = stackalloc byte[8]; + if (rd.Read(marker) < 8) + throw PassStoreFileException.UnexpectedEndOfFile; + if (!marker.SequenceEqual(BEGIN_MARKER)) + throw PassStoreFileException.InvalidBeginMarker; + } + + while (true) + { + try + { + uint fileField = rd.ReadUInt32(); + bool end = false; + switch (fileField) + { + case FILE_FIELD_INNER_CRYPTO: + ReadInnerCryptoInfo(cryptoReader); + break; + case FILE_FIELD_CONFIG: + break; + case FILE_FIELD_STORE: + this.root = PassStoreEntry.ReadFromStream(cryptoReader); + break; + case FILE_FIELD_END: + end = true; + break; + } + if (end) break; + } catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + } + } + + public void Lock() + { + if (Locked) return; + } + + /// + /// Проверяет внешнюю целостность файла хранилища, то есть: + /// 1. совпадает сигнатура (magic number) + /// 2. совпадает версия формата + /// 3. поля криптозаголовка корректны + /// + void CheckStoreFile() + { + using FileStream file = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.Read); + BinaryReader f = new(file); + byte[] magic = new byte[8]; + if (f.Read(magic, 0, 8) < 8) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + if (!magic.SequenceEqual(FORMAT_MAGIC)) + { + throw PassStoreFileException.IncorrectMagicNumber; + } + + ushort version; + try + { + version = f.ReadUInt16(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + if (version != FORMAT_VERSION_MAJOR) + { + throw PassStoreFileException.UnsupportedVersion; + } + + try + { + version = f.ReadUInt16(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + if (version != FORMAT_VERSION_MINOR) + { + throw PassStoreFileException.UnsupportedVersion; + } + + OuterEncryptionUtil.CheckOuterEncryptionHeader(file); + } + + private void CreateNewAndUnlock(StoreCreationOptions options) + { + using FileStream file = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.None); + FileHeader newHeader = FileHeader.Default(); + newHeader.WriteTo(file); + + options.Key.Salt = newHeader.PreSalt; + + int randomPaddingLen = 4096 - (int)(file.Position % 4096); + if (randomPaddingLen > 0) + { + byte[] randomPadding = new byte[randomPaddingLen]; + RandomNumberGenerator.Fill(randomPadding); + file.Write(randomPadding); + } + + key = newHeader.KdfInfo.GetKdf().Derive(options.Key, 32); + // пока предполагаем что везде используется AES + OuterEncryptionWriter cryptoWriter = new(file, key, ((OuterAesHeader)newHeader.OuterCryptoHeader).InitVector); + + BinaryWriter wr = new(cryptoWriter); + + wr.Write(FILE_FIELD_BEGIN); + cryptoWriter.Write(BEGIN_MARKER); + + byte[] innerKey = new byte[32]; + RandomNumberGenerator.Fill(innerKey); + byte[] innerIv = new byte[16]; + RandomNumberGenerator.Fill(innerIv); + + wr.Write(FILE_FIELD_INNER_CRYPTO); + cryptoWriter.Write(innerKey); + cryptoWriter.Write(innerIv); + + wr.Write(FILE_FIELD_CONFIG); + + wr.Write(FILE_FIELD_STORE); + root = WriteInitialStoreTree(cryptoWriter); + + wr.Write(FILE_FIELD_END); + + cryptoWriter.Flush(); + cryptoWriter.Dispose(); + } + + private PassStoreEntry WriteInitialStoreTree(OuterEncryptionWriter w) + { + PassStoreEntry root = + new PassStoreEntryGroup( + Guid.NewGuid(), + DateTime.UtcNow, + DateTime.UtcNow, + Guid.Empty, + "", + GROUP_TYPE_ROOT + ); + root.WriteToStream(w); + return root; + } + + private InnerEncryptionInfo ReadInnerCryptoInfo(Stream str) + { + byte[] key = new byte[32]; + byte[] iv = new byte[16]; + if (str.Read(key) < 32) + throw PassStoreFileException.UnexpectedEndOfFile; + if (str.Read(iv) < 16) + throw PassStoreFileException.UnexpectedEndOfFile; + return new(key, iv); + } + + record FileHeader ( + ushort FileVersionMajor, + ushort FileVersionMinor, + byte[] PreSalt, + OuterEncryptionHeader OuterCryptoHeader, + KdfHeader KdfInfo + ) + { + public static FileHeader Default() + { + int saltLen = (MIN_MASTER_SALT_LEN + MAX_MASTER_SALT_LEN) / 2; + byte[] preSalt = new byte[saltLen]; + RandomNumberGenerator.Fill(preSalt); + + byte[] iv = new byte[16]; + RandomNumberGenerator.Fill(iv); + + byte[] aesKdfSeed = new byte[32]; + RandomNumberGenerator.Fill(aesKdfSeed); + + return new FileHeader + ( + FORMAT_VERSION_MAJOR, + FORMAT_VERSION_MINOR, + preSalt, + new OuterAesHeader + ( + iv + ), + new AesKdfHeader + ( + 200000, + aesKdfSeed + ) + ); + } + public int WriteTo(Stream s) + { + int written = 0; + + s.Write(FORMAT_MAGIC); + written += FORMAT_MAGIC.Length; + + BinaryWriter wr = new(s); + wr.Write(FORMAT_VERSION_MAJOR); + wr.Write(FORMAT_VERSION_MINOR); + written += 4; + + wr.Write((byte)PreSalt.Length); + s.Write(PreSalt); + written += 1 + PreSalt.Length; + + if (OuterCryptoHeader is OuterAesHeader aes) + { + wr.Write(ENCRYPT_ALGO_AES); + s.Write(aes.InitVector); + written += 1 + aes.InitVector.Length; + } + if (KdfInfo is AesKdfHeader aesKdf) + { + long pos = s.Position; + wr.Write(KDF_TYPE_AESKDF); + wr.Write7BitEncodedInt(aesKdf.Rounds); + wr.Write(aesKdf.Seed); + written += (int)(s.Position - pos); + } + return written; + } + + public static FileHeader ReadFrom(Stream s) + { + BinaryReader rd = new(s); + { + byte[] magic = new byte[8]; + if (rd.Read(magic, 0, 8) < 8) + throw PassStoreFileException.UnexpectedEndOfFile; + if (!magic.SequenceEqual(FORMAT_MAGIC)) + throw PassStoreFileException.IncorrectMagicNumber; + } + try + { + ushort major, minor; + major = rd.ReadUInt16(); + minor = rd.ReadUInt16(); + if (major != FORMAT_VERSION_MAJOR || minor != FORMAT_VERSION_MINOR) + throw PassStoreFileException.UnsupportedVersion; + + byte saltLen = rd.ReadByte(); + if (saltLen < MIN_MASTER_SALT_LEN || saltLen > MAX_MASTER_SALT_LEN) + throw PassStoreFileException.InvalidCryptoHeader; + + byte[] salt = new byte[saltLen]; + if (rd.Read(salt) < saltLen) + throw PassStoreFileException.UnexpectedEndOfFile; + + byte typeDiscrim = rd.ReadByte(); + OuterEncryptionHeader outerEncrHdr; + if (typeDiscrim == ENCRYPT_ALGO_AES) + { + byte[] iv = new byte[16]; + if (rd.Read(iv) < 16) + throw PassStoreFileException.UnexpectedEndOfFile; + outerEncrHdr = new OuterAesHeader(iv); + } else + { + throw PassStoreFileException.InvalidCryptoHeader; + } + + typeDiscrim = rd.ReadByte(); + KdfHeader kdfHdr; + if (typeDiscrim == KDF_TYPE_AESKDF) + { + int rounds = rd.Read7BitEncodedInt(); + byte[] seed = new byte[32]; + if (rd.Read(seed) < 32) + throw PassStoreFileException.UnexpectedEndOfFile; + kdfHdr = new AesKdfHeader(rounds, seed); + } else + { + throw PassStoreFileException.InvalidCryptoHeader; + } + + return new FileHeader( + major, minor, salt, + outerEncrHdr, kdfHdr + ); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + } + }; + + record OuterEncryptionHeader {} + + record OuterAesHeader( + byte[] InitVector + ) : OuterEncryptionHeader; + + abstract record KdfHeader + { + public abstract MasterKeyDerivationFunction GetKdf(); + } + + record AesKdfHeader( + int Rounds, + byte[] Seed + ) : KdfHeader + { + public override MasterKeyDerivationFunction GetKdf() + { + return new AesKdf(Rounds, Seed); + } + } + + record struct InnerEncryptionInfo( + byte[] Key, + byte[] Iv + ) + {} +} diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileException.cs b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs new file mode 100644 index 0000000..35d5a03 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs @@ -0,0 +1,18 @@ +using System; + +namespace KeyKeeper.PasswordStore; + +public class PassStoreFileException : Exception +{ + public static readonly PassStoreFileException UnexpectedEndOfFile = new("unexpected EOF"); + public static readonly PassStoreFileException IncorrectMagicNumber = new("incorrect signature (magic number)"); + public static readonly PassStoreFileException UnsupportedVersion = new("unsupported format version"); + public static readonly PassStoreFileException InvalidCryptoHeader = new("invalid encryption header"); + public static readonly PassStoreFileException ContentHMACMismatch = new("content HMAC mismatch"); + public static readonly PassStoreFileException InvalidPassStoreEntry = new("invalid store entry"); + public static readonly PassStoreFileException InvalidBeginMarker = new("invalid marker of the beginning of data"); + + public PassStoreFileException(string description): base(description) + { + } +} diff --git a/src/KeyKeeper/PasswordStore/StoreCreationOptions.cs b/src/KeyKeeper/PasswordStore/StoreCreationOptions.cs new file mode 100644 index 0000000..e35eadf --- /dev/null +++ b/src/KeyKeeper/PasswordStore/StoreCreationOptions.cs @@ -0,0 +1,9 @@ +using KeyKeeper.PasswordStore.Crypto; + +namespace KeyKeeper.PasswordStore; + +public record StoreCreationOptions +{ + public int LockTimeoutSeconds { get; init; } + public CompositeKey Key { get; init; } +} \ No newline at end of file diff --git a/src/KeyKeeper/RepositoryWindow.axaml.cs b/src/KeyKeeper/RepositoryWindow.axaml.cs index 56f7f87..5bb3fc9 100644 --- a/src/KeyKeeper/RepositoryWindow.axaml.cs +++ b/src/KeyKeeper/RepositoryWindow.axaml.cs @@ -1,13 +1,25 @@ +using System; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using KeyKeeper.PasswordStore; +using KeyKeeper.PasswordStore.Crypto; namespace KeyKeeper; public partial class RepositoryWindow: Window { + public IPassStore? PassStore { private get; init; } + public RepositoryWindow() { InitializeComponent(); } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + if (PassStore!.Locked) + PassStore.Unlock(new CompositeKey("blablabla", null)); + } } \ No newline at end of file diff --git a/src/KeyKeeper/Views/MainWindow.axaml.cs b/src/KeyKeeper/Views/MainWindow.axaml.cs index ec9a8a1..b55c457 100644 --- a/src/KeyKeeper/Views/MainWindow.axaml.cs +++ b/src/KeyKeeper/Views/MainWindow.axaml.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Platform.Storage; +using KeyKeeper.PasswordStore; using KeyKeeper.ViewModels; using System; using System.Collections.Generic; @@ -39,7 +40,11 @@ namespace KeyKeeper.Views if (file.TryGetLocalPath() is string path) { (DataContext as MainWindowViewModel)!.CreateVault(path); - OpenRepositoryWindow(); + OpenRepositoryWindow(new PassStoreFileAccessor(path, true, new StoreCreationOptions() + { + Key = new PasswordStore.Crypto.CompositeKey("blablabla", null), + LockTimeoutSeconds = 800, + })); } } } @@ -70,16 +75,17 @@ namespace KeyKeeper.Views if (file.TryGetLocalPath() is string path) { (DataContext as MainWindowViewModel)!.OpenVault(path); - OpenRepositoryWindow(); + OpenRepositoryWindow(new PassStoreFileAccessor(path, false, null)); } } } - private void OpenRepositoryWindow() + private void OpenRepositoryWindow(IPassStore store) { var repositoryWindow = new RepositoryWindow() { DataContext = this.DataContext, + PassStore = store, WindowStartupLocation = WindowStartupLocation.CenterScreen }; repositoryWindow.Closed += (s, e) => this.Show();