Skip to content

Commit

Permalink
Add flyctl commands for managing secrets that are kms keys (#3901)
Browse files Browse the repository at this point in the history
Adds `flyctl secrets keys` with `ls`, `gen` and `rm` commands for managing KMS keys. Keys are generated randomly with a semantic type of `encrypting` or `signing`. Keys are versioned, and versioning is usually automatic, but can be specified explicitly. Adding new versions to a key label requires that the new key semantic type match the existing semantic types. Deletion can be done with an explicit key version, or across all versions for a label, and will prompt for confirmation unless overridden with a force flag. Key management is done through a flaps API.
  • Loading branch information
timflyio committed Sep 14, 2024
1 parent 4ff61fe commit 76d027c
Show file tree
Hide file tree
Showing 10 changed files with 688 additions and 0 deletions.
16 changes: 16 additions & 0 deletions internal/command/deploy/mock_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ func (m *mockFlapsClient) CreateApp(ctx context.Context, name string, org string
return fmt.Errorf("failed to create app %s", name)
}

func (m *mockFlapsClient) CreateSecret(ctx context.Context, sLabel, sType string, in fly.CreateSecretRequest) (err error) {
return fmt.Errorf("failed to create secret %s", sLabel)
}

func (m *mockFlapsClient) CreateVolume(ctx context.Context, req fly.CreateVolumeRequest) (*fly.Volume, error) {
return nil, fmt.Errorf("failed to create volume %s", req.Name)
}
Expand All @@ -47,6 +51,10 @@ func (m *mockFlapsClient) DeleteMetadata(ctx context.Context, machineID, key str
return fmt.Errorf("failed to delete metadata %s", key)
}

func (m *mockFlapsClient) DeleteSecret(ctx context.Context, label string) (err error) {
return fmt.Errorf("failed to delete secret %s", label)
}

func (m *mockFlapsClient) DeleteVolume(ctx context.Context, volumeId string) (*fly.Volume, error) {
return nil, fmt.Errorf("failed to delete volume %s", volumeId)
}
Expand All @@ -67,6 +75,10 @@ func (m *mockFlapsClient) FindLease(ctx context.Context, machineID string) (*fly
return nil, fmt.Errorf("failed to find lease for %s", machineID)
}

func (m *mockFlapsClient) GenerateSecret(ctx context.Context, sLabel, sType string) (err error) {
return fmt.Errorf("failed to generate secret %s", sLabel)
}

func (m *mockFlapsClient) Get(ctx context.Context, machineID string) (*fly.Machine, error) {
return nil, fmt.Errorf("failed to get %s", machineID)
}
Expand Down Expand Up @@ -122,6 +134,10 @@ func (m *mockFlapsClient) ListFlyAppsMachines(ctx context.Context) ([]*fly.Machi
return nil, nil, fmt.Errorf("failed to list fly apps machines")
}

func (m *mockFlapsClient) ListSecrets(ctx context.Context) (out []fly.ListSecret, err error) {
return nil, fmt.Errorf("failed to list secrets")
}

func (m *mockFlapsClient) NewRequest(ctx context.Context, method, path string, in interface{}, headers map[string][]string) (*http.Request, error) {
return nil, fmt.Errorf("failed to create request")
}
Expand Down
112 changes: 112 additions & 0 deletions internal/command/secrets/key_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package secrets

import (
"context"
"errors"
"fmt"

"github.com/spf13/cobra"
"github.com/superfly/fly-go/flaps"
"github.com/superfly/flyctl/internal/command"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/internal/prompt"
"github.com/superfly/flyctl/iostreams"
)

func newKeyDelete() (cmd *cobra.Command) {
const (
long = `Delete the application key secret by label.`
short = `Delete the application key secret`
usage = "delete [flags] label"
)

cmd = command.New(usage, short, long, runKeyDelete, command.RequireSession, command.RequireAppName)

cmd.Aliases = []string{"rm"}

flag.Add(cmd,
flag.App(),
flag.AppConfig(),
flag.Bool{
Name: "force",
Shorthand: "f",
Description: "Force deletion without prompting",
},
flag.Bool{
Name: "noversion",
Shorthand: "n",
Default: false,
Description: "do not automatically match all versions of a key when version is unspecified. all matches must be explicit",
},
)

cmd.Args = cobra.ExactArgs(1)

return cmd
}

func runKeyDelete(ctx context.Context) (err error) {
label := flag.Args(ctx)[0]
ver, prefix, err := SplitLabelKeyver(label)
if err != nil {
return err
}

flapsClient, err := getFlapsClient(ctx)
if err != nil {
return err
}

secrets, err := flapsClient.ListSecrets(ctx)
if err != nil {
return err
}

// Delete all matching secrets, prompting if necessary.
var rerr error
out := iostreams.FromContext(ctx).Out
for _, secret := range secrets {
ver2, prefix2, err := SplitLabelKeyver(secret.Label)
if err != nil {
continue
}
if prefix != prefix2 {
continue
}

if ver != ver2 {
// Subtle: If the `noversion` flag was specified, then we must have
// an exact match. Otherwise if version is unspecified, we
// match all secrets with the same version regardless of version.
if flag.GetBool(ctx, "noversion") {
continue
}
if ver != KeyverUnspec {
continue
}
}

if !flag.GetBool(ctx, "force") {
confirm, err := prompt.Confirm(ctx, fmt.Sprintf("delete secrets key %s?", secret.Label))
if err != nil {
rerr = errors.Join(rerr, err)
continue
}
if !confirm {
continue
}
}

err = flapsClient.DeleteSecret(ctx, secret.Label)
if err != nil {
var ferr *flaps.FlapsError
if errors.As(err, &ferr) && ferr.ResponseStatusCode == 404 {
err = fmt.Errorf("not found")
}
rerr = errors.Join(rerr, fmt.Errorf("deleting %v: %w", secret.Label, err))
} else {
fmt.Fprintf(out, "Deleted %v\n", secret.Label)
}
}
return rerr
}
147 changes: 147 additions & 0 deletions internal/command/secrets/key_set.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package secrets

import (
"context"
"encoding/base64"
"fmt"

"github.com/spf13/cobra"
fly "github.com/superfly/fly-go"
"github.com/superfly/flyctl/internal/command"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/iostreams"
)

func newKeyGenerate() (cmd *cobra.Command) {
const (
long = `Generate a random application key secret. If the label is not fully qualified
with a version, and a secret with the same label already exists, the label will be
updated to include the next version number.`
short = `Generate the application key secret`
usage = "generate [flags] type label"
)

cmd = command.New(usage, short, long, runKeySetOrGenerate, command.RequireSession, command.RequireAppName)

flag.Add(cmd,
flag.App(),
flag.AppConfig(),
flag.Bool{
Name: "force",
Shorthand: "f",
Description: "Force overwriting existing values",
},
flag.Bool{
Name: "noversion",
Shorthand: "n",
Default: false,
Description: "do not automatically version the key label",
},
flag.Bool{
Name: "quiet",
Shorthand: "q",
Description: "Don't print key label",
},
)

cmd.Aliases = []string{"gen"}
cmd.Args = cobra.ExactArgs(2)

return cmd
}

// runKeySetOrGenerate handles both `keys set typ label value` and
// `keys generate typ label`. The sole difference is whether a `value`
// arg is present or not.
func runKeySetOrGenerate(ctx context.Context) (err error) {
out := iostreams.FromContext(ctx).Out
args := flag.Args(ctx)
semType := SemanticType(args[0])
label := args[1]
val := []byte{}

ver, prefix, err := SplitLabelKeyver(label)
if err != nil {
return err
}

typ, err := SemanticTypeToSecretType(semType)
if err != nil {
return err
}

gen := true
if len(args) > 2 {
gen = false
val, err = base64.StdEncoding.DecodeString(args[2])
if err != nil {
return fmt.Errorf("bad value encoding: %w", err)
}
}

flapsClient, err := getFlapsClient(ctx)
if err != nil {
return err
}

secrets, err := flapsClient.ListSecrets(ctx)
if err != nil {
return err
}

// Verify consistency with existing keys with the same prefix
// while finding the highest version with the same prefix.
bestVer := KeyverUnspec
for _, secret := range secrets {
if label == secret.Label {
if !flag.GetBool(ctx, "force") {
return fmt.Errorf("refusing to overwrite existing key")
}
}

ver2, prefix2, err := SplitLabelKeyver(secret.Label)
if err != nil {
continue
}
if prefix != prefix2 {
continue
}

// The semantic type must be the same as any existing keys with the same label prefix.
semType2, _ := SecretTypeToSemanticType(secret.Type)
if semType2 != semType {
typs := secretTypeToString(secret.Type)
return fmt.Errorf("key %v (%v) has conflicting type %v (%v)", prefix, secret.Label, semType2, typs)
}

if CompareKeyver(ver2, bestVer) > 0 {
bestVer = ver2
}
}

// If the label does not contain an explicit version,
// we will automatically apply a version to the label
// unless the user said not to.
if ver == KeyverUnspec && !flag.GetBool(ctx, "noversion") {
ver, err := bestVer.Incr()
if err != nil {
return err
}
label = JoinLabelVersion(ver, prefix)
}

if !flag.GetBool(ctx, "quiet") {
typs := secretTypeToString(typ)
fmt.Fprintf(out, "Setting %s %s (%s)\n", label, semType, typs)
}

if gen {
err = flapsClient.GenerateSecret(ctx, label, typ)
} else {
err = flapsClient.CreateSecret(ctx, label, typ, fly.CreateSecretRequest{Value: val})
}
if err != nil {
return err
}
return nil
}
75 changes: 75 additions & 0 deletions internal/command/secrets/keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package secrets

import (
"context"
"fmt"
"strings"

"github.com/spf13/cobra"
fly "github.com/superfly/fly-go"
"github.com/superfly/fly-go/flaps"
"github.com/superfly/flyctl/internal/appconfig"
"github.com/superfly/flyctl/internal/command"
"github.com/superfly/flyctl/internal/flapsutil"
"github.com/superfly/flyctl/internal/flyutil"
)

type SecretType = string

const (
SECRET_TYPE_KMS_HS256 = fly.SECRET_TYPE_KMS_HS256
SECRET_TYPE_KMS_HS384 = fly.SECRET_TYPE_KMS_HS384
SECRET_TYPE_KMS_HS512 = fly.SECRET_TYPE_KMS_HS512
SECRET_TYPE_KMS_XAES256GCM = fly.SECRET_TYPE_KMS_XAES256GCM
SECRET_TYPE_KMS_NACL_AUTH = fly.SECRET_TYPE_KMS_NACL_AUTH
SECRET_TYPE_KMS_NACL_BOX = fly.SECRET_TYPE_KMS_NACL_BOX
SECRET_TYPE_KMS_NACL_SECRETBOX = fly.SECRET_TYPE_KMS_NACL_SECRETBOX
SECRET_TYPE_KMS_NACL_SIGN = fly.SECRET_TYPE_KMS_NACL_SIGN
)

func newKeys() *cobra.Command {
const (
long = `Keys are available to applications through the /.fly/kms filesystem. Names are case
sensitive and stored as-is, so ensure names are appropriate as filesystem names.
Names optionally include version information with a "vN" suffix.
`

short = "Manage application key secrets with the gen, list, and delete commands."
)

keys := command.New("keys", short, long, nil)

keys.AddCommand(
newKeysList(),
newKeyGenerate(),
newKeyDelete(),
)

keys.Hidden = true // TODO: unhide when we're ready to go public.

return keys
}

// secretTypeToString converts from a standard sType to flyctl's abbreviated string form.
func secretTypeToString(sType string) string {
return strings.TrimPrefix(strings.ToLower(sType), "secret_type_kms_")
}

// getFlapsClient builds and returns a flaps client for the App from the context.
func getFlapsClient(ctx context.Context) (*flaps.Client, error) {
client := flyutil.ClientFromContext(ctx)
appName := appconfig.NameFromContext(ctx)
app, err := client.GetAppCompact(ctx, appName)
if err != nil {
return nil, fmt.Errorf("get app: %w", err)
}

flapsClient, err := flapsutil.NewClientWithOptions(ctx, flaps.NewClientOpts{
AppCompact: app,
AppName: app.Name,
})
if err != nil {
return nil, fmt.Errorf("could not create flaps client: %w", err)
}
return flapsClient, nil
}
Loading

0 comments on commit 76d027c

Please sign in to comment.