-
Notifications
You must be signed in to change notification settings - Fork 248
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add flyctl commands for managing secrets that are kms keys (#3901)
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
Showing
10 changed files
with
688 additions
and
0 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
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,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 | ||
} |
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,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 | ||
} |
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,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 | ||
} |
Oops, something went wrong.