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
+47
View File
@@ -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")