From a94ea771a398f187f75893b5f2261c8df79d3d0b Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sun, 9 Nov 2025 18:27:04 +0300 Subject: [PATCH 01/35] create basic password store interface --- src/KeyKeeper/PasswordStore/IPassStore.cs | 7 +++++++ src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs | 7 +++++++ src/KeyKeeper/PasswordStore/IPassStoreEntry.cs | 10 ++++++++++ 3 files changed, 24 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/IPassStore.cs create mode 100644 src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs create mode 100644 src/KeyKeeper/PasswordStore/IPassStoreEntry.cs diff --git a/src/KeyKeeper/PasswordStore/IPassStore.cs b/src/KeyKeeper/PasswordStore/IPassStore.cs new file mode 100644 index 0000000..2c1b756 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/IPassStore.cs @@ -0,0 +1,7 @@ +namespace KeyKeeper.PasswordStore; + +interface IPassStore +{ + IPassStoreDirectory GetRootDirectory(); + int GetTotalEntryCount(); +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs new file mode 100644 index 0000000..318ae82 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; + +namespace KeyKeeper.PasswordStore; + +interface IPassStoreDirectory : IEnumerable +{ +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs b/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs new file mode 100644 index 0000000..a7d9b16 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs @@ -0,0 +1,10 @@ +using System; + +namespace KeyKeeper.PasswordStore; + +interface IPassStoreEntry +{ + string Name { get; set; } + PassStoreEntryType Type { get; set; } + DateTime CreationDate { get; } +} \ No newline at end of file From 3543d11ab089ee04771cec7ca9fce806563e2c72 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sun, 9 Nov 2025 18:40:28 +0300 Subject: [PATCH 02/35] add PassStoreEntryType enum --- src/KeyKeeper/PasswordStore/PassStoreEntryType.cs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/PassStoreEntryType.cs diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs new file mode 100644 index 0000000..5183893 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs @@ -0,0 +1,7 @@ +namespace KeyKeeper.PasswordStore; + +enum PassStoreEntryType +{ + Password, + Directory, +} \ No newline at end of file From 3cf31a6e6ba5c4446424186c0aa4dc2552830d1e Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sun, 23 Nov 2025 15:52:14 +0300 Subject: [PATCH 03/35] make classes and interfaces in KeyKeeper.PasswordStore public --- src/KeyKeeper/PasswordStore/IPassStore.cs | 4 ++-- src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs | 4 ++-- src/KeyKeeper/PasswordStore/IPassStoreEntry.cs | 4 ++-- src/KeyKeeper/PasswordStore/PassStoreEntryType.cs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/IPassStore.cs b/src/KeyKeeper/PasswordStore/IPassStore.cs index 2c1b756..0fc4694 100644 --- a/src/KeyKeeper/PasswordStore/IPassStore.cs +++ b/src/KeyKeeper/PasswordStore/IPassStore.cs @@ -1,7 +1,7 @@ namespace KeyKeeper.PasswordStore; -interface IPassStore +public interface IPassStore { IPassStoreDirectory GetRootDirectory(); int GetTotalEntryCount(); -} \ No newline at end of file +} diff --git a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs index 318ae82..f290493 100644 --- a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs +++ b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs @@ -2,6 +2,6 @@ using System.Collections.Generic; namespace KeyKeeper.PasswordStore; -interface IPassStoreDirectory : IEnumerable +public interface IPassStoreDirectory : IEnumerable { -} \ No newline at end of file +} diff --git a/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs b/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs index a7d9b16..dbeea0c 100644 --- a/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs +++ b/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs @@ -2,9 +2,9 @@ using System; namespace KeyKeeper.PasswordStore; -interface IPassStoreEntry +public interface IPassStoreEntry { string Name { get; set; } PassStoreEntryType Type { get; set; } DateTime CreationDate { get; } -} \ No newline at end of file +} diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs index 5183893..8be01f8 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs @@ -1,7 +1,7 @@ namespace KeyKeeper.PasswordStore; -enum PassStoreEntryType +public enum PassStoreEntryType { Password, Directory, -} \ No newline at end of file +} From 355f7d562dda9bb5a042abc9ed8394411b049066 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sun, 23 Nov 2025 18:13:31 +0300 Subject: [PATCH 04/35] add class for constants related to the file format --- src/KeyKeeper/PasswordStore/FileFormatConstants.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/FileFormatConstants.cs diff --git a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs new file mode 100644 index 0000000..e76ebe2 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -0,0 +1,11 @@ +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 = 10; + public const int MAX_AESKDF_ROUNDS = 65536; + public const byte ENCRYPT_ALGO_AES = 14; + public const byte KDF_TYPE_AESKDF = 195; +} \ No newline at end of file From 8eedf73e6d9533580e1a6814d0ac8a34600afd45 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sun, 23 Nov 2025 21:06:37 +0300 Subject: [PATCH 05/35] add a constant for the HMAC-SHA3-512 size --- src/KeyKeeper/PasswordStore/FileFormatConstants.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs index e76ebe2..6f755f7 100644 --- a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; + namespace KeyKeeper.PasswordStore; static class FileFormatConstants @@ -8,4 +10,5 @@ static class FileFormatConstants public const int MAX_AESKDF_ROUNDS = 65536; public const byte ENCRYPT_ALGO_AES = 14; public const byte KDF_TYPE_AESKDF = 195; + public const int HMAC_SIZE = HMACSHA3_512.HashSizeInBytes; } \ No newline at end of file From 6b90ad615ea44cfb3e7ba68257103ea065ac4f48 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 24 Nov 2025 02:23:44 +0300 Subject: [PATCH 06/35] add PassStoreFileException class --- .../PasswordStore/PassStoreFileException.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/PassStoreFileException.cs diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileException.cs b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs new file mode 100644 index 0000000..9f28c57 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs @@ -0,0 +1,17 @@ +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 string Description { get; } + + public PassStoreFileException(string description) + { + Description = description; + } +} From 9722686e0f2cf00eefcd274b758efb7f59b95541 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 24 Nov 2025 16:52:25 +0300 Subject: [PATCH 07/35] add class representing a content chunk of the password store --- .../Crypto/PassStoreContentChunk.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs diff --git a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs new file mode 100644 index 0000000..bab004a --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs @@ -0,0 +1,107 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Security.Cryptography; +using KeyKeeper.PasswordStore; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.Crypto; + +/// +/// Класс, представляющий собой обертку над content chunkом, считанным из файла +/// хранилища. Не расшифровывает содержимое, но проверяет целостность и +/// подлинность при создании объекта. +/// +public class PassStoreContentChunk +{ + 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 + { + chunkLen = rd.ReadUInt16(); + chunkLen = (chunkLen << 8) | rd.ReadByte(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + + if (chunk.Length != chunkLen + 3 + HMAC_SIZE) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + + byte[] storedHmac = new byte[HMAC_SIZE]; + str.Read(storedHmac, 0, HMAC_SIZE); + + HMACSHA3_512 hmac = new(key); + hmac.TransformBlock(chunk, (int)str.Position, Math.Min(chunkLen, chunk.Length - (int)str.Position), null, 0); + + byte[] encodedOrdinal = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(new Span(encodedOrdinal), chunkOrdinal); + hmac.TransformBlock(encodedOrdinal, 0, encodedOrdinal.Length, null, 0); + + byte[] actualHmac = hmac.Hash!; + + if (!storedHmac.Equals(actualHmac)) + { + throw PassStoreFileException.ContentHMACMismatch; + } + } + + /// + /// Создаёт объект 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; + try + { + chunkLen = rd.ReadUInt16(); + chunkLen = (chunkLen << 8) | rd.ReadByte(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + byte[] chunk = new byte[3 + HMAC_SIZE + chunkLen]; + if (s.Read(chunk) < chunk.Length) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + return new PassStoreContentChunk(chunk, key, chunkOrdinal); + } + + public ReadOnlySpan GetContent() + { + return new ReadOnlySpan(chunk, 3 + HMAC_SIZE, chunkLen); + } +} From 4787b18074aa8aeb0d47fcd5bcb2252640ab3733 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 24 Nov 2025 19:15:42 +0300 Subject: [PATCH 08/35] add an exception type for PassStoreFileException --- src/KeyKeeper/PasswordStore/PassStoreFileException.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileException.cs b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs index 9f28c57..98e2074 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileException.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs @@ -8,6 +8,7 @@ public class PassStoreFileException : Exception 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 string Description { get; } public PassStoreFileException(string description) From 05c76caaaf327cd78f27b5de3b56bf498b684682 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 24 Nov 2025 19:16:21 +0300 Subject: [PATCH 09/35] add OuterEncryptionUtil utility class with a method to quickly check if the outer encryption header of a file is valid --- .../Crypto/OuterEncryptionUtil.cs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs new file mode 100644 index 0000000..d10afd5 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs @@ -0,0 +1,90 @@ +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); + byte masterSaltLen; + try + { + masterSaltLen = rd.ReadByte(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + if (masterSaltLen < MIN_MASTER_SALT_LEN || masterSaltLen > MAX_MASTER_SALT_LEN) + { + throw PassStoreFileException.InvalidCryptoHeader; + } + + f.Seek(masterSaltLen, SeekOrigin.Current); + + byte encryptAlgo; + try + { + encryptAlgo = rd.ReadByte(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + + if (encryptAlgo == ENCRYPT_ALGO_AES) + { + // пропустить 16 байт вектора инициализации AES + f.Seek(16, SeekOrigin.Current); + } + else + { + throw PassStoreFileException.InvalidCryptoHeader; + } + + byte keyDerivationFunctionType; + try + { + keyDerivationFunctionType = rd.ReadByte(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + + if (keyDerivationFunctionType == KDF_TYPE_AESKDF) + { + int nRounds; + try + { + nRounds = rd.Read7BitEncodedInt(); + } + catch (EndOfStreamException) + { + throw PassStoreFileException.UnexpectedEndOfFile; + } + 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); + } + } +} From 6a7e1b2eae4e2cbb2292bdabd7a30ca6229435e9 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 24 Nov 2025 20:16:20 +0300 Subject: [PATCH 10/35] fix namespace of PassStoreContentChunk --- src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs index bab004a..a4ac43c 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs @@ -2,10 +2,9 @@ using System; using System.Buffers.Binary; using System.IO; using System.Security.Cryptography; -using KeyKeeper.PasswordStore; using static KeyKeeper.PasswordStore.FileFormatConstants; -namespace KeyKeeper.Crypto; +namespace KeyKeeper.PasswordStore.Crypto; /// /// Класс, представляющий собой обертку над content chunkом, считанным из файла From a2f5ccf64b00d27e8edb675a0670c3d190fb9379 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 25 Nov 2025 00:41:22 +0300 Subject: [PATCH 11/35] slightly refactor OuterEncryptionUtil.CheckOuterEncryptionHeader --- .../Crypto/OuterEncryptionUtil.cs | 96 ++++++++----------- 1 file changed, 38 insertions(+), 58 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs index d10afd5..8708517 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionUtil.cs @@ -18,73 +18,53 @@ public static class OuterEncryptionUtil public static void CheckOuterEncryptionHeader(FileStream f) { BinaryReader rd = new(f); - byte masterSaltLen; try { - masterSaltLen = rd.ReadByte(); - } - catch (EndOfStreamException) - { - throw PassStoreFileException.UnexpectedEndOfFile; - } - if (masterSaltLen < MIN_MASTER_SALT_LEN || masterSaltLen > MAX_MASTER_SALT_LEN) - { - throw PassStoreFileException.InvalidCryptoHeader; - } + byte masterSaltLen = rd.ReadByte(); - f.Seek(masterSaltLen, SeekOrigin.Current); - - byte encryptAlgo; - try - { - encryptAlgo = rd.ReadByte(); - } - catch (EndOfStreamException) - { - throw PassStoreFileException.UnexpectedEndOfFile; - } - - if (encryptAlgo == ENCRYPT_ALGO_AES) - { - // пропустить 16 байт вектора инициализации AES - f.Seek(16, SeekOrigin.Current); - } - else - { - throw PassStoreFileException.InvalidCryptoHeader; - } - - byte keyDerivationFunctionType; - try - { - keyDerivationFunctionType = rd.ReadByte(); - } - catch (EndOfStreamException) - { - throw PassStoreFileException.UnexpectedEndOfFile; - } - - if (keyDerivationFunctionType == KDF_TYPE_AESKDF) - { - int nRounds; - try - { - nRounds = rd.Read7BitEncodedInt(); - } - catch (EndOfStreamException) - { - throw PassStoreFileException.UnexpectedEndOfFile; - } - catch (FormatException) + if (masterSaltLen < MIN_MASTER_SALT_LEN || masterSaltLen > MAX_MASTER_SALT_LEN) { throw PassStoreFileException.InvalidCryptoHeader; } - if (nRounds < MIN_AESKDF_ROUNDS || nRounds > MAX_AESKDF_ROUNDS) + + 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; } - // пропустить 32 байта сида AES-KDF - f.Seek(32, SeekOrigin.Current); + + 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; } } } From 384a303a1dd88954ac9da2a9a42015608daeaa0d Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 25 Nov 2025 20:47:00 +0300 Subject: [PATCH 12/35] add CompositeKey class with a hashing method --- .../PasswordStore/Crypto/CompositeKey.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs diff --git a/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs new file mode 100644 index 0000000..43ba1ad --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs @@ -0,0 +1,34 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace KeyKeeper.PasswordStore.Crypto; + +public struct CompositeKey +{ + public string Password { get; } + public byte[] Salt { get; } + + 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"); + Salt = salt; + } + + public byte[] Hash() + { + 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); + } +} From 3492fa1cd0472e5ffa41ae7ab717e7075bb2097c Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 25 Nov 2025 21:05:37 +0300 Subject: [PATCH 13/35] add class for password-based key derivation --- .../Crypto/KeyDerivation/AesKdf.cs | 42 +++++++++++++++++++ .../MasterKeyDerivationFunction.cs | 6 +++ .../PasswordStore/FileFormatConstants.cs | 5 ++- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs create mode 100644 src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/MasterKeyDerivationFunction.cs diff --git a/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs b/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs new file mode 100644 index 0000000..64ce007 --- /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; + 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/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs index 6f755f7..c45e49e 100644 --- a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using KeyKeeper.PasswordStore.Crypto.KeyDerivation; namespace KeyKeeper.PasswordStore; @@ -6,8 +7,8 @@ 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 = 10; - public const int MAX_AESKDF_ROUNDS = 65536; + 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; From fecab564b73c5c6a21b28e5a24fa60c8e0ccef8d Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 1 Dec 2025 00:37:06 +0300 Subject: [PATCH 14/35] fix HMAC in PassStoreContentChunk --- .../Crypto/PassStoreContentChunk.cs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs index a4ac43c..6d5301f 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Buffers.Binary; using System.IO; using System.Security.Cryptography; @@ -37,7 +38,7 @@ public class PassStoreContentChunk try { chunkLen = rd.ReadUInt16(); - chunkLen = (chunkLen << 8) | rd.ReadByte(); + chunkLen = chunkLen | (rd.ReadByte() << 16); } catch (EndOfStreamException) { @@ -52,16 +53,28 @@ public class PassStoreContentChunk byte[] storedHmac = new byte[HMAC_SIZE]; str.Read(storedHmac, 0, HMAC_SIZE); - HMACSHA3_512 hmac = new(key); - hmac.TransformBlock(chunk, (int)str.Position, Math.Min(chunkLen, chunk.Length - (int)str.Position), null, 0); + 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(chunk, (int)str.Position, chunk.Length - (int)str.Position, null, 0); byte[] encodedOrdinal = new byte[sizeof(int)]; BinaryPrimitives.WriteInt32LittleEndian(new Span(encodedOrdinal), chunkOrdinal); - hmac.TransformBlock(encodedOrdinal, 0, encodedOrdinal.Length, null, 0); - - byte[] actualHmac = hmac.Hash!; - if (!storedHmac.Equals(actualHmac)) + hasher.TransformFinalBlock(encodedOrdinal, 0, encodedOrdinal.Length); + byte[] innerHash = hasher.Hash!; + + hasher = SHA3_512.Create(); + hasher.TransformBlock(outerKey, 0, outerKey.Length, null, 0); + Array.Fill(outerKey, 0); + hasher.TransformFinalBlock(innerHash, 0, innerHash.Length); + byte[] actualHmac = hasher.Hash!; + + if (!storedHmac.SequenceEqual(actualHmac)) { throw PassStoreFileException.ContentHMACMismatch; } From 18992842775bdbf4b783621f640359fdf1d254ee Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 1 Dec 2025 15:43:03 +0300 Subject: [PATCH 15/35] add IsLast property to PassStoreContentChunk - add special handling of the bit 23 in the 24-bit ChunkSize field - make the constructor set the property depending on that bit --- src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs index 6d5301f..3321dc9 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs @@ -14,6 +14,7 @@ namespace KeyKeeper.PasswordStore.Crypto; /// public class PassStoreContentChunk { + public bool IsLast { get; } private byte[] chunk; private int chunkLen; @@ -45,6 +46,9 @@ public class PassStoreContentChunk throw PassStoreFileException.UnexpectedEndOfFile; } + IsLast = (chunkLen & (1 << 23)) != 0; + chunkLen &= ~(1 << 23); + if (chunk.Length != chunkLen + 3 + HMAC_SIZE) { throw PassStoreFileException.UnexpectedEndOfFile; @@ -104,6 +108,7 @@ public class PassStoreContentChunk { throw PassStoreFileException.UnexpectedEndOfFile; } + chunkLen &= ~(1 << 23); // 23 бит имеет специальное значение byte[] chunk = new byte[3 + HMAC_SIZE + chunkLen]; if (s.Read(chunk) < chunk.Length) { From e07e9731d1c0ca8eae1b4ca38c5bc78a398370be Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 1 Dec 2025 17:56:41 +0300 Subject: [PATCH 16/35] implement OuterEncryptionReader --- .../Crypto/OuterEncryptionReader.cs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs new file mode 100644 index 0000000..cc05baa --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs @@ -0,0 +1,200 @@ +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 => position = value; + } + 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 currentChunkOrdinal = 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={} toread={} pos={}", read, toRead, chunkPosition)); + } + return read; + } + + private void LoadAndDecryptNextChunk() + { + if (isCurrentChunkLast) + return; + var chunk = PassStoreContentChunk.GetFromStream(file, key, currentChunkOrdinal); + isCurrentChunkLast = chunk.IsLast; + var encryptedData = chunk.GetContent(); + EraseCurrentChunk(); + + int decrypted = 0, read = 0; + 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; + } +} From d19ea304cb332fdb9f381c55b7063a4addc24fe3 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 1 Dec 2025 20:58:14 +0300 Subject: [PATCH 17/35] make CompositeKey a class to make it simpler to protect its memory in the future --- src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs index 43ba1ad..c9b5182 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs @@ -4,7 +4,7 @@ using System.Text; namespace KeyKeeper.PasswordStore.Crypto; -public struct CompositeKey +public class CompositeKey { public string Password { get; } public byte[] Salt { get; } From 4fbebbbf80d385fae728c95a66a83cdb2ba3116d Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 2 Dec 2025 16:47:44 +0300 Subject: [PATCH 18/35] add Lock and Unlock methods to IPassStore --- src/KeyKeeper/PasswordStore/IPassStore.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/KeyKeeper/PasswordStore/IPassStore.cs b/src/KeyKeeper/PasswordStore/IPassStore.cs index 0fc4694..68a1f6f 100644 --- a/src/KeyKeeper/PasswordStore/IPassStore.cs +++ b/src/KeyKeeper/PasswordStore/IPassStore.cs @@ -1,7 +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(); } From f8ddcddc6fb784f72f15bfbd6362223f6d230940 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 2 Dec 2025 19:20:46 +0300 Subject: [PATCH 19/35] make salt field optional in CompositeKey so it can be set later by the callee --- .../PasswordStore/Crypto/CompositeKey.cs | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs index c9b5182..f95b90e 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/CompositeKey.cs @@ -7,22 +7,39 @@ namespace KeyKeeper.PasswordStore.Crypto; public class CompositeKey { public string Password { get; } - public byte[] Salt { get; } + public byte[]? Salt + { + get { return salt; } + set + { + if (salt == null) + salt = value; + } + } - public CompositeKey(string password, byte[] salt) + 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) + if (salt != null && (salt.Length < FileFormatConstants.MIN_MASTER_SALT_LEN || + salt.Length > FileFormatConstants.MAX_MASTER_SALT_LEN)) throw new ArgumentException("salt"); - Salt = 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); From 5ab0fc9f1ee605b71c193e42cb427d872a7a5cb1 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 2 Dec 2025 19:24:16 +0300 Subject: [PATCH 20/35] add StoreCreationOptions class --- src/KeyKeeper/PasswordStore/StoreCreationOptions.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/StoreCreationOptions.cs 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 From c3bc68b6e9458c21e9281c045e9155bfba075290 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 2 Dec 2025 22:18:30 +0300 Subject: [PATCH 21/35] implement OuterEncryptionWriter --- .../Crypto/OuterEncryptionWriter.cs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs new file mode 100644 index 0000000..b89792a --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs @@ -0,0 +1,134 @@ +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) + { + Console.WriteLine("OE write " + buffer.Length); + int written = 0; + while (written < buffer.Length) + { + if (chunkPosition == currentChunk.Length) + EncryptAndStoreCurrentFullChunk(); + int n = Math.Min(buffer.Length, currentChunk.Length - chunkPosition); + Console.WriteLine("OEW: copy " + n + " bytes buffer+" + written + " -> chunk+" + chunkPosition); + buffer.Slice(written, n).CopyTo(new Span(currentChunk, chunkPosition, n)); + written += n; + chunkPosition += n; + position += n; + Console.WriteLine(string.Format("written={} pos={}", written, chunkPosition)); + } + } + + /// + /// Шифрует оставшиеся данные и добавляет к файлу чанк с пометкой о том + /// что он последний. Вызов после завершения записи полезных данных + /// обязателен, в противном случае потеря данных неизбежна. + /// + 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 From 403283a30b4857eadcbad74718b967477d67bda0 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 2 Dec 2025 22:19:10 +0300 Subject: [PATCH 22/35] some edits in OuterEncryptionReader --- .../PasswordStore/Crypto/OuterEncryptionReader.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs index cc05baa..f94e61d 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs @@ -23,7 +23,7 @@ public class OuterEncryptionReader : Stream public override long Position { get => position; - set => position = value; + set { throw new NotSupportedException(); } } public override long Length => throw new NotSupportedException(); @@ -42,7 +42,7 @@ public class OuterEncryptionReader : Stream /// /// Порядковый номер чанка, лежащего в . /// - private int currentChunkOrdinal = 0; + private int nextChunkOrdinal = 0; private bool isCurrentChunkLast; private long position = 0; @@ -148,7 +148,8 @@ public class OuterEncryptionReader : Stream { if (isCurrentChunkLast) return; - var chunk = PassStoreContentChunk.GetFromStream(file, key, currentChunkOrdinal); + var chunk = PassStoreContentChunk.GetFromStream(file, key, nextChunkOrdinal); + nextChunkOrdinal += 1; isCurrentChunkLast = chunk.IsLast; var encryptedData = chunk.GetContent(); EraseCurrentChunk(); From 2023c9f5216f8f07c07d51d5251f36d9b8e95065 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 2 Dec 2025 22:20:08 +0300 Subject: [PATCH 23/35] expose raw chunk data --- .../Crypto/PassStoreContentChunk.cs | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs index 3321dc9..b58c94a 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs @@ -15,6 +15,8 @@ namespace KeyKeeper.PasswordStore.Crypto; public class PassStoreContentChunk { public bool IsLast { get; } + public byte[] Chunk { get { return chunk; } } + private byte[] chunk; private int chunkLen; @@ -57,33 +59,22 @@ public class PassStoreContentChunk byte[] storedHmac = new byte[HMAC_SIZE]; str.Read(storedHmac, 0, HMAC_SIZE); - 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(chunk, (int)str.Position, chunk.Length - (int)str.Position, 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 = SHA3_512.Create(); - hasher.TransformBlock(outerKey, 0, outerKey.Length, null, 0); - Array.Fill(outerKey, 0); - hasher.TransformFinalBlock(innerHash, 0, innerHash.Length); - byte[] actualHmac = hasher.Hash!; - + 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 @@ -117,8 +108,47 @@ public class PassStoreContentChunk 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; + } } From 31d41cb9cd9af133e6852383a3133cd528bf4de7 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Wed, 3 Dec 2025 17:02:56 +0300 Subject: [PATCH 24/35] implement serialization for PassStoreEntry --- src/KeyKeeper/PasswordStore/FileFormatUtil.cs | 45 +++++++++++++++++++ .../PasswordStore/IPassStoreEntry.cs | 10 ----- src/KeyKeeper/PasswordStore/PassStoreEntry.cs | 37 +++++++++++++++ 3 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 src/KeyKeeper/PasswordStore/FileFormatUtil.cs delete mode 100644 src/KeyKeeper/PasswordStore/IPassStoreEntry.cs create mode 100644 src/KeyKeeper/PasswordStore/PassStoreEntry.cs diff --git a/src/KeyKeeper/PasswordStore/FileFormatUtil.cs b/src/KeyKeeper/PasswordStore/FileFormatUtil.cs new file mode 100644 index 0000000..c98ccaf --- /dev/null +++ b/src/KeyKeeper/PasswordStore/FileFormatUtil.cs @@ -0,0 +1,45 @@ +using System; +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 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; + } +} \ No newline at end of file diff --git a/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs b/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs deleted file mode 100644 index dbeea0c..0000000 --- a/src/KeyKeeper/PasswordStore/IPassStoreEntry.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace KeyKeeper.PasswordStore; - -public interface IPassStoreEntry -{ - string Name { get; set; } - PassStoreEntryType Type { get; set; } - DateTime CreationDate { get; } -} diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs new file mode 100644 index 0000000..ed4513e --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using KeyKeeper.PasswordStore.Crypto; + +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.WriteU8TaggedString(tmp, Name); + wr.Write(InnerSerialize()); + byte[] serializedEntry = tmp.ToArray(); + tmp.Dispose(); + + wr = new(str); + wr.Write7BitEncodedInt(serializedEntry.Length); + wr.Write(serializedEntry); + } + + protected abstract byte[] InnerSerialize(); +} From 825103cf1639d732642daaa2d864b49be4578565 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Wed, 3 Dec 2025 17:04:03 +0300 Subject: [PATCH 25/35] add a lot of constants to FileFormatConstants --- .../PasswordStore/FileFormatConstants.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs index c45e49e..71bf840 100644 --- a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -12,4 +12,23 @@ static class FileFormatConstants 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]; } \ No newline at end of file From b880c620fd4e12e93e49e31ab9179cfe6caa2afe Mon Sep 17 00:00:00 2001 From: Slavasil Date: Wed, 3 Dec 2025 17:05:26 +0300 Subject: [PATCH 26/35] create PassStoreEntryPassword --- src/KeyKeeper/PasswordStore/LoginField.cs | 10 ++++ .../PasswordStore/PassStoreEntryPassword.cs | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/LoginField.cs create mode 100644 src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs diff --git a/src/KeyKeeper/PasswordStore/LoginField.cs b/src/KeyKeeper/PasswordStore/LoginField.cs new file mode 100644 index 0000000..d2067f0 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/LoginField.cs @@ -0,0 +1,10 @@ +using System; + +namespace KeyKeeper.PasswordStore; + +public struct LoginField +{ + public byte Type; + public Guid CustomFieldSubtype; + public required string Value; +} \ 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..56481c2 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs @@ -0,0 +1,46 @@ +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(); + } + + 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 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); + } +} \ No newline at end of file From a27d9b34fb81794d1b3bea9a127a51b9529a32bf Mon Sep 17 00:00:00 2001 From: Slavasil Date: Wed, 3 Dec 2025 17:05:41 +0300 Subject: [PATCH 27/35] create PassStoreEntryGroup --- .../PasswordStore/IPassStoreDirectory.cs | 2 +- .../PasswordStore/PassStoreEntryGroup.cs | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs diff --git a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs index f290493..b5c5acc 100644 --- a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs +++ b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs @@ -2,6 +2,6 @@ using System.Collections.Generic; namespace KeyKeeper.PasswordStore; -public interface IPassStoreDirectory : IEnumerable +public interface IPassStoreDirectory : IEnumerable { } diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs new file mode 100644 index 0000000..24c810c --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs @@ -0,0 +1,59 @@ +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(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ChildEntries.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ChildEntries.GetEnumerator(); + } + + 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 From 4ca776b54ef5dac0feeae0827a2796f2a879c90e Mon Sep 17 00:00:00 2001 From: Slavasil Date: Wed, 3 Dec 2025 17:18:10 +0300 Subject: [PATCH 28/35] try to implement PassStoreFileAccessor with file creation functionality --- .../PasswordStore/PassStoreFileAccessor.cs | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs new file mode 100644 index 0000000..0fa7a40 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.IO; +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 IPassStoreDirectory? 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() + { + return root!; + } + + public int GetTotalEntryCount() + { + throw new NotImplementedException(); + } + + public void Unlock(CompositeKey key) + { + if (!Locked) return; + } + + 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 != 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); + } + + byte[] masterKey = newHeader.KdfInfo.GetKdf().Derive(options.Key, 32); + // пока предполагаем что везде используется AES + OuterEncryptionWriter cryptoWriter = new(file, masterKey, ((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); + WriteInitialStoreTree(cryptoWriter); + cryptoWriter.Flush(); + cryptoWriter.Dispose(); + } + + private void WriteInitialStoreTree(OuterEncryptionWriter w) + { + PassStoreEntry root = + new PassStoreEntryGroup( + Guid.NewGuid(), + DateTime.UtcNow, + DateTime.UtcNow, + Guid.Empty, + "", + GROUP_TYPE_ROOT + ); + root.WriteToStream(w); + } + + 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 + ( + MAX_AESKDF_ROUNDS, + 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; + } + }; + + 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); + } + } +} From 239591b160b931b3da2de8786ef81678a065a994 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Wed, 3 Dec 2025 18:46:24 +0300 Subject: [PATCH 29/35] set root after creating file --- src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs index 0fa7a40..ff6eef9 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs @@ -145,12 +145,12 @@ public class PassStoreFileAccessor : IPassStore wr.Write(FILE_FIELD_CONFIG); wr.Write(FILE_FIELD_STORE); - WriteInitialStoreTree(cryptoWriter); + root = (IPassStoreDirectory) WriteInitialStoreTree(cryptoWriter); cryptoWriter.Flush(); cryptoWriter.Dispose(); } - private void WriteInitialStoreTree(OuterEncryptionWriter w) + private PassStoreEntry WriteInitialStoreTree(OuterEncryptionWriter w) { PassStoreEntry root = new PassStoreEntryGroup( @@ -162,6 +162,7 @@ public class PassStoreFileAccessor : IPassStore GROUP_TYPE_ROOT ); root.WriteToStream(w); + return root; } record FileHeader ( From 55ad709579fe6470221985b366def2768dffe79c Mon Sep 17 00:00:00 2001 From: Slavasil Date: Wed, 3 Dec 2025 21:50:30 +0300 Subject: [PATCH 30/35] file write fixes --- src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs | 2 +- src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs | 3 --- src/KeyKeeper/PasswordStore/PassStoreEntry.cs | 2 +- src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs | 3 ++- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs b/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs index 64ce007..1231fb0 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/KeyDerivation/AesKdf.cs @@ -30,7 +30,7 @@ public class AesKdf : MasterKeyDerivationFunction byte[] key = source.Hash()[..SEED_LENGTH]; byte[] nextKey = new byte[SEED_LENGTH]; Aes cipher = Aes.Create(); - cipher.KeySize = SEED_LENGTH; + cipher.KeySize = SEED_LENGTH * 8; for (int i = 0; i < rounds; ++i) { cipher.Key = key; diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs index b89792a..1357acd 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionWriter.cs @@ -85,19 +85,16 @@ public class OuterEncryptionWriter : Stream public override void Write(ReadOnlySpan buffer) { - Console.WriteLine("OE write " + buffer.Length); int written = 0; while (written < buffer.Length) { if (chunkPosition == currentChunk.Length) EncryptAndStoreCurrentFullChunk(); int n = Math.Min(buffer.Length, currentChunk.Length - chunkPosition); - Console.WriteLine("OEW: copy " + n + " bytes buffer+" + written + " -> chunk+" + chunkPosition); buffer.Slice(written, n).CopyTo(new Span(currentChunk, chunkPosition, n)); written += n; chunkPosition += n; position += n; - Console.WriteLine(string.Format("written={} pos={}", written, chunkPosition)); } } diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs index ed4513e..eee882e 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs @@ -23,7 +23,7 @@ public abstract class PassStoreEntry timestamp = (ulong) new DateTimeOffset(ModificationDate.ToUniversalTime()).ToUnixTimeSeconds(); FileFormatUtil.WriteVarUint16(tmp, timestamp); wr.Write(IconType.ToByteArray()); - FileFormatUtil.WriteU8TaggedString(tmp, Name); + FileFormatUtil.WriteU16TaggedString(tmp, Name); wr.Write(InnerSerialize()); byte[] serializedEntry = tmp.ToArray(); tmp.Dispose(); diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs index ff6eef9..5a331c2 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs @@ -122,6 +122,7 @@ public class PassStoreFileAccessor : IPassStore { byte[] randomPadding = new byte[randomPaddingLen]; RandomNumberGenerator.Fill(randomPadding); + file.Write(randomPadding); } byte[] masterKey = newHeader.KdfInfo.GetKdf().Derive(options.Key, 32); @@ -196,7 +197,7 @@ public class PassStoreFileAccessor : IPassStore ), new AesKdfHeader ( - MAX_AESKDF_ROUNDS, + 200000, aesKdfSeed ) ); From 8fe565ca828b54674a7dcad4b317adaf0fb9327f Mon Sep 17 00:00:00 2001 From: Slavasil Date: Thu, 4 Dec 2025 00:25:42 +0300 Subject: [PATCH 31/35] PassStoreFileException: fix exception description in console --- src/KeyKeeper/PasswordStore/PassStoreFileException.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileException.cs b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs index 98e2074..ad858c0 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileException.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs @@ -9,10 +9,8 @@ public class PassStoreFileException : Exception 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 string Description { get; } - public PassStoreFileException(string description) + public PassStoreFileException(string description): base(description) { - Description = description; } } From 000722e5a656deefb2699e9fb133e82d0356ff49 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Thu, 4 Dec 2025 00:32:15 +0300 Subject: [PATCH 32/35] fix bugs and add some placeholders + fix file magic number check + change marker size 7 -> 8 + set key field when creating-and-unlocking, so Locked becomes false + throw InvalidOperationException in GetRootDirectory when the store is not unlocked + invert Locked property (hahaha) + fix debug logging in OuterEncryptionReader + implement reading the file header from a Stream + PassStoreFileAccessor.Unlock reads and prints out the file header (placeholder for future implementation) + the Create Store button creates an empty file at the selected location + the Open Store button checks the file and calls Unlock --- .../Crypto/OuterEncryptionReader.cs | 2 +- .../PasswordStore/FileFormatConstants.cs | 2 +- .../PasswordStore/PassStoreFileAccessor.cs | 79 ++++++++++++++++++- src/KeyKeeper/RepositoryWindow.axaml.cs | 12 +++ src/KeyKeeper/Views/MainWindow.axaml.cs | 12 ++- 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs index f94e61d..a479e83 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs @@ -139,7 +139,7 @@ public class OuterEncryptionReader : Stream toRead -= n; chunkPosition += n; position += n; - Console.WriteLine(string.Format("read={} toread={} pos={}", read, toRead, chunkPosition)); + Console.WriteLine(string.Format("read={0} toread={1} pos={2}", read, toRead, chunkPosition)); } return read; } diff --git a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs index 71bf840..761f5a0 100644 --- a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -30,5 +30,5 @@ static class FileFormatConstants 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]; + 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/PassStoreFileAccessor.cs b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs index 5a331c2..3876aeb 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs @@ -1,6 +1,7 @@ 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; @@ -36,11 +37,13 @@ public class PassStoreFileAccessor : IPassStore public bool Locked { - get { return key != null; } + get { return key == null; } } public IPassStoreDirectory GetRootDirectory() { + if (Locked) + throw new InvalidOperationException(); return root!; } @@ -52,6 +55,10 @@ public class PassStoreFileAccessor : IPassStore public void Unlock(CompositeKey key) { if (!Locked) return; + + using FileStream file = new(filename, FileMode.Open, FileAccess.Read, FileShare.None); + FileHeader hdr = FileHeader.ReadFrom(file); + Console.WriteLine(hdr); // debug } public void Lock() @@ -74,7 +81,7 @@ public class PassStoreFileAccessor : IPassStore { throw PassStoreFileException.UnexpectedEndOfFile; } - if (magic != FORMAT_MAGIC) + if (!magic.SequenceEqual(FORMAT_MAGIC)) { throw PassStoreFileException.IncorrectMagicNumber; } @@ -125,9 +132,9 @@ public class PassStoreFileAccessor : IPassStore file.Write(randomPadding); } - byte[] masterKey = newHeader.KdfInfo.GetKdf().Derive(options.Key, 32); + key = newHeader.KdfInfo.GetKdf().Derive(options.Key, 32); // пока предполагаем что везде используется AES - OuterEncryptionWriter cryptoWriter = new(file, masterKey, ((OuterAesHeader)newHeader.OuterCryptoHeader).InitVector); + OuterEncryptionWriter cryptoWriter = new(file, key, ((OuterAesHeader)newHeader.OuterCryptoHeader).InitVector); BinaryWriter wr = new(cryptoWriter); @@ -234,6 +241,70 @@ public class PassStoreFileAccessor : IPassStore } 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 {} 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(); From 73bb767919c8011e183c14627cc721a6ca235095 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Thu, 4 Dec 2025 02:07:40 +0300 Subject: [PATCH 33/35] implement PassStoreEntryGroup.ReadFromStream --- .../PasswordStore/PassStoreEntryGroup.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs index 24c810c..0d75a41 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs @@ -30,6 +30,37 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory 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[8]; + 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(); From 5c8a63f56af2aafc443256ab7a947d17db13b335 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Thu, 4 Dec 2025 02:09:37 +0300 Subject: [PATCH 34/35] implement some missing methods in FileFormatUtil --- src/KeyKeeper/PasswordStore/FileFormatUtil.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/KeyKeeper/PasswordStore/FileFormatUtil.cs b/src/KeyKeeper/PasswordStore/FileFormatUtil.cs index c98ccaf..18ce17c 100644 --- a/src/KeyKeeper/PasswordStore/FileFormatUtil.cs +++ b/src/KeyKeeper/PasswordStore/FileFormatUtil.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers.Binary; using System.IO; using System.Text; @@ -22,6 +23,28 @@ static class FileFormatUtil 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); @@ -42,4 +65,16 @@ static class FileFormatUtil 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 From 33e31cb0703aaf3ba01fb2314d908e02489e2600 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Thu, 4 Dec 2025 21:10:04 +0300 Subject: [PATCH 35/35] implement file reading (unlocking the database) - implement ReadField in PassStoreEntryPassword - make WriteField static - add ToString for LoginField, PassStoreEntryPassword & PassStoreEntryGroup - fix bugs in OuterEncryptionReader and PassStoreFileAccessor - change PassStoreFileAccessor.root type to PassStoreEntry --- .../Crypto/OuterEncryptionReader.cs | 5 +- .../Crypto/PassStoreContentChunk.cs | 24 ++++--- src/KeyKeeper/PasswordStore/LoginField.cs | 6 ++ src/KeyKeeper/PasswordStore/PassStoreEntry.cs | 41 +++++++++++ .../PasswordStore/PassStoreEntryGroup.cs | 9 ++- .../PasswordStore/PassStoreEntryPassword.cs | 45 +++++++++++- .../PasswordStore/PassStoreFileAccessor.cs | 72 +++++++++++++++++-- .../PasswordStore/PassStoreFileException.cs | 2 + 8 files changed, 186 insertions(+), 18 deletions(-) diff --git a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs index a479e83..035215b 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/OuterEncryptionReader.cs @@ -155,7 +155,10 @@ public class OuterEncryptionReader : Stream EraseCurrentChunk(); int decrypted = 0, read = 0; - currentChunk = new byte[(encryptedData.Length + encryptedRemainderLength) / 16 * 16]; + 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) diff --git a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs index b58c94a..30efa6d 100644 --- a/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs +++ b/src/KeyKeeper/PasswordStore/Crypto/PassStoreContentChunk.cs @@ -40,8 +40,11 @@ public class PassStoreContentChunk try { - chunkLen = rd.ReadUInt16(); - chunkLen = chunkLen | (rd.ReadByte() << 16); + 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) { @@ -90,18 +93,17 @@ public class PassStoreContentChunk { BinaryReader rd = new(s); int chunkLen; - try - { - chunkLen = rd.ReadUInt16(); - chunkLen = (chunkLen << 8) | rd.ReadByte(); - } - catch (EndOfStreamException) - { + + 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]; - if (s.Read(chunk) < chunk.Length) + Array.Copy(chunkLenBytes, chunk, 3); + if (s.Read(chunk, 3, HMAC_SIZE + chunkLen) < HMAC_SIZE + chunkLen) { throw PassStoreFileException.UnexpectedEndOfFile; } diff --git a/src/KeyKeeper/PasswordStore/LoginField.cs b/src/KeyKeeper/PasswordStore/LoginField.cs index d2067f0..e4973d9 100644 --- a/src/KeyKeeper/PasswordStore/LoginField.cs +++ b/src/KeyKeeper/PasswordStore/LoginField.cs @@ -1,4 +1,5 @@ using System; +using static KeyKeeper.PasswordStore.FileFormatConstants; namespace KeyKeeper.PasswordStore; @@ -7,4 +8,9 @@ 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 index eee882e..f9588bf 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs @@ -1,6 +1,7 @@ using System; using System.IO; using KeyKeeper.PasswordStore.Crypto; +using static KeyKeeper.PasswordStore.FileFormatConstants; namespace KeyKeeper.PasswordStore; @@ -33,5 +34,45 @@ public abstract class PassStoreEntry 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 index 0d75a41..9624cec 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs @@ -36,7 +36,7 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory try { byte groupType = rd.ReadByte(); - byte[] guidBuffer = new byte[8]; + byte[] guidBuffer = new byte[16]; Guid? customGroupSubtype = null; if (groupType == GROUP_TYPE_CUSTOM) { @@ -71,6 +71,13 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory 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(); diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs index 56481c2..09cece9 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs @@ -23,6 +23,31 @@ public class PassStoreEntryPassword : PassStoreEntry 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(); @@ -36,11 +61,29 @@ public class PassStoreEntryPassword : PassStoreEntry return str.ToArray(); } - private void WriteField(Stream str, LoginField field) + 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/PassStoreFileAccessor.cs b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs index 3876aeb..8169c34 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs @@ -20,7 +20,8 @@ public class PassStoreFileAccessor : IPassStore private string filename; private byte[]? key; - private IPassStoreDirectory? root; + private InnerEncryptionInfo? innerCrypto; + private PassStoreEntry? root; public PassStoreFileAccessor(string filename, bool create, StoreCreationOptions? createOptions) { @@ -44,7 +45,7 @@ public class PassStoreFileAccessor : IPassStore { if (Locked) throw new InvalidOperationException(); - return root!; + return (IPassStoreDirectory)root!; } public int GetTotalEntryCount() @@ -58,7 +59,50 @@ public class PassStoreFileAccessor : IPassStore using FileStream file = new(filename, FileMode.Open, FileAccess.Read, FileShare.None); FileHeader hdr = FileHeader.ReadFrom(file); - Console.WriteLine(hdr); // debug + + 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() @@ -153,7 +197,10 @@ public class PassStoreFileAccessor : IPassStore wr.Write(FILE_FIELD_CONFIG); wr.Write(FILE_FIELD_STORE); - root = (IPassStoreDirectory) WriteInitialStoreTree(cryptoWriter); + root = WriteInitialStoreTree(cryptoWriter); + + wr.Write(FILE_FIELD_END); + cryptoWriter.Flush(); cryptoWriter.Dispose(); } @@ -173,6 +220,17 @@ public class PassStoreFileAccessor : IPassStore 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, @@ -328,4 +386,10 @@ public class PassStoreFileAccessor : IPassStore 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 index ad858c0..35d5a03 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileException.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileException.cs @@ -9,6 +9,8 @@ public class PassStoreFileException : Exception 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) {