// 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
}