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{}{}) +}