Commit 4cf9a72907
Verified · cmc
Layout: unified · split
.gitbay/wiki/Admin.org +3 −1
| @@ -605,7 +605,9 @@ gitbayd admin secrets rotate # new key, reseal, retire the old one (as root) | |||
| 605 | transaction, then removes the old keys. Run it as root, since it | 605 | transaction, then removes the old keys. Run it as root, since it |
| 606 | replaces the key file in =/etc/gitbay=; the file keeps its owner. | 606 | replaces the key file in =/etc/gitbay=; the file keeps its owner. |
| 607 | The daemon re-reads the file when it changes, so it needs no | 607 | The daemon re-reads the file when it changes, so it needs no |
| 608 | restart. Copy the new file off the host afterwards. | 608 | restart. Copy the new file off the host afterwards. =init= and |
| 609 | =rotate= hold an flock on =<key file>.lock= while they run, so a | ||
| 610 | second run waits for the first. | ||
| 609 | - Push devices are looked up by the SHA-256 of their token | 611 | - Push devices are looked up by the SHA-256 of their token |
| 610 | (=push_devices.token_hash=), since two seals of one token differ. | 612 | (=push_devices.token_hash=), since two seals of one token differ. |
| 611 | 613 | ||
cmd/gitbayd/secrets.go +27
| @@ -65,6 +65,11 @@ keeps its owner. Copy the new file off the host afterwards.`, | |||
| 65 | // the owner of server.root, since the daemon reads it as that user. | 65 | // the owner of server.root, since the daemon reads it as that user. |
| 66 | func initSecrets(cfg config.Config, w io.Writer) error { | 66 | func initSecrets(cfg config.Config, w io.Writer) error { |
| 67 | path := cfg.Server.SecretKeyFile | 67 | path := cfg.Server.SecretKeyFile |
| 68 | unlock, err := lockKeyFile(path) | ||
| 69 | if err != nil { | ||
| 70 | return err | ||
| 71 | } | ||
| 72 | defer unlock() | ||
| 68 | if _, err := os.Lstat(path); err == nil { | 73 | if _, err := os.Lstat(path); err == nil { |
| 69 | return fmt.Errorf("%s already exists; gitbayd admin secrets rotate replaces its key", path) | 74 | return fmt.Errorf("%s already exists; gitbayd admin secrets rotate replaces its key", path) |
| 70 | } else if !errors.Is(err, fs.ErrNotExist) { | 75 | } else if !errors.Is(err, fs.ErrNotExist) { |
| @@ -108,6 +113,11 @@ func initSecrets(cfg config.Config, w io.Writer) error { | |||
| 108 | // finishes the job. | 113 | // finishes the job. |
| 109 | func rotateSecrets(cfg config.Config, w io.Writer) error { | 114 | func rotateSecrets(cfg config.Config, w io.Writer) error { |
| 110 | path := cfg.Server.SecretKeyFile | 115 | path := cfg.Server.SecretKeyFile |
| 116 | unlock, err := lockKeyFile(path) | ||
| 117 | if err != nil { | ||
| 118 | return err | ||
| 119 | } | ||
| 120 | defer unlock() | ||
| 111 | old, err := seal.ReadKeys(path) | 121 | old, err := seal.ReadKeys(path) |
| 112 | if err != nil { | 122 | if err != nil { |
| 113 | return err | 123 | return err |
| @@ -152,6 +162,23 @@ func rotateSecrets(cfg config.Config, w io.Writer) error { | |||
| 152 | return nil | 162 | return nil |
| 153 | } | 163 | } |
| 154 | 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. | ||
| 170 | func 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 | |||
| 155 | // checkSecrets opens every stored secret and prints, per column, how | 182 | // checkSecrets opens every stored secret and prints, per column, how |
| 156 | // many values each key sealed and every value that does not open. Any | 183 | // many values each key sealed and every value that does not open. Any |
| 157 | // such value is an error. | 184 | // such value is an error. |
cmd/gitbayd/secrets_test.go +48
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "path/filepath" | 7 | "path/filepath" |
| 8 | "strings" | 8 | "strings" |
| 9 | "testing" | 9 | "testing" |
| 10 | "time" | ||
| 10 | 11 | ||
| 11 | "gitbay.org/gitbay/internal/config" | 12 | "gitbay.org/gitbay/internal/config" |
| 12 | "gitbay.org/gitbay/internal/seal" | 13 | "gitbay.org/gitbay/internal/seal" |
| @@ -174,3 +175,50 @@ func assertNoKeyMaterial(t *testing.T, out string, keys []seal.Key) { | |||
| 174 | } | 175 | } |
| 175 | } | 176 | } |
| 176 | } | 177 | } |
| 178 | |||
| 179 | // A rotate waits while another holds the key file's lock, and two run | ||
| 180 | // at once both finish with every value still opening. | ||
| 181 | func TestRotateSecretsSerializes(t *testing.T) { | ||
| 182 | cfg := testConfig(t) | ||
| 183 | st, repoID := storeWithSecret(t, cfg) | ||
| 184 | st.Close() | ||
| 185 | |||
| 186 | unlock, err := lockKeyFile(cfg.Server.SecretKeyFile) | ||
| 187 | if err != nil { | ||
| 188 | t.Fatal(err) | ||
| 189 | } | ||
| 190 | done := make(chan error, 2) | ||
| 191 | go func() { done <- rotateSecrets(cfg, &bytes.Buffer{}) }() | ||
| 192 | go func() { done <- rotateSecrets(cfg, &bytes.Buffer{}) }() | ||
| 193 | select { | ||
| 194 | case err := <-done: | ||
| 195 | t.Fatalf("rotate finished while the lock was held: %v", err) | ||
| 196 | case <-time.After(200 * time.Millisecond): | ||
| 197 | } | ||
| 198 | unlock() | ||
| 199 | for range 2 { | ||
| 200 | select { | ||
| 201 | case err := <-done: | ||
| 202 | if err != nil { | ||
| 203 | t.Fatalf("rotate: %v", err) | ||
| 204 | } | ||
| 205 | case <-time.After(30 * time.Second): | ||
| 206 | t.Fatal("rotate never finished") | ||
| 207 | } | ||
| 208 | } | ||
| 209 | keys, err := seal.ReadKeys(cfg.Server.SecretKeyFile) | ||
| 210 | if err != nil || len(keys) != 1 { | ||
| 211 | t.Fatalf("key file after two rotations: %v, %v", keys, err) | ||
| 212 | } | ||
| 213 | st, err = openStore(cfg) | ||
| 214 | if err != nil { | ||
| 215 | t.Fatal(err) | ||
| 216 | } | ||
| 217 | defer st.Close() | ||
| 218 | if got, err := st.BuildSecrets(repoID); err != nil || got["TOKEN"] != "v1" { | ||
| 219 | t.Fatalf("value after two rotations: %v, %v", got, err) | ||
| 220 | } | ||
| 221 | if fi, err := os.Lstat(cfg.Server.SecretKeyFile + ".lock"); err != nil || fi.Mode().Perm() != 0o600 { | ||
| 222 | t.Fatalf("lock file: %v, %v", fi, err) | ||
| 223 | } | ||
| 224 | } | ||