cmd/gitbayd/secrets.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/cmd/gitbayd/secrets.go history · blame · raw

223 lines · 6861 bytes

5 symbols in this file
  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	unlock, err := lockKeyFile(path)
 69	if err != nil {
 70		return err
 71	}
 72	defer unlock()
 73	if _, err := os.Lstat(path); err == nil {
 74		return fmt.Errorf("%s already exists; gitbayd admin secrets rotate replaces its key", path)
 75	} else if !errors.Is(err, fs.ErrNotExist) {
 76		return err
 77	}
 78	uid, gid := -1, -1
 79	if os.Geteuid() == 0 {
 80		fi, err := os.Stat(cfg.Server.Root)
 81		if err != nil {
 82			return fmt.Errorf("the key file is given to the owner of server.root: %w", err)
 83		}
 84		st, ok := fi.Sys().(*syscall.Stat_t)
 85		if !ok {
 86			return fmt.Errorf("cannot read the owner of %s", cfg.Server.Root)
 87		}
 88		uid, gid = int(st.Uid), int(st.Gid)
 89	}
 90	k, err := seal.NewKey()
 91	if err != nil {
 92		return err
 93	}
 94	if err := seal.WriteKeys(path, []seal.Key{k}); err != nil {
 95		return err
 96	}
 97	if uid >= 0 {
 98		if err := os.Chown(path, uid, gid); err != nil {
 99			// A root-owned file left behind would make a re-run refuse.
100			os.Remove(path)
101			return fmt.Errorf("could not give %s to the owner of %s, so it was removed: %w", path, cfg.Server.Root, err)
102		}
103	}
104	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)
105	return nil
106}
107
108// rotateSecrets adds a key, reseals under it, then drops the old keys.
109// Each step leaves a file that opens every stored value: after the first
110// write the file holds old and new keys; the reseal is one transaction;
111// the last write happens only after the reseal committed and every value
112// is confirmed under the new key. Interrupted anywhere, running it again
113// finishes the job.
114func rotateSecrets(cfg config.Config, w io.Writer) error {
115	path := cfg.Server.SecretKeyFile
116	unlock, err := lockKeyFile(path)
117	if err != nil {
118		return err
119	}
120	defer unlock()
121	old, err := seal.ReadKeys(path)
122	if err != nil {
123		return err
124	}
125	next, err := seal.NewKey()
126	if err != nil {
127		return err
128	}
129	if err := seal.WriteKeys(path, append(old, next)); err != nil {
130		return err
131	}
132	st, err := openStore(cfg)
133	if err != nil {
134		return err
135	}
136	defer st.Close()
137	keep := fmt.Sprintf("the key file holds the old keys and %s; run rotate again", next.ID)
138	n, err := st.ResealSecrets()
139	if err != nil {
140		return fmt.Errorf("resealing: %w (%s)", err, keep)
141	}
142	// Guards against a value sealed outside the reseal transaction under
143	// an old key; no test reaches it, since that needs a hook between the
144	// two calls.
145	use, err := st.SecretKeyUse()
146	if err != nil {
147		return fmt.Errorf("checking the reseal: %w (%s)", err, keep)
148	}
149	for id, c := range use {
150		if id != next.ID {
151			return fmt.Errorf("%d values are not under %s after the reseal (%s)", c, next.ID, keep)
152		}
153	}
154	if err := seal.WriteKeys(path, []seal.Key{next}); err != nil {
155		return err
156	}
157	retired := make([]string, len(old))
158	for i, k := range old {
159		retired[i] = k.ID
160	}
161	fmt.Fprintf(w, "key %s: resealed %d values; retired %s. Copy %s off this host.\n", next.ID, n, strings.Join(retired, ", "), path)
162	return nil
163}
164
165// lockKeyFile takes an exclusive flock on <path>.lock, waiting for
166// another init or rotate to finish. Two rotations interleaved would each
167// write a file without the other's new key, and values resealed under
168// the lost one would no longer open. O_NOFOLLOW refuses a symlink planted
169// at the lock's name.
170func lockKeyFile(path string) (func(), error) {
171	f, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE|syscall.O_NOFOLLOW, 0o600)
172	if err != nil {
173		return nil, fmt.Errorf("key file lock: %w", err)
174	}
175	if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
176		f.Close()
177		return nil, fmt.Errorf("key file lock: %w", err)
178	}
179	return func() { f.Close() }, nil
180}
181
182// checkSecrets opens every stored secret and prints, per column, how
183// many values each key sealed and every value that does not open. Any
184// such value is an error.
185func checkSecrets(cfg config.Config, w io.Writer) error {
186	st, err := openStore(cfg)
187	if err != nil {
188		return err
189	}
190	defer st.Close()
191	report, err := st.SecretReport()
192	if err != nil {
193		return err
194	}
195	failed := 0
196	for _, u := range report {
197		ids := make([]string, 0, len(u.ByKey))
198		for id := range u.ByKey {
199			ids = append(ids, id)
200		}
201		sort.Strings(ids)
202		var parts []string
203		for _, id := range ids {
204			if id == "" {
205				parts = append(parts, fmt.Sprintf("clear %d (sealed when the daemon next starts)", u.ByKey[id]))
206			} else {
207				parts = append(parts, fmt.Sprintf("key %s %d", id, u.ByKey[id]))
208			}
209		}
210		if len(parts) == 0 {
211			parts = []string{"none"}
212		}
213		fmt.Fprintf(w, "%s: %s\n", u.Column, strings.Join(parts, ", "))
214		for _, f := range u.Failed {
215			fmt.Fprintf(w, "%s row %d: %s\n", u.Column, f.RowID, f.Err)
216			failed++
217		}
218	}
219	if failed > 0 {
220		return fmt.Errorf("%s does not open %d stored values", cfg.Server.SecretKeyFile, failed)
221	}
222	return nil
223}