-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement process for migrating keys and algorithms
Fixes #3315 Implement CLI command which is used to migrate keys and algorithms. This will: 1) Open connection to the DB 2) Query for secrets which do not have the default key ID or algorithm 3) Decrypts and re-encrypts with new data 4) Wraps all the above in a transaction which asks permission before commiting State secrets in the session state table are deleted. Since they are short-lived secrets, there is no point in migrating them. Tested locally.
- Loading branch information
Showing
12 changed files
with
527 additions
and
62 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
// Copyright 2024 Stacklok, Inc. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package app | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"os" | ||
|
||
"github.com/rs/zerolog" | ||
"github.com/spf13/cobra" | ||
|
||
serverconfig "github.com/stacklok/minder/internal/config/server" | ||
"github.com/stacklok/minder/internal/db" | ||
) | ||
|
||
// This file contains logic shared between different commands. | ||
|
||
func wireUpDB(ctx context.Context, cfg *serverconfig.Config) (db.Store, func(), error) { | ||
dbConn, _, err := cfg.Database.GetDBConnection(ctx) | ||
if err != nil { | ||
return nil, nil, fmt.Errorf("unable to connect to database: %w", err) | ||
} | ||
closer := func() { | ||
err := dbConn.Close() | ||
if err != nil { | ||
zerolog.Ctx(ctx).Error().Err(err).Msg("error closing database connection") | ||
} | ||
} | ||
|
||
return db.NewStore(dbConn), closer, nil | ||
} | ||
|
||
func confirm(cmd *cobra.Command, message string) (bool, error) { | ||
yes, err := cmd.Flags().GetBool("yes") | ||
if err != nil { | ||
cmd.Printf("Error while getting yes flag: %v", err) | ||
} | ||
if !yes { | ||
cmd.Printf("WARNING: %s. Do you want to continue? (y/n): ", message) | ||
var response string | ||
_, err := fmt.Scanln(&response) | ||
if err != nil { | ||
return false, fmt.Errorf("error while reading user input: %w", err) | ||
} | ||
|
||
if response != "y" { | ||
cmd.Printf("Exiting...") | ||
return false, nil | ||
} | ||
} | ||
return true, nil | ||
} | ||
|
||
// cliError prints the error and exits | ||
func cliErrorf(cmd *cobra.Command, message string, args ...any) { | ||
cmd.Printf(message, args...) | ||
os.Exit(1) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
// Copyright 2024 Stacklok, Inc. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package app | ||
|
||
import ( | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
// encryptionCmd groups together the encryption-related commands | ||
var encryptionCmd = &cobra.Command{ | ||
Use: "encryption", | ||
Short: "Tools for managing encryption keys", | ||
Long: `Use with rotate to re-encrypt provider access tokens with new keys/algorithms`, | ||
RunE: func(cmd *cobra.Command, _ []string) error { | ||
return cmd.Usage() | ||
}, | ||
} | ||
|
||
func init() { | ||
RootCmd.AddCommand(encryptionCmd) | ||
encryptionCmd.PersistentFlags().BoolP("yes", "y", false, "Answer yes to all questions") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,255 @@ | ||
// Copyright 2024 Stacklok, Inc. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
// Package app provides the entrypoint for the minder migrations | ||
package app | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
|
||
_ "github.com/golang-migrate/migrate/v4/database/postgres" // nolint | ||
_ "github.com/golang-migrate/migrate/v4/source/file" // nolint | ||
"github.com/rs/zerolog" | ||
"github.com/spf13/cobra" | ||
"github.com/spf13/viper" | ||
|
||
"github.com/stacklok/minder/internal/config" | ||
serverconfig "github.com/stacklok/minder/internal/config/server" | ||
"github.com/stacklok/minder/internal/crypto" | ||
"github.com/stacklok/minder/internal/db" | ||
"github.com/stacklok/minder/internal/logger" | ||
) | ||
|
||
// number of secrets to re-encrypt per batch | ||
const batchSize = 100 | ||
|
||
// used if rotation is cancelled before commit for one reason or another | ||
var errCancelRotation = errors.New("cancelling rotation process") | ||
|
||
// rotateCmd represents the up command | ||
var rotateCmd = &cobra.Command{ | ||
Use: "rotate", | ||
Short: "Rotate keys and encryption algorithms", | ||
Long: `re-encrypt all provider access tokens with the default key version and algorithm`, | ||
RunE: func(cmd *cobra.Command, _ []string) error { | ||
cfg, err := config.ReadConfigFromViper[serverconfig.Config](viper.GetViper()) | ||
if err != nil { | ||
cliErrorf(cmd, "unable to read config: %w", err) | ||
Check failure on line 50 in cmd/server/app/encryption_rotate.go GitHub Actions / lint / Go Lint
Check failure on line 50 in cmd/server/app/encryption_rotate.go GitHub Actions / test / Coverage
|
||
} | ||
|
||
ctx := logger.FromFlags(cfg.LoggingConfig).WithContext(context.Background()) | ||
|
||
// instantiate `db.Store` so we can run queries | ||
store, closer, err := wireUpDB(ctx, cfg) | ||
if err != nil { | ||
cliErrorf(cmd, "unable to connect to database: %s", err) | ||
} | ||
defer closer() | ||
|
||
yes, err := confirm(cmd, "Running this command will change encrypted secrets") | ||
if err != nil { | ||
cmd.Printf("Exiting without migrating\n") | ||
cliErrorf(cmd, err.Error()) | ||
} | ||
if !yes { | ||
return nil | ||
} | ||
|
||
// Clean up old session secrets instead of migrating | ||
sessionsDeleted, err := deleteStaleSessions(ctx, cmd, store) | ||
if err != nil { | ||
// if we cancel or have nothing to migrate... | ||
if errors.Is(err, errCancelRotation) { | ||
cmd.Printf("Cleanup canceled, exiting\n") | ||
return nil | ||
} | ||
cliErrorf(cmd, "error while cleanup up stale sessions: %s", err) | ||
} | ||
if sessionsDeleted != 0 { | ||
cmd.Printf("Successfully deleted %d stale sessions\n", sessionsDeleted) | ||
} | ||
|
||
// rotate the provider access tokens | ||
totalRotated, err := rotateSecrets(ctx, cmd, store, cfg) | ||
if err != nil { | ||
// if we cancel or have nothing to migrate... | ||
if errors.Is(err, errCancelRotation) { | ||
cmd.Printf("Nothing to migrate, exiting\n") | ||
return nil | ||
} | ||
cliErrorf(cmd, "error while attempting to rotate secrets: %s", err) | ||
} | ||
|
||
cmd.Printf("Successfully rotated %d secrets\n", totalRotated) | ||
return nil | ||
}, | ||
} | ||
|
||
func deleteStaleSessions( | ||
ctx context.Context, | ||
cmd *cobra.Command, | ||
store db.Store, | ||
) (int64, error) { | ||
return db.WithTransaction[int64](store, func(qtx db.ExtendQuerier) (int64, error) { | ||
// delete any sessions more than one day old | ||
deleted, err := qtx.DeleteExpiredSessionStates(ctx) | ||
if err != nil { | ||
return 0, err | ||
} | ||
|
||
// skip the confirmation if there's nothing to do | ||
if deleted == 0 { | ||
cmd.Printf("No stale sessions to delete\n") | ||
return 0, nil | ||
} | ||
|
||
// one last chance to reconsider your choices | ||
yes, err := confirm(cmd, fmt.Sprintf("About to delete %d stale sessions", deleted)) | ||
if err != nil { | ||
return 0, err | ||
} | ||
if !yes { | ||
return 0, errCancelRotation | ||
} | ||
return deleted, nil | ||
}) | ||
} | ||
|
||
func rotateSecrets( | ||
ctx context.Context, | ||
cmd *cobra.Command, | ||
store db.Store, | ||
cfg *serverconfig.Config, | ||
) (int64, error) { | ||
// ensure that the new config structure is set - otherwise bad things will happen | ||
if cfg.Crypto.Default.KeyID == "" || cfg.Crypto.Default.Algorithm == "" { | ||
cliErrorf(cmd, "defaults not defined in crypto config - exiting") | ||
} | ||
|
||
// instantiate crypto engine so that we can decrypt and re-encrypt | ||
cryptoEngine, err := crypto.NewEngineFromConfig(cfg) | ||
if err != nil { | ||
cliErrorf(cmd, "unable to instantiate crypto engine: %s", err) | ||
} | ||
|
||
return db.WithTransaction[int64](store, func(qtx db.ExtendQuerier) (int64, error) { | ||
var rotated int64 = 0 | ||
|
||
for { | ||
updated, err := runRotationBatch(ctx, rotated, qtx, cryptoEngine, &cfg.Crypto) | ||
if err != nil { | ||
return rotated, err | ||
} | ||
// nothing more to do - exit loop | ||
if updated == 0 { | ||
break | ||
} | ||
rotated += updated | ||
} | ||
|
||
// print useful status for the case where there is nothing to rotate | ||
if rotated == 0 { | ||
return 0, errCancelRotation | ||
} | ||
// one last chance to reconsider your choices | ||
yes, err := confirm(cmd, fmt.Sprintf("About to rotate %d secrets, do you want to continue?", rotated)) | ||
if err != nil { | ||
return 0, err | ||
} | ||
if !yes { | ||
return 0, errCancelRotation | ||
} | ||
return rotated, nil | ||
}) | ||
} | ||
|
||
func runRotationBatch( | ||
ctx context.Context, | ||
offset int64, | ||
store db.ExtendQuerier, | ||
engine crypto.Engine, | ||
cfg *serverconfig.CryptoConfig, | ||
) (int64, error) { | ||
batch, err := store.ListTokensToMigrate(ctx, db.ListTokensToMigrateParams{ | ||
DefaultAlgorithm: cfg.Default.Algorithm, | ||
DefaultKeyVersion: cfg.Default.KeyID, | ||
BatchOffset: offset, | ||
BatchSize: batchSize, | ||
}) | ||
if err != nil { | ||
return 0, err | ||
} | ||
|
||
zerolog.Ctx(ctx). | ||
Debug(). | ||
Msgf("processing batch of %d tokens", len(batch)) | ||
|
||
for _, token := range batch { | ||
var oldSecret crypto.EncryptedData | ||
if token.EncryptedAccessToken.Valid { | ||
deserialized, err := crypto.DeserializeEncryptedData(token.EncryptedAccessToken.RawMessage) | ||
if err != nil { | ||
return 0, tokenError(token.ID, err) | ||
} | ||
oldSecret = deserialized | ||
} else if token.EncryptedToken.Valid { | ||
oldSecret = crypto.NewBackwardsCompatibleEncryptedData(token.EncryptedToken.String) | ||
} else { | ||
// this should never happen | ||
return 0, tokenError(token.ID, errors.New("no encrypted secret found")) | ||
} | ||
|
||
// decrypt the secret | ||
decrypted, err := engine.DecryptOAuthToken(oldSecret) | ||
if err != nil { | ||
return 0, tokenError(token.ID, err) | ||
} | ||
|
||
// re-encrypt it with new key/algorithm | ||
encrypted, err := engine.EncryptOAuthToken(&decrypted) | ||
if err != nil { | ||
return 0, tokenError(token.ID, err) | ||
} | ||
|
||
// update DB | ||
serialized, err := encrypted.Serialize() | ||
if err != nil { | ||
return 0, tokenError(token.ID, err) | ||
} | ||
|
||
zerolog.Ctx(ctx). | ||
Debug(). | ||
Msgf("updating provider token %d", token.ID) | ||
|
||
err = store.UpdateEncryptedSecret(ctx, db.UpdateEncryptedSecretParams{ | ||
ID: token.ID, | ||
Secret: serialized, | ||
}) | ||
if err != nil { | ||
return 0, tokenError(token.ID, err) | ||
} | ||
} | ||
|
||
return int64(len(batch)), nil | ||
} | ||
|
||
func tokenError(tokenID int32, err error) error { | ||
return fmt.Errorf("unable to re-encrypt provider token %d: %s", tokenID, err) | ||
} | ||
|
||
func init() { | ||
encryptionCmd.AddCommand(rotateCmd) | ||
} |
Oops, something went wrong.