Files
hanzo-dev 65d5d83332 test: byte-compatibility gate against the real C library
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.
2026-07-16 12:33:02 -07:00

210 lines
7.8 KiB
Go

// Package sqlcipher implements the SQLCipher 4 on-disk page format in pure Go.
//
// It is a port of the SQLCipher codec (https://github.com/sqlcipher/sqlcipher,
// v4.5.6, BSD-3, Copyright (c) ZETETIC LLC) — see NOTICE. It knows nothing about
// database/sql or SQLite drivers: it is the format, and only the format. Anything
// that must read or write SQLCipher files — a driver, a backup or replication
// path, a migration or a forensic tool — uses this package directly.
//
// # The format
//
// A SQLCipher database is a SQLite database whose pages are individually
// encrypted, with per-page reserve bytes carrying the IV and authentication tag:
//
// page N on disk: [ ciphertext | IV (16) | HMAC-SHA512 (64) ]
// page 1 on disk: [ salt (16) | ciphertext | IV (16) | HMAC-SHA512 (64) ]
//
// Pages are independent: no cross-page state, no chaining. The salt is the first
// SaltSize bytes of page 1 and is stored in the clear — it is the only part of
// the file that is not ciphertext. Because page 1's first SaltSize bytes hold the
// salt instead of SQLite's "SQLite format 3\x00" magic, page 1 encrypts only the
// bytes after that offset; Decrypt restores the magic, which is what SQLite's
// pager expects to see.
//
// The ciphertext is AES-256-CBC with no padding, so its length is always a
// multiple of the AES block size. The IV is fresh on every page write. The HMAC
// covers ciphertext || IV || page number (little-endian uint32), which
// authenticates the page contents, the IV, and the page's position in the file —
// so pages cannot be reordered, and a modified IV is detected.
//
// # Keying
//
// A Key is either a raw 32-byte key (no KDF — SQLCipher's x'HEX' form) or a
// passphrase (PBKDF2-HMAC-SHA512, 256000 iterations by default). Either way the
// page-authentication key is a second, distinct key derived from the page
// encryption key using the salt masked with 0x3a and 2 PBKDF2 iterations.
//
// # Failing closed
//
// Decrypt authenticates before it decrypts and returns ErrKey on any HMAC
// mismatch. A wrong key errors; it never returns garbage plaintext.
package sqlcipher
import (
"bytes"
"crypto/hmac"
"crypto/pbkdf2"
"crypto/sha512"
"errors"
"fmt"
"io"
)
// Format sizes, all fixed by SQLCipher 4.
const (
SaltSize = 16 // KDF salt: the first bytes of page 1, stored in the clear
KeySize = 32 // AES-256
IVSize = 16 // AES block size
HMACSize = 64 // SHA-512 digest
// Reserve is the per-page trailer SQLite must leave free at the end of every
// page: IV || HMAC, rounded up to a multiple of the AES block size (it is
// already a multiple, so 16+64=80). SQLite records it in byte 20 of the
// database header, so a database carries its own reserve size.
Reserve = IVSize + HMACSize
// DefaultPageSize and DefaultIter are SQLCipher 4's defaults.
DefaultPageSize = 4096
DefaultIter = 256000
blockSize = 16 // AES
fastIter = 2 // PBKDF2 iterations for the page-authentication key
saltMask = 0x3a // masks the salt so the two keys derive from distinct salts
)
// magic is the SQLite file header. It occupies page 1's first SaltSize bytes in
// a plaintext database, where an encrypted database keeps the salt instead.
var magic = []byte("SQLite format 3\x00")
// ErrKey reports that a page did not authenticate: the key is wrong, or the page
// was corrupted or tampered with. The two are deliberately indistinguishable.
var ErrKey = errors.New("sqlcipher: wrong key or corrupted page")
// Params are the format parameters that vary between databases. The zero value
// means SQLCipher 4 defaults; set a field only to interoperate with a database
// that was written with a non-default PRAGMA.
type Params struct {
PageSize int // cipher_page_size; 0 means DefaultPageSize
Iter int // kdf_iter, passphrase keying only; 0 means DefaultIter
}
func (p Params) pageSize() int {
if p.PageSize == 0 {
return DefaultPageSize
}
return p.PageSize
}
func (p Params) iter() int {
if p.Iter == 0 {
return DefaultIter
}
return p.Iter
}
// Key is database key material: a raw key or a passphrase. It is inert until
// bound to a database salt by NewCodec.
type Key struct {
raw []byte
pass string
}
// RawKey uses key directly as the page encryption key, skipping the passphrase
// KDF. key must be KeySize bytes. This is SQLCipher's x'HEX' keying form and the
// one Hanzo uses: keys come from KMS already uniformly random, so a KDF over them
// would buy nothing.
func RawKey(key []byte) Key { return Key{raw: key} }
// Passphrase derives the page encryption key from pass via PBKDF2-HMAC-SHA512.
func Passphrase(pass string) Key { return Key{pass: pass} }
// Codec encrypts and decrypts the pages of one database. It is immutable and safe
// for concurrent use.
type Codec struct {
pageSize int
salt []byte // SaltSize bytes, page 1's plaintext prefix
key []byte // page encryption key
mac []byte // page authentication key
rand io.Reader
}
// NewCodec binds key material to a database salt. salt is SaltSize bytes, read
// from the first bytes of page 1 of an existing database (see FileSalt), or freshly
// random for a new one.
//
// Deriving from a passphrase runs Params.Iter PBKDF2 iterations and is
// deliberately slow; do it once per database, not once per page.
func NewCodec(k Key, salt []byte, p Params) (*Codec, error) {
if len(salt) != SaltSize {
return nil, fmt.Errorf("sqlcipher: salt must be %d bytes, got %d", SaltSize, len(salt))
}
if n := p.pageSize(); n < 512 || n > 65536 || n&(n-1) != 0 {
return nil, fmt.Errorf("sqlcipher: page size %d is not a power of two in [512,65536]", n)
}
var key []byte
switch {
case k.raw != nil:
if len(k.raw) != KeySize {
return nil, fmt.Errorf("sqlcipher: raw key must be %d bytes, got %d", KeySize, len(k.raw))
}
key = k.raw
case k.pass != "":
var err error
if key, err = pbkdf2.Key(sha512.New, k.pass, salt, p.iter(), KeySize); err != nil {
return nil, fmt.Errorf("sqlcipher: derive key: %w", err)
}
default:
return nil, errors.New("sqlcipher: no key material")
}
// The page-authentication key derives from the page encryption key over the
// masked salt, so the two keys are distinct but both reproducible from the
// passphrase and the file.
masked := make([]byte, SaltSize)
for i, b := range salt {
masked[i] = b ^ saltMask
}
mac, err := pbkdf2.Key(sha512.New, string(key), masked, fastIter, KeySize)
if err != nil {
return nil, fmt.Errorf("sqlcipher: derive page-authentication key: %w", err)
}
return &Codec{pageSize: p.pageSize(), salt: bytes.Clone(salt), key: bytes.Clone(key), mac: mac}, nil
}
// PageSize is the on-disk size of every page, including the reserve trailer.
func (c *Codec) PageSize() int { return c.pageSize }
// Salt returns the database salt.
func (c *Codec) Salt() []byte { return bytes.Clone(c.salt) }
// FileSalt reads the salt from page 1 of an encrypted database. page1 need only
// be the first SaltSize bytes of the file.
func FileSalt(page1 []byte) ([]byte, error) {
if len(page1) < SaltSize {
return nil, fmt.Errorf("sqlcipher: need %d bytes to read the salt, got %d", SaltSize, len(page1))
}
return bytes.Clone(page1[:SaltSize]), nil
}
// offset is the number of leading bytes page pgno does not encrypt: page 1 keeps
// the salt in the clear, every other page encrypts from byte 0.
func offset(pgno uint32) int {
if pgno == 1 {
return SaltSize
}
return 0
}
// pageMAC authenticates ciphertext||IV for page pgno. Binding the page number
// stops a valid page from being replayed at another position in the file; it is
// little-endian to match SQLCipher's default (CIPHER_FLAG_LE_PGNO), which is
// a fixed byte order rather than the host's so files stay portable.
func (c *Codec) pageMAC(pgno uint32, data []byte) []byte {
m := hmac.New(sha512.New, c.mac)
m.Write(data)
m.Write([]byte{byte(pgno), byte(pgno >> 8), byte(pgno >> 16), byte(pgno >> 24)})
return m.Sum(nil)
}