Skip to content

Commit

Permalink
Implement process for migrating keys and algorithms
Browse files Browse the repository at this point in the history
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
dmjb committed May 27, 2024
1 parent 64a22e8 commit ca6e2e8
Show file tree
Hide file tree
Showing 12 changed files with 527 additions and 62 deletions.
71 changes: 71 additions & 0 deletions cmd/server/app/common.go
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)
}
34 changes: 34 additions & 0 deletions cmd/server/app/encryption.go
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")
}
255 changes: 255 additions & 0 deletions cmd/server/app/encryption_rotate.go
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

View workflow job for this annotation

GitHub Actions / lint / Go Lint

printf: github.com/stacklok/minder/cmd/server/app.cliErrorf does not support error-wrapping directive %w (govet)

Check failure on line 50 in cmd/server/app/encryption_rotate.go

View workflow job for this annotation

GitHub Actions / test / Coverage

github.com/stacklok/minder/cmd/server/app.cliErrorf does not support error-wrapping directive %w

Check failure on line 50 in cmd/server/app/encryption_rotate.go

View workflow job for this annotation

GitHub Actions / test / Unit testing

github.com/stacklok/minder/cmd/server/app.cliErrorf does not support error-wrapping directive %w
}

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)
}
Loading

0 comments on commit ca6e2e8

Please sign in to comment.