cmd/gitbayd/secrets.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/cmd/gitbayd/secrets.go history · blame · raw

196 lines · 6007 bytes

  1package main
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"io/fs"
  8	"os"
  9	"sort"
 10	"strings"
 11	"syscall"
 12
 13	"github.com/spf13/cobra"
 14
 15	"gitbay.org/gitbay/internal/config"
 16	"gitbay.org/gitbay/internal/seal"
 17)
 18
 19// secretsCmd manages the key file that seals CI secrets, webhook
 20// secrets, mirror tokens and push device tokens in the database. No
 21// subcommand prints key material, only key ids.
 22func secretsCmd() *cobra.Command {
 23	cmd := &cobra.Command{
 24		Use:   "secrets",
 25		Short: "the key file that seals secrets stored in the database",
 26	}
 27	run := func(f func(config.Config, io.Writer) error) func(*cobra.Command, []string) error {
 28		return func(cmd *cobra.Command, args []string) error {
 29			cfg, err := config.Load(configPath)
 30			if err != nil {
 31				return err
 32			}
 33			return f(cfg, os.Stdout)
 34		}
 35	}
 36	cmd.AddCommand(
 37		&cobra.Command{
 38			Use:   "init",
 39			Short: "create the key file (server.secret_key_file) with one new key",
 40			Long: `Creates server.secret_key_file, mode 0600, holding one new key. Run as
 41root, the file is given to the owner of server.root, the daemon's user.
 42Refuses when the file exists.`,
 43			RunE: run(initSecrets),
 44		},
 45		&cobra.Command{
 46			Use:   "rotate",
 47			Short: "seal every secret under a new key and retire the old ones",
 48			Long: `Adds a new key to the key file, reseals every value under it in one
 49transaction, then removes the old keys from the file. A running daemon
 50re-reads the file when it changes, so no restart is needed. Run as the
 51user that can replace the key file (root, for /etc/gitbay); the file
 52keeps its owner. Copy the new file off the host afterwards.`,
 53			RunE: run(rotateSecrets),
 54		},
 55		&cobra.Command{
 56			Use:   "check",
 57			Short: "open every stored secret and count them per column by key; exit 1 if any does not open",
 58			RunE:  run(checkSecrets),
 59		},
 60	)
 61	return cmd
 62}
 63
 64// initSecrets writes a new key file. Run as root, it hands the file to
 65// the owner of server.root, since the daemon reads it as that user.
 66func initSecrets(cfg config.Config, w io.Writer) error {
 67	path := cfg.Server.SecretKeyFile
 68	if _, err := os.Lstat(path); err == nil {
 69		return fmt.Errorf("%s already exists; gitbayd admin secrets rotate replaces its key", path)
 70	} else if !errors.Is(err, fs.ErrNotExist) {
 71		return err
 72	}
 73	uid, gid := -1, -1
 74	if os.Geteuid() == 0 {
 75		fi, err := os.Stat(cfg.Server.Root)
 76		if err != nil {
 77			return fmt.Errorf("the key file is given to the owner of server.root: %w", err)
 78		}
 79		st, ok := fi.Sys().(*syscall.Stat_t)
 80		if !ok {
 81			return fmt.Errorf("cannot read the owner of %s", cfg.Server.Root)
 82		}
 83		uid, gid = int(st.Uid), int(st.Gid)
 84	}
 85	k, err := seal.NewKey()
 86	if err != nil {
 87		return err
 88	}
 89	if err := seal.WriteKeys(path, []seal.Key{k}); err != nil {
 90		return err
 91	}
 92	if uid >= 0 {
 93		if err := os.Chown(path, uid, gid); err != nil {
 94			// A root-owned file left behind would make a re-run refuse.
 95			os.Remove(path)
 96			return fmt.Errorf("could not give %s to the owner of %s, so it was removed: %w", path, cfg.Server.Root, err)
 97		}
 98	}
 99	fmt.Fprintf(w, "wrote %s (key %s). Copy it off this host: backups do not carry it, and a restored database's secrets do not open without it.\n", path, k.ID)
100	return nil
101}
102
103// rotateSecrets adds a key, reseals under it, then drops the old keys.
104// Each step leaves a file that opens every stored value: after the first
105// write the file holds old and new keys; the reseal is one transaction;
106// the last write happens only after the reseal committed and every value
107// is confirmed under the new key. Interrupted anywhere, running it again
108// finishes the job.
109func rotateSecrets(cfg config.Config, w io.Writer) error {
110	path := cfg.Server.SecretKeyFile
111	old, err := seal.ReadKeys(path)
112	if err != nil {
113		return err
114	}
115	next, err := seal.NewKey()
116	if err != nil {
117		return err
118	}
119	if err := seal.WriteKeys(path, append(old, next)); err != nil {
120		return err
121	}
122	st, err := openStore(cfg)
123	if err != nil {
124		return err
125	}
126	defer st.Close()
127	keep := fmt.Sprintf("the key file holds the old keys and %s; run rotate again", next.ID)
128	n, err := st.ResealSecrets()
129	if err != nil {
130		return fmt.Errorf("resealing: %w (%s)", err, keep)
131	}
132	// Guards against a value sealed outside the reseal transaction under
133	// an old key; no test reaches it, since that needs a hook between the
134	// two calls.
135	use, err := st.SecretKeyUse()
136	if err != nil {
137		return fmt.Errorf("checking the reseal: %w (%s)", err, keep)
138	}
139	for id, c := range use {
140		if id != next.ID {
141			return fmt.Errorf("%d values are not under %s after the reseal (%s)", c, next.ID, keep)
142		}
143	}
144	if err := seal.WriteKeys(path, []seal.Key{next}); err != nil {
145		return err
146	}
147	retired := make([]string, len(old))
148	for i, k := range old {
149		retired[i] = k.ID
150	}
151	fmt.Fprintf(w, "key %s: resealed %d values; retired %s. Copy %s off this host.\n", next.ID, n, strings.Join(retired, ", "), path)
152	return nil
153}
154
155// checkSecrets opens every stored secret and prints, per column, how
156// many values each key sealed and every value that does not open. Any
157// such value is an error.
158func checkSecrets(cfg config.Config, w io.Writer) error {
159	st, err := openStore(cfg)
160	if err != nil {
161		return err
162	}
163	defer st.Close()
164	report, err := st.SecretReport()
165	if err != nil {
166		return err
167	}
168	failed := 0
169	for _, u := range report {
170		ids := make([]string, 0, len(u.ByKey))
171		for id := range u.ByKey {
172			ids = append(ids, id)
173		}
174		sort.Strings(ids)
175		var parts []string
176		for _, id := range ids {
177			if id == "" {
178				parts = append(parts, fmt.Sprintf("clear %d (sealed when the daemon next starts)", u.ByKey[id]))
179			} else {
180				parts = append(parts, fmt.Sprintf("key %s %d", id, u.ByKey[id]))
181			}
182		}
183		if len(parts) == 0 {
184			parts = []string{"none"}
185		}
186		fmt.Fprintf(w, "%s: %s\n", u.Column, strings.Join(parts, ", "))
187		for _, f := range u.Failed {
188			fmt.Fprintf(w, "%s row %d: %s\n", u.Column, f.RowID, f.Err)
189			failed++
190		}
191	}
192	if failed > 0 {
193		return fmt.Errorf("%s does not open %d stored values", cfg.Server.SecretKeyFile, failed)
194	}
195	return nil
196}