using Newtonsoft.Json; using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; namespace SimpleHttpServer.Login; internal struct SerialLoginData { public string passwordSalt; public string extraDataSalt; public string pwd; public string extraData; public LoginData ToPlainData() { return new LoginData { passwordSalt = Convert.FromBase64String(passwordSalt), extraDataSalt = Convert.FromBase64String(extraDataSalt) }; } } internal struct LoginData { public byte[] passwordSalt; public byte[] extraDataSalt; public byte[] passwordHash; public byte[] encryptedExtraData; public SerialLoginData ToSerial() { return new SerialLoginData { passwordSalt = Convert.ToBase64String(passwordSalt), extraDataSalt = Convert.ToBase64String(extraDataSalt), pwd = Convert.ToBase64String(passwordHash), extraData = Convert.ToBase64String(encryptedExtraData) }; } } internal struct LoginDataProviderConfig { /// /// Size of the password salt and the extradata salt. So each salt will be of size . /// public int SALT_SIZE = 32; public int KEY_LENGTH = 256 / 8; public int PBKDF2_ITERATIONS = 600_000; public LoginDataProviderConfig() { } } public class LoginProvider { private static readonly Func JsonSerialize = t => Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(t)); private static readonly Func JsonDeserialize = b => JsonConvert.DeserializeObject(Encoding.UTF8.GetString(b))!; [ThreadStatic] private static SHA256? _sha256PerThread; private static SHA256 Sha256PerThread { get => _sha256PerThread ??= SHA256.Create(); } private readonly LoginDataProviderConfig config; private readonly ReaderWriterLockSlim ldLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); private readonly string ldPath; private readonly Dictionary loginDatas; private Func DataSerializer = JsonSerialize; private Func DataDeserializer = JsonDeserialize; public void SetDataSerializers(Func serializer, Func deserializer) { DataSerializer = serializer ?? JsonSerialize; DataDeserializer = deserializer ?? JsonDeserialize; } public LoginProvider(string ldPath, string confPath) { this.ldPath = ldPath; loginDatas = LoadLoginDatas(ldPath); config = LoadLoginProviderConfig(confPath); } private static Dictionary LoadLoginDatas(string path) { Dictionary tempData; if (!File.Exists(path)) { File.WriteAllText(path, "{}", Encoding.UTF8); tempData = new(); } else { tempData = JsonConvert.DeserializeObject>(File.ReadAllText(path))!; if (tempData == null) { throw new InvalidDataException($"could not read login data from file {path}"); } } var ld = new Dictionary(); foreach (var pair in tempData) { ld.Add(pair.Key, pair.Value.ToPlainData()); } return ld; } private void SaveLoginData() { var serial = new Dictionary(); ldLock.EnterWriteLock(); try { foreach (var pair in loginDatas) { serial.Add(pair.Key, pair.Value.ToSerial()); } } finally { ldLock.ExitWriteLock(); } File.WriteAllText(ldPath, JsonConvert.SerializeObject(serial)); } private static LoginDataProviderConfig LoadLoginProviderConfig(string path) { if (!File.Exists(path)) { var conf = new LoginDataProviderConfig(); File.WriteAllText(path, JsonConvert.SerializeObject(conf)); return conf; } return JsonConvert.DeserializeObject(File.ReadAllText(path)); } public bool AddUser(string username, string password, TExtraData additional) { ldLock.EnterWriteLock(); try { if (loginDatas.ContainsKey(username)) { return false; } var passwordSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE); var extraDataSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE); LoginData ld = new LoginData() { passwordSalt = passwordSalt, extraDataSalt = extraDataSalt, passwordHash = ComputeSaltedSha256Hash(password, passwordSalt), encryptedExtraData = EncryptExtraData(password, extraDataSalt, additional), }; loginDatas.Add(username, ld); SaveLoginData(); } finally { ldLock.ExitWriteLock(); } return true; } public bool RemoveUser(string username) { ldLock.EnterWriteLock(); try { var removed = loginDatas.Remove(username); if (removed) { SaveLoginData(); } return removed; } finally { ldLock.ExitWriteLock(); } } public bool ModifyUser(string username, string newPassword, TExtraData newExtraData) { ldLock.EnterWriteLock(); try { if (!loginDatas.ContainsKey(username)) { return false; } loginDatas.Remove(username, out var data); data.passwordHash = ComputeSaltedSha256Hash(newPassword, data.passwordSalt); data.encryptedExtraData = EncryptExtraData(newPassword, data.extraDataSalt, newExtraData); loginDatas.Add(username, data); SaveLoginData(); } finally { ldLock.ExitWriteLock(); } return true; } public bool TryAuthenticate(string username, string password, [MaybeNullWhen(false)] out TExtraData extraData) { LoginData data; ldLock.EnterReadLock(); try { if (!loginDatas.TryGetValue(username, out data)) { extraData = default; return false; } } finally { ldLock.ExitReadLock(); } var hash = ComputeSaltedSha256Hash(password, data.passwordSalt); if (!hash.SequenceEqual(data.passwordHash)) { extraData = default; return false; } extraData = DecryptExtraData(password, data.extraDataSalt, data.encryptedExtraData); return true; } /// /// Threadsafe as the SHA256 instance () is per thread. /// /// /// /// private static byte[] ComputeSaltedSha256Hash(string data, byte[] salt) { var dataBytes = Encoding.UTF8.GetBytes(data); var buf = new byte[data.Length + salt.Length]; Buffer.BlockCopy(dataBytes, 0, buf, 0, dataBytes.Length); Buffer.BlockCopy(salt, 0, buf, dataBytes.Length, salt.Length); return Sha256PerThread.ComputeHash(buf); } private byte[] EncryptExtraData(string pwd, byte[] salt, TExtraData extraData) { var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8); var plainBytes = DataSerializer(extraData); using var aes = Aes.Create(); aes.KeySize = config.KEY_LENGTH; aes.Key = key; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV); byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); var encryptedBytes = new byte[aes.IV.Length + cipherBytes.Length]; Array.Copy(aes.IV, 0, encryptedBytes, 0, aes.IV.Length); Array.Copy(cipherBytes, 0, encryptedBytes, aes.IV.Length, cipherBytes.Length); return encryptedBytes; } private TExtraData DecryptExtraData(string pwd, byte[] salt, byte[] encryptedData) { var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8); using var aes = Aes.Create(); aes.KeySize = config.KEY_LENGTH; aes.Key = key; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; var iv = new byte[aes.BlockSize / 8]; var cipherBytes = new byte[encryptedData.Length - iv.Length]; Array.Copy(encryptedData, 0, iv, 0, iv.Length); Array.Copy(encryptedData, iv.Length, cipherBytes, 0, cipherBytes.Length); aes.IV = iv; ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV); byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); return DataDeserializer(plainBytes); } }