diff --git a/cmd/kes/autocomplete.go b/cmd/kes/autocomplete.go index 6f72f47b..227543fe 100644 --- a/cmd/kes/autocomplete.go +++ b/cmd/kes/autocomplete.go @@ -110,7 +110,7 @@ func installAutoCompletion() { home, err := os.UserHomeDir() if err != nil { - cli.Fatalf("failed to detect home directory: %v") + cli.Fatalf("failed to detect home directory: %v", home) } if home == "" { home = "~" @@ -119,11 +119,11 @@ func installAutoCompletion() { binaryPath, err := os.Executable() if err != nil { - cli.Fatalf("failed to detect binary path: %v") + cli.Fatalf("failed to detect binary path: %v", err) } binaryPath, err = filepath.Abs(binaryPath) if err != nil { - cli.Fatalf("failed to turn binary path into an absolute path: %v") + cli.Fatalf("failed to turn binary path into an absolute path: %v", err) } var ( diff --git a/cmd/kes/identity.go b/cmd/kes/identity.go index c718b5fa..469def61 100644 --- a/cmd/kes/identity.go +++ b/cmd/kes/identity.go @@ -7,9 +7,11 @@ package main import ( "bytes" "context" + "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" @@ -27,12 +29,14 @@ import ( "github.com/minio/kes/internal/cli" "github.com/minio/kes/internal/https" "github.com/minio/kms-go/kes" + sdk "github.com/minio/kms-go/kes" flag "github.com/spf13/pflag" "golang.org/x/term" ) const identityCmdUsage = `Usage: kes identity + kes identity [KEY | FILE] Commands: new Create a new KES identity. @@ -45,36 +49,134 @@ Options: ` func identityCmd(args []string) { - cmd := flag.NewFlagSet(args[0], flag.ContinueOnError) - cmd.Usage = func() { fmt.Fprint(os.Stderr, identityCmdUsage) } + if len(args) >= 2 { + subCmds := commands{ + "new": newIdentityCmd, + "of": ofIdentityCmd, + "info": infoIdentityCmd, + "ls": lsIdentityCmd, + } + if cmd, ok := subCmds[args[1]]; ok { + cmd(args[1:]) + return + } + } - subCmds := commands{ - "new": newIdentityCmd, - "of": ofIdentityCmd, - "info": infoIdentityCmd, - "ls": lsIdentityCmd, + flags := flag.NewFlagSet(args[0], flag.ContinueOnError) + flags.Usage = func() { fmt.Fprint(os.Stderr, identityCmdUsage) } + if err := flags.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(2) + } + cli.Exit(err) } - if len(args) < 2 { - cmd.Usage() - os.Exit(2) + if flags.NArg() > 1 { + cli.Exit("too many arguments") } - if cmd, ok := subCmds[args[1]]; ok { - cmd(args[1:]) + + if flags.NArg() == 0 { + key, err := sdk.GenerateAPIKey(nil) + if err != nil { + cli.Exitf("failed to generate API key: %v", err) + } + + if !cli.IsTerminal() { + fmt.Print(key) + return + } + + buf := &strings.Builder{} + fmt.Fprintln(buf, "Your API key:") + fmt.Fprintln(buf) + fmt.Fprintln(buf, " ", tui.NewStyle().Bold(true).Render(key.String())) + fmt.Fprintln(buf) + fmt.Fprintln(buf, "This is the only time it is shown. Keep it secret and secure!") + fmt.Fprintln(buf) + fmt.Fprintln(buf, "Your API key's identity:") + fmt.Fprintln(buf) + fmt.Fprintln(buf, " ", tui.NewStyle().Bold(true).Render(key.Identity().String())) + fmt.Fprintln(buf) + fmt.Fprintln(buf, "The identity is not a secret and can be shared securely.") + fmt.Fprintln(buf, "Peers need your identity in order to verify your API key.") + fmt.Fprintln(buf) + fmt.Fprintln(buf, "This identity can be re-computed again via:") + fmt.Fprintln(buf) + fmt.Fprintf(buf, " $ minkms identity %s", key.String()) + fmt.Println(tui.NewStyle().Border(tui.HiddenBorder()).Padding(0, 0, 0, 0).Render(buf.String())) return } - if err := cmd.Parse(args[1:]); err != nil { - if errors.Is(err, flag.ErrHelp) { - os.Exit(2) + printIdentity := func(identity sdk.Identity) { + if !cli.IsTerminal() { + fmt.Print(identity) + return } - cli.Fatalf("%v. See 'kes identity --help'", err) + + buf := &strings.Builder{} + fmt.Fprintln(buf, "Identity:") + fmt.Fprintln(buf) + fmt.Fprintln(buf, " ", tui.NewStyle().Bold(true).Render(identity.String())) + fmt.Fprintln(buf) + fmt.Fprintln(buf, "An identity is a fingerprint of your API key") + fmt.Fprint(buf, "or certificate and can be shared securely.") + fmt.Println(tui.NewStyle().Border(tui.HiddenBorder()).Padding(0, 0, 0, 0).Render(buf.String())) + } + if key, err := sdk.ParseAPIKey(flags.Arg(0)); err == nil { + printIdentity(key.Identity()) + return } - if cmd.NArg() > 0 { - cli.Fatalf("%q is not an identity command. See 'kes identity --help'", cmd.Arg(0)) + + filename := flags.Arg(0) + raw, err := os.ReadFile(filename) + if err != nil { + cli.Exit(err) + } + raw = bytes.TrimSpace(raw) + + var block *pem.Block + for len(raw) > 0 { + if block, raw = pem.Decode(raw); block == nil { + cli.Exitf("'%s' contains no valid PEM certificate or private key", filename) + } + + switch { + case block.Type == "CERTIFICATE": + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + cli.Exit(err) + } + h := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + printIdentity(sdk.Identity(hex.EncodeToString(h[:]))) + return + case strings.Contains(block.Type, "PRIVATE KEY"): // Type may be PRIVATE KEY, EC PRIVATE KEY, ... + priv, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + cli.Exit(err) + } + + key, ok := priv.(ed25519.PrivateKey) + if !ok { + cli.Exitf("'%s' contains unsupported private key. Only ed25519 private keys are supported", filename) + } + apiKey := "kes:v1:" + base64.StdEncoding.EncodeToString(append([]byte{0}, key[:ed25519.SeedSize]...)) + + if !cli.IsTerminal() { + fmt.Print(apiKey) + return + } + buf := &strings.Builder{} + fmt.Fprintln(buf, "Your API key:") + fmt.Fprintln(buf) + fmt.Fprintln(buf, " ", tui.NewStyle().Bold(true).Render(apiKey)) + fmt.Fprintln(buf) + fmt.Fprintf(buf, "It corresponds to the private key in: %s.\n", filename) + fmt.Fprint(buf, "Keep it secret and secure!") + fmt.Println(tui.NewStyle().Border(tui.HiddenBorder()).Padding(0, 0, 0, 0).Render(buf.String())) + return + } } - cmd.Usage() - os.Exit(2) + cli.Exitf("'%s' contains no valid PEM certificate or private key", filename) } const newIdentityCmdUsage = `Usage: diff --git a/internal/cli/env.go b/internal/cli/env.go new file mode 100644 index 00000000..98586466 --- /dev/null +++ b/internal/cli/env.go @@ -0,0 +1,15 @@ +// Copyright 2024 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +package cli + +// Environment variable used by the KES CLI. +const ( + // EnvServer is the server endpoint the client uses. If not set, + // clients will use '127.0.0.1:7373'. + EnvServer = "MINIO_KES_SERVER" + + // EnvAPIKey is used by the client to authenticate to the server. + EnvAPIKey = "MINIO_KES_API_KEY" +) diff --git a/internal/cli/exit.go b/internal/cli/exit.go new file mode 100644 index 00000000..ac2b5186 --- /dev/null +++ b/internal/cli/exit.go @@ -0,0 +1,30 @@ +// Copyright 2024 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +package cli + +import ( + "fmt" + "os" + + tui "github.com/charmbracelet/lipgloss" +) + +// Exit prints args as error message and aborts with exit code 1. +func Exit(args ...any) { + const FG tui.Color = "#ac0000" + s := tui.NewStyle().Foreground(FG).Render("Error: ") + + fmt.Fprintln(os.Stderr, s+fmt.Sprint(args...)) + os.Exit(1) +} + +// Exitf formats args as error message and aborts with exit code 1. +func Exitf(format string, args ...any) { + const FG tui.Color = "#ac0000" + s := tui.NewStyle().Foreground(FG).Render("Error: ") + + fmt.Fprintln(os.Stderr, s+fmt.Sprintf(format, args...)) + os.Exit(1) +} diff --git a/internal/cli/fmt.go b/internal/cli/fmt.go index 2149c9a3..8e44ce65 100644 --- a/internal/cli/fmt.go +++ b/internal/cli/fmt.go @@ -6,30 +6,17 @@ package cli import ( "fmt" - "os" - - tui "github.com/charmbracelet/lipgloss" ) -var errPrefix = tui.NewStyle().Foreground(tui.Color("#ac0000")).Render("Error: ") - // Fatal writes an error prefix and the operands // to OS stderr. Then, Fatal terminates the program by // calling os.Exit(1). -func Fatal(v ...any) { - fmt.Fprint(os.Stderr, errPrefix) - fmt.Fprint(os.Stderr, v...) - fmt.Fprintln(os.Stderr) - os.Exit(1) -} +func Fatal(v ...any) { Exit(v...) } // Fatalf writes an error prefix and the operands, // formatted according to the format specifier, to OS stderr. // Then, Fatalf terminates the program by calling os.Exit(1). -func Fatalf(format string, v ...any) { - fmt.Fprintf(os.Stderr, errPrefix+format+"\n", v...) - os.Exit(1) -} +func Fatalf(format string, v ...any) { Exitf(format, v...) } // Print formats using the default formats for its operands and // writes to standard output. Spaces are added between operands diff --git a/internal/cli/init.go b/internal/cli/init.go deleted file mode 100644 index 5b1028e4..00000000 --- a/internal/cli/init.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2022 - MinIO, Inc. All rights reserved. -// Use of this source code is governed by the AGPLv3 -// license that can be found in the LICENSE file. - -package cli - -import ( - "errors" - "strconv" - - "gopkg.in/yaml.v3" -) - -// findVersion finds the version field in the -// the given YAML document AST. -// -// It returns an error if the top level of the -// AST does not contain a version field. -func findVersion(root *yaml.Node) (string, error) { - if root == nil { - return "", errors.New("cli: invalid init config: root not found") - } - if root.Kind != yaml.DocumentNode { - return "", errors.New("cli: invalid init config: not document node") - } - if len(root.Content) != 1 { - return "", errors.New("cli: invalid init config: none or several root nodes") - } - - doc := root.Content[0] - for i, n := range doc.Content { - if n.Value == "version" { - if n.Kind != yaml.ScalarNode { - return "", errors.New("cli: invalid init config version at line " + strconv.Itoa(n.Line)) - } - if i == len(doc.Content)-1 { - return "", errors.New("cli: invalid init config version at line " + strconv.Itoa(n.Line)) - } - v := doc.Content[i+1] - if v.Kind != yaml.ScalarNode { - return "", errors.New("cli: invalid init config version at line " + strconv.Itoa(v.Line)) - } - return v.Value, nil - } - } - return "", errors.New("cli: invalid init config: missing 'version' field") -} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go deleted file mode 100644 index 0d92dce8..00000000 --- a/internal/cli/init_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2022 - MinIO, Inc. All rights reserved. -// Use of this source code is governed by the AGPLv3 -// license that can be found in the LICENSE file. - -package cli - -import ( - "testing" - - "gopkg.in/yaml.v3" -) - -func TestFindVersion(t *testing.T) { - for i, test := range findVersionsTests { - version, err := findVersion(test.Root) - if err == nil && test.ShouldFail { - t.Fatalf("Test %d should fail but passed", i) - } - if err != nil && !test.ShouldFail { - t.Fatalf("Test %d: failed to find version: %v", i, err) - } - if !test.ShouldFail && version != test.Version { - t.Fatalf("Test %d: got '%s' - want '%s'", i, version, test.Version) - } - } -} - -var findVersionsTests = []struct { - Version string - Root *yaml.Node - ShouldFail bool -}{ - { // 0 - Document tree with a "version" node - Version: "v1", - Root: &yaml.Node{ - Kind: yaml.DocumentNode, - Content: []*yaml.Node{ - {Content: []*yaml.Node{ - { - Kind: yaml.ScalarNode, - Value: "version", - }, - { - Kind: yaml.ScalarNode, - Value: "v1", - }, - }}, - }, - }, - }, - - { // 1 - Root: nil, - ShouldFail: true, - }, - { // 2 - Root: &yaml.Node{Kind: yaml.ScalarNode}, - ShouldFail: true, - }, - { // 3 - Root: &yaml.Node{Kind: yaml.DocumentNode}, - ShouldFail: true, - }, - { // 4 - Root: &yaml.Node{Kind: yaml.DocumentNode, Content: make([]*yaml.Node, 2)}, - ShouldFail: true, - }, - { // 5 - Root: &yaml.Node{ - Kind: yaml.DocumentNode, - Content: []*yaml.Node{ - {Content: []*yaml.Node{ - { - Kind: yaml.DocumentNode, - Value: "version", - }, - }}, - }, - }, - ShouldFail: true, - }, - { // 6 - Root: &yaml.Node{ - Kind: yaml.DocumentNode, - Content: []*yaml.Node{ - {Content: []*yaml.Node{ - { - Kind: yaml.ScalarNode, - Value: "version", - }, - }}, - }, - }, - ShouldFail: true, - }, -} diff --git a/internal/cli/term.go b/internal/cli/term.go new file mode 100644 index 00000000..c640fb35 --- /dev/null +++ b/internal/cli/term.go @@ -0,0 +1,26 @@ +// Copyright 2024 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +package cli + +import ( + "os" + + tui "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +var isTerm = term.IsTerminal(int(os.Stdout.Fd())) || term.IsTerminal(int(os.Stderr.Fd())) + +// IsTerminal reports whether stdout is a terminal. +func IsTerminal() bool { return isTerm } + +// Fg returns a new style with the given foreground +// color. All strings s are rendered with the style. +// For example: +// +// fmt.Println(cli.Fg(tui.ANSIColor(2), "Hello World")) +func Fg(c tui.TerminalColor, s ...string) tui.Style { + return tui.NewStyle().Foreground(c).SetString(s...) +}