internal/seal/seal.go
340 lines · 9331 bytes
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/hmac"
14 "crypto/rand"
15 "crypto/sha256"
16 "encoding/base64"
17 "encoding/hex"
18 "errors"
19 "fmt"
20 "io"
21 "os"
22 "path/filepath"
23 "strings"
24 "sync"
25 "syscall"
26)
27
28// Prefix marks a sealed value: "gbs1:<key id>:<base64 nonce||ciphertext>".
29const Prefix = "gbs1:"
30
31// maxKeyFile is the largest key file ReadKeys accepts.
32const maxKeyFile = 1 << 20
33
34// Key is one line of the key file.
35type Key struct {
36 ID string // 8 lowercase hex characters
37 Secret []byte // 32 bytes
38}
39
40// NewKey returns a key with a random id and secret.
41func NewKey() (Key, error) {
42 id := make([]byte, 4)
43 secret := make([]byte, 32)
44 if _, err := rand.Read(id); err != nil {
45 return Key{}, err
46 }
47 if _, err := rand.Read(secret); err != nil {
48 return Key{}, err
49 }
50 return Key{ID: hex.EncodeToString(id), Secret: secret}, nil
51}
52
53// ReadKeys reads the key file. The last key seals; every key opens.
54func ReadKeys(path string) ([]Key, error) {
55 f, err := os.Open(path)
56 if err != nil {
57 return nil, err
58 }
59 defer f.Close()
60 fi, err := f.Stat()
61 if err != nil {
62 return nil, err
63 }
64 if !fi.Mode().IsRegular() {
65 return nil, fmt.Errorf("%s is not a regular file", path)
66 }
67 if perm := fi.Mode().Perm(); perm&0o077 != 0 {
68 return nil, fmt.Errorf("%s is mode %04o; it must be readable by its owner alone (0600)", path, perm)
69 }
70 data, err := io.ReadAll(io.LimitReader(f, maxKeyFile+1))
71 if err != nil {
72 return nil, err
73 }
74 if len(data) > maxKeyFile {
75 return nil, fmt.Errorf("%s is larger than %d bytes", path, maxKeyFile)
76 }
77 var keys []Key
78 seen := map[string]bool{}
79 sc := bufio.NewScanner(bytes.NewReader(data))
80 for n := 1; sc.Scan(); n++ {
81 line := strings.TrimSpace(sc.Text())
82 if line == "" || strings.HasPrefix(line, "#") {
83 continue
84 }
85 f := strings.Fields(line)
86 if len(f) != 2 || !validID(f[0]) {
87 return nil, fmt.Errorf("%s:%d: want \"<8 hex id> <base64 32-byte key>\"", path, n)
88 }
89 secret, err := base64.StdEncoding.DecodeString(f[1])
90 if err != nil || len(secret) != 32 {
91 return nil, fmt.Errorf("%s:%d: key is not 32 bytes of base64", path, n)
92 }
93 if seen[f[0]] {
94 return nil, fmt.Errorf("%s:%d: key id %s appears twice", path, n, f[0])
95 }
96 seen[f[0]] = true
97 keys = append(keys, Key{ID: f[0], Secret: secret})
98 }
99 if err := sc.Err(); err != nil {
100 return nil, err
101 }
102 if len(keys) == 0 {
103 return nil, fmt.Errorf("%s holds no keys", path)
104 }
105 return keys, nil
106}
107
108// WriteKeys replaces the key file: a temporary file in the same
109// directory, mode 0600, given the existing file's owner when there is
110// one (rotation runs as root; the daemon reads the file as its own
111// user), then renamed over it. Keys ReadKeys would refuse are refused
112// before anything is written.
113func WriteKeys(path string, keys []Key) error {
114 if len(keys) == 0 {
115 return errors.New("no keys to write")
116 }
117 seen := map[string]bool{}
118 for _, k := range keys {
119 if !validID(k.ID) || len(k.Secret) != 32 || seen[k.ID] {
120 return fmt.Errorf("key %q is not an 8-hex-id, 32-byte key or appears twice", k.ID)
121 }
122 seen[k.ID] = true
123 }
124 var b strings.Builder
125 b.WriteString("# gitbay secret keys, \"<id> <base64 key>\" per line. The last line seals\n")
126 b.WriteString("# new values; the others open values sealed before a rotation.\n")
127 b.WriteString("# Keep a copy off this host: backups do not carry this file.\n")
128 for _, k := range keys {
129 fmt.Fprintf(&b, "%s %s\n", k.ID, base64.StdEncoding.EncodeToString(k.Secret))
130 }
131 dir := filepath.Dir(path)
132 tmp, err := os.CreateTemp(dir, ".secret-key-*")
133 if err != nil {
134 return err
135 }
136 defer os.Remove(tmp.Name())
137 fail := func(err error) error {
138 tmp.Close()
139 return err
140 }
141 if err := tmp.Chmod(0o600); err != nil {
142 return fail(err)
143 }
144 if fi, err := os.Stat(path); err == nil {
145 if st, ok := fi.Sys().(*syscall.Stat_t); ok {
146 if err := tmp.Chown(int(st.Uid), int(st.Gid)); err != nil {
147 return fail(err)
148 }
149 }
150 }
151 if _, err := tmp.WriteString(b.String()); err != nil {
152 return fail(err)
153 }
154 if err := tmp.Sync(); err != nil {
155 return fail(err)
156 }
157 if err := tmp.Close(); err != nil {
158 return err
159 }
160 if err := os.Rename(tmp.Name(), path); err != nil {
161 return err
162 }
163 // Losing the file loses every sealed value, so the rename is made
164 // durable before returning.
165 d, err := os.Open(dir)
166 if err == nil {
167 err = d.Sync()
168 d.Close()
169 }
170 if err != nil {
171 return fmt.Errorf("%s was replaced, but syncing %s failed: %w", path, dir, err)
172 }
173 return nil
174}
175
176// Keyring is the loaded key file. It re-reads the file whenever the file
177// changes, so a running daemon follows a rotation without a restart.
178type Keyring struct {
179 path string
180
181 mu sync.Mutex
182 fi os.FileInfo
183 cur string
184 aead map[string]cipher.AEAD
185 // secrets holds every key's secret, the current key's first.
186 secrets [][]byte
187}
188
189// Load reads the key file at path and returns a Keyring over it. It
190// fails when ReadKeys would.
191func Load(path string) (*Keyring, error) {
192 k := &Keyring{path: path}
193 if err := k.refresh(); err != nil {
194 return nil, err
195 }
196 return k, nil
197}
198
199// refresh reloads the file unless it is the one last read. Callers hold k.mu.
200func (k *Keyring) refresh() error {
201 fi, err := os.Stat(k.path)
202 if err != nil {
203 return err
204 }
205 if k.fi != nil && os.SameFile(k.fi, fi) && fi.ModTime().Equal(k.fi.ModTime()) && fi.Size() == k.fi.Size() {
206 return nil
207 }
208 keys, err := ReadKeys(k.path)
209 if err != nil {
210 return err
211 }
212 aead := make(map[string]cipher.AEAD, len(keys))
213 secrets := make([][]byte, 0, len(keys))
214 for i := len(keys) - 1; i >= 0; i-- {
215 secrets = append(secrets, keys[i].Secret)
216 }
217 for _, key := range keys {
218 block, err := aes.NewCipher(key.Secret)
219 if err != nil {
220 return err
221 }
222 g, err := cipher.NewGCM(block)
223 if err != nil {
224 return err
225 }
226 aead[key.ID] = g
227 }
228 k.fi, k.cur, k.aead, k.secrets = fi, keys[len(keys)-1].ID, aead, secrets
229 return nil
230}
231
232// Derive returns a 32-byte key for purpose from every key in the file,
233// the current key's first: HMAC-SHA256 of purpose under each secret. A
234// value authenticated under the first still verifies under the others
235// after a rotation, while the retired key stays in the file.
236func (k *Keyring) Derive(purpose string) ([][]byte, error) {
237 if purpose == "" {
238 return nil, errors.New("seal: a purpose is required")
239 }
240 k.mu.Lock()
241 defer k.mu.Unlock()
242 if err := k.refresh(); err != nil {
243 return nil, err
244 }
245 out := make([][]byte, 0, len(k.secrets))
246 for _, s := range k.secrets {
247 m := hmac.New(sha256.New, s)
248 m.Write([]byte(purpose))
249 out = append(out, m.Sum(nil))
250 }
251 return out, nil
252}
253
254// CurrentID is the id of the key that seals new values.
255func (k *Keyring) CurrentID() (string, error) {
256 k.mu.Lock()
257 defer k.mu.Unlock()
258 if err := k.refresh(); err != nil {
259 return "", err
260 }
261 return k.cur, nil
262}
263
264// Seal encrypts plain under the current key with a random nonce. aad
265// names the column, so a value copied into another column does not open
266// there.
267func (k *Keyring) Seal(aad, plain string) (string, error) {
268 if aad == "" {
269 return "", errNoAAD
270 }
271 k.mu.Lock()
272 defer k.mu.Unlock()
273 if err := k.refresh(); err != nil {
274 return "", err
275 }
276 g := k.aead[k.cur]
277 nonce := make([]byte, g.NonceSize())
278 if _, err := rand.Read(nonce); err != nil {
279 return "", err
280 }
281 ct := g.Seal(nonce, nonce, []byte(plain), []byte(aad))
282 return Prefix + k.cur + ":" + base64.RawStdEncoding.EncodeToString(ct), nil
283}
284
285// Open decrypts a value Seal produced under any key the file holds.
286func (k *Keyring) Open(aad, sealed string) (string, error) {
287 if aad == "" {
288 return "", errNoAAD
289 }
290 id, body, ok := split(sealed)
291 if !ok {
292 return "", errors.New("not a sealed value")
293 }
294 k.mu.Lock()
295 defer k.mu.Unlock()
296 if err := k.refresh(); err != nil {
297 return "", err
298 }
299 g, ok := k.aead[id]
300 if !ok {
301 return "", fmt.Errorf("sealed with key %s, which %s does not hold", id, k.path)
302 }
303 ct, err := base64.RawStdEncoding.DecodeString(body)
304 if err != nil || len(ct) < g.NonceSize()+g.Overhead() {
305 return "", fmt.Errorf("value sealed with key %s is malformed", id)
306 }
307 plain, err := g.Open(nil, ct[:g.NonceSize()], ct[g.NonceSize():], []byte(aad))
308 if err != nil {
309 return "", fmt.Errorf("value sealed with key %s does not open: wrong key, wrong column or altered value", id)
310 }
311 return string(plain), nil
312}
313
314var errNoAAD = errors.New("seal: additional data (table.column) is required")
315
316// IsSealed reports whether v carries the sealed prefix.
317func IsSealed(v string) bool { return strings.HasPrefix(v, Prefix) }
318
319// KeyID is the id of the key that sealed v.
320func KeyID(v string) (string, bool) {
321 id, _, ok := split(v)
322 return id, ok
323}
324
325func split(v string) (id, body string, ok bool) {
326 rest, ok := strings.CutPrefix(v, Prefix)
327 if !ok {
328 return "", "", false
329 }
330 id, body, ok = strings.Cut(rest, ":")
331 return id, body, ok && validID(id)
332}
333
334func validID(s string) bool {
335 if len(s) != 8 || strings.ToLower(s) != s {
336 return false
337 }
338 _, err := hex.DecodeString(s)
339 return err == nil
340}