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