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}