github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/bitwarden/hub.go (about)

     1  package bitwarden
     2  
     3  import (
     4  	"encoding/base64"
     5  	"net/http"
     6  	"time"
     7  
     8  	"github.com/cozy/cozy-stack/model/bitwarden"
     9  	"github.com/cozy/cozy-stack/model/bitwarden/settings"
    10  	"github.com/cozy/cozy-stack/model/instance"
    11  	"github.com/cozy/cozy-stack/model/permission"
    12  	"github.com/cozy/cozy-stack/pkg/consts"
    13  	"github.com/cozy/cozy-stack/pkg/couchdb"
    14  	"github.com/cozy/cozy-stack/pkg/crypto"
    15  	"github.com/cozy/cozy-stack/pkg/logger"
    16  	"github.com/cozy/cozy-stack/pkg/realtime"
    17  	"github.com/cozy/cozy-stack/web/middlewares"
    18  	"github.com/gorilla/websocket"
    19  	"github.com/labstack/echo/v4"
    20  	"github.com/ugorji/go/codec"
    21  )
    22  
    23  type transport struct {
    24  	Transport string   `json:"transport"`
    25  	Formats   []string `json:"transferFormats"`
    26  }
    27  
    28  // NegotiateHub is the handler for negotiating between the server and the
    29  // client which transport to use for bitwarden notifications. Currently,
    30  // only websocket is supported.
    31  func NegotiateHub(c echo.Context) error {
    32  	if err := middlewares.AllowWholeType(c, permission.GET, consts.BitwardenCiphers); err != nil {
    33  		return c.JSON(http.StatusUnauthorized, echo.Map{
    34  			"error": "invalid token",
    35  		})
    36  	}
    37  
    38  	transports := []transport{
    39  		// Bitwarden jslib supports only msgpack (Binary), not JSON (Text)
    40  		{Transport: "WebSockets", Formats: []string{"Binary"}},
    41  	}
    42  
    43  	connID := crypto.GenerateRandomBytes(16)
    44  	return c.JSON(http.StatusOK, echo.Map{
    45  		"connectionId":        base64.URLEncoding.EncodeToString(connID),
    46  		"availableTransports": transports,
    47  	})
    48  }
    49  
    50  // WebsocketHub is the websocket handler for the hub to send notifications in
    51  // real-time for bitwarden stuff.
    52  func WebsocketHub(c echo.Context) error {
    53  	inst := middlewares.GetInstance(c)
    54  	token := c.QueryParam("access_token")
    55  	pdoc, err := middlewares.ParseJWT(c, inst, token)
    56  	if err != nil || !pdoc.Permissions.AllowWholeType(permission.GET, consts.BitwardenCiphers) {
    57  		return c.JSON(http.StatusUnauthorized, echo.Map{
    58  			"error": "invalid token",
    59  		})
    60  	}
    61  
    62  	notifier, err := upgradeWebsocket(c, inst)
    63  	if err != nil {
    64  		return c.JSON(http.StatusInternalServerError, echo.Map{
    65  			"error": err.Error(),
    66  		})
    67  	}
    68  	go readPump(notifier)
    69  	return writePump(notifier)
    70  }
    71  
    72  type wsNotifier struct {
    73  	UserID    string
    74  	Settings  *settings.Settings
    75  	WS        *websocket.Conn
    76  	DS        *realtime.Subscriber
    77  	Responses chan []byte
    78  }
    79  
    80  const (
    81  	// Time allowed to write a message to the peer
    82  	writeWait = 10 * time.Second
    83  	// Time allowed to read the next pong message from the peer
    84  	pongWait = 20 * time.Second
    85  	// Send pings to peer with this period (must be less than pongWait)
    86  	pingPeriod = 15 * time.Second
    87  	// Maximum message size allowed from peer (in bytes)
    88  	maxMessageSize = 1024
    89  )
    90  
    91  var upgrader = websocket.Upgrader{
    92  	// Don't check the origin of the connexion
    93  	CheckOrigin:     func(r *http.Request) bool { return true },
    94  	ReadBufferSize:  1024,
    95  	WriteBufferSize: 1024,
    96  }
    97  
    98  func upgradeWebsocket(c echo.Context, inst *instance.Instance) (*wsNotifier, error) {
    99  	setting, err := settings.Get(inst)
   100  	if err != nil {
   101  		return nil, err
   102  	}
   103  
   104  	ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
   105  	if err != nil {
   106  		return nil, err
   107  	}
   108  
   109  	ws.SetReadLimit(maxMessageSize)
   110  	if err = ws.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
   111  		ws.Close()
   112  		return nil, err
   113  	}
   114  	ws.SetPongHandler(func(string) error {
   115  		return ws.SetReadDeadline(time.Now().Add(pongWait))
   116  	})
   117  
   118  	responses := make(chan []byte)
   119  	ds := realtime.GetHub().Subscriber(inst)
   120  	notifier := wsNotifier{
   121  		UserID:    inst.ID(),
   122  		Settings:  setting,
   123  		WS:        ws,
   124  		DS:        ds,
   125  		Responses: responses,
   126  	}
   127  	return &notifier, nil
   128  }
   129  
   130  var initialResponse = []byte{0x7b, 0x7d, 0x1e} // {}<RS>
   131  
   132  func readPump(notifier *wsNotifier) {
   133  	ws := notifier.WS
   134  	ds := notifier.DS
   135  	var msg struct {
   136  		Protocol string `json:"protocol"`
   137  		Version  int    `json:"version"`
   138  	}
   139  	if err := ws.ReadJSON(&msg); err != nil {
   140  		if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
   141  			logger.WithDomain(ds.DomainName()).WithNamespace("bitwarden").
   142  				Infof("Read error: %s", err)
   143  		}
   144  		return
   145  	}
   146  	if msg.Protocol != "messagepack" || msg.Version != 1 {
   147  		logger.WithDomain(ds.DomainName()).WithNamespace("bitwarden").
   148  			Infof("Unexpected message: %v", msg)
   149  		return
   150  	}
   151  	ds.Watch(consts.Settings, consts.BitwardenSettingsID)
   152  	ds.Subscribe(consts.BitwardenFolders)
   153  	ds.Subscribe(consts.BitwardenCiphers)
   154  	notifier.Responses <- initialResponse
   155  
   156  	// Just send back the pings from the client
   157  	for {
   158  		_, msg, err := ws.ReadMessage()
   159  		if err != nil {
   160  			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
   161  				logger.WithDomain(ds.DomainName()).WithNamespace("bitwarden").
   162  					Infof("Read error: %s", err)
   163  			}
   164  			close(notifier.Responses)
   165  			return
   166  		}
   167  		notifier.Responses <- msg
   168  	}
   169  }
   170  
   171  func writePump(notifier *wsNotifier) error {
   172  	ws := notifier.WS
   173  	defer ws.Close()
   174  	ds := notifier.DS
   175  	defer ds.Close()
   176  
   177  	handle := new(codec.MsgpackHandle)
   178  	handle.WriteExt = true
   179  	ticker := time.NewTicker(pingPeriod)
   180  	defer ticker.Stop()
   181  
   182  	for {
   183  		select {
   184  		case r, ok := <-notifier.Responses:
   185  			if !ok {
   186  				return nil // Client has closed the websocket
   187  			}
   188  			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
   189  				return err
   190  			}
   191  			if err := ws.WriteMessage(websocket.BinaryMessage, r); err != nil {
   192  				logger.WithDomain(ds.DomainName()).WithNamespace("bitwarden").
   193  					Infof("Write error: %s", err)
   194  				return nil
   195  			}
   196  		case e := <-ds.Channel:
   197  			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
   198  				return err
   199  			}
   200  			notif := buildNotification(e, notifier.UserID, notifier.Settings)
   201  			if notif == nil {
   202  				continue
   203  			}
   204  			serialized, err := serializeNotification(handle, *notif)
   205  			if err != nil {
   206  				logger.WithDomain(ds.DomainName()).WithNamespace("bitwarden").
   207  					Infof("Serialize error: %s", err)
   208  				continue
   209  			}
   210  			if err := ws.WriteMessage(websocket.BinaryMessage, serialized); err != nil {
   211  				return nil
   212  			}
   213  		case <-ticker.C:
   214  			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
   215  				return err
   216  			}
   217  			if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
   218  				return nil
   219  			}
   220  		}
   221  	}
   222  }
   223  
   224  type notificationResponse struct {
   225  	ContextID string `codec:"ContextId"`
   226  	Type      int
   227  	Payload   map[string]interface{}
   228  }
   229  
   230  type notification []interface{}
   231  
   232  // https://github.com/bitwarden/jslib/blob/master/common/src/enums/notificationType.ts
   233  const (
   234  	hubCipherUpdate = 0
   235  	hubCipherCreate = 1
   236  	// hubLoginDelete  = 2
   237  	hubFolderDelete = 3
   238  	// hubCiphers      = 4
   239  	hubVault = 5
   240  	// hubOrgKeys      = 6
   241  	hubFolderCreate = 7
   242  	hubFolderUpdate = 8
   243  	hubCipherDelete = 9
   244  	// hubSettings     = 10
   245  	hubLogOut = 11
   246  )
   247  
   248  func buildNotification(e *realtime.Event, userID string, setting *settings.Settings) *notification {
   249  	if e == nil || e.Doc == nil {
   250  		return nil
   251  	}
   252  
   253  	doctype := e.Doc.DocType()
   254  	t := -1
   255  	var payload map[string]interface{}
   256  	switch doctype {
   257  	case consts.BitwardenFolders:
   258  		payload = buildFolderPayload(e, userID)
   259  		switch e.Verb {
   260  		case realtime.EventCreate:
   261  			t = hubFolderCreate
   262  		case realtime.EventUpdate:
   263  			t = hubFolderUpdate
   264  		case realtime.EventDelete:
   265  			t = hubFolderDelete
   266  		}
   267  	case consts.BitwardenCiphers:
   268  		payload = buildCipherPayload(e, userID, setting)
   269  		switch e.Verb {
   270  		case realtime.EventCreate:
   271  			t = hubCipherCreate
   272  		case realtime.EventUpdate:
   273  			t = hubCipherUpdate
   274  		case realtime.EventDelete:
   275  			t = hubCipherDelete
   276  		case realtime.EventNotify:
   277  			t = hubVault
   278  		}
   279  	case consts.Settings:
   280  		payload = buildLogoutPayload(e, userID)
   281  		if len(payload) > 0 {
   282  			t = hubLogOut
   283  		}
   284  	}
   285  	if t < 0 {
   286  		return nil
   287  	}
   288  
   289  	arg := notificationResponse{
   290  		ContextID: "app_id",
   291  		Type:      t,
   292  		Payload:   payload,
   293  	}
   294  	msg := notification{
   295  		1,                           // MessageType.Invocation
   296  		[]interface{}{},             // Headers
   297  		nil,                         // InvocationId
   298  		"ReceiveMessage",            // Target
   299  		[]notificationResponse{arg}, // Arguments
   300  	}
   301  	return &msg
   302  }
   303  
   304  func buildFolderPayload(e *realtime.Event, userID string) map[string]interface{} {
   305  	var updatedAt interface{}
   306  	var date string
   307  	if doc, ok := e.Doc.(*couchdb.JSONDoc); ok {
   308  		meta, _ := doc.M["cozyMetadata"].(map[string]interface{})
   309  		date, _ = meta["updatedAt"].(string)
   310  	} else if doc, ok := e.Doc.(*realtime.JSONDoc); ok {
   311  		meta, _ := doc.M["cozyMetadata"].(map[string]interface{})
   312  		date, _ = meta["updatedAt"].(string)
   313  	} else if doc, ok := e.Doc.(*settings.Settings); ok {
   314  		if doc.Metadata != nil {
   315  			updatedAt = doc.Metadata.UpdatedAt
   316  		}
   317  	}
   318  	if date != "" {
   319  		if t, err := time.Parse(time.RFC3339, date); err == nil {
   320  			updatedAt = t
   321  		}
   322  	}
   323  	if updatedAt == nil {
   324  		updatedAt = time.Now()
   325  	}
   326  	return map[string]interface{}{
   327  		"Id":           e.Doc.ID(),
   328  		"UserId":       userID,
   329  		"RevisionDate": updatedAt,
   330  	}
   331  }
   332  
   333  func buildCipherPayload(e *realtime.Event, userID string, setting *settings.Settings) map[string]interface{} {
   334  	if e.Verb == realtime.EventNotify {
   335  		return map[string]interface{}{
   336  			"UserId":       userID,
   337  			"RevisionDate": time.Now(),
   338  		}
   339  	}
   340  
   341  	var sharedWithCozy bool
   342  	var updatedAt interface{}
   343  	var date string
   344  	var orgID, collIDs interface{}
   345  	if doc, ok := e.Doc.(*couchdb.JSONDoc); ok {
   346  		sharedWithCozy, _ = doc.M["shared_with_cozy"].(bool)
   347  		orgID, _ = doc.M["organization_id"].(string)
   348  		if collID, _ := doc.M["collection_id"].(string); collID != "" {
   349  			collIDs = []string{collID}
   350  		}
   351  		meta, _ := doc.M["cozyMetadata"].(map[string]interface{})
   352  		date, _ = meta["updatedAt"].(string)
   353  	} else if doc, ok := e.Doc.(*realtime.JSONDoc); ok {
   354  		sharedWithCozy, _ = doc.M["shared_with_cozy"].(bool)
   355  		orgID, _ = doc.M["organization_id"].(string)
   356  		if collID, _ := doc.M["collection_id"].(string); collID != "" {
   357  			collIDs = []string{collID}
   358  		}
   359  		meta, _ := doc.M["cozyMetadata"].(map[string]interface{})
   360  		date, _ = meta["updatedAt"].(string)
   361  	} else if doc, ok := e.Doc.(*bitwarden.Cipher); ok {
   362  		sharedWithCozy = doc.SharedWithCozy
   363  		orgID = doc.OrganizationID
   364  		if doc.CollectionID != "" {
   365  			collIDs = []string{doc.CollectionID}
   366  		}
   367  		if doc.Metadata != nil {
   368  			updatedAt = doc.Metadata.UpdatedAt
   369  		}
   370  	}
   371  	if date != "" {
   372  		if t, err := time.Parse(time.RFC3339, date); err == nil {
   373  			updatedAt = t
   374  		}
   375  	}
   376  	if updatedAt == nil {
   377  		updatedAt = time.Now()
   378  	}
   379  	if sharedWithCozy {
   380  		orgID = setting.OrganizationID
   381  		collIDs = []string{setting.CollectionID}
   382  	}
   383  	return map[string]interface{}{
   384  		"Id":             e.Doc.ID(),
   385  		"UserId":         userID,
   386  		"OrganizationId": orgID,
   387  		"CollectionIds":  collIDs,
   388  		"RevisionDate":   updatedAt,
   389  	}
   390  }
   391  
   392  func buildLogoutPayload(e *realtime.Event, userID string) map[string]interface{} {
   393  	if e.OldDoc == nil {
   394  		return nil
   395  	}
   396  
   397  	var updatedAt interface{}
   398  	var date string
   399  	if doc, ok := e.Doc.(*couchdb.JSONDoc); ok {
   400  		oldDoc, _ := e.OldDoc.(*couchdb.JSONDoc)
   401  		if oldDoc == nil || doc.M["security_stamp"] == oldDoc.M["security_stamp"] {
   402  			return nil
   403  		}
   404  		meta, _ := doc.M["cozyMetadata"].(map[string]interface{})
   405  		date, _ = meta["updatedAt"].(string)
   406  	} else if doc, ok := e.Doc.(*realtime.JSONDoc); ok {
   407  		oldDoc, _ := e.OldDoc.(*realtime.JSONDoc)
   408  		if oldDoc == nil || doc.M["security_stamp"] == oldDoc.M["security_stamp"] {
   409  			return nil
   410  		}
   411  		meta, _ := doc.M["cozyMetadata"].(map[string]interface{})
   412  		date, _ = meta["updatedAt"].(string)
   413  	} else if doc, ok := e.Doc.(*settings.Settings); ok {
   414  		oldDoc, _ := e.OldDoc.(*settings.Settings)
   415  		if oldDoc == nil || doc.SecurityStamp == oldDoc.SecurityStamp {
   416  			return nil
   417  		}
   418  		if doc.Metadata != nil {
   419  			updatedAt = doc.Metadata.UpdatedAt
   420  		}
   421  	}
   422  	if date != "" {
   423  		if t, err := time.Parse(time.RFC3339, date); err == nil {
   424  			updatedAt = t
   425  		}
   426  	}
   427  	if updatedAt == nil {
   428  		updatedAt = time.Now()
   429  	}
   430  	return map[string]interface{}{
   431  		"UserId": userID,
   432  		"Date":   updatedAt,
   433  	}
   434  }
   435  
   436  func serializeNotification(handle *codec.MsgpackHandle, notif notification) ([]byte, error) {
   437  	// First serialize the notification to msgpack
   438  	packed := make([]byte, 0, 256)
   439  	encoder := codec.NewEncoderBytes(&packed, handle)
   440  	if err := encoder.Encode(notif); err != nil {
   441  		return nil, err
   442  	}
   443  
   444  	// Then, put it in a BinaryMessageFormat
   445  	// https://github.com/aspnet/AspNetCore/blob/master/src/SignalR/clients/ts/signalr-protocol-msgpack/src/BinaryMessageFormat.ts
   446  	size := uint(len(packed))
   447  	lenBuf := make([]byte, 0, 8)
   448  	for size > 0 {
   449  		sizePart := size & 0x7f
   450  		size >>= 7
   451  		if size > 0 {
   452  			sizePart |= 0x80
   453  		}
   454  		lenBuf = append(lenBuf, byte(sizePart))
   455  	}
   456  	buf := make([]byte, len(lenBuf)+len(packed))
   457  	copy(buf[:len(lenBuf)], lenBuf)
   458  	copy(buf[len(lenBuf):], packed)
   459  	return buf, nil
   460  }