using System.Security.Cryptography; using Microsoft.Data.Sqlite; namespace WebDrop.Storage; /// Account authentication and opaque client-encrypted vault storage. public sealed partial class SqliteAccountStore : IDisposable { private readonly SqliteConnection _connection; private readonly object _gate = new(); public SqliteAccountStore(string path) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); _connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString()); _connection.Open(); using var cmd = _connection.CreateCommand(); cmd.CommandText = """ CREATE TABLE IF NOT EXISTS accounts (username TEXT PRIMARY KEY COLLATE NOCASE, salt BLOB NOT NULL, verifier BLOB NOT NULL, verifier_salt BLOB NOT NULL, public_key TEXT NOT NULL, vault BLOB NOT NULL, revision INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS account_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, expires INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS account_devices (username TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY(username,id)); CREATE TABLE IF NOT EXISTS account_device_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, device_id TEXT NOT NULL); CREATE TABLE IF NOT EXISTS account_device_signing_keys (username TEXT NOT NULL, device_id TEXT NOT NULL, public_key TEXT NOT NULL, certificate_signature BLOB NOT NULL, modified_at INTEGER NOT NULL, PRIMARY KEY(username,device_id)); CREATE TABLE IF NOT EXISTS account_profiles (username TEXT PRIMARY KEY COLLATE NOCASE, display_name TEXT NOT NULL, avatar TEXT, modified_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS account_passkeys (id TEXT PRIMARY KEY, username TEXT NOT NULL, name TEXT NOT NULL, data TEXT NOT NULL, prf_salt TEXT NOT NULL, key_envelope TEXT, created_at INTEGER NOT NULL); """; cmd.ExecuteNonQuery(); using (var columns = _connection.CreateCommand()) { columns.CommandText = "PRAGMA table_info(accounts)"; using var reader = columns.ExecuteReader(); bool hasSigningKey = false; while (reader.Read()) if (reader.GetString(1) == "signing_public_key") hasSigningKey = true; reader.Close(); if (!hasSigningKey) { using var alter = _connection.CreateCommand(); alter.CommandText = "ALTER TABLE accounts ADD COLUMN signing_public_key TEXT"; alter.ExecuteNonQuery(); } } } private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32); public byte[] LoginSalt(string username) { lock (_gate) { using (var cleanup = _connection.CreateCommand()) { cleanup.CommandText = "DELETE FROM account_sessions WHERE expires<=$n; DELETE FROM account_device_sessions WHERE token_hash NOT IN (SELECT token_hash FROM account_sessions);"; cleanup.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); cleanup.ExecuteNonQuery(); } using var q = _connection.CreateCommand(); q.CommandText = "SELECT salt FROM accounts WHERE username=$u"; q.Parameters.AddWithValue("$u", username); var value = q.ExecuteScalar(); byte[] salt = value is byte[] found ? found : RandomNumberGenerator.GetBytes(16); return salt; } } public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey, string? signingPublicKey = null) { lock (_gate) { try { using var cmd = _connection.CreateCommand(); var verifierSalt = RandomNumberGenerator.GetBytes(16); cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt,signing_public_key) VALUES($u,$s,$v,$b,1,$p,$vs,$sp)"; cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$s", salt); cmd.Parameters.AddWithValue("$v", HashVerifier(verifier, verifierSalt)); cmd.Parameters.AddWithValue("$b", vault); cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$vs", verifierSalt); cmd.Parameters.AddWithValue("$sp", (object?)signingPublicKey ?? DBNull.Value); cmd.ExecuteNonQuery(); return true; } catch (SqliteException e) when (e.SqliteErrorCode == 19) { return false; } } } public string? Authenticate(string username, byte[] suppliedVerifier, string? deviceId = null, string? deviceName = null) { lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT verifier,verifier_salt FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username); using var r = cmd.ExecuteReader(); bool found = r.Read(); var verifier = found ? (byte[])r[0] : new byte[32]; var verifierSalt = found ? (byte[])r[1] : new byte[16]; r.Close(); var expected = HashVerifier(suppliedVerifier, verifierSalt); if (!CryptographicOperations.FixedTimeEquals(verifier, expected) || !found) return null; return CreateDeviceSession(username, deviceId, deviceName); } } public bool VerifyLogin(string username, byte[] verifier) { lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT verifier,verifier_salt FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader(); var found = reader.Read(); var expected = found ? (byte[])reader[0] : new byte[32]; var salt = found ? (byte[])reader[1] : new byte[16]; return CryptographicOperations.FixedTimeEquals(expected, HashVerifier(verifier, salt)) && found; } } public string? CreateDeviceSession(string username, string? deviceId, string? deviceName) { lock (_gate) { using var exists = _connection.CreateCommand(); exists.CommandText = "SELECT 1 FROM accounts WHERE username=$u"; exists.Parameters.AddWithValue("$u", username); if (exists.ExecuteScalar() is null) return null; if (deviceId is not null && (!Guid.TryParseExact(deviceId, "D", out _) || deviceName is null || deviceName.Length is < 1 or > 80 || deviceName.Any(char.IsControl))) return null; var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); using var transaction = _connection.BeginTransaction(); using var add = _connection.CreateCommand(); add.Transaction = transaction; add.CommandText = "INSERT INTO account_sessions(token_hash,username,expires) VALUES($h,$u,$e)"; add.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token))); add.Parameters.AddWithValue("$u", username); add.Parameters.AddWithValue("$e", DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeSeconds()); add.ExecuteNonQuery(); if (deviceId is not null) { using var device = _connection.CreateCommand(); device.Transaction = transaction; device.CommandText = """ INSERT INTO account_devices(username,id,name,created_at,last_seen) VALUES($u,$d,$name,$now,$now) ON CONFLICT(username,id) DO UPDATE SET name=excluded.name,last_seen=excluded.last_seen; INSERT INTO account_device_sessions(token_hash,username,device_id) VALUES($h,$u,$d); """; device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId); device.Parameters.AddWithValue("$name", deviceName!); device.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); device.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token))); device.ExecuteNonQuery(); } transaction.Commit(); return token; } } public bool HasSession(string token, out string username) { username = ""; if (token.Length != 44) return false; byte[] raw; try { raw = Convert.FromBase64String(token); } catch (FormatException) { return false; } if (raw.Length != 32) return false; lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT username FROM account_sessions WHERE token_hash=$h AND expires>$n"; cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw)); cmd.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); var found = cmd.ExecuteScalar(); if (found is not string name) return false; username = name; using var seen = _connection.CreateCommand(); seen.CommandText = """ UPDATE account_devices SET last_seen=$now WHERE username=$u AND last_seen<$cutoff AND id=(SELECT device_id FROM account_device_sessions WHERE token_hash=$h) """; var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); seen.Parameters.AddWithValue("$now", now); seen.Parameters.AddWithValue("$cutoff", now - 60_000); seen.Parameters.AddWithValue("$u", name); seen.Parameters.AddWithValue("$h", SHA256.HashData(raw)); seen.ExecuteNonQuery(); return true; } } public void RevokeSession(string token) { if (token.Length != 44) return; byte[] raw; try { raw = Convert.FromBase64String(token); } catch (FormatException) { return; } lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "DELETE FROM account_sessions WHERE token_hash=$h; DELETE FROM account_device_sessions WHERE token_hash=$h;"; cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw)); cmd.ExecuteNonQuery(); } } public (byte[] Vault, long Revision)? GetVault(string username) { lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT vault,revision FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username); using var r = cmd.ExecuteReader(); return r.Read() ? ((byte[])r[0], r.GetInt64(1)) : null; } } public long? PutVault(string username, byte[] vault, long revision) { lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE accounts SET vault=$v,revision=revision+1 WHERE username=$u AND revision=$r RETURNING revision"; cmd.Parameters.AddWithValue("$v", vault); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$r", revision); var updated = cmd.ExecuteScalar(); return updated is long value ? value : null; } } public void Dispose() { _connection.Dispose(); SqliteConnection.ClearPool(_connection); } public string? SessionDevice(string token) { if (!HasSession(token, out _)) return null; lock (_gate) { using var q = _connection.CreateCommand(); q.CommandText = "SELECT device_id FROM account_device_sessions WHERE token_hash=$h"; q.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token))); return q.ExecuteScalar() as string; } } public IReadOnlyList Devices(string username, string? currentId) { lock (_gate) { using var q = _connection.CreateCommand(); q.CommandText = """ SELECT d.id,d.name,d.created_at,d.last_seen FROM account_devices d WHERE d.username=$u AND EXISTS (SELECT 1 FROM account_device_sessions ds JOIN account_sessions s ON s.token_hash=ds.token_hash WHERE ds.username=d.username AND ds.device_id=d.id AND s.expires>$now) ORDER BY d.last_seen DESC """; q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); using var r = q.ExecuteReader(); var result = new List(); while (r.Read()) result.Add(new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3), r.GetString(0) == currentId)); return result; } } public bool RenameDevice(string username, string id, string name) { if (name.Length is < 1 or > 80 || name.Any(char.IsControl)) return false; lock (_gate) { using var q = _connection.CreateCommand(); q.CommandText = "UPDATE account_devices SET name=$n WHERE username=$u AND id=$d"; q.Parameters.AddWithValue("$n", name); q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id); return q.ExecuteNonQuery() > 0; } } public void RevokeDevice(string username, string id) { lock (_gate) { using var q = _connection.CreateCommand(); q.CommandText = """ DELETE FROM account_sessions WHERE token_hash IN (SELECT token_hash FROM account_device_sessions WHERE username=$u AND device_id=$d); DELETE FROM account_device_sessions WHERE username=$u AND device_id=$d; DELETE FROM account_devices WHERE username=$u AND id=$d; """; q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id); q.ExecuteNonQuery(); } } public string? GetPublicKey(string username) { lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT public_key FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username); return cmd.ExecuteScalar() as string; } } public string? GetSigningPublicKey(string username) { lock (_gate) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT signing_public_key FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username); return cmd.ExecuteScalar() as string; } } } public sealed record AccountDevice(string Id, string Name, long CreatedAt, long LastSeen, bool Current);