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}