From 487fc699fe8f4154b45f37cccfcea3d915eea9c0 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 20 May 2025 10:32:33 +0100 Subject: [PATCH] bridgev2/provisioning: add session transfer support For connector logins that support it this will expose an API to transfer credentials between bridge instances. Currently does not do any extra validation beyond the usual provisioning API request validation (so shared secret or matrix token). One future improvement would be to require clients to sign incoming requests, and to then validate a) the signature and b) the device is verified. --- bridgev2/bridgeconfig/config.go | 7 +- bridgev2/matrix/mxmain/example-config.yaml | 3 + bridgev2/matrix/provisioning.go | 121 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index bd7746d1..37517818 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -95,9 +95,10 @@ type AnalyticsConfig struct { } type ProvisioningConfig struct { - Prefix string `yaml:"prefix"` - SharedSecret string `yaml:"shared_secret"` - DebugEndpoints bool `yaml:"debug_endpoints"` + Prefix string `yaml:"prefix"` + SharedSecret string `yaml:"shared_secret"` + DebugEndpoints bool `yaml:"debug_endpoints"` + EnableSessionTransfers bool `yaml:"enable_session_transfers"` } type DirectMediaConfig struct { diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 4dee2650..a9d05fd1 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -252,6 +252,9 @@ provisioning: allow_matrix_auth: true # Enable debug API at /debug with provisioning authentication. debug_endpoints: false + # Enable session transfers between bridges. Note that this only validates Matrix or shared secret + # auth before passing live network client credentials down in the response. + enable_session_transfers: false # Some networks require publicly accessible media download links (e.g. for user avatars when using Discord webhooks). # These settings control whether the bridge will provide such public media access. diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index d809d039..2b9b5124 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -53,6 +53,11 @@ type ProvisioningAPI struct { matrixAuthCache map[string]matrixAuthCacheEntry matrixAuthCacheLock sync.Mutex + // Set for a given login once credentials have been exported, once in this state the finish + // API is available which will call logout on the client in question. + sessionTransfers map[networkid.UserLoginID]struct{} + sessionTransfersLock sync.Mutex + // GetAuthFromRequest is a custom function for getting the auth token from // the request if the Authorization header is not present. GetAuthFromRequest func(r *http.Request) string @@ -101,6 +106,7 @@ func (br *Connector) GetProvisioning() IProvisioningAPI { func (prov *ProvisioningAPI) Init() { prov.matrixAuthCache = make(map[string]matrixAuthCacheEntry) prov.logins = make(map[string]*ProvLogin) + prov.sessionTransfers = make(map[networkid.UserLoginID]struct{}) prov.net = prov.br.Bridge.Network prov.log = prov.br.Log.With().Str("component", "provisioning").Logger() prov.fedClient = federation.NewClient("", nil, nil) @@ -128,6 +134,12 @@ func (prov *ProvisioningAPI) Init() { prov.Router.Path("/v3/create_dm/{identifier}").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateDM) prov.Router.Path("/v3/create_group").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostCreateGroup) + if prov.br.Config.Provisioning.EnableSessionTransfers { + prov.log.Debug().Msg("Enabling session transfer API") + prov.Router.Path("/v3/session_transfer/init").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostInitSessionTransfer) + prov.Router.Path("/v3/session_transfer/finish").Methods(http.MethodPost, http.MethodOptions).HandlerFunc(prov.PostFinishSessionTransfer) + } + if prov.br.Config.Provisioning.DebugEndpoints { prov.log.Debug().Msg("Enabling debug API at /debug") r := prov.br.AS.Router.PathPrefix("/debug").Subrouter() @@ -791,3 +803,112 @@ func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Requ ErrCode: mautrix.MUnrecognized.ErrCode, }) } + +type ReqExportCredentials struct { + RemoteID networkid.UserLoginID `json:"remote_name"` +} + +type RespExportCredentials struct { + Credentials any `json:"credentials"` +} + +func (prov *ProvisioningAPI) PostInitSessionTransfer(w http.ResponseWriter, r *http.Request) { + prov.sessionTransfersLock.Lock() + defer prov.sessionTransfersLock.Unlock() + + var req ReqExportCredentials + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "Failed to decode request body", + ErrCode: mautrix.MNotJSON.ErrCode, + }) + return + } + + user := prov.GetUser(r) + logins := user.GetUserLogins() + var loginToExport *bridgev2.UserLogin + for _, login := range logins { + if login.ID == req.RemoteID { + loginToExport = login + break + } + } + if loginToExport == nil { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + Err: "No matching user login found", + ErrCode: mautrix.MNotFound.ErrCode, + }) + return + } + + client, ok := loginToExport.Client.(bridgev2.CredentialExportingNetworkAPI) + if !ok { + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "Client does not support credential exporting", + ErrCode: mautrix.MInvalidParam.ErrCode, + }) + return + } + + if _, ok := prov.sessionTransfers[loginToExport.ID]; ok { + // Warn, but allow, double exports. This might happen if a client crashes handling creds, + // and should be safe to call multiple times. + zerolog.Ctx(r.Context()).Warn().Msg("Exporting already exported credentials") + } + + resp := RespExportCredentials{ + Credentials: client.ExportCredentials(r.Context()), + } + jsonResponse(w, http.StatusOK, resp) +} + +func (prov *ProvisioningAPI) PostFinishSessionTransfer(w http.ResponseWriter, r *http.Request) { + prov.sessionTransfersLock.Lock() + defer prov.sessionTransfersLock.Unlock() + + var req ReqExportCredentials + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "Failed to decode request body", + ErrCode: mautrix.MNotJSON.ErrCode, + }) + return + } + + user := prov.GetUser(r) + logins := user.GetUserLogins() + var loginToExport *bridgev2.UserLogin + for _, login := range logins { + if login.ID == req.RemoteID { + loginToExport = login + break + } + } + if loginToExport == nil { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + Err: "No matching user login found", + ErrCode: mautrix.MNotFound.ErrCode, + }) + return + } else if _, ok := prov.sessionTransfers[loginToExport.ID]; !ok { + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + Err: "No matching credential export found", + ErrCode: mautrix.MNotJSON.ErrCode, + }) + return + } + + zerolog.Ctx(r.Context()).Info(). + Str("remote_name", string(req.RemoteID)). + Msg("Logging out remote after finishing credential export") + + loginToExport.Client.LogoutRemote(r.Context()) + delete(prov.sessionTransfers, req.RemoteID) + + jsonResponse(w, http.StatusOK, struct{}{}) +}