f1a809121b
mavend seals its encrypted database in `defer st.Close()` when run() returns. It had not returned since 2026-07-21. Every restart since then decrypted the same eleven-day-old ciphertext and rolled back everything written in between: the Telegram nudge that kept firing was a fact being un-written on each boot. The goroutine dump named it. main → srv.Close() → ipc.(*Server).Close → wg.Wait(), waiting on per-connection goroutines parked in readFrame. Close shut the listener and nothing else, so the idle persistent sockets held by mavweb, mavpoll, mavcaldav and mavmaild blocked shutdown forever. `docker compose stop -t 60` spent the whole sixty seconds and then took a SIGKILL. So: track the accepted conns and close them, in ipc and in voice, which had the identical defect. Bound all three waits — the two per-server ones and the worker wait in main — because the seal matters more than any single in-flight call. A dropped RPC costs one reply; a missed seal costs a session. The regression test leaves a client connected and idle, which is the case the old tests avoided by closing the client first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
302 lines
9.7 KiB
Go
302 lines
9.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// At-rest encryption for the sqlite store.
|
|
//
|
|
// The on-disk file at cfg.DBPath is ALWAYS ciphertext: a fixed magic header +
|
|
// a random 12-byte GCM nonce + AES-256-GCM ciphertext of the whole sqlite
|
|
// file. On Open the ciphertext is decrypted into a tmpfs (RAM-backed)
|
|
// plaintext working copy that modernc sqlite operates on directly; on Close
|
|
// the working copy is checkpointed, re-encrypted, and atomically written back,
|
|
// then wiped. A crash leaves plaintext only in RAM (gone on reboot) and never
|
|
// a half-written ciphertext file (atomic rename).
|
|
//
|
|
// Wrong key or a tampered file => GCM authentication fails => Open fails
|
|
// closed. We NEVER fall back to opening the file as plaintext.
|
|
//
|
|
// ponytail: chosen over option 2 (github.com/ncruces/go-sqlite3 pure-Go
|
|
// page-level encryption VFS = no transient plaintext file). Rejected for now
|
|
// because the threat model here is disk-at-rest only — the tmpfs plaintext is
|
|
// RAM that clears on reboot, acceptable — and option 2 swaps the sqlite driver
|
|
// project-wide. Revisit if the threat model grows to include RAM capture.
|
|
//
|
|
// KEY: OpenEncrypted takes a raw 32-byte key. Today the operator supplies it
|
|
// as base64 in config/env (see config.DBEncryptionKey). No KDF: x/crypto isn't
|
|
// a dependency and stdlib has no argon2/scrypt, so a passphrase-derived key
|
|
// would only get sha256 — worse than demanding real key material. This same
|
|
// []byte seam is where the L3 passkey-derived cold-start key plugs in later
|
|
// (auth/tier.go Layer3): produce the 32 bytes from the passkey op and pass them
|
|
// here instead of reading config. ponytail: if a passphrase path is ever
|
|
// wanted, store a random salt in the header below and derive with argon2id
|
|
// (new dep) — do NOT bolt on sha256(passphrase).
|
|
|
|
const (
|
|
cryptMagic = "MVNC1\x00" // 6-byte file magic; version in the trailing byte
|
|
cryptMagicLen = len(cryptMagic)
|
|
nonceLen = 12 // AES-GCM standard nonce
|
|
keyLen = 32 // AES-256
|
|
)
|
|
|
|
// ErrKeyLen — the supplied encryption key was not exactly 32 bytes.
|
|
var ErrKeyLen = errors.New("store: encryption key must be 32 bytes")
|
|
|
|
// ErrDecrypt — the ciphertext failed to authenticate/decrypt (wrong key or
|
|
// tampering). Fail closed: the caller gets no handle and no plaintext.
|
|
var ErrDecrypt = errors.New("store: decrypt failed (wrong key or corrupt file)")
|
|
|
|
type encState struct {
|
|
cipherPath string // on-disk ciphertext (cfg.DBPath)
|
|
plainPath string // tmpfs working copy
|
|
key []byte // 32 bytes; zeroed on Close
|
|
}
|
|
|
|
// OpenEncrypted opens the encrypted store whose ciphertext lives at cipherPath.
|
|
// The plaintext working copy is created at tmpfsPath (default under /dev/shm if
|
|
// empty). key must be exactly 32 bytes.
|
|
//
|
|
// Flow:
|
|
// - ciphertext file exists (our magic) -> decrypt to tmpfs, open.
|
|
// - file exists but is legacy plaintext -> first-run upgrade: copy to tmpfs,
|
|
// open; Close will write ciphertext over the original (rename) so the
|
|
// plaintext original is gone after the first clean shutdown.
|
|
// - no file -> fresh empty db in tmpfs.
|
|
func OpenEncrypted(ctx context.Context, cipherPath, tmpfsPath string, key []byte) (*Store, error) {
|
|
if len(key) != keyLen {
|
|
return nil, ErrKeyLen
|
|
}
|
|
if tmpfsPath == "" {
|
|
tmpfsPath = defaultTmpfsPath(cipherPath)
|
|
}
|
|
|
|
// Stale working copy from a previous crash: tmpfs is RAM so this only
|
|
// happens without a reboot in between. Remove it; the ciphertext file is
|
|
// the source of truth.
|
|
removeDBFiles(tmpfsPath)
|
|
|
|
raw, err := os.ReadFile(cipherPath)
|
|
switch {
|
|
case err == nil && isCiphertext(raw):
|
|
plain, derr := decrypt(key, raw)
|
|
if derr != nil {
|
|
return nil, derr // ErrDecrypt — fail closed
|
|
}
|
|
if err := writeFileSync(tmpfsPath, plain, 0o600); err != nil {
|
|
return nil, fmt.Errorf("write working copy: %w", err)
|
|
}
|
|
case err == nil:
|
|
// Legacy plaintext sqlite db: first-run upgrade. Copy verbatim to tmpfs
|
|
// and let Close encrypt it back over the original.
|
|
if err := writeFileSync(tmpfsPath, raw, 0o600); err != nil {
|
|
return nil, fmt.Errorf("stage plaintext for upgrade: %w", err)
|
|
}
|
|
case errors.Is(err, os.ErrNotExist):
|
|
// fresh: nothing to stage; sqlite creates tmpfsPath on open.
|
|
default:
|
|
return nil, fmt.Errorf("read ciphertext %s: %w", cipherPath, err)
|
|
}
|
|
|
|
db, err := openAt(ctx, tmpfsPath)
|
|
if err != nil {
|
|
removeDBFiles(tmpfsPath)
|
|
return nil, err
|
|
}
|
|
return &Store{db: db, enc: &encState{cipherPath: cipherPath, plainPath: tmpfsPath, key: key}}, nil
|
|
}
|
|
|
|
// closeAndSeal checkpoints the WAL into the main file, closes the handle,
|
|
// re-encrypts the working copy to the ciphertext file atomically, then wipes
|
|
// the plaintext copy and zeroes the key.
|
|
func (e *encState) closeAndSeal(db *sql.DB) error {
|
|
// Fold -wal/-shm into the main file so we encrypt a single complete db.
|
|
// Best-effort: a checkpoint failure still lets us encrypt what's committed.
|
|
_, _ = db.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)")
|
|
if err := db.Close(); err != nil {
|
|
return fmt.Errorf("close db: %w", err)
|
|
}
|
|
|
|
plain, err := os.ReadFile(e.plainPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read working copy: %w", err)
|
|
}
|
|
blob, err := encrypt(e.key, plain)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
if err := atomicWrite(e.cipherPath, blob); err != nil {
|
|
return fmt.Errorf("seal ciphertext: %w", err)
|
|
}
|
|
|
|
removeDBFiles(e.plainPath)
|
|
zero(e.key)
|
|
return nil
|
|
}
|
|
|
|
func isCiphertext(b []byte) bool {
|
|
return len(b) >= cryptMagicLen && string(b[:cryptMagicLen]) == cryptMagic
|
|
}
|
|
|
|
// encrypt: magic || nonce || AES-256-GCM(seal). The magic is bound as
|
|
// additional data so a truncated/retagged header also fails authentication.
|
|
func encrypt(key, plain []byte) ([]byte, error) {
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nonce := make([]byte, nonceLen)
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, fmt.Errorf("nonce: %w", err)
|
|
}
|
|
out := make([]byte, 0, cryptMagicLen+nonceLen+len(plain)+gcm.Overhead())
|
|
out = append(out, cryptMagic...)
|
|
out = append(out, nonce...)
|
|
out = gcm.Seal(out, nonce, plain, []byte(cryptMagic))
|
|
return out, nil
|
|
}
|
|
|
|
func decrypt(key, blob []byte) ([]byte, error) {
|
|
if len(blob) < cryptMagicLen+nonceLen {
|
|
return nil, ErrDecrypt
|
|
}
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nonce := blob[cryptMagicLen : cryptMagicLen+nonceLen]
|
|
ct := blob[cryptMagicLen+nonceLen:]
|
|
plain, err := gcm.Open(nil, nonce, ct, []byte(cryptMagic))
|
|
if err != nil {
|
|
return nil, ErrDecrypt // fail closed, don't leak the GCM error detail
|
|
}
|
|
return plain, nil
|
|
}
|
|
|
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
|
if len(key) != keyLen {
|
|
return nil, ErrKeyLen
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("aes: %w", err)
|
|
}
|
|
return cipher.NewGCM(block)
|
|
}
|
|
|
|
// defaultTmpfsPath: a stable per-db path under /dev/shm so restarts reuse it.
|
|
// Hash of the ciphertext path keeps distinct dbs from colliding.
|
|
func defaultTmpfsPath(cipherPath string) string {
|
|
sum := sha256.Sum256([]byte(cipherPath))
|
|
name := fmt.Sprintf("maven-%s.db", encodeHex(sum[:6]))
|
|
return filepath.Join("/dev/shm", name)
|
|
}
|
|
|
|
func encodeHex(b []byte) string {
|
|
const hexd = "0123456789abcdef"
|
|
out := make([]byte, len(b)*2)
|
|
for i, c := range b {
|
|
out[i*2] = hexd[c>>4]
|
|
out[i*2+1] = hexd[c&0xf]
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// atomicWrite writes to a temp file in the same dir, fsyncs, and renames over
|
|
// the target so the ciphertext file is never observed half-written.
|
|
func atomicWrite(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".maven-seal-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmpName, 0o600); err != nil {
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeFileSync(path string, data []byte, perm os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, perm)
|
|
}
|
|
|
|
// removeDBFiles removes the db and its -wal/-shm sidecars, best-effort.
|
|
func removeDBFiles(path string) {
|
|
for _, p := range []string{path, path + "-wal", path + "-shm"} {
|
|
_ = os.Remove(p)
|
|
}
|
|
}
|
|
|
|
func zero(b []byte) {
|
|
for i := range b {
|
|
b[i] = 0
|
|
}
|
|
}
|
|
|
|
// SealPlaintext encrypts an existing plaintext sqlite file at plainPath and
|
|
// writes the ciphertext to cipherPath, atomically. key must be 32 bytes. The
|
|
// plaintext file is left alone: this is a recovery path, and deleting the only
|
|
// good copy of the data on the strength of a write that just succeeded is not
|
|
// a trade worth making here.
|
|
//
|
|
// It exists for the case closeAndSeal cannot cover: a daemon that was killed
|
|
// rather than shut down, leaving a live working copy in tmpfs and a stale
|
|
// ciphertext on disk. mavseal folds the WAL in first, so what arrives here is
|
|
// a single complete database.
|
|
//
|
|
// Nothing else should call this. The normal path is Close, which seals and
|
|
// then wipes the plaintext and the key.
|
|
func SealPlaintext(plainPath, cipherPath string, key []byte) error {
|
|
if len(key) != keyLen {
|
|
return ErrKeyLen
|
|
}
|
|
plain, err := os.ReadFile(plainPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read working copy: %w", err)
|
|
}
|
|
blob, err := encrypt(key, plain)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
if err := atomicWrite(cipherPath, blob); err != nil {
|
|
return fmt.Errorf("seal ciphertext: %w", err)
|
|
}
|
|
return nil
|
|
}
|