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 ¬ifier, 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 }