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
@@ -0,0 +1,41 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
public sealed partial class SqliteAccountStore
|
||||
{
|
||||
public AccountProfile Profile(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT display_name,avatar,modified_at FROM account_profiles WHERE username=$u";
|
||||
cmd.Parameters.AddWithValue("$u", username);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), ModifiedAt = reader.GetInt64(2) }
|
||||
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
|
||||
}
|
||||
}
|
||||
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
|
||||
{
|
||||
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (avatar is not null)
|
||||
{
|
||||
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
||||
if (prefix is null || avatar.Length > 350_000 || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try { _ = Convert.FromBase64String(avatar[prefix.Length..]); }
|
||||
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,modified_at) VALUES($u,$n,$a,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)";
|
||||
cmd.Parameters.AddWithValue("$u", username);
|
||||
cmd.Parameters.AddWithValue("$n", displayName.Trim());
|
||||
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
cmd.ExecuteNonQuery();
|
||||
return Profile(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
public sealed record AccountDeviceSigningKey(string PublicKey, byte[] CertificateSignature);
|
||||
|
||||
public sealed partial class SqliteAccountStore
|
||||
{
|
||||
public AccountDeviceSigningKey? DeviceSigningKey(string username, string deviceId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT public_key,certificate_signature FROM account_device_signing_keys WHERE username=$u AND device_id=$d";
|
||||
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
return reader.Read() ? new(reader.GetString(0), (byte[])reader[1]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SaveDeviceSigningKey(string username, string deviceId, string publicKey, byte[] signature)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var device = _connection.CreateCommand();
|
||||
device.CommandText = "SELECT 1 FROM account_devices WHERE username=$u AND id=$d";
|
||||
device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId);
|
||||
if (device.ExecuteScalar() is null) return false;
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO account_device_signing_keys(username,device_id,public_key,certificate_signature,modified_at)
|
||||
VALUES($u,$d,$p,$s,$m)
|
||||
ON CONFLICT(username,device_id) DO UPDATE SET public_key=excluded.public_key,
|
||||
certificate_signature=excluded.certificate_signature,modified_at=excluded.modified_at
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
|
||||
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$s", signature);
|
||||
cmd.Parameters.AddWithValue("$m", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
return cmd.ExecuteNonQuery() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
@@ -44,6 +44,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
|
||||
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -253,6 +255,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
|
||||
{
|
||||
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
|
||||
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
|
||||
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceJson.Default.WorkspaceState)!, ("@id", id.ToArray()));
|
||||
public CryptoState GetWorkspaceCrypto(Id16 id) => One("SELECT state FROM workspace_crypto WHERE space_id=@id",
|
||||
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceCryptoJson.Default.CryptoState)!, ("@id", id.ToArray())) ?? new CryptoState();
|
||||
public void PutWorkspaceCrypto(Id16 id, CryptoState state) => Run(
|
||||
"INSERT INTO workspace_crypto(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
|
||||
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceCryptoJson.Default.CryptoState)));
|
||||
public IReadOnlyList<Id16> ListAccountSpaces(string username, long now) => Many(
|
||||
"SELECT c.id FROM channels c JOIN channel_members m ON m.channel_id=c.id WHERE m.username=@u AND c.expires_at>@n ORDER BY c.created_at DESC",
|
||||
r => Id16.FromBytes((byte[])r[0]), ("@u", username), ("@n", now));
|
||||
public void PutWorkspace(Id16 id, WorkspaceState state) => Run(
|
||||
"INSERT INTO workspace_structures(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
|
||||
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceJson.Default.WorkspaceState)));
|
||||
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, author_account";
|
||||
private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
|
||||
private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline";
|
||||
@@ -487,6 +502,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key));
|
||||
Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
|
||||
}
|
||||
@@ -505,6 +522,9 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public int CountSeats(Id16 channelId) =>
|
||||
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public int CountMembers(Id16 channelId) =>
|
||||
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public void InsertSeat(Id16 channelId, Seat seat) => Run(
|
||||
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)",
|
||||
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
public sealed record StoredPasskey(string Id, string Username, string Name, string Data, string PrfSalt, string? KeyEnvelope, long CreatedAt);
|
||||
public sealed partial class SqliteAccountStore
|
||||
{
|
||||
public IReadOnlyList<StoredPasskey> Passkeys(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT id,username,name,data,prf_salt,key_envelope,created_at FROM account_passkeys WHERE username=$u ORDER BY created_at";
|
||||
cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader(); var rows = new List<StoredPasskey>();
|
||||
while (reader.Read()) rows.Add(new(reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4), reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetInt64(6)));
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
public StoredPasskey? Passkey(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT username FROM account_passkeys WHERE id=$i"; cmd.Parameters.AddWithValue("$i", id);
|
||||
return cmd.ExecuteScalar() is string username ? Passkeys(username).FirstOrDefault(p => p.Id == id) : null;
|
||||
}
|
||||
}
|
||||
public void AddPasskey(StoredPasskey passkey)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (Passkeys(passkey.Username).Count >= 16 || Passkey(passkey.Id) is not null) throw new WebDropException(ErrorCode.Conflict);
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "INSERT INTO account_passkeys(id,username,name,data,prf_salt,key_envelope,created_at) VALUES($i,$u,$n,$d,$s,$k,$t)";
|
||||
cmd.Parameters.AddWithValue("$i", passkey.Id); cmd.Parameters.AddWithValue("$u", passkey.Username); cmd.Parameters.AddWithValue("$n", passkey.Name);
|
||||
cmd.Parameters.AddWithValue("$d", passkey.Data); cmd.Parameters.AddWithValue("$s", passkey.PrfSalt); cmd.Parameters.AddWithValue("$k", (object?)passkey.KeyEnvelope ?? DBNull.Value); cmd.Parameters.AddWithValue("$t", passkey.CreatedAt); cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
public bool UpdatePasskeyData(string id, string username, string oldData, string newData)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET data=$n WHERE id=$i AND username=$u AND data=$old";
|
||||
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$n", newData); cmd.Parameters.AddWithValue("$old", oldData); return cmd.ExecuteNonQuery() == 1;
|
||||
}
|
||||
}
|
||||
public void UpdatePasskeyEnvelope(string id, string username, string envelope)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET key_envelope=$k WHERE id=$i AND username=$u";
|
||||
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$k", envelope);
|
||||
if (cmd.ExecuteNonQuery() != 1) throw new WebDropException(ErrorCode.NotFound);
|
||||
}
|
||||
}
|
||||
public void RemovePasskey(string id, string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "DELETE FROM account_passkeys WHERE id=$i AND username=$u";
|
||||
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user