cmd/gitbayd/secrets.go
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}