feat(api): bind encrypted workspaces to accounts and devices
This commit is contained in:
1 parent
019b15041a
commit
c7d8ef15bc
49 files changed
+1904
-278
No files matched your search
@@ -0,0 +1,236 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
|
||||
public sealed 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);
|
||||
""";
|
||||
cmd.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)
|
||||
{
|
||||
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) VALUES($u,$s,$v,$b,1,$p,$vs)";
|
||||
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.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;
|
||||
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 sealed record AccountDevice(string Id, string Name, long CreatedAt, long LastSeen, bool Current);
|
||||
Reference in new issue
Block a user