diff --git a/vfs/adiantum/math.go b/internal/util/math.go similarity index 57% rename from vfs/adiantum/math.go rename to internal/util/math.go index 17f7a291..ffbd0c2f 100644 --- a/vfs/adiantum/math.go +++ b/internal/util/math.go @@ -1,4 +1,4 @@ -package adiantum +package util func abs(n int) int { if n < 0 { @@ -7,16 +7,16 @@ func abs(n int) int { return n } -func gcd(m, n int) int { +func GCD(m, n int) int { for n != 0 { m, n = n, m%n } return abs(m) } -func lcm(m, n int) int { +func LCM(m, n int) int { if n == 0 { return 0 } - return abs(n) * (abs(m) / gcd(m, n)) + return abs(n) * (abs(m) / GCD(m, n)) } diff --git a/vfs/adiantum/math_test.go b/internal/util/math_test.go similarity index 87% rename from vfs/adiantum/math_test.go rename to internal/util/math_test.go index 2e2398a7..052314ca 100644 --- a/vfs/adiantum/math_test.go +++ b/internal/util/math_test.go @@ -1,4 +1,4 @@ -package adiantum +package util import ( "math" @@ -25,7 +25,7 @@ func Test_abs(t *testing.T) { } } -func Test_gcd(t *testing.T) { +func Test_GCD(t *testing.T) { tests := []struct { arg1 int arg2 int @@ -46,14 +46,14 @@ func Test_gcd(t *testing.T) { } for _, tt := range tests { t.Run("", func(t *testing.T) { - if got := gcd(tt.arg1, tt.arg2); got != tt.want { + if got := GCD(tt.arg1, tt.arg2); got != tt.want { t.Errorf("gcd(%d, %d) = %d, want %d", tt.arg1, tt.arg2, got, tt.want) } }) } } -func Test_lcm(t *testing.T) { +func Test_LCM(t *testing.T) { tests := []struct { arg1 int arg2 int @@ -74,7 +74,7 @@ func Test_lcm(t *testing.T) { } for _, tt := range tests { t.Run("", func(t *testing.T) { - if got := lcm(tt.arg1, tt.arg2); got != tt.want { + if got := LCM(tt.arg1, tt.arg2); got != tt.want { t.Errorf("lcm(%d, %d) = %d, want %d", tt.arg1, tt.arg2, got, tt.want) } }) diff --git a/vfs/adiantum/README.md b/vfs/adiantum/README.md index 508c6851..bc3f094b 100644 --- a/vfs/adiantum/README.md +++ b/vfs/adiantum/README.md @@ -11,7 +11,7 @@ The default Adiantum construction uses XChaCha12 for its stream cipher, AES for its block cipher, and NH and Poly1305 for hashing.\ Additionally, we use [Argon2id](https://pkg.go.dev/golang.org/x/crypto/argon2#hdr-Argon2id) to derive 256-bit keys from plain text where needed. -File contents are encrypted in 4K blocks, matching the +File contents are encrypted in 4 KiB blocks, matching the [default](https://sqlite.org/pgszchng2016.html) SQLite page size. The VFS encrypts all files _except_ @@ -53,6 +53,10 @@ and want to protect against forgery, you should sign your backups, and verify signatures before restoring them. This is slightly weaker than other forms of SQLite encryption -that include block-level [MACs](https://en.wikipedia.org/wiki/Message_authentication_code). -Block-level MACs can protect against forging individual blocks, +that include page-level [MACs](https://en.wikipedia.org/wiki/Message_authentication_code). +Page-level MACs can protect against forging individual pages, but can't prevent them from being reverted to former versions of themselves. + +> [!TIP] +> The [`"xts"`](../xts/README.md) package also offers encryption at rest. +> AES-XTS uses _only_ NIST and FIPS-140 approved cryptographic primitives. \ No newline at end of file diff --git a/vfs/adiantum/adiantum_test.go b/vfs/adiantum/adiantum_test.go index 1096a358..dc327d1d 100644 --- a/vfs/adiantum/adiantum_test.go +++ b/vfs/adiantum/adiantum_test.go @@ -42,6 +42,11 @@ func Test_fileformat(t *testing.T) { if version != 0xBADDB { t.Error(version) } + + _, err = db.Exec(`PRAGMA integrity_check`) + if err != nil { + t.Error(err) + } } func Benchmark_nokey(b *testing.B) { @@ -57,6 +62,7 @@ func Benchmark_nokey(b *testing.B) { db.Close() } } + func Benchmark_hexkey(b *testing.B) { tmp := filepath.Join(b.TempDir(), "test.db") sqlite3.Initialize() diff --git a/vfs/adiantum/api.go b/vfs/adiantum/api.go index c484ec98..bbfc89b2 100644 --- a/vfs/adiantum/api.go +++ b/vfs/adiantum/api.go @@ -45,13 +45,17 @@ func init() { // Register registers an encrypting VFS, wrapping a base VFS, // and possibly using a custom HBSH cipher construction. // To use the default Adiantum construction, set cipher to nil. +// +// The default construction uses a 32 byte key/hexkey. +// If a textkey is provided, the default KDF is Argon2id +// with 64 MiB of memory, 3 iterations, and 4 threads. func Register(name string, base vfs.VFS, cipher HBSHCreator) { if cipher == nil { cipher = adiantumCreator{} } vfs.Register(name, &hbshVFS{ VFS: base, - hbsh: cipher, + init: cipher, }) } diff --git a/vfs/adiantum/hbsh.go b/vfs/adiantum/hbsh.go index 4522104b..02cb63bd 100644 --- a/vfs/adiantum/hbsh.go +++ b/vfs/adiantum/hbsh.go @@ -13,7 +13,7 @@ import ( type hbshVFS struct { vfs.VFS - hbsh HBSHCreator + init HBSHCreator } func (h *hbshVFS) Open(name string, flags vfs.OpenFlag) (vfs.File, vfs.OpenFlag, error) { @@ -39,26 +39,31 @@ func (h *hbshVFS) OpenFilename(name *vfs.Filename, flags vfs.OpenFlag) (file vfs } else { var key []byte if params := name.URIParameters(); name == nil { - key = h.hbsh.KDF("") // Temporary files get a random key. + key = h.init.KDF("") // Temporary files get a random key. } else if t, ok := params["key"]; ok { key = []byte(t[0]) } else if t, ok := params["hexkey"]; ok { key, _ = hex.DecodeString(t[0]) - } else if t, ok := params["textkey"]; ok { - key = h.hbsh.KDF(t[0]) + } else if t, ok := params["textkey"]; ok && len(t[0]) > 0 { + key = h.init.KDF(t[0]) } else if flags&vfs.OPEN_MAIN_DB != 0 { // Main datatabases may have their key specified as a PRAGMA. - return &hbshFile{File: file, reset: h.hbsh}, flags, nil + return &hbshFile{File: file, init: h.init}, flags, nil } - hbsh = h.hbsh.HBSH(key) + hbsh = h.init.HBSH(key) } if hbsh == nil { return nil, flags, sqlite3.CANTOPEN } - return &hbshFile{File: file, hbsh: hbsh, reset: h.hbsh}, flags, nil + return &hbshFile{File: file, hbsh: hbsh, init: h.init}, flags, nil } +// Larger blocks improve both security (wide-block cipher) +// and throughput (cheap hashes amortize the block cipher's cost). +// Use the default SQLite page size; +// smaller pages pay the cost of unaligned access. +// https://sqlite.org/pgszchng2016.html const ( tweakSize = 8 blockSize = 4096 @@ -66,8 +71,8 @@ const ( type hbshFile struct { vfs.File + init HBSHCreator hbsh *hbsh.HBSH - reset HBSHCreator tweak [tweakSize]byte block [blockSize]byte } @@ -80,7 +85,9 @@ func (h *hbshFile) Pragma(name string, value string) (string, error) { case "hexkey": key, _ = hex.DecodeString(value) case "textkey": - key = h.reset.KDF(value) + if len(value) > 0 { + key = h.init.KDF(value) + } default: if f, ok := h.File.(vfs.FilePragma); ok { return f.Pragma(name, value) @@ -88,7 +95,7 @@ func (h *hbshFile) Pragma(name string, value string) (string, error) { return "", sqlite3.NOTFOUND } - if h.hbsh = h.reset.HBSH(key); h.hbsh != nil { + if h.hbsh = h.init.HBSH(key); h.hbsh != nil { return "ok", nil } return "", sqlite3.CANTOPEN @@ -99,7 +106,7 @@ func (h *hbshFile) ReadAt(p []byte, off int64) (n int, err error) { // Only OPEN_MAIN_DB can have a missing key. if off == 0 && len(p) == 100 { // SQLite is trying to read the header of a database file. - // Pretend the file is empty so the key may specified as a PRAGMA. + // Pretend the file is empty so the key may be specified as a PRAGMA. return 0, io.EOF } return 0, sqlite3.CANTOPEN @@ -187,7 +194,7 @@ func (h *hbshFile) Truncate(size int64) error { } func (h *hbshFile) SectorSize() int { - return lcm(h.File.SectorSize(), blockSize) + return util.LCM(h.File.SectorSize(), blockSize) } func (h *hbshFile) DeviceCharacteristics() vfs.DeviceCharacteristic { diff --git a/vfs/adiantum/testdata/test.db b/vfs/adiantum/testdata/test.db index 3f8ac937..f778e70a 100644 Binary files a/vfs/adiantum/testdata/test.db and b/vfs/adiantum/testdata/test.db differ diff --git a/vfs/tests/mptest/mptest_test.go b/vfs/tests/mptest/mptest_test.go index a47133e5..c8873637 100644 --- a/vfs/tests/mptest/mptest_test.go +++ b/vfs/tests/mptest/mptest_test.go @@ -21,6 +21,7 @@ import ( "github.com/ncruces/go-sqlite3/vfs" _ "github.com/ncruces/go-sqlite3/vfs/adiantum" "github.com/ncruces/go-sqlite3/vfs/memdb" + _ "github.com/ncruces/go-sqlite3/vfs/xts" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" @@ -294,6 +295,52 @@ func Test_crash01_adiantum_wal(t *testing.T) { mod.Close(ctx) } +func Test_crash01_xts(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + if os.Getenv("CI") != "" { + t.Skip("skipping in CI") + } + if !vfs.SupportsFileLocking { + t.Skip("skipping without locks") + } + + ctx := util.NewContext(newContext(t)) + name := "file:" + filepath.Join(t.TempDir(), "test.db") + + "?hexkey=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + cfg := config(ctx).WithArgs("mptest", name, "crash01.test", + "--vfs", "xts") + mod, err := rt.InstantiateModule(ctx, module, cfg) + if err != nil { + t.Fatal(err) + } + mod.Close(ctx) +} + +func Test_crash01_xts_wal(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + if os.Getenv("CI") != "" { + t.Skip("skipping in CI") + } + if !vfs.SupportsSharedMemory { + t.Skip("skipping without shared memory") + } + + ctx := util.NewContext(newContext(t)) + name := "file:" + filepath.Join(t.TempDir(), "test.db") + + "?hexkey=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + cfg := config(ctx).WithArgs("mptest", name, "crash01.test", + "--vfs", "xts", "--journalmode", "wal") + mod, err := rt.InstantiateModule(ctx, module, cfg) + if err != nil { + t.Fatal(err) + } + mod.Close(ctx) +} + func newContext(t *testing.T) context.Context { return context.WithValue(context.Background(), logger{}, &testWriter{T: t}) } diff --git a/vfs/tests/speedtest1/speedtest1_test.go b/vfs/tests/speedtest1/speedtest1_test.go index 4aa0fd31..abd033b1 100644 --- a/vfs/tests/speedtest1/speedtest1_test.go +++ b/vfs/tests/speedtest1/speedtest1_test.go @@ -25,6 +25,7 @@ import ( "github.com/ncruces/go-sqlite3/vfs" _ "github.com/ncruces/go-sqlite3/vfs/adiantum" _ "github.com/ncruces/go-sqlite3/vfs/memdb" + _ "github.com/ncruces/go-sqlite3/vfs/xts" ) //go:embed testdata/speedtest1.wasm.bz2 @@ -125,3 +126,22 @@ func Benchmark_adiantum(b *testing.B) { } mod.Close(ctx) } + +func Benchmark_xts(b *testing.B) { + output.Reset() + ctx := util.NewContext(context.Background()) + name := "file:" + filepath.Join(b.TempDir(), "test.db") + + "?hexkey=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + args := append(options, "--vfs", "xts", "--size", strconv.Itoa(b.N), name) + cfg := wazero.NewModuleConfig(). + WithArgs(args...).WithName("speedtest1"). + WithStdout(&output).WithStderr(&output). + WithSysWalltime().WithSysNanotime().WithSysNanosleep(). + WithOsyield(runtime.Gosched). + WithRandSource(rand.Reader) + mod, err := rt.InstantiateModule(ctx, module, cfg) + if err != nil { + b.Fatal(err) + } + mod.Close(ctx) +} diff --git a/vfs/xts/README.md b/vfs/xts/README.md new file mode 100644 index 00000000..786435de --- /dev/null +++ b/vfs/xts/README.md @@ -0,0 +1,63 @@ +# Go `xts` SQLite VFS + +This package wraps an SQLite VFS to offer encryption at rest. + +The `"xts"` VFS wraps the default SQLite VFS using the +[AES-XTS](https://pkg.go.dev/golang.org/x/crypto/xts) +tweakable and length-preserving encryption.\ +In general, any XTS construction can be used to wrap any VFS. + +The default AES-XTS construction uses AES-128, AES-192, or AES-256 +for its block cipher. +Additionally, we use [PBKDF2-HMAC-SHA512](https://pkg.go.dev/golang.org/x/crypto/pbkdf2) +to derive AES-128 keys from plain text where needed. +File contents are encrypted in 512 byte sectors, matching the +[minimum](https://sqlite.org/fileformat.html#pages) SQLite page size. + +The VFS encrypts all files _except_ +[super journals](https://sqlite.org/tempfiles.html#super_journal_files): +these _never_ contain database data, only filenames, +and padding them to the sector size is problematic. +Temporary files _are_ encrypted with **random** AES-128 keys, +as they _may_ contain database data. +To avoid the overhead of encrypting temporary files, +keep them in memory: + + PRAGMA temp_store = memory; + +> [!IMPORTANT] +> XTS is a cipher mode typically used for disk encryption. +> The standard threat model for disk encryption considers an adversary +> that can read multiple snapshots of a disk. +> The only security property that disk encryption provides +> is that all information such an adversary can obtain +> is whether the data in a sector has or has not changed over time. + +The encryption offered by this package is fully deterministic. + +This means that an adversary who can get ahold of multiple snapshots +(e.g. backups) of a database file can learn precisely: +which sectors changed, which ones didn't, which got reverted. + +This is slightly weaker than other forms of SQLite encryption +that include *some* nondeterminism; with limited nondeterminism, +an adversary can't distinguish between +sectors that actually changed, and sectors that got reverted. + +> [!CAUTION] +> This package does not claim protect databases against tampering or forgery. + +The major practical consequence of the above point is that, +if you're keeping `"xts"` encrypted backups of your database, +and want to protect against forgery, you should sign your backups, +and verify signatures before restoring them. + +This is slightly weaker than other forms of SQLite encryption +that include page-level [MACs](https://en.wikipedia.org/wiki/Message_authentication_code). +Page-level MACs can protect against forging individual pages, +but can't prevent them from being reverted to former versions of themselves. + +> [!TIP] +> The [`"adiantum"`](../adiantum/README.md) package also offers encryption at rest. +> In general Adiantum performs significantly better, +> and as a "wide-block" cipher, _may_ offer improved security. \ No newline at end of file diff --git a/vfs/xts/aes.go b/vfs/xts/aes.go new file mode 100644 index 00000000..b6b4c396 --- /dev/null +++ b/vfs/xts/aes.go @@ -0,0 +1,34 @@ +package xts + +import ( + "crypto/aes" + "crypto/rand" + "crypto/sha512" + + "golang.org/x/crypto/pbkdf2" + "golang.org/x/crypto/xts" +) + +// This variable can be replaced with -ldflags: +// +// go build -ldflags="-X github.com/ncruces/go-sqlite3/vfs/xts.pepper=xts" +var pepper = "github.com/ncruces/go-sqlite3/vfs/xts" + +type aesCreator struct{} + +func (aesCreator) XTS(key []byte) *xts.Cipher { + c, err := xts.NewCipher(aes.NewCipher, key) + if err != nil { + return nil + } + return c +} + +func (aesCreator) KDF(text string) []byte { + if text == "" { + key := make([]byte, 32) + n, _ := rand.Read(key) + return key[:n] + } + return pbkdf2.Key([]byte(text), []byte(pepper), 10_000, 32, sha512.New) +} diff --git a/vfs/xts/aes_test.go b/vfs/xts/aes_test.go new file mode 100644 index 00000000..d7b78cc3 --- /dev/null +++ b/vfs/xts/aes_test.go @@ -0,0 +1,94 @@ +package xts_test + +import ( + _ "embed" + "path/filepath" + "strings" + "testing" + + "github.com/ncruces/go-sqlite3" + "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" + _ "github.com/ncruces/go-sqlite3/internal/testcfg" + "github.com/ncruces/go-sqlite3/util/ioutil" + "github.com/ncruces/go-sqlite3/vfs" + "github.com/ncruces/go-sqlite3/vfs/readervfs" + "github.com/ncruces/go-sqlite3/vfs/xts" +) + +//go:embed testdata/test.db +var testDB string + +func Test_fileformat(t *testing.T) { + readervfs.Create("test.db", ioutil.NewSizeReaderAt(strings.NewReader(testDB))) + xts.Register("rxts", vfs.Find("reader"), nil) + + db, err := driver.Open("file:test.db?vfs=rxts") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + _, err = db.Exec(`PRAGMA textkey='correct+horse+battery+staple'`) + if err != nil { + t.Fatal(err) + } + + var version uint32 + err = db.QueryRow(`PRAGMA user_version`).Scan(&version) + if err != nil { + t.Fatal(err) + } + if version != 0xBADDB { + t.Error(version) + } + + _, err = db.Exec(`PRAGMA integrity_check`) + if err != nil { + t.Error(err) + } +} + +func Benchmark_nokey(b *testing.B) { + tmp := filepath.Join(b.TempDir(), "test.db") + sqlite3.Initialize() + b.ResetTimer() + + for n := 0; n < b.N; n++ { + db, err := sqlite3.Open("file:" + filepath.ToSlash(tmp) + "?nolock=1") + if err != nil { + b.Fatal(err) + } + db.Close() + } +} + +func Benchmark_hexkey(b *testing.B) { + tmp := filepath.Join(b.TempDir(), "test.db") + sqlite3.Initialize() + b.ResetTimer() + + for n := 0; n < b.N; n++ { + db, err := sqlite3.Open("file:" + filepath.ToSlash(tmp) + "?nolock=1" + + "&vfs=xts&hexkey=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + if err != nil { + b.Fatal(err) + } + db.Close() + } +} + +func Benchmark_textkey(b *testing.B) { + tmp := filepath.Join(b.TempDir(), "test.db") + sqlite3.Initialize() + b.ResetTimer() + + for n := 0; n < b.N; n++ { + db, err := sqlite3.Open("file:" + filepath.ToSlash(tmp) + "?nolock=1" + + "&vfs=xts&textkey=correct+horse+battery+staple") + if err != nil { + b.Fatal(err) + } + db.Close() + } +} diff --git a/vfs/xts/api.go b/vfs/xts/api.go new file mode 100644 index 00000000..ad30fb25 --- /dev/null +++ b/vfs/xts/api.go @@ -0,0 +1,73 @@ +// Package xts wraps an SQLite VFS to offer encryption at rest. +// +// The "xts" [vfs.VFS] wraps the default VFS using the +// AES-XTS tweakable, length-preserving encryption. +// +// Importing package xts registers that VFS: +// +// import _ "github.com/ncruces/go-sqlite3/vfs/xts" +// +// To open an encrypted database you need to provide key material. +// +// The simplest way to do that is to specify the key through an [URI] parameter: +// +// - key: key material in binary (32, 48 or 64 bytes) +// - hexkey: key material in hex (64, 96 or 128 hex digits) +// - textkey: key material in text (any length) +// +// However, this makes your key easily accessible to other parts of +// your application (e.g. through [vfs.Filename.URIParameters]). +// +// To avoid this, invoke any of the following PRAGMAs +// immediately after opening a connection: +// +// PRAGMA key='D41d8cD98f00b204e9800998eCf8427e'; +// PRAGMA hexkey='e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; +// PRAGMA textkey='your-secret-key'; +// +// For an ATTACH-ed database, you must specify the schema name: +// +// ATTACH DATABASE 'demo.db' AS demo; +// PRAGMA demo.textkey='your-secret-key'; +// +// [URI]: https://sqlite.org/uri.html +package xts + +import ( + "github.com/ncruces/go-sqlite3/vfs" + "golang.org/x/crypto/xts" +) + +func init() { + Register("xts", vfs.Find(""), nil) +} + +// Register registers an encrypting VFS, wrapping a base VFS, +// and possibly using a custom XTS cipher construction. +// To use the default AES-XTS construction, set cipher to nil. +// +// The default construction uses AES-128, AES-192, or AES-256 +// if the key/hexkey is 32, 48, or 64 bytes, respectively. +// If a textkey is provided, the default KDF is PBKDF2-HMAC-SHA512 +// with 10,000 iterations, always producing a 32 byte key. +func Register(name string, base vfs.VFS, cipher XTSCreator) { + if cipher == nil { + cipher = aesCreator{} + } + vfs.Register(name, &xtsVFS{ + VFS: base, + init: cipher, + }) +} + +// XTSCreator creates an [xts.Cipher] +// given key material. +type XTSCreator interface { + // KDF derives an XTS key from a secret. + // If no secret is given, a random key is generated. + KDF(secret string) (key []byte) + + // XTS creates an XTS cipher given a key. + // If key is not appropriate, nil is returned. + XTS(key []byte) *xts.Cipher +} diff --git a/vfs/xts/testdata/test.db b/vfs/xts/testdata/test.db new file mode 100644 index 00000000..98d2fb99 Binary files /dev/null and b/vfs/xts/testdata/test.db differ diff --git a/vfs/xts/xts.go b/vfs/xts/xts.go new file mode 100644 index 00000000..1d3107f2 --- /dev/null +++ b/vfs/xts/xts.go @@ -0,0 +1,283 @@ +package xts + +import ( + "encoding/hex" + "io" + + "golang.org/x/crypto/xts" + + "github.com/ncruces/go-sqlite3" + "github.com/ncruces/go-sqlite3/internal/util" + "github.com/ncruces/go-sqlite3/vfs" +) + +type xtsVFS struct { + vfs.VFS + init XTSCreator +} + +func (x *xtsVFS) Open(name string, flags vfs.OpenFlag) (vfs.File, vfs.OpenFlag, error) { + // notest // OpenFilename is called instead + return nil, 0, sqlite3.CANTOPEN +} + +func (x *xtsVFS) OpenFilename(name *vfs.Filename, flags vfs.OpenFlag) (file vfs.File, _ vfs.OpenFlag, err error) { + if hf, ok := x.VFS.(vfs.VFSFilename); ok { + file, flags, err = hf.OpenFilename(name, flags) + } else { + file, flags, err = x.VFS.Open(name.String(), flags) + } + + // Encrypt everything except super journals and memory files. + if err != nil || flags&(vfs.OPEN_SUPER_JOURNAL|vfs.OPEN_MEMORY) != 0 { + return file, flags, err + } + + var cipher *xts.Cipher + if f, ok := name.DatabaseFile().(*xtsFile); ok { + cipher = f.cipher + } else { + var key []byte + if params := name.URIParameters(); name == nil { + key = x.init.KDF("") // Temporary files get a random key. + } else if t, ok := params["key"]; ok { + key = []byte(t[0]) + } else if t, ok := params["hexkey"]; ok { + key, _ = hex.DecodeString(t[0]) + } else if t, ok := params["textkey"]; ok && len(t[0]) > 0 { + key = x.init.KDF(t[0]) + } else if flags&vfs.OPEN_MAIN_DB != 0 { + // Main datatabases may have their key specified as a PRAGMA. + return &xtsFile{File: file, init: x.init}, flags, nil + } + cipher = x.init.XTS(key) + } + + if cipher == nil { + return nil, flags, sqlite3.CANTOPEN + } + return &xtsFile{File: file, cipher: cipher, init: x.init}, flags, nil +} + +// Larger sectors don't seem to significantly improve security, +// and don't affect perfomance. +// https://crossbowerbt.github.io/docs/crypto/pdf00086.pdf +// For flexibility, pick the minimum size of an SQLite page. +// https://sqlite.org/fileformat.html#pages +const sectorSize = 512 + +type xtsFile struct { + vfs.File + init XTSCreator + cipher *xts.Cipher + sector [sectorSize]byte +} + +func (x *xtsFile) Pragma(name string, value string) (string, error) { + var key []byte + switch name { + case "key": + key = []byte(value) + case "hexkey": + key, _ = hex.DecodeString(value) + case "textkey": + if len(value) > 0 { + key = x.init.KDF(value) + } + default: + if f, ok := x.File.(vfs.FilePragma); ok { + return f.Pragma(name, value) + } + return "", sqlite3.NOTFOUND + } + + if x.cipher = x.init.XTS(key); x.cipher != nil { + return "ok", nil + } + return "", sqlite3.CANTOPEN +} + +func (x *xtsFile) ReadAt(p []byte, off int64) (n int, err error) { + if x.cipher == nil { + // Only OPEN_MAIN_DB can have a missing key. + if off == 0 && len(p) == 100 { + // SQLite is trying to read the header of a database file. + // Pretend the file is empty so the key may be specified as a PRAGMA. + return 0, io.EOF + } + return 0, sqlite3.CANTOPEN + } + + min := (off) &^ (sectorSize - 1) // round down + max := (off + int64(len(p)) + (sectorSize - 1)) &^ (sectorSize - 1) // round up + + // Read one block at a time. + for ; min < max; min += sectorSize { + m, err := x.File.ReadAt(x.sector[:], min) + if m != sectorSize { + return n, err + } + + sectorNum := uint64(min / sectorSize) + x.cipher.Decrypt(x.sector[:], x.sector[:], sectorNum) + + data := x.sector[:] + if off > min { + data = data[off-min:] + } + n += copy(p[n:], data) + } + + if n != len(p) { + panic(util.AssertErr()) + } + return n, nil +} + +func (x *xtsFile) WriteAt(p []byte, off int64) (n int, err error) { + if x.cipher == nil { + return 0, sqlite3.READONLY + } + + min := (off) &^ (sectorSize - 1) // round down + max := (off + int64(len(p)) + (sectorSize - 1)) &^ (sectorSize - 1) // round up + + // Write one block at a time. + for ; min < max; min += sectorSize { + sectorNum := uint64(min / sectorSize) + data := x.sector[:] + + if off > min || len(p[n:]) < sectorSize { + // Partial block write: read-update-write. + m, err := x.File.ReadAt(x.sector[:], min) + if m != sectorSize { + if err != io.EOF { + return n, err + } + // Writing past the EOF. + // We're either appending an entirely new block, + // or the final block was only partially written. + // A partially written block can't be decrypted, + // and is as good as corrupt. + // Either way, zero pad the file to the next block size. + clear(data) + } else { + x.cipher.Decrypt(data, data, sectorNum) + } + if off > min { + data = data[off-min:] + } + } + + t := copy(data, p[n:]) + x.cipher.Encrypt(x.sector[:], x.sector[:], sectorNum) + + m, err := x.File.WriteAt(x.sector[:], min) + if m != sectorSize { + return n, err + } + n += t + } + + if n != len(p) { + panic(util.AssertErr()) + } + return n, nil +} + +func (x *xtsFile) Truncate(size int64) error { + size = (size + (sectorSize - 1)) &^ (sectorSize - 1) // round up + return x.File.Truncate(size) +} + +func (x *xtsFile) SectorSize() int { + return util.LCM(x.File.SectorSize(), sectorSize) +} + +func (x *xtsFile) DeviceCharacteristics() vfs.DeviceCharacteristic { + return x.File.DeviceCharacteristics() & (0 | + // The only safe flags are these: + vfs.IOCAP_UNDELETABLE_WHEN_OPEN | + vfs.IOCAP_IMMUTABLE | + vfs.IOCAP_BATCH_ATOMIC) +} + +// Wrap optional methods. + +func (x *xtsFile) SharedMemory() vfs.SharedMemory { + if f, ok := x.File.(vfs.FileSharedMemory); ok { + return f.SharedMemory() + } + return nil +} + +func (x *xtsFile) ChunkSize(size int) { + if f, ok := x.File.(vfs.FileChunkSize); ok { + size = (size + (sectorSize - 1)) &^ (sectorSize - 1) // round up + f.ChunkSize(size) + } +} + +func (x *xtsFile) SizeHint(size int64) error { + if f, ok := x.File.(vfs.FileSizeHint); ok { + size = (size + (sectorSize - 1)) &^ (sectorSize - 1) // round up + return f.SizeHint(size) + } + return sqlite3.NOTFOUND +} + +func (x *xtsFile) HasMoved() (bool, error) { + if f, ok := x.File.(vfs.FileHasMoved); ok { + return f.HasMoved() + } + return false, sqlite3.NOTFOUND +} + +func (x *xtsFile) Overwrite() error { + if f, ok := x.File.(vfs.FileOverwrite); ok { + return f.Overwrite() + } + return sqlite3.NOTFOUND +} + +func (x *xtsFile) CommitPhaseTwo() error { + if f, ok := x.File.(vfs.FileCommitPhaseTwo); ok { + return f.CommitPhaseTwo() + } + return sqlite3.NOTFOUND +} + +func (x *xtsFile) BeginAtomicWrite() error { + if f, ok := x.File.(vfs.FileBatchAtomicWrite); ok { + return f.BeginAtomicWrite() + } + return sqlite3.NOTFOUND +} + +func (x *xtsFile) CommitAtomicWrite() error { + if f, ok := x.File.(vfs.FileBatchAtomicWrite); ok { + return f.CommitAtomicWrite() + } + return sqlite3.NOTFOUND +} + +func (x *xtsFile) RollbackAtomicWrite() error { + if f, ok := x.File.(vfs.FileBatchAtomicWrite); ok { + return f.RollbackAtomicWrite() + } + return sqlite3.NOTFOUND +} + +func (x *xtsFile) CheckpointDone() error { + if f, ok := x.File.(vfs.FileCheckpoint); ok { + return f.CheckpointDone() + } + return sqlite3.NOTFOUND +} + +func (x *xtsFile) CheckpointStart() error { + if f, ok := x.File.(vfs.FileCheckpoint); ok { + return f.CheckpointStart() + } + return sqlite3.NOTFOUND +}