Files
hanzo-dev fbc1bcce40 feat: pure-Go SQLCipher 4 page format
Port of the SQLCipher codec (sqlcipher/sqlcipher v4.5.6, BSD-3, ZETETIC LLC)
in pure Go, with no third-party dependencies.

The format, and only the format: key derivation, per-page AES-256-CBC,
per-page HMAC-SHA512 over ciphertext||IV||pgno_le32, and page-1 salt
handling. It knows nothing about database/sql or SQLite drivers, so a
driver, a backup path, a migration or a forensic tool can all use it
without dragging in an engine.

Constants taken from the C source and confirmed against libsqlcipher 4.5.6:
kdf_iter 256000, fast_kdf_iter 2, hmac salt mask 0x3a, salt 16 bytes,
key 32, iv 16, hmac 64, reserve 80, LE page numbers.

Decrypt authenticates before it decrypts: a wrong key returns ErrKey, never
garbage.
2026-07-16 12:26:23 -07:00

84 lines
2.9 KiB
Go

package sqlcipher
import (
"crypto/rand"
"errors"
"fmt"
"io"
)
// DecryptFile decrypts a whole database, writing a plaintext SQLite database that
// any SQLite build can open without a key. The salt is read from the source's
// page 1.
//
// The result keeps the source's per-page Reserve trailer — SQLite records the
// reserve in byte 20 of the header, so the plaintext database describes itself and
// stays a lossless round-trip back through EncryptFile.
func DecryptFile(dst io.Writer, src io.Reader, k Key, p Params) error {
return convert(dst, src, k, p, nil, (*Codec).Decrypt)
}
// EncryptFile encrypts a whole plaintext SQLite database.
//
// The source must already reserve Reserve bytes per page (header byte 20), which
// is what a database DecryptFile produced does; otherwise there is no room for the
// IV and tag and this returns an error rather than truncating data. A nil salt
// draws a fresh random one, which is what a new database wants; pass a salt only
// to rewrite a database under its existing one.
func EncryptFile(dst io.Writer, src io.Reader, k Key, salt []byte, p Params) error {
if salt == nil {
salt = make([]byte, SaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return fmt.Errorf("sqlcipher: new salt: %w", err)
}
}
return convert(dst, src, k, p, salt, (*Codec).Encrypt)
}
// convert streams src page by page through op. salt is nil when it must be read
// from the source's page 1 (decrypting) and set when it is chosen (encrypting).
func convert(dst io.Writer, src io.Reader, k Key, p Params, salt []byte, op func(*Codec, uint32, []byte) ([]byte, error)) error {
page := make([]byte, p.pageSize())
var c *Codec
for pgno := uint32(1); ; pgno++ {
n, err := io.ReadFull(src, page)
if errors.Is(err, io.EOF) && pgno > 1 {
return nil // clean end of file: whole number of pages
}
if err != nil {
return fmt.Errorf("sqlcipher: read page %d: %w", pgno, err)
}
if n != len(page) {
return fmt.Errorf("sqlcipher: page %d is %d bytes, want %d", pgno, n, len(page))
}
if pgno == 1 {
if salt == nil {
// Decrypting: the database carries its own salt in the clear.
if salt, err = FileSalt(page); err != nil {
return err
}
} else if !hasReserve(page) {
return fmt.Errorf("sqlcipher: source database reserves %d bytes per page, need %d "+
"(SQLite header byte 20): it cannot be encrypted without losing data", page[20], Reserve)
}
if c, err = NewCodec(k, salt, p); err != nil {
return err
}
}
out, err := op(c, pgno, page)
if err != nil {
return err
}
if _, err := dst.Write(out); err != nil {
return fmt.Errorf("sqlcipher: write page %d: %w", pgno, err)
}
}
}
// hasReserve reports whether a plaintext page 1 declares the reserve the format
// needs. Byte 20 of the SQLite header is the per-page unused reserve.
func hasReserve(page1 []byte) bool { return len(page1) > 20 && page1[20] == Reserve }