feat(api): add account workspaces and signed content events

This commit is contained in:
justme committed 2026-10-14 04:05:30 +08:00
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();
}
}
}