code.vegaprotocol.io/vega@v0.79.0/wallet/service/v2/connections/manager.go (about) 1 // Copyright (C) 2023 Gobalsky Labs Limited 2 // 3 // This program is free software: you can redistribute it and/or modify 4 // it under the terms of the GNU Affero General Public License as 5 // published by the Free Software Foundation, either version 3 of the 6 // License, or (at your option) any later version. 7 // 8 // This program is distributed in the hope that it will be useful, 9 // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 // GNU Affero General Public License for more details. 12 // 13 // You should have received a copy of the GNU Affero General Public License 14 // along with this program. If not, see <http://www.gnu.org/licenses/>. 15 16 package connections 17 18 import ( 19 "context" 20 "errors" 21 "fmt" 22 "sort" 23 "sync" 24 "time" 25 26 vgcrypto "code.vegaprotocol.io/vega/libs/crypto" 27 "code.vegaprotocol.io/vega/libs/jsonrpc" 28 "code.vegaprotocol.io/vega/wallet/api" 29 "code.vegaprotocol.io/vega/wallet/wallet" 30 ) 31 32 // Manager holds the opened connections between the third-party 33 // applications and the wallets. 34 type Manager struct { 35 // tokenToConnection maps the token to the connection. It is the base 36 // registry for all opened connections. 37 tokenToConnection map[Token]*walletConnection 38 39 // sessionFingerprintToToken maps the session fingerprint (wallet + hostname 40 // on a session connection). This is used to determine whether a session 41 // connection already exists for a given wallet and hostname. 42 // It only holds sessions fingerprints. 43 // Long-living connections are not tracked. 44 sessionFingerprintToToken map[string]Token 45 46 // timeService is used to resolve the current time to update the last activity 47 // time on the token, and figure out their expiration. 48 timeService TimeService 49 50 walletStore WalletStore 51 sessionStore SessionStore 52 tokenStore TokenStore 53 54 interactor api.Interactor 55 56 mu sync.Mutex 57 } 58 59 type walletConnection struct { 60 // connectedWallet is the projection of the wallet through the permissions 61 // and authentication system. On a regular wallet, there is no restriction 62 // on what we can call, which doesn't fit the model of having allowed 63 // access, so we wrap the "regular wallet" behind the "connected wallet". 64 connectedWallet api.ConnectedWallet 65 66 policy connectionPolicy 67 } 68 69 // StartSession initializes a connection between a wallet and a third-party 70 // application. 71 // If a connection already exists, it's disconnected and a new token is 72 // generated. 73 func (m *Manager) StartSession(hostname string, w wallet.Wallet) (Token, error) { 74 m.mu.Lock() 75 defer m.mu.Unlock() 76 77 cw, err := api.NewConnectedWallet(hostname, w) 78 if err != nil { 79 return "", fmt.Errorf("could not instantiate the connected wallet for a session connection: %w", err) 80 } 81 82 newToken := m.generateToken() 83 84 sessionFingerprint := asSessionFingerprint(hostname, w.Name()) 85 if previousToken, sessionAlreadyExists := m.sessionFingerprintToToken[sessionFingerprint]; sessionAlreadyExists { 86 m.destroySessionToken(previousToken) 87 } 88 m.sessionFingerprintToToken[sessionFingerprint] = newToken 89 90 now := m.timeService.Now() 91 m.tokenToConnection[newToken] = &walletConnection{ 92 connectedWallet: cw, 93 policy: &sessionPolicy{ 94 expiryDate: now.Add(1 * time.Hour), 95 }, 96 } 97 98 // We ignore this error as tracking the session a nice-to-have feature to 99 // ease reconnection after a software reboot. We don't want to prevent the 100 // connection because an error on that layer. 101 _ = m.sessionStore.TrackSession(Session{ 102 Token: newToken, 103 Hostname: hostname, 104 Wallet: w.Name(), 105 }) 106 107 return newToken, nil 108 } 109 110 func (m *Manager) EndSessionConnectionWithToken(token Token) { 111 m.mu.Lock() 112 defer m.mu.Unlock() 113 114 m.destroySessionToken(token) 115 } 116 117 func (m *Manager) EndSessionConnection(hostname, walletName string) { 118 m.mu.Lock() 119 defer m.mu.Unlock() 120 121 fingerprint := asSessionFingerprint(hostname, walletName) 122 123 token, exists := m.sessionFingerprintToToken[fingerprint] 124 if !exists { 125 return 126 } 127 128 m.destroySessionToken(token) 129 } 130 131 func (m *Manager) EndAllSessionConnections() { 132 m.mu.Lock() 133 defer m.mu.Unlock() 134 135 for token := range m.tokenToConnection { 136 m.destroySessionToken(token) 137 } 138 } 139 140 // ConnectedWallet retrieves the wallet associated to the specified token. 141 func (m *Manager) ConnectedWallet(ctx context.Context, hostname string, token Token) (api.ConnectedWallet, *jsonrpc.ErrorDetails) { 142 m.mu.Lock() 143 defer m.mu.Unlock() 144 145 connection, exists := m.tokenToConnection[token] 146 if !exists { 147 return api.ConnectedWallet{}, serverErrorAuthenticationFailure(ErrNoConnectionAssociatedThisAuthenticationToken) 148 } 149 150 now := m.timeService.Now() 151 152 hasExpired := connection.policy.HasConnectionExpired(now) 153 154 if connection.policy.IsLongLivingConnection() { 155 if hasExpired { 156 return api.ConnectedWallet{}, serverErrorAuthenticationFailure(ErrTokenHasExpired) 157 } 158 } else { 159 traceID := jsonrpc.TraceIDFromContext(ctx) 160 161 if connection.connectedWallet.Hostname() != hostname { 162 return api.ConnectedWallet{}, serverErrorAuthenticationFailure(ErrHostnamesMismatchForThisToken) 163 } 164 165 isClosed := connection.policy.IsClosed() 166 167 if hasExpired || isClosed { 168 if err := m.interactor.NotifyInteractionSessionBegan(ctx, traceID, api.WalletUnlockingWorkflow, 2); err != nil { 169 return api.ConnectedWallet{}, api.RequestNotPermittedError(err) 170 } 171 defer m.interactor.NotifyInteractionSessionEnded(ctx, traceID) 172 173 for { 174 if ctx.Err() != nil { 175 m.interactor.NotifyError(ctx, traceID, api.ApplicationErrorType, api.ErrRequestInterrupted) 176 return api.ConnectedWallet{}, api.RequestInterruptedError(api.ErrRequestInterrupted) 177 } 178 179 unlockingReason := fmt.Sprintf("The third-party application %q is attempting access to the locked wallet %q. To unlock this wallet and allow access to all connected apps associated to it, enter its passphrase.", hostname, connection.connectedWallet.Name()) 180 181 walletPassphrase, err := m.interactor.RequestPassphrase(ctx, traceID, 1, connection.connectedWallet.Name(), unlockingReason) 182 if err != nil { 183 if errDetails := api.HandleRequestFlowError(ctx, traceID, m.interactor, err); errDetails != nil { 184 return api.ConnectedWallet{}, errDetails 185 } 186 m.interactor.NotifyError(ctx, traceID, api.InternalErrorType, fmt.Errorf("requesting the wallet passphrase failed: %w", err)) 187 return api.ConnectedWallet{}, api.InternalError(api.ErrCouldNotConnectToWallet) 188 } 189 190 if err := m.walletStore.UnlockWallet(ctx, connection.connectedWallet.Name(), walletPassphrase); err != nil { 191 if errors.Is(err, wallet.ErrWrongPassphrase) { 192 m.interactor.NotifyError(ctx, traceID, api.UserErrorType, wallet.ErrWrongPassphrase) 193 continue 194 } 195 if errDetails := api.HandleRequestFlowError(ctx, traceID, m.interactor, err); errDetails != nil { 196 return api.ConnectedWallet{}, errDetails 197 } 198 m.interactor.NotifyError(ctx, traceID, api.InternalErrorType, fmt.Errorf("could not unlock the wallet: %w", err)) 199 return api.ConnectedWallet{}, api.InternalError(api.ErrCouldNotConnectToWallet) 200 } 201 break 202 } 203 204 w, err := m.walletStore.GetWallet(ctx, connection.connectedWallet.Name()) 205 if err != nil { 206 m.interactor.NotifyError(ctx, traceID, api.InternalErrorType, fmt.Errorf("could not retrieve the wallet: %w", err)) 207 return api.ConnectedWallet{}, api.InternalError(api.ErrCouldNotConnectToWallet) 208 } 209 210 // Update the connected wallet for all connections referencing the 211 // newly unlocked wallet. 212 for _, otherConnection := range m.tokenToConnection { 213 if w.Name() != otherConnection.connectedWallet.Name() { 214 continue 215 } 216 217 cw, err := api.NewConnectedWallet(otherConnection.connectedWallet.Hostname(), w) 218 if err != nil { 219 m.interactor.NotifyError(ctx, traceID, api.InternalErrorType, fmt.Errorf("could not instantiate the connected wallet for a session connection: %w", err)) 220 return api.ConnectedWallet{}, api.InternalError(api.ErrCouldNotConnectToWallet) 221 } 222 223 otherConnection.connectedWallet = cw 224 otherConnection.policy = &sessionPolicy{ 225 expiryDate: now.Add(1 * time.Hour), 226 closed: false, 227 } 228 } 229 230 m.interactor.NotifySuccessfulRequest(ctx, traceID, 2, fmt.Sprintf("The wallet %q has been successfully unlocked.", w.Name())) 231 } 232 } 233 234 return connection.connectedWallet, nil 235 } 236 237 // ListSessionConnections lists all the session connections as a list of pairs of 238 // hostname/wallet name. 239 // The list is sorted, first, by hostname, and, then, by wallet name. 240 func (m *Manager) ListSessionConnections() []api.Connection { 241 m.mu.Lock() 242 defer m.mu.Unlock() 243 244 connections := make([]api.Connection, 0, len(m.tokenToConnection)) 245 connectionsCount := 0 246 for _, connection := range m.tokenToConnection { 247 if connection.policy.IsLongLivingConnection() { 248 continue 249 } 250 251 connections = append(connections, api.Connection{ 252 Hostname: connection.connectedWallet.Hostname(), 253 Wallet: connection.connectedWallet.Name(), 254 }) 255 256 connectionsCount++ 257 } 258 connections = connections[0:connectionsCount] 259 260 sort.SliceStable(connections, func(i, j int) bool { 261 if connections[i].Hostname == connections[j].Hostname { 262 return connections[i].Wallet < connections[j].Wallet 263 } 264 265 return connections[i].Hostname < connections[j].Hostname 266 }) 267 268 return connections 269 } 270 271 // generateToken generates a new token and ensure it is not already in use to 272 // avoid collisions. 273 func (m *Manager) generateToken() Token { 274 for { 275 token := GenerateToken() 276 if _, alreadyExistingToken := m.tokenToConnection[token]; !alreadyExistingToken { 277 return token 278 } 279 } 280 } 281 282 func (m *Manager) destroySessionToken(tokenToDestroy Token) { 283 connection, exists := m.tokenToConnection[tokenToDestroy] 284 if !exists || connection.policy.IsLongLivingConnection() { 285 return 286 } 287 288 // Remove the session fingerprint associated to the session token. 289 for sessionFingerprint, t := range m.sessionFingerprintToToken { 290 if t == tokenToDestroy { 291 delete(m.sessionFingerprintToToken, sessionFingerprint) 292 break 293 } 294 } 295 296 // Break the link between a token and its associated wallet. 297 m.tokenToConnection[tokenToDestroy] = nil 298 delete(m.tokenToConnection, tokenToDestroy) 299 } 300 301 func (m *Manager) loadLongLivingConnections(ctx context.Context) error { 302 tokenSummaries, err := m.tokenStore.ListTokens() 303 if err != nil { 304 return err 305 } 306 307 for _, tokenSummary := range tokenSummaries { 308 tokenDescription, err := m.tokenStore.DescribeToken(tokenSummary.Token) 309 if err != nil { 310 return fmt.Errorf("could not get information associated to the token %q: %w", tokenDescription.Token.Short(), err) 311 } 312 313 if err := m.loadLongLivingConnection(ctx, tokenDescription); err != nil { 314 return err 315 } 316 } 317 318 return nil 319 } 320 321 func (m *Manager) loadLongLivingConnection(ctx context.Context, tokenDescription TokenDescription) error { 322 if err := m.walletStore.UnlockWallet(ctx, tokenDescription.Wallet.Name, tokenDescription.Wallet.Passphrase); err != nil { 323 // We don't properly handle wallets renaming, nor wallets passphrase 324 // update in the token file automatically. We only support a direct 325 // update of the token file. 326 return fmt.Errorf("could not unlock the wallet %q associated to the token %q: %w", 327 tokenDescription.Wallet.Name, 328 tokenDescription.Token.Short(), 329 err) 330 } 331 332 w, err := m.walletStore.GetWallet(ctx, tokenDescription.Wallet.Name) 333 if err != nil { 334 // This should not happen because we just unlocked the wallet. 335 return fmt.Errorf("could not get the information for the wallet %q associated to the token %q: %w", 336 tokenDescription.Wallet.Name, 337 tokenDescription.Token.Short(), 338 err) 339 } 340 341 m.tokenToConnection[tokenDescription.Token] = &walletConnection{ 342 connectedWallet: api.NewLongLivingConnectedWallet(w), 343 policy: &longLivingConnectionPolicy{ 344 expirationDate: tokenDescription.ExpirationDate, 345 }, 346 } 347 348 return nil 349 } 350 351 func (m *Manager) loadPreviousSessionConnections(ctx context.Context) error { 352 sessions, err := m.sessionStore.ListSessions(ctx) 353 if err != nil { 354 return fmt.Errorf("could not list the sessions: %w", err) 355 } 356 357 now := m.timeService.Now() 358 359 for _, session := range sessions { 360 sessionFingerprint := asSessionFingerprint(session.Hostname, session.Wallet) 361 m.sessionFingerprintToToken[sessionFingerprint] = session.Token 362 363 // If the wallet in the session store doesn't exist, we destroy that session 364 // and move onto the next. 365 walletExists, err := m.walletStore.WalletExists(ctx, session.Wallet) 366 if err != nil { 367 return fmt.Errorf("could not verify if the wallet %q exists: %w", session.Wallet, err) 368 } 369 370 if !walletExists { 371 err := m.sessionStore.DeleteSession(ctx, session.Token) 372 if err != nil { 373 return fmt.Errorf("could not delete the session with token %q: %w", session.Token, err) 374 } 375 continue 376 } 377 378 // If the wallet is already unlocked, we fully restore the connection. 379 isAlreadyUnlocked, err := m.walletStore.IsWalletAlreadyUnlocked(ctx, session.Wallet) 380 if err != nil { 381 return fmt.Errorf("could not verify wether the wallet %q is locked or not: %w", session.Wallet, err) 382 } 383 384 var connectedWallet api.ConnectedWallet 385 isClosed := true 386 if isAlreadyUnlocked { 387 w, err := m.walletStore.GetWallet(ctx, session.Wallet) 388 if err != nil { 389 return fmt.Errorf("could not retrieve the wallet %q: %w", session.Wallet, err) 390 } 391 cw, err := api.NewConnectedWallet(session.Hostname, w) 392 if err != nil { 393 return fmt.Errorf("could not instantiate the connected wallet for a session connection: %w", err) 394 } 395 connectedWallet = cw 396 isClosed = false 397 } else { 398 connectedWallet = api.NewDisconnectedWallet(session.Hostname, session.Wallet) 399 } 400 401 m.tokenToConnection[session.Token] = &walletConnection{ 402 connectedWallet: connectedWallet, 403 policy: &sessionPolicy{ 404 // Since this session is being reloaded, we consider it to be 405 // expired. 406 expiryDate: now, 407 closed: isClosed, 408 }, 409 } 410 } 411 412 return nil 413 } 414 415 // refreshConnections is called when the wallet store notices a change in 416 // the wallets. This way the connection manager is able to reload the connected 417 // wallets. 418 func (m *Manager) refreshConnections(_ context.Context, event wallet.Event) { 419 m.mu.Lock() 420 defer m.mu.Unlock() 421 422 switch event.Type { 423 case wallet.WalletRemovedEventType: 424 m.destroyConnectionsUsingThisWallet(event.Data.(wallet.WalletRemovedEventData).Name) 425 case wallet.UnlockedWalletUpdatedEventType: 426 m.updateConnectionsUsingThisWallet(event.Data.(wallet.UnlockedWalletUpdatedEventData).UpdatedWallet) 427 case wallet.WalletHasBeenLockedEventType: 428 m.closeConnectionsUsingThisWallet(event.Data.(wallet.WalletHasBeenLockedEventData).Name) 429 case wallet.WalletRenamedEventType: 430 data := event.Data.(wallet.WalletRenamedEventData) 431 m.updateConnectionsUsingThisRenamedWallet(data.PreviousName, data.NewName) 432 } 433 } 434 435 // destroyConnectionUsingThisWallet close the connection, dereference it, and 436 // remove it from the session store. 437 func (m *Manager) destroyConnectionsUsingThisWallet(walletName string) { 438 ctx := context.Background() 439 440 for token, connection := range m.tokenToConnection { 441 if connection.connectedWallet.Name() != walletName { 442 continue 443 } 444 445 connection.policy.SetAsForcefullyClose() 446 447 delete(m.tokenToConnection, token) 448 449 // We ignore the error in a best-effort to have the session store clean 450 // up. 451 _ = m.sessionStore.DeleteSession(ctx, token) 452 } 453 } 454 455 func (m *Manager) updateConnectionsUsingThisWallet(w wallet.Wallet) { 456 for _, connection := range m.tokenToConnection { 457 if connection.connectedWallet.Name() != w.Name() { 458 continue 459 } 460 461 var updatedConnectedWallet api.ConnectedWallet 462 if connection.policy.IsLongLivingConnection() { 463 updatedConnectedWallet = api.NewLongLivingConnectedWallet(w) 464 } else { 465 updatedConnectedWallet, _ = api.NewConnectedWallet(connection.connectedWallet.Hostname(), w) 466 } 467 468 connection.connectedWallet = updatedConnectedWallet 469 } 470 } 471 472 func (m *Manager) refreshLongLivingTokens(ctx context.Context, activeTokensDescriptions ...TokenDescription) { 473 m.mu.Lock() 474 defer m.mu.Unlock() 475 476 // We need to find the new tokens among the active ones, so, we build a 477 // registry with all the active tokens. Then, we remove the tracked token 478 // when found. We will end up with the new tokens, only. 479 activeTokens := make(map[Token]TokenDescription, len(activeTokensDescriptions)) 480 for _, tokenDescription := range activeTokensDescriptions { 481 activeTokens[tokenDescription.Token] = tokenDescription 482 } 483 484 isActiveToken := func(token Token) (TokenDescription, bool) { 485 activeTokenDescription, isTracked := activeTokens[token] 486 if isTracked { 487 delete(activeTokens, token) 488 } 489 return activeTokenDescription, isTracked 490 } 491 492 // First, we update of the tokens we already track. 493 for token, connection := range m.tokenToConnection { 494 if !connection.policy.IsLongLivingConnection() { 495 continue 496 } 497 498 activeToken, isActive := isActiveToken(token) 499 if !isActive { 500 // If the token could not be found in the active tokens, this means 501 // the token has been deleted from the token store. Thus, we close the 502 // connection. 503 delete(m.tokenToConnection, token) 504 continue 505 } 506 507 _ = m.loadLongLivingConnection(ctx, activeToken) 508 } 509 510 // Then, we load the new tokens. 511 for _, tokenDescription := range activeTokens { 512 _ = m.loadLongLivingConnection(ctx, tokenDescription) 513 } 514 } 515 516 // closeConnectionsUsingThisWallet defines the connection as closed. It keeps track of it 517 // but next time it will be used the application will request for the passphrase 518 // to reinstate it. 519 func (m *Manager) closeConnectionsUsingThisWallet(walletName string) { 520 for _, connection := range m.tokenToConnection { 521 if connection.connectedWallet.Name() != walletName { 522 continue 523 } 524 525 connection.policy.SetAsForcefullyClose() 526 } 527 } 528 529 func (m *Manager) updateConnectionsUsingThisRenamedWallet(previousWalletName, newWalletName string) { 530 var _updatedWallet wallet.Wallet 531 532 // This acts as a cached getter, to avoid multiple or useless fetch. 533 getUpdatedWallet := func() wallet.Wallet { 534 if _updatedWallet == nil { 535 w, _ := m.walletStore.GetWallet(context.Background(), newWalletName) 536 _updatedWallet = w 537 } 538 return _updatedWallet 539 } 540 541 for _, connection := range m.tokenToConnection { 542 if connection.connectedWallet.Name() != previousWalletName { 543 continue 544 } 545 546 if connection.policy.IsLongLivingConnection() { 547 connection.connectedWallet = api.NewLongLivingConnectedWallet( 548 getUpdatedWallet(), 549 ) 550 } 551 552 connection.connectedWallet, _ = api.NewConnectedWallet( 553 connection.connectedWallet.Hostname(), 554 getUpdatedWallet(), 555 ) 556 } 557 } 558 559 func NewManager(timeService TimeService, walletStore WalletStore, tokenStore TokenStore, sessionStore SessionStore, interactor api.Interactor) (*Manager, error) { 560 m := &Manager{ 561 tokenToConnection: map[Token]*walletConnection{}, 562 sessionFingerprintToToken: map[string]Token{}, 563 timeService: timeService, 564 walletStore: walletStore, 565 sessionStore: sessionStore, 566 tokenStore: tokenStore, 567 interactor: interactor, 568 } 569 570 ctx := context.Background() 571 572 if err := m.loadLongLivingConnections(ctx); err != nil { 573 return nil, fmt.Errorf("could not load the long-living connections: %w", err) 574 } 575 576 if err := m.loadPreviousSessionConnections(ctx); err != nil { 577 return nil, fmt.Errorf("could not load the previous session connections: %w", err) 578 } 579 580 walletStore.OnUpdate(m.refreshConnections) 581 tokenStore.OnUpdate(m.refreshLongLivingTokens) 582 583 return m, nil 584 } 585 586 func asSessionFingerprint(hostname string, walletName string) string { 587 return vgcrypto.HashStrToHex(hostname + "::" + walletName) 588 } 589 590 func serverErrorAuthenticationFailure(err error) *jsonrpc.ErrorDetails { 591 return jsonrpc.NewServerError(api.ErrorCodeAuthenticationFailure, err) 592 }