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) { string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired); if (account == channel.CreatorAccount) return; if (tx.IsJoinDenied(channel.Id, account)) throw new WebDropException(ErrorCode.Forbidden); if (tx.HasInvitation(channel.Id, account)) return; if (tx.GetChannelMember(channel.Id, account) is not null) return; if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired); 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 IReadOnlyList Invitations(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); if (principal.Role != Role.Owner) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); return tx.ListInvitations(id); }); /// 非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限 private Principal RequireMemberPermission(IResourceTx tx, Id16 id, Credentials creds, string permission) { var (_, principal) = Authenticate(tx, id, creds, Membership.Required); if (!HasWorkspacePermission(tx.GetWorkspace(id), principal, permission)) throw new WebDropException(ErrorCode.Forbidden); return principal; } 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, Membership.None); if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); if (principal.Role != Role.Owner) { // 被授权的成员不能移除自己,也不能移除同样有移除权限的人 var acting = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); if (username == acting.AccountName || HasWorkspacePermission(tx.GetWorkspace(id), new Principal(Role.Write, username), WorkspacePermission.RemoveMembers)) throw new WebDropException(ErrorCode.Forbidden); } tx.DeleteInvitation(id, username); tx.DeleteKeyPackage(id, username); tx.DeleteChannelMember(id, username); var crypto = tx.GetWorkspaceCrypto(id); crypto.Access.RemoveAll(a => a.Username == username); crypto.Envelopes.RemoveAll(e => e.Username == username); crypto.Revision++; tx.PutWorkspaceCrypto(id, crypto); tx.DenyAccount(id, username); return 0; }); events.MembersChanged(id); } /// 空间里所有成员的账号(不做鉴权,供已经通过鉴权的接口补全成员资料) public IReadOnlyList MemberAccounts(Id16 id) => store.Read(tx => tx.ListChannelMembers(id).Select(m => m.Username).ToList()); public IReadOnlyList Members(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, Membership.Required); return tx.ListChannelMembers(id); }); public IReadOnlyList AccountChannels(string username) => store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs)); 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, Membership.None); RequireOwner(principal); tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs); return 0; }); } }