diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 73d1970c..879ddbf5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -557,10 +557,6 @@ const ( secret delete secret list secret set - ssh-key create - ssh-key delete - ssh-key list - ssh-key view status time create time delete @@ -603,7 +599,6 @@ const ( run artifact search secret - ssh-key time variable wiki diff --git a/internal/app/ssh_keys.go b/internal/app/ssh_keys.go index 3bcb2aa8..04299cb0 100644 --- a/internal/app/ssh_keys.go +++ b/internal/app/ssh_keys.go @@ -4,9 +4,11 @@ package app import ( + "bytes" + "context" "errors" "fmt" - "path/filepath" + "os" "strconv" "strings" @@ -18,55 +20,196 @@ import ( ) func (a *application) sshKeyCommand() *cobra.Command { - group := &cobra.Command{Use: "ssh-key", Short: "Manage account SSH public keys"} + group := &cobra.Command{Use: "ssh-key", Short: "Manage SSH keys", Long: "Manage the SSH public keys of your account, which authenticate Git operations over SSH."} + group.AddCommand(a.sshKeyListCommand(), a.sshKeyViewCommand(), a.sshKeyCreateCommand(), a.sshKeyDeleteCommand()) + return group +} + +// sshKeyLabel names a key in messages: `SSH key "laptop" (SHA256:…)`. +func sshKeyLabel(key services.SSHKey) string { + return fmt.Sprintf("SSH key %q (%s)", key.Title, key.Fingerprint) +} + +// completeSSHKeys completes the key IDs of the account with their titles. +func (a *application) completeSSHKeys() cobra.CompletionFunc { + return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + keys, err := (services.SSHKeys{Client: t.client}).List(ctx, completionLimit, 1) + return describe(keys.Items, func(key services.SSHKey) (string, string) { + return strconv.FormatInt(key.ID, 10), key.Title + " (" + key.Fingerprint + ")" + }), err + }) +} + +func (a *application) sshKeyListCommand() *cobra.Command { var limit, page int - list := &cobra.Command{Use: "list", Short: "List account SSH keys", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - target, err := a.resolve(cmd.Context(), false) - if err != nil { - return err - } - items, err := (services.SSHKeys{Client: target.client}).List(cmd.Context(), limit, page) - if err != nil { - return err - } - return writeList(a, cmd, items, sshKeyRow) - }} - pageFlags(list, &limit, &page) - group.AddCommand(list) - var title string - create := &cobra.Command{Use: "create ", Short: "Register an SSH public key; - reads stdin", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - data, err := readFileOrStdin(a.deps.Streams.In, args[0]) - if err != nil { - return fmt.Errorf("read public key: %w", err) - } - _, _, _, rest, err := ssh.ParseAuthorizedKey(data) - if err != nil || len(strings.TrimSpace(string(rest))) != 0 { - return clierr.Wrap(clierr.Usage, errors.New("supply exactly one valid SSH public key")) - } - if title == "" { - if args[0] == "-" { - return clierr.Wrap(clierr.Usage, errors.New("--title is required when reading a key from stdin")) + cmd := &cobra.Command{ + Use: "list", Short: "List SSH keys", Args: argsExact(0), + Example: `$ tea ssh-key list +$ tea ssh-key list --json title,fingerprint`, + RunE: func(cmd *cobra.Command, _ []string) error { + target, err := a.resolve(cmd.Context(), false) + if err != nil { + return err } - base := filepath.Base(args[0]) - title = strings.TrimSuffix(base, filepath.Ext(base)) + keys, err := (services.SSHKeys{Client: target.client}).List(cmd.Context(), limit, page) + if err != nil { + return err + } + account := target.login() + return writeTable(a, cmd, keys.Items, listSpec[services.SSHKey]{ + Columns: []terminal.Column{{Header: "ID"}, {Header: "Title", Flex: true}, {Header: "Fingerprint"}, {Header: "Added"}}, + Row: func(key services.SSHKey) []terminal.Cell { + return []terminal.Cell{{Text: strconv.FormatInt(key.ID, 10), Link: webURL(target, "user", "settings", "keys")}, {Text: key.Title, Bold: true}, {Text: key.Fingerprint, Muted: true}, {Time: terminal.ParseTime(key.CreatedAt)}} + }, + Context: "SSH keys of " + account, + Empty: "no SSH keys for " + account + "; add one with: tea ssh-key add ~/.ssh/id_ed25519.pub", + }, servicePage(keys, page)) + }, + } + pageFlags(cmd, &limit, &page) + return cmd +} + +func (a *application) sshKeyViewCommand() *cobra.Command { + return &cobra.Command{ + Use: "view ", Short: "View an SSH key", Args: argsExact(1), ValidArgsFunction: a.completeSSHKeys(), + Example: `$ tea ssh-key view 2 +# print the public key +$ tea ssh-key view 2 --jq .key`, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := number(args[0]) + if err != nil { + return err + } + target, err := a.resolve(cmd.Context(), false) + if err != nil { + return err + } + key, err := (services.SSHKeys{Client: target.client}).View(cmd.Context(), id) + if err != nil { + return err + } + d := a.display() + return writeView(a, cmd, key, func(key services.SSHKey) terminal.View { + added := timeField("Added", key.CreatedAt, d) + meta := "" + if added.Value != "" { + meta = "added " + added.Value + } + added.PipedOnly = true + algorithm, _, _ := strings.Cut(key.Key, " ") + return terminal.View{ + Title: key.Title, ID: fmt.Sprintf("(ID %d)", key.ID), Meta: meta, + Fields: []terminal.Field{{Label: "Fingerprint", Value: key.Fingerprint}, {Label: "Type", Value: algorithm}, added, {Label: "Key", Value: key.Key}}, + Bodyless: true, Kind: "SSH key", URL: webURL(target, "user", "settings", "keys"), + } + }) + }, + } +} + +// publicKey reads one SSH public key from data, which came from source, and returns it with its +// comment. It recognizes a private key and points to its public half. +func publicKey(data []byte, source string) (ssh.PublicKey, string, error) { + if bytes.Contains(data, []byte("PRIVATE KEY-----")) { + hint := "pipe the public key (.pub) instead" + if source != "-" { + hint = "add its public key instead: tea ssh-key add " + source + ".pub" } - if strings.TrimSpace(title) == "" { - return clierr.Wrap(clierr.Usage, errors.New("key title cannot be empty")) + return nil, "", clierr.Wrap(clierr.Usage, fmt.Errorf("%s holds a private key; %s", sshKeySource(source), hint)) + } + key, comment, _, rest, err := ssh.ParseAuthorizedKey(data) + if err != nil || len(bytes.TrimSpace(rest)) != 0 { + return nil, "", clierr.Wrap(clierr.Usage, fmt.Errorf("%s must hold exactly one valid SSH public key", sshKeySource(source))) + } + return key, comment, nil +} + +func sshKeySource(source string) string { + if source == "-" { + return "stdin" + } + return source +} + +func (a *application) sshKeyCreateCommand() *cobra.Command { + var title string + cmd := &cobra.Command{ + Use: "create []", Aliases: []string{"add"}, Short: "Add an SSH public key from a file or stdin", Args: usageArgs(cobra.MaximumNArgs(1)), + Long: `Add an SSH public key to your account. Without a file, or with -, the key is read from stdin. + +The title defaults to the key's comment, such as alice@laptop, or else to this computer's name.`, + Example: `$ tea ssh-key add ~/.ssh/id_ed25519.pub +$ tea ssh-key add ~/.ssh/id_ed25519.pub --title "Work laptop" +$ cat key.pub | tea ssh-key add`, + RunE: func(cmd *cobra.Command, args []string) error { + source := "-" + if len(args) > 0 { + source = args[0] + } + if source == "-" && a.deps.Streams.InTTY { + return clierr.Wrap(clierr.Usage, errors.New("supply a public key file, such as ~/.ssh/id_ed25519.pub, or pipe a key to stdin")) + } + data, err := readFileOrStdin(a.deps.Streams.In, source) + if err != nil { + return fmt.Errorf("read public key: %w", err) + } + key, comment, err := publicKey(data, source) + if err != nil { + return err + } + if !cmd.Flags().Changed("title") { + title = comment + if title == "" { + title, _ = os.Hostname() + } + } + if strings.TrimSpace(title) == "" { + return clierr.Wrap(clierr.Usage, errors.New("key title cannot be empty; pass --title")) + } + target, err := a.resolve(cmd.Context(), false) + if err != nil { + return err + } + service := services.SSHKeys{Client: target.client} + created, err := service.Create(cmd.Context(), title, strings.TrimSpace(string(data))) + if err != nil { + return a.registeredKey(cmd.Context(), service, key, services.ClassifyRequest(err)) + } + a.done(cmd, "Added %s", sshKeyLabel(created)) + return a.jsonResult(cmd, created) + }, + } + cmd.Flags().StringVarP(&title, "title", "t", "", "Key title (defaults to the key comment, then the computer's name)") + return cmd +} + +// registeredKey names the key of the account that already holds key when Gitea refuses to add it. +func (a *application) registeredKey(ctx context.Context, service services.SSHKeys, key ssh.PublicKey, err error) error { + conflict, ok := errors.AsType[*services.ConflictError](err) + if !ok || conflict.Reason != services.ReasonKeyInUse { + return err + } + keys, listErr := service.List(ctx, maxLimit, 1) + if listErr != nil { + return conflict + } + fingerprint := ssh.FingerprintSHA256(key) + for _, registered := range keys.Items { + if registered.Fingerprint == fingerprint { + conflict.Hint = fmt.Sprintf("it is your SSH key %q (ID %d)", registered.Title, registered.ID) } - target, err := a.resolve(cmd.Context(), false) - if err != nil { - return err - } - item, err := (services.SSHKeys{Client: target.client}).Create(cmd.Context(), title, strings.TrimSpace(string(data))) - if err != nil { - return err - } - return writeItem(a, cmd, item, sshKeyRow) - }} - create.Flags().StringVar(&title, "title", "", "Key title (defaults to filename)") - group.AddCommand(create) - for _, verb := range []string{"view", "delete"} { - command := &cobra.Command{Use: verb + " ", Short: titleCase(verb) + " an SSH key", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { + } + return conflict +} + +func (a *application) sshKeyDeleteCommand() *cobra.Command { + return &cobra.Command{ + Use: "delete ", Short: "Delete an SSH key", Args: argsExact(1), ValidArgsFunction: a.completeSSHKeys(), + Example: `$ tea ssh-key delete 2 +$ tea ssh-key delete 2 --yes`, + Annotations: map[string]string{"tea.text": "SSH key deletion does not return a resource", "tea.confirm": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { id, err := number(args[0]) if err != nil { return err @@ -76,26 +219,18 @@ func (a *application) sshKeyCommand() *cobra.Command { return err } service := services.SSHKeys{Client: target.client} - if verb == "delete" { - if err := a.confirm(cmd, "Delete SSH key "+args[0]); err != nil { - return err - } - return service.Delete(cmd.Context(), id) - } - item, err := service.View(cmd.Context(), id) + key, err := service.View(cmd.Context(), id) if err != nil { return err } - return writeItem(a, cmd, item, sshKeyRow) - }} - if verb == "delete" { - command.Annotations = textOutput("SSH key deletion does not return a resource") - } - group.AddCommand(command) + if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + sshKeyLabel(key) + "?", NonTTY: "delete " + sshKeyLabel(key)}); err != nil { + return err + } + if err := service.Delete(cmd.Context(), id); err != nil { + return err + } + a.done(cmd, "Deleted %s", sshKeyLabel(key)) + return nil + }, } - return group -} - -func sshKeyRow(key services.SSHKey) []terminal.Cell { - return terminal.Row(strconv.FormatInt(key.ID, 10), key.Title, key.Fingerprint) } diff --git a/internal/app/ssh_keys_test.go b/internal/app/ssh_keys_test.go index f3067370..811500c6 100644 --- a/internal/app/ssh_keys_test.go +++ b/internal/app/ssh_keys_test.go @@ -4,19 +4,95 @@ package app import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" ) func TestSSHKeyValidationPrecedesTargetResolution(t *testing.T) { - for _, content := range []string{"", "not a public key", "-----BEGIN OPENSSH PRIVATE KEY-----"} { - t.Run(content, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "invalid.pub") - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - requireUsageBeforeRequests(t, "exactly one valid SSH public key", "ssh-key", "create", path) + for _, tc := range []struct{ content, want string }{ + {"", "must hold exactly one valid SSH public key"}, + {"not a public key", "must hold exactly one valid SSH public key"}, + {"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----\n", "holds a private key; add its public key instead: tea ssh-key add "}, + } { + t.Run(tc.content, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "id_ed25519") + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0o600)) + requireUsageBeforeRequests(t, tc.want, "ssh-key", "create", path) }) } } + +// testPublicKey returns a new public key in authorized_keys format with comment, and its fingerprint. +func testPublicKey(t *testing.T, comment string) (string, string) { + t.Helper() + public, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + key, err := ssh.NewPublicKey(public) + require.NoError(t, err) + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))) + " " + comment, ssh.FingerprintSHA256(key) +} + +func TestSSHKeyAddTitlesKeysByTheirComment(t *testing.T) { + key, fingerprint := testPublicKey(t, "alice@laptop") + var titles []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + titles = append(titles, body["title"].(string)) + if len(titles) > 1 { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Key content has been used as non-deploy key"}`)) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":3,"title":%q,"fingerprint":%q}`, body["title"], fingerprint) + case http.MethodGet: + _, _ = fmt.Fprintf(w, `[{"id":3,"title":"alice@laptop","fingerprint":%q}]`, fingerprint) + } + })) + defer server.Close() + deps, _, diagnostics := serverDeps(t, server.URL) + deps.Streams.In = bytes.NewBufferString(key + "\n") + deps.Streams.OutTTY = true + require.NoError(t, Execute(context.Background(), deps, []string{"ssh-key", "add"})) + require.Equal(t, []string{"alice@laptop"}, titles) + require.Equal(t, fmt.Sprintf("✓ Added SSH key \"alice@laptop\" (%s)\n", fingerprint), withoutANSI(diagnostics.String())) + + deps.Streams.In = bytes.NewBufferString(key) + err := Execute(context.Background(), deps, []string{"ssh-key", "create", "-", "--title", "again"}) + require.EqualError(t, err, `this SSH key is already registered; it is your SSH key "alice@laptop" (ID 3)`) +} + +func TestSSHKeyDeleteNamesTheKey(t *testing.T) { + var deleted bool + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleted = true + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`{"id":2,"title":"uxkey","fingerprint":"SHA256:aR5L"}`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + prompt := &scriptedPrompter{} + deps.Prompter = prompt + require.Error(t, Execute(context.Background(), deps, []string{"ssh-key", "delete", "2"})) + require.Equal(t, []string{`Delete SSH key "uxkey" (SHA256:aR5L)?`}, prompt.confirmations) + require.False(t, deleted) +} diff --git a/internal/services/ssh_keys.go b/internal/services/ssh_keys.go index 9b8a627a..eb7052c9 100644 --- a/internal/services/ssh_keys.go +++ b/internal/services/ssh_keys.go @@ -5,6 +5,7 @@ package services import ( "context" + "net/http" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" @@ -27,15 +28,15 @@ func sshKeyFrom(key *gitea.PublicKey) SSHKey { // SSHKeys manages public keys for the authenticated account. type SSHKeys struct{ Client *transport.Client } -// List returns a non-nil page of public keys. -func (s SSHKeys) List(ctx context.Context, limit, page int) ([]SSHKey, error) { - keys, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.PublicKey, *gitea.Response, error) { - return c.Users.ListMyPublicKeys(ctx, gitea.ListPublicKeysOptions{ListOptions: listOptions(limit, page)}) +// List returns a page of public keys. +func (s SSHKeys) List(ctx context.Context, limit, page int) (Page[SSHKey], error) { + keys, err := listPage(limit, page, func(limit, page int) ([]*gitea.PublicKey, http.Header, error) { + keys, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.PublicKey, *gitea.Response, error) { + return c.Users.ListMyPublicKeys(ctx, gitea.ListPublicKeysOptions{ListOptions: listOptions(limit, page)}) + }) + return keys, headerOf(resp), err }) - if err != nil { - return nil, err - } - return convertAll(keys, sshKeyFrom), nil + return convertPage(keys, sshKeyFrom), err } // View retrieves a public key by ID. diff --git a/internal/services/ssh_keys_test.go b/internal/services/ssh_keys_test.go index 2e6ab6c2..6d06c99a 100644 --- a/internal/services/ssh_keys_test.go +++ b/internal/services/ssh_keys_test.go @@ -28,11 +28,11 @@ func TestSSHKeyListPaginatesAndMapsKeys(t *testing.T) { service := SSHKeys{Client: client} keys, err := service.List(context.Background(), 20, 1) require.NoError(t, err) - require.Equal(t, []SSHKey{{ID: 3, Title: "laptop", Key: "ssh-ed25519 AAAA", Fingerprint: "SHA256:abc", CreatedAt: "2026-10-01T12:00:00Z", ReadOnly: true}}, keys) + require.Equal(t, []SSHKey{{ID: 3, Title: "laptop", Key: "ssh-ed25519 AAAA", Fingerprint: "SHA256:abc", CreatedAt: "2026-10-01T12:00:00Z", ReadOnly: true}}, keys.Items) keys, err = service.List(context.Background(), 20, 2) require.NoError(t, err) - require.NotNil(t, keys) - require.Empty(t, keys) + require.NotNil(t, keys.Items) + require.Empty(t, keys.Items) } func TestSSHKeyViewCreateAndDelete(t *testing.T) {