Skip to content

Commit 59a0571

Browse files
MM-67613 Added handling of encryption key rotation (#979)
* MM-67613 Added handling of encryption key rotation with force disconnect fallback * Added force disconnect fallback on normal disconnect flow - Ensuring that even if an environment somehow bypasses the OnConfigurationChanged hook, a user can still disconnect and re-connect their accounts instead of being locked * Ensuring any failure to fetch a page of KV entries doesn't abort the entire rotation flow * PR review fixes * Improving behavior in clustered environments to prevent race conditions * Addressing multiple issues with rotation - Making sure rotation is non-blocking - Passing correct references to remove risk of race conditions - Applying stricter unpad logic * Added audit logging to migration process * Fixing issue with force disconnect not working
1 parent c2840e9 commit 59a0571

File tree

8 files changed

+617
-18
lines changed

8 files changed

+617
-18
lines changed

server/plugin/api.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ func (p *Plugin) completeConnectUserToGitHub(c *Context, w http.ResponseWriter,
457457
MM34646ResetTokenDone: true,
458458
}
459459

460-
if err = p.storeGitHubUserInfo(userInfo); err != nil {
460+
if err = p.storeGitHubUserInfo(userInfo, p.getConfiguration().EncryptionKey); err != nil {
461461
c.Log.WithError(err).Errorf("Failed to store GitHub user info")
462462
p.writeAPIError(w, &APIErrorResponse{Message: "unable to connect user to GitHub", StatusCode: http.StatusInternalServerError})
463463
return
@@ -653,7 +653,7 @@ func (p *Plugin) getConnected(c *Context, w http.ResponseWriter, r *http.Request
653653
c.Log.WithError(err).Warnf("Failed to create GitHub todo message")
654654
}
655655
info.LastToDoPostAt = now
656-
if err := p.storeGitHubUserInfo(info); err != nil {
656+
if err := p.storeGitHubUserInfo(info, p.getConfiguration().EncryptionKey); err != nil {
657657
c.Log.WithError(err).Warnf("Failed to store github info for new user")
658658
}
659659
}
@@ -1157,7 +1157,7 @@ func (p *Plugin) updateSettings(c *UserContext, w http.ResponseWriter, r *http.R
11571157
info := c.GHInfo
11581158
info.Settings = settings
11591159

1160-
if err := p.storeGitHubUserInfo(info); err != nil {
1160+
if err := p.storeGitHubUserInfo(info, p.getConfiguration().EncryptionKey); err != nil {
11611161
c.Log.WithError(err).Errorf("Failed to store GitHub user info")
11621162
p.writeAPIError(w, &APIErrorResponse{Message: "error occurred while updating settings", StatusCode: http.StatusInternalServerError})
11631163
return

server/plugin/audit.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
package plugin
5+
6+
// ReEncryptUserDataAuditParams holds request audit data for the reEncryptUserData transaction.
7+
type ReEncryptUserDataAuditParams struct {
8+
TotalUsers int `json:"total_users"`
9+
}
10+
11+
func (p ReEncryptUserDataAuditParams) Auditable() map[string]any {
12+
return map[string]any{
13+
"total_users": p.TotalUsers,
14+
}
15+
}
16+
17+
// ReEncryptUserDataAuditResult holds the outcome of the reEncryptUserData transaction.
18+
type ReEncryptUserDataAuditResult struct {
19+
Migrated int `json:"migrated"`
20+
ForceDisconnected int `json:"force_disconnected"`
21+
}
22+
23+
func (p ReEncryptUserDataAuditResult) Auditable() map[string]any {
24+
return map[string]any{
25+
"migrated": p.Migrated,
26+
"force_disconnected": p.ForceDisconnected,
27+
}
28+
}

server/plugin/command.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ func (p *Plugin) handleSettings(_ *plugin.Context, _ *model.CommandArgs, paramet
791791
}
792792
}
793793

794-
err := p.storeGitHubUserInfo(userInfo)
794+
err := p.storeGitHubUserInfo(userInfo, p.getConfiguration().EncryptionKey)
795795
if err != nil {
796796
p.client.Log.Warn("Failed to store github user info", "error", err.Error())
797797
return "Failed to store settings"
@@ -1076,6 +1076,12 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo
10761076
return &model.CommandResponse{}, nil
10771077
}
10781078

1079+
if action == "disconnect" {
1080+
p.disconnectGitHubAccount(args.UserId)
1081+
p.postCommandResponse(args, "Disconnected your GitHub account.")
1082+
return &model.CommandResponse{}, nil
1083+
}
1084+
10791085
info, apiErr := p.getGitHubUserInfo(args.UserId)
10801086
if apiErr != nil {
10811087
text := "Unknown error."

server/plugin/configuration.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,15 @@ func (p *Plugin) OnConfigurationChange() error {
216216

217217
configuration.sanitize()
218218

219-
p.sendWebsocketEventIfNeeded(p.getConfiguration(), configuration)
219+
previousConfig := p.getConfiguration()
220+
previousEncryptionKey := previousConfig.EncryptionKey
221+
222+
p.sendWebsocketEventIfNeeded(previousConfig, configuration)
223+
224+
if previousEncryptionKey != "" && configuration.EncryptionKey != "" &&
225+
previousEncryptionKey != configuration.EncryptionKey {
226+
go p.reEncryptUserData(configuration.EncryptionKey, previousEncryptionKey)
227+
}
220228

221229
p.setConfiguration(configuration)
222230

server/plugin/mm_34646_token_refresh.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ func (p *Plugin) forceResetUserTokenMM34646(ctx context.Context, config *Configu
115115

116116
info.Token.AccessToken = *a.Token
117117
info.MM34646ResetTokenDone = true
118-
err = p.storeGitHubUserInfo(info)
118+
err = p.storeGitHubUserInfo(info, p.getConfiguration().EncryptionKey)
119119
if err != nil {
120120
return "", errors.Wrap(err, "failed to store updated GitHubUserInfo")
121121
}

server/plugin/plugin.go

Lines changed: 182 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/mattermost/mattermost/server/public/model"
2525
"github.com/mattermost/mattermost/server/public/plugin"
2626
"github.com/mattermost/mattermost/server/public/pluginapi"
27+
"github.com/mattermost/mattermost/server/public/pluginapi/cluster"
2728
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
2829
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster"
2930

@@ -36,8 +37,9 @@ const (
3637
githubUsernameKey = "_githubusername"
3738
githubPrivateRepoKey = "_githubprivate"
3839

39-
mm34646MutexKey = "mm34646_token_reset_mutex"
40-
mm34646DoneKey = "mm34646_token_reset_done"
40+
mm34646MutexKey = "mm34646_token_reset_mutex"
41+
mm34646DoneKey = "mm34646_token_reset_done"
42+
reEncryptMutexKey = "reencrypt_user_data_mutex"
4143

4244
wsEventConnect = "connect"
4345
wsEventDisconnect = "disconnect"
@@ -657,10 +659,8 @@ type UserSettings struct {
657659
Notifications bool `json:"notifications"`
658660
}
659661

660-
func (p *Plugin) storeGitHubUserInfo(info *GitHubUserInfo) error {
661-
config := p.getConfiguration()
662-
663-
encryptedToken, err := encrypt([]byte(config.EncryptionKey), info.Token.AccessToken)
662+
func (p *Plugin) storeGitHubUserInfo(info *GitHubUserInfo, encryptionKey string) error {
663+
encryptedToken, err := encrypt([]byte(encryptionKey), info.Token.AccessToken)
664664
if err != nil {
665665
return errors.Wrap(err, "error occurred while encrypting access token")
666666
}
@@ -728,8 +728,24 @@ func (p *Plugin) getGitHubToUsernameMapping(githubUsername string) string {
728728
}
729729

730730
func (p *Plugin) disconnectGitHubAccount(userID string) {
731-
userInfo, _ := p.getGitHubUserInfo(userID)
732-
if userInfo == nil {
731+
userInfo, apiErr := p.getGitHubUserInfo(userID)
732+
if apiErr != nil {
733+
if apiErr.ID == apiErrorIDNotConnected {
734+
return
735+
}
736+
737+
p.client.Log.Warn("Failed to load user info for disconnect, falling back to force-disconnect",
738+
"user_id", userID, "error", apiErr.Message)
739+
var rawInfo *GitHubUserInfo
740+
if err := p.store.Get(userID+githubTokenKey, &rawInfo); err != nil {
741+
p.client.Log.Warn("Failed to load raw user info during fallback disconnect",
742+
"user_id", userID, "error", err.Error())
743+
}
744+
githubUsername := ""
745+
if rawInfo != nil {
746+
githubUsername = rawInfo.GitHubUsername
747+
}
748+
p.forceDisconnectUser(userID, githubUsername)
733749
return
734750
}
735751

@@ -738,7 +754,11 @@ func (p *Plugin) disconnectGitHubAccount(userID string) {
738754
}
739755

740756
if err := p.store.Delete(userInfo.GitHubUsername + githubUsernameKey); err != nil {
741-
p.client.Log.Warn("Failed to delete github token from KV store", "userID", userID, "error", err.Error())
757+
p.client.Log.Warn("Failed to delete github username mapping from KV store", "userID", userID, "error", err.Error())
758+
}
759+
760+
if err := p.store.Delete(userID + githubPrivateRepoKey); err != nil {
761+
p.client.Log.Warn("Failed to delete github private repo key from KV store", "userID", userID, "error", err.Error())
742762
}
743763

744764
user, err := p.client.User.Get(userID)
@@ -750,7 +770,7 @@ func (p *Plugin) disconnectGitHubAccount(userID string) {
750770
delete(user.Props, "git_user")
751771
err := p.client.User.Update(user)
752772
if err != nil {
753-
p.client.Log.Warn("Failed to get update user props", "userID", userID, "error", err.Error())
773+
p.client.Log.Warn("Failed to update user props", "userID", userID, "error", err.Error())
754774
}
755775
}
756776
}
@@ -762,6 +782,158 @@ func (p *Plugin) disconnectGitHubAccount(userID string) {
762782
)
763783
}
764784

785+
// reEncryptUserData re-encrypts all connected users' access tokens when
786+
// the encryption key changes. Users whose tokens cannot be migrated are
787+
// force-disconnected and notified to reconnect.
788+
// A cluster mutex ensures only one node performs the migration in HA setups.
789+
func (p *Plugin) reEncryptUserData(newEncryptionKey, previousEncryptionKey string) {
790+
m, err := cluster.NewMutex(p.API, reEncryptMutexKey)
791+
if err != nil {
792+
p.client.Log.Warn("Failed to create cluster mutex for encryption key rotation", "error", err.Error())
793+
return
794+
}
795+
m.Lock()
796+
defer m.Unlock()
797+
798+
checker := func(key string) (keep bool, err error) {
799+
return strings.HasSuffix(key, githubTokenKey), nil
800+
}
801+
802+
var allKeys []string
803+
for page := 0; ; page++ {
804+
keys, err := p.store.ListKeys(page, keysPerPage, pluginapi.WithChecker(checker))
805+
if err != nil {
806+
p.client.Log.Warn("Encryption key changed but failed to list user keys for re-encryption, proceeding with keys collected so far",
807+
"page", fmt.Sprintf("%d", page), "keys_collected", fmt.Sprintf("%d", len(allKeys)), "error", err.Error())
808+
break
809+
}
810+
allKeys = append(allKeys, keys...)
811+
if len(keys) < keysPerPage {
812+
break
813+
}
814+
}
815+
816+
if len(allKeys) == 0 {
817+
return
818+
}
819+
820+
auditRec := plugin.MakeAuditRecord("reEncryptUserData", model.AuditStatusFail)
821+
defer p.API.LogAuditRec(auditRec)
822+
model.AddEventParameterAuditableToAuditRec(auditRec, "re_encrypt_user_data", ReEncryptUserDataAuditParams{
823+
TotalUsers: len(allKeys),
824+
})
825+
826+
p.client.Log.Info("Encryption key changed, re-encrypting user tokens",
827+
"user_count", fmt.Sprintf("%d", len(allKeys)))
828+
829+
var migrated, forceDisconnected int
830+
for _, key := range allKeys {
831+
userID := strings.TrimSuffix(key, githubTokenKey)
832+
833+
githubUsername, err := p.reEncryptUserToken(key, newEncryptionKey, previousEncryptionKey)
834+
if err != nil {
835+
p.client.Log.Warn("Failed to re-encrypt user token during encryption key rotation",
836+
"user_id", userID, "error", err.Error())
837+
auditRec.AddErrorDesc(fmt.Sprintf("user %s: %s", userID, err.Error()))
838+
p.forceDisconnectUser(userID, githubUsername)
839+
forceDisconnected++
840+
} else {
841+
migrated++
842+
}
843+
}
844+
845+
if forceDisconnected == 0 {
846+
auditRec.Success()
847+
}
848+
auditRec.AddEventResultState(ReEncryptUserDataAuditResult{
849+
Migrated: migrated,
850+
ForceDisconnected: forceDisconnected,
851+
})
852+
}
853+
854+
// reEncryptUserToken decrypts a single user's token with the old key and
855+
// re-encrypts it with the new key. Returns the GitHub username (best-effort,
856+
// may be empty) and any error encountered.
857+
func (p *Plugin) reEncryptUserToken(kvKey, newEncryptionKey, previousEncryptionKey string) (string, error) {
858+
var userInfo *GitHubUserInfo
859+
if err := p.store.Get(kvKey, &userInfo); err != nil {
860+
return "", errors.Wrap(err, "could not load user info")
861+
}
862+
if userInfo == nil {
863+
return "", errors.New("user info not found")
864+
}
865+
866+
if userInfo.Token == nil || userInfo.Token.AccessToken == "" {
867+
return userInfo.GitHubUsername, errors.New("user has no token to re-encrypt")
868+
}
869+
870+
if _, err := decrypt([]byte(newEncryptionKey), userInfo.Token.AccessToken); err == nil {
871+
return userInfo.GitHubUsername, nil
872+
}
873+
874+
plainToken, err := decrypt([]byte(previousEncryptionKey), userInfo.Token.AccessToken)
875+
if err != nil {
876+
return userInfo.GitHubUsername, errors.Wrap(err, "could not decrypt token with previous key")
877+
}
878+
879+
userInfo.Token.AccessToken = plainToken
880+
if err := p.storeGitHubUserInfo(userInfo, newEncryptionKey); err != nil {
881+
return userInfo.GitHubUsername, errors.Wrap(err, "could not store re-encrypted token")
882+
}
883+
884+
return userInfo.GitHubUsername, nil
885+
}
886+
887+
// forceDisconnectUser performs a best-effort cleanup of a user's encrypted
888+
// data and notifies them to reconnect
889+
func (p *Plugin) forceDisconnectUser(userID, githubUsername string) {
890+
if err := p.store.Delete(userID + githubTokenKey); err != nil {
891+
p.client.Log.Warn("forceDisconnectUser: failed to delete github token",
892+
"user_id", userID, "error", err.Error())
893+
}
894+
895+
if err := p.store.Delete(userID + githubPrivateRepoKey); err != nil {
896+
p.client.Log.Warn("forceDisconnectUser: failed to delete github private repo key",
897+
"user_id", userID, "error", err.Error())
898+
}
899+
900+
user, err := p.client.User.Get(userID)
901+
if err != nil {
902+
p.client.Log.Warn("forceDisconnectUser: failed to get user props",
903+
"user_id", userID, "error", err.Error())
904+
} else {
905+
if githubUsername == "" {
906+
if gitUser, ok := user.Props["git_user"]; ok {
907+
githubUsername = gitUser
908+
}
909+
}
910+
if _, ok := user.Props["git_user"]; ok {
911+
delete(user.Props, "git_user")
912+
if err := p.client.User.Update(user); err != nil {
913+
p.client.Log.Warn("forceDisconnectUser: failed to update user props",
914+
"user_id", userID, "error", err.Error())
915+
}
916+
}
917+
}
918+
919+
if githubUsername != "" {
920+
if err := p.store.Delete(githubUsername + githubUsernameKey); err != nil {
921+
p.client.Log.Warn("forceDisconnectUser: failed to delete username mapping",
922+
"user_id", userID, "error", err.Error())
923+
}
924+
}
925+
926+
p.client.Frontend.PublishWebSocketEvent(
927+
wsEventDisconnect,
928+
nil,
929+
&model.WebsocketBroadcast{UserId: userID},
930+
)
931+
932+
p.CreateBotDMPost(userID,
933+
"Your GitHub connection has been reset due to a change in the plugin configuration. Please reconnect your account using `/github connect`.",
934+
"custom_git_disconnect")
935+
}
936+
765937
func (p *Plugin) openIssueCreateModal(userID string, channelID string, title string) {
766938
p.client.Frontend.PublishWebSocketEvent(
767939
wsEventCreateIssue,

0 commit comments

Comments
 (0)