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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user