store: at-rest encryption + schema-migration runner

Two spine infra items (feature-ranking #1, part of the migration prereq):

- migrations.go: PRAGMA user_version runner, empty (no-op) migration slice,
  one tx per step, fail-closed. Mechanism in place before any real schema
  change needs it.
- crypt.go: file-level at-rest encryption. On-disk file is always AES-256-GCM
  ciphertext; decrypted to a tmpfs working copy modernc sqlite operates on;
  re-encrypted atomically on Close, plaintext wiped, key zeroed. Pure stdlib,
  CGO stays off. Fails closed on wrong key/tamper, never falls back to
  plaintext. Key is a 32-byte seam (config db_key_b64/db_key_env today; the
  passkey-derived L3 cold-start key plugs into the same seam later).

Chosen over cgo SQLCipher (would force libsqlcipher + CGO across the project)
and over the ncruces page-level VFS (swaps the driver project-wide); noted as
the upgrade path in a ponytail: comment. Threat model is disk-at-rest only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-03 21:22:09 +04:00
parent bd1e2789eb
commit 047a813278
7 changed files with 629 additions and 6 deletions
+14 -1
View File
@@ -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)
}