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}