Skip to content
quarry Artifact [1ec3692ed3]
Login

Artifact [1ec3692ed3]

Artifact 1ec3692ed3f38e3e73e13e0e7aaccc62cebbcaa62464c3eda9d00122cf76b2f5:


// secret.go seals the one thing OLTP holds that must not be readable
// from a copy of the database: a bucket's secret key (RFC 0016). The
// sync_user table stores derived secrets and the account table stores
// hashes, so neither needed this; a bucket key has to come back as it
// went in, every time an upload is signed.
//
// The sealing key lives beside the database as secret.key, made on
// first use and never rotated by quarry. A backup of oltp.db alone
// therefore yields no credentials, and a steward who loses the key
// file re-enters their bucket secret on the settings page, which is
// the whole recovery procedure.
package oltp

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"sync"
)

// sealKeyFile is the file's name in the data directory, beside oltp.db.
const sealKeyFile = "secret.key"

// sealer loads the key once, on the first seal or open, so a database
// that never binds a bucket never writes a key file — and a test that
// opens a database in a scratch directory leaves nothing behind.
type sealer struct {
	path string
	once sync.Once
	key  []byte
	err  error
}

func (s *sealer) load() ([]byte, error) {
	s.once.Do(func() { s.key, s.err = loadSealKey(s.path) })
	return s.key, s.err
}

// loadSealKey reads a 32-byte key spelled as 64 hex digits, creating
// it with crypto/rand when the file is absent. O_EXCL so two processes
// opening the same directory at once cannot each write a key and
// leave the other unable to read what it sealed.
func loadSealKey(path string) ([]byte, error) {
	if path == "" {
		return nil, errors.New("oltp: no data directory to keep secret.key in")
	}
	raw, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		fresh := make([]byte, 32)
		if _, err := rand.Read(fresh); err != nil {
			return nil, err
		}
		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
		if errors.Is(err, os.ErrExist) {
			raw, err = os.ReadFile(path)
			if err != nil {
				return nil, err
			}
			return parseSealKey(raw, path)
		}
		if err != nil {
			return nil, err
		}
		if _, err := f.WriteString(hex.EncodeToString(fresh) + "\n"); err != nil {
			f.Close()
			return nil, err
		}
		return fresh, f.Close()
	}
	if err != nil {
		return nil, err
	}
	return parseSealKey(raw, path)
}

func parseSealKey(raw []byte, path string) ([]byte, error) {
	key, err := hex.DecodeString(strings.TrimSpace(string(raw)))
	if err != nil || len(key) != 32 {
		return nil, fmt.Errorf("oltp: %s is not a 32-byte hex key", path)
	}
	return key, nil
}

// seal encrypts plain with AES-256-GCM under a fresh nonce and spells
// the result in base64: nonce first, then the ciphertext and tag.
func (s *sealer) seal(plain string) (string, error) {
	key, err := s.load()
	if err != nil {
		return "", err
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return "", err
	}
	out := gcm.Seal(nonce, nonce, []byte(plain), nil)
	return base64.StdEncoding.EncodeToString(out), nil
}

// open is seal's inverse. A ciphertext the key does not open is
// reported as such rather than as garbage: the commonest cause is a
// secret.key that was lost and regenerated.
func (s *sealer) open(sealed string) (string, error) {
	key, err := s.load()
	if err != nil {
		return "", err
	}
	raw, err := base64.StdEncoding.DecodeString(sealed)
	if err != nil {
		return "", fmt.Errorf("oltp: sealed secret is not base64")
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	if len(raw) < gcm.NonceSize() {
		return "", errors.New("oltp: sealed secret is too short")
	}
	plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
	if err != nil {
		return "", fmt.Errorf("oltp: the sealed secret does not open with %s; if the key file was replaced, enter the secret again", filepath.Base(s.path))
	}
	return string(plain), nil
}