using System.Security.Cryptography; namespace WebDrop.Core; public sealed partial class ChannelService { private static (byte[]? Hash, byte[]? Salt) HashJoinPassword(string? password) { if (string.IsNullOrEmpty(password)) return (null, null); if (password.Length is < 8 or > 256) throw new WebDropException(ErrorCode.BadRequest); var salt = RandomNumberGenerator.GetBytes(16); return (Rfc2898DeriveBytes.Pbkdf2(password, salt, 210_000, HashAlgorithmName.SHA256, 32), salt); } private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password) { if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return; if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden); bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName); if (invited) return; if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return; if (channel.Settings.JoinPolicy == "invite") throw new WebDropException(ErrorCode.InvitationRequired); if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired); if (channel.JoinPasswordHash is null) return; if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired); if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid); var actual = Rfc2898DeriveBytes.Pbkdf2(password, channel.JoinPasswordSalt!, 210_000, HashAlgorithmName.SHA256, 32); if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid); } public void InviteUser(Id16 id, Credentials creds, string username, string payload) { if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs)); if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); tx.PutKeyPackage(id, username, payload, clock.NowMs); tx.AllowAccount(id, username); return 0; }); } public IReadOnlyList Invitations(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); return tx.ListInvitations(id); }); public IReadOnlyList UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username)); public void RevokeInvitation(Id16 id, Credentials creds, string username) { store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); tx.DeleteInvitation(id, username); tx.DeleteKeyPackage(id, username); tx.DeleteAccountSeats(id, username); tx.DeleteChannelMember(id, username); tx.DenyAccount(id, username); return 0; }); events.SeatsChanged(id); } public IReadOnlyList Members(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required); return tx.ListChannelMembers(id); }); public IReadOnlyList AccountChannels(string username) => store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs)); public void ReleaseAccountDevice(string username, string deviceId) { var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId)); foreach (var id in channels) events.SeatsChanged(id); } public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload) { if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs); return 0; }); } }