package syncapi import ( "context" "encoding/json" "github.com/dariuszjaniak/warp/server/internal/storage" ) // To-device messaging: a message from one device to another, which the // server stores and forwards without reading. // // It exists for end-to-end encryption — a device sending another one a // Megolm session key is a to-device message — and the server deliberately // cannot tell what is in one. Phase 7 builds the keyserver on top of this; // the transport itself belongs here, because the only way a device ever // receives one is in a /sync response. // wildcardDevice is the device ID that means "every device of this user". const wildcardDevice = "*" // SendToDeviceRequest is PUT /sendToDevice/{eventType}/{txnID}. type SendToDeviceRequest struct { // Sender is the full user ID sending, which is the caller. Sender string EventType string // Messages is user ID -> device ID -> content, as the request body // nests it. A device ID of "*" addresses every device of that user. Messages map[string]map[string]json.RawMessage } // SendToDevice queues messages for delivery. // // A message to a user of another server goes to the federation queue. With // federation off it is dropped rather than refused. That is deliberate: a // client encrypting for a room with remote members sends one message per // device in one request, and failing the request because federation is // off would stop it from delivering to the local devices as well. The // dropped recipients simply never see the key and their client reports an // undecryptable message, which is what they would see anyway. func (s *API) SendToDevice(ctx context.Context, req SendToDeviceRequest) error { if req.EventType == "" { return errInvalidParamf("an event type is required") } var ( queued []storage.NewToDeviceMessage woken []string remote = map[string]map[string]json.RawMessage{} sentAt = s.nowMS() ) for userID, byDevice := range req.Messages { localpart, ok := s.localpartOf(userID) if !ok { // A remote recipient goes to the federation queue when there // is one and the sender is ours; a remote sender's message to // a remote user is not this server's to forward. if s.outbound != nil && s.isLocal(req.Sender) { remote[userID] = byDevice continue } s.log.Debug("dropping a to-device message for a remote user", "sender", req.Sender, "recipient", userID) continue } for deviceID, content := range byDevice { if deviceID != wildcardDevice { queued = append(queued, storage.NewToDeviceMessage{ Localpart: localpart, DeviceID: deviceID, Sender: req.Sender, Type: req.EventType, Content: contentOrEmpty(content), SentAtMS: sentAt, }) continue } // A wildcard is expanded here rather than stored as one: the // set of a user's devices changes, and a message addressed to // "every device" means the ones that existed when it was sent. // Storing the wildcard would deliver it to a device that was // created afterwards, which for a key share is both useless // and a leak. devices, err := s.accounts.Devices(ctx, localpart) if err != nil { return err } for _, device := range devices { queued = append(queued, storage.NewToDeviceMessage{ Localpart: localpart, DeviceID: device.ID, Sender: req.Sender, Type: req.EventType, Content: contentOrEmpty(content), SentAtMS: sentAt, }) } } woken = append(woken, userID) } if len(remote) > 0 { s.outbound.ToDevice(ctx, req.Sender, req.EventType, remote) } if len(queued) == 0 { return nil } if _, err := s.store.SendToDevice(ctx, queued); err != nil { return err } s.notifier.NotifyUsers(woken) return nil } // contentOrEmpty keeps a null or absent content out of the store: the // to-device content is passed through untouched, and "untouched" has to // still be valid JSON on the way out. func contentOrEmpty(content json.RawMessage) []byte { if len(content) == 0 { return []byte("{}") } return content } // toDeviceEvent is one message in the shape a sync reports it. type toDeviceEvent struct { Sender string `json:"sender"` Type string `json:"type"` Content json.RawMessage `json:"content"` } // toDeviceMessages reads a device's queue between two positions and returns // the events plus the position of the last one. // // The returned position is what the next token carries, and it is the // position of the last message *delivered* rather than the top of the // stream: a queue longer than one batch is drained over several syncs, and // a token naming the top would skip the rest. func (s *API) toDeviceMessages(ctx context.Context, localpart, deviceID string, from, to int64, limit int) ([]toDeviceEvent, int64, error) { if deviceID == "" { // An access token always names a device, so this cannot happen // through the API; a guest session later might. return nil, from, nil } msgs, err := s.store.ToDeviceMessages(ctx, localpart, deviceID, from, to, limit) if err != nil { return nil, 0, err } if len(msgs) == 0 { return nil, from, nil } out := make([]toDeviceEvent, 0, len(msgs)) for _, m := range msgs { out = append(out, toDeviceEvent{ Sender: m.Sender, Type: m.Type, Content: m.Content, }) } return out, msgs[len(msgs)-1].StreamPos, nil } // ackToDevice deletes the messages a device has acknowledged by syncing // past them. // // A device acknowledges a batch by sending back the token that batch came // with, so the deletion happens on the *next* sync rather than when the // batch is sent. A client that crashes between the two gets the batch // again, which is the right way round: a lost key share leaves a message // permanently undecryptable, and a duplicate one is ignored. func (s *API) ackToDevice(ctx context.Context, localpart, deviceID string, through int64) { if deviceID == "" || through <= 0 { return } if err := s.store.DeleteToDeviceMessages(ctx, localpart, deviceID, through); err != nil { // Failing the sync over this would be worse than leaving the rows: // the client has the messages, and the next sync will try again. s.log.Warn("could not drop acknowledged to-device messages", "localpart", localpart, "device_id", deviceID, "through", through, "error", err) } }