v0.33.0 cf26365

cli_signing.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"os"
11	"slices"
12	"strings"
13
14	"connectrpc.com/connect"
15	"github.com/spf13/cobra"
16
17	"bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/adapters/client"
19	"bnema.dev/ermite/internal/domain"
20)
21
22// jsonSigningKey is the stable JSON shape of `signing-key show --json`.
23type jsonSigningKey struct {
24	PublicKey      string `json:"public_key"`
25	Fingerprint    string `json:"fingerprint"`
26	CommitterName  string `json:"committer_name"`
27	CommitterEmail string `json:"committer_email"`
28	AllowedSigners string `json:"allowed_signers"`
29}
30
31func newSigningKeyCmd(env *cliEnv) *cobra.Command {
32	key := &cobra.Command{
33		Use:   "signing-key",
34		Short: "Show the server's signing key and manage the keys that verify pushed commits",
35		Long: "The server signs the commits it creates (squash merges) with its own SSH key, so that they show as\n" +
36			"verified: `show` prints that key. Needs a server with the signing_key feature (see `ermite version`).\n\n" +
37			"The server also checks the signatures of the commits you push, SSH and GPG, against the public keys\n" +
38			"you trust: `trust add|list|remove` manage them. A commit shows as verified when its signature is valid,\n" +
39			"made by a trusted key, and the key is trusted for the committer's email. The server's own key is\n" +
40			"always trusted (builtin) and cannot be removed. Needs the commit_signatures feature (see\n" +
41			"`ermite version`); adding and removing keys is for the owner.",
42	}
43	key.AddCommand(newSigningKeyShowCmd(env), newSigningKeyTrustCmd(env))
44	return key
45}
46
47// jsonTrustedKey is the stable JSON shape of a trusted key.
48type jsonTrustedKey struct {
49	ID        string   `json:"id"`
50	Format    string   `json:"format"`
51	Title     string   `json:"title"`
52	PublicKey string   `json:"public_key"`
53	Emails    []string `json:"emails"`
54	Builtin   bool     `json:"builtin"`
55	CreatedAt *string  `json:"created_at"`
56}
57
58const jsonTrustedKeyHelp = "\n\nJSON fields of a key (--json): id (SHA256:... for ssh, the fingerprint in hex for gpg), format\n" +
59	"(ssh, gpg), title, public_key, emails (array), builtin (true for the server's own key), created_at\n" +
60	"(RFC 3339 UTC, null for the builtin key)."
61
62func formatName(f ermite.SignatureFormat) string {
63	switch f {
64	case ermite.SignatureFormat_SIGNATURE_FORMAT_SSH:
65		return "ssh"
66	case ermite.SignatureFormat_SIGNATURE_FORMAT_GPG:
67		return "gpg"
68	case ermite.SignatureFormat_SIGNATURE_FORMAT_X509:
69		return "x509"
70	case ermite.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED:
71	}
72	return "unknown"
73}
74
75func toJSONTrustedKey(k *ermite.TrustedKey) jsonTrustedKey {
76	return jsonTrustedKey{
77		ID: k.GetId(), Format: formatName(k.GetFormat()), Title: k.GetTitle(), PublicKey: k.GetPublicKey(),
78		Emails: append([]string{}, k.GetEmails()...), Builtin: k.GetBuiltin(), CreatedAt: jsonTime(k.GetCreatedAt()),
79	}
80}
81
82// requireCommitSignatures refuses to go on with a server that does not list
83// commit_signatures: it has no TrustedKeyService. A server whose features
84// cannot be read is refused too.
85func requireCommitSignatures(ctx context.Context, c *client.Client) error {
86	res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
87	if err != nil {
88		return c.Describe(err)
89	}
90	if !slices.Contains(res.Msg.GetFeatures(), client.FeatureCommitSignatures) {
91		return fmt.Errorf("the server does not support %s, so it cannot manage trusted keys (upgrade the server, see `ermite version`)", client.FeatureCommitSignatures)
92	}
93	return nil
94}
95
96func newSigningKeyTrustCmd(env *cliEnv) *cobra.Command {
97	trust := &cobra.Command{
98		Use:   "trust",
99		Short: "Manage the keys commit signatures are verified against",
100		Long: "List, add and remove the public keys the server trusts to verify the signatures of commits (SSH and\n" +
101			"GPG). Each key is trusted for one to ten committer emails: a commit signed by the key but made\n" +
102			"under another email shows as unverified. Needs a server with the commit_signatures feature.",
103	}
104	trust.AddCommand(newTrustListCmd(env), newTrustAddCmd(env), newTrustRemoveCmd(env))
105	return trust
106}
107
108func newTrustListCmd(env *cliEnv) *cobra.Command {
109	cmd := &cobra.Command{
110		Use:   "list",
111		Short: "List the trusted keys",
112		Long: "List the trusted keys, the server's own (builtin) key first. Any authenticated token may." + jsonTrustedKeyHelp +
113			"\n--json prints an array of keys.",
114		Args: cobra.NoArgs,
115	}
116	asJSON := addJSONFlag(cmd)
117	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
118		c, err := env.client(cmd)
119		if err != nil {
120			return err
121		}
122		ctx := env.ctx(cmd)
123		if err := requireCommitSignatures(ctx, c); err != nil {
124			return err
125		}
126		res, err := c.TrustedKeys().ListTrustedKeys(ctx, connect.NewRequest(&ermite.ListTrustedKeysRequest{}))
127		if err != nil {
128			return c.Describe(err)
129		}
130		keys := res.Msg.GetKeys()
131		if *asJSON {
132			out := make([]jsonTrustedKey, 0, len(keys))
133			for _, k := range keys {
134				out = append(out, toJSONTrustedKey(k))
135			}
136			return writeJSON(cmd.OutOrStdout(), out)
137		}
138		if len(keys) == 0 {
139			noticef(cmd, "no trusted keys")
140			return nil
141		}
142		rows := make([][]string, 0, len(keys))
143		for _, k := range keys {
144			builtin := "-"
145			if k.GetBuiltin() {
146				builtin = "yes"
147			}
148			rows = append(rows, []string{k.GetId(), formatName(k.GetFormat()), k.GetTitle(), strings.Join(k.GetEmails(), ","), builtin})
149		}
150		return table(cmd.OutOrStdout(), []string{"ID", "FORMAT", "TITLE", "EMAILS", "BUILTIN"}, rows)
151	}
152	return cmd
153}
154
155func newTrustAddCmd(env *cliEnv) *cobra.Command {
156	var (
157		format string
158		title  string
159		emails []string
160	)
161	cmd := &cobra.Command{
162		Use:   "add --format ssh|gpg --title <title> --email <email> <file|->",
163		Short: "Trust a public key",
164		Long: "Trust a public key for the given committer emails. The key is read from the file, or from standard\n" +
165			"input with -, up to 64 KiB: an authorized_keys line for --format ssh (for example ~/.ssh/id_ed25519.pub),\n" +
166			"an armored public key for --format gpg (`gpg --armor --export <fingerprint>`, exactly one key, never\n" +
167			"a private key). --email is required, repeatable, 1 to 10 addresses; the title is 1 to 100 bytes.\n" +
168			"Only the owner may add a key. Prints the key id on standard output, details on standard error;\n" +
169			"with --json, standard output is the key object." + jsonTrustedKeyHelp,
170		Example: "  ermite signing-key trust add --format ssh --title laptop --email me@example.com ~/.ssh/id_ed25519.pub\n" +
171			"  gpg --armor --export 0xABCD | ermite signing-key trust add --format gpg --title work --email me@example.com -",
172		Args: cobra.ExactArgs(1),
173	}
174	cmd.Flags().StringVar(&format, "format", "", "key format: ssh or gpg")
175	cmd.Flags().StringVar(&title, "title", "", "label of the key")
176	cmd.Flags().StringSliceVar(&emails, "email", nil, "committer email the key may sign for, repeatable")
177	_ = cmd.MarkFlagRequired("format")
178	_ = cmd.MarkFlagRequired("title")
179	_ = cmd.MarkFlagRequired("email")
180	_ = cmd.RegisterFlagCompletionFunc("format", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
181		return []string{"ssh", "gpg"}, cobra.ShellCompDirectiveNoFileComp
182	})
183	asJSON := addJSONFlag(cmd)
184	cmd.RunE = func(cmd *cobra.Command, args []string) error {
185		var pbFormat ermite.SignatureFormat
186		switch f, err := domain.ParseSignatureFormat(format); {
187		case err != nil:
188			return fmt.Errorf("invalid --format %q: use ssh or gpg", format)
189		case f == domain.FormatGPG:
190			pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_GPG
191		default:
192			pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_SSH
193		}
194		key, err := readTrustedKey(cmd, args[0])
195		if err != nil {
196			return err
197		}
198		c, err := env.client(cmd)
199		if err != nil {
200			return err
201		}
202		ctx := env.ctx(cmd)
203		if err := requireCommitSignatures(ctx, c); err != nil {
204			return err
205		}
206		res, err := c.TrustedKeys().AddTrustedKey(ctx, connect.NewRequest(&ermite.AddTrustedKeyRequest{
207			Format: pbFormat, Title: title, PublicKey: key, Emails: emails,
208		}))
209		if err != nil {
210			return c.Describe(err)
211		}
212		k := res.Msg.GetKey()
213		if *asJSON {
214			return writeJSON(cmd.OutOrStdout(), toJSONTrustedKey(k))
215		}
216		noticef(cmd, "trusted %s key %q for %s", formatName(k.GetFormat()), clean(k.GetTitle()), clean(strings.Join(k.GetEmails(), ", ")))
217		_, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetId()))
218		return err
219	}
220	return cmd
221}
222
223// readTrustedKey reads a public key from a file, or from standard input for
224// "-", refusing more than domain.MaxTrustedKeyBytes.
225func readTrustedKey(cmd *cobra.Command, path string) (string, error) {
226	var r io.Reader
227	name := "standard input"
228	if path == "-" {
229		r = cmd.InOrStdin()
230	} else {
231		f, err := os.Open(path) //nolint:gosec // the user names the file
232		if err != nil {
233			return "", fmt.Errorf("read %s: %w", path, err)
234		}
235		defer func() { _ = f.Close() }()
236		r, name = f, path
237	}
238	b, err := io.ReadAll(io.LimitReader(r, domain.MaxTrustedKeyBytes+1))
239	if err != nil {
240		return "", fmt.Errorf("read %s: %w", name, err)
241	}
242	if len(b) > domain.MaxTrustedKeyBytes {
243		return "", fmt.Errorf("%s: the key is larger than %d KiB", name, domain.MaxTrustedKeyBytes>>10)
244	}
245	key := strings.TrimSpace(string(b))
246	if key == "" {
247		return "", errors.New(name + ": no key to read")
248	}
249	return key, nil
250}
251
252func newTrustRemoveCmd(env *cliEnv) *cobra.Command {
253	cmd := &cobra.Command{
254		Use:   "remove <id>",
255		Short: "Stop trusting a key",
256		Long: "Remove a trusted key by its id (see `signing-key trust list`). Commits it signed then show as\n" +
257			"unverified. The server's own (builtin) key cannot be removed. Only the owner may remove a key.",
258		Args: cobra.ExactArgs(1),
259	}
260	cmd.RunE = func(cmd *cobra.Command, args []string) error {
261		c, err := env.client(cmd)
262		if err != nil {
263			return err
264		}
265		ctx := env.ctx(cmd)
266		if err := requireCommitSignatures(ctx, c); err != nil {
267			return err
268		}
269		if _, err := c.TrustedKeys().RemoveTrustedKey(ctx, connect.NewRequest(&ermite.RemoveTrustedKeyRequest{Id: args[0]})); err != nil {
270			return c.Describe(err)
271		}
272		noticef(cmd, "removed trusted key %s", clean(args[0]))
273		return nil
274	}
275	return cmd
276}
277
278func newSigningKeyShowCmd(env *cliEnv) *cobra.Command {
279	var allowedSigners, publicKey bool
280	cmd := &cobra.Command{
281		Use:   "show",
282		Short: "Print the public signing key of the server",
283		Long: "Print the public key of the server, its fingerprint and the committer it records. The key is public:\n" +
284			"no token is needed. Register it for the committer's email on Forgejo or GitHub, or save the\n" +
285			"allowed-signers line to a file to verify commits locally:\n\n" +
286			"  ermite signing-key show --allowed-signers >> ~/.config/git/allowed_signers\n" +
287			"  git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers\n" +
288			"  git log --show-signature\n\n" +
289			"--public-key prints only the authorized_keys line, --allowed-signers only the allowed-signers line\n" +
290			"(\"<committer email> <key>\"), each ready to be redirected into a file.\n\n" +
291			"JSON fields (--json): public_key, fingerprint (SHA256:...), committer_name, committer_email,\n" +
292			"allowed_signers.",
293		Args: cobra.NoArgs,
294	}
295	cmd.Flags().BoolVar(&publicKey, "public-key", false, "print only the key as an authorized_keys line")
296	cmd.Flags().BoolVar(&allowedSigners, "allowed-signers", false, "print only the line for an allowed signers file")
297	cmd.MarkFlagsMutuallyExclusive("public-key", "allowed-signers")
298	asJSON := addJSONFlag(cmd)
299	cmd.MarkFlagsMutuallyExclusive("json", "public-key")
300	cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers")
301	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
302		c, err := env.client(cmd)
303		if err != nil {
304			return err
305		}
306		res, err := c.Server().GetSigningKey(env.ctx(cmd), connect.NewRequest(&ermite.GetSigningKeyRequest{}))
307		if err != nil {
308			return c.Describe(err)
309		}
310		k := res.Msg
311		out := cmd.OutOrStdout()
312		switch {
313		case *asJSON:
314			return writeJSON(out, jsonSigningKey{
315				PublicKey: k.GetPublicKey(), Fingerprint: k.GetFingerprint(), CommitterName: k.GetCommitterName(),
316				CommitterEmail: k.GetCommitterEmail(), AllowedSigners: k.GetAllowedSigners(),
317			})
318		case publicKey:
319			_, err = fmt.Fprintln(out, clean(k.GetPublicKey()))
320		case allowedSigners:
321			_, err = fmt.Fprintln(out, clean(k.GetAllowedSigners()))
322		default:
323			err = fields(out, [][2]string{
324				{"fingerprint", k.GetFingerprint()},
325				{"committer", k.GetCommitterName() + " <" + k.GetCommitterEmail() + ">"},
326				{"public key", k.GetPublicKey()},
327				{"allowed signers", k.GetAllowedSigners()},
328			})
329		}
330		return err
331	}
332	return cmd
333}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close