Files
WebDrop/server/src/WebDrop.Storage/SqliteAccountStore.cs
T

285 lines
15 KiB
C#

using System.Security.Cryptography;
using Microsoft.Data.Sqlite;
namespace WebDrop.Storage;
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
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<AccountDevice> 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<AccountDevice>();
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);