From a401ae952a63fe4cdbf33b6f70f2755f69cc8c8d Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Fri, 2 Oct 2026 13:39:00 -0400 Subject: [PATCH 1/6] Apply protos for existing features --- CLAUDE.md | 2 +- chat/create.go | 7 ++++--- chat/tests/server.go | 43 +++++++++++++++++++++++++++++---------- go.mod | 2 +- go.sum | 4 ++-- messaging/message.go | 42 ++++++++++++++++++++++++++------------ messaging/tests/server.go | 27 ++++++++++++++++++++++-- messaging/tests/store.go | 16 ++++++++++++--- 8 files changed, 107 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d4589d..baec9e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,7 +191,7 @@ This is the most intricate part of the codebase and where most current work happ **Widget content (`WidgetContent`).** Server-authored only: `clientAllowedContent` refuses it for send, edit and as a reply body; today only the team's welcome sends one (`ShareProfileWidget`). Replyable and reactable, never editable or deletable; `redact.Content` keeps the variant but sets a share's username to `redacted` (an unknown variant fails the read); a push renders its plain-text stand-in as the body (a profile share is `https://flipcash.com/`, `renderWidgetPushBody`; an unknown variant earns no push) and carries the whole message in the payload like any other. -**Encrypted content (`EncryptedContent`).** DMs only. `clientAllowedContent` accepts it top-level only (never as a reply body; a reply is encrypted whole), and `SendMessage` / `EditMessage` answer `ENCRYPTION_NOT_ALLOWED` for a group ID *after* the speaker gate, so a non-member is still `DENIED`. The server never reads the ciphertext: nothing inside is validated (a reply wrapped inside is never checked against the thread; a `MediaContent` wrapped inside names end-to-end encrypted blobs the sender uploaded for the DM, see `blob/`, which messaging never shares, hydrates or sees — the sender fills the ORIGINAL rendition's metadata itself), it is replyable/reactable/editable/deletable like text, except that an edit never downgrades it (encrypted → plaintext is `CANNOT_EDIT`, judged on `GetMessage`, which is strongly consistent so the check and the edit's `expected_event_sequence` guard together close the race; plaintext → encrypted is allowed), `redact.Content` passes it through (only a DM's members can reach it), and a DM push gets the generic body "Sent you a message" with the message in the payload when it fits under `maxChatPushBytes`, and only `ChatMetadata.message_id` otherwise. +**Encrypted content (`EncryptedContent`).** DMs only. `clientAllowedContent` accepts it top-level only (never as a reply body; a reply is encrypted whole), and `SendMessage` / `EditMessage` answer `ENCRYPTION_NOT_ALLOWED` (`encryptionAllowed`) for a group ID, or for a DM under any scheme but its own (`X25519_XCHACHA20POLY1305`; the proto also defines `CHAT_KEY_XCHACHA20POLY1305` for private groups, which are not built: `StartChat` refuses the `private_group` variant and the lobby and key envelope RPCs are unimplemented), *after* the speaker gate, so a non-member is still `DENIED`. The server never reads the ciphertext beyond its scheme: nothing inside is validated (a reply wrapped inside is never checked against the thread; a `MediaContent` wrapped inside names end-to-end encrypted blobs the sender uploaded for the DM, see `blob/`, which messaging never shares, hydrates or sees — the sender fills the ORIGINAL rendition's metadata itself), it is replyable/reactable/editable/deletable like text, except that an edit never downgrades it (encrypted → plaintext is `CANNOT_EDIT`, judged on `GetMessage`, which is strongly consistent so the check and the edit's `expected_event_sequence` guard together close the race; plaintext → encrypted is allowed), `redact.Content` passes it through (only a DM's members can reach it), and a DM push gets the generic body "Sent you a message" with the message in the payload when it fits under `maxChatPushBytes`, and only `ChatMetadata.message_id` otherwise. **`Metadata.use_e2ee` (transitional).** Set by `hydrate` alone, per read and never stored: exactly when the chat is a DM, **every member is a staff user** (`useE2ee`; the account store's `IsStaff` per DM member across the page, resolved concurrently in `staffFlags`) **and none is the Flipcash team account** (the `teamUserID` passed to `chat.NewServer`, nil for none; the parent resolves it with `flipcashteam.GetUserID`; the server writes into the team's DMs and holds no keys for it). Groups never carry it. Nothing else publishes DM metadata, so this is the only place the flag is decided; once E2EE launches the client is expected to ignore it and it will be deprecated. diff --git a/chat/create.go b/chat/create.go index b64eca7..c36f13c 100644 --- a/chat/create.go +++ b/chat/create.go @@ -63,9 +63,10 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* log := s.log.With(zap.String("user_id", model.UserIDString(userID))) - // Validation requires the oneof to be set, and GROUP is its only variant, so - // anything else here is a proto this server predates. - params := req.GetGroup() + // Validation requires the oneof to be set. Only a public group can be + // created: the private group variant is refused until private groups are + // built, like any variant this server predates. + params := req.GetPublicGroup() if params == nil { return nil, status.Error(codes.InvalidArgument, "unsupported chat parameters") } diff --git a/chat/tests/server.go b/chat/tests/server.go index 2244b8d..b37b7bd 100644 --- a/chat/tests/server.go +++ b/chat/tests/server.go @@ -109,6 +109,7 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_StartChat_PictureNotAccepted, testServer_StartChat_TitleModerated, testServer_StartChat_ModerationFailureIsInternal, + testServer_StartChat_PrivateGroupRefused, testServer_StartChat_InvalidRules, testServer_StartChat_RulesNotSatisfied, testServer_StartChat_WithRules, @@ -2866,26 +2867,26 @@ func newIdempotencyKey() *chatpb.IdempotencyKey { // startGroupChat starts a new group under a fresh idempotency key. A test // exercising retries supplies its own key via startGroupChatWithKey. -func (e *serverEnv) startGroupChat(keys model.KeyPair, params *chatpb.StartChatRequest_GroupChatParameters) (*chatpb.StartChatResponse, error) { +func (e *serverEnv) startGroupChat(keys model.KeyPair, params *chatpb.StartChatRequest_PublicGroupChatParameters) (*chatpb.StartChatResponse, error) { return e.startGroupChatWithKey(keys, newIdempotencyKey(), params) } -func (e *serverEnv) startGroupChatWithKey(keys model.KeyPair, key *chatpb.IdempotencyKey, params *chatpb.StartChatRequest_GroupChatParameters) (*chatpb.StartChatResponse, error) { +func (e *serverEnv) startGroupChatWithKey(keys model.KeyPair, key *chatpb.IdempotencyKey, params *chatpb.StartChatRequest_PublicGroupChatParameters) (*chatpb.StartChatResponse, error) { req := &chatpb.StartChatRequest{ - Parameters: &chatpb.StartChatRequest_Group{Group: params}, + Parameters: &chatpb.StartChatRequest_PublicGroup{PublicGroup: params}, IdempotencyKey: key, } require.NoError(e.t, keys.Auth(req, &req.Auth)) return e.client.StartChat(e.ctx, req) } -func (e *serverEnv) mustStartGroupChat(keys model.KeyPair, params *chatpb.StartChatRequest_GroupChatParameters) *chatpb.StartChatResponse { +func (e *serverEnv) mustStartGroupChat(keys model.KeyPair, params *chatpb.StartChatRequest_PublicGroupChatParameters) *chatpb.StartChatResponse { resp, err := e.startGroupChat(keys, params) require.NoError(e.t, err) return resp } -func (e *serverEnv) mustStartGroupChatWithKey(keys model.KeyPair, key *chatpb.IdempotencyKey, params *chatpb.StartChatRequest_GroupChatParameters) *chatpb.StartChatResponse { +func (e *serverEnv) mustStartGroupChatWithKey(keys model.KeyPair, key *chatpb.IdempotencyKey, params *chatpb.StartChatRequest_PublicGroupChatParameters) *chatpb.StartChatResponse { resp, err := e.startGroupChatWithKey(keys, key, params) require.NoError(e.t, err) return resp @@ -2905,8 +2906,8 @@ func minimumBalanceRule(currency string, amount float64) *chatpb.ListenerRules { // groupParams builds StartChat parameters for a group with the given title and // the default minimum balance rule. -func groupParams(title string) *chatpb.StartChatRequest_GroupChatParameters { - return &chatpb.StartChatRequest_GroupChatParameters{ +func groupParams(title string) *chatpb.StartChatRequest_PublicGroupChatParameters { + return &chatpb.StartChatRequest_PublicGroupChatParameters{ Title: title, Rules: &chatpb.Rules{Listener: []*chatpb.ListenerRules{minimumBalanceRule("usd", startChatMinimumBalance)}}, } @@ -3091,7 +3092,7 @@ func testServer_StartChat_Idempotent(t *testing.T, s chat.Store) { // before anything is read or written. for _, key := range []*chatpb.IdempotencyKey{nil, {Value: []byte("short")}} { req := &chatpb.StartChatRequest{ - Parameters: &chatpb.StartChatRequest_Group{Group: groupParams("Keyless")}, + Parameters: &chatpb.StartChatRequest_PublicGroup{PublicGroup: groupParams("Keyless")}, IdempotencyKey: key, } require.NoError(t, e.keys.Auth(req, &req.Auth)) @@ -3230,6 +3231,26 @@ func testServer_StartChat_ModerationFailureIsInternal(t *testing.T, s chat.Store require.Empty(t, groups) } +// testServer_StartChat_PrivateGroupRefused pins that a private group cannot +// be created yet: the variant is refused and nothing is written. +func testServer_StartChat_PrivateGroupRefused(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + + req := &chatpb.StartChatRequest{ + Parameters: &chatpb.StartChatRequest_PrivateGroup{PrivateGroup: &chatpb.StartChatRequest_PrivateGroupChatParameters{ + Title: "Sunday Hikers", + }}, + IdempotencyKey: newIdempotencyKey(), + } + require.NoError(t, e.keys.Auth(req, &req.Auth)) + _, err := e.client.StartChat(e.ctx, req) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + + groups, err := s.GetGroupChatsForUser(e.ctx, e.userID) + require.NoError(t, err) + require.Empty(t, groups) +} + func testServer_StartChat_InvalidRules(t *testing.T, s chat.Store) { e := newServerEnv(t, s) @@ -3269,7 +3290,7 @@ func testServer_StartChat_InvalidRules(t *testing.T, s chat.Store) { "unpriced minimum balance": {Listener: []*chatpb.ListenerRules{minimumBalanceRule("xyz", 1)}}, } { t.Run(name, func(t *testing.T) { - resp := e.mustStartGroupChat(e.keys, &chatpb.StartChatRequest_GroupChatParameters{Title: "Ruled", Rules: rules}) + resp := e.mustStartGroupChat(e.keys, &chatpb.StartChatRequest_PublicGroupChatParameters{Title: "Ruled", Rules: rules}) require.Equal(t, chatpb.StartChatResponse_INVALID_RULES, resp.Result) require.Nil(t, resp.Chat) }) @@ -3329,7 +3350,7 @@ func testServer_StartChat_WithRules(t *testing.T, s chat.Store) { require.NoError(t, err) e.ocpBalance.setBalance(e.keys.Proto(), ocp_common.ToCoreMintQuarks(requirement)) - resp := e.mustStartGroupChat(e.keys, &chatpb.StartChatRequest_GroupChatParameters{Title: "Staff Whales", Rules: rules}) + resp := e.mustStartGroupChat(e.keys, &chatpb.StartChatRequest_PublicGroupChatParameters{Title: "Staff Whales", Rules: rules}) require.Equal(t, chatpb.StartChatResponse_OK, resp.Result) require.NoError(t, protoutil.ProtoEqualError(rules, resp.Chat.GetRules())) @@ -3362,7 +3383,7 @@ func testServer_StartChat_FiatMinimumBalance(t *testing.T, s chat.Store) { // 0.9 EUR per USDF, so 90 EUR is 100 USDF. const requirement = 90 e.ocpBalance.setRate("eur", 0.9) - params := &chatpb.StartChatRequest_GroupChatParameters{ + params := &chatpb.StartChatRequest_PublicGroupChatParameters{ Title: "Euro Whales", Rules: &chatpb.Rules{Listener: []*chatpb.ListenerRules{minimumBalanceRule("eur", requirement)}}, } diff --git a/go.mod b/go.mod index 19e550a..fb73f98 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/aws/smithy-go v1.27.7 github.com/buckket/go-blurhash v1.1.0 github.com/cespare/xxhash/v2 v2.3.0 - github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261001124414-811dcdc76725 + github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261002173547-202b27a3db0c github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 github.com/code-payments/ocp-server v1.24.1-0.20260903184009-272d62754fdc github.com/devsisters/go-applereceipt v0.0.0-20240805020915-fa22a0160fc2 diff --git a/go.sum b/go.sum index 4d2adda..cc76305 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB0 github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261001124414-811dcdc76725 h1:7AEbV67MOJXGNXNS+vIpWkOBP0OAK6frCiyto5AR6MM= -github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261001124414-811dcdc76725/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261002173547-202b27a3db0c h1:yT2mWbB5fcIS4WxFti1FZIQ4N33oTkM2xtw+lrpjPSA= +github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261002173547-202b27a3db0c/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 h1:/cE319d70UiXoor2x9b2faiFTUFlzdgs10D4Fvq7dN4= github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/code-payments/ocp-server v1.24.1-0.20260903184009-272d62754fdc h1:PV15RiggjYCPhSwDlVmNvlYVQ8VZ0jqp+3YSuz5BN/E= diff --git a/messaging/message.go b/messaging/message.go index 861f9ea..ebd3364 100644 --- a/messaging/message.go +++ b/messaging/message.go @@ -156,10 +156,10 @@ func (s *Server) SendMessage(ctx context.Context, req *messagingpb.SendMessageRe return &messagingpb.SendMessageResponse{Result: messagingpb.SendMessageResponse_DENIED}, nil } - // Encrypted content is between a DM's two members (see - // messagingpb.EncryptedContent). Checked after the speaker gate so a - // non-member is DENIED like any other send. - if isEncrypted(req.Content) && chat.IsGroupChatID(req.ChatId) { + // Encrypted content is between a DM's two members, under the DM's scheme + // (see encryptionAllowed). Checked after the speaker gate so a non-member + // is DENIED like any other send. + if !encryptionAllowed(req.ChatId, req.Content) { return &messagingpb.SendMessageResponse{Result: messagingpb.SendMessageResponse_ENCRYPTION_NOT_ALLOWED}, nil } @@ -220,8 +220,8 @@ func (s *Server) EditMessage(ctx context.Context, req *messagingpb.EditMessageRe return &messagingpb.EditMessageResponse{Result: messagingpb.EditMessageResponse_DENIED}, nil } - // The same DM-only rule as a send. - if isEncrypted(req.Content) && chat.IsGroupChatID(req.ChatId) { + // The same rule as a send. + if !encryptionAllowed(req.ChatId, req.Content) { return &messagingpb.EditMessageResponse{Result: messagingpb.EditMessageResponse_ENCRYPTION_NOT_ALLOWED}, nil } @@ -421,10 +421,10 @@ func (s *Server) DeleteMessage(ctx context.Context, req *messagingpb.DeleteMessa // which only the server sends; see flipcashteam) and any content type added // later until it is explicitly allowed. repliedMessageID is non-nil // only for a valid reply, signaling the caller to verify the replied-to message -// exists and is repliable. Encrypted content is allowed in a DM only, which -// depends on the chat and so is the caller's to enforce (see isEncrypted), as -// is the rule that an edit never downgrades an encrypted message to plaintext, -// which depends on the message being edited. +// exists and is repliable. Whether encrypted content is allowed depends on the +// chat and so is the caller's to enforce (see encryptionAllowed), as is the +// rule that an edit never downgrades an encrypted message to plaintext, which +// depends on the message being edited. // // Encrypted content is opaque: whatever it wraps — the proto allows text, media, // or a reply whose body is either — is the recipient's to check, not the @@ -456,13 +456,29 @@ func clientAllowedContent(content []*messagingpb.Content) (repliedMessageID *mes } } -// isEncrypted reports whether content is end-to-end encrypted, which is allowed -// in a DM only (see messagingpb.EncryptedContent). Encrypted content is only -// ever top-level, never a reply's body (see clientAllowedContent). +// isEncrypted reports whether content is end-to-end encrypted (see +// messagingpb.EncryptedContent). Encrypted content is only ever top-level, +// never a reply's body (see clientAllowedContent). func isEncrypted(content []*messagingpb.Content) bool { return len(content) == 1 && content[0].GetEncrypted() != nil } +// encryptionAllowed reports whether a chat takes content as far as encryption +// goes: plaintext always, and encrypted content only under the scheme the chat +// uses, the one thing in it the server reads. A DM uses the pairwise scheme, +// X25519_XCHACHA20POLY1305. A group takes no encrypted content at all: +// CHAT_KEY_XCHACHA20POLY1305 is a private group's scheme, and no group is +// private yet. A refusal is ENCRYPTION_NOT_ALLOWED on either RPC. +func encryptionAllowed(chatID *commonpb.ChatId, content []*messagingpb.Content) bool { + if !isEncrypted(content) { + return true + } + if chat.IsGroupChatID(chatID) { + return false + } + return content[0].GetEncrypted().Scheme == messagingpb.EncryptedContent_X25519_XCHACHA20POLY1305 +} + // validReplyBody reports whether a reply's body is content a client may author: // text or well-formed media. func validReplyBody(content *messagingpb.Content) bool { diff --git a/messaging/tests/server.go b/messaging/tests/server.go index 5ec0d96..0dcd4a9 100644 --- a/messaging/tests/server.go +++ b/messaging/tests/server.go @@ -1164,6 +1164,19 @@ func testServer_EncryptedContent(t *testing.T, badges badge.Store, blocklists bl require.Equal(t, messagingpb.EditMessageResponse_OK, upgraded.Result) require.True(t, proto.Equal(encryptedContent(4)[0], upgraded.Message.Content[0])) + // A DM takes only its own scheme: a private group's is refused on a send + // and on an edit, and the edited message stays as it was. + wrongScheme, err := e.sendContentToChat(e.keysA, chatID, chatKeyEncryptedContent(6), generateClientID()) + require.NoError(t, err) + require.Equal(t, messagingpb.SendMessageResponse_ENCRYPTION_NOT_ALLOWED, wrongScheme.Result) + require.Nil(t, wrongScheme.Message) + wrongSchemeEdit, err := e.editMessageInChat(e.keysA, chatID, msgID, chatKeyEncryptedContent(6), edited.Message.EventSequence) + require.NoError(t, err) + require.Equal(t, messagingpb.EditMessageResponse_ENCRYPTION_NOT_ALLOWED, wrongSchemeEdit.Result) + stillEdited, err := e.getMessageInChat(e.keysA, chatID, msgID) + require.NoError(t, err) + require.True(t, proto.Equal(encryptedContent(3)[0], stillEdited.Message.Content[0])) + // A message too large to carry in a push is pushed by its ID alone, for // the recipient to fetch; the sender and generic body are still there. large := encryptedContent(5) @@ -1193,8 +1206,9 @@ func testServer_EncryptedContent(t *testing.T, badges badge.Store, blocklists bl } // testServer_EncryptedContent_NotInGroups: encrypted content is a DM's alone. -// A group member's send or edit carrying it is ENCRYPTION_NOT_ALLOWED and -// nothing is written; a non-member is DENIED before the chat type matters. +// A group member's send or edit carrying it is ENCRYPTION_NOT_ALLOWED under +// either scheme and nothing is written; a non-member is DENIED before the chat +// type matters. func testServer_EncryptedContent_NotInGroups(t *testing.T, badges badge.Store, blocklists blocklist.Store, chats chat.Store, messages messaging.Store, profiles profile.Store) { e := newServerEnv(t, badges, blocklists, chats, messages, profiles) @@ -1211,6 +1225,11 @@ func testServer_EncryptedContent_NotInGroups(t *testing.T, badges badge.Store, b require.Equal(t, messagingpb.SendMessageResponse_ENCRYPTION_NOT_ALLOWED, resp.Result) require.Nil(t, resp.Message) + chatKeyResp, err := e.sendContentToChat(e.keysA, groupID, chatKeyEncryptedContent(1), generateClientID()) + require.NoError(t, err) + require.Equal(t, messagingpb.SendMessageResponse_ENCRYPTION_NOT_ALLOWED, chatKeyResp.Result) + require.Nil(t, chatKeyResp.Message) + _, strangerKeys := e.addUser() strangerResp, err := e.sendContentToChat(strangerKeys, groupID, encryptedContent(1), generateClientID()) require.NoError(t, err) @@ -1224,6 +1243,10 @@ func testServer_EncryptedContent_NotInGroups(t *testing.T, badges badge.Store, b require.NoError(t, err) require.Equal(t, messagingpb.EditMessageResponse_ENCRYPTION_NOT_ALLOWED, edit.Result) + chatKeyEdit, err := e.editMessageInChat(e.keysA, groupID, sent.Message.MessageId, chatKeyEncryptedContent(2), sent.Message.EventSequence) + require.NoError(t, err) + require.Equal(t, messagingpb.EditMessageResponse_ENCRYPTION_NOT_ALLOWED, chatKeyEdit.Result) + // Only the plaintext message was written, and it is unchanged. got, err := e.getMessagesByOptionsInChat(e.keysA, groupID, nil) require.NoError(t, err) diff --git a/messaging/tests/store.go b/messaging/tests/store.go index 8fd0bda..2daa214 100644 --- a/messaging/tests/store.go +++ b/messaging/tests/store.go @@ -2377,12 +2377,22 @@ func widgetContent(username string) []*messagingpb.Content { }} } -// encryptedContent builds an end-to-end encrypted message. The server never -// reads the ciphertext, so any bytes of a valid length stand in for one. +// encryptedContent builds an end-to-end encrypted message under the DM scheme. +// The server never reads the ciphertext, so any bytes of a valid length stand +// in for one. func encryptedContent(ciphertext byte) []*messagingpb.Content { + return encryptedContentWithScheme(messagingpb.EncryptedContent_X25519_XCHACHA20POLY1305, ciphertext) +} + +// chatKeyEncryptedContent is encryptedContent under a private group's scheme. +func chatKeyEncryptedContent(ciphertext byte) []*messagingpb.Content { + return encryptedContentWithScheme(messagingpb.EncryptedContent_CHAT_KEY_XCHACHA20POLY1305, ciphertext) +} + +func encryptedContentWithScheme(scheme messagingpb.EncryptedContent_Scheme, ciphertext byte) []*messagingpb.Content { return []*messagingpb.Content{{ Type: &messagingpb.Content_Encrypted{Encrypted: &messagingpb.EncryptedContent{ - Scheme: messagingpb.EncryptedContent_X25519_XCHACHA20POLY1305, + Scheme: scheme, Nonce: bytes.Repeat([]byte{ciphertext}, 24), Ciphertext: bytes.Repeat([]byte{ciphertext}, 48), }}, From b6fc375004c2a4362d70052f5f30154c08ab595a Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Fri, 2 Oct 2026 13:57:32 -0400 Subject: [PATCH 2/6] Implement private group creation behind a staff gate --- CLAUDE.md | 4 +- chat/access.go | 36 +++++- chat/access_test.go | 79 ++++++++++++ chat/blob.go | 4 +- chat/cache/store.go | 13 +- chat/create.go | 126 +++++++++++++------ chat/dynamodb/store.go | 19 ++- chat/member.go | 13 +- chat/model.go | 30 ++++- chat/rules.go | 45 +++++-- chat/server.go | 14 ++- chat/store.go | 10 +- chat/tests/server.go | 248 +++++++++++++++++++++++++++++++++++--- chat/tests/store.go | 27 +++++ messaging/message.go | 6 +- messaging/tests/server.go | 48 ++++++++ 16 files changed, 624 insertions(+), 98 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index baec9e4..b50b535 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,6 +173,8 @@ This is the most intricate part of the codebase and where most current work happ **Group management.** `StartChat` / `JoinChat` / `LeaveChat` are open to every registered user (nothing in these packages gates on client version). DMs always deny join/leave. Membership is checked before rules so a re-join is a no-op. Every real transition publishes a `RosterUpdate` to both the user and chat topics. Creation validates rules, checks the creator satisfies them, moderates the title and attaches the picture *before* the record is written. +**Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and **staff-only for now** (`canCreatePrivateGroup`, `DENIED` otherwise; a transitional gate like `useE2ee`, removed by deleting the check; a retry of a creation that landed is answered from the record before it). `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `GroupRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; the `RuleEvaluator` admits no one to a private group (`satisfiesListener` is false on `IsPrivate`, so a caller asking the rules alone never finds an empty rule set open to all), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view is `DENIED` (`getPublicChat`); `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely. **No one speaks in one yet**: `Access.CanSpeak` refuses every member of a private group off the cached rules read, which is the proto's keyless state (sends, edits, deletions, typing and mention suggestions all `DENIED`), and encrypted blob uploads are refused because `IsDMMember` refuses every group ID. Not built: key envelopes (`SetKeyEnvelope` / `GetKeyEnvelope`, which will lift the speak refusal for a keyed group), `ENCRYPTION_REQUIRED` and the chat-key scheme in messaging, the lobby RPCs and `Metadata.in_lobby` (never set); those RPCs answer unimplemented. + **Edit (`chat/edit.go`).** `EditChat` changes a group's title and/or picture. **Only the creator, while a member, may edit** (`Chat.PermissionsFor`: `CanEdit = isMember && IsCreator`; DMs and legacy groups with no recorded creator are never editable; a departed creator is `DENIED` until they rejoin). Fields equal to the record are dropped first, so a no-op request (or one that sets nothing) is `OK` from the record with nothing moderated, attached, written or published. What remains runs in `StartChat`'s order — title moderation (`TITLE_MODERATED`), picture attach via `SetAsChatPicture` (`PICTURE_BLOB_NOT_ACCEPTED`), then one `Store.EditGroup` write (`GroupEdit`, nil = unchanged, SETs only the named attributes so concurrent edits of different fields never clobber; an empty edit is an error; `SetGroupPicture` remains the only way to *clear* a picture). A real change publishes **one event on the chat topic, excluding no one** (the editor's devices included), with one `MetadataUpdate` per field: `TitleChanged` / `PictureChanged` (the hydrated rendition set). `redact.ChatUpdate` passes both through. **Permissions (`ViewerState.permissions`).** Server-computed per read from the record and membership (`Chat.PermissionsFor`), never stored, and carried on **every** `ViewerState` carrier (`ToProto(permissions)`: hydrate, mute/unmute responses, `ViewerStateChanged`; the clear-on-leave publishes with none). `hydrate` **always sets `viewer_state` for a member** (the zero record with permissions when they have written none) and **never for a non-member**, whatever record persists for them (the read is skipped for a non-member's standing). The version does **not** move when permissions change with membership (decided: a creator's leave/rejoin is announced by `RosterUpdate`s carrying fresh metadata, not by a version bump), so the same version can carry different `can_edit` before and after the viewer's own transition. @@ -191,7 +193,7 @@ This is the most intricate part of the codebase and where most current work happ **Widget content (`WidgetContent`).** Server-authored only: `clientAllowedContent` refuses it for send, edit and as a reply body; today only the team's welcome sends one (`ShareProfileWidget`). Replyable and reactable, never editable or deletable; `redact.Content` keeps the variant but sets a share's username to `redacted` (an unknown variant fails the read); a push renders its plain-text stand-in as the body (a profile share is `https://flipcash.com/`, `renderWidgetPushBody`; an unknown variant earns no push) and carries the whole message in the payload like any other. -**Encrypted content (`EncryptedContent`).** DMs only. `clientAllowedContent` accepts it top-level only (never as a reply body; a reply is encrypted whole), and `SendMessage` / `EditMessage` answer `ENCRYPTION_NOT_ALLOWED` (`encryptionAllowed`) for a group ID, or for a DM under any scheme but its own (`X25519_XCHACHA20POLY1305`; the proto also defines `CHAT_KEY_XCHACHA20POLY1305` for private groups, which are not built: `StartChat` refuses the `private_group` variant and the lobby and key envelope RPCs are unimplemented), *after* the speaker gate, so a non-member is still `DENIED`. The server never reads the ciphertext beyond its scheme: nothing inside is validated (a reply wrapped inside is never checked against the thread; a `MediaContent` wrapped inside names end-to-end encrypted blobs the sender uploaded for the DM, see `blob/`, which messaging never shares, hydrates or sees — the sender fills the ORIGINAL rendition's metadata itself), it is replyable/reactable/editable/deletable like text, except that an edit never downgrades it (encrypted → plaintext is `CANNOT_EDIT`, judged on `GetMessage`, which is strongly consistent so the check and the edit's `expected_event_sequence` guard together close the race; plaintext → encrypted is allowed), `redact.Content` passes it through (only a DM's members can reach it), and a DM push gets the generic body "Sent you a message" with the message in the payload when it fits under `maxChatPushBytes`, and only `ChatMetadata.message_id` otherwise. +**Encrypted content (`EncryptedContent`).** DMs only. `clientAllowedContent` accepts it top-level only (never as a reply body; a reply is encrypted whole), and `SendMessage` / `EditMessage` answer `ENCRYPTION_NOT_ALLOWED` (`encryptionAllowed`) for a group ID, or for a DM under any scheme but its own (`X25519_XCHACHA20POLY1305`; the proto also defines `CHAT_KEY_XCHACHA20POLY1305` for private groups, which no one speaks in yet, so their sends never reach this rule; see "Private groups"), *after* the speaker gate, so a non-member is still `DENIED`. The server never reads the ciphertext beyond its scheme: nothing inside is validated (a reply wrapped inside is never checked against the thread; a `MediaContent` wrapped inside names end-to-end encrypted blobs the sender uploaded for the DM, see `blob/`, which messaging never shares, hydrates or sees — the sender fills the ORIGINAL rendition's metadata itself), it is replyable/reactable/editable/deletable like text, except that an edit never downgrades it (encrypted → plaintext is `CANNOT_EDIT`, judged on `GetMessage`, which is strongly consistent so the check and the edit's `expected_event_sequence` guard together close the race; plaintext → encrypted is allowed), `redact.Content` passes it through (only a DM's members can reach it), and a DM push gets the generic body "Sent you a message" with the message in the payload when it fits under `maxChatPushBytes`, and only `ChatMetadata.message_id` otherwise. **`Metadata.use_e2ee` (transitional).** Set by `hydrate` alone, per read and never stored: exactly when the chat is a DM, **every member is a staff user** (`useE2ee`; the account store's `IsStaff` per DM member across the page, resolved concurrently in `staffFlags`) **and none is the Flipcash team account** (the `teamUserID` passed to `chat.NewServer`, nil for none; the parent resolves it with `flipcashteam.GetUserID`; the server writes into the team's DMs and holds no keys for it). Groups never carry it. Nothing else publishes DM metadata, so this is the only place the flag is decided; once E2EE launches the client is expected to ignore it and it will be deprecated. diff --git a/chat/access.go b/chat/access.go index 508cf27..d89be88 100644 --- a/chat/access.go +++ b/chat/access.go @@ -75,6 +75,14 @@ const DefaultListenerAdmissionTTL = 30 * time.Second // a client that wants a group blurred asks for REDACTED, and is answered from // membership and the rules' existence without the rules being evaluated. // +// A private group (see Chat.IsPrivate) is its members' alone in every form: +// no non-member listens to it or previews it, whatever mode they ask under, +// and it has no public view. That is decided on the flag itself, before its +// rules are looked at. StartChat writes a private group with no rules, which +// would keep non-members out by the rule above, but a record written with +// one must not open it. Its record is still shown to any registered user +// (see Server.GetChat), which is how a user sees what they would ask to join. +// // A chat that does not exist admits no one: IsMember, CanListen and CanSpeak all // report false, not ErrChatNotFound, for a chat ID nothing is stored under. A // caller that must tell NOT_FOUND from DENIED reads the canonical record itself @@ -273,7 +281,7 @@ func (a *Access) StandingWithChat(ctx context.Context, c *Chat, userID *commonpb // rule may be previewed, and nothing else admits them in any form. Nothing // is read: the rules come off the record. func (a *Access) PublicStanding(c *Chat) Standing { - if !IsGroupChatID(c.ID) || len(c.Rules().GetListener()) == 0 { + if !IsGroupChatID(c.ID) || c.IsPrivate || len(c.Rules().GetListener()) == 0 { return Standing{} } return Standing{CanPreview: true} @@ -343,9 +351,10 @@ func (a *Access) standing(ctx context.Context, chatID *commonpb.ChatId, userID * if err != nil { return Standing{}, err } - // No listener rules, no admission of any kind: only a rule can admit a - // non-member (see above). - if len(rules.Rules.GetListener()) == 0 { + // A private group admits no non-member in any form, whatever its rules. + // Otherwise, no listener rules, no admission of any kind: only a rule can + // admit a non-member (see above). + if rules.IsPrivate || len(rules.Rules.GetListener()) == 0 { return Standing{}, nil } if !evaluate { @@ -368,18 +377,33 @@ func (a *Access) standing(ctx context.Context, chatID *commonpb.ChatId, userID * // they satisfy the chat's listener and speaker rules. Membership is checked // first, so a chat's rules are never evaluated for a send on behalf of a // non-member. It is false, not an error, for a chat that does not exist. +// +// No one speaks in a private group: nothing happens in one until its creator +// has stored its key, and no key can be stored yet (see Chat.IsPrivate). The +// refusal is decided off the rules read, which a store caches with the rules, +// so it costs nothing a send did not already pay. It is every gate CanSpeak +// stands behind at once: a send, an edit, a deletion, a typing notification, +// and mention suggestions. The RuleEvaluator would refuse a private group +// too, since no rule admits anyone to one; the refusal is made here because +// it is this one that a stored key will lift. func (a *Access) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { isMember, err := a.chats.IsMember(ctx, chatID, userID) if err != nil || !isMember { return false, err } - ok, err := a.rules.CanSpeak(ctx, chatID, userID) + rules, err := a.rules.rulesFor(ctx, chatID, userID) if errors.Is(err, ErrChatNotFound) { // Membership just confirmed the chat; a not-found here is a chat deleted // between the two reads, which admits no one. return false, nil } - return ok, err + if err != nil { + return false, err + } + if rules.IsPrivate { + return false, nil + } + return a.rules.CanSpeakWithRules(ctx, chatID, rules, userID) } // admissionKey keys the admission cache by (group, user). Group IDs are fixed diff --git a/chat/access_test.go b/chat/access_test.go index 0f93b19..8c28d6f 100644 --- a/chat/access_test.go +++ b/chat/access_test.go @@ -160,6 +160,85 @@ func TestAccess_GroupMember(t *testing.T) { require.Equal(t, asked+2, f.ocpBalance.asked) } +// TestAccess_PrivateGroup: a private group is its members' alone in every +// form, and no one speaks in it, a member included. +func TestAccess_PrivateGroup(t *testing.T) { + ctx := context.Background() + f := newAccessFixture(t) + a := NewAccess(f.chats, f.rules) + + creator := model.MustGenerateUserID() + private := f.chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, IsPrivate: true, CreatorID: creator}) + f.chats.join(private.ID, creator) + + // A member reads, and does not speak. + ok, err := a.CanListen(ctx, private.ID, creator) + require.NoError(t, err) + require.True(t, ok) + ok, err = a.CanSpeak(ctx, private.ID, creator) + require.NoError(t, err) + require.False(t, ok) + + // A non-member has no standing under any mode, however funded, whether + // the rules are read from the store or come with the record. + modes := []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} + for _, mode := range modes { + standing, err := a.Standing(ctx, private.ID, f.funded, mode) + require.NoError(t, err) + require.Equal(t, Standing{}, standing, mode) + standing, err = a.StandingWithChat(ctx, private, f.funded, mode) + require.NoError(t, err) + require.Equal(t, Standing{}, standing, mode) + } + ok, err = a.CanSpeak(ctx, private.ID, f.funded) + require.NoError(t, err) + require.False(t, ok) + require.Equal(t, Standing{}, a.PublicStanding(private)) + + // The rules admit no one to it either: asked alone, the evaluator refuses + // a private group that an empty rule set would open to everyone. + for _, u := range []*commonpb.UserId{creator, f.funded} { + ok, err = f.rules.CanListen(ctx, private.ID, u) + require.NoError(t, err) + require.False(t, ok) + ok, err = f.rules.CanListenWithRules(ctx, private.ID, private.GroupRules(), u) + require.NoError(t, err) + require.False(t, ok) + ok, err = f.rules.CanSpeak(ctx, private.ID, u) + require.NoError(t, err) + require.False(t, ok) + ok, err = f.rules.CanSpeakWithRules(ctx, private.ID, private.GroupRules(), u) + require.NoError(t, err) + require.False(t, ok) + } + + // None of it rests on the group having no rules. StartChat writes none, + // but a private group whose record carries a listener rule a non-member + // satisfies is no more open to them: the flag decides, not the rules. + ruled := f.chats.put(&Chat{ + ID: MustGenerateGroupChatID(), + Type: chatpb.ChatType_GROUP, + IsPrivate: true, + CreatorID: creator, + MinimumListenerBalance: &MinimumBalance{Currency: "usd", NativeAmount: accessRequirement}, + }) + for _, mode := range modes { + standing, err := a.Standing(ctx, ruled.ID, f.funded, mode) + require.NoError(t, err) + require.Equal(t, Standing{}, standing, mode) + standing, err = a.StandingWithChat(ctx, ruled, f.funded, mode) + require.NoError(t, err) + require.Equal(t, Standing{}, standing, mode) + } + require.Equal(t, Standing{}, a.PublicStanding(ruled)) + ok, err = f.rules.CanListen(ctx, ruled.ID, f.funded) + require.NoError(t, err) + require.False(t, ok) + + // No rule was evaluated for any of it. + require.Zero(t, f.ocpBalance.asked) +} + func TestAccess_GroupNonMember(t *testing.T) { ctx := context.Background() f := newAccessFixture(t) diff --git a/chat/blob.go b/chat/blob.go index 3e5ffe3..0211870 100644 --- a/chat/blob.go +++ b/chat/blob.go @@ -13,7 +13,9 @@ import ( // It lives here rather than in blob because blob must not import chat; the // dependency is one-way. Keeping it here also keeps the chat ID discriminator // (see DmChatIDSize) in the package that owns it: a group ID is refused before -// membership is ever read, because encrypted content is a DM's alone. +// membership is ever read, because encrypted content is a DM's alone. That +// includes a private group's (see Chat.IsPrivate): nothing is uploaded for one +// before its key is stored, and no key can be stored yet. type dmMembership struct { store Store } diff --git a/chat/cache/store.go b/chat/cache/store.go index 350eb48..36f9596 100644 --- a/chat/cache/store.go +++ b/chat/cache/store.go @@ -140,12 +140,13 @@ func (c *Cache) GetGroupRosterSummaries(ctx context.Context, chatIDs []*commonpb } // GetGroupRules is cached, including the absence of rules (a nil Rules), so a -// group without any is read once too. Rules and the creator are fixed at -// creation (see chat.Store), so a cached entry is never stale: if either ever -// becomes mutable, this needs invalidation on write. That holds only while -// both are written with the group: a creator backfilled onto a legacy group, -// or IsCreatorOnlySpeaker set on a group that already exists, reaches a -// process that has read the group only when it restarts. A cached entry is +// group without any is read once too. Rules, the creator and whether the +// group is private are fixed at creation (see chat.Store), so a cached entry +// is never stale: if any ever becomes mutable, this needs invalidation on +// write. That holds only while all are written with the group: a creator +// backfilled onto a legacy group, or IsCreatorOnlySpeaker or IsPrivate set on +// a group that already exists, reaches a process that has read the group only +// when it restarts. A cached entry is // shared by every caller and must be treated as read-only. Errors — including // ErrChatNotFound, since the group may be created later — are not cached. func (c *Cache) GetGroupRules(ctx context.Context, chatID *commonpb.ChatId) (chat.GroupRules, error) { diff --git a/chat/create.go b/chat/create.go index c36f13c..2d38477 100644 --- a/chat/create.go +++ b/chat/create.go @@ -9,6 +9,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" chatpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1" commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" messagingpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/messaging/v1" @@ -45,13 +46,22 @@ import ( // so they insert the new chat without a refetch. There is no one else to // tell. // +// A private group (see Chat.IsPrivate) is created the same way, with two +// differences. It carries no rules, so there are none to validate or satisfy. +// And while private groups are being built, only a staff user may create one +// (DENIED otherwise, see canCreatePrivateGroup). What is created is a group +// without a key: the creator's client stores the chat key's envelope as a +// second step, since the envelope is bound to the chat ID this RPC returns, +// and until it has, nothing happens in the group (see Chat.IsPrivate). +// // The RPC is retry-safe. The group's ID is derived from the caller and the // request's idempotency key (see MustDeriveGroupChatID), so a retry names the // same group, and one that already exists is answered from its record before -// any check runs: a title the moderator has since learned to flag, or a -// balance that has since fallen below the minimum, are facts about a new -// group, not this one. Which parameters the retry carries does not matter -// either; the key is the request's identity. A retry that +// any check runs: a title the moderator has since learned to flag, a balance +// that has since fallen below the minimum, or a staff flag since revoked, are +// facts about a new group, not this one. Which parameters the retry carries +// does not matter either, the kind of group included; the key is the +// request's identity. A retry that // loses a race with its twin — both pass the read, one write lands — is caught // by the store's uniqueness condition and answered the same way. Nothing is // published for a retry: the creation was announced when it happened. @@ -63,11 +73,22 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* log := s.log.With(zap.String("user_id", model.UserIDString(userID))) - // Validation requires the oneof to be set. Only a public group can be - // created: the private group variant is refused until private groups are - // built, like any variant this server predates. - params := req.GetPublicGroup() - if params == nil { + // Validation requires the oneof to be set, so anything but the two kinds + // of group here is a variant this server predates. The two differ in what + // a group is created with: a public group has rules, a private one has + // none. + var ( + title string + picture *blobpb.BlobId + rules *chatpb.Rules + isPrivate bool + ) + switch params := req.Parameters.(type) { + case *chatpb.StartChatRequest_PublicGroup: + title, picture, rules = params.PublicGroup.GetTitle(), params.PublicGroup.GetPicture(), params.PublicGroup.GetRules() + case *chatpb.StartChatRequest_PrivateGroup: + title, picture, isPrivate = params.PrivateGroup.GetTitle(), params.PrivateGroup.GetPicture(), true + default: return nil, status.Error(codes.InvalidArgument, "unsupported chat parameters") } @@ -87,36 +108,51 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* return nil, status.Error(codes.Internal, "") } - isStaffOnly, minimumListenerBalance, err := RulesFromProto(params.Rules) - if err != nil { - return &chatpb.StartChatResponse{Result: chatpb.StartChatResponse_INVALID_RULES}, nil - } + var ( + isStaffOnly bool + minimumListenerBalance *MinimumBalance + ) + if isPrivate { + allowed, err := s.canCreatePrivateGroup(ctx, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking private group creation") + return nil, status.Error(codes.Internal, "") + } + if !allowed { + return &chatpb.StartChatResponse{Result: chatpb.StartChatResponse_DENIED}, nil + } + } else { + isStaffOnly, minimumListenerBalance, err = RulesFromProto(rules) + if err != nil { + return &chatpb.StartChatResponse{Result: chatpb.StartChatResponse_INVALID_RULES}, nil + } - // The creator must satisfy the group's own rules in full, speaker rules - // included: a group whose creator cannot read it is a group nobody can - // reach, and one whose creator cannot post in it is a room they opened and - // cannot use. The rules are evaluated from the request rather than a - // stored record, since there is no record yet, with the caller as the - // creator they will record. - // - // This is also where the requirement's currency is first put to OCP. One - // OCP cannot value is a rule the server cannot enforce, refused as - // INVALID_RULES like any other (see RulesFromProto) rather than failed: - // the currency is the client's choice, and no group is written that no one - // could ever be admitted to. - satisfied, err := s.rules.CanSpeakWithRules(ctx, chatID, GroupRules{Rules: params.Rules, CreatorID: userID}, userID) - if errors.Is(err, balance.ErrUnsupportedCurrency) { - return &chatpb.StartChatResponse{Result: chatpb.StartChatResponse_INVALID_RULES}, nil - } - if err != nil { - log.With(zap.Error(err)).Warn("Failure evaluating chat rules") - return nil, status.Error(codes.Internal, "") - } - if !satisfied { - return &chatpb.StartChatResponse{Result: chatpb.StartChatResponse_RULES_NOT_SATISFIED}, nil + // The creator must satisfy the group's own rules in full, speaker rules + // included: a group whose creator cannot read it is a group nobody can + // reach, and one whose creator cannot post in it is a room they opened and + // cannot use. The rules are evaluated from the request rather than a + // stored record, since there is no record yet, with the caller as the + // creator they will record. + // + // This is also where the requirement's currency is first put to OCP. One + // OCP cannot value is a rule the server cannot enforce, refused as + // INVALID_RULES like any other (see RulesFromProto) rather than failed: + // the currency is the client's choice, and no group is written that no one + // could ever be admitted to. + satisfied, err := s.rules.CanSpeakWithRules(ctx, chatID, GroupRules{Rules: rules, CreatorID: userID}, userID) + if errors.Is(err, balance.ErrUnsupportedCurrency) { + return &chatpb.StartChatResponse{Result: chatpb.StartChatResponse_INVALID_RULES}, nil + } + if err != nil { + log.With(zap.Error(err)).Warn("Failure evaluating chat rules") + return nil, status.Error(codes.Internal, "") + } + if !satisfied { + return &chatpb.StartChatResponse{Result: chatpb.StartChatResponse_RULES_NOT_SATISFIED}, nil + } } - flagged, category, err := s.moderateTitle(ctx, log, params.Title) + flagged, category, err := s.moderateTitle(ctx, log, title) if err != nil { return nil, status.Error(codes.Internal, "") } @@ -132,8 +168,8 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* // same for each of them. Anything else is a failure to attach, and the // server's fault. The grant is keyed by the chat ID, so a retry that // reaches here again repeats it rather than orphaning one. - if params.Picture != nil { - err := s.media.SetAsChatPicture(ctx, userID, chatID, params.Picture) + if picture != nil { + err := s.media.SetAsChatPicture(ctx, userID, chatID, picture) switch { case errors.Is(err, blob.ErrBlobNotFound), errors.Is(err, blob.ErrBlobNotReady), @@ -151,11 +187,12 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* ID: chatID, Type: chatpb.ChatType_GROUP, Members: []*commonpb.UserId{userID}, - Title: params.Title, + Title: title, IsStaffOnly: isStaffOnly, MinimumListenerBalance: minimumListenerBalance, + IsPrivate: isPrivate, CreatorID: userID, - PictureBlobID: params.Picture, + PictureBlobID: picture, LastActivity: time.Now().UTC(), } err = s.chats.PutChat(ctx, c) @@ -211,6 +248,15 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* }, nil } +// canCreatePrivateGroup reports whether userID may create a private group: +// today, only a staff user. It is a transitional gate, like use_e2ee's (see +// useE2ee): private groups are being built in steps, and one created now has +// no key and no lobby, so it is a group nobody can join or speak in. Opening +// creation to everyone is removing this check, once the rest is built. +func (s *Server) canCreatePrivateGroup(ctx context.Context, userID *commonpb.UserId) (bool, error) { + return s.accounts.IsStaff(ctx, userID) +} + // replayStartChat answers a StartChat whose group c already exists: an earlier // attempt by the same caller — the ID embeds them, so it can be no one else — // created it, and this request is a retry (see StartChat). The response is the diff --git a/chat/dynamodb/store.go b/chat/dynamodb/store.go index 6098c01..7947fb6 100644 --- a/chat/dynamodb/store.go +++ b/chat/dynamodb/store.go @@ -196,6 +196,7 @@ const ( attrIsStaffOnly = "is_staff_only" attrMinListenerBalance = "min_listener_balance" // map: see minimumBalanceAttr attrIsCreatorOnlySpeaker = "is_creator_only_speaker" + attrIsPrivate = "is_private" attrCreator = "creator" attrPictureBlobID = "picture" attrState = "state" @@ -1431,19 +1432,20 @@ func (s *store) GetGroupRules(ctx context.Context, chatID *commonpb.ChatId) (cha } // Only the attributes the rules are projected from: the type, the - // attributes that stand for a rule, and the creator a CreatorRequirement - // names. The rest of the record — title, picture, activity — is neither - // fetched nor deserialized. + // attributes that stand for a rule, the creator a CreatorRequirement + // names, and whether the group is private. The rest of the record — title, + // picture, activity — is neither fetched nor deserialized. out, err := s.client.GetItem(ctx, &dynamodb.GetItemInput{ TableName: aws.String(s.chatsTable), Key: map[string]types.AttributeValue{attrPK: avS(chatPK(chatID))}, - ProjectionExpression: aws.String("#type, #staff, #balance, #creatorOnly, #creator"), + ProjectionExpression: aws.String("#type, #staff, #balance, #creatorOnly, #creator, #private"), ExpressionAttributeNames: map[string]string{ "#type": attrType, "#staff": attrIsStaffOnly, "#balance": attrMinListenerBalance, "#creatorOnly": attrIsCreatorOnlySpeaker, "#creator": attrCreator, + "#private": attrIsPrivate, }, }) if err != nil { @@ -1465,6 +1467,7 @@ func (s *store) GetGroupRules(ctx context.Context, chatID *commonpb.ChatId) (cha IsStaffOnly: asBool(out.Item[attrIsStaffOnly]), MinimumListenerBalance: balance, IsCreatorOnlySpeaker: asBool(out.Item[attrIsCreatorOnlySpeaker]), + IsPrivate: asBool(out.Item[attrIsPrivate]), } // creator is absent for groups written before it was recorded. if creator := asB(out.Item[attrCreator]); len(creator) > 0 { @@ -1617,8 +1620,8 @@ func (s *store) chatItem(c *chat.Chat) map[string]types.AttributeValue { } // A group's membership lives in group_members, not on the canonical item — // an inline list could not hold a large group. Title, the staff-only flag, - // the minimum listener balance, the creator-only speaker flag, the creator - // and the picture are group-only; + // the minimum listener balance, the creator-only speaker flag, the private + // flag, the creator and the picture are group-only; // each is written only when set, so an absent attribute (including on every // item written before it existed) reads as its zero value. if c.Type == chatpb.ChatType_GROUP { @@ -1634,6 +1637,9 @@ func (s *store) chatItem(c *chat.Chat) map[string]types.AttributeValue { if c.IsCreatorOnlySpeaker { item[attrIsCreatorOnlySpeaker] = avBool(true) } + if c.IsPrivate { + item[attrIsPrivate] = avBool(true) + } if c.CreatorID != nil { item[attrCreator] = avB(c.CreatorID.Value) } @@ -1728,6 +1734,7 @@ func chatFromItem(chatID *commonpb.ChatId, item map[string]types.AttributeValue) IsStaffOnly: asBool(item[attrIsStaffOnly]), MinimumListenerBalance: balance, IsCreatorOnlySpeaker: asBool(item[attrIsCreatorOnlySpeaker]), + IsPrivate: asBool(item[attrIsPrivate]), LastActivity: time.Unix(0, nanos).UTC(), } // creator is absent for DMs and for groups written before it was recorded. diff --git a/chat/member.go b/chat/member.go index 8c2c709..c240a93 100644 --- a/chat/member.go +++ b/chat/member.go @@ -31,6 +31,13 @@ import ( // current member re-joining — a member's reads gate on membership alone (see // Access), and so does a no-op join. Leaving has no rule to satisfy. // +// A private group (see Chat.IsPrivate) is not joined here: its members are +// admitted by its creator from its lobby, which is not built, so JoinChat +// refuses everyone as DENIED. Everyone but the creator, that is, who needs no +// one's approval and rejoins a private group they left. No rule is evaluated +// for them: a private group's rules admit no one (see RuleEvaluator), and +// being its creator is what admits them. +// // Each transition that actually happens is broadcast as a RosterUpdate to the // chat's members and to the affected user (see publishRosterUpdate). A join or // departure that is a no-op broadcasts nothing: the roster did not move, and a @@ -59,6 +66,9 @@ func (s *Server) JoinChat(ctx context.Context, req *chatpb.JoinChatRequest) (*ch if c.Type != chatpb.ChatType_GROUP { return &chatpb.JoinChatResponse{Result: chatpb.JoinChatResponse_DENIED}, nil } + if c.IsPrivate && !c.IsCreator(userID) { + return &chatpb.JoinChatResponse{Result: chatpb.JoinChatResponse_DENIED}, nil + } // A current member re-joining is a no-op answered on membership alone, // before the rules: a member whose balance has since dipped under the @@ -70,7 +80,8 @@ func (s *Server) JoinChat(ctx context.Context, req *chatpb.JoinChatRequest) (*ch return nil, status.Error(codes.Internal, "") } - if !isMember { + // A private group's caller is its creator by now, whom no rule gates. + if !isMember && !c.IsPrivate { // The rules come from the canonical record already in hand rather than // a second read through the evaluator: the record is what they are // projected from. diff --git a/chat/model.go b/chat/model.go index 76b8b15..cae4f80 100644 --- a/chat/model.go +++ b/chat/model.go @@ -240,8 +240,8 @@ func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatp // a group chat: group membership is mutable and lives in its own store records, // which no path that reads the canonical record touches. A caller that needs a // group's members reads them explicitly via Store.GetMembers. Title, -// IsStaffOnly, IsCreatorOnlySpeaker, CreatorID and PictureBlobID are -// group-only and zero for DMs. +// IsStaffOnly, IsCreatorOnlySpeaker, IsPrivate, CreatorID and PictureBlobID +// are group-only and zero for DMs. // // RosterSummary describes the member list without containing it. Like Members, // it is complete for a DM on any read and left zero for a group by the @@ -265,6 +265,25 @@ func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatp // it only when its record was written with it. A group that carries it but // has no recorded creator admits no one to speak. // +// IsPrivate marks a private group (see chatpb.Metadata.is_private): one whose +// creator admits each member, and whose messages are end-to-end encrypted +// with a chat key the server never holds. It is fixed at creation, like the +// rules, and read with them (see GroupRules). A private group carries no +// rules: StartChat writes none. No non-member is admitted to it in any form +// (see Access), no rule admits anyone to it (see RuleEvaluator), and +// JoinChat refuses everyone but its creator; each is decided on this flag, +// not on the absence of rules. Its +// title and picture are plaintext on the record like any group's, and are +// shown to any registered user. +// +// A private group's chat key reaches the server only as its members' key +// envelopes, and nothing can happen in one until its creator has stored +// theirs. Key envelopes are not built, so every private group is in that +// state and stays there: it exists, is visible, and can be left and rejoined +// by its creator, and no one speaks in it (see Access.CanSpeak). The lobby a +// user would enter to be admitted is not built either, so its creator is the +// only member it can have. +// // CreatorID is the user who created the group, or nil when unknown (a DM has // none, and so does any group written before the field existed). It is fixed // at creation and records provenance only: creating a group does not by itself @@ -287,6 +306,7 @@ type Chat struct { IsStaffOnly bool MinimumListenerBalance *MinimumBalance IsCreatorOnlySpeaker bool + IsPrivate bool CreatorID *commonpb.UserId PictureBlobID *blobpb.BlobId LastActivity time.Time @@ -439,6 +459,7 @@ func (c *Chat) Clone() *Chat { IsStaffOnly: c.IsStaffOnly, MinimumListenerBalance: minimumListenerBalance, IsCreatorOnlySpeaker: c.IsCreatorOnlySpeaker, + IsPrivate: c.IsPrivate, CreatorID: creatorID, PictureBlobID: pictureBlobID, LastActivity: c.LastActivity, @@ -448,8 +469,8 @@ func (c *Chat) Clone() *Chat { // ToProto projects the stored chat onto a chatpb.Metadata. Only the fields // owned by the chat domain are populated: chat_id, type, title, last_activity, -// roster_summary, rules, creator (a group's, when recorded), a Member entry per -// member with just user_id set, and — for a group with a picture — a picture +// roster_summary, rules, is_private, creator (a group's, when recorded), a +// Member entry per member with just user_id set, and — for a group with a picture — a picture // carrying only its ORIGINAL rendition's blob id. The caller is responsible for hydrating member profiles, pointers, // the last message, and the picture's resolved rendition set. func (c *Chat) ToProto() *chatpb.Metadata { @@ -466,6 +487,7 @@ func (c *Chat) ToProto() *chatpb.Metadata { RosterSummary: c.RosterSummary.ToProto(), Title: c.Title, Rules: c.Rules(), + IsPrivate: c.IsPrivate, LastActivity: timestamppb.New(c.LastActivity), } if c.CreatorID != nil { diff --git a/chat/rules.go b/chat/rules.go index 6826a90..ffc96b3 100644 --- a/chat/rules.go +++ b/chat/rules.go @@ -71,11 +71,13 @@ func (c *Chat) Rules() *chatpb.Rules { return &chatpb.Rules{Listener: listener, Speaker: speaker} } -// GroupRules is what a chat's rules are evaluated against: the rules, and the -// creator a CreatorRequirement names, since the rule itself names no one. Both -// are fixed at creation and read together (see Store.GetGroupRules), so a -// store that caches one caches the other with it, and evaluating a -// creator-only group costs no read beyond its rules. +// GroupRules is what a chat's rules are evaluated against: the rules, the +// creator a CreatorRequirement names, since the rule itself names no one, and +// whether the group is private, which decides who speaks in it before any +// rule does (see Access.CanSpeak). All are fixed at creation and read +// together (see Store.GetGroupRules), so a store that caches one caches the +// others with it, and evaluating a creator-only group, or refusing a send in +// a private one, costs no read beyond its rules. // // Everything beside Rules is metadata carried only so that evaluation is // efficient: it is what a rule is relative to, read with the rules rather @@ -89,12 +91,17 @@ type GroupRules struct { // CreatorID is the group's creator, nil when it has none recorded (see // Chat.CreatorID), in which case a CreatorRequirement admits no one. CreatorID *commonpb.UserId + + // IsPrivate is whether the group is private (see Chat.IsPrivate). It is + // not a rule, and it overrides them: no rule admits anyone to a private + // group, whatever rules its record carries (see RuleEvaluator). + IsPrivate bool } -// GroupRules returns the chat's rules and creator as a RuleEvaluator -// evaluates them (see GroupRules). +// GroupRules returns the chat's rules, creator and privacy as they are +// evaluated (see GroupRules). func (c *Chat) GroupRules() GroupRules { - return GroupRules{Rules: c.Rules(), CreatorID: c.CreatorID} + return GroupRules{Rules: c.Rules(), CreatorID: c.CreatorID, IsPrivate: c.IsPrivate} } // isCreator reports whether userID is the recorded creator; false when none @@ -200,6 +207,15 @@ func minimumTransferValue(code currency_lib.Code) float64 { // that the rule a client is shown (RulesOf, through Metadata.rules) and the // rule a send is refused by (CanSpeak) are one decision. // +// A private group (see Chat.IsPrivate) is not the rules' to open. Its members +// are admitted by its creator, so the evaluator admits no one to one: every +// listen and speak evaluation of a private group is false, before any rule is +// read. StartChat writes a private group with no rules, and an empty rule set +// admits everyone, so without this a caller that asked the evaluator alone +// would find a private group open to all; with it, the answer does not depend +// on what rules the record carries. Who reads and speaks in a private group +// is decided on membership, by Access. +// // Rules are read through Store.GetGroupRules — in production the caching // store, which holds every group's rules after its first read. A // StaffRequirement is answered by the account store's staff flag, a @@ -257,7 +273,8 @@ func (e *RuleEvaluator) RulesOf(c *Chat) *chatpb.Rules { // CanListen reports whether userID satisfies every listener rule of chatID — // the requirements to read (and join) the chat. A chat with no listener rules -// admits everyone. It returns ErrChatNotFound if a group chat does not exist. +// admits everyone, except a private group, which admits no one (see +// RuleEvaluator). It returns ErrChatNotFound if a group chat does not exist. func (e *RuleEvaluator) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { rules, err := e.rulesFor(ctx, chatID, userID) if err != nil { @@ -270,7 +287,7 @@ func (e *RuleEvaluator) CanListen(ctx context.Context, chatID *commonpb.ChatId, // rules — read off a canonical record it loaded for its own purposes, or // taken from a request for a chat that does not exist yet — so the rules are // not read a second time. A nil rules.Rules admits everyone, as a chat with -// none does. chatID is the chat the rules are evaluated for; no rule reads it +// none does, unless rules.IsPrivate. chatID is the chat the rules are evaluated for; no rule reads it // today, since what a rule is relative to comes with the rules (see // GroupRules). func (e *RuleEvaluator) CanListenWithRules(ctx context.Context, chatID *commonpb.ChatId, rules GroupRules, userID *commonpb.UserId) (bool, error) { @@ -297,7 +314,12 @@ func (e *RuleEvaluator) CanSpeakWithRules(ctx context.Context, chatID *commonpb. return e.satisfiesSpeaker(ctx, rules, userID) } +// satisfiesListener evaluates the listener rules, stopping at the first the +// user fails. No one satisfies a private group's (see RuleEvaluator). func (e *RuleEvaluator) satisfiesListener(ctx context.Context, rules GroupRules, userID *commonpb.UserId) (bool, error) { + if rules.IsPrivate { + return false, nil + } for _, rule := range rules.Rules.GetListener() { ok, err := e.satisfies(ctx, rules, rule.GetKind(), userID) if err != nil || !ok { @@ -308,7 +330,8 @@ func (e *RuleEvaluator) satisfiesListener(ctx context.Context, rules GroupRules, } // satisfiesSpeaker evaluates the listener rules and then the speaker rules, -// stopping at the first the user fails. +// stopping at the first the user fails. A private group fails at the listener +// rules, like any chat the user cannot listen to. func (e *RuleEvaluator) satisfiesSpeaker(ctx context.Context, rules GroupRules, userID *commonpb.UserId) (bool, error) { if ok, err := e.satisfiesListener(ctx, rules, userID); err != nil || !ok { return false, err diff --git a/chat/server.go b/chat/server.go index 578fcaf..098f583 100644 --- a/chat/server.go +++ b/chat/server.go @@ -270,6 +270,13 @@ func NewServer( // sees its last message redacted, and the rules are not evaluated for a // non-member (see Access.Standing). // +// A private group (see Chat.IsPrivate) falls out of the same rules. Its record +// is returned to any registered user, with is_private set, so that one who is +// not a member can see what they would ask to join. It carries no rules, so a +// non-member's standing is none under every mode and they see the record +// alone: its messaging state is its members'. A private group whose key has +// not been stored is returned like any other. +// // The group's picture is returned in every case: it is part of the record, as // the title is, and the two are what identify a group — a group's picture is // readable by anyone. Its download URLs are resolved here without a blob ACL @@ -338,7 +345,8 @@ func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chat // per-viewer fields, since there is no viewer: no hydrated member, no // is_hidden, no viewer_state. It is REDACTED or nothing: any other mode is // DENIED, as is a DM, before anything is read, so an anonymous caller cannot -// learn whether a DM exists. +// learn whether a DM exists. A private group has no public view either and is +// DENIED off its record: its title and picture are for registered users. func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest) (*chatpb.GetChatResponse, error) { if req.GetViewMode() != messagingpb.ViewMode_REDACTED || !IsGroupChatID(req.ChatId) { return &chatpb.GetChatResponse{Result: chatpb.GetChatResponse_DENIED}, nil @@ -355,6 +363,10 @@ func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest) return nil, status.Error(codes.Internal, "") } + if c.IsPrivate { + return &chatpb.GetChatResponse{Result: chatpb.GetChatResponse_DENIED}, nil + } + standing := s.access.PublicStanding(c) metadata, err := s.hydrate(ctx, nil, standing, standing.Reading(req.GetViewMode()), []*Chat{c}) if err != nil { diff --git a/chat/store.go b/chat/store.go index 0f682f5..2ed74af 100644 --- a/chat/store.go +++ b/chat/store.go @@ -270,11 +270,11 @@ type Store interface { // (no error) when chatIDs is empty. GetGroupRosterSummaries(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]RosterSummary, error) - // GetGroupRules returns a group chat's participation rules and its - // recorded creator (see GroupRules), with a nil Rules when it has none. It - // reads only what they are projected from, never the full canonical record - // — and both are fixed at creation, so an implementation is free to cache - // them indefinitely. It returns ErrChatNotFound if the chat does not exist, + // GetGroupRules returns a group chat's participation rules, its recorded + // creator and whether it is private (see GroupRules), with a nil Rules + // when it has none. It reads only what they are projected from, never the + // full canonical record — and all are fixed at creation, so an + // implementation is free to cache them indefinitely. It returns ErrChatNotFound if the chat does not exist, // and an error if chatID is not a group chat ID. GetGroupRules(ctx context.Context, chatID *commonpb.ChatId) (GroupRules, error) diff --git a/chat/tests/server.go b/chat/tests/server.go index b37b7bd..6699768 100644 --- a/chat/tests/server.go +++ b/chat/tests/server.go @@ -109,7 +109,9 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_StartChat_PictureNotAccepted, testServer_StartChat_TitleModerated, testServer_StartChat_ModerationFailureIsInternal, - testServer_StartChat_PrivateGroupRefused, + testServer_StartChat_PrivateGroup, + testServer_PrivateGroup_Visibility, + testServer_PrivateGroup_Membership, testServer_StartChat_InvalidRules, testServer_StartChat_RulesNotSatisfied, testServer_StartChat_WithRules, @@ -2892,6 +2894,25 @@ func (e *serverEnv) mustStartGroupChatWithKey(keys model.KeyPair, key *chatpb.Id return resp } +// mustStartPrivateGroupChat starts a private group under the given +// idempotency key. +func (e *serverEnv) mustStartPrivateGroupChat(keys model.KeyPair, key *chatpb.IdempotencyKey, params *chatpb.StartChatRequest_PrivateGroupChatParameters) *chatpb.StartChatResponse { + req := &chatpb.StartChatRequest{ + Parameters: &chatpb.StartChatRequest_PrivateGroup{PrivateGroup: params}, + IdempotencyKey: key, + } + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.StartChat(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +// privateGroupParams builds StartChat parameters for a private group with the +// given title. +func privateGroupParams(title string) *chatpb.StartChatRequest_PrivateGroupChatParameters { + return &chatpb.StartChatRequest_PrivateGroupChatParameters{Title: title} +} + // startChatMinimumBalance is the minimum listener balance, in USD, the // StartChat tests ask their groups to carry when the rules are not what is // under test. Every group must carry one (see chat.RulesFromProto). @@ -3231,24 +3252,223 @@ func testServer_StartChat_ModerationFailureIsInternal(t *testing.T, s chat.Store require.Empty(t, groups) } -// testServer_StartChat_PrivateGroupRefused pins that a private group cannot -// be created yet: the variant is refused and nothing is written. -func testServer_StartChat_PrivateGroupRefused(t *testing.T, s chat.Store) { +// testServer_StartChat_PrivateGroup pins the creation of a private group: only +// a staff user may create one while private groups are being built, it has no +// rules to satisfy, and it is otherwise created as any group is — title +// moderated, the creator its only member, announced to their devices, and +// retry-safe. +func testServer_StartChat_PrivateGroup(t *testing.T, s chat.Store) { e := newServerEnv(t, s) + e.profiles.displayNames[string(e.userID.Value)] = "Founder" - req := &chatpb.StartChatRequest{ - Parameters: &chatpb.StartChatRequest_PrivateGroup{PrivateGroup: &chatpb.StartChatRequest_PrivateGroupChatParameters{ - Title: "Sunday Hikers", - }}, - IdempotencyKey: newIdempotencyKey(), + groups := func() []*chat.Chat { + t.Helper() + groups, err := s.GetGroupChatsForUser(e.ctx, e.userID) + require.NoError(t, err) + return groups } - require.NoError(t, e.keys.Auth(req, &req.Auth)) - _, err := e.client.StartChat(e.ctx, req) - require.Equal(t, codes.InvalidArgument, status.Code(err)) - groups, err := s.GetGroupChatsForUser(e.ctx, e.userID) + // Anyone but a staff user is refused, and nothing is written. + resp := e.mustStartPrivateGroupChat(e.keys, newIdempotencyKey(), privateGroupParams("Sunday Hikers")) + require.Equal(t, chatpb.StartChatResponse_DENIED, resp.Result) + require.Nil(t, resp.Chat) + require.Empty(t, groups()) + + // A staff user creates one holding nothing: there is no rule to satisfy. + e.accounts.setStaff(e.userID, true) + key := newIdempotencyKey() + resp = e.mustStartPrivateGroupChat(e.keys, key, privateGroupParams("Sunday Hikers")) + require.Equal(t, chatpb.StartChatResponse_OK, resp.Result) + md := resp.Chat + require.NotNil(t, md) + require.True(t, chat.IsGroupChatID(md.ChatId)) + require.Equal(t, chatpb.ChatType_GROUP, md.Type) + require.True(t, md.IsPrivate) + require.Nil(t, md.Rules) + require.Equal(t, e.userID.Value, md.GetCreator().GetValue()) + require.Equal(t, "Sunday Hikers", md.Title) + require.NoError(t, protoutil.ProtoEqualError(&chatpb.RosterSummary{MemberCount: 1, Version: 0}, md.GetRosterSummary())) + require.Len(t, md.Members, 1) + require.Equal(t, e.userID.Value, md.Members[0].UserId.Value) + require.True(t, md.GetViewerState().GetPermissions().GetCanEdit()) + require.Equal(t, "Sunday Hikers", e.moderator.classifiedTitle) + + stored, err := s.GetChatByID(e.ctx, md.ChatId) require.NoError(t, err) - require.Empty(t, groups) + require.True(t, stored.IsPrivate) + require.Nil(t, stored.Rules()) + require.Equal(t, e.userID.Value, stored.CreatorID.Value) + members, err := s.GetMembers(e.ctx, md.ChatId) + require.NoError(t, err) + require.Len(t, members, 1) + require.Equal(t, e.userID.Value, members[0].Value) + + // The creator's other devices learn of it as a join carrying the metadata. + e.userObserver.WaitFor(t, func([]*event.KeyAndEvent[*commonpb.UserId, *eventpb.Event]) bool { + return len(e.rosterUpdatesOnUserTopic(e.userID, md.ChatId)) >= 1 + }) + toCreator := e.rosterUpdatesOnUserTopic(e.userID, md.ChatId) + require.Len(t, toCreator, 1) + require.True(t, toCreator[0].GetMemberJoined().GetMetadata().GetIsPrivate()) + + // A retry names the same group and is answered from its record, whatever + // it carries and whether or not the caller is still staff. + e.accounts.setStaff(e.userID, false) + again := e.mustStartPrivateGroupChat(e.keys, key, privateGroupParams("Renamed")) + require.Equal(t, chatpb.StartChatResponse_OK, again.Result) + require.Equal(t, md.ChatId.Value, again.Chat.ChatId.Value) + require.Equal(t, "Sunday Hikers", again.Chat.Title) + require.True(t, again.Chat.IsPrivate) + asPublic := e.mustStartGroupChatWithKey(e.keys, key, groupParams("Renamed")) + require.Equal(t, chatpb.StartChatResponse_OK, asPublic.Result) + require.Equal(t, md.ChatId.Value, asPublic.Chat.ChatId.Value) + require.True(t, asPublic.Chat.IsPrivate) + require.Len(t, groups(), 1) + + // Its title is moderated like any group's. + e.accounts.setStaff(e.userID, true) + e.moderator.titleFlagged = true + e.moderator.titleCategories = []string{"gibberish", "solicitation"} + resp = e.mustStartPrivateGroupChat(e.keys, newIdempotencyKey(), privateGroupParams("DM for signals")) + require.Equal(t, chatpb.StartChatResponse_TITLE_MODERATED, resp.Result) + require.Equal(t, moderationpb.FlaggedCategory_SPAM, resp.FlaggedCategory) + require.Nil(t, resp.Chat) + require.Len(t, groups(), 1) +} + +// testServer_PrivateGroup_Visibility pins who sees what of a private group: +// any registered user its record and nothing else under every view mode, its +// members its messaging state, and an unauthenticated caller nothing at all. +func testServer_PrivateGroup_Visibility(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + + private := &chat.Chat{ + ID: chat.MustGenerateGroupChatID(), + Type: chatpb.ChatType_GROUP, + Members: []*commonpb.UserId{e.userID}, + Title: "Sunday Hikers", + IsPrivate: true, + CreatorID: e.userID, + LastActivity: at(1), + LastMessageID: &messagingpb.MessageId{Value: 2}, + } + require.NoError(t, s.PutChat(e.ctx, private)) + e.messaging.lastMessages[string(private.ID.Value)] = textMessage(2, e.userID, "members only") + e.messaging.latestEventSeqs[string(private.ID.Value)] = 2 + + // A member reads it like any group they are in. + resp := e.getChat(e.keys, private.ID) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) + require.True(t, resp.Metadata.IsPrivate) + require.Len(t, resp.Metadata.Members, 1) + require.Equal(t, "members only", resp.Metadata.LastMessage.Content[0].GetText().GetText()) + require.Equal(t, uint64(2), resp.Metadata.LatestEventSequence) + require.NotNil(t, resp.Metadata.ViewerState) + + // A non-member sees the record alone, whatever they ask for and whoever + // they are: nothing about a staff user admits them. + strangerID, strangerKeys := e.addUser() + e.accounts.setStaff(strangerID, true) + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + resp = e.getChatWithMode(strangerKeys, private.ID, mode) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result, mode) + require.True(t, resp.Metadata.IsPrivate, mode) + require.Equal(t, "Sunday Hikers", resp.Metadata.Title, mode) + require.Equal(t, e.userID.Value, resp.Metadata.GetCreator().GetValue(), mode) + require.Nil(t, resp.Metadata.Rules, mode) + require.Empty(t, resp.Metadata.Members, mode) + require.Nil(t, resp.Metadata.LastMessage, mode) + require.Zero(t, resp.Metadata.LatestEventSequence, mode) + require.Nil(t, resp.Metadata.ViewerState, mode) + } + + // It has no public view. + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + resp = e.getPublicChat(private.ID, mode) + require.Equal(t, chatpb.GetChatResponse_DENIED, resp.Result, mode) + require.Nil(t, resp.Metadata, mode) + } + + // None of that rests on a private group having no rules. One whose record + // carries a listener rule shows a non-member who satisfies it the record + // alone all the same, and admits them no more than any other. + _, err := e.accounts.Bind(e.ctx, strangerID, strangerKeys.Proto()) + require.NoError(t, err) + e.ocpBalance.setBalance(strangerKeys.Proto(), ocp_common.ToCoreMintQuarks(startChatMinimumBalance)) + ruled := private.Clone() + ruled.ID = chat.MustGenerateGroupChatID() + ruled.Members = []*commonpb.UserId{model.MustGenerateUserID()} + ruled.MinimumListenerBalance = &chat.MinimumBalance{Currency: "usd", NativeAmount: startChatMinimumBalance} + require.NoError(t, s.PutChat(e.ctx, ruled)) + e.messaging.lastMessages[string(ruled.ID.Value)] = textMessage(2, e.userID, "members only") + e.messaging.latestEventSeqs[string(ruled.ID.Value)] = 2 + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + resp = e.getChatWithMode(strangerKeys, ruled.ID, mode) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result, mode) + require.True(t, resp.Metadata.IsPrivate, mode) + require.Empty(t, resp.Metadata.Members, mode) + require.Nil(t, resp.Metadata.LastMessage, mode) + require.Zero(t, resp.Metadata.LatestEventSequence, mode) + require.Equal(t, chatpb.GetChatResponse_DENIED, e.getPublicChat(ruled.ID, mode).Result, mode) + } + require.Equal(t, chatpb.JoinChatResponse_DENIED, e.mustJoinChat(strangerKeys, ruled.ID).Result) + + // It is in its member's feed, marked private. + feed := e.mustGetGroupFeed(nil) + require.Equal(t, chatpb.GetGroupChatFeedResponse_OK, feed.Result) + require.Len(t, feed.Chats, 1) + require.True(t, feed.Chats[0].IsPrivate) +} + +// testServer_PrivateGroup_Membership pins that a private group is not joined +// with JoinChat by anyone but its creator, who leaves and rejoins it like any +// group, and that nobody speaks in one, its creator included. +func testServer_PrivateGroup_Membership(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + e.accounts.setStaff(e.userID, true) + + created := e.mustStartPrivateGroupChat(e.keys, newIdempotencyKey(), privateGroupParams("Sunday Hikers")) + require.Equal(t, chatpb.StartChatResponse_OK, created.Result) + chatID := created.Chat.ChatId + + isMember := func(userID *commonpb.UserId) bool { + t.Helper() + ok, err := s.IsMember(e.ctx, chatID, userID) + require.NoError(t, err) + return ok + } + + // No one else joins, a staff user included. + strangerID, strangerKeys := e.addUser() + e.accounts.setStaff(strangerID, true) + joined := e.mustJoinChat(strangerKeys, chatID) + require.Equal(t, chatpb.JoinChatResponse_DENIED, joined.Result) + require.Nil(t, joined.Chat) + require.False(t, isMember(strangerID)) + + // The creator's join of a group they are in is the no-op it always is. + joined = e.mustJoinChat(e.keys, chatID) + require.Equal(t, chatpb.JoinChatResponse_OK, joined.Result) + require.NoError(t, protoutil.ProtoEqualError(&chatpb.RosterSummary{MemberCount: 1, Version: 0}, joined.Chat.GetRosterSummary())) + + // The creator leaves, and rejoins on their own: no one need approve them. + require.Equal(t, chatpb.LeaveChatResponse_OK, e.mustLeaveChat(e.keys, chatID).Result) + require.False(t, isMember(e.userID)) + joined = e.mustJoinChat(strangerKeys, chatID) + require.Equal(t, chatpb.JoinChatResponse_DENIED, joined.Result) + joined = e.mustJoinChat(e.keys, chatID) + require.Equal(t, chatpb.JoinChatResponse_OK, joined.Result) + require.True(t, joined.Chat.IsPrivate) + require.NoError(t, protoutil.ProtoEqualError(&chatpb.RosterSummary{MemberCount: 1, Version: 2}, joined.Chat.GetRosterSummary())) + require.True(t, isMember(e.userID)) + + // The creator edits it like any group they created. + title := "Monday Hikers" + edited := e.mustEditChat(e.keys, chatID, &title, nil) + require.Equal(t, chatpb.EditChatResponse_OK, edited.Result) + + // No one speaks in it, so its creator is refused what a speaker is given. + require.Equal(t, chatpb.GetMentionSuggestionsResponse_DENIED, e.getMentionSuggestions(e.keys, chatID).Result) } func testServer_StartChat_InvalidRules(t *testing.T, s chat.Store) { diff --git a/chat/tests/store.go b/chat/tests/store.go index 2ab49a1..7112271 100644 --- a/chat/tests/store.go +++ b/chat/tests/store.go @@ -621,6 +621,33 @@ func testStore_GroupChat_Rules(t *testing.T, s chat.Store) { require.Equal(t, creator.Value, record.CreatorID.GetValue()) require.True(t, proto.Equal(creatorOnly.Rules(), record.Rules())) + // A private group carries no rules, and reads back as private beside + // them and on its canonical record. Every other group reads as not. + private := &chat.Chat{ + ID: chat.MustGenerateGroupChatID(), + Type: chatpb.ChatType_GROUP, + Members: []*commonpb.UserId{creator}, + Title: "Private", + IsPrivate: true, + CreatorID: creator, + LastActivity: at(100), + } + require.NoError(t, s.PutChat(ctx, private)) + got, err = s.GetGroupRules(ctx, private.ID) + require.NoError(t, err) + require.True(t, got.IsPrivate) + require.Nil(t, got.Rules) + require.Equal(t, creator.Value, got.CreatorID.GetValue()) + record, err = s.GetChatByID(ctx, private.ID) + require.NoError(t, err) + require.True(t, record.IsPrivate) + got, err = s.GetGroupRules(ctx, creatorOnly.ID) + require.NoError(t, err) + require.False(t, got.IsPrivate) + record, err = s.GetChatByID(ctx, creatorOnly.ID) + require.NoError(t, err) + require.False(t, record.IsPrivate) + // The creator is read for any group that recorded one, whatever its rules. withCreator := &chat.Chat{ ID: chat.MustGenerateGroupChatID(), diff --git a/messaging/message.go b/messaging/message.go index ebd3364..eb773d9 100644 --- a/messaging/message.go +++ b/messaging/message.go @@ -467,8 +467,10 @@ func isEncrypted(content []*messagingpb.Content) bool { // goes: plaintext always, and encrypted content only under the scheme the chat // uses, the one thing in it the server reads. A DM uses the pairwise scheme, // X25519_XCHACHA20POLY1305. A group takes no encrypted content at all: -// CHAT_KEY_XCHACHA20POLY1305 is a private group's scheme, and no group is -// private yet. A refusal is ENCRYPTION_NOT_ALLOWED on either RPC. +// CHAT_KEY_XCHACHA20POLY1305 is a private group's scheme, and a private group +// never reaches this rule, since no one speaks in one yet (see +// chat.Access.CanSpeak) and its sends are DENIED at the speaker gate before +// it. A refusal is ENCRYPTION_NOT_ALLOWED on either RPC. func encryptionAllowed(chatID *commonpb.ChatId, content []*messagingpb.Content) bool { if !isEncrypted(content) { return true diff --git a/messaging/tests/server.go b/messaging/tests/server.go index 0dcd4a9..f9008fd 100644 --- a/messaging/tests/server.go +++ b/messaging/tests/server.go @@ -95,6 +95,7 @@ func RunServerTests(t *testing.T, badges badge.Store, blocklists blocklist.Store testServer_ViewMode, testServer_StaffOnlyGroup_Rules, testServer_CreatorOnlyGroup_Rules, + testServer_PrivateGroup_NoOneSpeaks, testServer_BalanceGatedGroup_Rules, testServer_Broadcast_IncludesActor, testServer_SendMessage_PushPerChatType, @@ -3342,6 +3343,53 @@ func testServer_StaffOnlyGroup_Rules(t *testing.T, badges badge.Store, blocklist require.Equal(t, messagingpb.SendMessageResponse_OK, dmResp.Result) } +// testServer_PrivateGroup_NoOneSpeaks pins that nothing is sent in a private +// group, whose key cannot be stored yet: a member's send is DENIED whatever it +// carries, encrypted content included, as is their typing notification, and +// nothing is written. +func testServer_PrivateGroup_NoOneSpeaks(t *testing.T, badges badge.Store, blocklists blocklist.Store, chats chat.Store, messages messaging.Store, profiles profile.Store) { + e := newServerEnv(t, badges, blocklists, chats, messages, profiles) + + groupID := chat.MustGenerateGroupChatID() + require.NoError(t, chats.PutChat(e.ctx, &chat.Chat{ + ID: groupID, + Type: chatpb.ChatType_GROUP, + Members: []*commonpb.UserId{e.userA}, + Title: "Private", + IsPrivate: true, + CreatorID: e.userA, + LastActivity: at(1), + })) + + for _, content := range [][]*messagingpb.Content{ + textContent("plaintext"), + encryptedContent(1), + chatKeyEncryptedContent(1), + } { + resp, err := e.sendContentToChat(e.keysA, groupID, content, generateClientID()) + require.NoError(t, err) + require.Equal(t, messagingpb.SendMessageResponse_DENIED, resp.Result) + require.Nil(t, resp.Message) + } + + typingResp, err := e.notifyIsTypingInChat(e.keysA, groupID, messagingpb.IsTypingNotification_STARTED_TYPING) + require.NoError(t, err) + require.Equal(t, messagingpb.NotifyIsTypingResponse_DENIED, typingResp.Result) + + // The member still reads it, and finds it empty. + got, err := e.getMessagesByOptionsInChat(e.keysA, groupID, nil) + require.NoError(t, err) + require.Equal(t, messagingpb.GetMessagesResponse_NOT_FOUND, got.Result) + + // A non-member reads nothing of it under any mode. + _, strangerKeys := e.addUser() + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + denied, err := e.getMessagesByOptionsInChatWithMode(strangerKeys, groupID, nil, mode) + require.NoError(t, err) + require.Equal(t, messagingpb.GetMessagesResponse_DENIED, denied.Result, mode) + } +} + // testServer_CreatorOnlyGroup_Rules pins that a creator-only group's speaker // rule is enforced on the send paths: its creator speaks, and every other // member is refused as a non-member is, while still reading and reacting, From 5a4629e40165ffeaabe7beb4472822fd50d34288 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Fri, 2 Oct 2026 14:25:35 -0400 Subject: [PATCH 3/6] Implement key envelopes for private groups --- CLAUDE.md | 6 +- chat/access.go | 19 +-- chat/blob.go | 4 +- chat/cache/store.go | 22 ++- chat/create.go | 12 +- chat/dynamodb/server_test.go | 5 +- chat/dynamodb/store.go | 147 ++++++++++++++++++-- chat/dynamodb/store_test.go | 13 +- chat/dynamodb/table.go | 25 +++- chat/dynamodb/tombstone_test.go | 8 +- chat/key.go | 149 ++++++++++++++++++++ chat/member.go | 25 +++- chat/memory/store.go | 46 ++++++- chat/model.go | 86 +++++++++++- chat/store.go | 44 +++++- chat/tests/server.go | 191 ++++++++++++++++++++++++- chat/tests/store.go | 222 +++++++++++++++++++++++++++++- event/tests/server.go | 6 +- messaging/dynamodb/server_test.go | 5 +- messaging/tests/server.go | 41 ++++-- 20 files changed, 991 insertions(+), 85 deletions(-) create mode 100644 chat/key.go diff --git a/CLAUDE.md b/CLAUDE.md index b50b535..af639a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ This is the most intricate part of the codebase and where most current work happ **Chat IDs.** Length is the type discriminator: 32 bytes = DM (SHA-256 over a domain-separated, sorted, deduped member set, so creation is idempotent and order-independent), 16 bytes = group (server-derived: a truncated, domain-separated SHA-256 over the creator's user ID and the request's required `IdempotencyKey`, so a retried `StartChat` names the same group and is answered from the existing record; stamped as a version 8 UUID so every group ID is UUID-shaped, though the ID is opaque and nothing parses it). DM paths must reject 16-byte IDs and vice versa. `CONTACT_DM` uses the bare legacy hash domain; other DM types append their enum number. A chat type of `UNKNOWN` falls back to `CONTACT_DM` for legacy clients. -**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — today a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel) and a per-row `version` with the same state-not-delta rule. Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is one blind conditional update of `last_sent_at` (epoch ms), throttled to one per `ActivityRecordInterval` (1 min) per user, never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one strongly consistent query on the `by_last_sent_at` LSI, most recent first. A second LSI, `by_activity_score`, is reserved for a future frequency-weighted ordering and empty today: nothing writes `activity_score`, and it exists only because an LSI cannot be added later. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). +**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — today a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel) and a per-row `version` with the same state-not-delta rule. Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is one blind conditional update of `last_sent_at` (epoch ms), throttled to one per `ActivityRecordInterval` (1 min) per user, never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one strongly consistent query on the `by_last_sent_at` LSI, most recent first. A second LSI, `by_activity_score`, is reserved for a future frequency-weighted ordering and empty today: nothing writes `activity_score`, and it exists only because an LSI cannot be added later. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). A sixth table, `chat_key_envelopes` (pk user, sk chat, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each member's **key envelope** for a private group (`chat.KeyEnvelope`: scheme, nonce, ciphertext and `wrapped_by`, the user who stored it; see "Private groups"). `SetKeyEnvelope` is one conditional put, refused when the stored envelope is one the user wrapped themself, with the refusal returning the stored item; `GetKeyEnvelope` is a strongly consistent point read. Like viewer state, the methods are on `chat.Store`, write against the IDs alone and know nothing of the roster. The one tie to the roster is a departure: `RemoveGroupMember` takes a required `discardKeyEnvelope` flag and, when set, deletes the leaver's envelope **in the same transaction** as the membership transition and its `#meta` update (an unconditional third item, so it commits iff the departure happens and a no-op leave deletes nothing). **Tombstones.** A departed group member's row stays with `state=2`, `left_at`, a 1h TTL and the departure's roster version, so re-joins are idempotent updates and delayed duplicates of an undone join can be distinguished from news. Readers trust `state`, never the clock. "Formerly a member" is not durable. @@ -173,7 +173,7 @@ This is the most intricate part of the codebase and where most current work happ **Group management.** `StartChat` / `JoinChat` / `LeaveChat` are open to every registered user (nothing in these packages gates on client version). DMs always deny join/leave. Membership is checked before rules so a re-join is a no-op. Every real transition publishes a `RosterUpdate` to both the user and chat topics. Creation validates rules, checks the creator satisfies them, moderates the title and attaches the picture *before* the record is written. -**Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and **staff-only for now** (`canCreatePrivateGroup`, `DENIED` otherwise; a transitional gate like `useE2ee`, removed by deleting the check; a retry of a creation that landed is answered from the record before it). `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `GroupRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; the `RuleEvaluator` admits no one to a private group (`satisfiesListener` is false on `IsPrivate`, so a caller asking the rules alone never finds an empty rule set open to all), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view is `DENIED` (`getPublicChat`); `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely. **No one speaks in one yet**: `Access.CanSpeak` refuses every member of a private group off the cached rules read, which is the proto's keyless state (sends, edits, deletions, typing and mention suggestions all `DENIED`), and encrypted blob uploads are refused because `IsDMMember` refuses every group ID. Not built: key envelopes (`SetKeyEnvelope` / `GetKeyEnvelope`, which will lift the speak refusal for a keyed group), `ENCRYPTION_REQUIRED` and the chat-key scheme in messaging, the lobby RPCs and `Metadata.in_lobby` (never set); those RPCs answer unimplemented. +**Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and **staff-only for now** (`canCreatePrivateGroup`, `DENIED` otherwise; a transitional gate like `useE2ee`, removed by deleting the check; a retry of a creation that landed is answered from the record before it). `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `GroupRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; the `RuleEvaluator` admits no one to a private group (`satisfiesListener` is false on `IsPrivate`, so a caller asking the rules alone never finds an empty rule set open to all), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view is `DENIED` (`getPublicChat`); `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely. **Key envelopes (`chat/key.go`)**: `SetKeyEnvelope` / `GetKeyEnvelope` are a member's alone, of a private group alone (a DM `DENIED` before any read, then the record for `NOT_FOUND`, then `IsPrivate`, then membership). Set stores the request's envelope as wrapped by the caller; **the first envelope a user wraps for themself stands** (a different one later is `ALREADY_SET`, the same one `OK`), while an envelope someone else wrapped for them is replaced by their own. Get returns the envelope with `wrapped_by`, or `NO_ENVELOPE`. Nothing is published. **A private group has a key exactly when its creator has an envelope stored**: there is no flag on the record, since the creator's envelope is the only possible first one, is always self-wrapped, and is never deleted. `LeaveChat` discards the leaver's envelope atomically with the departure, except the creator's: it reads the cached rules first (`NOT_FOUND` off that read, a failed read fails the RPC) and passes `discardKeyEnvelope` to `RemoveGroupMember` for a non-creator leaving a private group. `SetKeyEnvelope` is not in that transaction, so one racing a leave can still write an envelope for a user who has just left; it is unreadable to them and holds the right key if they are readmitted (accepted). The server never opens an envelope and validates only its shape (proto validation). **No one speaks in one yet, keyed or not**: `Access.CanSpeak` refuses every member of a private group off the cached rules read (sends, edits, deletions, typing and mention suggestions all `DENIED`), because messaging does not yet require encrypted content there and lifting the refusal for a keyed group would let plaintext in; encrypted blob uploads are refused because `IsDMMember` refuses every group ID. Not built: speaking in a keyed group (`ENCRYPTION_REQUIRED`, the chat-key scheme, the blob gate, the group push body, decided in `Access.CanSpeak` on membership and the creator's envelope without the evaluator), the lobby RPCs and `Metadata.in_lobby` (never set); the lobby RPCs answer unimplemented. **Edit (`chat/edit.go`).** `EditChat` changes a group's title and/or picture. **Only the creator, while a member, may edit** (`Chat.PermissionsFor`: `CanEdit = isMember && IsCreator`; DMs and legacy groups with no recorded creator are never editable; a departed creator is `DENIED` until they rejoin). Fields equal to the record are dropped first, so a no-op request (or one that sets nothing) is `OK` from the record with nothing moderated, attached, written or published. What remains runs in `StartChat`'s order — title moderation (`TITLE_MODERATED`), picture attach via `SetAsChatPicture` (`PICTURE_BLOB_NOT_ACCEPTED`), then one `Store.EditGroup` write (`GroupEdit`, nil = unchanged, SETs only the named attributes so concurrent edits of different fields never clobber; an empty edit is an error; `SetGroupPicture` remains the only way to *clear* a picture). A real change publishes **one event on the chat topic, excluding no one** (the editor's devices included), with one `MetadataUpdate` per field: `TitleChanged` / `PictureChanged` (the hydrated rendition set). `redact.ChatUpdate` passes both through. @@ -187,7 +187,7 @@ This is the most intricate part of the codebase and where most current work happ **Push fan-out (`messaging/push.go`).** A group send publishes once on the chat topic and never loads the roster; the pushes it earns run detached (`pushSentMessages`) and **walk the roster in pages** (`GetGroupMembersPage`, `defaultPushPageSize` 2000, `WithPushPageSize` for tests), running the whole pipeline per page: drop the sender, `GetBlockers` for the page (fails closed for that page only), mute split, then `ChatMessagePush.Send` (its own token lookup, badge batch and FCM batches). What is the same for every recipient — the sender profile, the rendered push including any currency-name lookup, the mute shape — is resolved once per message (`messagePush.prepare`) before the walk, via the `push.Build*Push` constructors. Pages are read sequentially but **sent concurrently**: each page's send runs on its own goroutine under a slot from the `Sender`'s pool (`pushPageSlots`, `defaultPushPageConcurrency` 4, `WithPushPageConcurrency` for tests), taken by the reader before it launches the page and before it reads the next, so a walk holds at most one page more than it has in send, and the pool is **shared by every fan-out the `Sender` runs** — it is the cross-message throttle, not just per-walk parallelism. `walkGroup` waits for its launched pages before returning. A DM is a walk of one page, its inline pair, sent inline and outside the pool so a large group's walk never delays it. Budgets: `pushStepTimeout` (15s) bounds each step (setup, each page read, each page send) on its own; `pushBudget` (1 min) bounds a whole update, slot waits included. A failed page read, or a budget that runs out waiting for a slot, ends the walk (the cursor is what failed to arrive, or the page that never launched — logged); a failed page send costs that page only. The cursor is a user ID, which a future durable worker can checkpoint to resume without re-sending pages already out. Not built: a read-ahead of the next page, a once-per-message blocker set. -**`chat/cache`** caches what is fixed at creation: DM membership (positives only), DM member lists, group rules with their creator. Group membership is never cached, and neither is viewer state: every `SetMute`/`ClearMute`/`GetViewerStates`/`GetMuted*` call passes straight through. The one exception is a per-process lower bound on each activity record: `RecordSend` answers a throttled send itself (a failed conditional write is still billed), holding each send it saw recorded for `ActivityRecordInterval`; it is safe because the record only moves forward, so a stale entry can cost a write but never skip one, and a refusal is never held. `GetRecentSenders` passes through. +**`chat/cache`** caches what is fixed at creation: DM membership (positives only), DM member lists, group rules with their creator. Group membership is never cached, and neither is viewer state or a key envelope: every `SetMute`/`ClearMute`/`GetViewerStates`/`GetMuted*` and `SetKeyEnvelope`/`GetKeyEnvelope` call passes straight through. The one exception is a per-process lower bound on each activity record: `RecordSend` answers a throttled send itself (a failed conditional write is still billed), holding each send it saw recorded for `ActivityRecordInterval`; it is safe because the record only moves forward, so a stale entry can cost a write but never skip one, and a refusal is never held. `GetRecentSenders` passes through. **Messaging storage (`messaging/dynamodb`).** `messages` uses one partition per chat (sk `#counter` / `msg#` / `evt#`) so `evt#` is a gapless, strongly consistent range for delta catch-up; each message's idempotency marker is **its own partition** (pk `cmid##`, sk `#marker`) so markers spread across the table instead of loading the chat's, and a send is a single transaction over both. `Store.PutMessages` writes a batch of messages to one chat in that same single transaction (all or nothing, consecutive IDs and event sequences in batch order, nothing interleaved); `PutMessage` is a batch of one in every store. A batch writes **one `evt#` row for its whole run of sends**, keyed by the run's *last* event sequence and carrying `first_event_seq` = its first, with `message_id` = the message sent at that first event (absent = the key, so a single send's row and every pre-existing row is a run of one); `GetEventDelta` expands runs back into events, clipped to `(after, head]` and cut at `limit` events, so a cursor or page boundary may sit inside a run (last-seq keying is what lets the range read find it), and it fails the read on a hole or overlap rather than skip a message. Only sends form runs; edits and deletes are single rows. A batch is at most `MaxMessagesPerPut` (49: DynamoDB's 100-item transaction cap over 2n + 2 rows — counter, a message row and marker per message, one run row), enforced by every backend; the memory store keeps one log entry per event and behaves identically. Idempotency is the batch's: every client message ID already spent is a replay (`created == false`, the originals in batch order), a mix of spent and fresh IDs is `ErrPartialReplay` with nothing written. `GetDelta` pages 100 at a time and returns `RESET_REQUIRED` past 1000 events. `message_pointers`: DMs use a **single `#ptrs` item per chat** with per-member attribute suffixes; groups use one `ptr#` item per member. Group pointer advances are stored but never broadcast (N² fan-out). Idempotency markers (`cmid#`) carry a TTL. Reactions span three tables: `message_reactions` is chat-keyed (`agg##` per emoji with count, **per-emoji version** and a bounded sample; `meta#` holding the message's own state, today its active-emoji count) so a page of summaries is one range query — a **strongly consistent** one, so a reader who reacts and refreshes never finds their emoji absent with no version to explain it; `message_reactors` is **message-keyed** (`user##`, one row per current reaction, deleted on remove, no tombstone) with the `by_version` LSI on `emoji_version` so an emoji's reactors page most-recent-first under a strongly consistent read; `message_self_reactions` is **viewer-keyed** (pk chat+user, sk `#`, the add's version and `reacted_ts`, i.e. the viewer's own reactor entry; **groups only** — a DM's overlay is answered from the sample, so a DM writes no row and `GetSelfReactions` refuses a DM ID) so a viewer's own reactions across a page of messages — the `self_reactor` overlay for groups — are one strongly consistent range query on their own partition, billed by what the viewer reacted rather than a key probe per aggregate on the page (a GSI would be eventually consistent and outside the transaction; a prefix in `message_reactions` would double the write load on the chat partition that already takes every aggregate CAS). Every add/remove is one transaction that writes the reactor row and its viewer-keyed copy and compare-and-sets the aggregate (and the meta row when an emoji activates or empties) to the exact next state, on the `group_members` `#meta` pattern: no read-back, exact type cap and sample eviction, lost CAS retried from the returned item, transaction conflicts backed off. Reactor rows are stamped with the version that added them; that version is the ordering key and the paging cursor, never `reacted_ts`. `GetReactors` reads the aggregate version *before* the page so the page is never older than the version. A summary's **wire order** is decided at projection, not by the store: `ReactionSummary.ToProto` sorts by `messaging.ReactionLess` (count descending, ties by the emoji's UTF-8 bytes, so the order is total for a given state and identical from every backend); stores still return their own by-emoji order and nothing downstream relies on it. diff --git a/chat/access.go b/chat/access.go index d89be88..78bd32f 100644 --- a/chat/access.go +++ b/chat/access.go @@ -378,14 +378,17 @@ func (a *Access) standing(ctx context.Context, chatID *commonpb.ChatId, userID * // first, so a chat's rules are never evaluated for a send on behalf of a // non-member. It is false, not an error, for a chat that does not exist. // -// No one speaks in a private group: nothing happens in one until its creator -// has stored its key, and no key can be stored yet (see Chat.IsPrivate). The -// refusal is decided off the rules read, which a store caches with the rules, -// so it costs nothing a send did not already pay. It is every gate CanSpeak -// stands behind at once: a send, an edit, a deletion, a typing notification, -// and mention suggestions. The RuleEvaluator would refuse a private group -// too, since no rule admits anyone to one; the refusal is made here because -// it is this one that a stored key will lift. +// No one speaks in a private group, whether or not its key is stored (see +// Chat.IsPrivate). A group with no key takes nothing by contract. A group +// with one is meant to take encrypted content from its members and nothing +// else, and messaging does not enforce that yet, so lifting the refusal for a +// keyed group now would let plaintext into it. The refusal is decided off the +// rules read, which a store caches with the rules, so it costs nothing a send +// did not already pay. It is every gate CanSpeak stands behind at once: a +// send, an edit, a deletion, a typing notification, and mention suggestions. +// The RuleEvaluator would refuse a private group too, since no rule admits +// anyone to one; the refusal is made here because it is this one that a +// stored key will lift, on membership and the key alone. func (a *Access) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { isMember, err := a.chats.IsMember(ctx, chatID, userID) if err != nil || !isMember { diff --git a/chat/blob.go b/chat/blob.go index 0211870..a9f1a0a 100644 --- a/chat/blob.go +++ b/chat/blob.go @@ -14,8 +14,8 @@ import ( // dependency is one-way. Keeping it here also keeps the chat ID discriminator // (see DmChatIDSize) in the package that owns it: a group ID is refused before // membership is ever read, because encrypted content is a DM's alone. That -// includes a private group's (see Chat.IsPrivate): nothing is uploaded for one -// before its key is stored, and no key can be stored yet. +// includes a private group's (see Chat.IsPrivate): no one speaks in one yet, +// keyed or not, so there is nothing an upload for one could be sent in. type dmMembership struct { store Store } diff --git a/chat/cache/store.go b/chat/cache/store.go index 36f9596..ddd3b72 100644 --- a/chat/cache/store.go +++ b/chat/cache/store.go @@ -21,9 +21,10 @@ import ( // mutable — and can be mutated by other processes, which this cache can never // observe — so group membership checks and member lists always defer to the // backing store. The rest of the store is passed straight through, viewer -// state and activity reads included: a user's state is theirs to change at any -// time, a group's recent senders change with every send, and both reads are -// already one strongly consistent query, so nothing of either is held. +// state, activity reads and key envelopes included: a user's state is theirs +// to change at any time, a group's recent senders change with every send, and +// both reads are already one strongly consistent query, so nothing of either +// is held. // // One thing is held that is not fixed at creation: a lower bound on each // activity record, so a throttled send costs no write (see RecordSend). It can @@ -60,8 +61,8 @@ func (c *Cache) AddGroupMembers(ctx context.Context, chatID *commonpb.ChatId, us return c.db.AddGroupMembers(ctx, chatID, userIDs) } -func (c *Cache) RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, chat.RosterSummary, error) { - return c.db.RemoveGroupMember(ctx, chatID, userID) +func (c *Cache) RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, discardKeyEnvelope bool) (bool, chat.RosterSummary, error) { + return c.db.RemoveGroupMember(ctx, chatID, userID, discardKeyEnvelope) } func (c *Cache) SetGroupPicture(ctx context.Context, chatID *commonpb.ChatId, blobID *blobpb.BlobId) error { @@ -269,6 +270,17 @@ func (c *Cache) GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, l return c.db.GetRecentSenders(ctx, chatID, limit) } +// The key envelope methods pass through: an envelope is read once per chat +// per install, and one a user wrapped for themself replaces the one they were +// admitted with, so there is nothing worth holding and something to get wrong. +func (c *Cache) SetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope chat.KeyEnvelope) (chat.KeyEnvelope, error) { + return c.db.SetKeyEnvelope(ctx, chatID, userID, envelope) +} + +func (c *Cache) GetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (chat.KeyEnvelope, error) { + return c.db.GetKeyEnvelope(ctx, chatID, userID) +} + // sendActivityCacheKey keys the activity cache by (group, user). Only group // IDs are held, and they are fixed width (chat.GroupChatIDSize), so // concatenating the raw bytes is unambiguous. diff --git a/chat/create.go b/chat/create.go index 2d38477..ed21f75 100644 --- a/chat/create.go +++ b/chat/create.go @@ -51,8 +51,9 @@ import ( // And while private groups are being built, only a staff user may create one // (DENIED otherwise, see canCreatePrivateGroup). What is created is a group // without a key: the creator's client stores the chat key's envelope as a -// second step, since the envelope is bound to the chat ID this RPC returns, -// and until it has, nothing happens in the group (see Chat.IsPrivate). +// second step (see SetKeyEnvelope), since the envelope is bound to the chat +// ID this RPC returns, and until it has, nothing happens in the group (see +// Chat.IsPrivate). // // The RPC is retry-safe. The group's ID is derived from the caller and the // request's idempotency key (see MustDeriveGroupChatID), so a retry names the @@ -250,9 +251,10 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* // canCreatePrivateGroup reports whether userID may create a private group: // today, only a staff user. It is a transitional gate, like use_e2ee's (see -// useE2ee): private groups are being built in steps, and one created now has -// no key and no lobby, so it is a group nobody can join or speak in. Opening -// creation to everyone is removing this check, once the rest is built. +// useE2ee): private groups are being built in steps, and one created now can +// be given its key but has no lobby and takes no messages, so it is a group +// nobody can join or speak in. Opening creation to everyone is removing this +// check, once the rest is built. func (s *Server) canCreatePrivateGroup(ctx context.Context, userID *commonpb.UserId) (bool, error) { return s.accounts.IsStaff(ctx, userID) } diff --git a/chat/dynamodb/server_test.go b/chat/dynamodb/server_test.go index 6eef1c3..891f087 100644 --- a/chat/dynamodb/server_test.go +++ b/chat/dynamodb/server_test.go @@ -17,12 +17,13 @@ const ( serverGroupMembersTable = "group_members_server_test" serverUserStateTable = "chat_user_state_server_test" serverActivityTable = "chat_activity_server_test" + serverKeyEnvelopesTable = "chat_key_envelopes_server_test" ) func TestChat_DynamoDBServer(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable)) - testStore := NewInDynamoDB(testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, nil) + testStore := NewInDynamoDB(testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, nil) teardown := func() { testStore.(*store).reset() } diff --git a/chat/dynamodb/store.go b/chat/dynamodb/store.go index 7947fb6..e0051a6 100644 --- a/chat/dynamodb/store.go +++ b/chat/dynamodb/store.go @@ -24,7 +24,7 @@ import ( "github.com/code-payments/flipcash2-server/chat" ) -// The chat store spans five tables: +// The chat store spans six tables: // // chats pk = "chat#" (one item per chat). Canonical metadata: type, // members (the DM participants; absent for groups), title, creator @@ -132,6 +132,24 @@ import ( // maintains it will need the item's prior score, so it will read // first and condition its update on last_sent_at as read, which // every recorded send moves forward. +// +// chat_key_envelopes pk = "user#", sk = "chat#" (one item per +// (user, private group) the user holds a key envelope for; see +// chat.KeyEnvelope). The envelope as stored: scheme, nonce, +// ciphertext, and wrapped_by, the user who stored it. Keyed by +// user, like chat_user_state, since every read is a user's read +// of their own envelope: a strongly consistent point read. There +// is no index and no read by chat, because nothing needs one: a +// group's size is unbounded and nothing enumerates its envelopes. +// +// A write is one conditional put of the one item, refused when +// the stored envelope is one the user wrapped themself +// (wrapped_by is the user), and the refusal returns the stored +// item, so the caller learns which envelope stands without a +// read. An item is deleted when its user leaves the group, in +// the transaction that records the departure (see +// RemoveGroupMember), so no departed user keeps one. No item +// expires. const ( // gsiByActivity is the legacy feed index on (pk, last_activity), spanning // all of a user's DM types. Superseded by gsiByTypeActivity; retained until @@ -213,6 +231,10 @@ const ( attrMutedCount = "muted_count" // chat_user_state #meta item: records with a mute recorded attrLastSentAt = "last_sent_at" // chat_activity: epoch ms of the latest recorded send attrActivityScore = "activity_score" // chat_activity: reserved, see lsiByActivityScore + attrScheme = "scheme" // chat_key_envelopes: chatpb.KeyEnvelope_Scheme, by number + attrNonce = "nonce" // chat_key_envelopes: the envelope's nonce (B) + attrCiphertext = "ciphertext" // chat_key_envelopes: the wrapped chat key (B) + attrWrappedBy = "wrapped_by" // chat_key_envelopes: the raw ID of the user who stored the envelope (B) // Keys of the min_listener_balance map. attrBalanceCurrency = "currency" @@ -278,6 +300,7 @@ type store struct { groupMembersTable string userStateTable string activityTable string + keyEnvelopesTable string exclusions chat.FeedExclusions } @@ -286,7 +309,7 @@ type store struct { // creating every DM with a user in excludedFromFeed excluding them from the // feed (see chat.FeedExclusions); nil excludes no one. Use CreateTables to // provision the tables. -func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable string, excludedFromFeed []*commonpb.UserId) chat.Store { +func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable string, excludedFromFeed []*commonpb.UserId) chat.Store { return &store{ exclusions: chat.NewFeedExclusions(excludedFromFeed), client: client, @@ -295,6 +318,7 @@ func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembe groupMembersTable: groupMembersTable, userStateTable: userStateTable, activityTable: activityTable, + keyEnvelopesTable: keyEnvelopesTable, } } @@ -475,7 +499,7 @@ func (s *store) addGroupMembers(ctx context.Context, chatID *commonpb.ChatId, us ":now": avN(uint64(time.Now().UTC().UnixNano())), }, } - joined, err := s.transitionMembership(ctx, chatID, join, 1, &roster) + joined, err := s.transitionMembership(ctx, chatID, join, 1, &roster, nil) if err != nil { return changed, roster, err } @@ -484,7 +508,7 @@ func (s *store) addGroupMembers(ctx context.Context, chatID *commonpb.ChatId, us return changed, roster, nil } -func (s *store) RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, chat.RosterSummary, error) { +func (s *store) RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, discardKeyEnvelope bool) (bool, chat.RosterSummary, error) { if !chat.IsGroupChatID(chatID) { return false, chat.RosterSummary{}, fmt.Errorf("not a group chat id") } @@ -518,7 +542,17 @@ func (s *store) RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, ":expires": avN(uint64(now.Add(tombstoneTTL).Unix())), }, } - changed, err := s.transitionMembership(ctx, chatID, leave, -1, &roster) + // The departing user's key envelope goes in the same transaction, so it + // is deleted iff the departure happens. The delete is unconditional: a + // user with no envelope leaves all the same. + var alongside []types.TransactWriteItem + if discardKeyEnvelope { + alongside = append(alongside, types.TransactWriteItem{Delete: &types.Delete{ + TableName: aws.String(s.keyEnvelopesTable), + Key: keyEnvelopeKey(chatID, userID), + }}) + } + changed, err := s.transitionMembership(ctx, chatID, leave, -1, &roster, alongside) return changed, roster, err } @@ -558,14 +592,20 @@ func (s *store) readRosterSummaryForWrite(ctx context.Context, chatID *commonpb. // success *roster is advanced to match; the next transition in a batch chains // from it without another read. // +// alongside are further writes the transition carries: they commit iff it +// does, and are not made when it is a no-op. They must be unconditional, so +// the only way one can cancel the transaction is by losing to a concurrent +// write on its own item. +// // Cancellation reasons are positional over the transaction's items: [0] is the -// membership transition and [1] the summary. A failed [1] condition means a +// membership transition, [1] the summary, and the rest alongside's. A failed +// [1] condition means a // concurrent writer moved the version — the failed item is returned with the // cancellation, so *roster is refreshed from it and the transition retried // with no extra read. Losing the write itself to a concurrent transaction on -// the same item surfaces as TransactionConflict and is retried with backoff. -// Both share the attempt budget (see maxMembershipAttempts). -func (s *store) transitionMembership(ctx context.Context, chatID *commonpb.ChatId, transition *types.Update, delta int64, roster *chat.RosterSummary) (bool, error) { +// the same item, any of them, surfaces as TransactionConflict and is retried +// with backoff. Both share the attempt budget (see maxMembershipAttempts). +func (s *store) transitionMembership(ctx context.Context, chatID *commonpb.ChatId, transition *types.Update, delta int64, roster *chat.RosterSummary, alongside []types.TransactWriteItem) (bool, error) { backoff := membershipBackoffBase for attempt := 0; ; attempt++ { next := chat.RosterSummary{ @@ -591,6 +631,7 @@ func (s *store) transitionMembership(ctx context.Context, chatID *commonpb.ChatI ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld, }}, } + transactItems = append(transactItems, alongside...) _, err := s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{TransactItems: transactItems}) if err == nil { @@ -601,7 +642,10 @@ func (s *store) transitionMembership(ctx context.Context, chatID *commonpb.ChatI if !ok || len(reasons) != len(transactItems) { return false, err } - codes := []string{aws.ToString(reasons[0].Code), aws.ToString(reasons[1].Code)} + codes := make([]string, len(reasons)) + for i, reason := range reasons { + codes[i] = aws.ToString(reason.Code) + } // The budget applies to the retries alone: a no-op is a no-op on the // last attempt too, and is reported as one, not as an exhausted retry. @@ -2579,3 +2623,86 @@ func (s *store) GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, l startKey = res.LastEvaluatedKey } } + +func (s *store) SetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope chat.KeyEnvelope) (chat.KeyEnvelope, error) { + if !chat.IsGroupChatID(chatID) { + return chat.KeyEnvelope{}, fmt.Errorf("not a group chat id") + } + if envelope.WrappedBy == nil { + return chat.KeyEnvelope{}, fmt.Errorf("key envelope has no wrapper") + } + + item := keyEnvelopeKey(chatID, userID) + item[attrScheme] = avN(uint64(envelope.Scheme)) + item[attrNonce] = avB(envelope.Nonce) + item[attrCiphertext] = avB(envelope.Ciphertext) + item[attrWrappedBy] = avB(envelope.WrappedBy.Value) + + // An envelope the user wrapped themself stands: the put is refused when + // one is stored, and the refusal returns it. + _, err := s.client.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(s.keyEnvelopesTable), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(#pk) OR #wrappedBy <> :user"), + ExpressionAttributeNames: map[string]string{ + "#pk": attrPK, + "#wrappedBy": attrWrappedBy, + }, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":user": avB(userID.Value), + }, + ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld, + }) + if err == nil { + return envelope.Clone(), nil + } + var ccf *types.ConditionalCheckFailedException + if !errors.As(err, &ccf) { + return chat.KeyEnvelope{}, err + } + return keyEnvelopeFromItem(ccf.Item) +} + +func (s *store) GetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (chat.KeyEnvelope, error) { + out, err := s.client.GetItem(ctx, &dynamodb.GetItemInput{ + TableName: aws.String(s.keyEnvelopesTable), + Key: keyEnvelopeKey(chatID, userID), + ConsistentRead: aws.Bool(true), + }) + if err != nil { + return chat.KeyEnvelope{}, err + } + if len(out.Item) == 0 { + return chat.KeyEnvelope{}, chat.ErrKeyEnvelopeNotFound + } + return keyEnvelopeFromItem(out.Item) +} + +// keyEnvelopeKey is the key of userID's envelope for chatID in +// chat_key_envelopes. +func keyEnvelopeKey(chatID *commonpb.ChatId, userID *commonpb.UserId) map[string]types.AttributeValue { + return map[string]types.AttributeValue{ + attrPK: avS(userPK(userID)), + attrSK: avS(chatSK(chatID)), + } +} + +// keyEnvelopeFromItem decodes a chat_key_envelopes item. Every attribute is +// written with every item, so one missing its wrapper is a corrupt item +// rather than a default. +func keyEnvelopeFromItem(item map[string]types.AttributeValue) (chat.KeyEnvelope, error) { + scheme, err := parseN(item[attrScheme]) + if err != nil { + return chat.KeyEnvelope{}, err + } + wrappedBy := asB(item[attrWrappedBy]) + if len(wrappedBy) == 0 { + return chat.KeyEnvelope{}, fmt.Errorf("key envelope item has no wrapper") + } + return chat.KeyEnvelope{ + Scheme: chatpb.KeyEnvelope_Scheme(scheme), + Nonce: bytes.Clone(asB(item[attrNonce])), + Ciphertext: bytes.Clone(asB(item[attrCiphertext])), + WrappedBy: &commonpb.UserId{Value: bytes.Clone(wrappedBy)}, + }, nil +} diff --git a/chat/dynamodb/store_test.go b/chat/dynamodb/store_test.go index 16bcb6a..e7e320f 100644 --- a/chat/dynamodb/store_test.go +++ b/chat/dynamodb/store_test.go @@ -25,19 +25,20 @@ const ( groupMembersTable = "group_members_test" userStateTable = "chat_user_state_test" activityTable = "chat_activity_test" + keyEnvelopesTable = "chat_key_envelopes_test" ) func TestChat_DynamoDBStore(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) teardown := func() { testStore.(*store).reset() } // The stores newStore builds share testStore's tables, so its teardown // resets theirs too. newStore := func(excludedFromFeed []*commonpb.UserId) chat.Store { - return NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, excludedFromFeed) + return NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, excludedFromFeed) } tests.RunStoreTests(t, testStore, newStore, teardown) } @@ -49,11 +50,11 @@ func TestChat_DynamoDBStore(t *testing.T) { // the other's. func TestChat_DynamoDBExclusionOutlivesConfig(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable)) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) team := model.MustGenerateUserID() - configured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, []*commonpb.UserId{team}) - unconfigured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, nil) + configured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, []*commonpb.UserId{team}) + unconfigured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) t.Cleanup(func() { configured.(*store).reset() }) chatIDValue := make([]byte, chat.DmChatIDSize) diff --git a/chat/dynamodb/table.go b/chat/dynamodb/table.go index a4862cd..ff88015 100644 --- a/chat/dynamodb/table.go +++ b/chat/dynamodb/table.go @@ -10,8 +10,9 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) -// CreateTables provisions the chats, dm_inbox, group_members and -// chat_user_state tables with on-demand billing. The chats table is keyed by +// CreateTables provisions the chats, dm_inbox, group_members, +// chat_user_state, chat_activity and chat_key_envelopes tables with on-demand +// billing. The chats table is keyed by // pk only; dm_inbox is keyed by (pk, sk) with a GSI ordering each user's DMs // by last_activity; group_members is keyed by (pk, sk) = (chat, user) — plus // one "#meta" aggregates item per group — with an inverted GSI for listing a @@ -24,8 +25,9 @@ import ( // user (see gsiUserStateByUser); chat_activity is keyed by (pk, sk) = (chat, // user) with two LSIs, one by last_sent_at (lsiByLastSentAt) and one by // activity_score (lsiByActivityScore, reserved and empty today), and TTL on -// expires_at. It is idempotent and blocks until all tables are ACTIVE. -func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable string) error { +// expires_at; chat_key_envelopes is keyed by (pk, sk) = (user, chat) with no +// index. It is idempotent and blocks until all tables are ACTIVE. +func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable string) error { inputs := []*dynamodb.CreateTableInput{ { TableName: aws.String(chatsTable), @@ -202,6 +204,18 @@ func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmIn }, }, }, + { + TableName: aws.String(keyEnvelopesTable), + BillingMode: types.BillingModePayPerRequest, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String(attrPK), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String(attrSK), AttributeType: types.ScalarAttributeTypeS}, + }, + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String(attrPK), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String(attrSK), KeyType: types.KeyTypeRange}, + }, + }, } for _, input := range inputs { @@ -270,6 +284,9 @@ func (s *store) reset() { if err := clearTable(ctx, s.client, s.activityTable, []string{attrPK, attrSK}); err != nil { panic(err) } + if err := clearTable(ctx, s.client, s.keyEnvelopesTable, []string{attrPK, attrSK}); err != nil { + panic(err) + } } func clearTable(ctx context.Context, client *dynamodb.Client, table string, keyAttrs []string) error { diff --git a/chat/dynamodb/tombstone_test.go b/chat/dynamodb/tombstone_test.go index 68dd943..75db325 100644 --- a/chat/dynamodb/tombstone_test.go +++ b/chat/dynamodb/tombstone_test.go @@ -27,9 +27,9 @@ import ( // any of these would delete live memberships silently. func TestChat_TombstoneTTL(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable)) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) defer testStore.(*store).reset() ttl, err := testEnv.Client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{TableName: aws.String(groupMembersTable)}) @@ -63,7 +63,7 @@ func TestChat_TombstoneTTL(t *testing.T) { require.NotContains(t, memberItem(), attrExpiresAt) // A departure stamps expiry at left_at plus the TTL, in epoch seconds. - changed, _, err := testStore.RemoveGroupMember(ctx, c.ID, userID) + changed, _, err := testStore.RemoveGroupMember(ctx, c.ID, userID, false) require.NoError(t, err) require.True(t, changed) item := memberItem() @@ -83,7 +83,7 @@ func TestChat_TombstoneTTL(t *testing.T) { require.Contains(t, item, attrJoinedAt) // Leaving again stamps it afresh. - changed, _, err = testStore.RemoveGroupMember(ctx, c.ID, userID) + changed, _, err = testStore.RemoveGroupMember(ctx, c.ID, userID, false) require.NoError(t, err) require.True(t, changed) require.Contains(t, memberItem(), attrExpiresAt) diff --git a/chat/key.go b/chat/key.go new file mode 100644 index 0000000..37409fb --- /dev/null +++ b/chat/key.go @@ -0,0 +1,149 @@ +package chat + +import ( + "context" + "errors" + + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + chatpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1" + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" + + "github.com/code-payments/flipcash2-server/model" +) + +// Key envelopes: a member's own copy of a private group's chat key (see +// KeyEnvelope), stored and fetched by that member. +// +// Both RPCs are a member's alone, and only of a private group. They are gated +// like the mute RPCs (see mutingAllowed): the canonical record first, since +// its absence is the only thing that tells NOT_FOUND from DENIED, then that +// the group is private, then membership, read strongly consistent. A DM is +// DENIED before anything is read. +// +// SetKeyEnvelope stores the envelope as one the caller wrapped for themself, +// whatever they hold already. It is how a group gets its key: the creator +// stores theirs as the second step of creating the group, and a private group +// has a key exactly when they have (see KeyEnvelope). It is also how an +// admitted member replaces the envelope the creator wrapped for them with one +// that depends on no one else's key. In both cases the first envelope a +// caller stores for themself stands (see Store.SetKeyEnvelope): a later call +// with a different one changes nothing and is ALREADY_SET, so two of a +// creator's devices setting up the same new group cannot end up holding +// different keys, and a repeat of the stored envelope is OK, so a retry after +// a lost response is. +// +// The server cannot tell a good envelope from a bad one. A caller who stores +// one that does not hold the group's key has locked only themself out, since +// an envelope is read back by its own user alone; for the creator of a group +// with no key yet, whatever they store is the group's key. +// +// Nothing is published by either: a user's other devices fetch the envelope +// when they need it. +// +// Storing a key changes nothing else yet. Speaking in a private group, and +// the lobby its members are admitted from, are not built (see +// Chat.IsPrivate), so today the only envelope a group holds is its creator's. + +func (s *Server) SetKeyEnvelope(ctx context.Context, req *chatpb.SetKeyEnvelopeRequest) (*chatpb.SetKeyEnvelopeResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + ) + + allowed, err := s.keyEnvelopeAllowed(ctx, log, req.ChatId, userID) + if err != nil { + return nil, err + } + switch allowed { + case chatpb.GetKeyEnvelopeResponse_NOT_FOUND: + return &chatpb.SetKeyEnvelopeResponse{Result: chatpb.SetKeyEnvelopeResponse_NOT_FOUND}, nil + case chatpb.GetKeyEnvelopeResponse_DENIED: + return &chatpb.SetKeyEnvelopeResponse{Result: chatpb.SetKeyEnvelopeResponse_DENIED}, nil + } + + envelope := KeyEnvelopeFromProto(req.KeyEnvelope, userID) + stored, err := s.chats.SetKeyEnvelope(ctx, req.ChatId, userID, envelope) + if err != nil { + log.With(zap.Error(err)).Warn("Failure storing key envelope") + return nil, status.Error(codes.Internal, "") + } + if !stored.Equal(envelope) { + return &chatpb.SetKeyEnvelopeResponse{Result: chatpb.SetKeyEnvelopeResponse_ALREADY_SET}, nil + } + return &chatpb.SetKeyEnvelopeResponse{Result: chatpb.SetKeyEnvelopeResponse_OK}, nil +} + +func (s *Server) GetKeyEnvelope(ctx context.Context, req *chatpb.GetKeyEnvelopeRequest) (*chatpb.GetKeyEnvelopeResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + ) + + allowed, err := s.keyEnvelopeAllowed(ctx, log, req.ChatId, userID) + if err != nil { + return nil, err + } + if allowed != chatpb.GetKeyEnvelopeResponse_OK { + return &chatpb.GetKeyEnvelopeResponse{Result: allowed}, nil + } + + envelope, err := s.chats.GetKeyEnvelope(ctx, req.ChatId, userID) + switch { + case errors.Is(err, ErrKeyEnvelopeNotFound): + return &chatpb.GetKeyEnvelopeResponse{Result: chatpb.GetKeyEnvelopeResponse_NO_ENVELOPE}, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure getting key envelope") + return nil, status.Error(codes.Internal, "") + } + return &chatpb.GetKeyEnvelopeResponse{ + Result: chatpb.GetKeyEnvelopeResponse_OK, + KeyEnvelope: envelope.ToProto(), + WrappedBy: envelope.WrappedBy, + }, nil +} + +// keyEnvelopeAllowed is the gate SetKeyEnvelope and GetKeyEnvelope share, +// reported in GetKeyEnvelope's result vocabulary (Set's is identical for +// these): NOT_FOUND for a chat that does not exist, DENIED for a chat that is +// not a private group or a caller who is not a member of it, OK otherwise. A +// gRPC error is returned only for a failed read. +func (s *Server) keyEnvelopeAllowed(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, userID *commonpb.UserId) (chatpb.GetKeyEnvelopeResponse_Result, error) { + if !IsGroupChatID(chatID) { + return chatpb.GetKeyEnvelopeResponse_DENIED, nil + } + + c, err := s.chats.GetChatByID(ctx, chatID) + switch { + case errors.Is(err, ErrChatNotFound): + return chatpb.GetKeyEnvelopeResponse_NOT_FOUND, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure getting chat") + return 0, status.Error(codes.Internal, "") + } + if !c.IsPrivate { + return chatpb.GetKeyEnvelopeResponse_DENIED, nil + } + + isMember, err := s.access.IsMemberWithChat(ctx, c, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking chat membership") + return 0, status.Error(codes.Internal, "") + } + if !isMember { + return chatpb.GetKeyEnvelopeResponse_DENIED, nil + } + return chatpb.GetKeyEnvelopeResponse_OK, nil +} diff --git a/chat/member.go b/chat/member.go index c240a93..118e333 100644 --- a/chat/member.go +++ b/chat/member.go @@ -162,17 +162,34 @@ func (s *Server) LeaveChat(ctx context.Context, req *chatpb.LeaveChatRequest) (* // The ID's length is the type check: a group ID names a group or nothing, // so there is no canonical record to read here. Unlike a join, a departure - // needs nothing from it — no rules to evaluate, no metadata to return — and - // the store's write reports a missing chat itself, so leaving is the one - // write and nothing else. + // has no rules to evaluate and no metadata to return. if !IsGroupChatID(req.ChatId) { return &chatpb.LeaveChatResponse{Result: chatpb.LeaveChatResponse_DENIED}, nil } + // A departure from a private group takes the caller's key envelope with + // it, as the proto promises: a user who returns enters the lobby and is + // given a new one. The creator's is kept, since no one else could give + // them another, and it is what marks the group as having a key (see + // KeyEnvelope). The envelope is removed in the write that records the + // departure, so the two cannot disagree, which means deciding it before + // that write. Whether the group is private, and who created it, come from + // the rules read: it is the one a store caches, so a leave pays nothing + // for it in steady state. + rules, err := s.chats.GetGroupRules(ctx, req.ChatId) + switch { + case errors.Is(err, ErrChatNotFound): + return &chatpb.LeaveChatResponse{Result: chatpb.LeaveChatResponse_NOT_FOUND}, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure getting chat rules") + return nil, status.Error(codes.Internal, "") + } + discardKeyEnvelope := rules.IsPrivate && !rules.isCreator(userID) + // Leaving a group the caller is not in — never joined, or already gone — // is a no-op that already holds: the caller asked not to be a member, and // they are not. The store answers it without a transition. - changed, roster, err := s.chats.RemoveGroupMember(ctx, req.ChatId, userID) + changed, roster, err := s.chats.RemoveGroupMember(ctx, req.ChatId, userID, discardKeyEnvelope) switch { case errors.Is(err, ErrChatNotFound): return &chatpb.LeaveChatResponse{Result: chatpb.LeaveChatResponse_NOT_FOUND}, nil diff --git a/chat/memory/store.go b/chat/memory/store.go index d0b7ff9..4203d94 100644 --- a/chat/memory/store.go +++ b/chat/memory/store.go @@ -51,6 +51,10 @@ type memory struct { // contract lets a reader see one past chat.ActivityRetention. lastSent map[string]map[string]time.Time + // keyEnvelopes holds each user's key envelope per chat, keyed by user ID + // then chat ID, mirroring the persistent layout (see chat.Store). + keyEnvelopes map[string]map[string]chat.KeyEnvelope + exclusions chat.FeedExclusions } @@ -77,6 +81,7 @@ func NewInMemory(excludedFromFeed []*commonpb.UserId) chat.Store { viewerStates: make(map[string]map[string]*chat.ViewerState), mutedCounts: make(map[string]uint64), lastSent: make(map[string]map[string]time.Time), + keyEnvelopes: make(map[string]map[string]chat.KeyEnvelope), } } @@ -91,6 +96,7 @@ func (m *memory) reset() { m.mutedCounts = make(map[string]uint64) m.excludedFromFeed = make(map[string]map[string]struct{}) m.lastSent = make(map[string]map[string]time.Time) + m.keyEnvelopes = make(map[string]map[string]chat.KeyEnvelope) } // isJoinedLocked reports whether the user's record on the group has them @@ -193,7 +199,7 @@ func (m *memory) AddGroupMembers(_ context.Context, chatID *commonpb.ChatId, use return changed, m.rosterSummaryLocked(chatID), nil } -func (m *memory) RemoveGroupMember(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, chat.RosterSummary, error) { +func (m *memory) RemoveGroupMember(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, discardKeyEnvelope bool) (bool, chat.RosterSummary, error) { if !chat.IsGroupChatID(chatID) { return false, chat.RosterSummary{}, fmt.Errorf("not a group chat id") } @@ -212,6 +218,9 @@ func (m *memory) RemoveGroupMember(_ context.Context, chatID *commonpb.ChatId, u // do; the join time is meaningless once departed and is dropped with it. m.groupVersions[key]++ m.groupMembers[key][string(userID.Value)] = &memberRecord{version: m.groupVersions[key]} + if discardKeyEnvelope { + delete(m.keyEnvelopes[string(userID.Value)], key) + } return true, m.rosterSummaryLocked(chatID), nil } @@ -799,3 +808,38 @@ func (m *memory) GetRecentSenders(_ context.Context, chatID *commonpb.ChatId, li } return senders, nil } + +func (m *memory) SetKeyEnvelope(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope chat.KeyEnvelope) (chat.KeyEnvelope, error) { + if !chat.IsGroupChatID(chatID) { + return chat.KeyEnvelope{}, fmt.Errorf("not a group chat id") + } + if envelope.WrappedBy == nil { + return chat.KeyEnvelope{}, fmt.Errorf("key envelope has no wrapper") + } + + m.Lock() + defer m.Unlock() + + byChat := m.keyEnvelopes[string(userID.Value)] + if byChat == nil { + byChat = make(map[string]chat.KeyEnvelope) + m.keyEnvelopes[string(userID.Value)] = byChat + } + // An envelope the user wrapped themself stands. + if stored, ok := byChat[string(chatID.Value)]; ok && stored.IsWrappedBy(userID) { + return stored.Clone(), nil + } + byChat[string(chatID.Value)] = envelope.Clone() + return envelope.Clone(), nil +} + +func (m *memory) GetKeyEnvelope(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (chat.KeyEnvelope, error) { + m.Lock() + defer m.Unlock() + + stored, ok := m.keyEnvelopes[string(userID.Value)][string(chatID.Value)] + if !ok { + return chat.KeyEnvelope{}, chat.ErrKeyEnvelopeNotFound + } + return stored.Clone(), nil +} diff --git a/chat/model.go b/chat/model.go index cae4f80..9a718d4 100644 --- a/chat/model.go +++ b/chat/model.go @@ -277,12 +277,12 @@ func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatp // shown to any registered user. // // A private group's chat key reaches the server only as its members' key -// envelopes, and nothing can happen in one until its creator has stored -// theirs. Key envelopes are not built, so every private group is in that -// state and stays there: it exists, is visible, and can be left and rejoined -// by its creator, and no one speaks in it (see Access.CanSpeak). The lobby a -// user would enter to be admitted is not built either, so its creator is the -// only member it can have. +// envelopes (see KeyEnvelope), and nothing is meant to happen in one until +// its creator has stored theirs. Speaking in a private group is not built: +// messaging does not yet require its content to be encrypted, so no one +// speaks in one, keyed or not (see Access.CanSpeak). The lobby a user would +// enter to be admitted is not built either, so its creator is the only +// member it can have. // // CreatorID is the user who created the group, or nil when unknown (a DM has // none, and so does any group written before the field existed). It is fixed @@ -504,6 +504,80 @@ func (c *Chat) ToProto() *chatpb.Metadata { return md } +// KeyEnvelope is one user's key envelope for a private group (see +// chatpb.KeyEnvelope and Chat.IsPrivate): the group's chat key, encrypted so +// that only that user can open it. The server stores an envelope and hands it +// back to the user it is for. It cannot open one and never holds the chat +// key, so nothing in an envelope is checked beyond its shape, which is the +// boundary's job (proto validation), not the record's. +// +// WrappedBy is who stored the envelope, as the server authenticated them: the +// user it is for, or the group's creator admitting them. It is the server's +// record, never a client's claim, and it is what a client is told to open the +// envelope against (GetKeyEnvelopeResponse.wrapped_by). It also decides +// whether the envelope can be replaced: one a user wrapped for themself +// stands (see Store.SetKeyEnvelope). +// +// A private group has a key exactly when its creator has an envelope stored. +// Nothing else records it: the creator's envelope is the only one that can +// be the group's first, it is always one they wrapped themself, and it is +// never deleted, so its presence is one-way. Every other member's envelope is +// deleted with their departure (see Server.LeaveChat). +type KeyEnvelope struct { + Scheme chatpb.KeyEnvelope_Scheme + Nonce []byte + Ciphertext []byte + WrappedBy *commonpb.UserId +} + +// KeyEnvelopeFromProto returns the envelope pb describes, recorded as stored +// by wrappedBy. +func KeyEnvelopeFromProto(pb *chatpb.KeyEnvelope, wrappedBy *commonpb.UserId) KeyEnvelope { + return KeyEnvelope{ + Scheme: pb.GetScheme(), + Nonce: pb.GetNonce(), + Ciphertext: pb.GetCiphertext(), + WrappedBy: wrappedBy, + }.Clone() +} + +// ToProto projects the envelope onto a chatpb.KeyEnvelope. Who wrapped it is +// not part of the proto envelope; a response carries it beside the envelope. +func (e KeyEnvelope) ToProto() *chatpb.KeyEnvelope { + return &chatpb.KeyEnvelope{ + Scheme: e.Scheme, + Nonce: bytes.Clone(e.Nonce), + Ciphertext: bytes.Clone(e.Ciphertext), + } +} + +// Equal reports whether the two envelopes are the same envelope: the same +// bytes under the same scheme, stored by the same user. +func (e KeyEnvelope) Equal(other KeyEnvelope) bool { + return e.Scheme == other.Scheme && + bytes.Equal(e.Nonce, other.Nonce) && + bytes.Equal(e.Ciphertext, other.Ciphertext) && + bytes.Equal(e.WrappedBy.GetValue(), other.WrappedBy.GetValue()) +} + +// IsWrappedBy reports whether userID stored the envelope. +func (e KeyEnvelope) IsWrappedBy(userID *commonpb.UserId) bool { + return e.WrappedBy != nil && bytes.Equal(e.WrappedBy.Value, userID.GetValue()) +} + +// Clone returns a deep copy of the envelope. +func (e KeyEnvelope) Clone() KeyEnvelope { + clone := KeyEnvelope{ + Scheme: e.Scheme, + Nonce: bytes.Clone(e.Nonce), + Ciphertext: bytes.Clone(e.Ciphertext), + } + if e.WrappedBy != nil { + clone.WrappedBy = &commonpb.UserId{Value: bytes.Clone(e.WrappedBy.Value)} + } + return clone +} + // ErrMuteUntilOutOfRange indicates that a timed mute ends outside the range a // store can record (see Mute.Until). var ErrMuteUntilOutOfRange = errors.New("mute until is out of range") diff --git a/chat/store.go b/chat/store.go index 2ed74af..b2d0295 100644 --- a/chat/store.go +++ b/chat/store.go @@ -26,6 +26,10 @@ var ( // A chat nobody belongs to is unreachable: no one can read it, send to it, // or be added to it, since every such path gates on membership. ErrNoMembers = errors.New("chat must have at least one member") + + // ErrKeyEnvelopeNotFound indicates that a user has no key envelope stored + // for a chat. + ErrKeyEnvelopeNotFound = errors.New("key envelope not found") ) // MaxGroupChatCreationMembers is the largest initial member set a group chat @@ -89,6 +93,15 @@ type DmFeedCursor struct { // aggregate alongside the record — and moves Version by exactly one on a real // change and not at all on a no-op, so a retried or duplicated request is // harmless. +// +// A key envelope (see KeyEnvelope) is likewise one record per (user, chat), +// for a private group: the group's chat key as that user can open it. It is +// written against the IDs alone, like viewer state, and knows nothing of the +// group or its roster: that the chat is a private group, and that the user is +// a member, are the caller's gates. The one tie to the roster is a departure, +// which removes the envelope in the same write when the caller asks it to +// (see RemoveGroupMember). A store of one is one conditional write of the one +// record, so a retried or duplicated request is harmless. type Store interface { // PutChat persists a new chat and its membership. It returns ErrChatExists // if a chat with the same ID already exists, ErrNoMembers if the member set @@ -134,7 +147,15 @@ type Store interface { // the record; changed and roster are as for AddGroupMembers. It returns // ErrChatNotFound if the chat does not exist, and an error if chatID is not // a group chat ID. - RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (changed bool, roster RosterSummary, err error) + // + // With discardKeyEnvelope, a departure that actually happens also removes + // the user's key envelope for the chat (see KeyEnvelope), atomically with + // it: the user stops being a member and stops holding an envelope + // together, or neither. A no-op removes nothing, the envelope included. + // Whether a departure takes the envelope is the caller's to say, since + // the store knows neither that a group is private nor who created it (see + // Server.LeaveChat). + RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, discardKeyEnvelope bool) (changed bool, roster RosterSummary, err error) // SetGroupPicture sets a group chat's picture to the blob holding its // ORIGINAL rendition, replacing any picture already set; a nil blobID clears @@ -422,4 +443,25 @@ type Store interface { // exist, is an empty result. It returns an error if chatID is not a group // chat ID. GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]RecentSender, error) + + // SetKeyEnvelope stores envelope as userID's key envelope for the group + // chatID, and returns the envelope that stands after the call. An + // envelope the user wrapped themself (KeyEnvelope.IsWrappedBy userID) is + // never replaced: when one is stored, nothing is written and it is + // returned, whatever the call carried. Otherwise the call's envelope is + // stored, over none or over one someone else wrapped for the user, and + // returned. So the first envelope a user stores for themself stands, a + // repeat of the stored envelope is the no-op it looks like, and a caller + // learns whether its envelope is the one stored by comparing it with the + // one returned (KeyEnvelope.Equal). The check and the write are one + // conditional write, so two concurrent calls agree on which envelope + // stands. It returns an error if chatID is not a group chat ID, or if + // envelope.WrappedBy is nil. + SetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope KeyEnvelope) (KeyEnvelope, error) + + // GetKeyEnvelope returns userID's key envelope for chatID, or + // ErrKeyEnvelopeNotFound when they have none. The read is strongly + // consistent: it reflects every write that completed before it, so a + // user who just stored an envelope, or was just given one, reads it. + GetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (KeyEnvelope, error) } diff --git a/chat/tests/server.go b/chat/tests/server.go index 6699768..bec69c3 100644 --- a/chat/tests/server.go +++ b/chat/tests/server.go @@ -112,6 +112,9 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_StartChat_PrivateGroup, testServer_PrivateGroup_Visibility, testServer_PrivateGroup_Membership, + testServer_KeyEnvelope_Gates, + testServer_KeyEnvelope_Creator, + testServer_KeyEnvelope_Member, testServer_StartChat_InvalidRules, testServer_StartChat_RulesNotSatisfied, testServer_StartChat_WithRules, @@ -1047,7 +1050,7 @@ func testServer_GetMentionSuggestions(t *testing.T, s chat.Store) { e.recordSend(groupID, blocker, at(45)) e.recordSend(groupID, nameless, at(50)) e.recordSend(groupID, e.userID, at(60)) - _, _, err := s.RemoveGroupMember(e.ctx, groupID, departed) + _, _, err := s.RemoveGroupMember(e.ctx, groupID, departed, false) require.NoError(t, err) // Most recent first; someone who left is still suggested, and so is @@ -1684,7 +1687,7 @@ func testServer_GetChat_Group_MembershipLifecycle(t *testing.T, s chat.Store) { // A removed member is a non-member — the tombstone is not membership — and // is shown the group as one... - _, _, err := s.RemoveGroupMember(e.ctx, chatID, e.userID) + _, _, err := s.RemoveGroupMember(e.ctx, chatID, e.userID, false) require.NoError(t, err) resp = e.getChat(e.keys, chatID) require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) @@ -2328,7 +2331,7 @@ func testServer_GetGroupChatFeed_DropsDepartedBetweenPages(t *testing.T, s chat. require.Equal(t, [][]byte{g3.Value}, metadataChatIDs(page1.Chats)) require.True(t, page1.HasMore) - _, _, err := s.RemoveGroupMember(e.ctx, g2, e.userID) + _, _, err := s.RemoveGroupMember(e.ctx, g2, e.userID, false) require.NoError(t, err) page2 := e.mustGetGroupFeed(&commonpb.QueryOptions{PageSize: 10, PagingToken: page1.PagingToken}) @@ -2894,6 +2897,188 @@ func (e *serverEnv) mustStartGroupChatWithKey(keys model.KeyPair, key *chatpb.Id return resp } +// keyEnvelopeProto builds a key envelope as a client sends one. The server +// never opens it, so any bytes of the right length stand in for a wrapped key. +func keyEnvelopeProto(fill byte) *chatpb.KeyEnvelope { + return &chatpb.KeyEnvelope{ + Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, + Nonce: bytes.Repeat([]byte{fill}, 24), + Ciphertext: bytes.Repeat([]byte{fill}, 48), + } +} + +func (e *serverEnv) setKeyEnvelope(keys model.KeyPair, chatID *commonpb.ChatId, envelope *chatpb.KeyEnvelope) (*chatpb.SetKeyEnvelopeResponse, error) { + req := &chatpb.SetKeyEnvelopeRequest{ChatId: chatID, KeyEnvelope: envelope} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + return e.client.SetKeyEnvelope(e.ctx, req) +} + +func (e *serverEnv) mustSetKeyEnvelope(keys model.KeyPair, chatID *commonpb.ChatId, envelope *chatpb.KeyEnvelope) *chatpb.SetKeyEnvelopeResponse { + resp, err := e.setKeyEnvelope(keys, chatID, envelope) + require.NoError(e.t, err) + return resp +} + +func (e *serverEnv) mustGetKeyEnvelope(keys model.KeyPair, chatID *commonpb.ChatId) *chatpb.GetKeyEnvelopeResponse { + req := &chatpb.GetKeyEnvelopeRequest{ChatId: chatID} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.GetKeyEnvelope(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +// putPrivateGroup persists a private group created by the env user, whose +// members are the env user and the given others. +func (e *serverEnv) putPrivateGroup(title string, others ...*commonpb.UserId) *commonpb.ChatId { + chatID := chat.MustGenerateGroupChatID() + require.NoError(e.t, e.store.PutChat(e.ctx, &chat.Chat{ + ID: chatID, + Type: chatpb.ChatType_GROUP, + Members: append([]*commonpb.UserId{e.userID}, others...), + Title: title, + IsPrivate: true, + CreatorID: e.userID, + LastActivity: at(1), + })) + return chatID +} + +// testServer_KeyEnvelope_Gates pins who may store and fetch a key envelope: +// a member of a private group, and no one else of anything else. +func testServer_KeyEnvelope_Gates(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + _, strangerKeys := e.addUser() + + requireBoth := func(keys model.KeyPair, chatID *commonpb.ChatId, set chatpb.SetKeyEnvelopeResponse_Result, get chatpb.GetKeyEnvelopeResponse_Result) { + t.Helper() + require.Equal(t, set, e.mustSetKeyEnvelope(keys, chatID, keyEnvelopeProto(1)).Result) + got := e.mustGetKeyEnvelope(keys, chatID) + require.Equal(t, get, got.Result) + require.Nil(t, got.KeyEnvelope) + require.Nil(t, got.WrappedBy) + } + + // A DM has no key envelopes, for its members or anyone. + dm := e.putDM(at(1)) + requireBoth(e.keys, dm, chatpb.SetKeyEnvelopeResponse_DENIED, chatpb.GetKeyEnvelopeResponse_DENIED) + requireBoth(strangerKeys, dm, chatpb.SetKeyEnvelopeResponse_DENIED, chatpb.GetKeyEnvelopeResponse_DENIED) + + // A group that does not exist. + requireBoth(e.keys, chat.MustGenerateGroupChatID(), chatpb.SetKeyEnvelopeResponse_NOT_FOUND, chatpb.GetKeyEnvelopeResponse_NOT_FOUND) + + // Nor has a public group, for its members. + public := e.putGroup("Public", at(1)) + requireBoth(e.keys, public, chatpb.SetKeyEnvelopeResponse_DENIED, chatpb.GetKeyEnvelopeResponse_DENIED) + + // A private group's are its members' alone, and nothing was stored for + // anyone refused. + private := e.putPrivateGroup("Private") + requireBoth(strangerKeys, private, chatpb.SetKeyEnvelopeResponse_DENIED, chatpb.GetKeyEnvelopeResponse_DENIED) + require.Equal(t, chatpb.GetKeyEnvelopeResponse_NO_ENVELOPE, e.mustGetKeyEnvelope(e.keys, private).Result) + _, err := s.GetKeyEnvelope(e.ctx, public, e.userID) + require.ErrorIs(t, err, chat.ErrKeyEnvelopeNotFound) + + // An envelope of the wrong shape never reaches the store. + for _, malformed := range []*chatpb.KeyEnvelope{ + nil, + {Scheme: chatpb.KeyEnvelope_UNKNOWN, Nonce: bytes.Repeat([]byte{1}, 24), Ciphertext: bytes.Repeat([]byte{1}, 48)}, + {Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, Nonce: bytes.Repeat([]byte{1}, 23), Ciphertext: bytes.Repeat([]byte{1}, 48)}, + {Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, Nonce: bytes.Repeat([]byte{1}, 24), Ciphertext: bytes.Repeat([]byte{1}, 49)}, + } { + _, err := e.setKeyEnvelope(e.keys, private, malformed) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + } + require.Equal(t, chatpb.GetKeyEnvelopeResponse_NO_ENVELOPE, e.mustGetKeyEnvelope(e.keys, private).Result) +} + +// testServer_KeyEnvelope_Creator pins the second step of creating a private +// group: its creator stores the chat key's envelope, the first one stored +// stands whatever a later call carries, and it is kept when they leave. +func testServer_KeyEnvelope_Creator(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + e.accounts.setStaff(e.userID, true) + + created := e.mustStartPrivateGroupChat(e.keys, newIdempotencyKey(), privateGroupParams("Sunday Hikers")) + require.Equal(t, chatpb.StartChatResponse_OK, created.Result) + chatID := created.Chat.ChatId + + // A new group has no key. + require.Equal(t, chatpb.GetKeyEnvelopeResponse_NO_ENVELOPE, e.mustGetKeyEnvelope(e.keys, chatID).Result) + + // The creator stores it, and reads it back as their own. + require.Equal(t, chatpb.SetKeyEnvelopeResponse_OK, e.mustSetKeyEnvelope(e.keys, chatID, keyEnvelopeProto(1)).Result) + requireStored := func() { + t.Helper() + got := e.mustGetKeyEnvelope(e.keys, chatID) + require.Equal(t, chatpb.GetKeyEnvelopeResponse_OK, got.Result) + require.NoError(t, protoutil.ProtoEqualError(keyEnvelopeProto(1), got.KeyEnvelope)) + require.Equal(t, e.userID.Value, got.WrappedBy.GetValue()) + } + requireStored() + + // A retry of the same envelope is OK; a different one, as a second device + // setting up the same group would send, is ALREADY_SET and changes + // nothing. + require.Equal(t, chatpb.SetKeyEnvelopeResponse_OK, e.mustSetKeyEnvelope(e.keys, chatID, keyEnvelopeProto(1)).Result) + require.Equal(t, chatpb.SetKeyEnvelopeResponse_ALREADY_SET, e.mustSetKeyEnvelope(e.keys, chatID, keyEnvelopeProto(2)).Result) + requireStored() + + // Leaving keeps the creator's envelope, though only a member reads it: + // it is there again when they rejoin. + require.Equal(t, chatpb.LeaveChatResponse_OK, e.mustLeaveChat(e.keys, chatID).Result) + require.Equal(t, chatpb.GetKeyEnvelopeResponse_DENIED, e.mustGetKeyEnvelope(e.keys, chatID).Result) + require.Equal(t, chatpb.SetKeyEnvelopeResponse_DENIED, e.mustSetKeyEnvelope(e.keys, chatID, keyEnvelopeProto(3)).Result) + _, err := s.GetKeyEnvelope(e.ctx, chatID, e.userID) + require.NoError(t, err) + require.Equal(t, chatpb.JoinChatResponse_OK, e.mustJoinChat(e.keys, chatID).Result) + requireStored() +} + +// testServer_KeyEnvelope_Member pins a member's envelope: the one the creator +// wrapped for them is read back as the creator's, they replace it once with +// one of their own, and it is discarded when they leave. +func testServer_KeyEnvelope_Member(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + + // No RPC admits a member to a private group yet, so the membership and + // the envelope the creator would have wrapped are written by hand. + memberID, memberKeys := e.addUser() + chatID := e.putPrivateGroup("Sunday Hikers", memberID) + require.Equal(t, chatpb.SetKeyEnvelopeResponse_OK, e.mustSetKeyEnvelope(e.keys, chatID, keyEnvelopeProto(1)).Result) + _, err := s.SetKeyEnvelope(e.ctx, chatID, memberID, chat.KeyEnvelopeFromProto(keyEnvelopeProto(2), e.userID)) + require.NoError(t, err) + + got := e.mustGetKeyEnvelope(memberKeys, chatID) + require.Equal(t, chatpb.GetKeyEnvelopeResponse_OK, got.Result) + require.NoError(t, protoutil.ProtoEqualError(keyEnvelopeProto(2), got.KeyEnvelope)) + require.Equal(t, e.userID.Value, got.WrappedBy.GetValue()) + + // The member wraps the key for themself, which replaces the creator's + // envelope and then stands. + require.Equal(t, chatpb.SetKeyEnvelopeResponse_OK, e.mustSetKeyEnvelope(memberKeys, chatID, keyEnvelopeProto(3)).Result) + require.Equal(t, chatpb.SetKeyEnvelopeResponse_ALREADY_SET, e.mustSetKeyEnvelope(memberKeys, chatID, keyEnvelopeProto(4)).Result) + got = e.mustGetKeyEnvelope(memberKeys, chatID) + require.Equal(t, chatpb.GetKeyEnvelopeResponse_OK, got.Result) + require.NoError(t, protoutil.ProtoEqualError(keyEnvelopeProto(3), got.KeyEnvelope)) + require.Equal(t, memberID.Value, got.WrappedBy.GetValue()) + + // Each member's envelope is their own: the creator's is untouched. + got = e.mustGetKeyEnvelope(e.keys, chatID) + require.NoError(t, protoutil.ProtoEqualError(keyEnvelopeProto(1), got.KeyEnvelope)) + + // Leaving discards the member's envelope and no one else's. + require.Equal(t, chatpb.LeaveChatResponse_OK, e.mustLeaveChat(memberKeys, chatID).Result) + _, err = s.GetKeyEnvelope(e.ctx, chatID, memberID) + require.ErrorIs(t, err, chat.ErrKeyEnvelopeNotFound) + _, err = s.GetKeyEnvelope(e.ctx, chatID, e.userID) + require.NoError(t, err) + require.Equal(t, chatpb.GetKeyEnvelopeResponse_DENIED, e.mustGetKeyEnvelope(memberKeys, chatID).Result) + + // A leave of a public group is unaffected. + public := e.putGroup("Public", at(1), memberID) + require.Equal(t, chatpb.LeaveChatResponse_OK, e.mustLeaveChat(memberKeys, public).Result) +} + // mustStartPrivateGroupChat starts a private group under the given // idempotency key. func (e *serverEnv) mustStartPrivateGroupChat(keys model.KeyPair, key *chatpb.IdempotencyKey, params *chatpb.StartChatRequest_PrivateGroupChatParameters) *chatpb.StartChatResponse { diff --git a/chat/tests/store.go b/chat/tests/store.go index 7112271..9f326c1 100644 --- a/chat/tests/store.go +++ b/chat/tests/store.go @@ -84,6 +84,10 @@ func RunStoreTests(t *testing.T, s chat.Store, newStore func(excludedFromFeed [] testStore_Activity_Precision, testStore_Activity_Scope, testStore_Activity_Concurrent, + testStore_KeyEnvelope_SetAndGet, + testStore_KeyEnvelope_OwnWrapStands, + testStore_KeyEnvelope_DiscardedOnLeave, + testStore_KeyEnvelope_Concurrent, } { tf(t, s) teardown() @@ -1066,7 +1070,7 @@ func addGroupMembers(t *testing.T, s chat.Store, chatID *commonpb.ChatId, userID func removeGroupMember(t *testing.T, s chat.Store, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, chat.RosterSummary) { t.Helper() - changed, roster, err := s.RemoveGroupMember(context.Background(), chatID, userID) + changed, roster, err := s.RemoveGroupMember(context.Background(), chatID, userID, false) require.NoError(t, err) return changed, roster } @@ -1290,7 +1294,7 @@ func testStore_GroupChat_MembersPage(t *testing.T, s chat.Store) { // A departed member sits between the others in key order and must be // stepped over, not counted against the limit. - changed, _, err := s.RemoveGroupMember(ctx, c.ID, users[2]) + changed, _, err := s.RemoveGroupMember(ctx, c.ID, users[2], false) require.NoError(t, err) require.True(t, changed) joined := []*commonpb.UserId{users[0], users[1], users[3], users[4]} @@ -1678,14 +1682,14 @@ func testStore_GroupChat_AddMembersErrors(t *testing.T, s chat.Store) { // orphaned records, and report the chat missing rather than a no-op. _, _, err := s.AddGroupMembers(ctx, chat.MustGenerateGroupChatID(), []*commonpb.UserId{user}) require.ErrorIs(t, err, chat.ErrChatNotFound) - _, _, err = s.RemoveGroupMember(ctx, chat.MustGenerateGroupChatID(), user) + _, _, err = s.RemoveGroupMember(ctx, chat.MustGenerateGroupChatID(), user, false) require.ErrorIs(t, err, chat.ErrChatNotFound) // Group membership methods reject DM chat IDs outright. dm := putDmChat(t, s, user, model.MustGenerateUserID(), at(1)) _, _, err = s.AddGroupMembers(ctx, dm.ID, []*commonpb.UserId{user}) require.Error(t, err) - _, _, err = s.RemoveGroupMember(ctx, dm.ID, user) + _, _, err = s.RemoveGroupMember(ctx, dm.ID, user, false) require.Error(t, err) // An unknown group chat has no members, as opposed to an empty set. @@ -2571,3 +2575,213 @@ func requireRecentSenders(t *testing.T, senders []chat.RecentSender, want ...any require.True(t, want[2*i+1].(time.Time).Equal(sender.LastSentAt), "sender %d sent at %v, want %v", i, sender.LastSentAt, want[2*i+1]) } } + +// keyEnvelope builds a key envelope stored by wrappedBy. The store never +// opens one, so any bytes of the right length stand in for a wrapped key. +func keyEnvelope(fill byte, wrappedBy *commonpb.UserId) chat.KeyEnvelope { + return chat.KeyEnvelope{ + Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, + Nonce: bytes.Repeat([]byte{fill}, 24), + Ciphertext: bytes.Repeat([]byte{fill}, 48), + WrappedBy: wrappedBy, + } +} + +func testStore_KeyEnvelope_SetAndGet(t *testing.T, s chat.Store) { + ctx := context.Background() + + // Stored against the IDs alone: no canonical record or membership is + // read, so neither a group nobody created nor a non-member stops it. + groupID := chat.MustGenerateGroupChatID() + user := model.MustGenerateUserID() + + _, err := s.GetKeyEnvelope(ctx, groupID, user) + require.ErrorIs(t, err, chat.ErrKeyEnvelopeNotFound) + + // The first envelope a user stores for themself is stored as given, and + // read back exactly. + first := keyEnvelope(1, user) + stored, err := s.SetKeyEnvelope(ctx, groupID, user, first) + require.NoError(t, err) + require.True(t, first.Equal(stored)) + got, err := s.GetKeyEnvelope(ctx, groupID, user) + require.NoError(t, err) + require.True(t, first.Equal(got)) + require.Equal(t, chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, got.Scheme) + require.Equal(t, user.Value, got.WrappedBy.Value) + + // Storing it again is a no-op that returns it. + stored, err = s.SetKeyEnvelope(ctx, groupID, user, first) + require.NoError(t, err) + require.True(t, first.Equal(stored)) + + // An envelope belongs to one user on one chat. + _, err = s.GetKeyEnvelope(ctx, groupID, model.MustGenerateUserID()) + require.ErrorIs(t, err, chat.ErrKeyEnvelopeNotFound) + _, err = s.GetKeyEnvelope(ctx, chat.MustGenerateGroupChatID(), user) + require.ErrorIs(t, err, chat.ErrKeyEnvelopeNotFound) + otherGroup := chat.MustGenerateGroupChatID() + second := keyEnvelope(2, user) + stored, err = s.SetKeyEnvelope(ctx, otherGroup, user, second) + require.NoError(t, err) + require.True(t, second.Equal(stored)) + got, err = s.GetKeyEnvelope(ctx, groupID, user) + require.NoError(t, err) + require.True(t, first.Equal(got)) + + // Groups only, and an envelope names who stored it. + _, err = s.SetKeyEnvelope(ctx, generateDmChatID(), user, first) + require.Error(t, err) + _, err = s.SetKeyEnvelope(ctx, groupID, user, keyEnvelope(3, nil)) + require.Error(t, err) +} + +// testStore_KeyEnvelope_OwnWrapStands pins which envelope stands (see +// chat.Store.SetKeyEnvelope): one the user wrapped themself is never +// replaced, and one someone else wrapped for them always is. +func testStore_KeyEnvelope_OwnWrapStands(t *testing.T, s chat.Store) { + ctx := context.Background() + + groupID := chat.MustGenerateGroupChatID() + creator := model.MustGenerateUserID() + member := model.MustGenerateUserID() + + // A different envelope of the user's own changes nothing, and the stored + // one is returned. + first := keyEnvelope(1, creator) + _, err := s.SetKeyEnvelope(ctx, groupID, creator, first) + require.NoError(t, err) + stored, err := s.SetKeyEnvelope(ctx, groupID, creator, keyEnvelope(2, creator)) + require.NoError(t, err) + require.True(t, first.Equal(stored)) + got, err := s.GetKeyEnvelope(ctx, groupID, creator) + require.NoError(t, err) + require.True(t, first.Equal(got)) + + // An envelope the creator wrapped for a member is stored, and replaced + // by a later one from the creator... + admitted := keyEnvelope(3, creator) + stored, err = s.SetKeyEnvelope(ctx, groupID, member, admitted) + require.NoError(t, err) + require.True(t, admitted.Equal(stored)) + readmitted := keyEnvelope(4, creator) + stored, err = s.SetKeyEnvelope(ctx, groupID, member, readmitted) + require.NoError(t, err) + require.True(t, readmitted.Equal(stored)) + + // ...and by the member's own, which then stands against both. + rewrapped := keyEnvelope(5, member) + stored, err = s.SetKeyEnvelope(ctx, groupID, member, rewrapped) + require.NoError(t, err) + require.True(t, rewrapped.Equal(stored)) + for _, later := range []chat.KeyEnvelope{keyEnvelope(6, creator), keyEnvelope(7, member)} { + stored, err = s.SetKeyEnvelope(ctx, groupID, member, later) + require.NoError(t, err) + require.True(t, rewrapped.Equal(stored)) + } + got, err = s.GetKeyEnvelope(ctx, groupID, member) + require.NoError(t, err) + require.True(t, rewrapped.Equal(got)) + require.Equal(t, member.Value, got.WrappedBy.Value) + + // The same bytes stored by someone else are not the same envelope. + require.False(t, rewrapped.Equal(keyEnvelope(5, creator))) +} + +// testStore_KeyEnvelope_DiscardedOnLeave pins that a departure removes the +// departing user's envelope exactly when the caller asks, and only when the +// departure actually happens (see chat.Store.RemoveGroupMember). +func testStore_KeyEnvelope_DiscardedOnLeave(t *testing.T, s chat.Store) { + ctx := context.Background() + + creator := model.MustGenerateUserID() + member := model.MustGenerateUserID() + stranger := model.MustGenerateUserID() + c := putGroupChat(t, s, "Private", at(100), creator, member) + + creatorEnvelope := keyEnvelope(1, creator) + _, err := s.SetKeyEnvelope(ctx, c.ID, creator, creatorEnvelope) + require.NoError(t, err) + _, err = s.SetKeyEnvelope(ctx, c.ID, member, keyEnvelope(2, member)) + require.NoError(t, err) + strangerEnvelope := keyEnvelope(3, stranger) + _, err = s.SetKeyEnvelope(ctx, c.ID, stranger, strangerEnvelope) + require.NoError(t, err) + + // A departure asked to discard takes the envelope with it, and no one + // else's. + changed, _, err := s.RemoveGroupMember(ctx, c.ID, member, true) + require.NoError(t, err) + require.True(t, changed) + _, err = s.GetKeyEnvelope(ctx, c.ID, member) + require.ErrorIs(t, err, chat.ErrKeyEnvelopeNotFound) + got, err := s.GetKeyEnvelope(ctx, c.ID, creator) + require.NoError(t, err) + require.True(t, creatorEnvelope.Equal(got)) + + // Nothing stands once it is gone: a returning member's next envelope is + // stored. + _, _, err = s.AddGroupMembers(ctx, c.ID, []*commonpb.UserId{member}) + require.NoError(t, err) + next := keyEnvelope(4, creator) + stored, err := s.SetKeyEnvelope(ctx, c.ID, member, next) + require.NoError(t, err) + require.True(t, next.Equal(stored)) + + // A departure not asked to discard keeps it, as the creator's is kept. + changed, _, err = s.RemoveGroupMember(ctx, c.ID, creator, false) + require.NoError(t, err) + require.True(t, changed) + got, err = s.GetKeyEnvelope(ctx, c.ID, creator) + require.NoError(t, err) + require.True(t, creatorEnvelope.Equal(got)) + + // A user with no envelope leaves all the same. + changed, _, err = s.RemoveGroupMember(ctx, c.ID, member, true) + require.NoError(t, err) + require.True(t, changed) + changed, _, err = s.AddGroupMembers(ctx, c.ID, []*commonpb.UserId{member}) + require.NoError(t, err) + require.True(t, changed) + changed, _, err = s.RemoveGroupMember(ctx, c.ID, member, true) + require.NoError(t, err) + require.True(t, changed) + + // A no-op removes nothing: the envelope goes with a departure, not with + // the request for one. + changed, _, err = s.RemoveGroupMember(ctx, c.ID, stranger, true) + require.NoError(t, err) + require.False(t, changed) + got, err = s.GetKeyEnvelope(ctx, c.ID, stranger) + require.NoError(t, err) + require.True(t, strangerEnvelope.Equal(got)) +} + +// testStore_KeyEnvelope_Concurrent pins that concurrent first writes agree: +// exactly one envelope is stored, and every caller is told which. +func testStore_KeyEnvelope_Concurrent(t *testing.T, s chat.Store) { + ctx := context.Background() + + groupID := chat.MustGenerateGroupChatID() + user := model.MustGenerateUserID() + + const writers = 8 + results := make([]chat.KeyEnvelope, writers) + errs := make([]error, writers) + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + results[i], errs[i] = s.SetKeyEnvelope(ctx, groupID, user, keyEnvelope(byte(i+1), user)) + }() + } + wg.Wait() + + got, err := s.GetKeyEnvelope(ctx, groupID, user) + require.NoError(t, err) + for i := 0; i < writers; i++ { + require.NoError(t, errs[i]) + require.True(t, got.Equal(results[i])) + } +} diff --git a/event/tests/server.go b/event/tests/server.go index d0ca982..dc67620 100644 --- a/event/tests/server.go +++ b/event/tests/server.go @@ -585,7 +585,9 @@ func testMembershipFollowsStreams(t *testing.T, accounts account.Store) { func() (bool, chat.RosterSummary, error) { return testEnv.chats.AddGroupMembers(ctx, group, []*commonpb.UserId{userA}) }, - func() (bool, chat.RosterSummary, error) { return testEnv.chats.RemoveGroupMember(ctx, group, userA) }, + func() (bool, chat.RosterSummary, error) { + return testEnv.chats.RemoveGroupMember(ctx, group, userA, false) + }, func() (bool, chat.RosterSummary, error) { return testEnv.chats.AddGroupMembers(ctx, group, []*commonpb.UserId{userA}) }, @@ -712,7 +714,7 @@ func testMembershipReconciles(t *testing.T, accounts account.Store) { // streams off the topic, server1's row goes, and a chat publish from // either side no longer reaches them — their next event is the user event // that follows. - changed, _, err = testEnv.chats.RemoveGroupMember(ctx, group, userA) + changed, _, err = testEnv.chats.RemoveGroupMember(ctx, group, userA, false) require.NoError(t, err) require.True(t, changed) waitForChatSubscribers(map[string]bool{self(testEnv.server2): true}) diff --git a/messaging/dynamodb/server_test.go b/messaging/dynamodb/server_test.go index d08f206..2a4fca2 100644 --- a/messaging/dynamodb/server_test.go +++ b/messaging/dynamodb/server_test.go @@ -21,19 +21,20 @@ const ( groupMembersTable = "group_members_test" userStateTable = "chat_user_state_test" activityTable = "chat_activity_test" + keyEnvelopesTable = "chat_key_envelopes_test" badgesTable = "badges_test" ) func TestMessaging_DynamoDBServer(t *testing.T) { ctx := context.Background() - require.NoError(t, chat_dynamodb.CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable)) + require.NoError(t, chat_dynamodb.CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) require.NoError(t, CreateTables(ctx, testEnv.Client, messagesTable, pointersTable, reactionsTable, reactorsTable, selfReactionsTable)) require.NoError(t, badge_dynamodb.CreateTables(ctx, testEnv.Client, badgesTable)) badges := badge_dynamodb.NewInDynamoDB(testEnv.Client, badgesTable) blocklists := blocklist_memory.NewInMemory() - chats := chat_dynamodb.NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, nil) + chats := chat_dynamodb.NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) profiles := profile_memory.NewInMemory() messages := NewInDynamoDB(testEnv.Client, messagesTable, pointersTable, reactionsTable, reactorsTable, selfReactionsTable) teardown := func() { diff --git a/messaging/tests/server.go b/messaging/tests/server.go index f9008fd..d6705e4 100644 --- a/messaging/tests/server.go +++ b/messaging/tests/server.go @@ -3344,9 +3344,9 @@ func testServer_StaffOnlyGroup_Rules(t *testing.T, badges badge.Store, blocklist } // testServer_PrivateGroup_NoOneSpeaks pins that nothing is sent in a private -// group, whose key cannot be stored yet: a member's send is DENIED whatever it -// carries, encrypted content included, as is their typing notification, and -// nothing is written. +// group, before its key is stored or after: a member's send is DENIED whatever +// it carries, encrypted content included, as is their typing notification, +// and nothing is written. func testServer_PrivateGroup_NoOneSpeaks(t *testing.T, badges badge.Store, blocklists blocklist.Store, chats chat.Store, messages messaging.Store, profiles profile.Store) { e := newServerEnv(t, badges, blocklists, chats, messages, profiles) @@ -3361,16 +3361,31 @@ func testServer_PrivateGroup_NoOneSpeaks(t *testing.T, badges badge.Store, block LastActivity: at(1), })) - for _, content := range [][]*messagingpb.Content{ - textContent("plaintext"), - encryptedContent(1), - chatKeyEncryptedContent(1), - } { - resp, err := e.sendContentToChat(e.keysA, groupID, content, generateClientID()) - require.NoError(t, err) - require.Equal(t, messagingpb.SendMessageResponse_DENIED, resp.Result) - require.Nil(t, resp.Message) + requireNoSends := func() { + t.Helper() + for _, content := range [][]*messagingpb.Content{ + textContent("plaintext"), + encryptedContent(1), + chatKeyEncryptedContent(1), + } { + resp, err := e.sendContentToChat(e.keysA, groupID, content, generateClientID()) + require.NoError(t, err) + require.Equal(t, messagingpb.SendMessageResponse_DENIED, resp.Result) + require.Nil(t, resp.Message) + } } + requireNoSends() + + // Storing the group's key lifts nothing yet: speaking in a private group + // is not built, and a keyed group must not take plaintext meanwhile. + _, err := chats.SetKeyEnvelope(e.ctx, groupID, e.userA, chat.KeyEnvelope{ + Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, + Nonce: bytes.Repeat([]byte{1}, 24), + Ciphertext: bytes.Repeat([]byte{1}, 48), + WrappedBy: e.userA, + }) + require.NoError(t, err) + requireNoSends() typingResp, err := e.notifyIsTypingInChat(e.keysA, groupID, messagingpb.IsTypingNotification_STARTED_TYPING) require.NoError(t, err) @@ -3926,7 +3941,7 @@ func testServer_SendMessage_GroupPushPaged(t *testing.T, badges badge.Store, blo // recipients. _, _, err = chats.SetMute(e.ctx, groupID, departed, chat.Mute{Forever: true}) require.NoError(t, err) - changed, _, err := chats.RemoveGroupMember(e.ctx, groupID, departed) + changed, _, err := chats.RemoveGroupMember(e.ctx, groupID, departed, false) require.NoError(t, err) require.True(t, changed) _, _, err = chats.SetMute(e.ctx, groupID, model.MustGenerateUserID(), chat.Mute{Forever: true}) From a9b3749abeee7e2cbe9982b22a15ef9b61588391 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Mon, 5 Oct 2026 09:44:20 -0400 Subject: [PATCH 4/6] Implement messaging in private groups --- CLAUDE.md | 14 +-- blob/encrypted.go | 48 ++++---- blob/model.go | 2 +- blob/server.go | 44 +++---- blob/tests/server.go | 72 +++++++++--- chat/access.go | 237 ++++++++++++++++++++++++++++---------- chat/access_test.go | 211 +++++++++++++++++++++++++-------- chat/blob.go | 54 ++++++--- chat/blob_test.go | 73 ++++++++++++ chat/create.go | 8 +- chat/edit.go | 2 +- chat/feed.go | 4 +- chat/key.go | 8 +- chat/member.go | 2 +- chat/model.go | 12 +- chat/roster.go | 2 +- chat/rules_test.go | 33 +++++- chat/server.go | 18 +-- event/chat_preview.go | 8 +- messaging/access.go | 37 ++++-- messaging/message.go | 80 +++++++++---- messaging/tests/server.go | 183 ++++++++++++++++++++++++----- push/pushes.go | 21 +++- push/pushes_test.go | 32 ++++- 24 files changed, 908 insertions(+), 297 deletions(-) create mode 100644 chat/blob_test.go diff --git a/CLAUDE.md b/CLAUDE.md index af639a6..8c65af5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ Each package represents a bounded context with clear responsibilities: - `activity/` - Activity feed for payments, deposits, withdrawals, gift cards - `chat/` - Group/DM chat metadata, membership, feeds (DynamoDB-backed). See "Chat, messaging and events" below. - `messaging/` - Message persistence, delivery/read pointers, reactions, typing notifications (DynamoDB-backed); `sender.go` is the transport-free engine for server-initiated messages (used by `task/` and `flipcashteam/`; `SendBatch` sends several atomically over `PutMessages`, one broadcast, a push per message; `Send` is a batch of one; `NewSender`'s required `teamUserID` (nil for none) makes the team account write-only: its sends don't advance its READ pointer, its DMs are created with it excluded from the feed (the chat store is built excluding the team) so no send moves its inbox row, and `publishChatUpdate` never delivers to it, neither events nor pushes, with a user's message to it skipping push preparation entirely; the push path drops it only after deriving the DM type from the full pair; and nobody messages it: a DM with it carries a `Never` speaker rule (see `chat.RuleEvaluator` under "Rules"), so `Server.canSpeak` refuses every send, edit, deletion and typing notification in one, whoever asks, the team included, and only the `Sender`, which no rule gates, writes there; DM payments to or from it are refused at intent validation, so no payment message lands either, and reactions, which gate on `isMember`, are unaffected) -- `blob/` - User-uploaded media (images only today). Two S3 buckets (upload → origin) plus CloudFront signed download URLs; the server never proxies bytes. Flow: `GetUploadPolicy` → `InitiateExternalUpload` → client uploads → `CompleteExternalUpload` (queues finalization, returns PROCESSING) → poll `GetBlobs`. `finalizer.go` owns the checkpointed pipeline (inspect → moderate → copy → WebP renditions). Reads: owner always; others need an `AccessContext` whose principal (User, Chat, UserProfile, ChatProfile) holds a grant *and* covers the caller (`access.go`). `image.go` rejects EXIF/privacy metadata. `integration.go` (`ShareIntoChat`, `SetAsProfilePicture`, `SetAsChatPicture`, `ResolveRenditions`) is the API other domains use. **End-to-end encrypted blobs** (`encrypted.go`; `Blob.EncryptedFor` is a `*Principal` — the surface the READY grant is made to, `PrincipalForChat` for a DM — so the pipeline, stores (`encrypted_for_type` N + `encrypted_for_id` B) and read paths are surface-agnostic and only `initiateEncryptedUpload` maps a proto arm to a principal and its admission gate; `ContentKindEncrypted`, its own finalization queue and so its own `Worker` instance in the parent) are reserved with `InitiateExternalUploadRequest.end_to_end_encrypted_for.chat`: mime must be `application/octet-stream`, size ≤ `MaxEncryptedBlobSizeBytes` (the largest plaintext ceiling across encryptable kinds, today the image one; no allowance for the nonce and tag), and the caller must be a member of a **DM** (`blob.DMMembership`, satisfied by `chat.NewBlobDMMembership`, which owns the 32-byte discriminator check) or the reservation is `DENIED`. Finalization (`finalizeEncrypted`; the `Finalizer` takes the `AccessStore` for this) checks only the declared size (`TOO_LARGE` otherwise), promotes, **grants the DM read access, then** reaches READY, so READY implies granted and a rejected blob is never granted: no inspection, moderation or renditions. Before READY the other member sees nothing through `AccessContext.chat` (a reference that outruns READY reads as missing, which is why the sender waits for READY before sending). Metadata carries `kind.encrypted`. Every attach surface refuses one (`validateAttachable`) and `ResolveRenditions` omits it; only `EncryptedContent` in that DM, which the server never reads, references it. Policy always advertises `UploadPolicy.encrypted`; its image bounds (1600px) are advisory by construction. +- `blob/` - User-uploaded media (images only today). Two S3 buckets (upload → origin) plus CloudFront signed download URLs; the server never proxies bytes. Flow: `GetUploadPolicy` → `InitiateExternalUpload` → client uploads → `CompleteExternalUpload` (queues finalization, returns PROCESSING) → poll `GetBlobs`. `finalizer.go` owns the checkpointed pipeline (inspect → moderate → copy → WebP renditions). Reads: owner always; others need an `AccessContext` whose principal (User, Chat, UserProfile, ChatProfile) holds a grant *and* covers the caller (`access.go`). `image.go` rejects EXIF/privacy metadata. `integration.go` (`ShareIntoChat`, `SetAsProfilePicture`, `SetAsChatPicture`, `ResolveRenditions`) is the API other domains use. **End-to-end encrypted blobs** (`encrypted.go`; `Blob.EncryptedFor` is a `*Principal` — the surface the READY grant is made to, `PrincipalForChat` for a DM — so the pipeline, stores (`encrypted_for_type` N + `encrypted_for_id` B) and read paths are surface-agnostic and only `initiateEncryptedUpload` maps a proto arm to a principal and its admission gate; `ContentKindEncrypted`, its own finalization queue and so its own `Worker` instance in the parent) are reserved with `InitiateExternalUploadRequest.end_to_end_encrypted_for.chat`: mime must be `application/octet-stream`, size ≤ `MaxEncryptedBlobSizeBytes` (the largest plaintext ceiling across encryptable kinds, today the image one; no allowance for the nonce and tag), and the chat must take encrypted content from the caller (`blob.EncryptedUploadGate`, satisfied by `chat.NewBlobEncryptedUploadGate` over the shared `chat.Access`: an explicit `Access.IsMember` check, then `Access.SpeakerStanding(...).takesEncrypted()`, i.e. the caller is a member who may speak there and the chat takes encrypted content, a **DM** or a **private group that has its key**; the chat package owns the discriminator check and the rule) or the reservation is `DENIED`. Finalization (`finalizeEncrypted`; the `Finalizer` takes the `AccessStore` for this) checks only the declared size (`TOO_LARGE` otherwise), promotes, **grants the chat read access, then** reaches READY, so READY implies granted and a rejected blob is never granted: no inspection, moderation or renditions. Before READY the other members see nothing through `AccessContext.chat` (a reference that outruns READY reads as missing, which is why the sender waits for READY before sending). Metadata carries `kind.encrypted`. Every attach surface refuses one (`validateAttachable`) and `ResolveRenditions` omits it; only `EncryptedContent` in that chat, which the server never reads, references it. Policy always advertises `UploadPolicy.encrypted`; its image bounds (1600px) are advisory by construction. - `blocklist/` - Per-user block lists. `chat/` must not import it, so `blocklist/chat.go` adapts the store to `chat.BlocklistReader`; messaging uses `GetBlockers` to filter DM event fan-out. - `contact/` - Contact list sync (hashed phone numbers, XOR-of-SHA256 checksums, streaming delta/full uploads); maps contacts to Flipcash users and their DM chat IDs - `event/` - Real-time event streaming with bidirectional gRPC streams. See below. @@ -173,13 +173,13 @@ This is the most intricate part of the codebase and where most current work happ **Group management.** `StartChat` / `JoinChat` / `LeaveChat` are open to every registered user (nothing in these packages gates on client version). DMs always deny join/leave. Membership is checked before rules so a re-join is a no-op. Every real transition publishes a `RosterUpdate` to both the user and chat topics. Creation validates rules, checks the creator satisfies them, moderates the title and attaches the picture *before* the record is written. -**Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and **staff-only for now** (`canCreatePrivateGroup`, `DENIED` otherwise; a transitional gate like `useE2ee`, removed by deleting the check; a retry of a creation that landed is answered from the record before it). `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `GroupRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; the `RuleEvaluator` admits no one to a private group (`satisfiesListener` is false on `IsPrivate`, so a caller asking the rules alone never finds an empty rule set open to all), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view is `DENIED` (`getPublicChat`); `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely. **Key envelopes (`chat/key.go`)**: `SetKeyEnvelope` / `GetKeyEnvelope` are a member's alone, of a private group alone (a DM `DENIED` before any read, then the record for `NOT_FOUND`, then `IsPrivate`, then membership). Set stores the request's envelope as wrapped by the caller; **the first envelope a user wraps for themself stands** (a different one later is `ALREADY_SET`, the same one `OK`), while an envelope someone else wrapped for them is replaced by their own. Get returns the envelope with `wrapped_by`, or `NO_ENVELOPE`. Nothing is published. **A private group has a key exactly when its creator has an envelope stored**: there is no flag on the record, since the creator's envelope is the only possible first one, is always self-wrapped, and is never deleted. `LeaveChat` discards the leaver's envelope atomically with the departure, except the creator's: it reads the cached rules first (`NOT_FOUND` off that read, a failed read fails the RPC) and passes `discardKeyEnvelope` to `RemoveGroupMember` for a non-creator leaving a private group. `SetKeyEnvelope` is not in that transaction, so one racing a leave can still write an envelope for a user who has just left; it is unreadable to them and holds the right key if they are readmitted (accepted). The server never opens an envelope and validates only its shape (proto validation). **No one speaks in one yet, keyed or not**: `Access.CanSpeak` refuses every member of a private group off the cached rules read (sends, edits, deletions, typing and mention suggestions all `DENIED`), because messaging does not yet require encrypted content there and lifting the refusal for a keyed group would let plaintext in; encrypted blob uploads are refused because `IsDMMember` refuses every group ID. Not built: speaking in a keyed group (`ENCRYPTION_REQUIRED`, the chat-key scheme, the blob gate, the group push body, decided in `Access.CanSpeak` on membership and the creator's envelope without the evaluator), the lobby RPCs and `Metadata.in_lobby` (never set); the lobby RPCs answer unimplemented. +**Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and **staff-only for now** (`canCreatePrivateGroup`, `DENIED` otherwise; a transitional gate like `useE2ee`, removed by deleting the check; a retry of a creation that landed is answered from the record before it). `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `GroupRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicListenerStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; the `RuleEvaluator` admits no one to a private group (`satisfiesListener` is false on `IsPrivate`, so a caller asking the rules alone never finds an empty rule set open to all), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view is `DENIED` (`getPublicChat`); `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely. **Key envelopes (`chat/key.go`)**: `SetKeyEnvelope` / `GetKeyEnvelope` are a member's alone, of a private group alone (a DM `DENIED` before any read, then the record for `NOT_FOUND`, then `IsPrivate`, then membership). Set stores the request's envelope as wrapped by the caller; **the first envelope a user wraps for themself stands** (a different one later is `ALREADY_SET`, the same one `OK`), while an envelope someone else wrapped for them is replaced by their own. Get returns the envelope with `wrapped_by`, or `NO_ENVELOPE`. Nothing is published. **A private group has a key exactly when its creator has an envelope stored**: there is no flag on the record, since the creator's envelope is the only possible first one, is always self-wrapped, and is never deleted. `LeaveChat` discards the leaver's envelope atomically with the departure, except the creator's: it reads the cached rules first (`NOT_FOUND` off that read, a failed read fails the RPC) and passes `discardKeyEnvelope` to `RemoveGroupMember` for a non-creator leaving a private group. `SetKeyEnvelope` is not in that transaction, so one racing a leave can still write an envelope for a user who has just left; it is unreadable to them and holds the right key if they are readmitted (accepted). The server never opens an envelope and validates only its shape (proto validation). **SpeakerStanding (`Access.SpeakerStanding`)**: a member of a private group speaks exactly when the group has its key, decided on membership and the creator's envelope with the evaluator never consulted (no rule admits anyone to a private group). The key check is the cached rules read plus one strongly consistent point read of the creator's envelope (`hasKey`, unexported); a positive is held in the `Access` for the life of the process (the envelope is never deleted), a negative never, so a keyless group's refused sends each cost that read and its first send after the key lands is admitted at once. A keyless group's members are `DENIED` every send, edit, deletion, typing notification and mention suggestion; a keyed group's members get a `SpeakerStanding` with `EncryptionRequired` under `CHAT_KEY_XCHACHA20POLY1305`, and a member holding no envelope of their own speaks too (the key is the group's). Messaging applies that standing to the content (`encryptionVerdict`, see "Encrypted content"): the chat-key scheme and nothing else. Encrypted blob uploads follow the same gate (see `blob/`). The group push body for encrypted content is " sent a message". Not built: the lobby RPCs and `Metadata.in_lobby` (never set); the lobby RPCs answer unimplemented. **Edit (`chat/edit.go`).** `EditChat` changes a group's title and/or picture. **Only the creator, while a member, may edit** (`Chat.PermissionsFor`: `CanEdit = isMember && IsCreator`; DMs and legacy groups with no recorded creator are never editable; a departed creator is `DENIED` until they rejoin). Fields equal to the record are dropped first, so a no-op request (or one that sets nothing) is `OK` from the record with nothing moderated, attached, written or published. What remains runs in `StartChat`'s order — title moderation (`TITLE_MODERATED`), picture attach via `SetAsChatPicture` (`PICTURE_BLOB_NOT_ACCEPTED`), then one `Store.EditGroup` write (`GroupEdit`, nil = unchanged, SETs only the named attributes so concurrent edits of different fields never clobber; an empty edit is an error; `SetGroupPicture` remains the only way to *clear* a picture). A real change publishes **one event on the chat topic, excluding no one** (the editor's devices included), with one `MetadataUpdate` per field: `TitleChanged` / `PictureChanged` (the hydrated rendition set). `redact.ChatUpdate` passes both through. **Permissions (`ViewerState.permissions`).** Server-computed per read from the record and membership (`Chat.PermissionsFor`), never stored, and carried on **every** `ViewerState` carrier (`ToProto(permissions)`: hydrate, mute/unmute responses, `ViewerStateChanged`; the clear-on-leave publishes with none). `hydrate` **always sets `viewer_state` for a member** (the zero record with permissions when they have written none) and **never for a non-member**, whatever record persists for them (the read is skipped for a non-member's standing). The version does **not** move when permissions change with membership (decided: a creator's leave/rejoin is announced by `RosterUpdate`s carrying fresh metadata, not by a version bump), so the same version can carry different `can_edit` before and after the viewer's own transition. -**Roster read (`chat/roster.go`).** `GetRoster` pages a chat's members most-recently-joined first (`RosterPosition`: `(joined_at desc, user_id desc)`, total so a cursor resumes strictly after a member), every page carrying the `RosterSummary`. Two shapes chosen by size, like the mute fan-out: at or below `rosterWholeReadCap` (1000) the roster is read **whole and strongly consistent** (`GetGroupRoster`, one `Query` of the base partition that returns the `#meta` item at its head; a Query is consistent per item, not as a set, so the read is verified — joined rows == `member_count` and no row version above the summary's proves no transition straddled it — and re-read up to 3× otherwise, so the page is exactly the roster at that version), sorted and sliced per page; above it, `GetGroupRosterPage` ranges the sparse `by_joined_at` GSI descending (eventually consistent, billed by the page, `limit+1` for an exact `has_more`) beside a separate summary read. The proto is written for the weaker shape and the client never learns which it got. The token (`[version][chat ID padded to 16][joined_at nanos][user ID]`) is bound to the chat and refused elsewhere. Gate is `StandingWithChat(...).CanListen` (a member, or a qualifying non-member; a preview-only viewer is `DENIED`). The whole RPC has an operator kill switch, the `disableGetRoster` constructor flag: when set, every call is refused with `UNAVAILABLE` after authorization and before any read. Pointers are hydrated for a DM's participants only, never for group members. **`Member.joined_at` / `Member.version`** are the membership row's join time and version stamp, set for group members on roster pages and on `RosterUpdate.MemberJoined` (`announcedMember`: one strongly consistent `GetGroupMemberRecords` point read after a real join/creation, so the announcement carries the record the write produced), **unset on `Metadata.members`** (the viewer's own entry needs neither, and `hydrate` reads no membership rows) and never for a DM's participants. A departure's version is the `roster_summary.version` on its `MemberLeft`, so a client merges pages against the stream per member by version. +**Roster read (`chat/roster.go`).** `GetRoster` pages a chat's members most-recently-joined first (`RosterPosition`: `(joined_at desc, user_id desc)`, total so a cursor resumes strictly after a member), every page carrying the `RosterSummary`. Two shapes chosen by size, like the mute fan-out: at or below `rosterWholeReadCap` (1000) the roster is read **whole and strongly consistent** (`GetGroupRoster`, one `Query` of the base partition that returns the `#meta` item at its head; a Query is consistent per item, not as a set, so the read is verified — joined rows == `member_count` and no row version above the summary's proves no transition straddled it — and re-read up to 3× otherwise, so the page is exactly the roster at that version), sorted and sliced per page; above it, `GetGroupRosterPage` ranges the sparse `by_joined_at` GSI descending (eventually consistent, billed by the page, `limit+1` for an exact `has_more`) beside a separate summary read. The proto is written for the weaker shape and the client never learns which it got. The token (`[version][chat ID padded to 16][joined_at nanos][user ID]`) is bound to the chat and refused elsewhere. Gate is `ListenerStandingWithChat(...).CanListen` (a member, or a qualifying non-member; a preview-only viewer is `DENIED`). The whole RPC has an operator kill switch, the `disableGetRoster` constructor flag: when set, every call is refused with `UNAVAILABLE` after authorization and before any read. Pointers are hydrated for a DM's participants only, never for group members. **`Member.joined_at` / `Member.version`** are the membership row's join time and version stamp, set for group members on roster pages and on `RosterUpdate.MemberJoined` (`announcedMember`: one strongly consistent `GetGroupMemberRecords` point read after a real join/creation, so the announcement carries the record the write produced), **unset on `Metadata.members`** (the viewer's own entry needs neither, and `hydrate` reads no membership rows) and never for a DM's participants. A departure's version is the `roster_summary.version` on its `MemberLeft`, so a client merges pages against the stream per member by version. **Mention suggestions (`chat/mention.go`).** `GetMentionSuggestions` returns a ranked pool of people to @mention in a group, which the client filters locally: today the group's most recent senders (`GetRecentSenders`), most recent first, **departed users included** (users can't tell them from members). The server decides the size (`mentionSuggestionsSize` 50, reading `mentionSuggestionsSlack` 20 more to absorb filtering); the request carries no limit, and neither size nor ranking is contract. Gate: a DM is `DENIED` before any read, a missing group `NOT_FOUND`, then `Access.CanSpeak` (so a non-creator in a creator-only group is `DENIED`). Dropped after the read: the caller, users the caller blocked (`BlocklistReader.GetBlocked`), users without a username, and users the profile domain no longer knows. A user who blocked the caller **is** still suggested (decided). Each suggestion is a `MentionSuggestion{user_profile, last_sent_at}`; the ranking score is never exposed. @@ -193,17 +193,17 @@ This is the most intricate part of the codebase and where most current work happ **Widget content (`WidgetContent`).** Server-authored only: `clientAllowedContent` refuses it for send, edit and as a reply body; today only the team's welcome sends one (`ShareProfileWidget`). Replyable and reactable, never editable or deletable; `redact.Content` keeps the variant but sets a share's username to `redacted` (an unknown variant fails the read); a push renders its plain-text stand-in as the body (a profile share is `https://flipcash.com/`, `renderWidgetPushBody`; an unknown variant earns no push) and carries the whole message in the payload like any other. -**Encrypted content (`EncryptedContent`).** DMs only. `clientAllowedContent` accepts it top-level only (never as a reply body; a reply is encrypted whole), and `SendMessage` / `EditMessage` answer `ENCRYPTION_NOT_ALLOWED` (`encryptionAllowed`) for a group ID, or for a DM under any scheme but its own (`X25519_XCHACHA20POLY1305`; the proto also defines `CHAT_KEY_XCHACHA20POLY1305` for private groups, which no one speaks in yet, so their sends never reach this rule; see "Private groups"), *after* the speaker gate, so a non-member is still `DENIED`. The server never reads the ciphertext beyond its scheme: nothing inside is validated (a reply wrapped inside is never checked against the thread; a `MediaContent` wrapped inside names end-to-end encrypted blobs the sender uploaded for the DM, see `blob/`, which messaging never shares, hydrates or sees — the sender fills the ORIGINAL rendition's metadata itself), it is replyable/reactable/editable/deletable like text, except that an edit never downgrades it (encrypted → plaintext is `CANNOT_EDIT`, judged on `GetMessage`, which is strongly consistent so the check and the edit's `expected_event_sequence` guard together close the race; plaintext → encrypted is allowed), `redact.Content` passes it through (only a DM's members can reach it), and a DM push gets the generic body "Sent you a message" with the message in the payload when it fits under `maxChatPushBytes`, and only `ChatMetadata.message_id` otherwise. +**Encrypted content (`EncryptedContent`).** DMs and keyed private groups. `clientAllowedContent` accepts it top-level only (never as a reply body; a reply is encrypted whole). `SendMessage` / `EditMessage` ask `Access.SpeakerStanding` for the gate and judge the content against it (`encryptionVerdict`) *after* it, so a non-member is still `DENIED`: a DM takes plaintext and its own scheme (`X25519_XCHACHA20POLY1305`), any other scheme `ENCRYPTION_NOT_ALLOWED`; a public group takes plaintext alone, any encrypted content `ENCRYPTION_NOT_ALLOWED`; a private group takes the chat-key scheme (`CHAT_KEY_XCHACHA20POLY1305`) alone, plaintext (a reply included) `ENCRYPTION_REQUIRED` and any other scheme `ENCRYPTION_NOT_ALLOWED`. What a chat takes comes with the standing (see "Permissions"), so a send costs no read beyond the gate, and only a keyed private group reaches the verdict since the gate refuses a keyless one's members (see "Private groups"). The server never reads the ciphertext beyond its scheme: nothing inside is validated (a reply wrapped inside is never checked against the thread; a `MediaContent` wrapped inside names end-to-end encrypted blobs the sender uploaded for the chat, see `blob/`, which messaging never shares, hydrates or sees — the sender fills the ORIGINAL rendition's metadata itself), it is replyable/reactable/editable/deletable like text, except that an edit never downgrades it (encrypted → plaintext is `CANNOT_EDIT` in a DM, judged on `GetMessage`, which is strongly consistent so the check and the edit's `expected_event_sequence` guard together close the race, and `ENCRYPTION_REQUIRED` in a private group, where the verdict comes first; plaintext → encrypted is allowed), `redact.Content` passes it through (only the chat's members can reach it), and a push gets a generic body — "Sent you a message" in a DM, " sent a message" in a group (`renderGroupChatMessagePushBody`) — with the message in the payload when it fits under `maxChatPushBytes`, and only `ChatMetadata.message_id` otherwise. **`Metadata.use_e2ee` (transitional).** Set by `hydrate` alone, per read and never stored: exactly when the chat is a DM, **every member is a staff user** (`useE2ee`; the account store's `IsStaff` per DM member across the page, resolved concurrently in `staffFlags`) **and none is the Flipcash team account** (the `teamUserID` passed to `chat.NewServer`, nil for none; the parent resolves it with `flipcashteam.GetUserID`; the server writes into the team's DMs and holds no keys for it). Groups never carry it. Nothing else publishes DM metadata, so this is the only place the flag is decided; once E2EE launches the client is expected to ignore it and it will be deprecated. -**Permissions.** One definition lives in `chat.Access` (`chat/access.go`); the parent builds a single instance and injects it into both the chat and messaging servers, so the admission cache and its TTL are shared. Three gates: `CanListen` (member, or a non-member of a *group* who satisfies its listener rules right now — a qualifying user can preview a group before joining), `IsMember` (pointer advances, add/remove reaction: writes that are a member's alone), `CanSpeak` (sends, edits, deletes, typing: member plus listener and speaker rules). A caller already holding the canonical record uses `StandingWithChat` / `IsMemberWithChat` (`GetChat`, the mute gate, chat previews): a DM's membership is decided off the record's inline members with no membership read, a group's is still the store's. A member's read is answered on membership alone and never evaluates a rule; a non-member's read evaluates the listener rules, with a positive-only 30s admission cache per (group, user). A group with **no** listener rules admits no non-member (only a rule can admit one; the store does not require rules, so legacy groups may carry none). A DM never reaches the rules fallback. Membership is meant to track listener rules (a member who stops satisfying one gets removed) — not built yet. `GetChat` returns a group's record to any registered user; the viewer's `Standing` decides how much `hydrate` reads (no hydrated member for a non-member, no messaging state for a viewer who cannot read). DMs stay member-only. `GetChat` also takes no auth at all: the group's **public view** (`getPublicChat`, standing from `Access.PublicStanding`, off the record with nothing read), which is exactly a registered non-member's REDACTED read with no per-viewer fields; any other mode, or any DM ID, is `DENIED` before the record is read. +**Permissions.** One definition lives in `chat.Access` (`chat/access.go`); the parent builds a single instance and injects it into both the chat and messaging servers, so the admission cache and its TTL are shared. Three gates: `CanListen` (member, or a non-member of a *group* who satisfies its listener rules right now — a qualifying user can preview a group before joining), `IsMember` (pointer advances, add/remove reaction: writes that are a member's alone), `CanSpeak` (sends, edits, deletes, typing: member plus listener and speaker rules). `CanSpeak` is the boolean projection of `Access.SpeakerStanding`, a `chat.SpeakerStanding{CanSpeak, Encryption, Scheme}` that also says what the chat takes from a speaker — `EncryptionNone` (public group: plaintext alone), `EncryptionOptional` (DM: plaintext or the pairwise scheme), `EncryptionRequired` (keyed private group: the chat-key scheme alone) — so nothing outside `chat/` knows what makes a chat take encrypted content; sends and edits ask for the whole standing, deletes/typing/mention suggestions the boolean, and the blob gate checks membership explicitly and then asks the unexported `takesEncrypted()`. A caller already holding the canonical record uses `ListenerStandingWithChat` / `IsMemberWithChat` (`GetChat`, the mute gate, chat previews): a DM's membership is decided off the record's inline members with no membership read, a group's is still the store's. A member's read is answered on membership alone and never evaluates a rule; a non-member's read evaluates the listener rules, with a positive-only 30s admission cache per (group, user). A group with **no** listener rules admits no non-member (only a rule can admit one; the store does not require rules, so legacy groups may carry none). A DM never reaches the rules fallback. Membership is meant to track listener rules (a member who stops satisfying one gets removed) — not built yet. `GetChat` returns a group's record to any registered user; the viewer's `ListenerStanding` decides how much `hydrate` reads (no hydrated member for a non-member, no messaging state for a viewer who cannot read). DMs stay member-only. `GetChat` also takes no auth at all: the group's **public view** (`getPublicChat`, standing from `Access.PublicListenerStanding`, off the record with nothing read), which is exactly a registered non-member's REDACTED read with no per-viewer fields; any other mode, or any DM ID, is `DENIED` before the record is read. -**Redacted reads (`redact/`, `messaging.v1.ViewMode`).** `Standing` has three levels: `IsMember`, `CanListen` (full read), `CanPreview` (redacted read: a non-member of a group that carries ≥1 listener rule, whatever the rules say of them; `CanListen` implies it). Every message read (`GetMessage`, `GetMessages`, `GetDelta`, and `GetChat` for `last_message`) carries a `view_mode`; `Standing.Reading(mode)` resolves it to denied / full / redacted and **never widens** the standing: `FULL` (proto default, legacy contract) is full or `DENIED`; `FULL_OR_REDACTED` is the most the standing allows; `REDACTED` is a placeholder for anyone who may read the chat at all, members included, and **never evaluates the rules** (`Access.Standing` skips the valuation and the admission cache), so a discovery screen pays nothing per group. Redaction happens at one chokepoint per server — `messaging.Server.present` and the last-message step of `chat.Server.hydrate` — **after** media hydration, so placeholders keep dimensions and blurhash and drop only the download URL; a message that cannot be redacted fails the read (`Internal`), never leaks. `redact.Message`/`redact.Content` are allowlist-built: text/captions/reply bodies become shape-only placeholders seeded by (chat ID, message seq); cash, system and deleted content pass through; reactions are not carried (no message read carries them). The reaction reads (`GetReactionSummary`, `GetReactionSummaries`, `GetReactors`) take no mode and answer anyone with `CanPreview`, gated by `overlayStanding` (a REDACTED-mode standing: no rule evaluated, no admission remembered); a non-member's `self_reactor` is never looked up. Pointer RPCs stay a member's. The event stream serves a redacted viewer only through a **chat preview** (see "Event delivery"): `redact.ChatUpdate` is the stream's chokepoint, walking every message an update carries (event-log mutations, a metadata refresh's `last_message`, a join's `metadata.last_message`) and passing the overlays through. +**Redacted reads (`redact/`, `messaging.v1.ViewMode`).** `ListenerStanding` has three levels: `IsMember`, `CanListen` (full read), `CanPreview` (redacted read: a non-member of a group that carries ≥1 listener rule, whatever the rules say of them; `CanListen` implies it). Every message read (`GetMessage`, `GetMessages`, `GetDelta`, and `GetChat` for `last_message`) carries a `view_mode`; `ListenerStanding.Reading(mode)` resolves it to denied / full / redacted and **never widens** the standing: `FULL` (proto default, legacy contract) is full or `DENIED`; `FULL_OR_REDACTED` is the most the standing allows; `REDACTED` is a placeholder for anyone who may read the chat at all, members included, and **never evaluates the rules** (`Access.ListenerStanding` skips the valuation and the admission cache), so a discovery screen pays nothing per group. Redaction happens at one chokepoint per server — `messaging.Server.present` and the last-message step of `chat.Server.hydrate` — **after** media hydration, so placeholders keep dimensions and blurhash and drop only the download URL; a message that cannot be redacted fails the read (`Internal`), never leaks. `redact.Message`/`redact.Content` are allowlist-built: text/captions/reply bodies become shape-only placeholders seeded by (chat ID, message seq); cash, system and deleted content pass through; reactions are not carried (no message read carries them). The reaction reads (`GetReactionSummary`, `GetReactionSummaries`, `GetReactors`) take no mode and answer anyone with `CanPreview`, gated by `overlayStanding` (a REDACTED-mode standing: no rule evaluated, no admission remembered); a non-member's `self_reactor` is never looked up. Pointer RPCs stay a member's. The event stream serves a redacted viewer only through a **chat preview** (see "Event delivery"): `redact.ChatUpdate` is the stream's chokepoint, walking every message an update carries (event-log mutations, a metadata refresh's `last_message`, a join's `metadata.last_message`) and passing the overlays through. **Event delivery (`event/`).** `Bus` runs each handler on its own goroutine. The stream registry is sharded 64 ways. Two cluster namespaces: `user-events` and `chat-events` (groups only; DM updates fan out per member on the user bus, group updates publish once on the chat topic). Stream keys are prefixed because user and group IDs are both 16 bytes. A session's chat topics **follow membership and are version-gated**: a roster transition applies only if newer than the last one applied for that chat, seeded at stream open from membership records including tombstones. A reconcile sweep re-reads each streaming user's membership every 5 minutes (1s tick, 8 workers, quota-paced). Per-peer outboxes are 4096 deep and drop on overflow; a lagging stream is closed with `ErrStreamLagging`. `ForwardEvents` (cross-server, API-key authed) delivers locally only and never re-resolves ownership. -**Chat previews (`event/chat_preview.go`).** `StreamEvents` with `Params.chat_preview` set streams one **group's** `ChatUpdate`s under a `ViewMode`, and nothing else, for a **server-fixed window** (`defaultChatPreviewLifetime` 5 min, `WithChatPreviewLifetime` for tests) that ends with `STREAM_EXPIRED` whatever the client does; pongs keep it healthy, not alive. A user stream (no target) is unchanged. A DM is `DENIED` to everyone before its record is read. A preview is a **non-member's alone**: a member is `DENIED` whatever the mode (their user stream already carries the chat; this is stricter than the proto's "the server does not refuse it"). The viewer's `Standing` is resolved **once, at open**, through the shared `chat.Access` (the event server takes it at construction) with the same NOT_FOUND / DENIED rules as any read, and is trusted for the window: nothing is re-evaluated mid-stream, so a non-member who stops qualifying, or who joins, keeps the preview until it expires and is refused the next one. Registration is by the chat key alone, so previews are not in `sessions` and never move with `followMembership`; the viewer's own roster transitions are excluded from the chat topic and never appear on a preview (they ride the user stream). Every event passes `chatPreview.shape`, which drops anything that is not a `ChatUpdate` for that chat and redacts via `redact.ChatUpdate` when the reading is redacted; a shape failure ends the stream (`Internal`). Previews do not reset the badge. The proto's rate limit on opening previews is not built. +**Chat previews (`event/chat_preview.go`).** `StreamEvents` with `Params.chat_preview` set streams one **group's** `ChatUpdate`s under a `ViewMode`, and nothing else, for a **server-fixed window** (`defaultChatPreviewLifetime` 5 min, `WithChatPreviewLifetime` for tests) that ends with `STREAM_EXPIRED` whatever the client does; pongs keep it healthy, not alive. A user stream (no target) is unchanged. A DM is `DENIED` to everyone before its record is read. A preview is a **non-member's alone**: a member is `DENIED` whatever the mode (their user stream already carries the chat; this is stricter than the proto's "the server does not refuse it"). The viewer's `ListenerStanding` is resolved **once, at open**, through the shared `chat.Access` (the event server takes it at construction) with the same NOT_FOUND / DENIED rules as any read, and is trusted for the window: nothing is re-evaluated mid-stream, so a non-member who stops qualifying, or who joins, keeps the preview until it expires and is refused the next one. Registration is by the chat key alone, so previews are not in `sessions` and never move with `followMembership`; the viewer's own roster transitions are excluded from the chat topic and never appear on a preview (they ride the user stream). Every event passes `chatPreview.shape`, which drops anything that is not a `ChatUpdate` for that chat and redacts via `redact.ChatUpdate` when the reading is redacted; a shape failure ends the stream (`Internal`). Previews do not reset the badge. The proto's rate limit on opening previews is not built. ### Authentication Flow diff --git a/blob/encrypted.go b/blob/encrypted.go index a75e011..18c4db9 100644 --- a/blob/encrypted.go +++ b/blob/encrypted.go @@ -7,16 +7,17 @@ import ( ) // End-to-end encrypted blobs are the one kind of blob the server cannot read. -// A client encrypts an image for a DM (see messagingpb.EncryptedContent, which -// fixes the format) and uploads the ciphertext as an opaque octet stream, so -// the server derives nothing from the bytes: no metadata, no renditions, no -// moderation, no privacy-metadata check. What it does instead is pin the blob -// to the DM at reservation — the caller must be a member, and the chat must be -// a DM — check nothing but the size at finalization, grant the DM read access -// in the same step that makes the blob READY, and refuse the blob on every -// surface other than encrypted content in that chat (see validateAttachable). -// Everything specific to that kind is gathered here; the pipeline arms live -// beside their plaintext counterparts. +// A client encrypts an image for a chat (see messagingpb.EncryptedContent, +// which fixes the format) and uploads the ciphertext as an opaque octet +// stream, so the server derives nothing from the bytes: no metadata, no +// renditions, no moderation, no privacy-metadata check. What it does instead +// is pin the blob to the chat at reservation — the chat must take encrypted +// content and the caller must be able to send it there (see +// EncryptedUploadGate) — check nothing but the size at finalization, grant +// the chat read access in the same step that makes the blob READY, and refuse +// the blob on every surface other than encrypted content in that chat (see +// validateAttachable). Everything specific to that kind is gathered here; the +// pipeline arms live beside their plaintext counterparts. const ( // MaxEncryptedBlobSizeBytes bounds the declared size of an end-to-end // encrypted upload. It is the only constraint the server enforces on such a @@ -43,16 +44,19 @@ const ( maxEncryptedImagePixels = maxEncryptedImageDimension * maxEncryptedImageDimension ) -// DMMembership is the slice of the chat domain the upload path needs to admit -// an end-to-end encrypted upload: whether a user is currently a member of a -// chat that is a DM. It is declared here (consumer side) so this package need -// not import chat — which imports this package to attach pictures and match -// its errors — and the chat package supplies an adapter over its store that -// also owns the DM-versus-group rule, so the chat ID discriminator is never -// duplicated here. -type DMMembership interface { - // IsDMMember reports whether chatID names a DM and userID is a member of - // it. A group chat ID, an unknown chat, or a non-member is false with no - // error. - IsDMMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) +// EncryptedUploadGate is the slice of the chat domain the upload path needs +// to admit an end-to-end encrypted upload: whether a chat takes encrypted +// content, and whether the caller may send it there right now. Which chats +// do — a DM, and a private group once it has its chat key — and what admits +// a sender are the chat domain's rules, and change there. It is declared here +// (consumer side) so this package need not import chat — which imports this +// package to attach pictures and match its errors — and the chat package +// supplies the adapter (chat.NewBlobEncryptedUploadGate), so neither the chat +// ID discriminator nor the rules are duplicated here. +type EncryptedUploadGate interface { + // CanUploadEncrypted reports whether chatID takes end-to-end encrypted + // content and userID may send it there. A chat that takes none, an + // unknown chat, or a caller who may not send in it is false with no + // error, so a refused caller learns nothing of the chat. + CanUploadEncrypted(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) } diff --git a/blob/model.go b/blob/model.go index 850b69f..3c889f8 100644 --- a/blob/model.go +++ b/blob/model.go @@ -276,7 +276,7 @@ type Blob struct { // EncryptedFor is set when the blob's bytes are end-to-end encrypted for one // surface (blobpb.InitiateExternalUploadRequest.end_to_end_encrypted_for), // naming that surface as the principal its read grant will be made to — a - // DM is PrincipalForChat(chat), the only surface today. It is pinned at + // chat is PrincipalForChat(chat), the only surface today. It is pinned at // reservation and immutable. The server cannot read such a blob, so it // derives no metadata or renditions from it, never moderates it, grants the // principal read access in the step that makes it READY (see diff --git a/blob/server.go b/blob/server.go index 069d7ef..a49f57c 100644 --- a/blob/server.go +++ b/blob/server.go @@ -33,9 +33,10 @@ type Server struct { access AccessStore resolver PrincipalResolver - // dms gates end-to-end encrypted uploads: one is reserved only for a DM the - // caller is a member of (see initiateEncryptedUpload). - dms DMMembership + // encryptedUploads gates end-to-end encrypted uploads: one is reserved + // only for a chat that takes encrypted content from the caller (see + // initiateEncryptedUpload). + encryptedUploads EncryptedUploadGate blobpb.UnimplementedBlobStorageServer } @@ -48,17 +49,17 @@ func NewServer( storage ObjectStorage, access AccessStore, resolver PrincipalResolver, - dms DMMembership, + encryptedUploads EncryptedUploadGate, ) *Server { return &Server{ - log: log, - authz: authz, - accounts: accounts, - blobs: blobs, - storage: storage, - access: access, - resolver: resolver, - dms: dms, + log: log, + authz: authz, + accounts: accounts, + blobs: blobs, + storage: storage, + access: access, + resolver: resolver, + encryptedUploads: encryptedUploads, } } @@ -111,7 +112,7 @@ func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.Initiat } // An end-to-end encrypted upload is its own contract — an opaque type, its - // own size ceiling, and a DM it is pinned to — so it is reserved on its own + // own size ceiling, and a chat it is pinned to — so it is reserved on its own // path. An ordinary upload has no surface named. if chatID := req.GetChat(); chatID != nil { return s.initiateEncryptedUpload(ctx, log, owner, chatID, req) @@ -160,15 +161,16 @@ func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.Initiat }) } -// initiateEncryptedUpload reserves an end-to-end encrypted blob for a DM +// initiateEncryptedUpload reserves an end-to-end encrypted blob for a chat // (blobpb.InitiateExternalUploadRequest.end_to_end_encrypted_for). The server // cannot read the bytes, so the contract it pins is the one it can hold the // upload to: the opaque type (UNSUPPORTED_TYPE otherwise), the encrypted size // ceiling (TOO_LARGE otherwise; both policy-driven, so they echo the policy -// version), and a DM the caller is a member of (DENIED otherwise — a group, an -// unknown chat, and a non-member are indistinguishable, so a caller learns -// nothing about a chat they are not in). The cheap checks run first; the -// membership read runs only for a request that is otherwise acceptable. +// version), and a chat that takes encrypted content from the caller (see +// EncryptedUploadGate; DENIED otherwise — a chat that takes none, an unknown +// chat, and a caller who may not send are indistinguishable, so a caller +// learns nothing about a chat they are not in). The cheap checks run first; +// the gate's reads run only for a request that is otherwise acceptable. // // Reservation pins the blob to the chat as the principal its grant will be made // to (Blob.EncryptedFor = PrincipalForChat) but grants nothing: the read grant @@ -201,12 +203,12 @@ func (s *Server) initiateEncryptedUpload(ctx context.Context, log *zap.Logger, o }, nil } - isDMMember, err := s.dms.IsDMMember(ctx, chatID, owner) + allowed, err := s.encryptedUploads.CanUploadEncrypted(ctx, chatID, owner) if err != nil { - log.Warn("Failed to check DM membership", zap.Error(err)) + log.Warn("Failed to check encrypted upload gate", zap.Error(err)) return nil, status.Error(codes.Internal, "failed to initiate upload") } - if !isDMMember { + if !allowed { return &blobpb.InitiateExternalUploadResponse{Result: blobpb.InitiateExternalUploadResponse_DENIED}, nil } diff --git a/blob/tests/server.go b/blob/tests/server.go index 12e497d..5acc612 100644 --- a/blob/tests/server.go +++ b/blob/tests/server.go @@ -918,14 +918,48 @@ func testEncryptedUpload(t *testing.T, accounts account.Store, blobs blob.Store, require.Equal(t, blobpb.InitiateExternalUploadResponse_OK, resp.Result) }) - t.Run("a group chat is denied", func(t *testing.T) { + t.Run("a chat that takes no encrypted content is denied", func(t *testing.T) { groupID := &commonpb.ChatId{Value: dmID.Value[:16]} - resolver.joinDM(groupID, senderID) // membership does not help: it is not a DM + resolver.join(groupID, senderID) // membership does not help: the gate refuses the chat resp := initiateEncrypted(t, h, sender, groupID, blob.EncryptedMimeType, uint64(len(ciphertext))) require.Equal(t, blobpb.InitiateExternalUploadResponse_DENIED, resp.Result) require.Nil(t, resp.PolicyVersion) }) + t.Run("a keyed private group's member reserves a blob pinned to the group", func(t *testing.T) { + // The pipeline is surface-agnostic: a group the gate admits is pinned, + // finalized and granted exactly as a DM is, and read by its members + // through the chat context. + groupID := &commonpb.ChatId{Value: dmID.Value[16:]} + _, outsider := registerUser(t, accounts) + resolver.joinKeyedPrivateGroup(groupID, senderID) + resolver.joinKeyedPrivateGroup(groupID, recipientID) + + resp := initiateEncrypted(t, h, sender, groupID, blob.EncryptedMimeType, uint64(len(ciphertext))) + require.Equal(t, blobpb.InitiateExternalUploadResponse_OK, resp.Result) + record, err := blobs.GetByID(context.Background(), resp.BlobId) + require.NoError(t, err) + require.Equal(t, blob.PrincipalForChat(groupID), *record.EncryptedFor) + + upload(resp.UploadTarget, ciphertext) + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_PROCESSING, completeResponse(t, h, sender, resp.BlobId).Status) + h.drain(t) + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_READY, completeResponse(t, h, sender, resp.BlobId).Status) + granted, err := access.HasGrant(context.Background(), resp.BlobId, blob.PrincipalForChat(groupID), blob.PermissionRead) + require.NoError(t, err) + require.True(t, granted) + + got := getBlobs(t, h, recipient, []*blobpb.BlobId{resp.BlobId}, &blobpb.AccessContext{Scope: &blobpb.AccessContext_Chat{Chat: groupID}}) + require.Len(t, got, 1) + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_READY, got[0].Status) + require.NotNil(t, got[0].Metadata.GetEncrypted()) + require.Empty(t, getBlobs(t, h, outsider, []*blobpb.BlobId{resp.BlobId}, &blobpb.AccessContext{Scope: &blobpb.AccessContext_Chat{Chat: groupID}})) + + // The DM's member has no business with the group's upload. + denied := initiateEncrypted(t, h, outsider, groupID, blob.EncryptedMimeType, uint64(len(ciphertext))) + require.Equal(t, blobpb.InitiateExternalUploadResponse_DENIED, denied.Result) + }) + t.Run("a non-member is denied", func(t *testing.T) { _, outsider := registerUser(t, accounts) resp := initiateEncrypted(t, h, outsider, dmID, blob.EncryptedMimeType, uint64(len(ciphertext))) @@ -1161,34 +1195,40 @@ func makePNGWithExif(t *testing.T, width, height int) []byte { // fakeResolver is a controllable blob.PrincipalResolver for the server suite: a // (principal, user) pair resolves as covered only after allow records it. It -// doubles as the suite's blob.DMMembership: joinDM records a user as a member -// of a DM, which both admits their encrypted uploads for it and — as the -// production ChatResolver would — covers them for the chat's grants. +// doubles as the suite's blob.EncryptedUploadGate: joinDM records a user as a +// member of a DM, and joinKeyedPrivateGroup as a member of a private group +// that has its key, which both admits their encrypted uploads for the chat +// and — as the production ChatResolver would — covers them for its grants. A +// member of any other chat is covered by its grants (join) and not admitted +// to upload for it, as the production gate refuses a public group's member. type fakeResolver struct { covered map[string]bool - dmMembers map[string]bool + uploaders map[string]bool } func newFakeResolver() *fakeResolver { - return &fakeResolver{covered: make(map[string]bool), dmMembers: make(map[string]bool)} + return &fakeResolver{covered: make(map[string]bool), uploaders: make(map[string]bool)} } func (r *fakeResolver) allow(principal blob.Principal, user *commonpb.UserId) { r.covered[resolverKey(principal, user)] = true } -func (r *fakeResolver) joinDM(chatID *commonpb.ChatId, user *commonpb.UserId) { - r.dmMembers[resolverKey(blob.PrincipalForChat(chatID), user)] = true +func (r *fakeResolver) join(chatID *commonpb.ChatId, user *commonpb.UserId) { r.allow(blob.PrincipalForChat(chatID), user) } -func (r *fakeResolver) IsDMMember(_ context.Context, chatID *commonpb.ChatId, user *commonpb.UserId) (bool, error) { - // The production adapter refuses a group ID before reading membership; the - // fake mirrors that so the suite exercises the same shape. - if len(chatID.GetValue()) != 32 { - return false, nil - } - return r.dmMembers[resolverKey(blob.PrincipalForChat(chatID), user)], nil +func (r *fakeResolver) joinDM(chatID *commonpb.ChatId, user *commonpb.UserId) { + r.uploaders[resolverKey(blob.PrincipalForChat(chatID), user)] = true + r.join(chatID, user) +} + +func (r *fakeResolver) joinKeyedPrivateGroup(chatID *commonpb.ChatId, user *commonpb.UserId) { + r.joinDM(chatID, user) +} + +func (r *fakeResolver) CanUploadEncrypted(_ context.Context, chatID *commonpb.ChatId, user *commonpb.UserId) (bool, error) { + return r.uploaders[resolverKey(blob.PrincipalForChat(chatID), user)], nil } func (r *fakeResolver) Covers(ctx context.Context, principal blob.Principal, user *commonpb.UserId) (bool, error) { diff --git a/chat/access.go b/chat/access.go index 78bd32f..fc243d0 100644 --- a/chat/access.go +++ b/chat/access.go @@ -65,13 +65,13 @@ const DefaultListenerAdmissionTTL = 30 * time.Second // add here, not the absence of one. // // A non-member of a group whose listener rules they do not satisfy is not -// nothing to the group: they may read it redacted (see Standing.CanPreview and +// nothing to the group: they may read it redacted (see ListenerStanding.CanPreview and // redact.Message) — that the messages exist and their shape, never what they // say — provided the group carries a listener rule at all, on the same basis // as above: a rule is what opens a group to non-members, and its absence // keeps the group its members' alone in every form. What a read is answered // with — full, redacted, or nothing — is the viewer's standing combined with -// what the client asked for (see Standing.Reading and messagingpb.ViewMode): +// what the client asked for (see ListenerStanding.Reading and messagingpb.ViewMode): // a client that wants a group blurred asks for REDACTED, and is answered from // membership and the rules' existence without the rules being evaluated. // @@ -82,11 +82,13 @@ const DefaultListenerAdmissionTTL = 30 * time.Second // would keep non-members out by the rule above, but a record written with // one must not open it. Its record is still shown to any registered user // (see Server.GetChat), which is how a user sees what they would ask to join. +// Its members speak in it on membership and the group's key alone (see +// SpeakerStanding): the rules are never evaluated for one. // // A chat that does not exist admits no one: IsMember, CanListen and CanSpeak all // report false, not ErrChatNotFound, for a chat ID nothing is stored under. A // caller that must tell NOT_FOUND from DENIED reads the canonical record itself -// and asks with CanListenWithRules or StandingWithRules. +// and asks with CanListenWithRules or ListenerStandingWithRules. type Access struct { chats Store rules *RuleEvaluator @@ -96,6 +98,11 @@ type Access struct { // means admissions are not remembered (see WithListenerAdmissionTTL). admitted *ttlcache.Cache admittedTTL time.Duration + + // keyed remembers, by group, that a private group has its key (see + // SpeakerStanding). Positive entries only, held for the life of the process: a + // group that has its key has it for good. + keyed *ttlcache.Cache } // AccessOption configures an Access at construction. @@ -127,6 +134,7 @@ func NewAccess(chats Store, rules *RuleEvaluator, opts ...AccessOption) *Access chats: chats, rules: rules, admittedTTL: DefaultListenerAdmissionTTL, + keyed: ttlcache.NewCache(), } for _, opt := range opts { opt(a) @@ -153,7 +161,7 @@ func (a *Access) IsMember(ctx context.Context, chatID *commonpb.ChatId, userID * return a.chats.IsMember(ctx, chatID, userID) } -// Standing is a user's relation to a chat as Access sees it: whether they are +// ListenerStanding is a user's relation to a chat as Access sees it: whether they are // on its roster, whether they may read it in full (see Access.CanListen), and // whether they may read it redacted (see Access). A member may always read; a // non-member with CanListen is a group's qualifying non-member, admitted by @@ -162,24 +170,24 @@ func (a *Access) IsMember(ctx context.Context, chatID *commonpb.ChatId, userID * // their shape. CanListen implies CanPreview: whoever may read in full may // read redacted (see Reading). // -// A standing found under ViewMode REDACTED (see Access.Standing) never +// A standing found under ViewMode REDACTED (see Access.ListenerStanding) never // evaluates the rules, so its CanListen is false for a non-member whether or // not they satisfy them. Such a standing answers only the question it was // asked; a caller that needs CanListen asks under a mode that evaluates it. -type Standing struct { +type ListenerStanding struct { IsMember bool CanListen bool CanPreview bool } -// memberStanding is a member's standing, for a caller that has established +// memberListenerStanding is a member's standing, for a caller that has established // membership by other means — a feed built from the viewer's own memberships, a // join that just landed. -var memberStanding = Standing{IsMember: true, CanListen: true, CanPreview: true} +var memberListenerStanding = ListenerStanding{IsMember: true, CanListen: true, CanPreview: true} // Reading is what a read of a chat is answered with: nothing, the messages in // full, or the messages redacted (see redact.Message). It is the viewer's -// standing combined with the client's ViewMode (see Standing.Reading). +// standing combined with the client's ViewMode (see ListenerStanding.Reading). type Reading uint8 const ( @@ -198,7 +206,7 @@ const ( // version does not know denies, on the same footing as an unknown rule: what // the client wants is not understood, so nothing is shown. The mode never // widens the standing: a redacted reader is never answered in full. -func (s Standing) Reading(mode messagingpb.ViewMode) Reading { +func (s ListenerStanding) Reading(mode messagingpb.ViewMode) Reading { switch mode { case messagingpb.ViewMode_FULL: if s.CanListen { @@ -223,73 +231,73 @@ func (s Standing) Reading(mode messagingpb.ViewMode) Reading { // or chatID is a group whose listener rules they satisfy (see Access). It is // false, not an error, for a chat that does not exist. func (a *Access) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { - standing, err := a.Standing(ctx, chatID, userID, messagingpb.ViewMode_FULL) + standing, err := a.ListenerStanding(ctx, chatID, userID, messagingpb.ViewMode_FULL) return standing.CanListen, err } // CanListenWithRules is CanListen for a caller already holding the chat's rules -// (see StandingWithRules). +// (see ListenerStandingWithRules). func (a *Access) CanListenWithRules(ctx context.Context, chatID *commonpb.ChatId, rules GroupRules, userID *commonpb.UserId) (bool, error) { - standing, err := a.StandingWithRules(ctx, chatID, rules, userID, messagingpb.ViewMode_FULL) + standing, err := a.ListenerStandingWithRules(ctx, chatID, rules, userID, messagingpb.ViewMode_FULL) return standing.CanListen, err } -// Standing is userID's standing in chatID (see Standing) as needed to answer +// ListenerStanding is userID's standing in chatID (see ListenerStanding) as needed to answer // a read under mode: membership, then for a non-member of a group only, the // group's listener rules — whether it has any, and unless mode is REDACTED, // whether userID satisfies them. Under REDACTED the rules are not evaluated, // since a placeholder is the answer either way, so a client that wants a group // blurred never pays a valuation for it. The standing is zero, not an error, // for a chat that does not exist. -func (a *Access) Standing(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode) (Standing, error) { +func (a *Access) ListenerStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode) (ListenerStanding, error) { return a.standing(ctx, chatID, userID, mode, a.storedMembership(chatID, userID), func(ctx context.Context) (GroupRules, error) { return a.chats.GetGroupRules(ctx, chatID) }) } -// StandingWithRules is Standing for a caller already holding the chat's rules +// ListenerStandingWithRules is ListenerStanding for a caller already holding the chat's rules // (read off a canonical record it loaded for its own purposes, see // Chat.GroupRules), so they are not read a second time. The chat is taken as // existing: a caller that has its rules has already told NOT_FOUND from // everything else. A nil rules.Rules is a chat with none, which admits no // non-member in any form (see Access). On error the standing is zero. -func (a *Access) StandingWithRules(ctx context.Context, chatID *commonpb.ChatId, rules GroupRules, userID *commonpb.UserId, mode messagingpb.ViewMode) (Standing, error) { +func (a *Access) ListenerStandingWithRules(ctx context.Context, chatID *commonpb.ChatId, rules GroupRules, userID *commonpb.UserId, mode messagingpb.ViewMode) (ListenerStanding, error) { return a.standing(ctx, chatID, userID, mode, a.storedMembership(chatID, userID), func(context.Context) (GroupRules, error) { return rules, nil }) } -// StandingWithChat is Standing for a caller already holding the chat's +// ListenerStandingWithChat is ListenerStanding for a caller already holding the chat's // canonical record, which decides all it can before the store is asked // again: a DM's membership is read off the record's inline members (see // Chat.HasMember), so a DM costs no membership read at all, and a group's -// rules come off the record as for StandingWithRules. A group's membership +// rules come off the record as for ListenerStandingWithRules. A group's membership // is not on its record and is read from the store, strongly consistent, as // every gate reads it. The chat is taken as existing, as a caller holding // its record has established. -func (a *Access) StandingWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId, mode messagingpb.ViewMode) (Standing, error) { +func (a *Access) ListenerStandingWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId, mode messagingpb.ViewMode) (ListenerStanding, error) { return a.standing(ctx, c.ID, userID, mode, a.recordMembership(c, userID), func(context.Context) (GroupRules, error) { return c.GroupRules(), nil }) } -// PublicStanding is the standing of a viewer who is no one — an +// PublicListenerStanding is the standing of a viewer who is no one — an // unauthenticated read of a chat's public view (see chat.Server.GetChat) — // towards the chat whose canonical record the caller holds. They are on no // roster and satisfy no rule, so they stand exactly where a registered // non-member reading under REDACTED does: a group that carries a listener // rule may be previewed, and nothing else admits them in any form. Nothing // is read: the rules come off the record. -func (a *Access) PublicStanding(c *Chat) Standing { +func (a *Access) PublicListenerStanding(c *Chat) ListenerStanding { if !IsGroupChatID(c.ID) || c.IsPrivate || len(c.Rules().GetListener()) == 0 { - return Standing{} + return ListenerStanding{} } - return Standing{CanPreview: true} + return ListenerStanding{CanPreview: true} } // IsMemberWithChat is IsMember for a caller already holding the chat's // canonical record: a DM is answered off the record, a group from the store -// (see StandingWithChat). +// (see ListenerStandingWithChat). func (a *Access) IsMemberWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId) (bool, error) { return a.recordMembership(c, userID)(ctx) } @@ -312,25 +320,25 @@ func (a *Access) recordMembership(c *Chat, userID *commonpb.UserId) func(context return func(context.Context) (bool, error) { return c.HasMember(userID), nil } } -// standing is the shared shape of Standing, StandingWithRules and -// StandingWithChat: membership, then for a non-member of a group only, the +// standing is the shared shape of ListenerStanding, ListenerStandingWithRules and +// ListenerStandingWithChat: membership, then for a non-member of a group only, the // remembered or freshly evaluated listener rules. membership answers the // membership question — from the store, or off a record the caller holds — and // loadRules supplies the group's rules likewise, called only when they decide // the answer; ErrChatNotFound from it is a plain refusal. -func (a *Access) standing(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode, membership func(context.Context) (bool, error), loadRules func(context.Context) (GroupRules, error)) (Standing, error) { +func (a *Access) standing(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode, membership func(context.Context) (bool, error), loadRules func(context.Context) (GroupRules, error)) (ListenerStanding, error) { isMember, err := membership(ctx) if err != nil { - return Standing{}, err + return ListenerStanding{}, err } if isMember { - return memberStanding, nil + return memberListenerStanding, nil } // Only a group admits a non-member. A DM's rules are nil and would admit // everyone, so the fallback is never reached for one. if !IsGroupChatID(chatID) { - return Standing{}, nil + return ListenerStanding{}, nil } // A redacted read is answered without the rules' verdict, so a remembered @@ -340,73 +348,184 @@ func (a *Access) standing(ctx context.Context, chatID *commonpb.ChatId, userID * key := admissionKey(chatID, userID) if evaluate && a.admitted != nil { if _, ok := a.admitted.Get(key); ok { - return Standing{CanListen: true, CanPreview: true}, nil + return ListenerStanding{CanListen: true, CanPreview: true}, nil } } rules, err := loadRules(ctx) if errors.Is(err, ErrChatNotFound) { - return Standing{}, nil + return ListenerStanding{}, nil } if err != nil { - return Standing{}, err + return ListenerStanding{}, err } // A private group admits no non-member in any form, whatever its rules. // Otherwise, no listener rules, no admission of any kind: only a rule can // admit a non-member (see above). if rules.IsPrivate || len(rules.Rules.GetListener()) == 0 { - return Standing{}, nil + return ListenerStanding{}, nil } if !evaluate { - return Standing{CanPreview: true}, nil + return ListenerStanding{CanPreview: true}, nil } ok, err := a.rules.CanListenWithRules(ctx, chatID, rules, userID) if err != nil { - return Standing{}, err + return ListenerStanding{}, err } if !ok { - return Standing{CanPreview: true}, nil + return ListenerStanding{CanPreview: true}, nil } if a.admitted != nil { a.admitted.SetWithTTL(key, struct{}{}, a.admittedTTL) } - return Standing{CanListen: true, CanPreview: true}, nil + return ListenerStanding{CanListen: true, CanPreview: true}, nil } -// CanSpeak reports whether userID may send in chatID: they are a member, and -// they satisfy the chat's listener and speaker rules. Membership is checked -// first, so a chat's rules are never evaluated for a send on behalf of a -// non-member. It is false, not an error, for a chat that does not exist. -// -// No one speaks in a private group, whether or not its key is stored (see -// Chat.IsPrivate). A group with no key takes nothing by contract. A group -// with one is meant to take encrypted content from its members and nothing -// else, and messaging does not enforce that yet, so lifting the refusal for a -// keyed group now would let plaintext into it. The refusal is decided off the -// rules read, which a store caches with the rules, so it costs nothing a send -// did not already pay. It is every gate CanSpeak stands behind at once: a -// send, an edit, a deletion, a typing notification, and mention suggestions. -// The RuleEvaluator would refuse a private group too, since no rule admits -// anyone to one; the refusal is made here because it is this one that a -// stored key will lift, on membership and the key alone. +// Encryption is what a chat says of encrypted content in it (see SpeakerStanding): +// whether it takes any, and whether it takes anything else. +type Encryption uint8 + +const ( + // EncryptionNone: the chat takes plaintext alone. A public group. + EncryptionNone Encryption = iota + // EncryptionOptional: the chat takes plaintext, and encrypted content + // under SpeakerStanding.Scheme. A DM, whose pairwise key either member's client + // derives on its own, so a conversation can mix the two as clients + // adopt it. + EncryptionOptional + // EncryptionRequired: the chat takes encrypted content under + // SpeakerStanding.Scheme and nothing else. A private group with its key (see + // Chat.IsPrivate): the key is the group's definition, so plaintext never + // appears in one. + EncryptionRequired +) + +// SpeakerStanding is a user's standing to speak in a chat, the way ListenerStanding is their +// standing to read it: whether they may send in it at all (see CanSpeak), and +// if so what the chat takes from them as far as encryption goes, which is the +// chat's and the same for every speaker in it. Who may speak and what a chat +// takes are decided together so that nothing outside this package has to know +// what makes a chat take encrypted content — a DM's pairwise key, a private +// group's stored chat key — only that it does. A refused speaker is the zero +// value: CanSpeak false and nothing about the chat. +type SpeakerStanding struct { + CanSpeak bool + + // Encryption is how the chat takes encrypted content (see Encryption). + Encryption Encryption + + // Scheme is the scheme encrypted content in the chat must carry, the one + // thing in it the server reads. Unset when Encryption is EncryptionNone. + Scheme messagingpb.EncryptedContent_Scheme +} + +// takesEncrypted reports whether the chat takes encrypted content from the +// speaker at all: they may speak, and the chat takes some. It is the second +// half of the gate on an end-to-end encrypted blob upload for the chat (see +// NewBlobEncryptedUploadGate), which is sent as encrypted content. +func (s SpeakerStanding) takesEncrypted() bool { + return s.CanSpeak && s.Encryption != EncryptionNone +} + +// CanSpeak reports whether userID may send in chatID (see SpeakerStanding), for a +// caller that sends nothing the chat could judge: a deletion, a typing +// notification, a mention suggestion. func (a *Access) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { + speaker, err := a.SpeakerStanding(ctx, chatID, userID) + return speaker.CanSpeak, err +} + +// SpeakerStanding is userID's standing to speak in chatID (see SpeakerStanding): they are a +// member, and they satisfy the chat's listener and speaker rules. Membership +// is checked first, so a chat's rules are never evaluated for a send on +// behalf of a non-member. It is the zero value, not an error, for a chat that +// does not exist. +// +// A member of a private group (see Chat.IsPrivate) speaks exactly when the +// group has its key — its creator has stored a key envelope (see KeyEnvelope) +// — and the rules are never evaluated for one: the RuleEvaluator admits no +// one to a private group, since no rule is what admitted its members, so the +// answer is made here on membership and the key alone. A keyless group's +// members are refused, and it is every gate CanSpeak stands behind at once +// that refuses them: a send, an edit, a deletion, a typing notification, and +// mention suggestions. A keyed group's members speak with EncryptionRequired +// under the chat key's scheme, so messaging admits nothing but encrypted +// content there (see messaging.Server.SendMessage). Whether a member holds an +// envelope of their own is not asked: the key is the group's, and a member +// without one cannot read what they would write, which is their client's to +// notice. +// +// The key is found with the rules read every gate makes, which a store caches +// with the rules, and then one strongly consistent point read of the +// creator's envelope. A group found keyed is remembered for the life of the +// process and never read again: the creator's envelope is never deleted (see +// Store.RemoveGroupMember), so a group that has its key has it for good. A +// group found keyless is not remembered, so its first message after the +// creator stores the key is admitted at once, and until then each of its +// members' refused sends costs that one read. A private group with no +// recorded creator can have no key, since the creator's envelope is the only +// possible first one. +func (a *Access) SpeakerStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (SpeakerStanding, error) { isMember, err := a.chats.IsMember(ctx, chatID, userID) if err != nil || !isMember { - return false, err + return SpeakerStanding{}, err } rules, err := a.rules.rulesFor(ctx, chatID, userID) if errors.Is(err, ErrChatNotFound) { // Membership just confirmed the chat; a not-found here is a chat deleted // between the two reads, which admits no one. - return false, nil + return SpeakerStanding{}, nil } if err != nil { - return false, err + return SpeakerStanding{}, err } if rules.IsPrivate { + keyed, err := a.hasKey(ctx, chatID, rules) + if err != nil || !keyed { + return SpeakerStanding{}, err + } + return SpeakerStanding{ + CanSpeak: true, + Encryption: EncryptionRequired, + Scheme: messagingpb.EncryptedContent_CHAT_KEY_XCHACHA20POLY1305, + }, nil + } + ok, err := a.rules.CanSpeakWithRules(ctx, chatID, rules, userID) + if err != nil || !ok { + return SpeakerStanding{}, err + } + if IsGroupChatID(chatID) { + return SpeakerStanding{CanSpeak: true}, nil + } + return SpeakerStanding{ + CanSpeak: true, + Encryption: EncryptionOptional, + Scheme: messagingpb.EncryptedContent_X25519_XCHACHA20POLY1305, + }, nil +} + +// hasKey reports whether the private group whose rules the caller holds has +// its chat key (see SpeakerStanding). It is false for a public group. +func (a *Access) hasKey(ctx context.Context, chatID *commonpb.ChatId, rules GroupRules) (bool, error) { + if !rules.IsPrivate { return false, nil } - return a.rules.CanSpeakWithRules(ctx, chatID, rules, userID) + key := string(chatID.Value) + if _, ok := a.keyed.Get(key); ok { + return true, nil + } + if rules.CreatorID == nil { + return false, nil + } + _, err := a.chats.GetKeyEnvelope(ctx, chatID, rules.CreatorID) + if errors.Is(err, ErrKeyEnvelopeNotFound) { + return false, nil + } + if err != nil { + return false, err + } + a.keyed.Set(key, struct{}{}) + return true, nil } // admissionKey keys the admission cache by (group, user). Group IDs are fixed diff --git a/chat/access_test.go b/chat/access_test.go index 8c28d6f..dee64d5 100644 --- a/chat/access_test.go +++ b/chat/access_test.go @@ -125,7 +125,7 @@ func TestAccess_GroupMember(t *testing.T) { require.True(t, ok) require.Zero(t, f.ocpBalance.asked) - // Speaking is membership and the rules: the dipped member is refused, a + // SpeakerStanding is membership and the rules: the dipped member is refused, a // funded member admitted. ok, err = a.CanSpeak(ctx, f.gated.ID, f.unfunded) require.NoError(t, err) @@ -161,7 +161,7 @@ func TestAccess_GroupMember(t *testing.T) { } // TestAccess_PrivateGroup: a private group is its members' alone in every -// form, and no one speaks in it, a member included. +// form, and its members speak in it exactly when it has its key. func TestAccess_PrivateGroup(t *testing.T) { ctx := context.Background() f := newAccessFixture(t) @@ -171,29 +171,31 @@ func TestAccess_PrivateGroup(t *testing.T) { private := f.chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, IsPrivate: true, CreatorID: creator}) f.chats.join(private.ID, creator) - // A member reads, and does not speak. + // A member reads, and does not speak while the group has no key, which is + // one read of the creator's envelope per ask. ok, err := a.CanListen(ctx, private.ID, creator) require.NoError(t, err) require.True(t, ok) ok, err = a.CanSpeak(ctx, private.ID, creator) require.NoError(t, err) require.False(t, ok) + require.Equal(t, 1, f.chats.envelopeReads) // A non-member has no standing under any mode, however funded, whether // the rules are read from the store or come with the record. modes := []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} for _, mode := range modes { - standing, err := a.Standing(ctx, private.ID, f.funded, mode) + standing, err := a.ListenerStanding(ctx, private.ID, f.funded, mode) require.NoError(t, err) - require.Equal(t, Standing{}, standing, mode) - standing, err = a.StandingWithChat(ctx, private, f.funded, mode) + require.Equal(t, ListenerStanding{}, standing, mode) + standing, err = a.ListenerStandingWithChat(ctx, private, f.funded, mode) require.NoError(t, err) - require.Equal(t, Standing{}, standing, mode) + require.Equal(t, ListenerStanding{}, standing, mode) } ok, err = a.CanSpeak(ctx, private.ID, f.funded) require.NoError(t, err) require.False(t, ok) - require.Equal(t, Standing{}, a.PublicStanding(private)) + require.Equal(t, ListenerStanding{}, a.PublicListenerStanding(private)) // The rules admit no one to it either: asked alone, the evaluator refuses // a private group that an empty rule set would open to everyone. @@ -223,22 +225,139 @@ func TestAccess_PrivateGroup(t *testing.T) { MinimumListenerBalance: &MinimumBalance{Currency: "usd", NativeAmount: accessRequirement}, }) for _, mode := range modes { - standing, err := a.Standing(ctx, ruled.ID, f.funded, mode) + standing, err := a.ListenerStanding(ctx, ruled.ID, f.funded, mode) require.NoError(t, err) - require.Equal(t, Standing{}, standing, mode) - standing, err = a.StandingWithChat(ctx, ruled, f.funded, mode) + require.Equal(t, ListenerStanding{}, standing, mode) + standing, err = a.ListenerStandingWithChat(ctx, ruled, f.funded, mode) require.NoError(t, err) - require.Equal(t, Standing{}, standing, mode) + require.Equal(t, ListenerStanding{}, standing, mode) } - require.Equal(t, Standing{}, a.PublicStanding(ruled)) + require.Equal(t, ListenerStanding{}, a.PublicListenerStanding(ruled)) ok, err = f.rules.CanListen(ctx, ruled.ID, f.funded) require.NoError(t, err) require.False(t, ok) + // The creator's envelope is the group's key. Once it is stored every + // member speaks — one who holds no envelope of their own included, since + // the key is the group's — and a non-member still does not. The key is + // found with one envelope read and remembered: it is never read again, + // so an envelope that vanished (which no store allows) would not be + // noticed. + member := model.MustGenerateUserID() + f.chats.join(private.ID, member) + ok, err = a.CanSpeak(ctx, private.ID, member) + require.NoError(t, err) + require.False(t, ok) + envelopeReads := f.chats.envelopeReads + f.chats.storeKey(private.ID, creator) + for _, u := range []*commonpb.UserId{creator, member, creator} { + ok, err = a.CanSpeak(ctx, private.ID, u) + require.NoError(t, err) + require.True(t, ok) + } + require.Equal(t, envelopeReads+1, f.chats.envelopeReads) + ok, err = a.CanSpeak(ctx, private.ID, f.funded) + require.NoError(t, err) + require.False(t, ok) + f.chats.discardKey(private.ID, creator) + ok, err = a.CanSpeak(ctx, private.ID, creator) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, envelopeReads+1, f.chats.envelopeReads) + + // The key opens the group to its members' sends and to nothing else: a + // non-member has no more standing in a keyed group than in a keyless one. + for _, mode := range modes { + standing, err := a.ListenerStanding(ctx, private.ID, f.funded, mode) + require.NoError(t, err) + require.Equal(t, ListenerStanding{}, standing, mode) + } + + // A member's own envelope is not the group's key: a group whose creator + // has stored nothing is keyless whatever its other members hold. + keyless := f.chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, IsPrivate: true, CreatorID: creator}) + f.chats.join(keyless.ID, member) + f.chats.storeKey(keyless.ID, member) + ok, err = a.CanSpeak(ctx, keyless.ID, member) + require.NoError(t, err) + require.False(t, ok) + + // A private group with no recorded creator can have no key, and nothing + // is read to find that out. + orphan := f.chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, IsPrivate: true}) + f.chats.join(orphan.ID, member) + envelopeReads = f.chats.envelopeReads + ok, err = a.CanSpeak(ctx, orphan.ID, member) + require.NoError(t, err) + require.False(t, ok) + require.Equal(t, envelopeReads, f.chats.envelopeReads) + // No rule was evaluated for any of it. require.Zero(t, f.ocpBalance.asked) } +// TestAccess_Speaking: what each kind of chat takes from a speaker comes with +// the standing — a DM takes plaintext or its pairwise scheme, a public group +// plaintext alone, a keyed private group its chat key's scheme alone — and a +// refused speaker gets the zero value whatever the chat. A private group's +// key is found with one envelope read and then remembered. +func TestAccess_Speaking(t *testing.T) { + ctx := context.Background() + f := newAccessFixture(t) + a := NewAccess(f.chats, f.rules) + + speaker := func(chatID *commonpb.ChatId, userID *commonpb.UserId) SpeakerStanding { + t.Helper() + s, err := a.SpeakerStanding(ctx, chatID, userID) + require.NoError(t, err) + return s + } + dmSpeaking := SpeakerStanding{CanSpeak: true, Encryption: EncryptionOptional, Scheme: messagingpb.EncryptedContent_X25519_XCHACHA20POLY1305} + privateSpeaking := SpeakerStanding{CanSpeak: true, Encryption: EncryptionRequired, Scheme: messagingpb.EncryptedContent_CHAT_KEY_XCHACHA20POLY1305} + + // A DM, with no read beyond membership. + peer := model.MustGenerateUserID() + dm := MustDeriveDmChatID(chatpb.ChatType_DM, f.funded, peer) + f.chats.join(dm, f.funded) + f.chats.join(dm, peer) + require.Equal(t, dmSpeaking, speaker(dm, f.funded)) + require.Equal(t, dmSpeaking, speaker(dm, peer)) + require.Equal(t, SpeakerStanding{}, speaker(dm, f.unfunded)) + require.Zero(t, f.chats.reads) + require.Zero(t, f.chats.envelopeReads) + require.True(t, dmSpeaking.takesEncrypted()) + + // A public group: the rules decide, and nothing is said of encryption. + f.chats.join(f.gated.ID, f.funded) + f.chats.join(f.gated.ID, f.unfunded) + require.Equal(t, SpeakerStanding{CanSpeak: true}, speaker(f.gated.ID, f.funded)) + require.Equal(t, SpeakerStanding{}, speaker(f.gated.ID, f.unfunded)) + require.Equal(t, 2, f.ocpBalance.asked) + require.Zero(t, f.chats.envelopeReads) + require.False(t, SpeakerStanding{CanSpeak: true}.takesEncrypted()) + + // A private group: nothing until the key, then its scheme for every + // member, found once. No rule is evaluated for it. + creator := model.MustGenerateUserID() + private := f.chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, IsPrivate: true, CreatorID: creator}) + f.chats.join(private.ID, creator) + f.chats.join(private.ID, f.unfunded) + require.Equal(t, SpeakerStanding{}, speaker(private.ID, creator)) + require.Equal(t, SpeakerStanding{}, speaker(private.ID, f.unfunded)) + require.Equal(t, 2, f.chats.envelopeReads) + f.chats.storeKey(private.ID, creator) + require.Equal(t, privateSpeaking, speaker(private.ID, creator)) + require.Equal(t, privateSpeaking, speaker(private.ID, f.unfunded)) + require.Equal(t, SpeakerStanding{}, speaker(private.ID, f.funded)) + require.Equal(t, 3, f.chats.envelopeReads) + require.Equal(t, 2, f.ocpBalance.asked) + require.True(t, privateSpeaking.takesEncrypted()) + + // A chat that does not exist. + require.Equal(t, SpeakerStanding{}, speaker(MustGenerateGroupChatID(), f.funded)) + require.False(t, SpeakerStanding{}.takesEncrypted()) +} + func TestAccess_GroupNonMember(t *testing.T) { ctx := context.Background() f := newAccessFixture(t) @@ -398,7 +517,7 @@ func TestAccess_CanListenWithRules(t *testing.T) { // TestAccess_WithChat pins what a caller holding the canonical record saves: // a DM's standing and membership are answered off the record's inline // members with no store read at all, while a group's membership is still the -// store's, and its rules come off the record as for StandingWithRules. +// store's, and its rules come off the record as for ListenerStandingWithRules. func TestAccess_WithChat(t *testing.T) { ctx := context.Background() f := newAccessFixture(t) @@ -416,16 +535,16 @@ func TestAccess_WithChat(t *testing.T) { ok, err := a.IsMemberWithChat(ctx, dm, u) require.NoError(t, err) require.True(t, ok) - standing, err := a.StandingWithChat(ctx, dm, u, messagingpb.ViewMode_FULL) + standing, err := a.ListenerStandingWithChat(ctx, dm, u, messagingpb.ViewMode_FULL) require.NoError(t, err) - require.Equal(t, memberStanding, standing) + require.Equal(t, memberListenerStanding, standing) } ok, err := a.IsMemberWithChat(ctx, dm, third) require.NoError(t, err) require.False(t, ok) - standing, err := a.StandingWithChat(ctx, dm, third, messagingpb.ViewMode_FULL_OR_REDACTED) + standing, err := a.ListenerStandingWithChat(ctx, dm, third, messagingpb.ViewMode_FULL_OR_REDACTED) require.NoError(t, err) - require.Equal(t, Standing{}, standing) + require.Equal(t, ListenerStanding{}, standing) require.Equal(t, reads, f.chats.reads) require.Equal(t, membershipReads, f.chats.membershipReads) @@ -438,17 +557,17 @@ func TestAccess_WithChat(t *testing.T) { require.NoError(t, err) require.True(t, ok) require.Equal(t, membershipReads+1, f.chats.membershipReads) - standing, err = a.StandingWithChat(ctx, f.gated, f.unfunded, messagingpb.ViewMode_FULL) + standing, err = a.ListenerStandingWithChat(ctx, f.gated, f.unfunded, messagingpb.ViewMode_FULL) require.NoError(t, err) - require.Equal(t, memberStanding, standing) + require.Equal(t, memberListenerStanding, standing) require.Equal(t, membershipReads+2, f.chats.membershipReads) // A group non-member is judged by the rules off the record, not the // store's copy of them. reads = f.chats.reads - standing, err = a.StandingWithChat(ctx, f.gated, f.funded, messagingpb.ViewMode_FULL) + standing, err = a.ListenerStandingWithChat(ctx, f.gated, f.funded, messagingpb.ViewMode_FULL) require.NoError(t, err) - require.Equal(t, Standing{CanListen: true, CanPreview: true}, standing) + require.Equal(t, ListenerStanding{CanListen: true, CanPreview: true}, standing) require.Equal(t, reads, f.chats.reads) } @@ -487,8 +606,8 @@ func TestAccess_ViewMode(t *testing.T) { f := newAccessFixture(t) a := NewAccess(f.chats, f.rules) - preview := Standing{CanPreview: true} - full := Standing{CanListen: true, CanPreview: true} + preview := ListenerStanding{CanPreview: true} + full := ListenerStanding{CanListen: true, CanPreview: true} evaluating := []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED} every := append(evaluating, messagingpb.ViewMode_REDACTED) @@ -497,13 +616,13 @@ func TestAccess_ViewMode(t *testing.T) { // that out; under REDACTED the answer is the same without the valuation. for _, mode := range evaluating { asked := f.ocpBalance.asked - standing, err := a.Standing(ctx, f.gated.ID, f.unfunded, mode) + standing, err := a.ListenerStanding(ctx, f.gated.ID, f.unfunded, mode) require.NoError(t, err) require.Equal(t, preview, standing, mode) require.Equal(t, asked+1, f.ocpBalance.asked, mode) } asked := f.ocpBalance.asked - standing, err := a.Standing(ctx, f.gated.ID, f.unfunded, messagingpb.ViewMode_REDACTED) + standing, err := a.ListenerStanding(ctx, f.gated.ID, f.unfunded, messagingpb.ViewMode_REDACTED) require.NoError(t, err) require.Equal(t, preview, standing) require.Equal(t, asked, f.ocpBalance.asked) @@ -512,19 +631,19 @@ func TestAccess_ViewMode(t *testing.T) { // placeholder is the answer whatever the rules say — so their admission is // neither found nor remembered by it: the next evaluating read still pays, // and only then is remembered. - standing, err = a.Standing(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_REDACTED) + standing, err = a.ListenerStanding(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_REDACTED) require.NoError(t, err) require.Equal(t, preview, standing) require.Equal(t, asked, f.ocpBalance.asked) - standing, err = a.Standing(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_FULL_OR_REDACTED) + standing, err = a.ListenerStanding(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_FULL_OR_REDACTED) require.NoError(t, err) require.Equal(t, full, standing) require.Equal(t, asked+1, f.ocpBalance.asked) - standing, err = a.Standing(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_FULL) + standing, err = a.ListenerStanding(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_FULL) require.NoError(t, err) require.Equal(t, full, standing) require.Equal(t, asked+1, f.ocpBalance.asked) - standing, err = a.Standing(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_REDACTED) + standing, err = a.ListenerStanding(ctx, f.gated.ID, f.funded, messagingpb.ViewMode_REDACTED) require.NoError(t, err) require.Equal(t, preview, standing) require.Equal(t, asked+1, f.ocpBalance.asked) @@ -533,9 +652,9 @@ func TestAccess_ViewMode(t *testing.T) { f.chats.join(f.gated.ID, f.unfunded) asked = f.ocpBalance.asked for _, mode := range every { - standing, err := a.Standing(ctx, f.gated.ID, f.unfunded, mode) + standing, err := a.ListenerStanding(ctx, f.gated.ID, f.unfunded, mode) require.NoError(t, err) - require.Equal(t, memberStanding, standing, mode) + require.Equal(t, memberListenerStanding, standing, mode) } require.Equal(t, asked, f.ocpBalance.asked) @@ -551,21 +670,21 @@ func TestAccess_ViewMode(t *testing.T) { f.ocpBalance.set(f.accounts.bind(third), f.usdf, ocp_common.ToCoreMintQuarks(1_000_000)) for _, mode := range every { for _, chatID := range []*commonpb.ChatId{open.ID, dm, MustGenerateGroupChatID()} { - standing, err := a.Standing(ctx, chatID, third, mode) + standing, err := a.ListenerStanding(ctx, chatID, third, mode) require.NoError(t, err) - require.Equal(t, Standing{}, standing, mode) + require.Equal(t, ListenerStanding{}, standing, mode) } } require.Equal(t, asked, f.ocpBalance.asked) // With the rules in hand the answer is the same, without a store read. reads := f.chats.reads - standing, err = a.StandingWithRules(ctx, f.gated.ID, f.gated.GroupRules(), third, messagingpb.ViewMode_REDACTED) + standing, err = a.ListenerStandingWithRules(ctx, f.gated.ID, f.gated.GroupRules(), third, messagingpb.ViewMode_REDACTED) require.NoError(t, err) require.Equal(t, preview, standing) require.Equal(t, reads, f.chats.reads) require.Equal(t, asked, f.ocpBalance.asked) - standing, err = a.StandingWithRules(ctx, f.gated.ID, f.gated.GroupRules(), third, messagingpb.ViewMode_FULL_OR_REDACTED) + standing, err = a.ListenerStandingWithRules(ctx, f.gated.ID, f.gated.GroupRules(), third, messagingpb.ViewMode_FULL_OR_REDACTED) require.NoError(t, err) require.Equal(t, full, standing) require.Equal(t, reads, f.chats.reads) @@ -577,9 +696,9 @@ func TestAccess_ViewMode(t *testing.T) { fourth := model.MustGenerateUserID() f.accounts.bind(fourth) f.ocpBalance.err = errors.New("unavailable") - _, err = a.Standing(ctx, f.gated.ID, fourth, messagingpb.ViewMode_FULL_OR_REDACTED) + _, err = a.ListenerStanding(ctx, f.gated.ID, fourth, messagingpb.ViewMode_FULL_OR_REDACTED) require.Error(t, err) - standing, err = a.Standing(ctx, f.gated.ID, fourth, messagingpb.ViewMode_REDACTED) + standing, err = a.ListenerStanding(ctx, f.gated.ID, fourth, messagingpb.ViewMode_REDACTED) require.NoError(t, err) require.Equal(t, preview, standing) } @@ -587,33 +706,33 @@ func TestAccess_ViewMode(t *testing.T) { // TestStanding_Reading pins the mode table (see messagingpb.ViewMode): the // mode never widens a standing, and a mode this version does not know denies. func TestStanding_Reading(t *testing.T) { - none := Standing{} - preview := Standing{CanPreview: true} - full := Standing{CanListen: true, CanPreview: true} + none := ListenerStanding{} + preview := ListenerStanding{CanPreview: true} + full := ListenerStanding{CanListen: true, CanPreview: true} for _, tc := range []struct { - standing Standing + standing ListenerStanding mode messagingpb.ViewMode want Reading }{ {none, messagingpb.ViewMode_FULL, ReadingDenied}, {preview, messagingpb.ViewMode_FULL, ReadingDenied}, {full, messagingpb.ViewMode_FULL, ReadingFull}, - {memberStanding, messagingpb.ViewMode_FULL, ReadingFull}, + {memberListenerStanding, messagingpb.ViewMode_FULL, ReadingFull}, {none, messagingpb.ViewMode_FULL_OR_REDACTED, ReadingDenied}, {preview, messagingpb.ViewMode_FULL_OR_REDACTED, ReadingRedacted}, {full, messagingpb.ViewMode_FULL_OR_REDACTED, ReadingFull}, - {memberStanding, messagingpb.ViewMode_FULL_OR_REDACTED, ReadingFull}, + {memberListenerStanding, messagingpb.ViewMode_FULL_OR_REDACTED, ReadingFull}, {none, messagingpb.ViewMode_REDACTED, ReadingDenied}, {preview, messagingpb.ViewMode_REDACTED, ReadingRedacted}, {full, messagingpb.ViewMode_REDACTED, ReadingRedacted}, - {memberStanding, messagingpb.ViewMode_REDACTED, ReadingRedacted}, + {memberListenerStanding, messagingpb.ViewMode_REDACTED, ReadingRedacted}, {none, messagingpb.ViewMode(99), ReadingDenied}, {preview, messagingpb.ViewMode(99), ReadingDenied}, {full, messagingpb.ViewMode(99), ReadingDenied}, - {memberStanding, messagingpb.ViewMode(99), ReadingDenied}, + {memberListenerStanding, messagingpb.ViewMode(99), ReadingDenied}, } { require.Equal(t, tc.want, tc.standing.Reading(tc.mode), "%+v under %v", tc.standing, tc.mode) } diff --git a/chat/blob.go b/chat/blob.go index a9f1a0a..73206f4 100644 --- a/chat/blob.go +++ b/chat/blob.go @@ -8,27 +8,47 @@ import ( "github.com/code-payments/flipcash2-server/blob" ) -// dmMembership adapts a chat Store to blob.DMMembership, the gate the blob -// service consults before reserving an end-to-end encrypted upload for a DM. -// It lives here rather than in blob because blob must not import chat; the -// dependency is one-way. Keeping it here also keeps the chat ID discriminator -// (see DmChatIDSize) in the package that owns it: a group ID is refused before -// membership is ever read, because encrypted content is a DM's alone. That -// includes a private group's (see Chat.IsPrivate): no one speaks in one yet, -// keyed or not, so there is nothing an upload for one could be sent in. -type dmMembership struct { - store Store +// encryptedUploadGate adapts an Access to blob.EncryptedUploadGate, the gate +// the blob service consults before reserving an end-to-end encrypted upload +// for a chat. It lives here rather than in blob because blob must not import +// chat; the dependency is one-way. The rule is membership, then the speaker's +// standing (see SpeakerStanding.takesEncrypted): an encrypted blob is sent +// as encrypted content, so an upload for a chat is admitted exactly when the +// caller is a member who may send encrypted content there — of a DM, or of a +// private group that has its key — and refused for a chat that takes none, a +// public group or a keyless private group, however well the caller stands in +// it. That also keeps a DM with the Flipcash team account, where no one +// sends, free of uploads. A chat ID of neither shape is refused before +// anything is read, and a non-member before the chat is looked at. +// +// The standing already rests on membership, so the explicit check in front +// of it repeats a read (one a store caches for a DM, one keyed read for a +// group). It is there so the gate says what it is — a member's alone — in its +// own terms, rather than through what SpeakerStanding happens to check first, +// and so a non-member is refused before the rules or the key are read. A +// public group's member is refused only after their standing is found in +// full, rules included, since what the group takes comes with the standing; +// no client that follows the contract asks, so the cost is theirs alone. +type encryptedUploadGate struct { + access *Access } -// NewBlobDMMembership returns a blob.DMMembership backed by the given chat -// store, for wiring the blob service. -func NewBlobDMMembership(store Store) blob.DMMembership { - return &dmMembership{store: store} +// NewBlobEncryptedUploadGate returns a blob.EncryptedUploadGate backed by the +// given Access, for wiring the blob service. It should be the one Access the +// chat and messaging servers share, so the gate and the sends it stands +// before agree. +func NewBlobEncryptedUploadGate(access *Access) blob.EncryptedUploadGate { + return &encryptedUploadGate{access: access} } -func (m *dmMembership) IsDMMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { - if len(chatID.GetValue()) != DmChatIDSize { +func (g *encryptedUploadGate) CanUploadEncrypted(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { + if n := len(chatID.GetValue()); n != DmChatIDSize && n != GroupChatIDSize { return false, nil } - return m.store.IsMember(ctx, chatID, userID) + isMember, err := g.access.IsMember(ctx, chatID, userID) + if err != nil || !isMember { + return false, err + } + speaker, err := g.access.SpeakerStanding(ctx, chatID, userID) + return speaker.takesEncrypted(), err } diff --git a/chat/blob_test.go b/chat/blob_test.go new file mode 100644 index 0000000..afa9ad0 --- /dev/null +++ b/chat/blob_test.go @@ -0,0 +1,73 @@ +package chat + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + chatpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1" + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" + + "github.com/code-payments/flipcash2-server/model" +) + +// TestBlobEncryptedUploadGate: an encrypted upload is admitted for a chat +// that takes encrypted content — a DM, or a private group with its key — and +// only for a caller who may speak in it; every other chat refuses before +// membership is read. +func TestBlobEncryptedUploadGate(t *testing.T) { + ctx := context.Background() + f := newAccessFixture(t) + gate := NewBlobEncryptedUploadGate(NewAccess(f.chats, f.rules)) + + can := func(chatID *commonpb.ChatId, userID *commonpb.UserId) bool { + t.Helper() + ok, err := gate.CanUploadEncrypted(ctx, chatID, userID) + require.NoError(t, err) + return ok + } + + // A DM: its members, and no one else. + peer := model.MustGenerateUserID() + dm := MustDeriveDmChatID(chatpb.ChatType_DM, f.funded, peer) + f.chats.join(dm, f.funded) + f.chats.join(dm, peer) + require.True(t, can(dm, f.funded)) + require.True(t, can(dm, peer)) + require.False(t, can(dm, f.unfunded)) + + // A non-member is refused on membership alone: nothing else is read. + rulesReads, envelopeReads := f.chats.reads, f.chats.envelopeReads + require.False(t, can(f.gated.ID, f.funded)) + require.Equal(t, rulesReads, f.chats.reads) + require.Zero(t, f.ocpBalance.asked) + + // A public group takes none, from a member who may speak in it included. + // Their standing is found in full first — membership, then the rules — + // so the refusal costs a valuation, which only a client that should not + // be asking pays. + f.chats.join(f.gated.ID, f.funded) + require.False(t, can(f.gated.ID, f.funded)) + require.Equal(t, 1, f.ocpBalance.asked) + + // A private group takes none until its creator stores their envelope, and + // then takes its members' and no one else's. + creator := model.MustGenerateUserID() + private := f.chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, IsPrivate: true, CreatorID: creator}) + f.chats.join(private.ID, creator) + f.chats.join(private.ID, f.unfunded) + require.False(t, can(private.ID, creator)) + require.False(t, can(private.ID, f.unfunded)) + f.chats.storeKey(private.ID, creator) + require.True(t, can(private.ID, creator)) + require.True(t, can(private.ID, f.unfunded)) + envelopeReads = f.chats.envelopeReads + require.False(t, can(private.ID, f.funded)) + require.Equal(t, envelopeReads, f.chats.envelopeReads) + require.Equal(t, 1, f.ocpBalance.asked) + + // A chat ID of neither shape, and a group that does not exist. + require.False(t, can(&commonpb.ChatId{Value: []byte{1, 2, 3}}, f.funded)) + require.False(t, can(MustGenerateGroupChatID(), f.funded)) +} diff --git a/chat/create.go b/chat/create.go index ed21f75..e025265 100644 --- a/chat/create.go +++ b/chat/create.go @@ -214,7 +214,7 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* return nil, status.Error(codes.Internal, "") } - metadata, err := s.hydrate(ctx, userID, memberStanding, ReadingFull, []*Chat{c}) + metadata, err := s.hydrate(ctx, userID, memberListenerStanding, ReadingFull, []*Chat{c}) if err != nil { // The group exists; only the read back failed. It will surface on the // creator's next feed read. @@ -252,8 +252,8 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* // canCreatePrivateGroup reports whether userID may create a private group: // today, only a staff user. It is a transitional gate, like use_e2ee's (see // useE2ee): private groups are being built in steps, and one created now can -// be given its key but has no lobby and takes no messages, so it is a group -// nobody can join or speak in. Opening creation to everyone is removing this +// be given its key and messaged by its creator, but has no lobby, so it is a +// group nobody else can join. Opening creation to everyone is removing this // check, once the rest is built. func (s *Server) canCreatePrivateGroup(ctx context.Context, userID *commonpb.UserId) (bool, error) { return s.accounts.IsStaff(ctx, userID) @@ -266,7 +266,7 @@ func (s *Server) canCreatePrivateGroup(ctx context.Context, userID *commonpb.Use // creation, and the creator may even have left, in which case they see it as // the non-member they are. Nothing is published; a retry is not news. func (s *Server) replayStartChat(ctx context.Context, log *zap.Logger, userID *commonpb.UserId, c *Chat) (*chatpb.StartChatResponse, error) { - standing, err := s.access.StandingWithRules(ctx, c.ID, c.GroupRules(), userID, messagingpb.ViewMode_FULL) + standing, err := s.access.ListenerStandingWithRules(ctx, c.ID, c.GroupRules(), userID, messagingpb.ViewMode_FULL) if err != nil { log.With(zap.Error(err)).Warn("Failure determining chat standing") return nil, status.Error(codes.Internal, "") diff --git a/chat/edit.go b/chat/edit.go index d99e1e7..176ed0c 100644 --- a/chat/edit.go +++ b/chat/edit.go @@ -136,7 +136,7 @@ func (s *Server) EditChat(ctx context.Context, req *chatpb.EditChatRequest) (*ch } } - metadata, err := s.hydrate(ctx, userID, memberStanding, ReadingFull, []*Chat{c}) + metadata, err := s.hydrate(ctx, userID, memberListenerStanding, ReadingFull, []*Chat{c}) if err != nil { // The edit has landed; only the read back failed. A retry finds every // field already set and answers from the record. diff --git a/chat/feed.go b/chat/feed.go index 0bd9d21..c13735f 100644 --- a/chat/feed.go +++ b/chat/feed.go @@ -99,7 +99,7 @@ func (s *Server) GetDmChatFeed(ctx context.Context, req *chatpb.GetDmChatFeedReq chats = chats[:limit] } - metadata, err := s.hydrate(ctx, userID, memberStanding, ReadingFull, chats) + metadata, err := s.hydrate(ctx, userID, memberListenerStanding, ReadingFull, chats) if err != nil { log.With(zap.Error(err)).Warn("Failure hydrating DM feed metadata") return nil, status.Error(codes.Internal, "") @@ -292,7 +292,7 @@ func (s *Server) GetGroupChatFeed(ctx context.Context, req *chatpb.GetGroupChatF return nil, status.Error(codes.Internal, "") } - metadata, err := s.hydrate(ctx, userID, memberStanding, ReadingFull, page) + metadata, err := s.hydrate(ctx, userID, memberListenerStanding, ReadingFull, page) if err != nil { log.With(zap.Error(err)).Warn("Failure hydrating group feed metadata") return nil, status.Error(codes.Internal, "") diff --git a/chat/key.go b/chat/key.go index 37409fb..c8e09dc 100644 --- a/chat/key.go +++ b/chat/key.go @@ -43,9 +43,11 @@ import ( // Nothing is published by either: a user's other devices fetch the envelope // when they need it. // -// Storing a key changes nothing else yet. Speaking in a private group, and -// the lobby its members are admitted from, are not built (see -// Chat.IsPrivate), so today the only envelope a group holds is its creator's. +// Storing the creator's envelope is what opens the group to its members' +// messages (see Access.SpeakerStanding); nothing is published for that +// either, since the creator's client is the one that stored it. The lobby its +// members are admitted from is not built (see Chat.IsPrivate), so today the +// only envelope a group holds is its creator's. func (s *Server) SetKeyEnvelope(ctx context.Context, req *chatpb.SetKeyEnvelopeRequest) (*chatpb.SetKeyEnvelopeResponse, error) { userID, err := s.authz.Authorize(ctx, req, &req.Auth) diff --git a/chat/member.go b/chat/member.go index 118e333..ddaaa5c 100644 --- a/chat/member.go +++ b/chat/member.go @@ -104,7 +104,7 @@ func (s *Server) JoinChat(ctx context.Context, req *chatpb.JoinChatRequest) (*ch return nil, status.Error(codes.Internal, "") } - metadata, err := s.hydrate(ctx, userID, memberStanding, ReadingFull, []*Chat{c}) + metadata, err := s.hydrate(ctx, userID, memberListenerStanding, ReadingFull, []*Chat{c}) if err != nil { // The join has landed; only the read back failed. A retry is the // idempotent path above and returns the metadata then. diff --git a/chat/model.go b/chat/model.go index 9a718d4..3ad84e3 100644 --- a/chat/model.go +++ b/chat/model.go @@ -277,12 +277,12 @@ func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatp // shown to any registered user. // // A private group's chat key reaches the server only as its members' key -// envelopes (see KeyEnvelope), and nothing is meant to happen in one until -// its creator has stored theirs. Speaking in a private group is not built: -// messaging does not yet require its content to be encrypted, so no one -// speaks in one, keyed or not (see Access.CanSpeak). The lobby a user would -// enter to be admitted is not built either, so its creator is the only -// member it can have. +// envelopes (see KeyEnvelope), and nothing happens in one until its creator +// has stored theirs: a keyless group's members are refused every send, and +// a keyed group's members send encrypted content under the group's scheme +// and nothing else (see Access.SpeakerStanding and +// messaging.Server.SendMessage). The lobby a user would enter to be admitted +// is not built, so its creator is the only member it can have. // // CreatorID is the user who created the group, or nil when unknown (a DM has // none, and so does any group written before the field existed). It is fixed diff --git a/chat/roster.go b/chat/roster.go index ace309a..3b2dd06 100644 --- a/chat/roster.go +++ b/chat/roster.go @@ -123,7 +123,7 @@ func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (* return nil, status.Error(codes.Internal, "") } - standing, err := s.access.StandingWithChat(ctx, c, userID, messagingpb.ViewMode_FULL) + standing, err := s.access.ListenerStandingWithChat(ctx, c, userID, messagingpb.ViewMode_FULL) if err != nil { log.With(zap.Error(err)).Warn("Failure determining chat standing") return nil, status.Error(codes.Internal, "") diff --git a/chat/rules_test.go b/chat/rules_test.go index e76d82b..389945e 100644 --- a/chat/rules_test.go +++ b/chat/rules_test.go @@ -123,11 +123,11 @@ func (f *fakeOcpBalance) GetBalances(_ context.Context, req *ocp_balancepb.GetBa return resp, nil } -// fakeChats is a Store that serves rules from a map of group records and -// membership from a set, counting the reads of each, so a test can see which -// evaluations touched the store. Only GetGroupRules and IsMember are -// exercised, as above; any other read, the record's included, panics on the -// nil embedded Store. +// fakeChats is a Store that serves rules from a map of group records, +// membership from a set, and key envelopes from another, counting the reads +// of each, so a test can see which evaluations touched the store. Only +// GetGroupRules, IsMember and GetKeyEnvelope are exercised, as above; any +// other read, the record's included, panics on the nil embedded Store. type fakeChats struct { Store @@ -136,10 +136,31 @@ type fakeChats struct { members map[string]bool membershipReads int + + envelopes map[string]bool + envelopeReads int } func newFakeChats() *fakeChats { - return &fakeChats{chats: make(map[string]*Chat), members: make(map[string]bool)} + return &fakeChats{chats: make(map[string]*Chat), members: make(map[string]bool), envelopes: make(map[string]bool)} +} + +// storeKey records that userID has a key envelope for chatID; discardKey +// removes it. What the envelope holds is never read, so none is kept. +func (f *fakeChats) storeKey(chatID *commonpb.ChatId, userID *commonpb.UserId) { + f.envelopes[string(chatID.Value)+string(userID.Value)] = true +} + +func (f *fakeChats) discardKey(chatID *commonpb.ChatId, userID *commonpb.UserId) { + delete(f.envelopes, string(chatID.Value)+string(userID.Value)) +} + +func (f *fakeChats) GetKeyEnvelope(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (KeyEnvelope, error) { + f.envelopeReads++ + if !f.envelopes[string(chatID.Value)+string(userID.Value)] { + return KeyEnvelope{}, ErrKeyEnvelopeNotFound + } + return KeyEnvelope{WrappedBy: userID}, nil } func (f *fakeChats) put(c *Chat) *Chat { diff --git a/chat/server.go b/chat/server.go index 098f583..9fc8151 100644 --- a/chat/server.go +++ b/chat/server.go @@ -250,7 +250,7 @@ func NewServer( // rules and roster summary are what a user weighs before joining, and what a // client renders for a group it was pointed at (see Access for the rules). What // the caller's standing decides, combined with the view mode they asked for -// (see Standing.Reading), is how much of the group comes with it: +// (see ListenerStanding.Reading), is how much of the group comes with it: // // - A member sees everything, as before: the record, themselves as the // hydrated member with their pointers, and the group's messaging state — @@ -268,7 +268,7 @@ func NewServer( // would admit them. // - Under REDACTED anyone who may read the group at all — a member too — // sees its last message redacted, and the rules are not evaluated for a -// non-member (see Access.Standing). +// non-member (see Access.ListenerStanding). // // A private group (see Chat.IsPrivate) falls out of the same rules. Its record // is returned to any registered user, with is_private set, so that one who is @@ -317,7 +317,7 @@ func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chat // than a scan of the member list, so a group's membership is never // enumerated on behalf of a caller who turns out not to be a member — and, // for a non-member of a group, the listener rules. - standing, err := s.access.StandingWithChat(ctx, c, userID, req.GetViewMode()) + standing, err := s.access.ListenerStandingWithChat(ctx, c, userID, req.GetViewMode()) if err != nil { log.With(zap.Error(err)).Warn("Failure determining chat standing") return nil, status.Error(codes.Internal, "") @@ -340,7 +340,7 @@ func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chat // getPublicChat is GetChat for an unauthenticated caller: the chat's public // view, which is what a registered non-member previewing the group gets under -// REDACTED (see Access.PublicStanding) — the record, and the redacted +// REDACTED (see Access.PublicListenerStanding) — the record, and the redacted // messaging state when the group carries a listener rule — with none of the // per-viewer fields, since there is no viewer: no hydrated member, no // is_hidden, no viewer_state. It is REDACTED or nothing: any other mode is @@ -367,7 +367,7 @@ func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest) return &chatpb.GetChatResponse{Result: chatpb.GetChatResponse_DENIED}, nil } - standing := s.access.PublicStanding(c) + standing := s.access.PublicListenerStanding(c) metadata, err := s.hydrate(ctx, nil, standing, standing.Reading(req.GetViewMode()), []*Chat{c}) if err != nil { log.With(zap.Error(err)).Warn("Failure hydrating chat metadata") @@ -388,12 +388,12 @@ func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest) // phone number in one call. The calls are independent and run concurrently, so // a page costs the slowest of them rather than their sum. // -// The standing is the viewer's towards every chat in the set (see Standing), +// The standing is the viewer's towards every chat in the set (see ListenerStanding), // and the reading is what the viewer's read of every chat in the set is -// answered with (see Standing.Reading). Together they decide what is hydrated +// answered with (see ListenerStanding.Reading). Together they decide what is hydrated // at all: what they withhold is never read, not read and dropped. Most // callers hydrate chats the viewer is a member of — a feed built from their -// memberships, a join or creation that just landed — and pass memberStanding +// memberships, a join or creation that just landed — and pass memberListenerStanding // and ReadingFull. GetChat hydrates a group for whoever asks, and passes what // Access found under the mode the client asked for: // @@ -467,7 +467,7 @@ func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest) // GetChat). A picture whose original no longer resolves is left with its stored // ORIGINAL for the client to treat as unavailable, rather than failing the // whole read. -func (s *Server) hydrate(ctx context.Context, viewerID *commonpb.UserId, standing Standing, reading Reading, chats []*Chat) ([]*chatpb.Metadata, error) { +func (s *Server) hydrate(ctx context.Context, viewerID *commonpb.UserId, standing ListenerStanding, reading Reading, chats []*Chat) ([]*chatpb.Metadata, error) { var msgRefs []MessageRef var seqChatIDs []*commonpb.ChatId var pointerRefs []PointerRef diff --git a/event/chat_preview.go b/event/chat_preview.go index f23b563..955c02a 100644 --- a/event/chat_preview.go +++ b/event/chat_preview.go @@ -114,17 +114,17 @@ func (p *chatPreview) shape(e *eventpb.Event) (*eventpb.Event, error) { } // chatStanding is userID's standing in chatID as needed to answer a read -// under mode (see chat.Access.Standing), by the same rule as every other read +// under mode (see chat.Access.ListenerStanding), by the same rule as every other read // under a ViewMode. It reads the chat's canonical record itself so that a // chat that does not exist is chat.ErrChatNotFound, which a preview's open // reports as NOT_FOUND, distinct from the DENIED that a standing alone would // give it. -func (s *Server) chatStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode) (chat.Standing, error) { +func (s *Server) chatStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode) (chat.ListenerStanding, error) { c, err := s.chats.GetChatByID(ctx, chatID) if err != nil { - return chat.Standing{}, err + return chat.ListenerStanding{}, err } - return s.access.StandingWithChat(ctx, c, userID, mode) + return s.access.ListenerStandingWithChat(ctx, c, userID, mode) } // streamChatPreview serves a preview of one group chat (see chatPreview): it diff --git a/messaging/access.go b/messaging/access.go index e8be87b..d961f9b 100644 --- a/messaging/access.go +++ b/messaging/access.go @@ -20,7 +20,7 @@ import ( // // - reading: every read that returns messages — GetMessage, GetMessages, // GetDelta. It answers not just whether the caller may read but how the -// read is answered, full or redacted (see chat.Standing.Reading), from the +// read is answered, full or redacted (see chat.ListenerStanding.Reading), from the // caller's standing and the ViewMode they asked for. A member always // reads in full; a non-member reads a group in full if they satisfy its // listener rules, so a qualifying user can preview a group before joining, @@ -30,7 +30,7 @@ import ( // no ViewMode, since they return no message, and are answered for anyone // who may read the chat at all — a member, or a non-member of a group // that carries a listener rule, whatever the rules say of them (see -// chat.Standing.CanPreview). Reactions are an overlay: who reacted, with +// chat.ListenerStanding.CanPreview). Reactions are an overlay: who reacted, with // what, on which message is the conversation's movement, which a // redacted view shows, not its words, and the event stream delivers the // same overlay to a redacted preview (see redact.ChatUpdate). The gate is @@ -40,10 +40,16 @@ import ( // A pointer advance leaves a per-user item in the chat's partition, and a // reaction is something other members see, so neither is open to a // non-member however well they satisfy the rules. -// - canSpeak: every send — a message, an edit, a deletion, a typing -// notification. Members who satisfy the listener and speaker rules, which -// in a DM with the Flipcash team account include a Never rule no one -// satisfies (see chat.RuleEvaluator). +// - canSpeak and speakerStanding: every send — a message, an edit, a deletion, a +// typing notification. Members who satisfy the listener and speaker +// rules, which in a DM with the Flipcash team account include a Never +// rule no one satisfies (see chat.RuleEvaluator); in a private group, +// members of one that has its chat key, with no rule asked (see +// chat.Access.SpeakerStanding). A send or edit asks for the whole standing, +// since it also says what the chat takes — plaintext, or encrypted +// content under which scheme — which encryptionVerdict applies to the +// content; a deletion or typing notification carries nothing to judge +// and asks the boolean. // // A member's read is answered on membership alone, without evaluating a rule, // even though a listener rule is what admitted them: reads are the hot path, @@ -59,7 +65,7 @@ import ( // rules, since nothing else can admit them; Access bounds what that costs. func (s *Server) reading(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode) (chat.Reading, error) { - standing, err := s.access.Standing(ctx, chatID, userID, mode) + standing, err := s.access.ListenerStanding(ctx, chatID, userID, mode) if err != nil { log.With(zap.Error(err)).Warn("Failure determining chat standing") return chat.ReadingDenied, status.Error(codes.Internal, "") @@ -71,14 +77,14 @@ func (s *Server) reading(ctx context.Context, log *zap.Logger, chatID *commonpb. // read of its reaction overlay: CanPreview says whether they are answered at // all, IsMember whether they can have an overlay entry of their own (see // applySelfReactions). It is found under ViewMode REDACTED so that no rule is -// evaluated and no admission remembered (see chat.Access.Standing): a +// evaluated and no admission remembered (see chat.Access.ListenerStanding): a // redacted reader and a full one see the same overlay, so its verdict is // never needed. -func (s *Server) overlayStanding(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, userID *commonpb.UserId) (chat.Standing, error) { - standing, err := s.access.Standing(ctx, chatID, userID, messagingpb.ViewMode_REDACTED) +func (s *Server) overlayStanding(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, userID *commonpb.UserId) (chat.ListenerStanding, error) { + standing, err := s.access.ListenerStanding(ctx, chatID, userID, messagingpb.ViewMode_REDACTED) if err != nil { log.With(zap.Error(err)).Warn("Failure determining chat standing") - return chat.Standing{}, status.Error(codes.Internal, "") + return chat.ListenerStanding{}, status.Error(codes.Internal, "") } return standing, nil } @@ -100,3 +106,12 @@ func (s *Server) canSpeak(ctx context.Context, log *zap.Logger, chatID *commonpb } return ok, nil } + +func (s *Server) speakerStanding(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, userID *commonpb.UserId) (chat.SpeakerStanding, error) { + speaker, err := s.access.SpeakerStanding(ctx, chatID, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking chat speak access") + return chat.SpeakerStanding{}, status.Error(codes.Internal, "") + } + return speaker, nil +} diff --git a/messaging/message.go b/messaging/message.go index eb773d9..e8a7b36 100644 --- a/messaging/message.go +++ b/messaging/message.go @@ -150,17 +150,22 @@ func (s *Server) SendMessage(ctx context.Context, req *messagingpb.SendMessageRe return &messagingpb.SendMessageResponse{Result: messagingpb.SendMessageResponse_DENIED}, nil } - if allowed, err := s.canSpeak(ctx, log, req.ChatId, userID); err != nil { + speaker, err := s.speakerStanding(ctx, log, req.ChatId, userID) + if err != nil { return nil, err - } else if !allowed { + } + if !speaker.CanSpeak { return &messagingpb.SendMessageResponse{Result: messagingpb.SendMessageResponse_DENIED}, nil } - // Encrypted content is between a DM's two members, under the DM's scheme - // (see encryptionAllowed). Checked after the speaker gate so a non-member - // is DENIED like any other send. - if !encryptionAllowed(req.ChatId, req.Content) { + // What the chat takes as far as encryption goes (see encryptionVerdict). + // Judged after the speaker gate so a non-member is DENIED like any other + // send. + switch encryptionVerdictOf(speaker, req.Content) { + case encryptionNotAllowed: return &messagingpb.SendMessageResponse{Result: messagingpb.SendMessageResponse_ENCRYPTION_NOT_ALLOWED}, nil + case encryptionRequired: + return &messagingpb.SendMessageResponse{Result: messagingpb.SendMessageResponse_ENCRYPTION_REQUIRED}, nil } // The replied-to message must exist in this chat and be repliable. Checked @@ -214,15 +219,20 @@ func (s *Server) EditMessage(ctx context.Context, req *messagingpb.EditMessageRe return &messagingpb.EditMessageResponse{Result: messagingpb.EditMessageResponse_DENIED}, nil } - if allowed, err := s.canSpeak(ctx, log, req.ChatId, userID); err != nil { + speaker, err := s.speakerStanding(ctx, log, req.ChatId, userID) + if err != nil { return nil, err - } else if !allowed { + } + if !speaker.CanSpeak { return &messagingpb.EditMessageResponse{Result: messagingpb.EditMessageResponse_DENIED}, nil } // The same rule as a send. - if !encryptionAllowed(req.ChatId, req.Content) { + switch encryptionVerdictOf(speaker, req.Content) { + case encryptionNotAllowed: return &messagingpb.EditMessageResponse{Result: messagingpb.EditMessageResponse_ENCRYPTION_NOT_ALLOWED}, nil + case encryptionRequired: + return &messagingpb.EditMessageResponse{Result: messagingpb.EditMessageResponse_ENCRYPTION_REQUIRED}, nil } // The target must exist in this chat. Checked after membership so non-members @@ -422,7 +432,7 @@ func (s *Server) DeleteMessage(ctx context.Context, req *messagingpb.DeleteMessa // later until it is explicitly allowed. repliedMessageID is non-nil // only for a valid reply, signaling the caller to verify the replied-to message // exists and is repliable. Whether encrypted content is allowed depends on the -// chat and so is the caller's to enforce (see encryptionAllowed), as is the +// chat and so is the caller's to enforce (see encryptionVerdictOf), as is the // rule that an edit never downgrades an encrypted message to plaintext, which // depends on the message being edited. // @@ -430,7 +440,7 @@ func (s *Server) DeleteMessage(ctx context.Context, req *messagingpb.DeleteMessa // or a reply whose body is either — is the recipient's to check, not the // server's, so a reply inside it names a replied-to message the server never // sees or verifies, and media inside it names blobs the server never shares -// into the chat (an encrypted blob is granted to its DM when it becomes READY, +// into the chat (an encrypted blob is granted to its chat when it becomes READY, // see blob.Finalizer) and never hydrates. func clientAllowedContent(content []*messagingpb.Content) (repliedMessageID *messagingpb.MessageId, ok bool) { if len(content) != 1 { @@ -463,22 +473,44 @@ func isEncrypted(content []*messagingpb.Content) bool { return len(content) == 1 && content[0].GetEncrypted() != nil } -// encryptionAllowed reports whether a chat takes content as far as encryption -// goes: plaintext always, and encrypted content only under the scheme the chat -// uses, the one thing in it the server reads. A DM uses the pairwise scheme, -// X25519_XCHACHA20POLY1305. A group takes no encrypted content at all: -// CHAT_KEY_XCHACHA20POLY1305 is a private group's scheme, and a private group -// never reaches this rule, since no one speaks in one yet (see -// chat.Access.CanSpeak) and its sends are DENIED at the speaker gate before -// it. A refusal is ENCRYPTION_NOT_ALLOWED on either RPC. -func encryptionAllowed(chatID *commonpb.ChatId, content []*messagingpb.Content) bool { +// encryptionVerdict is what a chat says of content as far as encryption goes +// (see encryptionVerdictOf): it takes it, it takes no encrypted content like +// it, or it takes nothing but encrypted content. +type encryptionVerdict uint8 + +const ( + encryptionOK encryptionVerdict = iota + encryptionNotAllowed + encryptionRequired +) + +// encryptionVerdictOf judges content against the speaker's standing in the +// chat (see chat.SpeakerStanding), the one thing in encrypted content the server +// reads being its scheme. What a chat takes is the chat domain's to say and +// comes with the standing; this only applies it: +// +// - EncryptionNone (a public group) takes plaintext alone: any encrypted +// content is ENCRYPTION_NOT_ALLOWED. +// - EncryptionOptional (a DM) takes plaintext, and encrypted content under +// the standing's Scheme. Any other scheme is ENCRYPTION_NOT_ALLOWED. +// - EncryptionRequired (a private group with its key) takes encrypted +// content under the standing's Scheme and nothing else: plaintext is +// ENCRYPTION_REQUIRED, including a reply, which is encrypted whole, and +// any other scheme is ENCRYPTION_NOT_ALLOWED. +// +// It is asked only of a speaker the gate admitted, so a keyless private +// group, whose members the gate refuses, never reaches it. +func encryptionVerdictOf(speaker chat.SpeakerStanding, content []*messagingpb.Content) encryptionVerdict { if !isEncrypted(content) { - return true + if speaker.Encryption == chat.EncryptionRequired { + return encryptionRequired + } + return encryptionOK } - if chat.IsGroupChatID(chatID) { - return false + if speaker.Encryption == chat.EncryptionNone || content[0].GetEncrypted().Scheme != speaker.Scheme { + return encryptionNotAllowed } - return content[0].GetEncrypted().Scheme == messagingpb.EncryptedContent_X25519_XCHACHA20POLY1305 + return encryptionOK } // validReplyBody reports whether a reply's body is content a client may author: diff --git a/messaging/tests/server.go b/messaging/tests/server.go index d6705e4..fd7ccc5 100644 --- a/messaging/tests/server.go +++ b/messaging/tests/server.go @@ -95,7 +95,8 @@ func RunServerTests(t *testing.T, badges badge.Store, blocklists blocklist.Store testServer_ViewMode, testServer_StaffOnlyGroup_Rules, testServer_CreatorOnlyGroup_Rules, - testServer_PrivateGroup_NoOneSpeaks, + testServer_PrivateGroup_Keyless, + testServer_PrivateGroup_Keyed, testServer_BalanceGatedGroup_Rules, testServer_Broadcast_IncludesActor, testServer_SendMessage_PushPerChatType, @@ -3343,66 +3344,190 @@ func testServer_StaffOnlyGroup_Rules(t *testing.T, badges badge.Store, blocklist require.Equal(t, messagingpb.SendMessageResponse_OK, dmResp.Result) } -// testServer_PrivateGroup_NoOneSpeaks pins that nothing is sent in a private -// group, before its key is stored or after: a member's send is DENIED whatever -// it carries, encrypted content included, as is their typing notification, -// and nothing is written. -func testServer_PrivateGroup_NoOneSpeaks(t *testing.T, badges badge.Store, blocklists blocklist.Store, chats chat.Store, messages messaging.Store, profiles profile.Store) { - e := newServerEnv(t, badges, blocklists, chats, messages, profiles) - +// putPrivateGroup persists a private group created by userA, titled +// "Private", whose members are userA and the given others. +func (e *serverEnv) putPrivateGroup(chats chat.Store, others ...*commonpb.UserId) *commonpb.ChatId { groupID := chat.MustGenerateGroupChatID() - require.NoError(t, chats.PutChat(e.ctx, &chat.Chat{ + require.NoError(e.t, chats.PutChat(e.ctx, &chat.Chat{ ID: groupID, Type: chatpb.ChatType_GROUP, - Members: []*commonpb.UserId{e.userA}, + Members: append([]*commonpb.UserId{e.userA}, others...), Title: "Private", IsPrivate: true, CreatorID: e.userA, LastActivity: at(1), })) + return groupID +} - requireNoSends := func() { - t.Helper() +// storeChatKey stores userID's key envelope for the group straight into the +// store, as the chat service would on their SetKeyEnvelope. The creator's is +// what makes the group keyed (see chat.Access.SpeakerStanding). +func (e *serverEnv) storeChatKey(chats chat.Store, groupID *commonpb.ChatId, userID *commonpb.UserId) { + _, err := chats.SetKeyEnvelope(e.ctx, groupID, userID, chat.KeyEnvelope{ + Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, + Nonce: bytes.Repeat([]byte{1}, 24), + Ciphertext: bytes.Repeat([]byte{1}, 48), + WrappedBy: userID, + }) + require.NoError(e.t, err) +} + +// testServer_PrivateGroup_Keyless pins that nothing is sent in a private +// group before its creator has stored their key envelope: a member's send is +// DENIED whatever it carries, encrypted content under the group's own scheme +// included, as is their typing notification, and nothing is written. Another +// member's envelope is not the group's key. The member still reads it, and a +// non-member reads nothing of it. +func testServer_PrivateGroup_Keyless(t *testing.T, badges badge.Store, blocklists blocklist.Store, chats chat.Store, messages messaging.Store, profiles profile.Store) { + e := newServerEnv(t, badges, blocklists, chats, messages, profiles) + groupID := e.putPrivateGroup(chats, e.userB) + e.storeChatKey(chats, groupID, e.userB) + + for _, keys := range []model.KeyPair{e.keysA, e.keysB} { for _, content := range [][]*messagingpb.Content{ textContent("plaintext"), encryptedContent(1), chatKeyEncryptedContent(1), } { - resp, err := e.sendContentToChat(e.keysA, groupID, content, generateClientID()) + resp, err := e.sendContentToChat(keys, groupID, content, generateClientID()) require.NoError(t, err) require.Equal(t, messagingpb.SendMessageResponse_DENIED, resp.Result) require.Nil(t, resp.Message) } + typingResp, err := e.notifyIsTypingInChat(keys, groupID, messagingpb.IsTypingNotification_STARTED_TYPING) + require.NoError(t, err) + require.Equal(t, messagingpb.NotifyIsTypingResponse_DENIED, typingResp.Result) } - requireNoSends() - // Storing the group's key lifts nothing yet: speaking in a private group - // is not built, and a keyed group must not take plaintext meanwhile. - _, err := chats.SetKeyEnvelope(e.ctx, groupID, e.userA, chat.KeyEnvelope{ - Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305, - Nonce: bytes.Repeat([]byte{1}, 24), - Ciphertext: bytes.Repeat([]byte{1}, 48), - WrappedBy: e.userA, - }) + // The members still read it, and find it empty. + got, err := e.getMessagesByOptionsInChat(e.keysA, groupID, nil) require.NoError(t, err) - requireNoSends() + require.Equal(t, messagingpb.GetMessagesResponse_NOT_FOUND, got.Result) + + // A non-member reads nothing of it under any mode. + _, strangerKeys := e.addUser() + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + denied, err := e.getMessagesByOptionsInChatWithMode(strangerKeys, groupID, nil, mode) + require.NoError(t, err) + require.Equal(t, messagingpb.GetMessagesResponse_DENIED, denied.Result, mode) + } +} + +// testServer_PrivateGroup_Keyed pins what a private group takes once its +// creator has stored their envelope: encrypted content under the chat key's +// scheme, from any member — one holding no envelope of their own included — +// and nothing else. Plaintext, a reply included, is ENCRYPTION_REQUIRED and +// the DM scheme ENCRYPTION_NOT_ALLOWED, on a send and on an edit alike, with +// nothing written; a non-member is DENIED as before. The message's push names +// the sender with a generic body, and the group's title. +func testServer_PrivateGroup_Keyed(t *testing.T, badges badge.Store, blocklists blocklist.Store, chats chat.Store, messages messaging.Store, profiles profile.Store) { + e := newServerEnv(t, badges, blocklists, chats, messages, profiles) + require.NoError(t, profiles.SetDisplayName(e.ctx, e.userA, "Alice")) + require.NoError(t, profiles.SetDisplayName(e.ctx, e.userB, "Bob")) + groupID := e.putPrivateGroup(chats, e.userB) + e.storeChatKey(chats, groupID, e.userA) - typingResp, err := e.notifyIsTypingInChat(e.keysA, groupID, messagingpb.IsTypingNotification_STARTED_TYPING) + // Nothing but the group's scheme goes in. + for _, refused := range []struct { + content []*messagingpb.Content + result messagingpb.SendMessageResponse_Result + }{ + {textContent("plaintext"), messagingpb.SendMessageResponse_ENCRYPTION_REQUIRED}, + {replyContent(1, "plaintext reply"), messagingpb.SendMessageResponse_ENCRYPTION_REQUIRED}, + {encryptedContent(1), messagingpb.SendMessageResponse_ENCRYPTION_NOT_ALLOWED}, + } { + resp, err := e.sendContentToChat(e.keysA, groupID, refused.content, generateClientID()) + require.NoError(t, err) + require.Equal(t, refused.result, resp.Result) + require.Nil(t, resp.Message) + } + empty, err := e.getMessagesByOptionsInChat(e.keysA, groupID, nil) require.NoError(t, err) - require.Equal(t, messagingpb.NotifyIsTypingResponse_DENIED, typingResp.Result) + require.Equal(t, messagingpb.GetMessagesResponse_NOT_FOUND, empty.Result) - // The member still reads it, and finds it empty. - got, err := e.getMessagesByOptionsInChat(e.keysA, groupID, nil) + // The creator sends, and so does a member who has stored no envelope. + sent, err := e.sendContentToChat(e.keysA, groupID, chatKeyEncryptedContent(1), generateClientID()) require.NoError(t, err) - require.Equal(t, messagingpb.GetMessagesResponse_NOT_FOUND, got.Result) + require.Equal(t, messagingpb.SendMessageResponse_OK, sent.Result) + require.True(t, proto.Equal(chatKeyEncryptedContent(1)[0], sent.Message.Content[0])) + e.waitForNewMessage(e.userB, sent.Message.MessageId.Value) + sentB, err := e.sendContentToChat(e.keysB, groupID, chatKeyEncryptedContent(2), generateClientID()) + require.NoError(t, err) + require.Equal(t, messagingpb.SendMessageResponse_OK, sentB.Result) + e.waitForNewMessage(e.userA, sentB.Message.MessageId.Value) - // A non-member reads nothing of it under any mode. + // Both read the ciphertext back as sent; a non-member reads nothing. + for _, keys := range []model.KeyPair{e.keysA, e.keysB} { + got, err := e.getMessagesByOptionsInChat(keys, groupID, nil) + require.NoError(t, err) + require.Equal(t, messagingpb.GetMessagesResponse_OK, got.Result) + require.Len(t, got.Messages.Messages, 2) + require.True(t, proto.Equal(chatKeyEncryptedContent(1)[0], got.Messages.Messages[0].Content[0])) + require.True(t, proto.Equal(chatKeyEncryptedContent(2)[0], got.Messages.Messages[1].Content[0])) + } _, strangerKeys := e.addUser() for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { denied, err := e.getMessagesByOptionsInChatWithMode(strangerKeys, groupID, nil, mode) require.NoError(t, err) require.Equal(t, messagingpb.GetMessagesResponse_DENIED, denied.Result, mode) } + strangerSend, err := e.sendContentToChat(strangerKeys, groupID, chatKeyEncryptedContent(3), generateClientID()) + require.NoError(t, err) + require.Equal(t, messagingpb.SendMessageResponse_DENIED, strangerSend.Result) + strangerTyping, err := e.notifyIsTypingInChat(strangerKeys, groupID, messagingpb.IsTypingNotification_STARTED_TYPING) + require.NoError(t, err) + require.Equal(t, messagingpb.NotifyIsTypingResponse_DENIED, strangerTyping.Result) + + // An edit is held to the same rule, and a refused one changes nothing. + msgID := sent.Message.MessageId + for _, refused := range []struct { + content []*messagingpb.Content + result messagingpb.EditMessageResponse_Result + }{ + {textContent("plaintext"), messagingpb.EditMessageResponse_ENCRYPTION_REQUIRED}, + {encryptedContent(4), messagingpb.EditMessageResponse_ENCRYPTION_NOT_ALLOWED}, + } { + resp, err := e.editMessageInChat(e.keysA, groupID, msgID, refused.content, sent.Message.EventSequence) + require.NoError(t, err) + require.Equal(t, refused.result, resp.Result) + } + unchanged, err := e.getMessageInChat(e.keysA, groupID, msgID) + require.NoError(t, err) + require.True(t, proto.Equal(chatKeyEncryptedContent(1)[0], unchanged.Message.Content[0])) + require.Equal(t, sent.Message.EventSequence, unchanged.Message.EventSequence) + edited, err := e.editMessageInChat(e.keysA, groupID, msgID, chatKeyEncryptedContent(5), sent.Message.EventSequence) + require.NoError(t, err) + require.Equal(t, messagingpb.EditMessageResponse_OK, edited.Result) + require.True(t, proto.Equal(chatKeyEncryptedContent(5)[0], edited.Message.Content[0])) + + // Typing and deleting are a member's as in any group. + typing, err := e.notifyIsTypingInChat(e.keysB, groupID, messagingpb.IsTypingNotification_STARTED_TYPING) + require.NoError(t, err) + require.Equal(t, messagingpb.NotifyIsTypingResponse_OK, typing.Result) + deleted, err := e.deleteMessageInChat(e.keysA, groupID, msgID, edited.Message.EventSequence) + require.NoError(t, err) + require.Equal(t, messagingpb.DeleteMessageResponse_OK, deleted.Result) + require.NotNil(t, deleted.Message.Content[0].GetDeleted()) + + // The push for the creator's message reached the other member with the + // group's title, a body that names the sender and nothing of the content, + // and the message in the payload for a client that can decrypt it. + var groupPush capturedPush + require.Eventually(t, func() bool { + for _, p := range e.pusher.snapshot() { + if proto.Equal(sent.Message.MessageId, p.payload.GetChatMetadata().GetMessage().GetMessageId()) { + groupPush = p + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond) + require.Equal(t, "Private", groupPush.title) + require.Equal(t, "Alice sent a message", groupPush.body) + require.True(t, proto.Equal(sent.Message, groupPush.payload.ChatMetadata.GetMessage())) + require.Len(t, groupPush.users, 1) + require.True(t, proto.Equal(e.userB, groupPush.users[0])) } // testServer_CreatorOnlyGroup_Rules pins that a creator-only group's speaker diff --git a/push/pushes.go b/push/pushes.go index d91f846..3abbe63 100644 --- a/push/pushes.go +++ b/push/pushes.go @@ -229,8 +229,9 @@ func BuildDmPush(ctx context.Context, ocpData ocp_data.Provider, chatId *commonp // BuildGroupChatPush renders a new message in a group chat. The notification // is titled by the group ("Untitled Group" when it has none), with the sender -// identified by display name in the body ("Alice: hello"). Like a DM, a -// group push never carries the sender's phone number, which is private +// identified by display name in the body ("Alice: hello"; "Alice sent a +// message" for encrypted content, see renderGroupChatMessagePushBody). Like a +// DM, a group push never carries the sender's phone number, which is private // outside contact DMs. func BuildGroupChatPush(ctx context.Context, ocpData ocp_data.Provider, chatId *commonpb.ChatId, message *messagingpb.Message, senderID *commonpb.UserId, senderDisplayName, chatTitle string) (*ChatMessagePush, error) { body, ok, err := renderGroupChatMessagePushBody(ctx, ocpData, message, senderDisplayName) @@ -332,6 +333,13 @@ func renderGroupChatMessagePushBody(ctx context.Context, ocpData ocp_data.Provid return "", false, nil } body = fmt.Sprintf("%s: %s", senderDisplayName, widgetBody) + case *messagingpb.Content_Encrypted: + // The server cannot read encrypted content, so the body is generic, + // naming the sender in the third person like every group body. As in + // a DM, a payload within maxChatPushBytes carries the message for a + // client that can decrypt it before display; a larger one carries only + // the message ID. + body = fmt.Sprintf(encryptedGroupMessagePushBodyFormat, senderDisplayName) case *messagingpb.Content_Cash: currencyName, err := resolveCurrencyName(ctx, ocpData, content.Cash.Amount.Mint) if err != nil { @@ -384,8 +392,13 @@ func truncatePushBody(body string) string { } // encryptedDmMessagePushBody is the push body for an encrypted DM message, -// whose content the server cannot render. -const encryptedDmMessagePushBody = "Sent you a message" +// whose content the server cannot render; encryptedGroupMessagePushBodyFormat +// is the group one's, with the sender's display name in it ("Alice sent a +// message"). +const ( + encryptedDmMessagePushBody = "Sent you a message" + encryptedGroupMessagePushBodyFormat = "%s sent a message" +) // renderDmMessagePushBody renders the push body for a DM message. ok is false // for content types that don't produce a push. diff --git a/push/pushes_test.go b/push/pushes_test.go index 4930040..30bd569 100644 --- a/push/pushes_test.go +++ b/push/pushes_test.go @@ -85,9 +85,6 @@ func TestChatMessagePush_MessageOrID(t *testing.T) { t.Run(name, func(t *testing.T) { var carried, referenced int for _, content := range contents { - if name == "group" && content.GetEncrypted() != nil { - continue // encrypted content never reaches a group - } message := testChatMessage(content) p, err := build(message) require.NoError(t, err) @@ -126,6 +123,35 @@ func TestChatMessagePush_MessageOrID(t *testing.T) { } } +// TestChatMessagePush_Encrypted: the body of an encrypted message's push is +// generic, since the server cannot read the content — second person in a DM, +// naming the sender in a group — and the payload carries the message. +func TestChatMessagePush_Encrypted(t *testing.T) { + ctx := context.Background() + dmID := &commonpb.ChatId{Value: bytes.Repeat([]byte{3}, 32)} + groupID := &commonpb.ChatId{Value: bytes.Repeat([]byte{4}, 16)} + senderID := &commonpb.UserId{Value: bytes.Repeat([]byte{2}, 16)} + message := testChatMessage(&messagingpb.Content{Type: &messagingpb.Content_Encrypted{ + Encrypted: &messagingpb.EncryptedContent{ + Scheme: messagingpb.EncryptedContent_CHAT_KEY_XCHACHA20POLY1305, + Nonce: bytes.Repeat([]byte{6}, 24), + Ciphertext: bytes.Repeat([]byte{9}, 48), + }, + }}) + + dm, err := BuildDmPush(ctx, nil, dmID, message, senderID, "Alice") + require.NoError(t, err) + require.Equal(t, "Alice", dm.title) + require.Equal(t, "Sent you a message", dm.body) + require.True(t, proto.Equal(message, dm.payload.ChatMetadata.GetMessage())) + + group, err := BuildGroupChatPush(ctx, nil, groupID, message, senderID, "Alice", "Sunday Hikers") + require.NoError(t, err) + require.Equal(t, "Sunday Hikers", group.title) + require.Equal(t, "Alice sent a message", group.body) + require.True(t, proto.Equal(message, group.payload.ChatMetadata.GetMessage())) +} + // TestChatMessagePush_Widget: a widget's body is its plain-text stand-in, a // shared profile's link, and the payload carries the whole message, widget // included, for the client to render natively. From 89a0664f4062f554ccd70c7ee7f0e8b2decb6f18 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Mon, 5 Oct 2026 10:53:01 -0400 Subject: [PATCH 5/6] Implement lobbies for private groups --- CLAUDE.md | 6 +- chat/cache/store.go | 23 ++ chat/create.go | 6 +- chat/dynamodb/server_test.go | 5 +- chat/dynamodb/store.go | 488 +++++++++++++++++++++++-- chat/dynamodb/store_test.go | 13 +- chat/dynamodb/table.go | 42 ++- chat/dynamodb/tombstone_test.go | 4 +- chat/key.go | 9 +- chat/lobby.go | 573 ++++++++++++++++++++++++++++++ chat/member.go | 10 +- chat/memory/store.go | 167 +++++++++ chat/model.go | 71 +++- chat/server.go | 50 ++- chat/store.go | 75 ++++ chat/tests/server.go | 474 +++++++++++++++++++++++- chat/tests/store.go | 345 ++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- messaging/dynamodb/server_test.go | 5 +- 20 files changed, 2304 insertions(+), 68 deletions(-) create mode 100644 chat/lobby.go diff --git a/CLAUDE.md b/CLAUDE.md index 8c65af5..c0b8ead 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ This is the most intricate part of the codebase and where most current work happ **Chat IDs.** Length is the type discriminator: 32 bytes = DM (SHA-256 over a domain-separated, sorted, deduped member set, so creation is idempotent and order-independent), 16 bytes = group (server-derived: a truncated, domain-separated SHA-256 over the creator's user ID and the request's required `IdempotencyKey`, so a retried `StartChat` names the same group and is answered from the existing record; stamped as a version 8 UUID so every group ID is UUID-shaped, though the ID is opaque and nothing parses it). DM paths must reject 16-byte IDs and vice versa. `CONTACT_DM` uses the bare legacy hash domain; other DM types append their enum number. A chat type of `UNKNOWN` falls back to `CONTACT_DM` for legacy clients. -**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — today a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel) and a per-row `version` with the same state-not-delta rule. Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is one blind conditional update of `last_sent_at` (epoch ms), throttled to one per `ActivityRecordInterval` (1 min) per user, never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one strongly consistent query on the `by_last_sent_at` LSI, most recent first. A second LSI, `by_activity_score`, is reserved for a future frequency-weighted ordering and empty today: nothing writes `activity_score`, and it exists only because an LSI cannot be added later. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). A sixth table, `chat_key_envelopes` (pk user, sk chat, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each member's **key envelope** for a private group (`chat.KeyEnvelope`: scheme, nonce, ciphertext and `wrapped_by`, the user who stored it; see "Private groups"). `SetKeyEnvelope` is one conditional put, refused when the stored envelope is one the user wrapped themself, with the refusal returning the stored item; `GetKeyEnvelope` is a strongly consistent point read. Like viewer state, the methods are on `chat.Store`, write against the IDs alone and know nothing of the roster. The one tie to the roster is a departure: `RemoveGroupMember` takes a required `discardKeyEnvelope` flag and, when set, deletes the leaver's envelope **in the same transaction** as the membership transition and its `#meta` update (an unconditional third item, so it commits iff the departure happens and a no-op leave deletes nothing). +**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — today a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel) and a per-row `version` with the same state-not-delta rule. Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is one blind conditional update of `last_sent_at` (epoch ms), throttled to one per `ActivityRecordInterval` (1 min) per user, never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one strongly consistent query on the `by_last_sent_at` LSI, most recent first. A second LSI, `by_activity_score`, is reserved for a future frequency-weighted ordering and empty today: nothing writes `activity_score`, and it exists only because an LSI cannot be added later. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). A sixth table, `chat_key_envelopes` (pk user, sk chat, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each member's **key envelope** for a private group (`chat.KeyEnvelope`: scheme, nonce, ciphertext and `wrapped_by`, the user who stored it; see "Private groups"). `SetKeyEnvelope` is one conditional put, refused when the stored envelope is one the user wrapped themself, with the refusal returning the stored item; `GetKeyEnvelope` is a strongly consistent point read. Like viewer state, the methods are on `chat.Store`, write against the IDs alone and know nothing of the roster. The one tie to the roster is a departure: `RemoveGroupMember` takes a required `discardKeyEnvelope` flag and, when set, deletes the leaver's envelope **in the same transaction** as the membership transition and its `#meta` update (an unconditional third item, so it commits iff the departure happens and a no-op leave deletes nothing). A seventh table, `chat_lobbies` (pk user, sk chat; `NewInDynamoDB` / `CreateTables` take its name), holds each private group's **lobby entries** (`chat.LobbyEntry`: `entered_at` epoch nanos; the sparse `by_chat` **GSI** is hashed on the `sk` itself, the chat, and ranged on `entered_at`, so no attribute repeats the chat and the index pages a lobby earliest first, **eventually consistent** as the proto allows) and two `#meta` count items per entry, the lobby's size under `chat#` and the user's lobby count under `user#` (both omit `entered_at`, the index's range key, so they stay out of it). **Keyed by user, decided 2026-10-05**: a user's own entries, and the future listing of every lobby they wait in, are one strongly consistent query with no index; the price is the creator's page, which a chat-keyed table with an LSI could serve strongly consistent (an LSI cannot gather one chat's entries across user partitions), and the creator reconciles against `LobbyUpdate`s instead. `EnterLobby` is one transaction: a conditional put of the entry, both counts incremented under `attribute_not_exists OR count < cap`, and a `ConditionCheck` that the user's `group_members` row is absent or not joined, so the caps (`chat.LobbyLimits`, `DefaultLobbyLimits` 100 per lobby / 100 lobbies per user, user's choice 2026-10-05) hold under concurrent entries with no read and **a member never gains an entry** (closing the race where a retried `EnterLobby` lands after the creator's admission removed the entry); the cancellation reasons tell `ErrAlreadyMember` (judged first) from an existing entry (the no-op) from `ErrLobbyFull` (judged before) `ErrTooManyLobbies`. `LeaveLobby` is the inverse (conditional delete + two decrements; a missing entry is the no-op). `GetLobbyEntries` is a strongly consistent read of the user's partition (point read for one chat, bounded range query otherwise, like `GetViewerStates`). `AdmitFromLobby` is `transitionMembership`'s join carrying `alongside` the entry's **conditional** delete, both decrements and an unconditional put of the key envelope: `transitionMembership` now admits conditional alongside items and reports a failed one as `alongsideConditionFailed{Index}` (judged after the transition's own no-op and the summary CAS), which `AdmitFromLobby` translates to `ErrNotInLobby`; a user who is already a member is the transition's no-op (`changed` false, nothing written, a leftover entry is theirs to clear). **Tombstones.** A departed group member's row stays with `state=2`, `left_at`, a 1h TTL and the departure's roster version, so re-joins are idempotent updates and delayed duplicates of an undone join can be distinguished from news. Readers trust `state`, never the clock. "Formerly a member" is not durable. @@ -173,7 +173,7 @@ This is the most intricate part of the codebase and where most current work happ **Group management.** `StartChat` / `JoinChat` / `LeaveChat` are open to every registered user (nothing in these packages gates on client version). DMs always deny join/leave. Membership is checked before rules so a re-join is a no-op. Every real transition publishes a `RosterUpdate` to both the user and chat topics. Creation validates rules, checks the creator satisfies them, moderates the title and attaches the picture *before* the record is written. -**Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and **staff-only for now** (`canCreatePrivateGroup`, `DENIED` otherwise; a transitional gate like `useE2ee`, removed by deleting the check; a retry of a creation that landed is answered from the record before it). `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `GroupRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicListenerStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; the `RuleEvaluator` admits no one to a private group (`satisfiesListener` is false on `IsPrivate`, so a caller asking the rules alone never finds an empty rule set open to all), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view is `DENIED` (`getPublicChat`); `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely. **Key envelopes (`chat/key.go`)**: `SetKeyEnvelope` / `GetKeyEnvelope` are a member's alone, of a private group alone (a DM `DENIED` before any read, then the record for `NOT_FOUND`, then `IsPrivate`, then membership). Set stores the request's envelope as wrapped by the caller; **the first envelope a user wraps for themself stands** (a different one later is `ALREADY_SET`, the same one `OK`), while an envelope someone else wrapped for them is replaced by their own. Get returns the envelope with `wrapped_by`, or `NO_ENVELOPE`. Nothing is published. **A private group has a key exactly when its creator has an envelope stored**: there is no flag on the record, since the creator's envelope is the only possible first one, is always self-wrapped, and is never deleted. `LeaveChat` discards the leaver's envelope atomically with the departure, except the creator's: it reads the cached rules first (`NOT_FOUND` off that read, a failed read fails the RPC) and passes `discardKeyEnvelope` to `RemoveGroupMember` for a non-creator leaving a private group. `SetKeyEnvelope` is not in that transaction, so one racing a leave can still write an envelope for a user who has just left; it is unreadable to them and holds the right key if they are readmitted (accepted). The server never opens an envelope and validates only its shape (proto validation). **SpeakerStanding (`Access.SpeakerStanding`)**: a member of a private group speaks exactly when the group has its key, decided on membership and the creator's envelope with the evaluator never consulted (no rule admits anyone to a private group). The key check is the cached rules read plus one strongly consistent point read of the creator's envelope (`hasKey`, unexported); a positive is held in the `Access` for the life of the process (the envelope is never deleted), a negative never, so a keyless group's refused sends each cost that read and its first send after the key lands is admitted at once. A keyless group's members are `DENIED` every send, edit, deletion, typing notification and mention suggestion; a keyed group's members get a `SpeakerStanding` with `EncryptionRequired` under `CHAT_KEY_XCHACHA20POLY1305`, and a member holding no envelope of their own speaks too (the key is the group's). Messaging applies that standing to the content (`encryptionVerdict`, see "Encrypted content"): the chat-key scheme and nothing else. Encrypted blob uploads follow the same gate (see `blob/`). The group push body for encrypted content is " sent a message". Not built: the lobby RPCs and `Metadata.in_lobby` (never set); the lobby RPCs answer unimplemented. +**Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and **staff-only for now** (`canCreatePrivateGroup`, `DENIED` otherwise; a transitional gate like `useE2ee`, removed by deleting the check; a retry of a creation that landed is answered from the record before it). `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `GroupRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicListenerStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; the `RuleEvaluator` admits no one to a private group (`satisfiesListener` is false on `IsPrivate`, so a caller asking the rules alone never finds an empty rule set open to all), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view is `DENIED` (`getPublicChat`); `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely. **Key envelopes (`chat/key.go`)**: `SetKeyEnvelope` / `GetKeyEnvelope` are a member's alone, of a private group alone (a DM `DENIED` before any read, then the record for `NOT_FOUND`, then `IsPrivate`, then membership). Set stores the request's envelope as wrapped by the caller; **the first envelope a user wraps for themself stands** (a different one later is `ALREADY_SET`, the same one `OK`), while an envelope someone else wrapped for them is replaced by their own. Get returns the envelope with `wrapped_by`, or `NO_ENVELOPE`. Nothing is published. **A private group has a key exactly when its creator has an envelope stored**: there is no flag on the record, since the creator's envelope is the only possible first one, is always self-wrapped, and is never deleted. `LeaveChat` discards the leaver's envelope atomically with the departure, except the creator's: it reads the cached rules first (`NOT_FOUND` off that read, a failed read fails the RPC) and passes `discardKeyEnvelope` to `RemoveGroupMember` for a non-creator leaving a private group. `SetKeyEnvelope` is not in that transaction, so one racing a leave can still write an envelope for a user who has just left; it is unreadable to them and holds the right key if they are readmitted (accepted). The server never opens an envelope and validates only its shape (proto validation). **SpeakerStanding (`Access.SpeakerStanding`)**: a member of a private group speaks exactly when the group has its key, decided on membership and the creator's envelope with the evaluator never consulted (no rule admits anyone to a private group). The key check is the cached rules read plus one strongly consistent point read of the creator's envelope (`hasKey`, unexported); a positive is held in the `Access` for the life of the process (the envelope is never deleted), a negative never, so a keyless group's refused sends each cost that read and its first send after the key lands is admitted at once. A keyless group's members are `DENIED` every send, edit, deletion, typing notification and mention suggestion; a keyed group's members get a `SpeakerStanding` with `EncryptionRequired` under `CHAT_KEY_XCHACHA20POLY1305`, and a member holding no envelope of their own speaks too (the key is the group's). Messaging applies that standing to the content (`encryptionVerdict`, see "Encrypted content"): the chat-key scheme and nothing else. Encrypted blob uploads follow the same gate (see `blob/`). The group push body for encrypted content is " sent a message". **Lobby (`chat/lobby.go`)**: how anyone but the creator gets in. Every lobby RPC gates on a group ID (DM `DENIED` before any read), the record (`NOT_FOUND`), then `IsPrivate` (`DENIED`); the creator's RPCs (`GetLobbyMembers`, `AdmitLobbyMember`, `DenyLobbyMember`) then require the recorded creator **while a member** (a departed creator is `DENIED` until they rejoin). A lobby exists only once the group has its key (`Access.hasKey`): `EnterLobby` and `AdmitLobbyMember` are `DENIED` for a keyless group; `LeaveLobby`/`DenyLobbyMember` are the no-op they are. `EnterLobby`: the creator is `DENIED` (they rejoin with `JoinChat`), a member `ALREADY_MEMBER` (answered off a membership read first, and again by the store's atomic check, which is the guarantee), then the store's `LOBBY_FULL`/`TOO_MANY_LOBBIES`; OK returns `Lobby{chat, entered_at}` with the chat hydrated as a non-member sees it plus `in_lobby`; a repeat is OK with the first entry and publishes nothing. `GetLobbyMembers` pages earliest first (`maxGetLobbyMembersPageSize` 100, `limit+1` for an exact `has_more`, a token `[version][chat ID][entered_at nanos][user ID]` bound to the chat and refused elsewhere), each `LobbyMember` hydrated with the public profile (`ProfileReader.GetPublicProfiles`) and the account store's public key (`GetPubKeys`, one per user; a missing profile or key is `Internal`). `AdmitLobbyMember`: the target already a member is OK with their envelope untouched (checked before the write, which would replace it), else `Store.AdmitFromLobby` with the envelope wrapped by the creator (`NOT_IN_LOBBY` otherwise), announced exactly like `JoinChat` (`RosterUpdate.MemberJoined` on the chat topic excluding the joiner, and with the hydrated metadata on the joiner's user topic, via `announcedMember`) plus a `LobbyUpdate.MemberLeft` to the creator. `DenyLobbyMember` removes the entry, tells the denied user nothing, and lets them re-enter. **`LobbyUpdate`s go to the recorded creator's user topic only** (`publishLobbyUpdate`; never the chat topic, so `redact.ChatUpdate` never sees one), whether or not the creator is currently a member (decided), best effort: `MemberEntered` carrying the `LobbyMember`, `MemberLeft` for a withdrawal, denial or admission. **`Metadata.in_lobby`** is set by `hydrate` for a non-member viewer of a private group off one strongly consistent `GetLobbyEntries` read across the set (never for a member, a public group or the unauthenticated view). Caps are the store's to enforce; the server names them (`lobbyLimits`, `WithLobbyLimits` on `NewServer`'s new variadic `ServerOption`s, for tests). Remaining: remove the staff gate on creation once clients ship. **Edit (`chat/edit.go`).** `EditChat` changes a group's title and/or picture. **Only the creator, while a member, may edit** (`Chat.PermissionsFor`: `CanEdit = isMember && IsCreator`; DMs and legacy groups with no recorded creator are never editable; a departed creator is `DENIED` until they rejoin). Fields equal to the record are dropped first, so a no-op request (or one that sets nothing) is `OK` from the record with nothing moderated, attached, written or published. What remains runs in `StartChat`'s order — title moderation (`TITLE_MODERATED`), picture attach via `SetAsChatPicture` (`PICTURE_BLOB_NOT_ACCEPTED`), then one `Store.EditGroup` write (`GroupEdit`, nil = unchanged, SETs only the named attributes so concurrent edits of different fields never clobber; an empty edit is an error; `SetGroupPicture` remains the only way to *clear* a picture). A real change publishes **one event on the chat topic, excluding no one** (the editor's devices included), with one `MetadataUpdate` per field: `TitleChanged` / `PictureChanged` (the hydrated rendition set). `redact.ChatUpdate` passes both through. @@ -187,7 +187,7 @@ This is the most intricate part of the codebase and where most current work happ **Push fan-out (`messaging/push.go`).** A group send publishes once on the chat topic and never loads the roster; the pushes it earns run detached (`pushSentMessages`) and **walk the roster in pages** (`GetGroupMembersPage`, `defaultPushPageSize` 2000, `WithPushPageSize` for tests), running the whole pipeline per page: drop the sender, `GetBlockers` for the page (fails closed for that page only), mute split, then `ChatMessagePush.Send` (its own token lookup, badge batch and FCM batches). What is the same for every recipient — the sender profile, the rendered push including any currency-name lookup, the mute shape — is resolved once per message (`messagePush.prepare`) before the walk, via the `push.Build*Push` constructors. Pages are read sequentially but **sent concurrently**: each page's send runs on its own goroutine under a slot from the `Sender`'s pool (`pushPageSlots`, `defaultPushPageConcurrency` 4, `WithPushPageConcurrency` for tests), taken by the reader before it launches the page and before it reads the next, so a walk holds at most one page more than it has in send, and the pool is **shared by every fan-out the `Sender` runs** — it is the cross-message throttle, not just per-walk parallelism. `walkGroup` waits for its launched pages before returning. A DM is a walk of one page, its inline pair, sent inline and outside the pool so a large group's walk never delays it. Budgets: `pushStepTimeout` (15s) bounds each step (setup, each page read, each page send) on its own; `pushBudget` (1 min) bounds a whole update, slot waits included. A failed page read, or a budget that runs out waiting for a slot, ends the walk (the cursor is what failed to arrive, or the page that never launched — logged); a failed page send costs that page only. The cursor is a user ID, which a future durable worker can checkpoint to resume without re-sending pages already out. Not built: a read-ahead of the next page, a once-per-message blocker set. -**`chat/cache`** caches what is fixed at creation: DM membership (positives only), DM member lists, group rules with their creator. Group membership is never cached, and neither is viewer state or a key envelope: every `SetMute`/`ClearMute`/`GetViewerStates`/`GetMuted*` and `SetKeyEnvelope`/`GetKeyEnvelope` call passes straight through. The one exception is a per-process lower bound on each activity record: `RecordSend` answers a throttled send itself (a failed conditional write is still billed), holding each send it saw recorded for `ActivityRecordInterval`; it is safe because the record only moves forward, so a stale entry can cost a write but never skip one, and a refusal is never held. `GetRecentSenders` passes through. +**`chat/cache`** caches what is fixed at creation: DM membership (positives only), DM member lists, group rules with their creator. Group membership is never cached, and neither is viewer state, a key envelope or a lobby: every `SetMute`/`ClearMute`/`GetViewerStates`/`GetMuted*`, `SetKeyEnvelope`/`GetKeyEnvelope` and lobby call passes straight through. The one exception is a per-process lower bound on each activity record: `RecordSend` answers a throttled send itself (a failed conditional write is still billed), holding each send it saw recorded for `ActivityRecordInterval`; it is safe because the record only moves forward, so a stale entry can cost a write but never skip one, and a refusal is never held. `GetRecentSenders` passes through. **Messaging storage (`messaging/dynamodb`).** `messages` uses one partition per chat (sk `#counter` / `msg#` / `evt#`) so `evt#` is a gapless, strongly consistent range for delta catch-up; each message's idempotency marker is **its own partition** (pk `cmid##`, sk `#marker`) so markers spread across the table instead of loading the chat's, and a send is a single transaction over both. `Store.PutMessages` writes a batch of messages to one chat in that same single transaction (all or nothing, consecutive IDs and event sequences in batch order, nothing interleaved); `PutMessage` is a batch of one in every store. A batch writes **one `evt#` row for its whole run of sends**, keyed by the run's *last* event sequence and carrying `first_event_seq` = its first, with `message_id` = the message sent at that first event (absent = the key, so a single send's row and every pre-existing row is a run of one); `GetEventDelta` expands runs back into events, clipped to `(after, head]` and cut at `limit` events, so a cursor or page boundary may sit inside a run (last-seq keying is what lets the range read find it), and it fails the read on a hole or overlap rather than skip a message. Only sends form runs; edits and deletes are single rows. A batch is at most `MaxMessagesPerPut` (49: DynamoDB's 100-item transaction cap over 2n + 2 rows — counter, a message row and marker per message, one run row), enforced by every backend; the memory store keeps one log entry per event and behaves identically. Idempotency is the batch's: every client message ID already spent is a replay (`created == false`, the originals in batch order), a mix of spent and fresh IDs is `ErrPartialReplay` with nothing written. `GetDelta` pages 100 at a time and returns `RESET_REQUIRED` past 1000 events. `message_pointers`: DMs use a **single `#ptrs` item per chat** with per-member attribute suffixes; groups use one `ptr#` item per member. Group pointer advances are stored but never broadcast (N² fan-out). Idempotency markers (`cmid#`) carry a TTL. Reactions span three tables: `message_reactions` is chat-keyed (`agg##` per emoji with count, **per-emoji version** and a bounded sample; `meta#` holding the message's own state, today its active-emoji count) so a page of summaries is one range query — a **strongly consistent** one, so a reader who reacts and refreshes never finds their emoji absent with no version to explain it; `message_reactors` is **message-keyed** (`user##`, one row per current reaction, deleted on remove, no tombstone) with the `by_version` LSI on `emoji_version` so an emoji's reactors page most-recent-first under a strongly consistent read; `message_self_reactions` is **viewer-keyed** (pk chat+user, sk `#`, the add's version and `reacted_ts`, i.e. the viewer's own reactor entry; **groups only** — a DM's overlay is answered from the sample, so a DM writes no row and `GetSelfReactions` refuses a DM ID) so a viewer's own reactions across a page of messages — the `self_reactor` overlay for groups — are one strongly consistent range query on their own partition, billed by what the viewer reacted rather than a key probe per aggregate on the page (a GSI would be eventually consistent and outside the transaction; a prefix in `message_reactions` would double the write load on the chat partition that already takes every aggregate CAS). Every add/remove is one transaction that writes the reactor row and its viewer-keyed copy and compare-and-sets the aggregate (and the meta row when an emoji activates or empties) to the exact next state, on the `group_members` `#meta` pattern: no read-back, exact type cap and sample eviction, lost CAS retried from the returned item, transaction conflicts backed off. Reactor rows are stamped with the version that added them; that version is the ordering key and the paging cursor, never `reacted_ts`. `GetReactors` reads the aggregate version *before* the page so the page is never older than the version. A summary's **wire order** is decided at projection, not by the store: `ReactionSummary.ToProto` sorts by `messaging.ReactionLess` (count descending, ties by the emoji's UTF-8 bytes, so the order is total for a given state and identical from every backend); stores still return their own by-emoji order and nothing downstream relies on it. diff --git a/chat/cache/store.go b/chat/cache/store.go index ddd3b72..7491f52 100644 --- a/chat/cache/store.go +++ b/chat/cache/store.go @@ -281,6 +281,29 @@ func (c *Cache) GetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, use return c.db.GetKeyEnvelope(ctx, chatID, userID) } +// The lobby methods pass through: a lobby moves with every entry and +// admission, from any process, and every read of one is already a single +// query. +func (c *Cache) EnterLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, limits chat.LobbyLimits) (chat.LobbyEntry, bool, error) { + return c.db.EnterLobby(ctx, chatID, userID, limits) +} + +func (c *Cache) LeaveLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { + return c.db.LeaveLobby(ctx, chatID, userID) +} + +func (c *Cache) GetLobbyEntries(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]chat.LobbyEntry, error) { + return c.db.GetLobbyEntries(ctx, userID, chatIDs) +} + +func (c *Cache) GetLobbyPage(ctx context.Context, chatID *commonpb.ChatId, after *chat.LobbyPosition, limit int) ([]chat.LobbyEntry, error) { + return c.db.GetLobbyPage(ctx, chatID, after, limit) +} + +func (c *Cache) AdmitFromLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope chat.KeyEnvelope) (bool, chat.RosterSummary, error) { + return c.db.AdmitFromLobby(ctx, chatID, userID, envelope) +} + // sendActivityCacheKey keys the activity cache by (group, user). Only group // IDs are held, and they are fixed width (chat.GroupChatIDSize), so // concatenating the raw bytes is unambiguous. diff --git a/chat/create.go b/chat/create.go index e025265..724b2c5 100644 --- a/chat/create.go +++ b/chat/create.go @@ -251,10 +251,8 @@ func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (* // canCreatePrivateGroup reports whether userID may create a private group: // today, only a staff user. It is a transitional gate, like use_e2ee's (see -// useE2ee): private groups are being built in steps, and one created now can -// be given its key and messaged by its creator, but has no lobby, so it is a -// group nobody else can join. Opening creation to everyone is removing this -// check, once the rest is built. +// useE2ee): private groups were built in steps behind it, and clients have +// not shipped them. Opening creation to everyone is removing this check. func (s *Server) canCreatePrivateGroup(ctx context.Context, userID *commonpb.UserId) (bool, error) { return s.accounts.IsStaff(ctx, userID) } diff --git a/chat/dynamodb/server_test.go b/chat/dynamodb/server_test.go index 891f087..c0e463b 100644 --- a/chat/dynamodb/server_test.go +++ b/chat/dynamodb/server_test.go @@ -18,12 +18,13 @@ const ( serverUserStateTable = "chat_user_state_server_test" serverActivityTable = "chat_activity_server_test" serverKeyEnvelopesTable = "chat_key_envelopes_server_test" + serverLobbiesTable = "chat_lobbies_server_test" ) func TestChat_DynamoDBServer(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, serverLobbiesTable)) - testStore := NewInDynamoDB(testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, serverLobbiesTable, nil) teardown := func() { testStore.(*store).reset() } diff --git a/chat/dynamodb/store.go b/chat/dynamodb/store.go index e0051a6..75ad7fa 100644 --- a/chat/dynamodb/store.go +++ b/chat/dynamodb/store.go @@ -150,6 +150,35 @@ import ( // the transaction that records the departure (see // RemoveGroupMember), so no departed user keeps one. No item // expires. +// +// chat_lobbies pk = "user#", sk = "chat#" (one item per (user, +// private group) the user is waiting in the lobby of; see +// chat.LobbyEntry), with entered_at (epoch nanos). gsiLobbyByChat +// on (sk, entered_at) pages a group's lobby earliest first: the +// sk is the chat, so no attribute repeats it, and the index is +// sparse all the same because entered_at, its range key, is on +// entries alone. Keyed +// by user like chat_user_state, so that a user's own entries are +// one strongly consistent query with no index: in_lobby and +// EnterLobby's response today, and the listing of the lobbies a +// user waits in when that RPC comes (decided). The cost is the +// creator's page, which a chat-keyed table with an LSI could have +// served strongly consistent and here comes off a GSI that trails +// writes briefly, as the proto allows; the creator reconciles +// against the LobbyUpdates they receive. +// +// Two aggregates items count the entries, both sk = "#meta" (see +// skMeta) with lobby_count: pk = "chat#" is the lobby's size, +// pk = "user#" the lobbies the user waits in. Neither carries +// entered_at, so neither is in the index. Every +// write of an entry moves both counts in the same transaction, +// and an entry is made conditionally on both counts being under +// their caps and on the user's group_members row not being +// joined (see EnterLobby), so the caps hold under concurrent +// entries without a read and a member never gains an entry. An +// admission (see AdmitFromLobby) is the membership transition's +// transaction carrying the entry's removal, its counts and the +// key envelope, so the two tables agree in both directions. const ( // gsiByActivity is the legacy feed index on (pk, last_activity), spanning // all of a user's DM types. Superseded by gsiByTypeActivity; retained until @@ -194,6 +223,12 @@ const ( // reserved for a frequency-weighted ordering; nothing writes its key yet. lsiByActivityScore = "by_activity_score" + // gsiLobbyByChat is the (sk, entered_at) index on chat_lobbies: a group's + // lobby in entry order (see GetLobbyPage), hashed on the sk, which is the + // chat. It is sparse on entered_at, which only entries carry: the #meta + // items must omit it. + gsiLobbyByChat = "by_chat" + // chatKeyPrefix prefixes a chat ID in the chats table pk, the dm_inbox sk // and the chat_user_state sk. The chat ID is recovered from the key, so it // is not stored as its own attribute — except in chat_user_state, where a @@ -235,6 +270,8 @@ const ( attrNonce = "nonce" // chat_key_envelopes: the envelope's nonce (B) attrCiphertext = "ciphertext" // chat_key_envelopes: the wrapped chat key (B) attrWrappedBy = "wrapped_by" // chat_key_envelopes: the raw ID of the user who stored the envelope (B) + attrEnteredAt = "entered_at" // chat_lobbies: epoch nanos of the entry, keying gsiLobbyByChat + attrLobbyCount = "lobby_count" // chat_lobbies #meta items: a lobby's size (chat# pk) or a user's lobbies (user# pk) // Keys of the min_listener_balance map. attrBalanceCurrency = "currency" @@ -301,6 +338,7 @@ type store struct { userStateTable string activityTable string keyEnvelopesTable string + lobbiesTable string exclusions chat.FeedExclusions } @@ -309,7 +347,7 @@ type store struct { // creating every DM with a user in excludedFromFeed excluding them from the // feed (see chat.FeedExclusions); nil excludes no one. Use CreateTables to // provision the tables. -func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable string, excludedFromFeed []*commonpb.UserId) chat.Store { +func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable string, excludedFromFeed []*commonpb.UserId) chat.Store { return &store{ exclusions: chat.NewFeedExclusions(excludedFromFeed), client: client, @@ -319,6 +357,7 @@ func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembe userStateTable: userStateTable, activityTable: activityTable, keyEnvelopesTable: keyEnvelopesTable, + lobbiesTable: lobbiesTable, } } @@ -482,24 +521,7 @@ func (s *store) addGroupMembers(ctx context.Context, chatID *commonpb.ChatId, us changed := false for _, userID := range userIDs { - join := &types.Update{ - TableName: aws.String(s.groupMembersTable), - Key: map[string]types.AttributeValue{ - attrPK: avS(chatPK(chatID)), - attrSK: avS(userPK(userID)), - }, - UpdateExpression: aws.String(fmt.Sprintf( - "SET #state = :joined, #user = :user, %s = :now, %s = :version REMOVE %s, %s", attrJoinedAt, attrVersion, attrLeftAt, attrExpiresAt, - )), - ConditionExpression: aws.String("attribute_not_exists(#state) OR #state <> :joined"), - ExpressionAttributeNames: map[string]string{"#state": attrState, "#user": attrUser}, - ExpressionAttributeValues: map[string]types.AttributeValue{ - ":joined": avN(memberStateJoined), - ":user": avS(userIndexKey(userID)), - ":now": avN(uint64(time.Now().UTC().UnixNano())), - }, - } - joined, err := s.transitionMembership(ctx, chatID, join, 1, &roster, nil) + joined, err := s.transitionMembership(ctx, chatID, s.joinUpdate(chatID, userID), 1, &roster, nil) if err != nil { return changed, roster, err } @@ -508,6 +530,28 @@ func (s *store) addGroupMembers(ctx context.Context, chatID *commonpb.ChatId, us return changed, roster, nil } +// joinUpdate is the membership transition that (re)joins userID to chatID +// (see addGroupMembers), for transitionMembership to carry. +func (s *store) joinUpdate(chatID *commonpb.ChatId, userID *commonpb.UserId) *types.Update { + return &types.Update{ + TableName: aws.String(s.groupMembersTable), + Key: map[string]types.AttributeValue{ + attrPK: avS(chatPK(chatID)), + attrSK: avS(userPK(userID)), + }, + UpdateExpression: aws.String(fmt.Sprintf( + "SET #state = :joined, #user = :user, %s = :now, %s = :version REMOVE %s, %s", attrJoinedAt, attrVersion, attrLeftAt, attrExpiresAt, + )), + ConditionExpression: aws.String("attribute_not_exists(#state) OR #state <> :joined"), + ExpressionAttributeNames: map[string]string{"#state": attrState, "#user": attrUser}, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":joined": avN(memberStateJoined), + ":user": avS(userIndexKey(userID)), + ":now": avN(uint64(time.Now().UTC().UnixNano())), + }, + } +} + func (s *store) RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, discardKeyEnvelope bool) (bool, chat.RosterSummary, error) { if !chat.IsGroupChatID(chatID) { return false, chat.RosterSummary{}, fmt.Errorf("not a group chat id") @@ -593,9 +637,12 @@ func (s *store) readRosterSummaryForWrite(ctx context.Context, chatID *commonpb. // from it without another read. // // alongside are further writes the transition carries: they commit iff it -// does, and are not made when it is a no-op. They must be unconditional, so -// the only way one can cancel the transaction is by losing to a concurrent -// write on its own item. +// does, and are not made when it is a no-op. One may carry a condition of its +// own: when it fails, the whole transaction cancels, nothing is retried, and +// the failure is reported as an alongsideConditionFailed naming the item, for +// the caller to translate (see AdmitFromLobby). An unconditional one can +// cancel the transaction only by losing to a concurrent write on its own +// item. // // Cancellation reasons are positional over the transaction's items: [0] is the // membership transition, [1] the summary, and the rest alongside's. A failed @@ -605,6 +652,10 @@ func (s *store) readRosterSummaryForWrite(ctx context.Context, chatID *commonpb. // with no extra read. Losing the write itself to a concurrent transaction on // the same item, any of them, surfaces as TransactionConflict and is retried // with backoff. Both share the attempt budget (see maxMembershipAttempts). +// The transition's own no-op is judged first, then the summary, then +// alongside's conditions: a no-op is a no-op whatever else failed, and a +// stale summary is retried before an alongside condition is believed, since +// the retry re-evaluates it. func (s *store) transitionMembership(ctx context.Context, chatID *commonpb.ChatId, transition *types.Update, delta int64, roster *chat.RosterSummary, alongside []types.TransactWriteItem) (bool, error) { backoff := membershipBackoffBase for attempt := 0; ; attempt++ { @@ -672,6 +723,8 @@ func (s *store) transitionMembership(ctx context.Context, chatID *commonpb.ChatI return false, err } *roster = current + case slices.Contains(codes[2:], conditionalCheckFailedCode): + return false, alongsideConditionFailed{Index: slices.Index(codes[2:], conditionalCheckFailedCode)} case isTransactionConflict(codes): if exhausted { return false, fmt.Errorf("membership transition for chat %x: %w", chatID.Value, err) @@ -2632,17 +2685,11 @@ func (s *store) SetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, use return chat.KeyEnvelope{}, fmt.Errorf("key envelope has no wrapper") } - item := keyEnvelopeKey(chatID, userID) - item[attrScheme] = avN(uint64(envelope.Scheme)) - item[attrNonce] = avB(envelope.Nonce) - item[attrCiphertext] = avB(envelope.Ciphertext) - item[attrWrappedBy] = avB(envelope.WrappedBy.Value) - // An envelope the user wrapped themself stands: the put is refused when // one is stored, and the refusal returns it. _, err := s.client.PutItem(ctx, &dynamodb.PutItemInput{ TableName: aws.String(s.keyEnvelopesTable), - Item: item, + Item: keyEnvelopeItem(chatID, userID, envelope), ConditionExpression: aws.String("attribute_not_exists(#pk) OR #wrappedBy <> :user"), ExpressionAttributeNames: map[string]string{ "#pk": attrPK, @@ -2687,6 +2734,16 @@ func keyEnvelopeKey(chatID *commonpb.ChatId, userID *commonpb.UserId) map[string } } +// keyEnvelopeItem is userID's envelope for chatID as stored, key included. +func keyEnvelopeItem(chatID *commonpb.ChatId, userID *commonpb.UserId, envelope chat.KeyEnvelope) map[string]types.AttributeValue { + item := keyEnvelopeKey(chatID, userID) + item[attrScheme] = avN(uint64(envelope.Scheme)) + item[attrNonce] = avB(envelope.Nonce) + item[attrCiphertext] = avB(envelope.Ciphertext) + item[attrWrappedBy] = avB(envelope.WrappedBy.Value) + return item +} + // keyEnvelopeFromItem decodes a chat_key_envelopes item. Every attribute is // written with every item, so one missing its wrapper is a corrupt item // rather than a default. @@ -2706,3 +2763,376 @@ func keyEnvelopeFromItem(item map[string]types.AttributeValue) (chat.KeyEnvelope WrappedBy: &commonpb.UserId{Value: bytes.Clone(wrappedBy)}, }, nil } + +// alongsideConditionFailed reports that a conditional write carried alongside +// a membership transition failed its condition (see transitionMembership). +// Index is the write's position in alongside. +type alongsideConditionFailed struct { + Index int +} + +func (e alongsideConditionFailed) Error() string { + return fmt.Sprintf("write %d alongside the membership transition failed its condition", e.Index) +} + +// EnterLobby is one transaction over the entry, both counts and the user's +// membership record: the entry is put conditionally on not existing, each +// count is moved conditionally on being under its cap, and the membership +// row in group_members is checked to be absent or not joined, so a member, a +// lobby at its cap, or a user at theirs, cancels the whole write and nothing +// is recorded. The membership check is what keeps an entry from landing for +// a user whose admission committed between the caller's gate and this write. +// The cancellation's per-item reasons tell the cases apart with no read: a +// joined member is ErrAlreadyMember, judged first; an entry that exists is +// the no-op, returned from the item the failure hands back, judged before +// the caps so a repeat never reports a full lobby; a failed lobby count is +// ErrLobbyFull, judged before the user's; a failed user count is +// ErrTooManyLobbies. A TransactionConflict — a concurrent write to one of the +// items, which the counts make likely in a busy lobby — is retried with +// backoff. +func (s *store) EnterLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, limits chat.LobbyLimits) (chat.LobbyEntry, bool, error) { + if !chat.IsGroupChatID(chatID) { + return chat.LobbyEntry{}, false, fmt.Errorf("not a group chat id") + } + + entry := chat.LobbyEntry{ + UserID: &commonpb.UserId{Value: append([]byte(nil), userID.Value...)}, + EnteredAt: time.Now().UTC(), + } + item := lobbyKey(chatID, userID) + item[attrEnteredAt] = avN(uint64(entry.EnteredAt.UnixNano())) + transactItems := []types.TransactWriteItem{ + {Put: &types.Put{ + TableName: aws.String(s.lobbiesTable), + Item: item, + ConditionExpression: aws.String("attribute_not_exists(#pk)"), + ExpressionAttributeNames: map[string]string{"#pk": attrPK}, + ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld, + }}, + {Update: s.lobbyCountUpdate(chatPK(chatID), 1, limits.LobbySize)}, + {Update: s.lobbyCountUpdate(userPK(userID), 1, limits.LobbiesPerUser)}, + {ConditionCheck: &types.ConditionCheck{ + TableName: aws.String(s.groupMembersTable), + Key: map[string]types.AttributeValue{ + attrPK: avS(chatPK(chatID)), + attrSK: avS(userPK(userID)), + }, + ConditionExpression: aws.String("attribute_not_exists(#state) OR #state <> :joined"), + ExpressionAttributeNames: map[string]string{"#state": attrState}, + ExpressionAttributeValues: map[string]types.AttributeValue{":joined": avN(memberStateJoined)}, + }}, + } + + backoff := membershipBackoffBase + for attempt := 0; ; attempt++ { + _, err := s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{TransactItems: transactItems}) + if err == nil { + return entry, true, nil + } + reasons, ok := cancellationReasons(err) + if !ok || len(reasons) != len(transactItems) { + return chat.LobbyEntry{}, false, err + } + codes := make([]string, len(reasons)) + for i, reason := range reasons { + codes[i] = aws.ToString(reason.Code) + } + switch { + case codes[3] == conditionalCheckFailedCode: + return chat.LobbyEntry{}, false, chat.ErrAlreadyMember + case codes[0] == conditionalCheckFailedCode: + existing, err := lobbyEntryFromItem(reasons[0].Item) + return existing, false, err + case codes[1] == conditionalCheckFailedCode: + return chat.LobbyEntry{}, false, chat.ErrLobbyFull + case codes[2] == conditionalCheckFailedCode: + return chat.LobbyEntry{}, false, chat.ErrTooManyLobbies + case isTransactionConflict(codes): + if attempt+1 >= maxMembershipAttempts { + return chat.LobbyEntry{}, false, fmt.Errorf("entering lobby of chat %x: %w", chatID.Value, err) + } + select { + case <-ctx.Done(): + return chat.LobbyEntry{}, false, ctx.Err() + case <-time.After(backoff + rand.N(backoff)): + } + backoff = min(2*backoff, membershipBackoffMax) + default: + return chat.LobbyEntry{}, false, err + } + } +} + +// LeaveLobby is the inverse transaction: the entry deleted conditionally on +// existing, both counts moved down. An entry that does not exist cancels it, +// which is the no-op; a TransactionConflict is retried with backoff. +func (s *store) LeaveLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { + if !chat.IsGroupChatID(chatID) { + return false, fmt.Errorf("not a group chat id") + } + + transactItems := append([]types.TransactWriteItem{{Delete: s.lobbyEntryDelete(chatID, userID)}}, s.lobbyCountDecrements(chatID, userID)...) + backoff := membershipBackoffBase + for attempt := 0; ; attempt++ { + _, err := s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{TransactItems: transactItems}) + if err == nil { + return true, nil + } + reasons, ok := cancellationReasons(err) + if !ok || len(reasons) != len(transactItems) { + return false, err + } + codes := make([]string, len(reasons)) + for i, reason := range reasons { + codes[i] = aws.ToString(reason.Code) + } + switch { + case codes[0] == conditionalCheckFailedCode: + return false, nil + case isTransactionConflict(codes): + if attempt+1 >= maxMembershipAttempts { + return false, fmt.Errorf("leaving lobby of chat %x: %w", chatID.Value, err) + } + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(backoff + rand.N(backoff)): + } + backoff = min(2*backoff, membershipBackoffMax) + default: + return false, err + } + } +} + +// GetLobbyEntries reads the user's partition as GetViewerStates does: one +// strongly consistent point read for a single chat, else one strongly +// consistent query bounded to the sort-key range the requested chats span, +// with the rows not asked for dropped in memory. +func (s *store) GetLobbyEntries(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]chat.LobbyEntry, error) { + wanted := make(map[string]struct{}, len(chatIDs)) + var lo, hi string + for _, chatID := range chatIDs { + sk := chatSK(chatID) + wanted[sk] = struct{}{} + if lo == "" || sk < lo { + lo = sk + } + if sk > hi { + hi = sk + } + } + out := make(map[string]chat.LobbyEntry, len(wanted)) + if len(wanted) == 0 { + return out, nil + } + + if len(wanted) == 1 { + res, err := s.client.GetItem(ctx, &dynamodb.GetItemInput{ + TableName: aws.String(s.lobbiesTable), + Key: lobbyKey(chatIDs[0], userID), + ConsistentRead: aws.Bool(true), + }) + if err != nil { + return nil, err + } + if len(res.Item) == 0 { + return out, nil + } + entry, err := lobbyEntryFromItem(res.Item) + if err != nil { + return nil, err + } + out[string(chatIDs[0].Value)] = entry + return out, nil + } + + var startKey map[string]types.AttributeValue + for { + res, err := s.client.Query(ctx, &dynamodb.QueryInput{ + TableName: aws.String(s.lobbiesTable), + KeyConditionExpression: aws.String("#pk = :pk AND #sk BETWEEN :lo AND :hi"), + ExpressionAttributeNames: map[string]string{"#pk": attrPK, "#sk": attrSK}, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":pk": avS(userPK(userID)), + ":lo": avS(lo), + ":hi": avS(hi), + }, + ConsistentRead: aws.Bool(true), + ExclusiveStartKey: startKey, + }) + if err != nil { + return nil, err + } + for _, item := range res.Items { + if _, ok := wanted[asS(item[attrSK])]; !ok { + continue + } + chatID, err := chatIDFromSK(item) + if err != nil { + return nil, err + } + entry, err := lobbyEntryFromItem(item) + if err != nil { + return nil, err + } + out[string(chatID.Value)] = entry + } + if len(res.LastEvaluatedKey) == 0 { + return out, nil + } + startKey = res.LastEvaluatedKey + } +} + +// GetLobbyPage is an ascending range on gsiLobbyByChat, which holds exactly +// the lobby's entries in entry order, so the page is billed by what it +// returns. The index projects every attribute, so the user comes with the +// row's key. A page resumes from an exclusive start key built from the +// position alone — the table key plus entered_at, which a position and the +// chat name — so a cursor need not name an entry still in the index +// (see GetGroupRosterPage for the same shape, and chat.LobbyPosition for the +// tie order the index may not share). The read is eventually consistent, a +// GSI's; see the table's doc for why that is the read that pays. +func (s *store) GetLobbyPage(ctx context.Context, chatID *commonpb.ChatId, after *chat.LobbyPosition, limit int) ([]chat.LobbyEntry, error) { + if !chat.IsGroupChatID(chatID) { + return nil, fmt.Errorf("not a group chat id") + } + + var startKey map[string]types.AttributeValue + if after != nil { + startKey = lobbyKey(chatID, after.UserID) + startKey[attrEnteredAt] = avN(uint64(after.EnteredAt.UnixNano())) + } + + entries := make([]chat.LobbyEntry, 0) + for { + input := &dynamodb.QueryInput{ + TableName: aws.String(s.lobbiesTable), + IndexName: aws.String(gsiLobbyByChat), + KeyConditionExpression: aws.String("#sk = :chat"), + ExpressionAttributeNames: map[string]string{"#sk": attrSK}, + ExpressionAttributeValues: map[string]types.AttributeValue{":chat": avS(chatSK(chatID))}, + ExclusiveStartKey: startKey, + } + if limit > 0 { + input.Limit = aws.Int32(int32(limit - len(entries))) + } + out, err := s.client.Query(ctx, input) + if err != nil { + return nil, err + } + for _, item := range out.Items { + entry, err := lobbyEntryFromItem(item) + if err != nil { + return nil, err + } + entries = append(entries, entry) + if limit > 0 && len(entries) == limit { + return entries, nil + } + } + if len(out.LastEvaluatedKey) == 0 { + return entries, nil + } + startKey = out.LastEvaluatedKey + } +} + +// AdmitFromLobby is the join transition (see addGroupMembers) carrying the +// lobby's side of the admission alongside: the entry's conditional delete, +// its two count decrements and an unconditional put of the key envelope, so +// the four commit with the membership or not at all. A failed delete +// condition is the user not waiting, reported as ErrNotInLobby. A join that +// is a no-op — the user was a member already — writes nothing, the entry +// included: the caller gates on membership before asking, so an entry left +// behind this way is a race's residue the user clears by leaving the lobby. +func (s *store) AdmitFromLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope chat.KeyEnvelope) (bool, chat.RosterSummary, error) { + if !chat.IsGroupChatID(chatID) { + return false, chat.RosterSummary{}, fmt.Errorf("not a group chat id") + } + if envelope.WrappedBy == nil { + return false, chat.RosterSummary{}, fmt.Errorf("key envelope has no wrapper") + } + + roster, err := s.readRosterSummaryForWrite(ctx, chatID) + if err != nil { + return false, chat.RosterSummary{}, err + } + + alongside := append([]types.TransactWriteItem{{Delete: s.lobbyEntryDelete(chatID, userID)}}, s.lobbyCountDecrements(chatID, userID)...) + alongside = append(alongside, types.TransactWriteItem{Put: &types.Put{ + TableName: aws.String(s.keyEnvelopesTable), + Item: keyEnvelopeItem(chatID, userID, envelope), + }}) + changed, err := s.transitionMembership(ctx, chatID, s.joinUpdate(chatID, userID), 1, &roster, alongside) + var failed alongsideConditionFailed + if errors.As(err, &failed) && failed.Index == 0 { + return false, roster, chat.ErrNotInLobby + } + return changed, roster, err +} + +// lobbyKey is the key of userID's entry in chatID's lobby in chat_lobbies. +func lobbyKey(chatID *commonpb.ChatId, userID *commonpb.UserId) map[string]types.AttributeValue { + return map[string]types.AttributeValue{ + attrPK: avS(userPK(userID)), + attrSK: avS(chatSK(chatID)), + } +} + +// lobbyEntryDelete removes userID's entry in chatID's lobby, conditionally on +// its existing, so the transaction it rides tells a user who was waiting from +// one who was not. +func (s *store) lobbyEntryDelete(chatID *commonpb.ChatId, userID *commonpb.UserId) *types.Delete { + return &types.Delete{ + TableName: aws.String(s.lobbiesTable), + Key: lobbyKey(chatID, userID), + ConditionExpression: aws.String("attribute_exists(#pk)"), + ExpressionAttributeNames: map[string]string{"#pk": attrPK}, + } +} + +// lobbyCountDecrements move the lobby's size and the user's lobby count down +// by one, for the transaction that removes an entry. +func (s *store) lobbyCountDecrements(chatID *commonpb.ChatId, userID *commonpb.UserId) []types.TransactWriteItem { + return []types.TransactWriteItem{ + {Update: s.lobbyCountUpdate(chatPK(chatID), -1, 0)}, + {Update: s.lobbyCountUpdate(userPK(userID), -1, 0)}, + } +} + +// lobbyCountUpdate moves the lobby_count of the #meta item under pk by delta, +// creating the item on first use. An increment is conditioned on the count +// being under limit, so the transaction it rides cancels at the cap. The item +// carries no entered_at, so it stays out of gsiLobbyByChat. +func (s *store) lobbyCountUpdate(pk string, delta int64, limit int) *types.Update { + update := &types.Update{ + TableName: aws.String(s.lobbiesTable), + Key: map[string]types.AttributeValue{ + attrPK: avS(pk), + attrSK: avS(skMeta), + }, + UpdateExpression: aws.String(fmt.Sprintf("ADD %s :delta", attrLobbyCount)), + ExpressionAttributeValues: map[string]types.AttributeValue{":delta": avInt(delta)}, + } + if delta > 0 { + update.ConditionExpression = aws.String(fmt.Sprintf("attribute_not_exists(%s) OR %s < :limit", attrLobbyCount, attrLobbyCount)) + update.ExpressionAttributeValues[":limit"] = avInt(int64(limit)) + } + return update +} + +// lobbyEntryFromItem reads a chat_lobbies entry: the user from its pk, and +// when they entered. +func lobbyEntryFromItem(item map[string]types.AttributeValue) (chat.LobbyEntry, error) { + userID, err := userIDFromPK(item) + if err != nil { + return chat.LobbyEntry{}, err + } + nanos, err := parseN(item[attrEnteredAt]) + if err != nil { + return chat.LobbyEntry{}, fmt.Errorf("reading lobby entry: %w", err) + } + return chat.LobbyEntry{UserID: userID, EnteredAt: time.Unix(0, int64(nanos)).UTC()}, nil +} diff --git a/chat/dynamodb/store_test.go b/chat/dynamodb/store_test.go index e7e320f..587949c 100644 --- a/chat/dynamodb/store_test.go +++ b/chat/dynamodb/store_test.go @@ -26,19 +26,20 @@ const ( userStateTable = "chat_user_state_test" activityTable = "chat_activity_test" keyEnvelopesTable = "chat_key_envelopes_test" + lobbiesTable = "chat_lobbies_test" ) func TestChat_DynamoDBStore(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, nil) teardown := func() { testStore.(*store).reset() } // The stores newStore builds share testStore's tables, so its teardown // resets theirs too. newStore := func(excludedFromFeed []*commonpb.UserId) chat.Store { - return NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, excludedFromFeed) + return NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, excludedFromFeed) } tests.RunStoreTests(t, testStore, newStore, teardown) } @@ -50,11 +51,11 @@ func TestChat_DynamoDBStore(t *testing.T) { // the other's. func TestChat_DynamoDBExclusionOutlivesConfig(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable)) team := model.MustGenerateUserID() - configured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, []*commonpb.UserId{team}) - unconfigured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) + configured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, []*commonpb.UserId{team}) + unconfigured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, nil) t.Cleanup(func() { configured.(*store).reset() }) chatIDValue := make([]byte, chat.DmChatIDSize) diff --git a/chat/dynamodb/table.go b/chat/dynamodb/table.go index ff88015..566bfab 100644 --- a/chat/dynamodb/table.go +++ b/chat/dynamodb/table.go @@ -26,8 +26,11 @@ import ( // user) with two LSIs, one by last_sent_at (lsiByLastSentAt) and one by // activity_score (lsiByActivityScore, reserved and empty today), and TTL on // expires_at; chat_key_envelopes is keyed by (pk, sk) = (user, chat) with no -// index. It is idempotent and blocks until all tables are ACTIVE. -func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable string) error { +// index; chat_lobbies is keyed by (pk, sk) = (user, chat) — plus one "#meta" +// aggregates item per chat and per user — with a sparse GSI of a chat's +// lobby by entry time (see gsiLobbyByChat). It is idempotent and blocks +// until all tables are ACTIVE. +func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable string) error { inputs := []*dynamodb.CreateTableInput{ { TableName: aws.String(chatsTable), @@ -216,6 +219,38 @@ func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmIn {AttributeName: aws.String(attrSK), KeyType: types.KeyTypeRange}, }, }, + { + TableName: aws.String(lobbiesTable), + BillingMode: types.BillingModePayPerRequest, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String(attrPK), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String(attrSK), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String(attrEnteredAt), AttributeType: types.ScalarAttributeTypeN}, + }, + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String(attrPK), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String(attrSK), KeyType: types.KeyTypeRange}, + }, + GlobalSecondaryIndexes: []types.GlobalSecondaryIndex{ + { + // Inverted index of a chat's lobby in entry order, hashed on + // the sk (the chat) with no attribute repeating it. Sparse + // all the same: an item is indexed only with both keys, and + // entered_at exists on entries alone, so the two #meta items + // never appear. It pages a lobby earliest first (see + // gsiLobbyByChat); the user is recovered from the projected + // pk. A GSI, not an LSI, because the table is keyed by user + // (see the store's doc): a local index could only reorder one + // user's entries, never gather one chat's. + IndexName: aws.String(gsiLobbyByChat), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String(attrSK), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String(attrEnteredAt), KeyType: types.KeyTypeRange}, + }, + Projection: &types.Projection{ProjectionType: types.ProjectionTypeAll}, + }, + }, + }, } for _, input := range inputs { @@ -287,6 +322,9 @@ func (s *store) reset() { if err := clearTable(ctx, s.client, s.keyEnvelopesTable, []string{attrPK, attrSK}); err != nil { panic(err) } + if err := clearTable(ctx, s.client, s.lobbiesTable, []string{attrPK, attrSK}); err != nil { + panic(err) + } } func clearTable(ctx context.Context, client *dynamodb.Client, table string, keyAttrs []string) error { diff --git a/chat/dynamodb/tombstone_test.go b/chat/dynamodb/tombstone_test.go index 75db325..9044892 100644 --- a/chat/dynamodb/tombstone_test.go +++ b/chat/dynamodb/tombstone_test.go @@ -27,9 +27,9 @@ import ( // any of these would delete live memberships silently. func TestChat_TombstoneTTL(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, nil) defer testStore.(*store).reset() ttl, err := testEnv.Client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{TableName: aws.String(groupMembersTable)}) diff --git a/chat/key.go b/chat/key.go index c8e09dc..974471d 100644 --- a/chat/key.go +++ b/chat/key.go @@ -44,10 +44,11 @@ import ( // when they need it. // // Storing the creator's envelope is what opens the group to its members' -// messages (see Access.SpeakerStanding); nothing is published for that -// either, since the creator's client is the one that stored it. The lobby its -// members are admitted from is not built (see Chat.IsPrivate), so today the -// only envelope a group holds is its creator's. +// messages and its lobby to those who would join (see Access.SpeakerStanding +// and Server.EnterLobby); nothing is published for that either, since the +// creator's client is the one that stored it. Every other envelope a group +// holds was stored by the creator admitting that member (see +// Server.AdmitLobbyMember), until the member replaces it with their own. func (s *Server) SetKeyEnvelope(ctx context.Context, req *chatpb.SetKeyEnvelopeRequest) (*chatpb.SetKeyEnvelopeResponse, error) { userID, err := s.authz.Authorize(ctx, req, &req.Auth) diff --git a/chat/lobby.go b/chat/lobby.go new file mode 100644 index 0000000..6c93f4a --- /dev/null +++ b/chat/lobby.go @@ -0,0 +1,573 @@ +package chat + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "time" + + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + chatpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1" + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" + eventpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/event/v1" + profilepb "github.com/code-payments/flipcash2-protobuf-api/generated/go/profile/v1" + + "github.com/code-payments/flipcash2-server/model" +) + +// The lobby: how a user is admitted to a private group (see Chat.IsPrivate and +// LobbyEntry). A user who wants in enters the group's lobby and waits; the +// group's creator, who alone sees the lobby, admits them with the chat key +// wrapped for them, or denies them. JoinChat is DENIED for a private group, +// so this is the only way in for anyone but the creator. +// +// Every RPC here is gated on the canonical record first, since its absence is +// the only thing that tells NOT_FOUND from DENIED, then on the chat being a +// private group. The creator's RPCs (GetLobbyMembers, AdmitLobbyMember, +// DenyLobbyMember) then require the caller to be the recorded creator and a +// current member: a creator who has left may rejoin and resume, but may not +// run the lobby from outside. A lobby exists only once the group has its key +// (see Access.SpeakerStanding): entering a keyless group's lobby is DENIED, as +// is admitting anyone to one, so no one waits for a group that cannot take +// them and no one is admitted without a key to be wrapped for them. The +// creator is DENIED their own lobby: they rejoin with JoinChat. +// +// An admission is one write (see Store.AdmitFromLobby): the envelope the +// creator wrapped, the membership transition and the entry's removal commit +// together, so an admitted member always has an envelope and a waiting user is +// never both waiting and admitted. An entry is likewise refused in the write +// that would make it for a user who is a member by then (see +// Store.EnterLobby), so a retried EnterLobby racing the admission cannot +// leave a member with an entry. It is announced like any join, as a +// RosterUpdate.MemberJoined to the members and to the admitted user, who +// learns of it that way and fetches their envelope (see Server.GetKeyEnvelope). +// +// The lobby's own changes reach the creator alone, as LobbyUpdates on their +// user topic (see publishLobbyUpdate): a MemberEntered carrying the waiting +// user as a LobbyMember, and a MemberLeft for every way out, a withdrawal, a +// denial or an admission. They are sent to the recorded creator whether or +// not they are a member at the time — a creator who has left is still the +// one person the lobby is for, and may rejoin to act on what they heard — and +// are best-effort, outside the event log, so a creator reconciles by reading +// the lobby. A waiting user is told nothing of a denial; they read in_lobby +// off GetChat (see hydrate) to learn they are no longer waiting. +// +// Caps (see LobbyLimits) are the store's to enforce in the write that would +// exceed them, so they hold under concurrent entries; the server only names +// them (lobbyLimits, DefaultLobbyLimits in production). + +const ( + // maxGetLobbyMembersPageSize bounds a single GetLobbyMembers page and is + // its default. + maxGetLobbyMembersPageSize = 100 + + lobbyTokenVersion = 1 + + // lobbyTokenLen is the fixed size of a lobby paging token: the version + // byte, the chat ID, the entry time and the user ID (see encodeLobbyToken). + lobbyTokenLen = 1 + GroupChatIDSize + 8 + model.UserIDSize +) + +func (s *Server) EnterLobby(ctx context.Context, req *chatpb.EnterLobbyRequest) (*chatpb.EnterLobbyResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + ) + + c, result, err := s.lobbyChat(ctx, log, req.ChatId) + if err != nil { + return nil, err + } + switch result { + case lobbyChatNotFound: + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_NOT_FOUND}, nil + case lobbyChatDenied: + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_DENIED}, nil + } + if c.IsCreator(userID) { + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_DENIED}, nil + } + + // A member is answered here, cheaply and before the key is asked about; + // the store's write decides it again atomically (see Store.EnterLobby), + // so one admitted between this read and that write is refused there and + // never gains an entry. + isMember, err := s.access.IsMemberWithChat(ctx, c, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking chat membership") + return nil, status.Error(codes.Internal, "") + } + if isMember { + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_ALREADY_MEMBER}, nil + } + + // A keyless group has no lobby (see above). Decided off the record's + // rules, as the speaker gate decides it. + keyed, err := s.access.hasKey(ctx, c.ID, c.GroupRules()) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking chat key") + return nil, status.Error(codes.Internal, "") + } + if !keyed { + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_DENIED}, nil + } + + entry, changed, err := s.chats.EnterLobby(ctx, c.ID, userID, s.lobbyLimits) + switch { + case errors.Is(err, ErrAlreadyMember): + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_ALREADY_MEMBER}, nil + case errors.Is(err, ErrLobbyFull): + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_LOBBY_FULL}, nil + case errors.Is(err, ErrTooManyLobbies): + return &chatpb.EnterLobbyResponse{Result: chatpb.EnterLobbyResponse_TOO_MANY_LOBBIES}, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure entering lobby") + return nil, status.Error(codes.Internal, "") + } + + // The chat as a waiting non-member sees it: the record alone (see + // hydrate), with in_lobby set off the entry this call holds rather than a + // read that would only repeat it. + metadata, err := s.hydrate(ctx, userID, ListenerStanding{}, ReadingDenied, []*Chat{c}) + if err != nil { + // The entry has landed; only the read back failed. A retry is the + // no-op path and returns the lobby then. + log.With(zap.Error(err)).Warn("Failure hydrating chat metadata") + return nil, status.Error(codes.Internal, "") + } + md := metadata[0] + md.InLobby = true + + if changed { + members, err := s.hydrateLobbyMembers(ctx, []LobbyEntry{entry}) + if err != nil { + // The entry has landed and the response stands; the creator + // reconciles by reading the lobby, as for any missed update. + log.With(zap.Error(err)).Warn("Failure hydrating lobby member for update") + } else { + s.publishLobbyUpdate(c, &chatpb.LobbyUpdate{Kind: &chatpb.LobbyUpdate_MemberEntered_{ + MemberEntered: &chatpb.LobbyUpdate_MemberEntered{Member: members[0]}, + }}) + } + } + + return &chatpb.EnterLobbyResponse{ + Result: chatpb.EnterLobbyResponse_OK, + Lobby: &chatpb.Lobby{ + Chat: md, + EnteredAt: timestamppb.New(entry.EnteredAt), + }, + }, nil +} + +func (s *Server) LeaveLobby(ctx context.Context, req *chatpb.LeaveLobbyRequest) (*chatpb.LeaveLobbyResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + ) + + c, result, err := s.lobbyChat(ctx, log, req.ChatId) + if err != nil { + return nil, err + } + switch result { + case lobbyChatNotFound: + return &chatpb.LeaveLobbyResponse{Result: chatpb.LeaveLobbyResponse_NOT_FOUND}, nil + case lobbyChatDenied: + return &chatpb.LeaveLobbyResponse{Result: chatpb.LeaveLobbyResponse_DENIED}, nil + } + + // Whether the group has its key is not asked: a user may always withdraw + // from wherever they are waiting. + changed, err := s.chats.LeaveLobby(ctx, c.ID, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure leaving lobby") + return nil, status.Error(codes.Internal, "") + } + if changed { + s.publishLobbyLeft(c, userID) + } + return &chatpb.LeaveLobbyResponse{Result: chatpb.LeaveLobbyResponse_OK}, nil +} + +func (s *Server) GetLobbyMembers(ctx context.Context, req *chatpb.GetLobbyMembersRequest) (*chatpb.GetLobbyMembersResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + ) + + limit := maxGetLobbyMembersPageSize + if pageSize := req.GetQueryOptions().GetPageSize(); pageSize > 0 && int(pageSize) < limit { + limit = int(pageSize) + } + + // A token names the chat it was minted for, and is refused elsewhere (see + // GetRoster for the same rule). + var after *LobbyPosition + if token := req.GetQueryOptions().GetPagingToken(); token != nil { + pos, ok := decodeLobbyToken(token, req.ChatId) + if !ok { + return nil, status.Error(codes.InvalidArgument, "invalid paging token") + } + after = &pos + } + + c, result, err := s.lobbyCreatorChat(ctx, log, req.ChatId, userID) + if err != nil { + return nil, err + } + switch result { + case lobbyChatNotFound: + return &chatpb.GetLobbyMembersResponse{Result: chatpb.GetLobbyMembersResponse_NOT_FOUND}, nil + case lobbyChatDenied: + return &chatpb.GetLobbyMembersResponse{Result: chatpb.GetLobbyMembersResponse_DENIED}, nil + } + + // One more than the page, for an exact has_more, as the roster's paged + // shape reads it. + entries, err := s.chats.GetLobbyPage(ctx, c.ID, after, limit+1) + if err != nil { + log.With(zap.Error(err)).Warn("Failure reading lobby page") + return nil, status.Error(codes.Internal, "") + } + hasMore := len(entries) > limit + if hasMore { + entries = entries[:limit] + } + + members, err := s.hydrateLobbyMembers(ctx, entries) + if err != nil { + log.With(zap.Error(err)).Warn("Failure hydrating lobby page") + return nil, status.Error(codes.Internal, "") + } + + resp := &chatpb.GetLobbyMembersResponse{ + Result: chatpb.GetLobbyMembersResponse_OK, + Members: members, + HasMore: hasMore, + } + if hasMore { + resp.PagingToken = encodeLobbyToken(c.ID, entries[len(entries)-1].Position()) + } + return resp, nil +} + +func (s *Server) AdmitLobbyMember(ctx context.Context, req *chatpb.AdmitLobbyMemberRequest) (*chatpb.AdmitLobbyMemberResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + zap.String("admitted_user_id", model.UserIDString(req.UserId)), + ) + + c, result, err := s.lobbyCreatorChat(ctx, log, req.ChatId, userID) + if err != nil { + return nil, err + } + switch result { + case lobbyChatNotFound: + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_NOT_FOUND}, nil + case lobbyChatDenied: + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_DENIED}, nil + } + + // Nobody is admitted to a group before its creator holds its key (see + // above). The creator is the caller by now, so this is their own envelope. + keyed, err := s.access.hasKey(ctx, c.ID, c.GroupRules()) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking chat key") + return nil, status.Error(codes.Internal, "") + } + if !keyed { + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_DENIED}, nil + } + + // A member already is the no-op the proto promises, their envelope left + // as it is: decided here, before the write, since the write would replace + // it. The creator admitting themself is that case too. + isMember, err := s.chats.IsMember(ctx, c.ID, req.UserId) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking admitted user's membership") + return nil, status.Error(codes.Internal, "") + } + if isMember { + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_OK}, nil + } + + envelope := KeyEnvelopeFromProto(req.KeyEnvelope, userID) + changed, roster, err := s.chats.AdmitFromLobby(ctx, c.ID, req.UserId, envelope) + switch { + case errors.Is(err, ErrNotInLobby): + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_NOT_IN_LOBBY}, nil + case errors.Is(err, ErrChatNotFound): + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_NOT_FOUND}, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure admitting lobby member") + return nil, status.Error(codes.Internal, "") + } + if !changed { + // Admitted by a concurrent call between the membership check and the + // write: theirs announced it. + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_OK}, nil + } + + // Announced as any join is (see JoinChat): the admitted user's own entry + // as hydrated for them, with the metadata to their own devices so they + // can insert the chat, and the record the write produced. + metadata, err := s.hydrate(ctx, req.UserId, memberListenerStanding, ReadingFull, []*Chat{c}) + if err != nil { + // The admission has landed; only the read back failed. The admitted + // user is told nothing, and reads the chat on their next open; the + // creator's retry is the no-op path. + log.With(zap.Error(err)).Warn("Failure hydrating chat metadata for admitted user") + return nil, status.Error(codes.Internal, "") + } + md := metadata[0] + rosterSummary := roster.ToProto() + md.RosterSummary = rosterSummary + member, err := s.announcedMember(ctx, log, c.ID, req.UserId, md.Members[0].UserProfile, roster) + if err != nil { + return nil, err + } + toMembers := &chatpb.RosterUpdate{ + Kind: &chatpb.RosterUpdate_MemberJoined_{MemberJoined: &chatpb.RosterUpdate_MemberJoined{Member: member}}, + RosterSummary: rosterSummary, + } + toJoiner := &chatpb.RosterUpdate{ + Kind: &chatpb.RosterUpdate_MemberJoined_{MemberJoined: &chatpb.RosterUpdate_MemberJoined{ + Member: member, + Metadata: md, + }}, + RosterSummary: rosterSummary, + } + s.publishRosterUpdate(c.ID, req.UserId, toMembers, toJoiner) + s.publishLobbyLeft(c, req.UserId) + + return &chatpb.AdmitLobbyMemberResponse{Result: chatpb.AdmitLobbyMemberResponse_OK}, nil +} + +func (s *Server) DenyLobbyMember(ctx context.Context, req *chatpb.DenyLobbyMemberRequest) (*chatpb.DenyLobbyMemberResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + zap.String("denied_user_id", model.UserIDString(req.UserId)), + ) + + c, result, err := s.lobbyCreatorChat(ctx, log, req.ChatId, userID) + if err != nil { + return nil, err + } + switch result { + case lobbyChatNotFound: + return &chatpb.DenyLobbyMemberResponse{Result: chatpb.DenyLobbyMemberResponse_NOT_FOUND}, nil + case lobbyChatDenied: + return &chatpb.DenyLobbyMemberResponse{Result: chatpb.DenyLobbyMemberResponse_DENIED}, nil + } + + changed, err := s.chats.LeaveLobby(ctx, c.ID, req.UserId) + if err != nil { + log.With(zap.Error(err)).Warn("Failure removing lobby member") + return nil, status.Error(codes.Internal, "") + } + if changed { + s.publishLobbyLeft(c, req.UserId) + } + return &chatpb.DenyLobbyMemberResponse{Result: chatpb.DenyLobbyMemberResponse_OK}, nil +} + +// lobbyChatResult is the shared gate's verdict on a lobby RPC's chat. +type lobbyChatResult uint8 + +const ( + lobbyChatOK lobbyChatResult = iota + lobbyChatNotFound + lobbyChatDenied +) + +// lobbyChat is the gate every lobby RPC starts with: the chat is a group +// (DENIED before any read for a DM ID), its record exists (NOT_FOUND), and +// it is a private group (DENIED). On OK the record is returned for the +// caller's further gates. A gRPC error is returned only for a failed read. +func (s *Server) lobbyChat(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId) (*Chat, lobbyChatResult, error) { + if !IsGroupChatID(chatID) { + return nil, lobbyChatDenied, nil + } + c, err := s.chats.GetChatByID(ctx, chatID) + switch { + case errors.Is(err, ErrChatNotFound): + return nil, lobbyChatNotFound, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure getting chat") + return nil, 0, status.Error(codes.Internal, "") + } + if !c.IsPrivate { + return nil, lobbyChatDenied, nil + } + return c, lobbyChatOK, nil +} + +// lobbyCreatorChat is lobbyChat and then the creator's gate: the caller is +// the recorded creator and a current member (DENIED otherwise). Membership is +// the store's, strongly consistent, as every gate reads it. +func (s *Server) lobbyCreatorChat(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, userID *commonpb.UserId) (*Chat, lobbyChatResult, error) { + c, result, err := s.lobbyChat(ctx, log, chatID) + if err != nil || result != lobbyChatOK { + return nil, result, err + } + if !c.IsCreator(userID) { + return nil, lobbyChatDenied, nil + } + isMember, err := s.access.IsMemberWithChat(ctx, c, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking chat membership") + return nil, 0, status.Error(codes.Internal, "") + } + if !isMember { + return nil, lobbyChatDenied, nil + } + return c, lobbyChatOK, nil +} + +// hydrateLobbyMembers fills a page of entries into LobbyMembers: each user's +// public profile and the public key they registered with, which is what the +// creator wraps the chat key for. Every waiting user is a registered user the +// profile and account domains know, so a missing profile or key is a data +// integrity problem, not something to stand in for. The two reads run +// concurrently, the profiles batched, the keys one per user (the account +// store reads one user at a time, and a page is at most a hundred). +func (s *Server) hydrateLobbyMembers(ctx context.Context, entries []LobbyEntry) ([]*chatpb.LobbyMember, error) { + out := make([]*chatpb.LobbyMember, 0, len(entries)) + if len(entries) == 0 { + return out, nil + } + userIDs := make([]*commonpb.UserId, len(entries)) + for i, e := range entries { + userIDs[i] = e.UserID + } + + var publicProfiles map[string]*profilepb.UserProfile + publicKeys := make([]*commonpb.PublicKey, len(entries)) + g, gctx := errgroup.WithContext(ctx) + g.Go(func() (err error) { + publicProfiles, err = s.profiles.GetPublicProfiles(gctx, userIDs) + return err + }) + for i, userID := range userIDs { + g.Go(func() error { + keys, err := s.accounts.GetPubKeys(gctx, userID) + if err != nil { + return err + } + if len(keys) == 0 { + return fmt.Errorf("lobby member %s has no public key", model.UserIDString(userID)) + } + publicKeys[i] = keys[0] + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + + for i, e := range entries { + publicProfile, ok := publicProfiles[string(e.UserID.Value)] + if !ok { + return nil, fmt.Errorf("lobby member %s has no profile", model.UserIDString(e.UserID)) + } + profile := proto.Clone(publicProfile).(*profilepb.UserProfile) + profile.UserId = &commonpb.UserId{Value: append([]byte(nil), e.UserID.Value...)} + out = append(out, &chatpb.LobbyMember{ + UserProfile: profile, + PublicKey: publicKeys[i], + EnteredAt: timestamppb.New(e.EnteredAt), + }) + } + return out, nil +} + +// publishLobbyLeft announces that userID is no longer in c's lobby, whichever +// way they left (see publishLobbyUpdate). +func (s *Server) publishLobbyLeft(c *Chat, userID *commonpb.UserId) { + s.publishLobbyUpdate(c, &chatpb.LobbyUpdate{Kind: &chatpb.LobbyUpdate_MemberLeft_{ + MemberLeft: &chatpb.LobbyUpdate_MemberLeft{UserId: userID}, + }}) +} + +// publishLobbyUpdate sends one change to c's lobby to its creator's devices, +// on the creator's user topic alone: the lobby is theirs to see, so it never +// rides the chat topic, where the members would hear it. A group with no +// recorded creator has no one to tell. It is best-effort and non-blocking, as +// every publish here is, and never fails the RPC whose change it announces. +func (s *Server) publishLobbyUpdate(c *Chat, update *chatpb.LobbyUpdate) { + if c.CreatorID == nil { + return + } + s.userEventBus.OnEvent(c.CreatorID, &eventpb.Event{ + Id: model.MustGenerateEventID(), + Ts: timestamppb.Now(), + Type: &eventpb.Event_ChatUpdate{ChatUpdate: &eventpb.ChatUpdate{ + Chat: c.ID, + LobbyUpdates: &chatpb.LobbyUpdateBatch{LobbyUpdates: []*chatpb.LobbyUpdate{update}}, + }}, + }) +} + +// encodeLobbyToken serializes a page's resume position, bound to the chat it +// pages: the version byte, the chat ID, the entry time as big-endian +// nanoseconds, and the user ID (see encodeRosterToken for the same shape). +func encodeLobbyToken(chatID *commonpb.ChatId, pos LobbyPosition) *commonpb.PagingToken { + buf := make([]byte, lobbyTokenLen) + buf[0] = lobbyTokenVersion + copy(buf[1:1+GroupChatIDSize], chatID.Value) + binary.BigEndian.PutUint64(buf[1+GroupChatIDSize:], uint64(pos.EnteredAt.UnixNano())) + copy(buf[1+GroupChatIDSize+8:], pos.UserID.Value) + return &commonpb.PagingToken{Value: buf} +} + +// decodeLobbyToken parses a lobby token minted for chatID; a malformed token, +// or one bound to another chat, is refused. +func decodeLobbyToken(token *commonpb.PagingToken, chatID *commonpb.ChatId) (LobbyPosition, bool) { + b := token.GetValue() + if len(b) != lobbyTokenLen || b[0] != lobbyTokenVersion { + return LobbyPosition{}, false + } + if !bytes.Equal(b[1:1+GroupChatIDSize], chatID.GetValue()) { + return LobbyPosition{}, false + } + return LobbyPosition{ + EnteredAt: time.Unix(0, int64(binary.BigEndian.Uint64(b[1+GroupChatIDSize:]))).UTC(), + UserID: &commonpb.UserId{Value: append([]byte(nil), b[1+GroupChatIDSize+8:]...)}, + }, true +} diff --git a/chat/member.go b/chat/member.go index ddaaa5c..26bdf2d 100644 --- a/chat/member.go +++ b/chat/member.go @@ -32,11 +32,11 @@ import ( // Access), and so does a no-op join. Leaving has no rule to satisfy. // // A private group (see Chat.IsPrivate) is not joined here: its members are -// admitted by its creator from its lobby, which is not built, so JoinChat -// refuses everyone as DENIED. Everyone but the creator, that is, who needs no -// one's approval and rejoins a private group they left. No rule is evaluated -// for them: a private group's rules admit no one (see RuleEvaluator), and -// being its creator is what admits them. +// admitted by its creator from its lobby (see lobby.go), so JoinChat refuses +// everyone as DENIED. Everyone but the creator, that is, who needs no one's +// approval and rejoins a private group they left. No rule is evaluated for +// them: a private group's rules admit no one (see RuleEvaluator), and being +// its creator is what admits them. // // Each transition that actually happens is broadcast as a RosterUpdate to the // chat's members and to the affected user (see publishRosterUpdate). A join or diff --git a/chat/memory/store.go b/chat/memory/store.go index 4203d94..7d164d4 100644 --- a/chat/memory/store.go +++ b/chat/memory/store.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "slices" "sort" "sync" "time" @@ -55,6 +56,11 @@ type memory struct { // then chat ID, mirroring the persistent layout (see chat.Store). keyEnvelopes map[string]map[string]chat.KeyEnvelope + // lobbies holds each user's lobby entries, keyed by user ID then chat + // ID, as when they entered (see chat.LobbyEntry). The counts the + // persistent stores keep are computed from it. + lobbies map[string]map[string]time.Time + exclusions chat.FeedExclusions } @@ -82,6 +88,7 @@ func NewInMemory(excludedFromFeed []*commonpb.UserId) chat.Store { mutedCounts: make(map[string]uint64), lastSent: make(map[string]map[string]time.Time), keyEnvelopes: make(map[string]map[string]chat.KeyEnvelope), + lobbies: make(map[string]map[string]time.Time), } } @@ -97,6 +104,7 @@ func (m *memory) reset() { m.excludedFromFeed = make(map[string]map[string]struct{}) m.lastSent = make(map[string]map[string]time.Time) m.keyEnvelopes = make(map[string]map[string]chat.KeyEnvelope) + m.lobbies = make(map[string]map[string]time.Time) } // isJoinedLocked reports whether the user's record on the group has them @@ -843,3 +851,162 @@ func (m *memory) GetKeyEnvelope(_ context.Context, chatID *commonpb.ChatId, user } return stored.Clone(), nil } + +func (m *memory) EnterLobby(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, limits chat.LobbyLimits) (chat.LobbyEntry, bool, error) { + if !chat.IsGroupChatID(chatID) { + return chat.LobbyEntry{}, false, fmt.Errorf("not a group chat id") + } + + m.Lock() + defer m.Unlock() + + userKey, chatKey := string(userID.Value), string(chatID.Value) + // Membership first, then an existing entry, then the lobby's cap before + // the user's, as the persistent stores judge them. + if m.isJoinedLocked(chatKey, userKey) { + return chat.LobbyEntry{}, false, chat.ErrAlreadyMember + } + if enteredAt, ok := m.lobbies[userKey][chatKey]; ok { + return chat.LobbyEntry{UserID: cloneUserID(userID), EnteredAt: enteredAt}, false, nil + } + if m.lobbySizeLocked(chatKey) >= limits.LobbySize { + return chat.LobbyEntry{}, false, chat.ErrLobbyFull + } + if len(m.lobbies[userKey]) >= limits.LobbiesPerUser { + return chat.LobbyEntry{}, false, chat.ErrTooManyLobbies + } + + byChat := m.lobbies[userKey] + if byChat == nil { + byChat = make(map[string]time.Time) + m.lobbies[userKey] = byChat + } + enteredAt := time.Now().UTC() + byChat[chatKey] = enteredAt + return chat.LobbyEntry{UserID: cloneUserID(userID), EnteredAt: enteredAt}, true, nil +} + +func (m *memory) LeaveLobby(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error) { + if !chat.IsGroupChatID(chatID) { + return false, fmt.Errorf("not a group chat id") + } + + m.Lock() + defer m.Unlock() + + return m.leaveLobbyLocked(chatID, userID), nil +} + +func (m *memory) GetLobbyEntries(_ context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]chat.LobbyEntry, error) { + m.Lock() + defer m.Unlock() + + out := make(map[string]chat.LobbyEntry) + byChat := m.lobbies[string(userID.Value)] + for _, chatID := range chatIDs { + if enteredAt, ok := byChat[string(chatID.Value)]; ok { + out[string(chatID.Value)] = chat.LobbyEntry{UserID: cloneUserID(userID), EnteredAt: enteredAt} + } + } + return out, nil +} + +func (m *memory) GetLobbyPage(_ context.Context, chatID *commonpb.ChatId, after *chat.LobbyPosition, limit int) ([]chat.LobbyEntry, error) { + if !chat.IsGroupChatID(chatID) { + return nil, fmt.Errorf("not a group chat id") + } + + m.Lock() + defer m.Unlock() + + chatKey := string(chatID.Value) + entries := make([]chat.LobbyEntry, 0) + for userKey, byChat := range m.lobbies { + if enteredAt, ok := byChat[chatKey]; ok { + entries = append(entries, chat.LobbyEntry{UserID: &commonpb.UserId{Value: []byte(userKey)}, EnteredAt: enteredAt}) + } + } + slices.SortFunc(entries, func(a, b chat.LobbyEntry) int { return a.Position().Compare(b.Position()) }) + if after != nil { + entries = slices.DeleteFunc(entries, func(e chat.LobbyEntry) bool { return e.Position().Compare(*after) <= 0 }) + } + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + return entries, nil +} + +func (m *memory) AdmitFromLobby(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope chat.KeyEnvelope) (bool, chat.RosterSummary, error) { + if !chat.IsGroupChatID(chatID) { + return false, chat.RosterSummary{}, fmt.Errorf("not a group chat id") + } + if envelope.WrappedBy == nil { + return false, chat.RosterSummary{}, fmt.Errorf("key envelope has no wrapper") + } + + m.Lock() + defer m.Unlock() + + userKey, chatKey := string(userID.Value), string(chatID.Value) + if _, ok := m.chats[chatKey]; !ok { + return false, chat.RosterSummary{}, chat.ErrChatNotFound + } + // A member already is the no-op, judged before the lobby as the + // persistent stores judge it (see chat.Store.AdmitFromLobby). + if m.isJoinedLocked(chatKey, userKey) { + return false, m.rosterSummaryLocked(chatID), nil + } + if _, ok := m.lobbies[userKey][chatKey]; !ok { + return false, chat.RosterSummary{}, chat.ErrNotInLobby + } + + byChat := m.keyEnvelopes[userKey] + if byChat == nil { + byChat = make(map[string]chat.KeyEnvelope) + m.keyEnvelopes[userKey] = byChat + } + byChat[chatKey] = envelope.Clone() + + members := m.groupMembers[chatKey] + if members == nil { + members = make(map[string]*memberRecord) + m.groupMembers[chatKey] = members + } + m.groupVersions[chatKey]++ + members[userKey] = &memberRecord{ + joined: true, + joinedAt: time.Now().UTC(), + version: m.groupVersions[chatKey], + } + m.leaveLobbyLocked(chatID, userID) + return true, m.rosterSummaryLocked(chatID), nil +} + +// lobbySizeLocked counts the users waiting in chatKey's lobby. +func (m *memory) lobbySizeLocked(chatKey string) int { + n := 0 + for _, byChat := range m.lobbies { + if _, ok := byChat[chatKey]; ok { + n++ + } + } + return n +} + +// leaveLobbyLocked removes userID's entry in chatID's lobby, reporting +// whether there was one. +func (m *memory) leaveLobbyLocked(chatID *commonpb.ChatId, userID *commonpb.UserId) bool { + byChat := m.lobbies[string(userID.Value)] + if _, ok := byChat[string(chatID.Value)]; !ok { + return false + } + delete(byChat, string(chatID.Value)) + if len(byChat) == 0 { + delete(m.lobbies, string(userID.Value)) + } + return true +} + +func cloneUserID(userID *commonpb.UserId) *commonpb.UserId { + return &commonpb.UserId{Value: append([]byte(nil), userID.Value...)} +} diff --git a/chat/model.go b/chat/model.go index 3ad84e3..19d4596 100644 --- a/chat/model.go +++ b/chat/model.go @@ -281,8 +281,9 @@ func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatp // has stored theirs: a keyless group's members are refused every send, and // a keyed group's members send encrypted content under the group's scheme // and nothing else (see Access.SpeakerStanding and -// messaging.Server.SendMessage). The lobby a user would enter to be admitted -// is not built, so its creator is the only member it can have. +// messaging.Server.SendMessage). Its other members are admitted by its +// creator from its lobby (see LobbyEntry and Server.EnterLobby), with the +// chat key wrapped for each by the creator. // // CreatorID is the user who created the group, or nil when unknown (a DM has // none, and so does any group written before the field existed). It is fixed @@ -834,3 +835,69 @@ type RecentSender struct { UserID *commonpb.UserId LastSentAt time.Time } + +// A private group's lobby (see Chat.IsPrivate) is where a user waits to be +// admitted to it by its creator: they enter it (Server.EnterLobby), and leave +// it when they withdraw, are denied, or are admitted (Server.AdmitLobbyMember, +// which stores the chat key wrapped for them and joins them in one write). A +// lobby is the creator's alone to see; the waiting users are not shown to +// each other or to the members. It is recorded as one entry per (user, +// private group), written against the IDs alone like a key envelope, and is +// not membership: a waiting user has no standing in the chat beyond the +// record any registered user sees, with Metadata.in_lobby set for them. +// +// Both a lobby and a user's waiting are capped (see LobbyLimits), so a store +// counts both and refuses an entry past either cap in the write that would +// have made it. A lobby is read two ways: a user's own entries, strongly +// consistent, for in_lobby and the entry EnterLobby returns, and one day the +// listing of every lobby a user waits in; and a group's lobby paged +// earliest-entered first (see LobbyPosition) off an index that trails writes +// briefly, as the proto allows, since the creator reads it to act on it and +// reconciles against the LobbyUpdates they receive. + +// LobbyEntry is one user's place in a private group's lobby: who, and when +// they entered. It is what a lobby page carries per user and what +// LobbyMember.entered_at and Lobby.entered_at are projected from. +type LobbyEntry struct { + UserID *commonpb.UserId + EnteredAt time.Time +} + +// Position is the entry's place in its lobby's order. +func (e LobbyEntry) Position() LobbyPosition { + return LobbyPosition{EnteredAt: e.EnteredAt, UserID: e.UserID} +} + +// LobbyPosition is a place in a lobby's order: earliest entered first, ties +// broken by user ID ascending, so the order is total and a page can resume +// strictly after the last entry it carried. Ties in entry time are +// nanosecond coincidences within one lobby, so the tie-break exists for +// totality rather than for anything a client sees. +type LobbyPosition struct { + EnteredAt time.Time + UserID *commonpb.UserId +} + +// Compare orders two positions: negative when p precedes o. +func (p LobbyPosition) Compare(o LobbyPosition) int { + if c := p.EnteredAt.Compare(o.EnteredAt); c != 0 { + return c + } + return bytes.Compare(p.UserID.GetValue(), o.UserID.GetValue()) +} + +// LobbyLimits caps a lobby's size and how many lobbies one user may wait +// in, enforced by Store.EnterLobby in the write that records the entry. The +// server's are DefaultLobbyLimits; tests lower them. +type LobbyLimits struct { + // LobbySize is the most users one group's lobby holds. + LobbySize int + // LobbiesPerUser is the most lobbies one user waits in at once. + LobbiesPerUser int +} + +// DefaultLobbyLimits are the caps in production, deliberately modest to +// start: a lobby of a hundred is as many as a creator admits in a sitting, +// and a user waiting in a hundred groups is not waiting to join them. Either +// is raised here when it binds. +var DefaultLobbyLimits = LobbyLimits{LobbySize: 100, LobbiesPerUser: 100} diff --git a/chat/server.go b/chat/server.go index 9fc8151..386c0f6 100644 --- a/chat/server.go +++ b/chat/server.go @@ -190,9 +190,25 @@ type Server struct { // thousands of members. rosterWholeReadCap int + // lobbyLimits caps a private group's lobby and a user's lobbies (see + // lobby.go). DefaultLobbyLimits in production; tests lower them with + // WithLobbyLimits to exercise the refusals without thousands of users. + lobbyLimits LobbyLimits + chatpb.UnimplementedChatServer } +// ServerOption configures a Server at construction beyond its required +// dependencies. +type ServerOption func(*Server) + +// WithLobbyLimits overrides DefaultLobbyLimits (see LobbyLimits). +func WithLobbyLimits(limits LobbyLimits) ServerOption { + return func(s *Server) { + s.lobbyLimits = limits + } +} + func NewServer( log *zap.Logger, @@ -214,8 +230,10 @@ func NewServer( teamUserID *commonpb.UserId, disableGetRoster bool, + + opts ...ServerOption, ) *Server { - return &Server{ + s := &Server{ log: log, authz: authz, @@ -240,7 +258,12 @@ func NewServer( maxGroupFeedChats: maxGroupFeedChats, rosterWholeReadCap: rosterWholeReadCap, + lobbyLimits: DefaultLobbyLimits, + } + for _, opt := range opts { + opt(s) } + return s } // GetChat returns one chat's metadata as the caller may see it. @@ -274,8 +297,9 @@ func NewServer( // is returned to any registered user, with is_private set, so that one who is // not a member can see what they would ask to join. It carries no rules, so a // non-member's standing is none under every mode and they see the record -// alone: its messaging state is its members'. A private group whose key has -// not been stored is returned like any other. +// alone: its messaging state is its members'. What a non-member does get is +// in_lobby, whether they are waiting in its lobby (see lobby.go). A private +// group whose key has not been stored is returned like any other. // // The group's picture is returned in every case: it is part of the record, as // the title is, and the two are what identify a group — a group's picture is @@ -448,6 +472,12 @@ func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest) // standing is never a member's and it is never shown a DM, so no per-viewer // state is read on its behalf. // +// in_lobby is per-viewer and a non-member's alone: whether the viewer is +// waiting in a private group's lobby (see lobby.go), read for every private +// group in the set when the viewer is not a member, one strongly consistent +// read across the set, and never for a member, who is past waiting, or for a +// group that is not private, which has no lobby. A nil viewer waits nowhere. +// // viewer_state is per-viewer too, and a member's alone: it is what the chat // holds about the viewer (see ViewerState) plus what they may do in it, and // a non-member may do nothing and has no standing to see what the record @@ -478,8 +508,12 @@ func (s *Server) hydrate(ctx context.Context, viewerID *commonpb.UserId, standin uniquePeerIDs := make(map[string]*commonpb.UserId) uniquePictureBlobIDs := make(map[string]*blobpb.BlobId) uniqueDmMemberIDs := make(map[string]*commonpb.UserId) + var lobbyChatIDs []*commonpb.ChatId hydratedMembers := make([][]*commonpb.UserId, len(chats)) for i, c := range chats { + if c.IsPrivate && !standing.IsMember && viewerID != nil { + lobbyChatIDs = append(lobbyChatIDs, c.ID) + } // The members to hydrate: a DM's participants, or the viewer alone in a // group they are a member of (see above). members := c.Members @@ -563,6 +597,7 @@ func (s *Server) hydrate(ctx context.Context, viewerID *commonpb.UserId, standin pictureRenditions map[string][]*blobpb.Rendition viewerStates map[string]ViewerState staffByUserId map[string]bool + lobbyEntries map[string]LobbyEntry ) chatIDs := make([]*commonpb.ChatId, len(chats)) for i, c := range chats { @@ -619,6 +654,12 @@ func (s *Server) hydrate(ctx context.Context, viewerID *commonpb.UserId, standin return err }) } + if len(lobbyChatIDs) > 0 { + g.Go(func() (err error) { + lobbyEntries, err = s.chats.GetLobbyEntries(gctx, viewerID, lobbyChatIDs) + return err + }) + } if len(dmMemberIDs) > 0 { g.Go(func() (err error) { staffByUserId, err = s.staffFlags(gctx, dmMemberIDs) @@ -663,6 +704,9 @@ func (s *Server) hydrate(ctx context.Context, viewerID *commonpb.UserId, standin if peer, ok := dmPeerByChat[key]; ok { md.IsHidden = blockedPeers[string(peer.Value)] } + if _, ok := lobbyEntries[key]; ok { + md.InLobby = true + } if IsDmChatType(c.Type) { md.UseE2Ee = useE2ee(c, staffByUserId, s.teamUserID) md.Rules = s.rules.RulesOf(c) diff --git a/chat/store.go b/chat/store.go index b2d0295..7db677e 100644 --- a/chat/store.go +++ b/chat/store.go @@ -27,6 +27,22 @@ var ( // or be added to it, since every such path gates on membership. ErrNoMembers = errors.New("chat must have at least one member") + // ErrLobbyFull indicates that a private group's lobby holds as many users + // as the limits allow (see LobbyLimits.LobbySize). + ErrLobbyFull = errors.New("lobby full") + + // ErrTooManyLobbies indicates that a user is waiting in as many lobbies + // as the limits allow (see LobbyLimits.LobbiesPerUser). + ErrTooManyLobbies = errors.New("too many lobbies") + + // ErrNotInLobby indicates that a user is not waiting in the lobby they + // were to be admitted from. + ErrNotInLobby = errors.New("not in lobby") + + // ErrAlreadyMember indicates that a user is a member of the group whose + // lobby they were to enter. + ErrAlreadyMember = errors.New("already a member") + // ErrKeyEnvelopeNotFound indicates that a user has no key envelope stored // for a chat. ErrKeyEnvelopeNotFound = errors.New("key envelope not found") @@ -102,6 +118,15 @@ type DmFeedCursor struct { // which removes the envelope in the same write when the caller asks it to // (see RemoveGroupMember). A store of one is one conditional write of the one // record, so a retried or duplicated request is harmless. +// +// A lobby entry (see LobbyEntry) is one record per (user, private group) too, +// kept with two counts, the lobby's size and the user's lobbies, that move in +// the same write as the entry (see EnterLobby). Like the rest, it is written +// against the IDs alone; that the chat is a private group with its key, that +// the user is not a member, and who may admit them are the caller's gates. An +// admission (AdmitFromLobby) is where the lobby meets the roster: the entry +// leaves, the key envelope lands and the membership transitions in one +// write, so a user is never admitted without a key or left waiting after. type Store interface { // PutChat persists a new chat and its membership. It returns ErrChatExists // if a chat with the same ID already exists, ErrNoMembers if the member set @@ -464,4 +489,54 @@ type Store interface { // consistent: it reflects every write that completed before it, so a // user who just stored an envelope, or was just given one, reads it. GetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (KeyEnvelope, error) + + // EnterLobby records userID as waiting in the group chatID's lobby, as + // of now, and returns their entry. A member of the group is refused as + // ErrAlreadyMember, judged in the write that would record the entry + // against the membership record as of that write, so an entry can never + // land for a user admitted meanwhile (see AdmitFromLobby, which removes + // the entry in the write that joins them): the lobby and the roster + // never hold the same user. A user already waiting is a no-op that + // returns the entry as it stands, with changed false; the entry's time + // is the first entry's. Both limits are enforced in the same write: a + // lobby at LobbySize is ErrLobbyFull and a user in LobbiesPerUser + // lobbies ErrTooManyLobbies, judged against the counts as of the write, + // with nothing recorded. Membership is judged before an existing entry, + // and the lobby's cap before the user's. It returns an error if chatID + // is not a group chat ID. It does not check that the chat exists or that + // it is a private group: those are the caller's. + EnterLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, limits LobbyLimits) (entry LobbyEntry, changed bool, err error) + + // LeaveLobby removes userID from chatID's lobby, reporting whether they + // were in it. A user not waiting is a no-op. It returns an error if + // chatID is not a group chat ID. + LeaveLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (changed bool, err error) + + // GetLobbyEntries returns userID's entry in each of the given lobbies + // they are waiting in, keyed by string(chatID.Value); lobbies they are + // not in are absent. The read is strongly consistent, like a key + // envelope's: it is what a user is told of their own waiting. + GetLobbyEntries(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]LobbyEntry, error) + + // GetLobbyPage returns up to limit of chatID's lobby in lobby order (see + // LobbyPosition), strictly after the given position, or from the start + // when it is nil. A limit of zero or less means no limit. The read is + // eventually consistent: an entry just made may be absent and one just + // removed present, which the proto allows of this read alone. A group + // with no lobby, or that does not exist, is an empty result. It returns + // an error if chatID is not a group chat ID. + GetLobbyPage(ctx context.Context, chatID *commonpb.ChatId, after *LobbyPosition, limit int) ([]LobbyEntry, error) + + // AdmitFromLobby admits userID, waiting in chatID's lobby, to the group: + // in one write it stores envelope as their key envelope (replacing any + // they hold, since the one the creator wraps now is the one that opens + // the chat), joins them as a member (one membership transition, as + // AddGroupMembers makes it) and removes their lobby entry with its + // counts. It returns the roster summary as of the write, as + // AddGroupMembers does, and changed false when the user was a member + // already (nothing is written, their entry included). It returns + // ErrNotInLobby when the user is not waiting, with nothing written; + // ErrChatNotFound if the chat does not exist; and an error if chatID is + // not a group chat ID or envelope.WrappedBy is nil. + AdmitFromLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope KeyEnvelope) (changed bool, roster RosterSummary, err error) } diff --git a/chat/tests/server.go b/chat/tests/server.go index bec69c3..d0e93e9 100644 --- a/chat/tests/server.go +++ b/chat/tests/server.go @@ -115,6 +115,12 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_KeyEnvelope_Gates, testServer_KeyEnvelope_Creator, testServer_KeyEnvelope_Member, + testServer_Lobby_Gates, + testServer_Lobby_EnterAndLeave, + testServer_Lobby_Limits, + testServer_Lobby_GetLobbyMembers, + testServer_Lobby_Admit, + testServer_Lobby_Deny, testServer_StartChat_InvalidRules, testServer_StartChat_RulesNotSatisfied, testServer_StartChat_WithRules, @@ -169,6 +175,9 @@ type serverEnv struct { // zero value is what every test gets unless it asks otherwise. type serverConfig struct { disableGetRoster bool + + // lobbyLimits, when set, override chat.DefaultLobbyLimits. + lobbyLimits *chat.LobbyLimits } func newServerEnv(t *testing.T, s chat.Store) *serverEnv { @@ -202,7 +211,11 @@ func newServerEnvWithConfig(t *testing.T, s chat.Store, cfg serverConfig) *serve media := newFakeMedia() moderator := &fakeModerator{} access := chat.NewAccess(s, chat.NewRuleEvaluator(accounts, balances, s, teamUserID)) - server := chat.NewServer(log, authz, accounts, blocklist, s, media, messaging, moderator, profiles, access, userBus, chatBus, teamUserID, cfg.disableGetRoster) + var opts []chat.ServerOption + if cfg.lobbyLimits != nil { + opts = append(opts, chat.WithLobbyLimits(*cfg.lobbyLimits)) + } + server := chat.NewServer(log, authz, accounts, blocklist, s, media, messaging, moderator, profiles, access, userBus, chatBus, teamUserID, cfg.disableGetRoster, opts...) cc := testutil.RunGRPCServer(t, log, testutil.WithService(func(s *grpc.Server) { chatpb.RegisterChatServer(s, server) })) @@ -4613,3 +4626,462 @@ func testServer_ViewerState_Permissions(t *testing.T, s chat.Store) { title := "Created, Edited" require.Equal(t, chatpb.EditChatResponse_OK, e.mustEditChat(e.keys, created.Chat.ChatId, &title, nil).Result) } + +// addBoundUser is addUser with the user's public key bound in the account +// store, as every registered user's is: a lobby member is shown with the key +// the creator wraps the chat key for. +func (e *serverEnv) addBoundUser(displayName string) (*commonpb.UserId, model.KeyPair) { + userID, keys := e.addUser() + _, err := e.accounts.Bind(e.ctx, userID, keys.Proto()) + require.NoError(e.t, err) + e.profiles.displayNames[string(userID.Value)] = displayName + return userID, keys +} + +// putKeyedPrivateGroup is putPrivateGroup with the creator's key envelope +// stored, so the group has its key and a lobby. +func (e *serverEnv) putKeyedPrivateGroup(title string, others ...*commonpb.UserId) *commonpb.ChatId { + chatID := e.putPrivateGroup(title, others...) + _, err := e.store.SetKeyEnvelope(e.ctx, chatID, e.userID, chat.KeyEnvelopeFromProto(keyEnvelopeProto(1), e.userID)) + require.NoError(e.t, err) + return chatID +} + +func (e *serverEnv) enterLobby(keys model.KeyPair, chatID *commonpb.ChatId) *chatpb.EnterLobbyResponse { + req := &chatpb.EnterLobbyRequest{ChatId: chatID} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.EnterLobby(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +func (e *serverEnv) leaveLobby(keys model.KeyPair, chatID *commonpb.ChatId) *chatpb.LeaveLobbyResponse { + req := &chatpb.LeaveLobbyRequest{ChatId: chatID} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.LeaveLobby(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +func (e *serverEnv) getLobbyMembers(keys model.KeyPair, chatID *commonpb.ChatId, opts *commonpb.QueryOptions) (*chatpb.GetLobbyMembersResponse, error) { + req := &chatpb.GetLobbyMembersRequest{ChatId: chatID, QueryOptions: opts} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + return e.client.GetLobbyMembers(e.ctx, req) +} + +// waitForLobbyMembers reads chatID's lobby as keys until it holds n members: +// the page trails writes briefly (see chat.Store.GetLobbyPage). +func (e *serverEnv) waitForLobbyMembers(keys model.KeyPair, chatID *commonpb.ChatId, n int) *chatpb.GetLobbyMembersResponse { + var resp *chatpb.GetLobbyMembersResponse + require.Eventually(e.t, func() bool { + var err error + resp, err = e.getLobbyMembers(keys, chatID, nil) + require.NoError(e.t, err) + require.Equal(e.t, chatpb.GetLobbyMembersResponse_OK, resp.Result) + return len(resp.Members) == n + }, 5*time.Second, 10*time.Millisecond) + return resp +} + +func (e *serverEnv) admitLobbyMember(keys model.KeyPair, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope *chatpb.KeyEnvelope) *chatpb.AdmitLobbyMemberResponse { + req := &chatpb.AdmitLobbyMemberRequest{ChatId: chatID, UserId: userID, KeyEnvelope: envelope} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.AdmitLobbyMember(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +func (e *serverEnv) denyLobbyMember(keys model.KeyPair, chatID *commonpb.ChatId, userID *commonpb.UserId) *chatpb.DenyLobbyMemberResponse { + req := &chatpb.DenyLobbyMemberRequest{ChatId: chatID, UserId: userID} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.DenyLobbyMember(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +// lobbyUpdatesOnUserTopic collects every LobbyUpdate for chatID published on +// userID's topic, in order. +func (e *serverEnv) lobbyUpdatesOnUserTopic(userID *commonpb.UserId, chatID *commonpb.ChatId) []*chatpb.LobbyUpdate { + var updates []*chatpb.LobbyUpdate + for _, ev := range e.userObserver.GetEvents(func(k *commonpb.UserId) bool { return bytes.Equal(k.Value, userID.Value) }) { + update := ev.Event.GetChatUpdate() + if !bytes.Equal(update.GetChat().GetValue(), chatID.Value) { + continue + } + updates = append(updates, update.GetLobbyUpdates().GetLobbyUpdates()...) + } + return updates +} + +// waitForLobbyUpdates waits for at least n lobby updates for chatID on +// userID's topic and returns them. +func (e *serverEnv) waitForLobbyUpdates(userID *commonpb.UserId, chatID *commonpb.ChatId, n int) []*chatpb.LobbyUpdate { + e.userObserver.WaitFor(e.t, func([]*event.KeyAndEvent[*commonpb.UserId, *eventpb.Event]) bool { + return len(e.lobbyUpdatesOnUserTopic(userID, chatID)) >= n + }) + return e.lobbyUpdatesOnUserTopic(userID, chatID) +} + +// requireNoLobbyUpdatesOnChatTopic asserts, after giving the bus a moment, +// that nothing published on chatID's topic carried a lobby update: the lobby +// is the creator's alone. +func (e *serverEnv) requireNoLobbyUpdatesOnChatTopic(chatID *commonpb.ChatId) { + time.Sleep(100 * time.Millisecond) + for _, ev := range e.chatObserver.GetEvents(func(k *commonpb.ChatId) bool { return bytes.Equal(k.Value, chatID.Value) }) { + require.Nil(e.t, ev.Event.GetEvent().GetChatUpdate().GetLobbyUpdates()) + } +} + +// testServer_Lobby_Gates pins who may do what with a lobby: nothing of a +// DM or a public group, NOT_FOUND for a group that is not there, no lobby for +// a keyless private group, the creator's RPCs the creator's alone and only +// while a member, and no place in a lobby for a member or the creator. +func testServer_Lobby_Gates(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + strangerID, strangerKeys := e.addBoundUser("Stranger") + memberID, memberKeys := e.addBoundUser("Member") + + requireAllDenied := func(chatID *commonpb.ChatId, keys model.KeyPair) { + t.Helper() + require.Equal(t, chatpb.EnterLobbyResponse_DENIED, e.enterLobby(keys, chatID).Result) + require.Equal(t, chatpb.LeaveLobbyResponse_DENIED, e.leaveLobby(keys, chatID).Result) + got, err := e.getLobbyMembers(keys, chatID, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_DENIED, got.Result) + require.Empty(t, got.Members) + require.Equal(t, chatpb.AdmitLobbyMemberResponse_DENIED, e.admitLobbyMember(keys, chatID, strangerID, keyEnvelopeProto(2)).Result) + require.Equal(t, chatpb.DenyLobbyMemberResponse_DENIED, e.denyLobbyMember(keys, chatID, strangerID).Result) + } + + // A DM, for its members and anyone; a public group, for its creator and a + // stranger alike. + dm := e.putDM(at(1)) + requireAllDenied(dm, e.keys) + requireAllDenied(dm, strangerKeys) + public := e.putGroup("Public", at(1)) + requireAllDenied(public, e.keys) + requireAllDenied(public, strangerKeys) + + // A group that does not exist. + missing := chat.MustGenerateGroupChatID() + require.Equal(t, chatpb.EnterLobbyResponse_NOT_FOUND, e.enterLobby(strangerKeys, missing).Result) + require.Equal(t, chatpb.LeaveLobbyResponse_NOT_FOUND, e.leaveLobby(strangerKeys, missing).Result) + got, err := e.getLobbyMembers(e.keys, missing, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_NOT_FOUND, got.Result) + require.Equal(t, chatpb.AdmitLobbyMemberResponse_NOT_FOUND, e.admitLobbyMember(e.keys, missing, strangerID, keyEnvelopeProto(2)).Result) + require.Equal(t, chatpb.DenyLobbyMemberResponse_NOT_FOUND, e.denyLobbyMember(e.keys, missing, strangerID).Result) + + // A keyless private group has no lobby to enter and admits no one, while + // its creator may still read the (empty) lobby and leave or deny is the + // no-op it is. + keyless := e.putPrivateGroup("Keyless", memberID) + require.Equal(t, chatpb.EnterLobbyResponse_DENIED, e.enterLobby(strangerKeys, keyless).Result) + require.Equal(t, chatpb.LeaveLobbyResponse_OK, e.leaveLobby(strangerKeys, keyless).Result) + got, err = e.getLobbyMembers(e.keys, keyless, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_OK, got.Result) + require.Empty(t, got.Members) + require.Equal(t, chatpb.AdmitLobbyMemberResponse_DENIED, e.admitLobbyMember(e.keys, keyless, strangerID, keyEnvelopeProto(2)).Result) + require.Equal(t, chatpb.DenyLobbyMemberResponse_OK, e.denyLobbyMember(e.keys, keyless, strangerID).Result) + + // A keyed one: the creator's RPCs are refused to a member who is not the + // creator and to a stranger, and the lobby is for neither the creator nor + // a member. + keyed := e.putKeyedPrivateGroup("Keyed", memberID) + for _, keys := range []model.KeyPair{memberKeys, strangerKeys} { + got, err := e.getLobbyMembers(keys, keyed, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_DENIED, got.Result) + require.Equal(t, chatpb.AdmitLobbyMemberResponse_DENIED, e.admitLobbyMember(keys, keyed, strangerID, keyEnvelopeProto(2)).Result) + require.Equal(t, chatpb.DenyLobbyMemberResponse_DENIED, e.denyLobbyMember(keys, keyed, strangerID).Result) + } + require.Equal(t, chatpb.EnterLobbyResponse_DENIED, e.enterLobby(e.keys, keyed).Result) + require.Equal(t, chatpb.EnterLobbyResponse_ALREADY_MEMBER, e.enterLobby(memberKeys, keyed).Result) + + // A creator who has left runs nothing until they rejoin. + left, err := e.leaveChat(e.keys, keyed) + require.NoError(t, err) + require.Equal(t, chatpb.LeaveChatResponse_OK, left.Result) + got, err = e.getLobbyMembers(e.keys, keyed, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_DENIED, got.Result) + require.Equal(t, chatpb.AdmitLobbyMemberResponse_DENIED, e.admitLobbyMember(e.keys, keyed, strangerID, keyEnvelopeProto(2)).Result) + require.Equal(t, chatpb.DenyLobbyMemberResponse_DENIED, e.denyLobbyMember(e.keys, keyed, strangerID).Result) + require.Equal(t, chatpb.EnterLobbyResponse_DENIED, e.enterLobby(e.keys, keyed).Result) + rejoined, err := e.joinChat(e.keys, keyed) + require.NoError(t, err) + require.Equal(t, chatpb.JoinChatResponse_OK, rejoined.Result) + got, err = e.getLobbyMembers(e.keys, keyed, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_OK, got.Result) +} + +// testServer_Lobby_EnterAndLeave pins a user's way into and out of a lobby: +// the entry's Lobby, in_lobby on the chat for them and no one else, the +// creator's MemberEntered with the user as a LobbyMember, the no-op repeat, +// and the withdrawal with its MemberLeft. Nothing of it reaches the chat +// topic. +func testServer_Lobby_EnterAndLeave(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + e.profiles.displayNames[string(e.userID.Value)] = "Creator" + userID, userKeys := e.addBoundUser("Waiting") + chatID := e.putKeyedPrivateGroup("Sunday Hikers") + + before := time.Now() + resp := e.enterLobby(userKeys, chatID) + require.Equal(t, chatpb.EnterLobbyResponse_OK, resp.Result) + require.NotNil(t, resp.Lobby) + require.False(t, resp.Lobby.EnteredAt.AsTime().Before(before.Add(-time.Second))) + md := resp.Lobby.Chat + require.Equal(t, chatID.Value, md.ChatId.Value) + require.Equal(t, "Sunday Hikers", md.Title) + require.True(t, md.IsPrivate) + require.True(t, md.InLobby) + require.Empty(t, md.Members) + require.Nil(t, md.ViewerState) + require.Nil(t, md.LastMessage) + + // The chat says so for them, and for no one else. + got := e.getChat(userKeys, chatID) + require.Equal(t, chatpb.GetChatResponse_OK, got.Result) + require.True(t, got.Metadata.InLobby) + require.Empty(t, got.Metadata.Members) + got = e.getChat(e.keys, chatID) + require.Equal(t, chatpb.GetChatResponse_OK, got.Result) + require.False(t, got.Metadata.InLobby) + _, strangerKeys := e.addBoundUser("Stranger") + got = e.getChat(strangerKeys, chatID) + require.Equal(t, chatpb.GetChatResponse_OK, got.Result) + require.False(t, got.Metadata.InLobby) + + // The creator hears of it, with the user as the lobby shows them. + updates := e.waitForLobbyUpdates(e.userID, chatID, 1) + entered := updates[0].GetMemberEntered() + require.NotNil(t, entered) + require.Equal(t, userID.Value, entered.Member.UserProfile.UserId.Value) + require.Equal(t, "Waiting", entered.Member.UserProfile.DisplayName) + require.True(t, proto.Equal(userKeys.Proto(), entered.Member.PublicKey)) + require.True(t, entered.Member.EnteredAt.AsTime().Equal(resp.Lobby.EnteredAt.AsTime())) + require.Empty(t, e.lobbyUpdatesOnUserTopic(userID, chatID)) + e.requireNoLobbyUpdatesOnChatTopic(chatID) + + // A repeat is OK with the same entry, and announces nothing. + again := e.enterLobby(userKeys, chatID) + require.Equal(t, chatpb.EnterLobbyResponse_OK, again.Result) + require.True(t, again.Lobby.EnteredAt.AsTime().Equal(resp.Lobby.EnteredAt.AsTime())) + time.Sleep(100 * time.Millisecond) + require.Len(t, e.lobbyUpdatesOnUserTopic(e.userID, chatID), 1) + + // Withdrawing. + require.Equal(t, chatpb.LeaveLobbyResponse_OK, e.leaveLobby(userKeys, chatID).Result) + got = e.getChat(userKeys, chatID) + require.False(t, got.Metadata.InLobby) + updates = e.waitForLobbyUpdates(e.userID, chatID, 2) + require.Equal(t, userID.Value, updates[1].GetMemberLeft().GetUserId().GetValue()) + require.Equal(t, chatpb.LeaveLobbyResponse_OK, e.leaveLobby(userKeys, chatID).Result) + time.Sleep(100 * time.Millisecond) + require.Len(t, e.lobbyUpdatesOnUserTopic(e.userID, chatID), 2) + e.requireNoLobbyUpdatesOnChatTopic(chatID) + e.requireNoRosterUpdates(userID, chatID) +} + +// testServer_Lobby_Limits pins the server's caps: a full lobby is LOBBY_FULL, +// a user in too many lobbies TOO_MANY_LOBBIES, and neither refuses a user who +// is already waiting. +func testServer_Lobby_Limits(t *testing.T, s chat.Store) { + e := newServerEnvWithConfig(t, s, serverConfig{lobbyLimits: &chat.LobbyLimits{LobbySize: 1, LobbiesPerUser: 1}}) + _, aKeys := e.addBoundUser("A") + _, bKeys := e.addBoundUser("B") + first := e.putKeyedPrivateGroup("First") + second := e.putKeyedPrivateGroup("Second") + + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(aKeys, first).Result) + require.Equal(t, chatpb.EnterLobbyResponse_LOBBY_FULL, e.enterLobby(bKeys, first).Result) + require.Equal(t, chatpb.EnterLobbyResponse_TOO_MANY_LOBBIES, e.enterLobby(aKeys, second).Result) + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(aKeys, first).Result) + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(bKeys, second).Result) + + // Nothing was recorded for a refused entry. + got := e.getChat(bKeys, first) + require.False(t, got.Metadata.InLobby) + got = e.getChat(aKeys, second) + require.False(t, got.Metadata.InLobby) +} + +// testServer_Lobby_GetLobbyMembers pins the creator's read of the lobby: +// earliest entered first, paged under a chat-bound token, each member with +// their profile and public key. +func testServer_Lobby_GetLobbyMembers(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + chatID := e.putKeyedPrivateGroup("Sunday Hikers") + other := e.putKeyedPrivateGroup("Other") + + const n = 3 + userIDs := make([]*commonpb.UserId, n) + userKeys := make([]model.KeyPair, n) + for i := range n { + userIDs[i], userKeys[i] = e.addBoundUser(fmt.Sprintf("User %d", i)) + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(userKeys[i], chatID).Result) + time.Sleep(2 * time.Millisecond) + } + whole := e.waitForLobbyMembers(e.keys, chatID, n) + require.False(t, whole.HasMore) + require.Nil(t, whole.PagingToken) + for i, m := range whole.Members { + require.Equal(t, userIDs[i].Value, m.UserProfile.UserId.Value, i) + require.Equal(t, fmt.Sprintf("User %d", i), m.UserProfile.DisplayName, i) + require.True(t, proto.Equal(userKeys[i].Proto(), m.PublicKey), i) + require.NotNil(t, m.EnteredAt) + if i > 0 { + require.False(t, m.EnteredAt.AsTime().Before(whole.Members[i-1].EnteredAt.AsTime())) + } + } + + // Pages of two. + page, err := e.getLobbyMembers(e.keys, chatID, &commonpb.QueryOptions{PageSize: 2}) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_OK, page.Result) + require.Len(t, page.Members, 2) + require.True(t, page.HasMore) + require.NotNil(t, page.PagingToken) + require.Equal(t, userIDs[0].Value, page.Members[0].UserProfile.UserId.Value) + require.Equal(t, userIDs[1].Value, page.Members[1].UserProfile.UserId.Value) + rest, err := e.getLobbyMembers(e.keys, chatID, &commonpb.QueryOptions{PageSize: 2, PagingToken: page.PagingToken}) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_OK, rest.Result) + require.Len(t, rest.Members, 1) + require.False(t, rest.HasMore) + require.Nil(t, rest.PagingToken) + require.Equal(t, userIDs[2].Value, rest.Members[0].UserProfile.UserId.Value) + + // The token is the chat's: replayed into another lobby it is refused, as + // is a malformed one. + _, err = e.getLobbyMembers(e.keys, other, &commonpb.QueryOptions{PagingToken: page.PagingToken}) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = e.getLobbyMembers(e.keys, chatID, &commonpb.QueryOptions{PagingToken: &commonpb.PagingToken{Value: []byte{1, 2, 3}}}) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + + // The other lobby is empty, and a waiting user sees no lobby at all. + empty, err := e.getLobbyMembers(e.keys, other, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_OK, empty.Result) + require.Empty(t, empty.Members) + denied, err := e.getLobbyMembers(userKeys[0], chatID, nil) + require.NoError(t, err) + require.Equal(t, chatpb.GetLobbyMembersResponse_DENIED, denied.Result) +} + +// testServer_Lobby_Admit pins an admission: the waiting user becomes a +// member holding the envelope the creator wrapped, announced as a join to the +// members and to them, and as a MemberLeft to the creator; a repeat is a +// no-op, a user not waiting is NOT_IN_LOBBY, and a member who leaves may wait +// again. +func testServer_Lobby_Admit(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + e.profiles.displayNames[string(e.userID.Value)] = "Creator" + userID, userKeys := e.addBoundUser("Waiting") + chatID := e.putKeyedPrivateGroup("Sunday Hikers") + + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(userKeys, chatID).Result) + e.waitForLobbyUpdates(e.userID, chatID, 1) + + // Not waiting. + strangerID, _ := e.addBoundUser("Stranger") + require.Equal(t, chatpb.AdmitLobbyMemberResponse_NOT_IN_LOBBY, e.admitLobbyMember(e.keys, chatID, strangerID, keyEnvelopeProto(2)).Result) + + // Admitted. + wrapped := keyEnvelopeProto(2) + require.Equal(t, chatpb.AdmitLobbyMemberResponse_OK, e.admitLobbyMember(e.keys, chatID, userID, wrapped).Result) + isMember, err := s.IsMember(e.ctx, chatID, userID) + require.NoError(t, err) + require.True(t, isMember) + envelope := e.mustGetKeyEnvelope(userKeys, chatID) + require.Equal(t, chatpb.GetKeyEnvelopeResponse_OK, envelope.Result) + require.True(t, proto.Equal(wrapped, envelope.KeyEnvelope)) + require.Equal(t, e.userID.Value, envelope.WrappedBy.Value) + got := e.getChat(userKeys, chatID) + require.Equal(t, chatpb.GetChatResponse_OK, got.Result) + require.False(t, got.Metadata.InLobby) + require.Len(t, got.Metadata.Members, 1) + require.NotNil(t, got.Metadata.ViewerState) + require.NoError(t, protoutil.ProtoEqualError(&chatpb.RosterSummary{MemberCount: 2, Version: 1}, got.Metadata.RosterSummary)) + + // Announced as a join: the members' copy on the chat topic, the admitted + // user's with the metadata on their topic; and to the creator, the lobby + // shrank. + e.waitForRosterUpdates(userID, chatID, 1) + onChat, _ := e.rosterUpdatesOnChatTopic(chatID) + require.Len(t, onChat, 1) + require.Equal(t, userID.Value, onChat[0].GetMemberJoined().GetMember().GetUserId().GetValue()) + require.Nil(t, onChat[0].GetMemberJoined().GetMetadata()) + require.NoError(t, protoutil.ProtoEqualError(&chatpb.RosterSummary{MemberCount: 2, Version: 1}, onChat[0].RosterSummary)) + toUser := e.rosterUpdatesOnUserTopic(userID, chatID) + require.Len(t, toUser, 1) + joined := toUser[0].GetMemberJoined() + require.Equal(t, "Waiting", joined.Member.UserProfile.DisplayName) + require.Equal(t, uint64(1), joined.Member.Version) + require.NotNil(t, joined.Member.JoinedAt) + require.NotNil(t, joined.Metadata) + require.Equal(t, "Sunday Hikers", joined.Metadata.Title) + require.False(t, joined.Metadata.InLobby) + updates := e.waitForLobbyUpdates(e.userID, chatID, 2) + require.Equal(t, userID.Value, updates[1].GetMemberLeft().GetUserId().GetValue()) + e.requireNoLobbyUpdatesOnChatTopic(chatID) + + // A repeat is the no-op the proto promises, their envelope untouched. + require.Equal(t, chatpb.AdmitLobbyMemberResponse_OK, e.admitLobbyMember(e.keys, chatID, userID, keyEnvelopeProto(3)).Result) + envelope = e.mustGetKeyEnvelope(userKeys, chatID) + require.True(t, proto.Equal(wrapped, envelope.KeyEnvelope)) + time.Sleep(100 * time.Millisecond) + require.Len(t, e.lobbyUpdatesOnUserTopic(e.userID, chatID), 2) + onChat, _ = e.rosterUpdatesOnChatTopic(chatID) + require.Len(t, onChat, 1) + + // The lobby is empty for the creator, and the member replaces the envelope + // with their own as any member may. + empty, err := e.getLobbyMembers(e.keys, chatID, nil) + require.NoError(t, err) + require.Empty(t, empty.Members) + require.Equal(t, chatpb.SetKeyEnvelopeResponse_OK, e.mustSetKeyEnvelope(userKeys, chatID, keyEnvelopeProto(4)).Result) + + // Leaving discards the envelope, and the way back is the lobby. + left, err := e.leaveChat(userKeys, chatID) + require.NoError(t, err) + require.Equal(t, chatpb.LeaveChatResponse_OK, left.Result) + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(userKeys, chatID).Result) + require.Equal(t, chatpb.AdmitLobbyMemberResponse_OK, e.admitLobbyMember(e.keys, chatID, userID, keyEnvelopeProto(5)).Result) + envelope = e.mustGetKeyEnvelope(userKeys, chatID) + require.True(t, proto.Equal(keyEnvelopeProto(5), envelope.KeyEnvelope)) +} + +// testServer_Lobby_Deny pins a denial: the user leaves the lobby and is told +// nothing, the creator hears a MemberLeft, a repeat is a no-op, and the user +// may enter again. +func testServer_Lobby_Deny(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + userID, userKeys := e.addBoundUser("Waiting") + chatID := e.putKeyedPrivateGroup("Sunday Hikers") + + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(userKeys, chatID).Result) + e.waitForLobbyUpdates(e.userID, chatID, 1) + + require.Equal(t, chatpb.DenyLobbyMemberResponse_OK, e.denyLobbyMember(e.keys, chatID, userID).Result) + got := e.getChat(userKeys, chatID) + require.False(t, got.Metadata.InLobby) + isMember, err := s.IsMember(e.ctx, chatID, userID) + require.NoError(t, err) + require.False(t, isMember) + updates := e.waitForLobbyUpdates(e.userID, chatID, 2) + require.Equal(t, userID.Value, updates[1].GetMemberLeft().GetUserId().GetValue()) + require.Empty(t, e.lobbyUpdatesOnUserTopic(userID, chatID)) + e.requireNoRosterUpdates(userID, chatID) + + require.Equal(t, chatpb.DenyLobbyMemberResponse_OK, e.denyLobbyMember(e.keys, chatID, userID).Result) + time.Sleep(100 * time.Millisecond) + require.Len(t, e.lobbyUpdatesOnUserTopic(e.userID, chatID), 2) + + require.Equal(t, chatpb.EnterLobbyResponse_OK, e.enterLobby(userKeys, chatID).Result) + got = e.getChat(userKeys, chatID) + require.True(t, got.Metadata.InLobby) +} diff --git a/chat/tests/store.go b/chat/tests/store.go index 9f326c1..ce0abdc 100644 --- a/chat/tests/store.go +++ b/chat/tests/store.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rand" + "errors" "slices" "sync" "testing" @@ -88,6 +89,11 @@ func RunStoreTests(t *testing.T, s chat.Store, newStore func(excludedFromFeed [] testStore_KeyEnvelope_OwnWrapStands, testStore_KeyEnvelope_DiscardedOnLeave, testStore_KeyEnvelope_Concurrent, + testStore_Lobby_EnterAndLeave, + testStore_Lobby_Limits, + testStore_Lobby_Page, + testStore_Lobby_Admit, + testStore_Lobby_Concurrent, } { tf(t, s) teardown() @@ -2785,3 +2791,342 @@ func testStore_KeyEnvelope_Concurrent(t *testing.T, s chat.Store) { require.True(t, got.Equal(results[i])) } } + +// lobbyLimits are the caps the lobby tests run under unless one says +// otherwise: wide enough never to be hit. +var lobbyLimits = chat.LobbyLimits{LobbySize: 100, LobbiesPerUser: 100} + +// waitForLobbyPage reads chatID's lobby whole until it holds n entries: the +// page is read off an index that may trail the writes (see +// chat.Store.GetLobbyPage). +func waitForLobbyPage(t *testing.T, s chat.Store, chatID *commonpb.ChatId, n int) []chat.LobbyEntry { + t.Helper() + var entries []chat.LobbyEntry + require.Eventually(t, func() bool { + var err error + entries, err = s.GetLobbyPage(context.Background(), chatID, nil, 0) + require.NoError(t, err) + return len(entries) == n + }, 5*time.Second, 10*time.Millisecond) + return entries +} + +// testStore_Lobby_EnterAndLeave pins the lifecycle of one entry: recorded +// against the IDs alone, read back strongly consistent, a repeat the no-op +// that keeps the first entry's time, and a leave that is a no-op once gone. +func testStore_Lobby_EnterAndLeave(t *testing.T, s chat.Store) { + ctx := context.Background() + groupID := chat.MustGenerateGroupChatID() + user := model.MustGenerateUserID() + + entries, err := s.GetLobbyEntries(ctx, user, []*commonpb.ChatId{groupID}) + require.NoError(t, err) + require.Empty(t, entries) + changed, err := s.LeaveLobby(ctx, groupID, user) + require.NoError(t, err) + require.False(t, changed) + + before := time.Now() + entry, changed, err := s.EnterLobby(ctx, groupID, user, lobbyLimits) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, user.Value, entry.UserID.Value) + require.False(t, entry.EnteredAt.Before(before.Add(-time.Second))) + require.False(t, entry.EnteredAt.After(time.Now().Add(time.Second))) + + entries, err = s.GetLobbyEntries(ctx, user, []*commonpb.ChatId{groupID, chat.MustGenerateGroupChatID()}) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, user.Value, entries[string(groupID.Value)].UserID.Value) + require.True(t, entry.EnteredAt.Equal(entries[string(groupID.Value)].EnteredAt)) + + // A repeat is a no-op that returns the entry as it stands. + again, changed, err := s.EnterLobby(ctx, groupID, user, lobbyLimits) + require.NoError(t, err) + require.False(t, changed) + require.True(t, entry.EnteredAt.Equal(again.EnteredAt)) + + // An entry belongs to one user in one lobby. + other := model.MustGenerateUserID() + entries, err = s.GetLobbyEntries(ctx, other, []*commonpb.ChatId{groupID}) + require.NoError(t, err) + require.Empty(t, entries) + otherGroup := chat.MustGenerateGroupChatID() + _, changed, err = s.EnterLobby(ctx, otherGroup, user, lobbyLimits) + require.NoError(t, err) + require.True(t, changed) + entries, err = s.GetLobbyEntries(ctx, user, []*commonpb.ChatId{groupID, otherGroup}) + require.NoError(t, err) + require.Len(t, entries, 2) + + changed, err = s.LeaveLobby(ctx, groupID, user) + require.NoError(t, err) + require.True(t, changed) + entries, err = s.GetLobbyEntries(ctx, user, []*commonpb.ChatId{groupID, otherGroup}) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Contains(t, entries, string(otherGroup.Value)) + changed, err = s.LeaveLobby(ctx, groupID, user) + require.NoError(t, err) + require.False(t, changed) + + // Lobbies are a group's alone. + dm := generateDmChatID() + _, _, err = s.EnterLobby(ctx, dm, user, lobbyLimits) + require.Error(t, err) + _, err = s.LeaveLobby(ctx, dm, user) + require.Error(t, err) + _, err = s.GetLobbyPage(ctx, dm, nil, 0) + require.Error(t, err) +} + +// testStore_Lobby_Limits pins the caps: a lobby at LobbySize refuses the +// next user as ErrLobbyFull, a user in LobbiesPerUser lobbies is refused the +// next as ErrTooManyLobbies, the lobby's cap is judged first, a user already +// waiting is never refused, and a departure frees a place. +func testStore_Lobby_Limits(t *testing.T, s chat.Store) { + ctx := context.Background() + limits := chat.LobbyLimits{LobbySize: 2, LobbiesPerUser: 2} + groupA, groupB, groupC := chat.MustGenerateGroupChatID(), chat.MustGenerateGroupChatID(), chat.MustGenerateGroupChatID() + user1, user2, user3 := model.MustGenerateUserID(), model.MustGenerateUserID(), model.MustGenerateUserID() + + for _, u := range []*commonpb.UserId{user1, user2} { + _, changed, err := s.EnterLobby(ctx, groupA, u, limits) + require.NoError(t, err) + require.True(t, changed) + } + _, _, err := s.EnterLobby(ctx, groupA, user3, limits) + require.ErrorIs(t, err, chat.ErrLobbyFull) + entries, err := s.GetLobbyEntries(ctx, user3, []*commonpb.ChatId{groupA}) + require.NoError(t, err) + require.Empty(t, entries) + + _, changed, err := s.EnterLobby(ctx, groupB, user1, limits) + require.NoError(t, err) + require.True(t, changed) + _, _, err = s.EnterLobby(ctx, groupC, user1, limits) + require.ErrorIs(t, err, chat.ErrTooManyLobbies) + + // A user already waiting is the no-op whatever the counts say. + _, changed, err = s.EnterLobby(ctx, groupA, user1, limits) + require.NoError(t, err) + require.False(t, changed) + + // A user at their cap entering a full lobby hears of the lobby first. + _, _, err = s.EnterLobby(ctx, groupA, user1, chat.LobbyLimits{LobbySize: 1, LobbiesPerUser: 1}) + require.NoError(t, err) // still the no-op: already in it + _, _, err = s.EnterLobby(ctx, groupA, user3, chat.LobbyLimits{LobbySize: 1, LobbiesPerUser: 0}) + require.ErrorIs(t, err, chat.ErrLobbyFull) + + // A departure frees the place, and the user's own count. + changed, err = s.LeaveLobby(ctx, groupA, user2) + require.NoError(t, err) + require.True(t, changed) + _, changed, err = s.EnterLobby(ctx, groupA, user3, limits) + require.NoError(t, err) + require.True(t, changed) + changed, err = s.LeaveLobby(ctx, groupB, user1) + require.NoError(t, err) + require.True(t, changed) + _, changed, err = s.EnterLobby(ctx, groupC, user1, limits) + require.NoError(t, err) + require.True(t, changed) +} + +// testStore_Lobby_Page pins the lobby's order and its paging: earliest +// entered first, a page resuming strictly after a position, no limit reading +// the whole lobby, and an empty result for a lobby nobody is in. +func testStore_Lobby_Page(t *testing.T, s chat.Store) { + ctx := context.Background() + groupID := chat.MustGenerateGroupChatID() + + page, err := s.GetLobbyPage(ctx, groupID, nil, 0) + require.NoError(t, err) + require.Empty(t, page) + + const n = 5 + users := make([]*commonpb.UserId, n) + for i := range users { + users[i] = model.MustGenerateUserID() + _, changed, err := s.EnterLobby(ctx, groupID, users[i], lobbyLimits) + require.NoError(t, err) + require.True(t, changed) + // Entry times are the store's clock; a later entry must sort later. + time.Sleep(2 * time.Millisecond) + } + // A different lobby is not in the page. + _, _, err = s.EnterLobby(ctx, chat.MustGenerateGroupChatID(), model.MustGenerateUserID(), lobbyLimits) + require.NoError(t, err) + + whole := waitForLobbyPage(t, s, groupID, n) + for i, e := range whole { + require.Equal(t, users[i].Value, e.UserID.Value, i) + if i > 0 { + require.Negative(t, whole[i-1].Position().Compare(e.Position())) + } + } + + // Pages of two walk the same order and stop at the end. + var walked []chat.LobbyEntry + var after *chat.LobbyPosition + for { + page, err := s.GetLobbyPage(ctx, groupID, after, 2) + require.NoError(t, err) + require.LessOrEqual(t, len(page), 2) + walked = append(walked, page...) + if len(page) < 2 { + break + } + pos := page[len(page)-1].Position() + after = &pos + } + require.Len(t, walked, n) + for i, e := range walked { + require.Equal(t, users[i].Value, e.UserID.Value, i) + require.True(t, whole[i].EnteredAt.Equal(e.EnteredAt), i) + } + + // Resuming from the last position reads nothing more. + last := whole[n-1].Position() + page, err = s.GetLobbyPage(ctx, groupID, &last, 10) + require.NoError(t, err) + require.Empty(t, page) +} + +// testStore_Lobby_Admit pins the admission: in one write the user's entry +// leaves the lobby, their envelope lands (over any they held) and they join +// the roster at its next version; a member already is a no-op, a user not +// waiting ErrNotInLobby with nothing written, and a group that does not exist +// ErrChatNotFound. The lobby's count is freed by the admission. +func testStore_Lobby_Admit(t *testing.T, s chat.Store) { + ctx := context.Background() + creator := model.MustGenerateUserID() + user := model.MustGenerateUserID() + groupID := chat.MustGenerateGroupChatID() + require.NoError(t, s.PutChat(ctx, &chat.Chat{ + ID: groupID, + Type: chatpb.ChatType_GROUP, + Members: []*commonpb.UserId{creator}, + Title: "Private", + IsPrivate: true, + CreatorID: creator, + LastActivity: at(1), + })) + tight := chat.LobbyLimits{LobbySize: 1, LobbiesPerUser: 10} + + // A member has no place in the lobby: the creator is refused in the + // write, and nothing is counted for the refusal. + _, _, err := s.EnterLobby(ctx, groupID, creator, tight) + require.ErrorIs(t, err, chat.ErrAlreadyMember) + entries, err := s.GetLobbyEntries(ctx, creator, []*commonpb.ChatId{groupID}) + require.NoError(t, err) + require.Empty(t, entries) + + // Not waiting: nothing happens. + _, _, err = s.AdmitFromLobby(ctx, groupID, user, keyEnvelope(1, creator)) + require.ErrorIs(t, err, chat.ErrNotInLobby) + isMember, err := s.IsMember(ctx, groupID, user) + require.NoError(t, err) + require.False(t, isMember) + _, err = s.GetKeyEnvelope(ctx, groupID, user) + require.ErrorIs(t, err, chat.ErrKeyEnvelopeNotFound) + + // Waiting, holding a stale envelope of their own from an earlier stay. + _, changed, err := s.EnterLobby(ctx, groupID, user, tight) + require.NoError(t, err) + require.True(t, changed) + _, err = s.SetKeyEnvelope(ctx, groupID, user, keyEnvelope(9, user)) + require.NoError(t, err) + + _, _, err = s.AdmitFromLobby(ctx, groupID, user, chat.KeyEnvelope{Scheme: chatpb.KeyEnvelope_X25519_XCHACHA20POLY1305}) + require.Error(t, err) // no wrapper + _, _, err = s.AdmitFromLobby(ctx, chat.MustGenerateGroupChatID(), user, keyEnvelope(1, creator)) + require.ErrorIs(t, err, chat.ErrChatNotFound) + _, _, err = s.AdmitFromLobby(ctx, generateDmChatID(), user, keyEnvelope(1, creator)) + require.Error(t, err) + + admitted := keyEnvelope(1, creator) + changed, roster, err := s.AdmitFromLobby(ctx, groupID, user, admitted) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, chat.RosterSummary{MemberCount: 2, Version: 1}, roster) + isMember, err = s.IsMember(ctx, groupID, user) + require.NoError(t, err) + require.True(t, isMember) + got, err := s.GetKeyEnvelope(ctx, groupID, user) + require.NoError(t, err) + require.True(t, admitted.Equal(got)) + entries, err = s.GetLobbyEntries(ctx, user, []*commonpb.ChatId{groupID}) + require.NoError(t, err) + require.Empty(t, entries) + records, err := s.GetGroupMemberRecords(ctx, user, []*commonpb.ChatId{groupID}) + require.NoError(t, err) + require.Equal(t, uint64(1), records[string(groupID.Value)].Version) + + // The admitted member cannot re-enter the lobby, and the refusal left the + // lobby's one place free. + _, _, err = s.EnterLobby(ctx, groupID, user, tight) + require.ErrorIs(t, err, chat.ErrAlreadyMember) + + // A member already: nothing written, the summary as it stands. + changed, roster, err = s.AdmitFromLobby(ctx, groupID, user, keyEnvelope(2, creator)) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, chat.RosterSummary{MemberCount: 2, Version: 1}, roster) + got, err = s.GetKeyEnvelope(ctx, groupID, user) + require.NoError(t, err) + require.True(t, admitted.Equal(got)) + + // The admission freed the lobby's one place. + _, changed, err = s.EnterLobby(ctx, groupID, model.MustGenerateUserID(), tight) + require.NoError(t, err) + require.True(t, changed) + waitForLobbyPage(t, s, groupID, 1) +} + +// testStore_Lobby_Concurrent pins that the lobby's cap holds under +// concurrent entries: exactly LobbySize of many simultaneous entrants are +// recorded, every other is ErrLobbyFull, and the count agrees with the page. +func testStore_Lobby_Concurrent(t *testing.T, s chat.Store) { + ctx := context.Background() + groupID := chat.MustGenerateGroupChatID() + limits := chat.LobbyLimits{LobbySize: 5, LobbiesPerUser: 10} + + const entrants = 20 + results := make([]error, entrants) + var wg sync.WaitGroup + for i := range entrants { + wg.Add(1) + go func() { + defer wg.Done() + _, _, results[i] = s.EnterLobby(ctx, groupID, model.MustGenerateUserID(), limits) + }() + } + wg.Wait() + + var admitted, refused int + for _, err := range results { + switch { + case err == nil: + admitted++ + case errors.Is(err, chat.ErrLobbyFull): + refused++ + default: + require.NoError(t, err) + } + } + require.Equal(t, limits.LobbySize, admitted) + require.Equal(t, entrants-limits.LobbySize, refused) + waitForLobbyPage(t, s, groupID, limits.LobbySize) + + // One leaves, one more gets in. + page, err := s.GetLobbyPage(ctx, groupID, nil, 1) + require.NoError(t, err) + require.Len(t, page, 1) + changed, err := s.LeaveLobby(ctx, groupID, page[0].UserID) + require.NoError(t, err) + require.True(t, changed) + _, changed, err = s.EnterLobby(ctx, groupID, model.MustGenerateUserID(), limits) + require.NoError(t, err) + require.True(t, changed) +} diff --git a/go.mod b/go.mod index fb73f98..303b8f7 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/aws/smithy-go v1.27.7 github.com/buckket/go-blurhash v1.1.0 github.com/cespare/xxhash/v2 v2.3.0 - github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261002173547-202b27a3db0c + github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005144114-48983f7686d9 github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 github.com/code-payments/ocp-server v1.24.1-0.20260903184009-272d62754fdc github.com/devsisters/go-applereceipt v0.0.0-20240805020915-fa22a0160fc2 diff --git a/go.sum b/go.sum index cc76305..ba1e7f8 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB0 github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261002173547-202b27a3db0c h1:yT2mWbB5fcIS4WxFti1FZIQ4N33oTkM2xtw+lrpjPSA= -github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261002173547-202b27a3db0c/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005144114-48983f7686d9 h1:2KpK4ZxbZWbUifWGsl8lVjhmsYlOGnS/49Kif+exZv4= +github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005144114-48983f7686d9/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 h1:/cE319d70UiXoor2x9b2faiFTUFlzdgs10D4Fvq7dN4= github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/code-payments/ocp-server v1.24.1-0.20260903184009-272d62754fdc h1:PV15RiggjYCPhSwDlVmNvlYVQ8VZ0jqp+3YSuz5BN/E= diff --git a/messaging/dynamodb/server_test.go b/messaging/dynamodb/server_test.go index 2a4fca2..e33511a 100644 --- a/messaging/dynamodb/server_test.go +++ b/messaging/dynamodb/server_test.go @@ -22,19 +22,20 @@ const ( userStateTable = "chat_user_state_test" activityTable = "chat_activity_test" keyEnvelopesTable = "chat_key_envelopes_test" + lobbiesTable = "chat_lobbies_test" badgesTable = "badges_test" ) func TestMessaging_DynamoDBServer(t *testing.T) { ctx := context.Background() - require.NoError(t, chat_dynamodb.CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable)) + require.NoError(t, chat_dynamodb.CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable)) require.NoError(t, CreateTables(ctx, testEnv.Client, messagesTable, pointersTable, reactionsTable, reactorsTable, selfReactionsTable)) require.NoError(t, badge_dynamodb.CreateTables(ctx, testEnv.Client, badgesTable)) badges := badge_dynamodb.NewInDynamoDB(testEnv.Client, badgesTable) blocklists := blocklist_memory.NewInMemory() - chats := chat_dynamodb.NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, nil) + chats := chat_dynamodb.NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, nil) profiles := profile_memory.NewInMemory() messages := NewInDynamoDB(testEnv.Client, messagesTable, pointersTable, reactionsTable, reactorsTable, selfReactionsTable) teardown := func() { From 9baf88efcca04383a2bd98821eb5fb6ba2c5cc7a Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Mon, 5 Oct 2026 13:11:35 -0400 Subject: [PATCH 6/6] Update protos --- go.mod | 6 +++--- go.sum | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 303b8f7..36b4ef9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/code-payments/flipcash2-server go 1.27.0 require ( + filippo.io/edwards25519 v1.1.0 firebase.google.com/go/v4 v4.20.0 github.com/ReneKroon/ttlcache v1.7.0 github.com/aws/aws-sdk-go-v2 v1.43.5 @@ -14,7 +15,7 @@ require ( github.com/aws/smithy-go v1.27.7 github.com/buckket/go-blurhash v1.1.0 github.com/cespare/xxhash/v2 v2.3.0 - github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005144114-48983f7686d9 + github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005171051-3eb894dec811 github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 github.com/code-payments/ocp-server v1.24.1-0.20260903184009-272d62754fdc github.com/devsisters/go-applereceipt v0.0.0-20240805020915-fa22a0160fc2 @@ -32,6 +33,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/twilio/twilio-go v1.27.0 go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.55.0 golang.org/x/image v0.43.0 golang.org/x/sync v0.22.0 golang.org/x/text v0.41.0 @@ -45,7 +47,6 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.0 // indirect - filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect @@ -117,7 +118,6 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index ba1e7f8..459b4ac 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB0 github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005144114-48983f7686d9 h1:2KpK4ZxbZWbUifWGsl8lVjhmsYlOGnS/49Kif+exZv4= -github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005144114-48983f7686d9/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005171051-3eb894dec811 h1:2yQbcrFREDee05pFzY7VmqJSzDEevSD8WEaCVa5z6g4= +github.com/code-payments/flipcash2-protobuf-api v1.27.1-0.20261005171051-3eb894dec811/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1 h1:/cE319d70UiXoor2x9b2faiFTUFlzdgs10D4Fvq7dN4= github.com/code-payments/ocp-protobuf-api v1.16.1-0.20260918154336-e3d25a85b1e1/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/code-payments/ocp-server v1.24.1-0.20260903184009-272d62754fdc h1:PV15RiggjYCPhSwDlVmNvlYVQ8VZ0jqp+3YSuz5BN/E=