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:
+14
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user