internal/seal/seal.go
310 lines · 8376 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/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}