Commit ed99c936ad

ed99c936ada43b40b302f51a0a2f22112d2fe9d5

parent: e5b80f7a20

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:36 UTC

seal: AES-256-GCM keyring for secret columns

Ref #273

Layout: unified · split

internal/seal/seal.go added +310
@@ -0,0 +1,310 @@
1// Package seal encrypts the secret columns of the database with
2// AES-256-GCM under keys held in a file outside the database and outside
3// server.root, so neither a copy of the database nor a backup opens them
4// (#273). A key file that cannot be re-read after it changes fails
5// closed: Seal and Open return errors until the file is fixed.
6package seal
7
8import (
9 "bufio"
10 "bytes"
11 "crypto/aes"
12 "crypto/cipher"
13 "crypto/rand"
14 "encoding/base64"
15 "encoding/hex"
16 "errors"
17 "fmt"
18 "io"
19 "os"
20 "path/filepath"
21 "strings"
22 "sync"
23 "syscall"
24)
25
26// Prefix marks a sealed value: "gbs1:<key id>:<base64 nonce||ciphertext>".
27const Prefix = "gbs1:"
28
29// maxKeyFile is the largest key file ReadKeys accepts.
30const maxKeyFile = 1 << 20
31
32// Key is one line of the key file.
33type Key struct {
34 ID string // 8 lowercase hex characters
35 Secret []byte // 32 bytes
36}
37
38// NewKey returns a key with a random id and secret.
39func NewKey() (Key, error) {
40 id := make([]byte, 4)
41 secret := make([]byte, 32)
42 if _, err := rand.Read(id); err != nil {
43 return Key{}, err
44 }
45 if _, err := rand.Read(secret); err != nil {
46 return Key{}, err
47 }
48 return Key{ID: hex.EncodeToString(id), Secret: secret}, nil
49}
50
51// ReadKeys reads the key file. The last key seals; every key opens.
52func ReadKeys(path string) ([]Key, error) {
53 f, err := os.Open(path)
54 if err != nil {
55 return nil, err
56 }
57 defer f.Close()
58 fi, err := f.Stat()
59 if err != nil {
60 return nil, err
61 }
62 if !fi.Mode().IsRegular() {
63 return nil, fmt.Errorf("%s is not a regular file", path)
64 }
65 if perm := fi.Mode().Perm(); perm&0o077 != 0 {
66 return nil, fmt.Errorf("%s is mode %04o; it must be readable by its owner alone (0600)", path, perm)
67 }
68 data, err := io.ReadAll(io.LimitReader(f, maxKeyFile+1))
69 if err != nil {
70 return nil, err
71 }
72 if len(data) > maxKeyFile {
73 return nil, fmt.Errorf("%s is larger than %d bytes", path, maxKeyFile)
74 }
75 var keys []Key
76 seen := map[string]bool{}
77 sc := bufio.NewScanner(bytes.NewReader(data))
78 for n := 1; sc.Scan(); n++ {
79 line := strings.TrimSpace(sc.Text())
80 if line == "" || strings.HasPrefix(line, "#") {
81 continue
82 }
83 f := strings.Fields(line)
84 if len(f) != 2 || !validID(f[0]) {
85 return nil, fmt.Errorf("%s:%d: want \"<8 hex id> <base64 32-byte key>\"", path, n)
86 }
87 secret, err := base64.StdEncoding.DecodeString(f[1])
88 if err != nil || len(secret) != 32 {
89 return nil, fmt.Errorf("%s:%d: key is not 32 bytes of base64", path, n)
90 }
91 if seen[f[0]] {
92 return nil, fmt.Errorf("%s:%d: key id %s appears twice", path, n, f[0])
93 }
94 seen[f[0]] = true
95 keys = append(keys, Key{ID: f[0], Secret: secret})
96 }
97 if err := sc.Err(); err != nil {
98 return nil, err
99 }
100 if len(keys) == 0 {
101 return nil, fmt.Errorf("%s holds no keys", path)
102 }
103 return keys, nil
104}
105
106// WriteKeys replaces the key file: a temporary file in the same
107// directory, mode 0600, given the existing file's owner when there is
108// one (rotation runs as root; the daemon reads the file as its own
109// user), then renamed over it. Keys ReadKeys would refuse are refused
110// before anything is written.
111func WriteKeys(path string, keys []Key) error {
112 if len(keys) == 0 {
113 return errors.New("no keys to write")
114 }
115 seen := map[string]bool{}
116 for _, k := range keys {
117 if !validID(k.ID) || len(k.Secret) != 32 || seen[k.ID] {
118 return fmt.Errorf("key %q is not an 8-hex-id, 32-byte key or appears twice", k.ID)
119 }
120 seen[k.ID] = true
121 }
122 var b strings.Builder
123 b.WriteString("# gitbay secret keys, \"<id> <base64 key>\" per line. The last line seals\n")
124 b.WriteString("# new values; the others open values sealed before a rotation.\n")
125 b.WriteString("# Keep a copy off this host: backups do not carry this file.\n")
126 for _, k := range keys {
127 fmt.Fprintf(&b, "%s %s\n", k.ID, base64.StdEncoding.EncodeToString(k.Secret))
128 }
129 dir := filepath.Dir(path)
130 tmp, err := os.CreateTemp(dir, ".secret-key-*")
131 if err != nil {
132 return err
133 }
134 defer os.Remove(tmp.Name())
135 fail := func(err error) error {
136 tmp.Close()
137 return err
138 }
139 if err := tmp.Chmod(0o600); err != nil {
140 return fail(err)
141 }
142 if fi, err := os.Stat(path); err == nil {
143 if st, ok := fi.Sys().(*syscall.Stat_t); ok {
144 if err := tmp.Chown(int(st.Uid), int(st.Gid)); err != nil {
145 return fail(err)
146 }
147 }
148 }
149 if _, err := tmp.WriteString(b.String()); err != nil {
150 return fail(err)
151 }
152 if err := tmp.Sync(); err != nil {
153 return fail(err)
154 }
155 if err := tmp.Close(); err != nil {
156 return err
157 }
158 if err := os.Rename(tmp.Name(), path); err != nil {
159 return err
160 }
161 // Losing the file loses every sealed value, so the rename is made
162 // durable before returning.
163 d, err := os.Open(dir)
164 if err == nil {
165 err = d.Sync()
166 d.Close()
167 }
168 if err != nil {
169 return fmt.Errorf("%s was replaced, but syncing %s failed: %w", path, dir, err)
170 }
171 return nil
172}
173
174// Keyring is the loaded key file. It re-reads the file whenever the file
175// changes, so a running daemon follows a rotation without a restart.
176type Keyring struct {
177 path string
178
179 mu sync.Mutex
180 fi os.FileInfo
181 cur string
182 aead map[string]cipher.AEAD
183}
184
185// Load reads the key file at path and returns a Keyring over it. It
186// fails when ReadKeys would.
187func Load(path string) (*Keyring, error) {
188 k := &Keyring{path: path}
189 if err := k.refresh(); err != nil {
190 return nil, err
191 }
192 return k, nil
193}
194
195// refresh reloads the file unless it is the one last read. Callers hold k.mu.
196func (k *Keyring) refresh() error {
197 fi, err := os.Stat(k.path)
198 if err != nil {
199 return err
200 }
201 if k.fi != nil && os.SameFile(k.fi, fi) && fi.ModTime().Equal(k.fi.ModTime()) && fi.Size() == k.fi.Size() {
202 return nil
203 }
204 keys, err := ReadKeys(k.path)
205 if err != nil {
206 return err
207 }
208 aead := make(map[string]cipher.AEAD, len(keys))
209 for _, key := range keys {
210 block, err := aes.NewCipher(key.Secret)
211 if err != nil {
212 return err
213 }
214 g, err := cipher.NewGCM(block)
215 if err != nil {
216 return err
217 }
218 aead[key.ID] = g
219 }
220 k.fi, k.cur, k.aead = fi, keys[len(keys)-1].ID, aead
221 return nil
222}
223
224// CurrentID is the id of the key that seals new values.
225func (k *Keyring) CurrentID() (string, error) {
226 k.mu.Lock()
227 defer k.mu.Unlock()
228 if err := k.refresh(); err != nil {
229 return "", err
230 }
231 return k.cur, nil
232}
233
234// Seal encrypts plain under the current key with a random nonce. aad
235// names the column, so a value copied into another column does not open
236// there.
237func (k *Keyring) Seal(aad, plain string) (string, error) {
238 if aad == "" {
239 return "", errNoAAD
240 }
241 k.mu.Lock()
242 defer k.mu.Unlock()
243 if err := k.refresh(); err != nil {
244 return "", err
245 }
246 g := k.aead[k.cur]
247 nonce := make([]byte, g.NonceSize())
248 if _, err := rand.Read(nonce); err != nil {
249 return "", err
250 }
251 ct := g.Seal(nonce, nonce, []byte(plain), []byte(aad))
252 return Prefix + k.cur + ":" + base64.RawStdEncoding.EncodeToString(ct), nil
253}
254
255// Open decrypts a value Seal produced under any key the file holds.
256func (k *Keyring) Open(aad, sealed string) (string, error) {
257 if aad == "" {
258 return "", errNoAAD
259 }
260 id, body, ok := split(sealed)
261 if !ok {
262 return "", errors.New("not a sealed value")
263 }
264 k.mu.Lock()
265 defer k.mu.Unlock()
266 if err := k.refresh(); err != nil {
267 return "", err
268 }
269 g, ok := k.aead[id]
270 if !ok {
271 return "", fmt.Errorf("sealed with key %s, which %s does not hold", id, k.path)
272 }
273 ct, err := base64.RawStdEncoding.DecodeString(body)
274 if err != nil || len(ct) < g.NonceSize()+g.Overhead() {
275 return "", fmt.Errorf("value sealed with key %s is malformed", id)
276 }
277 plain, err := g.Open(nil, ct[:g.NonceSize()], ct[g.NonceSize():], []byte(aad))
278 if err != nil {
279 return "", fmt.Errorf("value sealed with key %s does not open: wrong key, wrong column or altered value", id)
280 }
281 return string(plain), nil
282}
283
284var errNoAAD = errors.New("seal: additional data (table.column) is required")
285
286// IsSealed reports whether v carries the sealed prefix.
287func IsSealed(v string) bool { return strings.HasPrefix(v, Prefix) }
288
289// KeyID is the id of the key that sealed v.
290func KeyID(v string) (string, bool) {
291 id, _, ok := split(v)
292 return id, ok
293}
294
295func split(v string) (id, body string, ok bool) {
296 rest, ok := strings.CutPrefix(v, Prefix)
297 if !ok {
298 return "", "", false
299 }
300 id, body, ok = strings.Cut(rest, ":")
301 return id, body, ok && validID(id)
302}
303
304func validID(s string) bool {
305 if len(s) != 8 || strings.ToLower(s) != s {
306 return false
307 }
308 _, err := hex.DecodeString(s)
309 return err == nil
310}
internal/seal/seal_test.go added +234
@@ -0,0 +1,234 @@
1package seal
2
3import (
4 "encoding/base64"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func keyFile(t *testing.T, keys ...Key) string {
12 t.Helper()
13 path := filepath.Join(t.TempDir(), "secret.key")
14 if err := WriteKeys(path, keys); err != nil {
15 t.Fatal(err)
16 }
17 return path
18}
19
20func newKey(t *testing.T) Key {
21 t.Helper()
22 k, err := NewKey()
23 if err != nil {
24 t.Fatal(err)
25 }
26 return k
27}
28
29func TestSealOpenRoundTrip(t *testing.T) {
30 k := newKey(t)
31 ring, err := Load(keyFile(t, k))
32 if err != nil {
33 t.Fatal(err)
34 }
35 v, err := ring.Seal("build_secrets.value", "hunter2")
36 if err != nil {
37 t.Fatal(err)
38 }
39 if !strings.HasPrefix(v, Prefix+k.ID+":") || strings.Contains(v, "hunter2") {
40 t.Fatalf("sealed value %q", v)
41 }
42 if id, ok := KeyID(v); !ok || id != k.ID {
43 t.Fatalf("KeyID = %q, %v", id, ok)
44 }
45 got, err := ring.Open("build_secrets.value", v)
46 if err != nil || got != "hunter2" {
47 t.Fatalf("Open = %q, %v", got, err)
48 }
49 // Two seals of one value differ: the nonce is random.
50 if w, _ := ring.Seal("build_secrets.value", "hunter2"); w == v {
51 t.Fatal("two seals produced the same value")
52 }
53}
54
55// A value moved to another column does not open there.
56func TestOpenChecksAdditionalData(t *testing.T) {
57 ring, err := Load(keyFile(t, newKey(t)))
58 if err != nil {
59 t.Fatal(err)
60 }
61 v, _ := ring.Seal("mirrors.token", "tok")
62 if _, err := ring.Open("webhooks.secret", v); err == nil {
63 t.Fatal("opened under the wrong column")
64 }
65}
66
67func TestOpenRefusesAnAlteredValue(t *testing.T) {
68 ring, err := Load(keyFile(t, newKey(t)))
69 if err != nil {
70 t.Fatal(err)
71 }
72 v, _ := ring.Seal("mirrors.token", "tok")
73 // A character in the middle: the last one may carry only padding bits.
74 i := len(v) - 10
75 alt := byte('A')
76 if v[i] == 'A' {
77 alt = 'B'
78 }
79 if _, err := ring.Open("mirrors.token", v[:i]+string(alt)+v[i+1:]); err == nil {
80 t.Fatal("opened an altered value")
81 }
82 if _, err := ring.Open("mirrors.token", "tok"); err == nil {
83 t.Fatal("opened a clear value")
84 }
85}
86
87// A running daemon sees a rotation without a restart: the ring re-reads
88// the file when it changes.
89func TestKeyringFollowsTheFile(t *testing.T) {
90 old, next := newKey(t), newKey(t)
91 path := keyFile(t, old)
92 ring, err := Load(path)
93 if err != nil {
94 t.Fatal(err)
95 }
96 before, _ := ring.Seal("webhooks.secret", "s")
97 if err := WriteKeys(path, []Key{old, next}); err != nil {
98 t.Fatal(err)
99 }
100 after, err := ring.Seal("webhooks.secret", "s")
101 if err != nil {
102 t.Fatal(err)
103 }
104 if id, _ := KeyID(after); id != next.ID {
105 t.Fatalf("sealed under %s after rotation, want %s", id, next.ID)
106 }
107 if got, err := ring.Open("webhooks.secret", before); err != nil || got != "s" {
108 t.Fatalf("old value after rotation: %q, %v", got, err)
109 }
110 if err := WriteKeys(path, []Key{next}); err != nil {
111 t.Fatal(err)
112 }
113 if _, err := ring.Open("webhooks.secret", before); err == nil || !strings.Contains(err.Error(), old.ID) {
114 t.Fatalf("a retired key's value opened, or the error does not name the key: %v", err)
115 }
116}
117
118func TestReadKeysRefusesAReadableFile(t *testing.T) {
119 path := keyFile(t, newKey(t))
120 if err := os.Chmod(path, 0o640); err != nil {
121 t.Fatal(err)
122 }
123 if _, err := ReadKeys(path); err == nil || !strings.Contains(err.Error(), "0600") {
124 t.Fatalf("group-readable key file: %v", err)
125 }
126}
127
128func TestWriteKeysMode(t *testing.T) {
129 path := keyFile(t, newKey(t))
130 fi, err := os.Stat(path)
131 if err != nil {
132 t.Fatal(err)
133 }
134 if fi.Mode().Perm() != 0o600 {
135 t.Fatalf("mode %04o", fi.Mode().Perm())
136 }
137}
138
139func TestWriteKeysRefusesABadKey(t *testing.T) {
140 path := filepath.Join(t.TempDir(), "secret.key")
141 good := newKey(t)
142 for _, keys := range [][]Key{
143 nil,
144 {{ID: good.ID, Secret: good.Secret[:16]}},
145 {{ID: "XYZ12345", Secret: good.Secret}},
146 {good, good},
147 } {
148 if err := WriteKeys(path, keys); err == nil {
149 t.Errorf("wrote %d keys that do not read back", len(keys))
150 }
151 }
152 if _, err := os.Stat(path); !os.IsNotExist(err) {
153 t.Fatalf("a refused write left a file: %v", err)
154 }
155}
156
157func TestReadKeysRejectsMalformedLines(t *testing.T) {
158 for _, body := range []string{
159 "",
160 "# only a comment\n",
161 "XYZ12345 AAAA\n",
162 "0123abcd bm90IDMyIGJ5dGVz\n",
163 } {
164 path := filepath.Join(t.TempDir(), "k")
165 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
166 t.Fatal(err)
167 }
168 if _, err := ReadKeys(path); err == nil {
169 t.Errorf("accepted %q", body)
170 }
171 }
172}
173
174func TestOpenRefusesMalformedInput(t *testing.T) {
175 k := newKey(t)
176 ring, err := Load(keyFile(t, k))
177 if err != nil {
178 t.Fatal(err)
179 }
180 for _, v := range []string{
181 "gbs1:",
182 "gbs1:" + k.ID + ":",
183 "gbs1:" + strings.ToUpper(k.ID) + ":AAAA",
184 "gbs1:" + k.ID[:7] + ":AAAA",
185 "gbs1:" + k.ID + ":!!!not base64!!!",
186 "gbs1:" + k.ID + ":AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // 29 bytes
187 "gbs1:0badf00d:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
188 } {
189 if got, err := ring.Open("mirrors.token", v); err == nil {
190 t.Errorf("Open(%q) = %q, want an error", v, got)
191 }
192 }
193}
194
195func TestEmptyAdditionalDataRefused(t *testing.T) {
196 ring, err := Load(keyFile(t, newKey(t)))
197 if err != nil {
198 t.Fatal(err)
199 }
200 if _, err := ring.Seal("", "s"); err == nil {
201 t.Fatal("sealed with no column")
202 }
203 v, _ := ring.Seal("mirrors.token", "s")
204 if _, err := ring.Open("", v); err == nil {
205 t.Fatal("opened with no column")
206 }
207}
208
209func TestReadKeysRefusesDuplicatesDirectoriesAndLargeFiles(t *testing.T) {
210 k := newKey(t)
211 line := k.ID + " " + base64.StdEncoding.EncodeToString(k.Secret) + "\n"
212 dup := filepath.Join(t.TempDir(), "dup")
213 if err := os.WriteFile(dup, []byte(line+line), 0o600); err != nil {
214 t.Fatal(err)
215 }
216 if _, err := ReadKeys(dup); err == nil || !strings.Contains(err.Error(), "twice") {
217 t.Errorf("duplicate id: %v", err)
218 }
219 dir := filepath.Join(t.TempDir(), "d")
220 if err := os.Mkdir(dir, 0o700); err != nil {
221 t.Fatal(err)
222 }
223 if _, err := ReadKeys(dir); err == nil {
224 t.Error("read a directory")
225 }
226 big := filepath.Join(t.TempDir(), "big")
227 body := line + "#" + strings.Repeat("x", maxKeyFile) + "\n"
228 if err := os.WriteFile(big, []byte(body), 0o600); err != nil {
229 t.Fatal(err)
230 }
231 if _, err := ReadKeys(big); err == nil {
232 t.Error("read a file over the size limit")
233 }
234}