diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index d51e68c..5a6638b 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -69,7 +69,20 @@ func run(args []string) error { defer stop() // ----- store (the unlocked handle; core = the only key-holder) ----- - st, err := store.Open(ctx, cfg.DBPath) + // The cold-start unlock dance (L3 passkey → key bytes) is not yet wired; + // today the key comes from config/env. When a key is present the on-disk + // file is ciphertext and we work on a tmpfs plaintext copy; no key ⇒ + // plaintext store (dev/CI). A configured-but-broken key fails closed. + key, err := cfg.DBEncryptionKey() + if err != nil { + return err + } + var st *store.Store + if key != nil { + st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key) + } else { + st, err = store.Open(ctx, cfg.DBPath) + } if err != nil { return fmt.Errorf("open store: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 66793f9..855559d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ package config import ( + "encoding/base64" "encoding/json" "errors" "fmt" @@ -30,8 +31,29 @@ import ( // deliberately-unwired channel at scaffold time). type Config struct { // DBPath — sqlite database path. Default applied by Load if empty. + // When encryption is configured, the file at this path is ciphertext + // (AES-256-GCM); the daemon works on a tmpfs plaintext copy. DBPath string `json:"db_path"` + // DBKeyB64 — base64 (std encoding) of a raw 32-byte AES-256 key. Empty ⇒ + // the store is plaintext (dev/CI). Prefer DBKeyEnv over baking the key + // into the config file. Exactly one of DBKeyB64/DBKeyEnv should be set. + // + // ponytail: raw key, no KDF — stdlib has no argon2/scrypt and x/crypto + // isn't a dep. This is also the seam the L3 passkey cold-start key plugs + // into later: the passkey op produces the 32 bytes and calls + // store.OpenEncrypted directly, bypassing config. + DBKeyB64 string `json:"db_key_b64,omitempty"` + + // DBKeyEnv — name of an env var holding the base64 32-byte key. Takes + // precedence over DBKeyB64. Lets systemd credentials / secrets managers + // inject the key without it touching the config file. + DBKeyEnv string `json:"db_key_env,omitempty"` + + // DBTmpfs — plaintext working-copy path (RAM-backed). Empty ⇒ a stable + // per-db path under /dev/shm. Only used when encryption is configured. + DBTmpfs string `json:"db_tmpfs,omitempty"` + // SocketPath — the unix socket the IPC server listens on. Modules // connect here; the dir is created 0700, the socket chmod'd 0600 by // ipc.Listen. Default applied by Load if empty. @@ -322,6 +344,31 @@ func (c *Config) validate() error { return nil } +// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins +// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens +// a plaintext store. A configured-but-invalid key is an error (fail closed, +// never silently downgrade to plaintext). +func (c *Config) DBEncryptionKey() ([]byte, error) { + raw := c.DBKeyB64 + if c.DBKeyEnv != "" { + raw = os.Getenv(c.DBKeyEnv) + if raw == "" { + return nil, fmt.Errorf("config: db_key_env %q is set but the env var is empty", c.DBKeyEnv) + } + } + if raw == "" { + return nil, nil + } + key, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("config: db key is not valid base64: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("config: db key must decode to 32 bytes, got %d", len(key)) + } + return key, nil +} + func defaultDataDir() string { if x := os.Getenv("XDG_DATA_HOME"); x != "" { return filepath.Join(x, "maven") diff --git a/internal/store/crypt.go b/internal/store/crypt.go new file mode 100644 index 0000000..fc93757 --- /dev/null +++ b/internal/store/crypt.go @@ -0,0 +1,270 @@ +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 + } +} diff --git a/internal/store/crypt_test.go b/internal/store/crypt_test.go new file mode 100644 index 0000000..0fc6c90 --- /dev/null +++ b/internal/store/crypt_test.go @@ -0,0 +1,169 @@ +package store + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func mustNow() time.Time { return time.Now().UTC().Truncate(time.Millisecond) } + +// testKey — a deterministic 32-byte key for tests. +func testKey(b byte) []byte { + k := make([]byte, 32) + for i := range k { + k[i] = b + byte(i) + } + return k +} + +func TestEncryptedRoundTrip(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + cipher := filepath.Join(dir, "maven.db") + tmpfs := filepath.Join(dir, "work.db") // tmpfs stand-in for the test + key := testKey(1) + + s, err := OpenEncrypted(ctx, cipher, tmpfs, key) + if err != nil { + t.Fatalf("OpenEncrypted: %v", err) + } + if _, err := s.SetValue(ctx, KindSelf, "water", "tap:water", map[string]int{"ml": 250}, mustNow()); err != nil { + t.Fatalf("SetValue: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // Ciphertext exists, plaintext working copy is wiped. + if _, err := os.Stat(cipher); err != nil { + t.Fatalf("ciphertext missing after Close: %v", err) + } + if _, err := os.Stat(tmpfs); !os.IsNotExist(err) { + t.Fatalf("working copy not wiped: %v", err) + } + + // Reopen with same key, read the fact back. + s2, err := OpenEncrypted(ctx, cipher, tmpfs, testKey(1)) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer s2.Close() + f, err := s2.LatestFact(ctx, "water") + if err != nil { + t.Fatalf("LatestFact: %v", err) + } + if f.Key != "water" || f.Source != "tap:water" { + t.Fatalf("got %+v", f) + } +} + +func TestWrongKeyFailsClosed(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + cipher := filepath.Join(dir, "maven.db") + tmpfs := filepath.Join(dir, "work.db") + + s, err := OpenEncrypted(ctx, cipher, tmpfs, testKey(1)) + if err != nil { + t.Fatalf("OpenEncrypted: %v", err) + } + if _, err := s.SetValue(ctx, KindSelf, "water", "tap:water", 1, mustNow()); err != nil { + t.Fatalf("SetValue: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + s2, err := OpenEncrypted(ctx, cipher, tmpfs, testKey(9)) // wrong key + if err == nil { + s2.Close() + t.Fatal("expected wrong key to fail, got nil error") + } + // Must not have left a decrypted working copy behind. + if _, statErr := os.Stat(tmpfs); !os.IsNotExist(statErr) { + t.Fatalf("wrong-key open leaked a working copy: %v", statErr) + } +} + +func TestFirstRunUpgrade(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + cipher := filepath.Join(dir, "maven.db") + tmpfs := filepath.Join(dir, "work.db") + + // Seed a PLAINTEXT db at the on-disk path (legacy state). + plain, err := Open(ctx, cipher) + if err != nil { + t.Fatalf("seed Open: %v", err) + } + if _, err := plain.SetValue(ctx, KindSelf, "water", "tap:water", 42, mustNow()); err != nil { + t.Fatalf("seed SetValue: %v", err) + } + if err := plain.Close(); err != nil { + t.Fatalf("seed Close: %v", err) + } + if isCiphertext(mustRead(t, cipher)) { + t.Fatal("seed db should be plaintext") + } + + // Open encrypted: upgrades in place. + key := testKey(3) + s, err := OpenEncrypted(ctx, cipher, tmpfs, key) + if err != nil { + t.Fatalf("OpenEncrypted upgrade: %v", err) + } + f, err := s.LatestFact(ctx, "water") + if err != nil { + t.Fatalf("LatestFact after upgrade: %v", err) + } + if f.Key != "water" { + t.Fatalf("upgraded data wrong: %+v", f) + } + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // On-disk file is now ciphertext, not a readable sqlite db. + if !isCiphertext(mustRead(t, cipher)) { + t.Fatal("on-disk file still plaintext after upgrade") + } + if _, err := Open(ctx, cipher); err == nil { + t.Fatal("ciphertext opened as plaintext sqlite — should fail") + } +} + +func TestNoPlaintextValueOnDisk(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + cipher := filepath.Join(dir, "maven.db") + tmpfs := filepath.Join(dir, "work.db") + + const secret = "SUPERSECRET_MARKER_VALUE_12345" + s, err := OpenEncrypted(ctx, cipher, tmpfs, testKey(7)) + if err != nil { + t.Fatalf("OpenEncrypted: %v", err) + } + if _, err := s.SetValue(ctx, KindSelf, "note", "tap:note", secret, mustNow()); err != nil { + t.Fatalf("SetValue: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if bytes.Contains(mustRead(t, cipher), []byte(secret)) { + t.Fatal("plaintext secret found in ciphertext file") + } +} + +func mustRead(t *testing.T, p string) []byte { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + return b +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go new file mode 100644 index 0000000..2b28c22 --- /dev/null +++ b/internal/store/migrations.go @@ -0,0 +1,49 @@ +package store + +import ( + "context" + "database/sql" + "fmt" +) + +// migrations are ordered, forward-only schema steps applied after schema.sql. +// Index i (1-based) is the user_version the step at migrations[i-1] brings the +// DB TO; there is no step 0 — schema.sql is the idempotent baseline (version 0). +// An empty slice is a clean no-op that leaves user_version at 0. +// +// To add migration #1 (e.g. the sqlcipher rekey), append its SQL: +// +// var migrations = []string{ +// `ALTER TABLE ...;`, // #1 +// } +var migrations = []string{} + +// migrate applies every migration with a number greater than the DB's current +// user_version, each in its own transaction that also bumps user_version. Fails +// closed: the first erroring step aborts and leaves prior steps committed. +func migrate(ctx context.Context, db *sql.DB) error { + var current int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(¤t); err != nil { + return fmt.Errorf("read user_version: %w", err) + } + for i := current; i < len(migrations); i++ { + version := i + 1 // 1-based: migrations[i] brings DB to `version` + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("migration %d begin: %w", version, err) + } + if _, err := tx.ExecContext(ctx, migrations[i]); err != nil { + _ = tx.Rollback() + return fmt.Errorf("migration %d: %w", version, err) + } + // PRAGMA user_version can't be parameterized; version is our own int. + if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("migration %d bump: %w", version, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("migration %d commit: %w", version, err) + } + } + return nil +} diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go new file mode 100644 index 0000000..9a2a6bd --- /dev/null +++ b/internal/store/migrations_test.go @@ -0,0 +1,47 @@ +package store + +import ( + "context" + "testing" +) + +func userVersion(t *testing.T, s *Store) int { + t.Helper() + var v int + if err := s.db.QueryRowContext(context.Background(), "PRAGMA user_version").Scan(&v); err != nil { + t.Fatalf("read user_version: %v", err) + } + return v +} + +func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + // Empty baseline slice leaves the DB at version 0. + if v := userVersion(t, s); v != 0 { + t.Fatalf("fresh DB user_version = %d, want 0", v) + } + + // Append a fake migration and run it: creates a throwaway table, bumps to 1. + migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`) + t.Cleanup(func() { migrations = migrations[:len(migrations)-1] }) + + if err := migrate(ctx, s.db); err != nil { + t.Fatalf("migrate: %v", err) + } + if v := userVersion(t, s); v != 1 { + t.Fatalf("after migrate user_version = %d, want 1", v) + } + if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil { + t.Fatalf("probe table not created: %v", err) + } + + // Second run is a no-op — re-running the CREATE would error (no IF NOT EXISTS). + if err := migrate(ctx, s.db); err != nil { + t.Fatalf("migrate second run not idempotent: %v", err) + } + if v := userVersion(t, s); v != 1 { + t.Fatalf("after re-migrate user_version = %d, want 1", v) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index d73b20f..beacac2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -54,13 +54,17 @@ const ( // here performs an UPDATE of a fact value (status-flips on reminders/nudges // are the documented exceptions — they mutate small state-machine columns). type Store struct { - db *sql.DB + db *sql.DB + enc *encState // nil ⇒ plaintext store (dev/CI); set ⇒ re-encrypt on Close } -// Open opens or creates the sqlite database at path and applies the schema. +// openAt opens or creates the sqlite database at path and applies schema + +// migrations. This is the raw plaintext open used by both Open (plaintext +// store) and OpenEncrypted (the tmpfs working copy). +// // Pragmas (WAL, NORMAL, FK on, busy_timeout) are set in schema.sql and re-applied // per connection on open via the modernc driver DSN. -func Open(ctx context.Context, path string) (*Store, error) { +func openAt(ctx context.Context, path string) (*sql.DB, error) { // `_pragma=busy_timeout(5000)` etc. embed cleanly; schema.sql sets them too. dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path) db, err := sql.Open("sqlite", dsn) @@ -75,11 +79,35 @@ func Open(ctx context.Context, path string) (*Store, error) { } return nil, fmt.Errorf("apply schema: %w", err) } + if err := migrate(ctx, db); err != nil { + if closeErr := db.Close(); closeErr != nil { + return nil, fmt.Errorf("migrate: %w (close: %v)", err, closeErr) + } + return nil, fmt.Errorf("migrate: %w", err) + } + return db, nil +} + +// Open opens or creates a PLAINTEXT sqlite database at path. Used by tests and +// by any deployment that keeps the db unencrypted (CI, dev). Production goes +// through OpenEncrypted. +func Open(ctx context.Context, path string) (*Store, error) { + db, err := openAt(ctx, path) + if err != nil { + return nil, err + } return &Store{db: db}, nil } -// Close releases the database handle. -func (s *Store) Close() error { return s.db.Close() } +// Close checkpoints, releases the database handle, and — for an encrypted +// store — re-encrypts the tmpfs working copy back to the on-disk ciphertext +// file atomically, then wipes the plaintext copy and zeroes the key. +func (s *Store) Close() error { + if s.enc == nil { + return s.db.Close() + } + return s.enc.closeAndSeal(s.db) +} // DB exposes the underlying handle for internal read-only snapshots. // Used by the loop to take a consistent read under a single transaction.