mirror of
https://github.com/hanzoai/sqlcipher.git
synced 2026-08-07 01:43:02 +00:00
Golden vectors pin the KDF, the page-authentication key derivation and the
exact on-disk bytes of a page under a fixed IV, so a refactor cannot silently
change the format.
The vectors alone would only pin this port to itself, so both are
cross-verified against libsqlcipher 4.5.6 by decrypting databases the C
library actually wrote:
testdata/c-4.5.6.db raw key (x'HEX'), 7 pages, overflow +
index + random and zero blobs
testdata/c-4.5.6-passphrase.db passphrase, proving PBKDF2-HMAC-SHA512 at
256000 iterations agrees byte for byte
Fail-closed is asserted, not assumed: wrong key, wrong salt, wrong
passphrase, a flipped ciphertext/IV/tag bit, and a page replayed at another
page number all return ErrKey with no data.
Note the reserve trailer carries the IV and is fresh on every write, so a
round-trip is the identity on the usable bytes, not on the trailer.
426 lines
13 KiB
Go
426 lines
13 KiB
Go
package sqlcipher
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// testKey and testSalt are fixed so every vector below is reproducible. testKey
|
|
// is also the key testdata/c-4.5.6.db was written under by the real C library.
|
|
var (
|
|
testKey = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
|
testSalt = mustHex("000102030405060708090a0b0c0d0e0f")
|
|
)
|
|
|
|
func mustHex(s string) []byte {
|
|
b, err := hex.DecodeString(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// fixed is a deterministic byte source, standing in for crypto/rand so a golden
|
|
// vector can pin the exact ciphertext of a page.
|
|
type fixed byte
|
|
|
|
func (f fixed) Read(p []byte) (int, error) {
|
|
for i := range p {
|
|
p[i] = byte(f)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func codec(t *testing.T) *Codec {
|
|
t.Helper()
|
|
c, err := NewCodec(RawKey(testKey), testSalt, Params{})
|
|
if err != nil {
|
|
t.Fatalf("NewCodec: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// TestKeyDerivation pins the KDF. These are the two keys SQLCipher derives; if
|
|
// either changes, every database written before the change becomes unreadable.
|
|
func TestKeyDerivation(t *testing.T) {
|
|
t.Run("raw key is used verbatim", func(t *testing.T) {
|
|
c := codec(t)
|
|
if !bytes.Equal(c.key, testKey) {
|
|
t.Errorf("raw keying must skip the KDF\n got %x\nwant %x", c.key, testKey)
|
|
}
|
|
})
|
|
|
|
t.Run("page-authentication key", func(t *testing.T) {
|
|
// PBKDF2-HMAC-SHA512(key, salt^0x3a, 2 iterations, 32 bytes).
|
|
const want = "601364baed0dde5a2d291ede302ae7e0540a85b57a281a0d19c344b7d4f382ce"
|
|
got := hex.EncodeToString(codec(t).mac)
|
|
if got != want {
|
|
t.Errorf("page-authentication key changed\n got %s\nwant %s", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("passphrase keying", func(t *testing.T) {
|
|
c, err := NewCodec(Passphrase("correct horse battery staple"), testSalt, Params{})
|
|
if err != nil {
|
|
t.Fatalf("NewCodec: %v", err)
|
|
}
|
|
const want = "2c6ee106931bbdc6ea7e33497f04526ccbe4fb541379d36a506a65eabed0d8e2"
|
|
if got := hex.EncodeToString(c.key); got != want {
|
|
t.Errorf("passphrase key changed\n got %s\nwant %s", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("the two keys are distinct", func(t *testing.T) {
|
|
c := codec(t)
|
|
if bytes.Equal(c.key, c.mac) {
|
|
t.Fatal("encryption and authentication keys must not be equal")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGoldenPage pins the whole on-disk page layout for a fixed key, salt, IV and
|
|
// plaintext. It is the vector that stops a refactor from silently changing the
|
|
// format.
|
|
func TestGoldenPage(t *testing.T) {
|
|
c := codec(t)
|
|
c.rand = fixed(0xAB) // pins the IV, which crypto/rand would otherwise vary
|
|
|
|
page := make([]byte, DefaultPageSize)
|
|
copy(page, magic)
|
|
copy(page[100:], "golden")
|
|
|
|
out, err := c.Encrypt(2, page)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt: %v", err)
|
|
}
|
|
|
|
const want = "a33e43ee2bbd44de" // sha256(page)[:8], hex
|
|
if got := digest(out); got != want {
|
|
t.Errorf("page 2 ciphertext changed\n got %s\nwant %s", got, want)
|
|
}
|
|
if iv := out[DefaultPageSize-Reserve : DefaultPageSize-Reserve+IVSize]; !bytes.Equal(iv, bytes.Repeat([]byte{0xAB}, IVSize)) {
|
|
t.Errorf("IV must be stored at the head of the reserve, got %x", iv)
|
|
}
|
|
}
|
|
|
|
// TestPage1KeepsSaltInTheClear checks the one page that is special.
|
|
func TestPage1KeepsSaltInTheClear(t *testing.T) {
|
|
c := codec(t)
|
|
page := make([]byte, DefaultPageSize)
|
|
copy(page, magic)
|
|
page[20] = Reserve
|
|
|
|
out, err := c.Encrypt(1, page)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt: %v", err)
|
|
}
|
|
if !bytes.Equal(out[:SaltSize], testSalt) {
|
|
t.Errorf("page 1 must store the salt in the clear, got %x", out[:SaltSize])
|
|
}
|
|
if bytes.HasPrefix(out, magic) {
|
|
t.Error("page 1 must not leave SQLite's magic on disk")
|
|
}
|
|
|
|
back, err := c.Decrypt(1, out)
|
|
if err != nil {
|
|
t.Fatalf("Decrypt: %v", err)
|
|
}
|
|
if !bytes.HasPrefix(back, magic) {
|
|
t.Errorf("decrypting page 1 must restore SQLite's magic, got %q", back[:SaltSize])
|
|
}
|
|
if !bytes.Equal(back[:DefaultPageSize-Reserve], page[:DefaultPageSize-Reserve]) {
|
|
t.Error("page 1 round-trip lost data")
|
|
}
|
|
}
|
|
|
|
// TestRoundTrip covers the ordinary pages.
|
|
func TestRoundTrip(t *testing.T) {
|
|
c := codec(t)
|
|
for _, pgno := range []uint32{1, 2, 3, 1000, 4294967295} {
|
|
page := make([]byte, DefaultPageSize)
|
|
for i := range page[:DefaultPageSize-Reserve] {
|
|
page[i] = byte(i * int(pgno))
|
|
}
|
|
if pgno == 1 {
|
|
copy(page, magic)
|
|
}
|
|
enc, err := c.Encrypt(pgno, page)
|
|
if err != nil {
|
|
t.Fatalf("page %d: Encrypt: %v", pgno, err)
|
|
}
|
|
if bytes.Equal(enc[offset(pgno):DefaultPageSize-Reserve], page[offset(pgno):DefaultPageSize-Reserve]) {
|
|
t.Fatalf("page %d was not encrypted", pgno)
|
|
}
|
|
back, err := c.Decrypt(pgno, enc)
|
|
if err != nil {
|
|
t.Fatalf("page %d: Decrypt: %v", pgno, err)
|
|
}
|
|
want := page[offset(pgno) : DefaultPageSize-Reserve]
|
|
if !bytes.Equal(back[offset(pgno):DefaultPageSize-Reserve], want) {
|
|
t.Fatalf("page %d round-trip corrupted data", pgno)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFreshIVEveryWrite: CBC reuses of an IV under one key leak plaintext
|
|
// relationships, so the same page encrypted twice must differ.
|
|
func TestFreshIVEveryWrite(t *testing.T) {
|
|
c := codec(t)
|
|
page := make([]byte, DefaultPageSize)
|
|
a, err := c.Encrypt(2, page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := c.Encrypt(2, page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Equal(a, b) {
|
|
t.Fatal("two writes of the same page produced the same bytes: the IV is not fresh")
|
|
}
|
|
}
|
|
|
|
// TestFailsClosed is the property the whole design rests on: anything other than
|
|
// the right key over intact bytes must error, never return plaintext.
|
|
func TestFailsClosed(t *testing.T) {
|
|
c := codec(t)
|
|
page := make([]byte, DefaultPageSize)
|
|
copy(page[100:], "secret")
|
|
enc, err := c.Encrypt(7, page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tamper := func(name string, mutate func([]byte) (*Codec, uint32, []byte)) {
|
|
t.Run(name, func(t *testing.T) {
|
|
bad := append([]byte(nil), enc...)
|
|
cc, pgno, in := mutate(bad)
|
|
out, err := cc.Decrypt(pgno, in)
|
|
if err == nil {
|
|
t.Fatalf("FAIL OPEN: returned %d bytes of plaintext instead of an error", len(out))
|
|
}
|
|
if !errors.Is(err, ErrKey) {
|
|
t.Fatalf("want ErrKey, got %v", err)
|
|
}
|
|
if out != nil {
|
|
t.Fatal("must not return data alongside the error")
|
|
}
|
|
})
|
|
}
|
|
|
|
tamper("wrong key", func(b []byte) (*Codec, uint32, []byte) {
|
|
k := append([]byte(nil), testKey...)
|
|
k[31] ^= 1
|
|
cc, err := NewCodec(RawKey(k), testSalt, Params{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cc, 7, b
|
|
})
|
|
tamper("wrong salt", func(b []byte) (*Codec, uint32, []byte) {
|
|
s := append([]byte(nil), testSalt...)
|
|
s[0] ^= 1
|
|
cc, err := NewCodec(RawKey(testKey), s, Params{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cc, 7, b
|
|
})
|
|
tamper("flipped ciphertext bit", func(b []byte) (*Codec, uint32, []byte) {
|
|
b[64] ^= 1
|
|
return c, 7, b
|
|
})
|
|
tamper("flipped IV bit", func(b []byte) (*Codec, uint32, []byte) {
|
|
b[DefaultPageSize-Reserve] ^= 1
|
|
return c, 7, b
|
|
})
|
|
tamper("flipped tag bit", func(b []byte) (*Codec, uint32, []byte) {
|
|
b[DefaultPageSize-1] ^= 1
|
|
return c, 7, b
|
|
})
|
|
// The page number is authenticated, so a page cannot be replayed at another
|
|
// offset in the file even though it is otherwise a valid page.
|
|
tamper("page replayed at another page number", func(b []byte) (*Codec, uint32, []byte) {
|
|
return c, 8, b
|
|
})
|
|
}
|
|
|
|
// TestDecryptsDatabaseWrittenByC is the byte-compatibility gate, pinned as a
|
|
// regression: testdata/c-4.5.6.db was written by the real C libsqlcipher 4.5.6.
|
|
// If this ever fails, the port has drifted from the format and every database in
|
|
// production has become unreadable.
|
|
func TestDecryptsDatabaseWrittenByC(t *testing.T) {
|
|
enc, err := os.ReadFile("testdata/c-4.5.6.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.HasPrefix(enc, magic) {
|
|
t.Fatal("fixture is not encrypted")
|
|
}
|
|
|
|
var plain bytes.Buffer
|
|
if err := DecryptFile(&plain, bytes.NewReader(enc), RawKey(testKey), Params{}); err != nil {
|
|
t.Fatalf("failed to decrypt a database the C library wrote: %v", err)
|
|
}
|
|
pt := plain.Bytes()
|
|
|
|
if len(pt) != len(enc) {
|
|
t.Errorf("decrypt changed the file length: %d -> %d", len(enc), len(pt))
|
|
}
|
|
if !bytes.HasPrefix(pt, magic) {
|
|
t.Fatalf("decrypted page 1 is not a SQLite header: %q", pt[:16])
|
|
}
|
|
if pt[20] != Reserve {
|
|
t.Errorf("C wrote reserve %d, this port expects %d", pt[20], Reserve)
|
|
}
|
|
if got := int(pt[16])<<8 | int(pt[17])<<16; got != DefaultPageSize {
|
|
t.Errorf("page size = %d, want %d", got, DefaultPageSize)
|
|
}
|
|
for _, want := range []string{"hello-from-c", "ledger-row-two", "idx_t_v"} {
|
|
if !bytes.Contains(pt, []byte(want)) {
|
|
t.Errorf("decrypted database is missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReencryptMatchesC checks the other direction at the file level: re-encrypt
|
|
// the decrypted fixture under its own salt and the result must decrypt back to
|
|
// exactly the same plaintext. (That the C library itself reads Go-written
|
|
// databases is proven by the parity harness, which needs libsqlcipher present.)
|
|
func TestReencryptMatchesC(t *testing.T) {
|
|
enc, err := os.ReadFile("testdata/c-4.5.6.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
salt, err := FileSalt(enc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var plain, again, back bytes.Buffer
|
|
if err := DecryptFile(&plain, bytes.NewReader(enc), RawKey(testKey), Params{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := EncryptFile(&again, bytes.NewReader(plain.Bytes()), RawKey(testKey), salt, Params{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(again.Bytes()[:SaltSize], salt) {
|
|
t.Error("re-encrypting under an explicit salt must keep that salt")
|
|
}
|
|
if err := DecryptFile(&back, bytes.NewReader(again.Bytes()), RawKey(testKey), Params{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The reserve holds the IV, which is fresh on every write, so the trailer is
|
|
// expected to differ. The usable bytes -- the database itself -- must not.
|
|
if err := sameData(plain.Bytes(), back.Bytes()); err != nil {
|
|
t.Fatalf("decrypt -> encrypt -> decrypt lost data: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEncryptFileRefusesUnreservedDatabase: a plaintext database with no reserve
|
|
// has no room for the IV and tag. Encrypting it would silently destroy the last
|
|
// Reserve bytes of every page, so it must be refused.
|
|
func TestEncryptFileRefusesUnreservedDatabase(t *testing.T) {
|
|
page := make([]byte, DefaultPageSize)
|
|
copy(page, magic)
|
|
page[16], page[17] = 0x10, 0x00 // page size 4096
|
|
page[20] = 0 // no reserve
|
|
|
|
err := EncryptFile(io.Discard, bytes.NewReader(page), RawKey(testKey), testSalt, Params{})
|
|
if err == nil {
|
|
t.Fatal("FAIL OPEN: encrypted a database that has no room for the page trailer")
|
|
}
|
|
if !strings.Contains(err.Error(), "reserve") {
|
|
t.Errorf("error should name the reserve, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRejectsBadInput(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fn func() error
|
|
}{
|
|
{"short salt", func() error { _, err := NewCodec(RawKey(testKey), testSalt[:8], Params{}); return err }},
|
|
{"short key", func() error { _, err := NewCodec(RawKey(testKey[:16]), testSalt, Params{}); return err }},
|
|
{"no key material", func() error { _, err := NewCodec(Key{}, testSalt, Params{}); return err }},
|
|
{"page size not a power of two", func() error {
|
|
_, err := NewCodec(RawKey(testKey), testSalt, Params{PageSize: 5000})
|
|
return err
|
|
}},
|
|
{"page 0", func() error { _, err := codec(t).Decrypt(0, make([]byte, DefaultPageSize)); return err }},
|
|
{"short page", func() error { _, err := codec(t).Decrypt(1, make([]byte, 100)); return err }},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if err := tc.fn(); err == nil {
|
|
t.Fatal("want error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// digest identifies a page by the first 8 bytes of its SHA-256, which is enough
|
|
// to pin the bytes without printing 4KiB of hex in a diff.
|
|
func digest(b []byte) string {
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:8])
|
|
}
|
|
|
|
// sameData compares two plaintext databases over the bytes SQLite actually uses,
|
|
// ignoring each page's reserve trailer.
|
|
func sameData(a, b []byte) error {
|
|
if len(a) != len(b) {
|
|
return fmt.Errorf("length %d != %d", len(a), len(b))
|
|
}
|
|
for off := 0; off < len(a); off += DefaultPageSize {
|
|
end := off + DefaultPageSize - Reserve
|
|
if !bytes.Equal(a[off:end], b[off:end]) {
|
|
return fmt.Errorf("page %d differs", off/DefaultPageSize+1)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestDecryptsPassphraseDatabaseWrittenByC cross-verifies the passphrase KDF
|
|
// against the C library: this fixture was keyed by libsqlcipher 4.5.6 with a
|
|
// passphrase, so decrypting it proves PBKDF2-HMAC-SHA512 at 256000 iterations
|
|
// agrees byte for byte. Without it, the passphrase vector above would only pin
|
|
// this port to itself.
|
|
func TestDecryptsPassphraseDatabaseWrittenByC(t *testing.T) {
|
|
enc, err := os.ReadFile("testdata/c-4.5.6-passphrase.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var plain bytes.Buffer
|
|
if err := DecryptFile(&plain, bytes.NewReader(enc), Passphrase("correct horse battery staple"), Params{}); err != nil {
|
|
t.Fatalf("failed to decrypt a passphrase-keyed database the C library wrote: %v", err)
|
|
}
|
|
if !bytes.HasPrefix(plain.Bytes(), magic) {
|
|
t.Fatal("decrypted page 1 is not a SQLite header")
|
|
}
|
|
if !bytes.Contains(plain.Bytes(), []byte("passphrase-keyed-by-c")) {
|
|
t.Error("decrypted database is missing the row the C library inserted")
|
|
}
|
|
}
|
|
|
|
// TestWrongPassphraseFailsClosed guards the KDF path too.
|
|
func TestWrongPassphraseFailsClosed(t *testing.T) {
|
|
enc, err := os.ReadFile("testdata/c-4.5.6-passphrase.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = DecryptFile(io.Discard, bytes.NewReader(enc), Passphrase("correct horse battery stapl"), Params{})
|
|
if err == nil {
|
|
t.Fatal("FAIL OPEN: a wrong passphrase decrypted the database")
|
|
}
|
|
if !errors.Is(err, ErrKey) {
|
|
t.Fatalf("want ErrKey, got %v", err)
|
|
}
|
|
}
|