From 5dd424c9a58013fd798f8c99791bc4472136d710 Mon Sep 17 00:00:00 2001 From: Andreas Auernhammer Date: Thu, 6 Jun 2024 16:13:27 +0200 Subject: [PATCH] cmd/kes: add support for migrating keys to minkms This commit adds support for migrating keys to minkms via the `kes migrate` command. Migrating all keys of a KES backend to a MinKMS server can be done as following: ``` kes migrate --from src-config.yml --server 127.0.0.1:7373 --enclave minio --api-key k1:... ``` Currently, this implementation has the following limitations: - The HMAC key is not migrated. This requires support from MinKMS. However, HMAC keys are not used for S3 object encryption and have been added to KES recently. - Ciphertexts produced by KES cannot be decrypted auto. because they lack the key version prefix (e.g. 'v1:'). Future KES servers may use ciphertexts with key versions and MinKMS may accept a ciphertext without one. Signed-off-by: Andreas Auernhammer --- cmd/kes/main.go | 2 +- cmd/kes/migrate.go | 352 +++++++++++++++++++++-------------------- go.mod | 3 +- go.sum | 4 +- internal/cli/exit.go | 14 ++ internal/crypto/key.go | 6 + 6 files changed, 205 insertions(+), 176 deletions(-) diff --git a/cmd/kes/main.go b/cmd/kes/main.go index 0801dc86..97fc5814 100644 --- a/cmd/kes/main.go +++ b/cmd/kes/main.go @@ -71,7 +71,7 @@ func main() { "status": statusCmd, "metric": metricCmd, - "migrate": migrateCmd, + "migrate": migrate, "update": updateCmd, } diff --git a/cmd/kes/migrate.go b/cmd/kes/migrate.go index 95d155e5..646e5f24 100644 --- a/cmd/kes/migrate.go +++ b/cmd/kes/migrate.go @@ -6,6 +6,7 @@ package main import ( "context" + "crypto/tls" "errors" "fmt" "io" @@ -15,243 +16,252 @@ import ( "sync/atomic" "time" - "github.com/fatih/color" "github.com/minio/kes/internal/cli" + "github.com/minio/kes/internal/crypto" "github.com/minio/kes/kesconf" "github.com/minio/kms-go/kes" + "github.com/minio/kms-go/kms" flag "github.com/spf13/pflag" - "golang.org/x/term" ) -const migrateCmdUsage = `Usage: - kes migrate [options] [] +const migrateUsage = `Usage: + kes migrate [-f] [--merge] [--from FILE] [--to FILE] [PATTERN] + kes migrate [-k] [-f] [--merge] [--from FILE] [--s HOST] [-e ENCLAVE] + [-a KEY] [PATTERN] Options: - --from Path to the KES config file of the migration source. - --to Path to the KES config file of the migration target. + --from Path to source KES config file. + --to Path to target KES config file. + + -s, --server HOST KMS server endpoint to which keys are migrated. + Defaults to the value of $MINIO_KMS_SERVER + -e, --enclave ENCLAVE KMS enclave endpoint to which keys are migrated. + Defaults to the value of $MINIO_KMS_ENCLAVE + -a, --api-key KEY KMS API key used to authenticate to the KMS server. + Defaults to the value of $MINIO_KMS_API_KEY + -k, --insecure Skip KMS server certificate verification. -f, --force Migrate keys even if a key with the same name exists at the target. The existing keys will be deleted. --merge Merge the source into the target by only migrating those keys that do not exist at the target. - - -q, --quiet Do not print progress information. - -h, --help Print command line options. - -Examples: - $ kes migrate --from vault-config.yml --to aws-config.yml ` -func migrateCmd(args []string) { - cmd := flag.NewFlagSet(args[0], flag.ContinueOnError) - cmd.Usage = func() { fmt.Fprint(os.Stderr, migrateCmdUsage) } - +func migrate(args []string) { var ( - fromPath string - toPath string - force bool - merge bool - quietFlag bool + insecureSkipVerify bool + force bool + merge bool + fromPath string + toPath string + kmsServer string + kmsEnclave string + kmsAPIKey string ) - cmd.StringVar(&fromPath, "from", "", "Path to the config file of the migration source") - cmd.StringVar(&toPath, "to", "", "Path to the config file of the migration target") - cmd.BoolVarP(&force, "force", "f", false, "Overwrite existing keys at the migration target") - cmd.BoolVar(&merge, "merge", false, "Only migrate keys that don't exist at the migration target") - cmd.BoolVarP(&quietFlag, "quiet", "q", false, "Do not print progress information") - if err := cmd.Parse(args[1:]); err != nil { + + flags := flag.NewFlagSet(args[0], flag.ContinueOnError) + flags.Usage = func() { fmt.Fprint(os.Stderr, migrateUsage) } + + flags.BoolVarP(&insecureSkipVerify, "insecure", "k", false, "") + flags.BoolVarP(&force, "force", "f", false, "") + flags.BoolVar(&merge, "merge", false, "") + flags.StringVar(&fromPath, "from", "", "") + flags.StringVar(&toPath, "to", "", "") + flags.StringVarP(&kmsServer, "server", "s", cli.Env("MINIO_KMS_SERVER"), "") + flags.StringVarP(&kmsEnclave, "enclave", "e", cli.Env("MINIO_KMS_ENCLAVE"), "") + flags.StringVarP(&kmsAPIKey, "api-key", "a", cli.Env("MINIO_KMS_API_KEY"), "") + if err := flags.Parse(args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { os.Exit(2) } cli.Fatalf("%v. See 'kes migrate --help'", err) } - if cmd.NArg() > 1 { - cli.Fatal("too many arguments. See 'kes migrate --help'") + + cli.Assert(flags.NArg() <= 1, "too many arguments") + cli.Assert(fromPath != "", "no source specified. Use '--from' flag") + if flags.Changed("server") { + cli.Assert(toPath == "", "cannot use '-s / --server' and '--to' flag") } - if fromPath == "" { - cli.Fatal("no migration source specified. Use '--from' to specify a config file") + if flags.Changed("enclave") { + cli.Assert(toPath == "", "cannot use '-e / --enclave' and '--to' flag") } - if toPath == "" { - cli.Fatal("no migration target specified. Use '--to' to specify a config file") + if flags.Changed("api-key") { + cli.Assert(toPath == "", "cannot use '-a / --api-key' and '--to' flag") } - if force && merge { - cli.Fatal("mutually exclusive options '--force' and '--merge' specified") + if toPath != "" { + cli.Assert(!insecureSkipVerify, "cannot use '-k / --insecure' and '--to' flag") + } + if toPath == "" { + cli.Assert(kmsServer != "", "missing migration target. Use '--to' or '--server'") + cli.Assert(kmsEnclave != "", "no KMS enclave specified. Use '--enclave'") + cli.Assert(kmsAPIKey != "", "no KMS API key specified. Use '--api-key'") } + cli.Assert(!(force && merge), "'--force' and '--merge' flags are mutually exclusive") - quiet := quiet(quietFlag) - pattern := cmd.Arg(0) + pattern := flags.Arg(0) if pattern == "" { pattern = "*" } - ctx, cancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt) defer cancel() - sourceConfig, err := kesconf.ReadFile(fromPath) - if err != nil { - cli.Fatalf("failed to read '--from' config file: %v", err) - } + srcConf, err := kesconf.ReadFile(fromPath) + cli.Assert(err == nil, err) - targetConfig, err := kesconf.ReadFile(toPath) - if err != nil { - cli.Fatalf("failed to read '--to' config file: %v", err) - } + src, err := srcConf.KeyStore.Connect(ctx) + cli.Assert(err == nil, err) - src, err := sourceConfig.KeyStore.Connect(ctx) - if err != nil { - cli.Fatal(err) - } - dst, err := targetConfig.KeyStore.Connect(ctx) - if err != nil { - cli.Fatal(err) + iter := &kes.ListIter[string]{ + NextFunc: src.List, } - var ( - n uint64 - uiTicker = time.NewTicker(100 * time.Millisecond) - ) - defer uiTicker.Stop() + // Migrate from one KES backend (--from) to another one (--to). + if toPath != "" { + dstConf, err := kesconf.ReadFile(toPath) + cli.Assert(err == nil, err) + + dst, err := dstConf.KeyStore.Connect(ctx) + cli.Assert(err == nil, err) + + var ( + count atomic.Uint64 + ticker = time.NewTicker(1 * time.Second) + ) + fmt.Println("Starting key migration:") + fmt.Println() + go func() { + for { + select { + case <-ticker.C: + if n := count.Load(); n <= 1 { + fmt.Printf("Migrated %6d key ...\n", n) + } else { + fmt.Printf("Migrated %6d keys ...\n", n) + } + case <-ctx.Done(): + return + } + } + }() - // Now, we start listing the keys at the source. - iterator := &kes.ListIter[string]{ - NextFunc: src.List, + for { + name, err := iter.Next(ctx) + if err == io.EOF { + break + } + cli.Assert(err == nil, err) + + if ok, _ := filepath.Match(pattern, name); !ok { + continue + } + + key, err := src.Get(ctx, name) + cli.Assert(err == nil, err) + + err = dst.Create(ctx, name, key) + if merge && errors.Is(err, kes.ErrKeyExists) { + continue // Do not increment the counter since we skip this key + } + if force && errors.Is(err, kes.ErrKeyExists) { // Try to overwrite the key + if err = dst.Delete(ctx, name); err != nil { + cli.Assert(err == nil, err) + } + err = dst.Create(ctx, name, key) + } + cli.Assert(err == nil, err) + count.Add(1) + } + ticker.Stop() + + if n := count.Load(); n == 0 { + fmt.Println("Migration succeeded! No keys migrated.") + } else { + fmt.Printf("Migrated %6d keys successfully!\n", count.Load()) + } + return } - // Then, we start the UI which prints how many keys have - // been migrated in fixed time intervals. + // Migrate from a KES backend (--from) to a KMS server (-s / --server). + apiKey, err := kms.ParseAPIKey(kmsAPIKey) + cli.Assert(err == nil, err) + + client, err := kms.NewClient(&kms.Config{ + Endpoints: []string{kmsServer}, + APIKey: apiKey, + TLS: &tls.Config{ + InsecureSkipVerify: insecureSkipVerify, + }, + }) + cli.Assert(err == nil, err) + + var ( + count atomic.Uint64 + ticker = time.NewTicker(1 * time.Second) + ) + fmt.Println("Starting key migration:") + fmt.Println() go func() { for { select { - case <-uiTicker.C: - msg := fmt.Sprintf("Migrated keys: %d", atomic.LoadUint64(&n)) - quiet.ClearMessage(msg) - quiet.Print(msg) + case <-ticker.C: + if n := count.Load(); n <= 1 { + fmt.Printf("Migrated %6d key ...\n", n) + } else { + fmt.Printf("Migrated %6d keys ...\n", n) + } case <-ctx.Done(): return } } }() - // Finally, we start the actual migration. for { - name, err := iterator.Next(ctx) + name, err := iter.Next(ctx) if err == io.EOF { break } - if err != nil { - quiet.ClearLine() - cli.Fatalf("failed to migrate %q: %v\nMigrated keys: %d", name, err, atomic.LoadUint64(&n)) - } + cli.Assert(err == nil, err) if ok, _ := filepath.Match(pattern, name); !ok { continue } - key, err := src.Get(ctx, name) - if err != nil { - quiet.ClearLine() - cli.Fatalf("failed to migrate %q: %v\nMigrated keys: %d", name, err, atomic.LoadUint64(&n)) - } + b, err := src.Get(ctx, name) + cli.Assert(err == nil, err) - err = dst.Create(ctx, name, key) - if merge && errors.Is(err, kes.ErrKeyExists) { + key, err := crypto.ParseKeyVersion(b) + cli.Assert(err == nil, err) + + err = client.ImportKey(ctx, &kms.ImportKeyRequest{ + Enclave: kmsEnclave, + Name: name, + Type: kms.SecretKeyType(key.Key.Type()), + Key: key.Key.Bytes(), + }) + if merge && errors.Is(err, kms.ErrKeyExists) { continue // Do not increment the counter since we skip this key } - if force && errors.Is(err, kes.ErrKeyExists) { // Try to overwrite the key - if err = dst.Delete(ctx, name); err != nil { - quiet.ClearLine() - cli.Fatalf("failed to migrate %q: %v\nMigrated keys: %d", name, err, atomic.LoadUint64(&n)) + if force && errors.Is(err, kms.ErrKeyExists) { // Try to overwrite the key + if err = client.DeleteKey(ctx, &kms.DeleteKeyRequest{Enclave: kmsEnclave, Name: name, AllVersions: true}); err != nil { + cli.Assert(err == nil, err) } - err = dst.Create(ctx, name, key) + err = client.ImportKey(ctx, &kms.ImportKeyRequest{ + Enclave: kmsEnclave, + Name: name, + Type: kms.SecretKeyType(key.Key.Type()), + Key: key.Key.Bytes(), + // TODO(aead): migrate HMAC key as well + }) } - if err != nil { - quiet.ClearLine() - cli.Fatalf("failed to migrate %q: %v\nMigrated keys: %d", name, err, atomic.LoadUint64(&n)) - } - atomic.AddUint64(&n, 1) - } - cancel() - - // At the end we show how many keys we have migrated successfully. - msg := fmt.Sprintf("Migrated keys: %d ", atomic.LoadUint64(&n)) - quiet.ClearMessage(msg) - quiet.Println(msg) -} - -// quiet is a boolean flag.Value that can print -// to STDOUT. -// -// If quiet is set to true then all quiet.Print* -// calls become no-ops and no output is printed to -// STDOUT. -type quiet bool - -// Print behaves as fmt.Print if quiet is false. -// Otherwise, Print does nothing. -func (q quiet) Print(a ...any) { - if !q { - fmt.Print(a...) - } -} - -// Printf behaves as fmt.Printf if quiet is false. -// Otherwise, Printf does nothing. -func (q quiet) Printf(format string, a ...any) { - if !q { - fmt.Printf(format, a...) - } -} - -// Println behaves as fmt.Println if quiet is false. -// Otherwise, Println does nothing. -func (q quiet) Println(a ...any) { - if !q { - fmt.Println(a...) + cli.Assert(err == nil, err) + count.Add(1) } -} + ticker.Stop() -// ClearLine clears the last line written to STDOUT if -// STDOUT is a terminal that supports terminal control -// sequences. -// -// Otherwise, ClearLine just prints a empty newline. -func (q quiet) ClearLine() { - if color.NoColor { - q.Println() + if n := count.Load(); n == 0 { + fmt.Println("Migration succeeded! No keys migrated.") } else { - q.Print(eraseLine) - } -} - -const ( - eraseLine = "\033[2K\r" - moveUp = "\033[1A" -) - -// ClearMessage tries to erase the given message from STDOUT -// if STDOUT is a terminal that supports terminal control sequences. -// -// Otherwise, ClearMessage just prints an empty newline. -func (q quiet) ClearMessage(msg string) { - if color.NoColor { - q.Println() - return - } - - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { // If we cannot get the width, just erasure one line - q.Print(eraseLine) - return - } - - // Erase and move up one line as long as the message is not empty. - for len(msg) > 0 { - q.Print(eraseLine) - - if len(msg) < width { - break - } - q.Print(moveUp) - msg = msg[width:] + fmt.Printf("Migrated %6d keys successfully!\n", count.Load()) } } diff --git a/go.mod b/go.mod index 593a747c..4671d292 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,9 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.1.0 github.com/aws/aws-sdk-go v1.52.1 github.com/charmbracelet/lipgloss v0.10.0 - github.com/fatih/color v1.16.0 github.com/hashicorp/vault/api v1.13.0 github.com/minio/kms-go/kes v0.3.1-0.20240226133855-0dfed1a72132 + github.com/minio/kms-go/kms v0.4.0 github.com/minio/selfupdate v0.6.0 github.com/muesli/termenv v0.15.2 github.com/prometheus/client_golang v1.19.0 @@ -64,7 +64,6 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect diff --git a/go.sum b/go.sum index e1ffd108..fe01bd92 100644 --- a/go.sum +++ b/go.sum @@ -145,7 +145,6 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -153,6 +152,8 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/minio/kms-go/kes v0.3.1-0.20240226133855-0dfed1a72132 h1:0J9XIk73q+EaZ7hR0XC6ZZ4hXLeqlvwGrZjQbrN1k4o= github.com/minio/kms-go/kes v0.3.1-0.20240226133855-0dfed1a72132/go.mod h1:w6DeVT878qEOU3nUrYVy1WOT5H1Ig9hbDIh698NYJKY= +github.com/minio/kms-go/kms v0.4.0 h1:cLPZceEp+05xHotVBaeFJrgL7JcXM4lBy6PU0idkE7I= +github.com/minio/kms-go/kms v0.4.0/go.mod h1:q12CehiIy2qgBnDKq6Q7wmPi2PHSyRVug5DKp0HAVeE= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -258,7 +259,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= diff --git a/internal/cli/exit.go b/internal/cli/exit.go index ac2b5186..ca4485b6 100644 --- a/internal/cli/exit.go +++ b/internal/cli/exit.go @@ -28,3 +28,17 @@ func Exitf(format string, args ...any) { fmt.Fprintln(os.Stderr, s+fmt.Sprintf(format, args...)) os.Exit(1) } + +// Assertf calls Exit if the statement is false. +func Assert(statement bool, args ...any) { + if !statement { + Exit(args...) + } +} + +// Assertf calls Exitf if the statement is false. +func Assertf(statement bool, format string, args ...any) { + if !statement { + Exitf(format, args...) + } +} diff --git a/internal/crypto/key.go b/internal/crypto/key.go index 856ed228..a6b33d9c 100644 --- a/internal/crypto/key.go +++ b/internal/crypto/key.go @@ -249,6 +249,12 @@ func (s SecretKey) Type() SecretKeyType { return s.cipher } // and its ciphertext. func (s SecretKey) Overhead() int { return randSize + 16 } +// Bytes returns the raw key bytes. +func (s SecretKey) Bytes() []byte { + b := make([]byte, 0, len(s.key)) + return append(b, s.key[:]...) +} + // Encrypt encrypts and authenticates the plaintext and // authenticates the associatedData. //