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