// SPDX-License-Identifier: AGPL-3.0-or-later
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"slices"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"bnema.dev/ermite-api/gen/ermite"
"bnema.dev/ermite/internal/adapters/client"
"bnema.dev/ermite/internal/domain"
)
// jsonSigningKey is the stable JSON shape of `signing-key show --json`.
type jsonSigningKey struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
CommitterName string `json:"committer_name"`
CommitterEmail string `json:"committer_email"`
AllowedSigners string `json:"allowed_signers"`
}
func newSigningKeyCmd(env *cliEnv) *cobra.Command {
key := &cobra.Command{
Use: "signing-key",
Short: "Show the server's signing key and manage the keys that verify pushed commits",
Long: "The server signs the commits it creates (squash merges) with its own SSH key, so that they show as\n" +
"verified: `show` prints that key. Needs a server with the signing_key feature (see `ermite version`).\n\n" +
"The server also checks the signatures of the commits you push, SSH and GPG, against the public keys\n" +
"you trust: `trust add|list|remove` manage them. A commit shows as verified when its signature is valid,\n" +
"made by a trusted key, and the key is trusted for the committer's email. The server's own key is\n" +
"always trusted (builtin) and cannot be removed. Needs the commit_signatures feature (see\n" +
"`ermite version`); adding and removing keys is for the owner.",
}
key.AddCommand(newSigningKeyShowCmd(env), newSigningKeyTrustCmd(env))
return key
}
// jsonTrustedKey is the stable JSON shape of a trusted key.
type jsonTrustedKey struct {
ID string `json:"id"`
Format string `json:"format"`
Title string `json:"title"`
PublicKey string `json:"public_key"`
Emails []string `json:"emails"`
Builtin bool `json:"builtin"`
CreatedAt *string `json:"created_at"`
}
const jsonTrustedKeyHelp = "\n\nJSON fields of a key (--json): id (SHA256:... for ssh, the fingerprint in hex for gpg), format\n" +
"(ssh, gpg), title, public_key, emails (array), builtin (true for the server's own key), created_at\n" +
"(RFC 3339 UTC, null for the builtin key)."
func formatName(f ermite.SignatureFormat) string {
switch f {
case ermite.SignatureFormat_SIGNATURE_FORMAT_SSH:
return "ssh"
case ermite.SignatureFormat_SIGNATURE_FORMAT_GPG:
return "gpg"
case ermite.SignatureFormat_SIGNATURE_FORMAT_X509:
return "x509"
case ermite.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED:
}
return "unknown"
}
func toJSONTrustedKey(k *ermite.TrustedKey) jsonTrustedKey {
return jsonTrustedKey{
ID: k.GetId(), Format: formatName(k.GetFormat()), Title: k.GetTitle(), PublicKey: k.GetPublicKey(),
Emails: append([]string{}, k.GetEmails()...), Builtin: k.GetBuiltin(), CreatedAt: jsonTime(k.GetCreatedAt()),
}
}
// requireCommitSignatures refuses to go on with a server that does not list
// commit_signatures: it has no TrustedKeyService. A server whose features
// cannot be read is refused too.
func requireCommitSignatures(ctx context.Context, c *client.Client) error {
res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
if err != nil {
return c.Describe(err)
}
if !slices.Contains(res.Msg.GetFeatures(), client.FeatureCommitSignatures) {
return fmt.Errorf("the server does not support %s, so it cannot manage trusted keys (upgrade the server, see `ermite version`)", client.FeatureCommitSignatures)
}
return nil
}
func newSigningKeyTrustCmd(env *cliEnv) *cobra.Command {
trust := &cobra.Command{
Use: "trust",
Short: "Manage the keys commit signatures are verified against",
Long: "List, add and remove the public keys the server trusts to verify the signatures of commits (SSH and\n" +
"GPG). Each key is trusted for one to ten committer emails: a commit signed by the key but made\n" +
"under another email shows as unverified. Needs a server with the commit_signatures feature.",
}
trust.AddCommand(newTrustListCmd(env), newTrustAddCmd(env), newTrustRemoveCmd(env))
return trust
}
func newTrustListCmd(env *cliEnv) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List the trusted keys",
Long: "List the trusted keys, the server's own (builtin) key first. Any authenticated token may." + jsonTrustedKeyHelp +
"\n--json prints an array of keys.",
Args: cobra.NoArgs,
}
asJSON := addJSONFlag(cmd)
cmd.RunE = func(cmd *cobra.Command, _ []string) error {
c, err := env.client(cmd)
if err != nil {
return err
}
ctx := env.ctx(cmd)
if err := requireCommitSignatures(ctx, c); err != nil {
return err
}
res, err := c.TrustedKeys().ListTrustedKeys(ctx, connect.NewRequest(&ermite.ListTrustedKeysRequest{}))
if err != nil {
return c.Describe(err)
}
keys := res.Msg.GetKeys()
if *asJSON {
out := make([]jsonTrustedKey, 0, len(keys))
for _, k := range keys {
out = append(out, toJSONTrustedKey(k))
}
return writeJSON(cmd.OutOrStdout(), out)
}
if len(keys) == 0 {
noticef(cmd, "no trusted keys")
return nil
}
rows := make([][]string, 0, len(keys))
for _, k := range keys {
builtin := "-"
if k.GetBuiltin() {
builtin = "yes"
}
rows = append(rows, []string{k.GetId(), formatName(k.GetFormat()), k.GetTitle(), strings.Join(k.GetEmails(), ","), builtin})
}
return table(cmd.OutOrStdout(), []string{"ID", "FORMAT", "TITLE", "EMAILS", "BUILTIN"}, rows)
}
return cmd
}
func newTrustAddCmd(env *cliEnv) *cobra.Command {
var (
format string
title string
emails []string
)
cmd := &cobra.Command{
Use: "add --format ssh|gpg --title
--email ",
Short: "Trust a public key",
Long: "Trust a public key for the given committer emails. The key is read from the file, or from standard\n" +
"input with -, up to 64 KiB: an authorized_keys line for --format ssh (for example ~/.ssh/id_ed25519.pub),\n" +
"an armored public key for --format gpg (`gpg --armor --export `, exactly one key, never\n" +
"a private key). --email is required, repeatable, 1 to 10 addresses; the title is 1 to 100 bytes.\n" +
"Only the owner may add a key. Prints the key id on standard output, details on standard error;\n" +
"with --json, standard output is the key object." + jsonTrustedKeyHelp,
Example: " ermite signing-key trust add --format ssh --title laptop --email me@example.com ~/.ssh/id_ed25519.pub\n" +
" gpg --armor --export 0xABCD | ermite signing-key trust add --format gpg --title work --email me@example.com -",
Args: cobra.ExactArgs(1),
}
cmd.Flags().StringVar(&format, "format", "", "key format: ssh or gpg")
cmd.Flags().StringVar(&title, "title", "", "label of the key")
cmd.Flags().StringSliceVar(&emails, "email", nil, "committer email the key may sign for, repeatable")
_ = cmd.MarkFlagRequired("format")
_ = cmd.MarkFlagRequired("title")
_ = cmd.MarkFlagRequired("email")
_ = cmd.RegisterFlagCompletionFunc("format", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return []string{"ssh", "gpg"}, cobra.ShellCompDirectiveNoFileComp
})
asJSON := addJSONFlag(cmd)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
var pbFormat ermite.SignatureFormat
switch f, err := domain.ParseSignatureFormat(format); {
case err != nil:
return fmt.Errorf("invalid --format %q: use ssh or gpg", format)
case f == domain.FormatGPG:
pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_GPG
default:
pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_SSH
}
key, err := readTrustedKey(cmd, args[0])
if err != nil {
return err
}
c, err := env.client(cmd)
if err != nil {
return err
}
ctx := env.ctx(cmd)
if err := requireCommitSignatures(ctx, c); err != nil {
return err
}
res, err := c.TrustedKeys().AddTrustedKey(ctx, connect.NewRequest(&ermite.AddTrustedKeyRequest{
Format: pbFormat, Title: title, PublicKey: key, Emails: emails,
}))
if err != nil {
return c.Describe(err)
}
k := res.Msg.GetKey()
if *asJSON {
return writeJSON(cmd.OutOrStdout(), toJSONTrustedKey(k))
}
noticef(cmd, "trusted %s key %q for %s", formatName(k.GetFormat()), clean(k.GetTitle()), clean(strings.Join(k.GetEmails(), ", ")))
_, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetId()))
return err
}
return cmd
}
// readTrustedKey reads a public key from a file, or from standard input for
// "-", refusing more than domain.MaxTrustedKeyBytes.
func readTrustedKey(cmd *cobra.Command, path string) (string, error) {
var r io.Reader
name := "standard input"
if path == "-" {
r = cmd.InOrStdin()
} else {
f, err := os.Open(path) //nolint:gosec // the user names the file
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
defer func() { _ = f.Close() }()
r, name = f, path
}
b, err := io.ReadAll(io.LimitReader(r, domain.MaxTrustedKeyBytes+1))
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
if len(b) > domain.MaxTrustedKeyBytes {
return "", fmt.Errorf("%s: the key is larger than %d KiB", name, domain.MaxTrustedKeyBytes>>10)
}
key := strings.TrimSpace(string(b))
if key == "" {
return "", errors.New(name + ": no key to read")
}
return key, nil
}
func newTrustRemoveCmd(env *cliEnv) *cobra.Command {
cmd := &cobra.Command{
Use: "remove ",
Short: "Stop trusting a key",
Long: "Remove a trusted key by its id (see `signing-key trust list`). Commits it signed then show as\n" +
"unverified. The server's own (builtin) key cannot be removed. Only the owner may remove a key.",
Args: cobra.ExactArgs(1),
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
c, err := env.client(cmd)
if err != nil {
return err
}
ctx := env.ctx(cmd)
if err := requireCommitSignatures(ctx, c); err != nil {
return err
}
if _, err := c.TrustedKeys().RemoveTrustedKey(ctx, connect.NewRequest(&ermite.RemoveTrustedKeyRequest{Id: args[0]})); err != nil {
return c.Describe(err)
}
noticef(cmd, "removed trusted key %s", clean(args[0]))
return nil
}
return cmd
}
func newSigningKeyShowCmd(env *cliEnv) *cobra.Command {
var allowedSigners, publicKey bool
cmd := &cobra.Command{
Use: "show",
Short: "Print the public signing key of the server",
Long: "Print the public key of the server, its fingerprint and the committer it records. The key is public:\n" +
"no token is needed. Register it for the committer's email on Forgejo or GitHub, or save the\n" +
"allowed-signers line to a file to verify commits locally:\n\n" +
" ermite signing-key show --allowed-signers >> ~/.config/git/allowed_signers\n" +
" git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers\n" +
" git log --show-signature\n\n" +
"--public-key prints only the authorized_keys line, --allowed-signers only the allowed-signers line\n" +
"(\" \"), each ready to be redirected into a file.\n\n" +
"JSON fields (--json): public_key, fingerprint (SHA256:...), committer_name, committer_email,\n" +
"allowed_signers.",
Args: cobra.NoArgs,
}
cmd.Flags().BoolVar(&publicKey, "public-key", false, "print only the key as an authorized_keys line")
cmd.Flags().BoolVar(&allowedSigners, "allowed-signers", false, "print only the line for an allowed signers file")
cmd.MarkFlagsMutuallyExclusive("public-key", "allowed-signers")
asJSON := addJSONFlag(cmd)
cmd.MarkFlagsMutuallyExclusive("json", "public-key")
cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers")
cmd.RunE = func(cmd *cobra.Command, _ []string) error {
c, err := env.client(cmd)
if err != nil {
return err
}
res, err := c.Server().GetSigningKey(env.ctx(cmd), connect.NewRequest(&ermite.GetSigningKeyRequest{}))
if err != nil {
return c.Describe(err)
}
k := res.Msg
out := cmd.OutOrStdout()
switch {
case *asJSON:
return writeJSON(out, jsonSigningKey{
PublicKey: k.GetPublicKey(), Fingerprint: k.GetFingerprint(), CommitterName: k.GetCommitterName(),
CommitterEmail: k.GetCommitterEmail(), AllowedSigners: k.GetAllowedSigners(),
})
case publicKey:
_, err = fmt.Fprintln(out, clean(k.GetPublicKey()))
case allowedSigners:
_, err = fmt.Fprintln(out, clean(k.GetAllowedSigners()))
default:
err = fields(out, [][2]string{
{"fingerprint", k.GetFingerprint()},
{"committer", k.GetCommitterName() + " <" + k.GetCommitterEmail() + ">"},
{"public key", k.GetPublicKey()},
{"allowed signers", k.GetAllowedSigners()},
})
}
return err
}
return cmd
}