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();