feat(api): add account workspaces and signed content events
This commit is contained in:
1 parent
937b774a93
commit
7b8d61d1ba
63 files changed
+5407
-370
No files matched your search
@@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite;
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
|
||||
public sealed class SqliteAccountStore : IDisposable
|
||||
public sealed partial class SqliteAccountStore : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly object _gate = new();
|
||||
@@ -20,8 +20,25 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
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);
|
||||
@@ -45,7 +62,7 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey)
|
||||
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey, string? signingPublicKey = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
@@ -53,10 +70,11 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
{
|
||||
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.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; }
|
||||
@@ -77,6 +95,26 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
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();
|
||||
@@ -231,6 +269,16 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in new issue
Block a user