mirror of
https://github.com/hanzoai/cek.git
synced 2026-10-09 02:37:18 +00:00
A database with a .dek sidecar is encrypted under a random DEK only the sidecar holds, so a copy of the file alone is unreadable: a durable snapshot, a backup or a new pod hydrating one loses it the day the disk holding the sidecar is lost. Rekey moves such a database to the key its owner and subsystem derive, then the sidecar goes. The copy is Convert's: export reads the source under the DEK into a fresh database under the derived key, and verify holds it row for row before anything moves. The commit sets the original and its sidecar aside as a pair, swaps the copy in, and drops the pair only once the copy reads under its key; recoverRekey reads a stop between any two steps back from the files. With no sidecar and nothing interrupted it costs two stats and takes no lock. Claude-Session: https://claude.ai/code/session_01SCPMGSQsLNF2YtHFL4bwyp
1015 lines
36 KiB
Go
1015 lines
36 KiB
Go
package cek
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"hash"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/hanzoai/namespace"
|
|
sqlitedrv "github.com/hanzoai/sqlite"
|
|
)
|
|
|
|
// hexKey renders a raw key in the x'…' form SQLCipher reads as key material
|
|
// rather than as a passphrase to stretch. Bound as a parameter, never formatted
|
|
// into a statement.
|
|
func hexKey(key []byte) string { return fmt.Sprintf("x'%x'", key) }
|
|
|
|
// openPlain opens a database with NO key, which is what a file written before
|
|
// cek is. It exists so the one place that reads plaintext says so out loud.
|
|
func openPlain(path string) (*sql.DB, error) {
|
|
db, err := sql.Open("sqlite", sqlitedrv.DSN(path, nil))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cek: open the plaintext %q: %w", path, err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// openKeyed opens a database under a raw key. The error never carries the DSN,
|
|
// because the DSN carries the key.
|
|
func openKeyed(path string, key []byte) (*sql.DB, error) {
|
|
db, err := sqlitedrv.OpenDB(path, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cek: open the encrypted %q: %w", path, err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// Converting a database that was written before it had a key.
|
|
//
|
|
// A database born under cek is encrypted from its first page, so nothing here
|
|
// runs for it. What this is for is the other kind: a file some earlier process
|
|
// wrote in the clear, which now has to become the encrypted store without
|
|
// losing a row. That is a migration, and a migration is exactly the thing the
|
|
// derived-key scheme does not need for its OWN files — so it is a separate verb
|
|
// a caller asks for, not something Open does behind one.
|
|
//
|
|
// It is CRASH-SAFE and FAIL-SECURE, in that order:
|
|
//
|
|
// - The plaintext file remains the source of truth until an atomic rename
|
|
// commits the encrypted copy. Every failure before that leaves the original
|
|
// exactly as it was.
|
|
// - The swap happens only after the copy has reproduced the source's schema,
|
|
// per-table row count and per-table content hash, passed integrity_check,
|
|
// and been RE-OPENED under the same keyed path the application will use. A
|
|
// key that cannot open the result is therefore caught while the plaintext is
|
|
// still there, rather than after it is gone.
|
|
// - A crash between the commit steps is resolved from the files alone
|
|
// (recoverInterrupted), because a derived key is reproducible: unlike a
|
|
// random per-file key, there is no sidecar that can be lost or get out of
|
|
// step with the database it belongs to.
|
|
//
|
|
// The transient plaintext copy the commit leaves behind is SHREDDED as soon as
|
|
// the encrypted database opens, so the window in which two readable copies exist
|
|
// is the swap itself and nothing longer.
|
|
|
|
const (
|
|
tmpSuffix = ".cek.tmp" // in-progress encrypted target; same volume, so the rename is atomic
|
|
plainBakSuffix = ".plain.bak" // transient pre-migration plaintext, shredded after a verified open
|
|
lockSuffix = ".cek.lock" // per-database flock, so two processes cannot convert at once
|
|
keyIDSuffix = ".keyid" // which key sealed this database; see keyID
|
|
|
|
sqliteMagic = "SQLite format 3\x00" // the 16-byte header of an UNENCRYPTED database
|
|
headerLen = 16
|
|
|
|
// convertTimeout bounds the copy and the verification. Both are proportional
|
|
// to the database, and a store this runs on is one that fits on a volume.
|
|
convertTimeout = 30 * time.Minute
|
|
)
|
|
|
|
// Convert makes the database at path encrypted under the key ns and subsystem
|
|
// derive, in place, without losing a row.
|
|
//
|
|
// It is IDEMPOTENT and cheap to repeat: a database that is already ciphertext is
|
|
// left untouched and the call costs a stat, so a caller can run it on every boot
|
|
// and stop thinking about which run is the first one.
|
|
//
|
|
// An absent database is not an error — there is nothing to convert, and Open
|
|
// will create one born encrypted.
|
|
func Convert(ns namespace.Namespace, subsystem, path string) error {
|
|
masterMu.RLock()
|
|
m := master
|
|
masterMu.RUnlock()
|
|
|
|
key, err := DeriveKey(m, ns, subsystem)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer zero(key)
|
|
|
|
unlock, err := flock(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
|
|
if err := recoverInterrupted(path); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch classify(path) {
|
|
case absent:
|
|
// Nothing to convert. Open will create one born encrypted, and this must
|
|
// not create it here — a file made by the converter is a file with no
|
|
// identities in it.
|
|
return nil
|
|
case plaintext:
|
|
return convert(path, key)
|
|
case truncated:
|
|
// Too small to be a database. This is NOT "already encrypted": a store is
|
|
// never two bytes long, and reading it as done is how a delayed
|
|
// allocation after an unclean stop becomes a shredded backup and an empty
|
|
// identity service. Refuse and let recoverInterrupted's backup, or a
|
|
// human, settle it.
|
|
return fmt.Errorf("cek: %q is too small to be a database, so this refuses to treat it as one", path)
|
|
default:
|
|
// Encrypted already — or something this cannot read. Which of those it is
|
|
// decides whether the plaintext beside it may go, so ASK the file.
|
|
return settle(path, key)
|
|
}
|
|
}
|
|
|
|
// settle proves the store at path opens and decrypts under key, and only then
|
|
// releases the plaintext copy a commit left beside it.
|
|
//
|
|
// The proof is the whole function. "Not a plaintext SQLite header" is not proof
|
|
// of anything — an empty file, a truncated one, and a database encrypted under a
|
|
// DIFFERENT master all look identical through that lens, and every one of them
|
|
// would otherwise shred the last readable copy of the identity graph while
|
|
// reporting success.
|
|
func settle(path string, key []byte) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), convertTimeout)
|
|
defer cancel()
|
|
|
|
db, err := openKeyed(path, key)
|
|
if err != nil {
|
|
return fmt.Errorf("cek: open %q: %w", path, err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
// Reading the schema is what forces SQLCipher to take the key to the pages.
|
|
// Opening a handle proves nothing: database/sql connects lazily, so a wrong
|
|
// key surfaces at the first read and not before.
|
|
var n int64
|
|
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master").Scan(&n); err != nil {
|
|
return fmt.Errorf("cek: %q does not read under its key (%s): %w", path, diagnose(path, key), err)
|
|
}
|
|
if err := recordKeyID(path, key); err != nil {
|
|
return fmt.Errorf("cek: %q opened but its key could not be recorded: %w", path, err)
|
|
}
|
|
shredPlainBak(path)
|
|
return nil
|
|
}
|
|
|
|
// keyID names the key a database was sealed under, so that a store which does
|
|
// not open can say WHICH of the two things happened to it.
|
|
//
|
|
// "wrong master key, or a damaged store" was one sentence covering two faults
|
|
// with opposite remedies: the first wants the right key and the bytes are
|
|
// intact, the second wants a restore and the bytes are gone. Told apart only by
|
|
// forensics — reading the file for a SQLite header that is not there, dating a
|
|
// lock file, and finding no backup — while an identity store sat unreadable.
|
|
// Recording the key's identity beside the database turns that into one
|
|
// comparison.
|
|
//
|
|
// It is a COMMITMENT, not the key: SHA-256 over a domain-separating label and
|
|
// the derived key, truncated. It cannot be run backwards to the key, it cannot
|
|
// be replayed as one, and it says nothing about any other database — two stores
|
|
// under one master have different derived keys and so different ids.
|
|
func keyID(key []byte) string {
|
|
sum := sha256.Sum256(append([]byte("cek/keyid/v1\x00"), key...))
|
|
return hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
// recordKeyID writes the id beside the database, replacing any earlier one: the
|
|
// key that opens it now is the fact worth keeping.
|
|
func recordKeyID(path string, key []byte) error {
|
|
return os.WriteFile(path+keyIDSuffix, []byte(keyID(key)+"\n"), 0o600)
|
|
}
|
|
|
|
// diagnose answers the question the caller actually has when a store will not
|
|
// read: is this the wrong key, or damage. An absent id cannot answer it, and
|
|
// says so rather than guessing — every store sealed before this existed is in
|
|
// that position, and inventing a verdict for them is how a restore gets aimed
|
|
// at the wrong file.
|
|
func diagnose(path string, key []byte) string {
|
|
recorded, err := os.ReadFile(path + keyIDSuffix)
|
|
if err != nil {
|
|
return "no key id recorded, so wrong-key and damage are indistinguishable here"
|
|
}
|
|
was, now := strings.TrimSpace(string(recorded)), keyID(key)
|
|
if was != now {
|
|
return fmt.Sprintf("SEALED UNDER A DIFFERENT KEY: sealed under %s, opened with %s — the bytes are intact and want the other key, not a restore", was, now)
|
|
}
|
|
return fmt.Sprintf("the key is the one that sealed it (%s), so the store itself is damaged", now)
|
|
}
|
|
|
|
// convert holds the whole of the one-way trip, with the lock already held and
|
|
// the file known to be plaintext.
|
|
func convert(path string, key []byte) error {
|
|
tmp := path + tmpSuffix
|
|
removeDBFiles(tmp) // a previous attempt that did not commit
|
|
|
|
srcInv, err := export(path, tmp, nil, key)
|
|
if err != nil {
|
|
removeDBFiles(tmp)
|
|
return err
|
|
}
|
|
if classify(tmp) != sealed {
|
|
removeDBFiles(tmp)
|
|
return fmt.Errorf("cek: converting %q did not produce an encrypted file — this build has no SQLCipher codec, so it cannot encrypt anything", path)
|
|
}
|
|
if err := verify(tmp, key, srcInv); err != nil {
|
|
removeDBFiles(tmp)
|
|
return fmt.Errorf("cek: the encrypted copy of %q does not match it, so the original is untouched: %w", path, err)
|
|
}
|
|
|
|
// Only the main file is renamed below, so the copy has to be COMPLETE in it.
|
|
// Closing the last connection checkpoints and removes a WAL or a rollback
|
|
// journal, so one still here means the close did not finish — and renaming
|
|
// the main file alone would drop whatever that file still holds. The journal
|
|
// is the one that actually turns up: the attached target runs in DELETE mode,
|
|
// because journal_mode applies to main and not to what ATTACH opens.
|
|
// Refusing costs a retry on the next boot; renaming anyway costs the rows.
|
|
for _, sidefile := range []string{"-wal", "-journal"} {
|
|
if fileExists(tmp + sidefile) {
|
|
removeDBFiles(tmp)
|
|
return fmt.Errorf("cek: the encrypted copy of %q still has a %s file beside it, so it is not complete in one file; the original is untouched", path, sidefile)
|
|
}
|
|
}
|
|
|
|
// Get the copy's BYTES to the disk before the plaintext stops being the
|
|
// source of truth. syncDir below makes the RENAME durable, which is a
|
|
// different promise: without this the encrypted pages can still be in the
|
|
// page cache when the original is released, and a power loss inside the
|
|
// writeback window leaves a torn database and no plaintext to rebuild it.
|
|
if err := syncFile(tmp); err != nil {
|
|
removeDBFiles(tmp)
|
|
return fmt.Errorf("cek: flush the encrypted copy of %q to disk: %w", path, err)
|
|
}
|
|
|
|
// Commit, in the order a crash can be read back from:
|
|
// 1. plaintext → <db>.plain.bak, so the original still exists,
|
|
// 2. drop the orphaned plaintext -wal/-shm (the WAL was folded into the main
|
|
// file before the copy, so the backup is complete and leaving them would
|
|
// put a plaintext WAL beside an encrypted database),
|
|
// 3. atomic rename tmp → path.
|
|
if err := os.Rename(path, path+plainBakSuffix); err != nil {
|
|
removeDBFiles(tmp)
|
|
return fmt.Errorf("cek: set the plaintext %q aside: %w", path, err)
|
|
}
|
|
_ = os.Remove(path + "-wal")
|
|
_ = os.Remove(path + "-shm")
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Rename(path+plainBakSuffix, path) // put it back
|
|
return fmt.Errorf("cek: swap the encrypted copy into %q: %w", path, err)
|
|
}
|
|
syncDir(filepath.Dir(path))
|
|
|
|
// Prove the real path READS under the real key before the plaintext goes, and
|
|
// let settle be the one place that decides it. This used to open a handle,
|
|
// close it, and shred — but database/sql connects lazily, so an open that
|
|
// touches no page proves nothing, which is the same mistake in a different
|
|
// shape as trusting the header. settle reads the schema, records which key
|
|
// sealed the file, and only then releases the plaintext.
|
|
if err := settle(path, key); err != nil {
|
|
return fmt.Errorf("cek: the converted %q did not verify (its plaintext is beside it as %s): %w", path, plainBakSuffix, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// export folds the source's WAL into its main file, records what it holds, and
|
|
// copies every object and every row into a fresh encrypted database at tmp. The
|
|
// source is only ever read.
|
|
//
|
|
// THE COPY IS LOGICAL AND IT RUNS IN GO, on two ordinary handles: the plaintext
|
|
// source opened with no key, the encrypted target opened with one. It was
|
|
// SQLCipher's sqlcipher_export, which is a SQL function only the C library
|
|
// defines — so a build without that library could not convert a database at all,
|
|
// and the store it was pointed at stayed unopenable. Nothing else here needed the
|
|
// C engine: github.com/hanzoai/sqlite encrypts on every build (the pure-Go
|
|
// SQLCipher codec envelope, byte-compatible with the C one), so the conversion
|
|
// was the only place the dependency survived, and it did not have to.
|
|
//
|
|
// A byte-level encrypt of the source is not available for this: SQLCipher needs 80
|
|
// reserved bytes per page for its IV and tag, an ordinary plaintext database
|
|
// reserves none, and no pragma retrofits that into an existing file. A fresh keyed
|
|
// database has the reserve from its first page, so the rows move logically — which
|
|
// is what sqlcipher_export did too, and why verify compares content rather than
|
|
// bytes.
|
|
//
|
|
// srcKey is the key the source is read under: nil for a plaintext source, a
|
|
// sidecar's DEK for a database Rekey moves to its derived key (rekey.go).
|
|
func export(path, tmp string, srcKey, key []byte) (inventory, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), convertTimeout)
|
|
defer cancel()
|
|
|
|
var src *sql.DB
|
|
var err error
|
|
if srcKey == nil {
|
|
src, err = openPlain(path)
|
|
} else {
|
|
src, err = openKeyed(path, srcKey)
|
|
}
|
|
if err != nil {
|
|
return inventory{}, err
|
|
}
|
|
defer func() { _ = src.Close() }()
|
|
src.SetMaxOpenConns(1)
|
|
|
|
conn, err := src.Conn(ctx)
|
|
if err != nil {
|
|
return inventory{}, fmt.Errorf("cek: take a connection to %q: %w", path, err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
// A live store carries megabytes of WAL. Fold it in first, so the copy and
|
|
// the backup both hold every committed row.
|
|
//
|
|
// The RESULT has to be read, not just the error. wal_checkpoint reports a
|
|
// refusal in its first column and still returns success, so an unchecked call
|
|
// is indistinguishable from one that did nothing — and the plaintext -wal is
|
|
// deleted a few lines later, on the strength of this having worked.
|
|
var busy, logFrames, checkpointed int64
|
|
if err := conn.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointed); err != nil {
|
|
return inventory{}, fmt.Errorf("cek: checkpoint %q: %w", path, err)
|
|
}
|
|
if busy != 0 {
|
|
return inventory{}, fmt.Errorf("cek: %q has a write-ahead log this could not fold in — something else is holding the database open, and converting it now would strand those rows", path)
|
|
}
|
|
srcInv, err := readInventory(ctx, conn)
|
|
if err != nil {
|
|
return inventory{}, fmt.Errorf("cek: read what %q holds: %w", path, err)
|
|
}
|
|
objs, err := schemaObjects(ctx, conn)
|
|
if err != nil {
|
|
return inventory{}, fmt.Errorf("cek: read the schema of %q: %w", path, err)
|
|
}
|
|
|
|
dst, err := openKeyed(tmp, key)
|
|
if err != nil {
|
|
return inventory{}, err
|
|
}
|
|
dst.SetMaxOpenConns(1)
|
|
if err := copyInto(ctx, conn, dst, objs, srcInv); err != nil {
|
|
_ = dst.Close()
|
|
return inventory{}, err
|
|
}
|
|
// CLOSE IT HERE, not on a defer. The encrypted file is written when the last
|
|
// handle closes — that is what the envelope's seal is — and convert() reads
|
|
// the file straight after this returns, to classify it and to refuse a copy
|
|
// that still has a journal beside it.
|
|
if err := dst.Close(); err != nil {
|
|
return inventory{}, fmt.Errorf("cek: close the encrypted copy of %q: %w", path, err)
|
|
}
|
|
return srcInv, nil
|
|
}
|
|
|
|
// object is one row of sqlite_master worth recreating.
|
|
type object struct{ typ, name, ddl string }
|
|
|
|
// schemaObjects reads every object the copy has to recreate, in creation order.
|
|
//
|
|
// It REFUSES a virtual table. One brings shadow tables that SQLite maintains for
|
|
// it, whose contents are an index layout rather than rows; recreating the virtual
|
|
// table regenerates them, and no logical copy can promise the same bytes. The C
|
|
// function did this from inside the engine. Nothing cek converts has one, and a
|
|
// refusal names the reason rather than producing a database that verify would
|
|
// reject for a difference it cannot explain.
|
|
func schemaObjects(ctx context.Context, conn *sql.Conn) ([]object, error) {
|
|
rows, err := conn.QueryContext(ctx,
|
|
`SELECT type,name,COALESCE(sql,'') FROM sqlite_master `+
|
|
`WHERE name NOT LIKE 'sqlite_%' ORDER BY rowid`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
var out []object
|
|
for rows.Next() {
|
|
var o object
|
|
if err := rows.Scan(&o.typ, &o.name, &o.ddl); err != nil {
|
|
return nil, err
|
|
}
|
|
if o.ddl == "" {
|
|
continue // an index SQLite made for a constraint; it comes back with the table
|
|
}
|
|
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(o.ddl)), "CREATE VIRTUAL TABLE") {
|
|
return nil, fmt.Errorf("cek: %q is a virtual table, whose shadow tables a logical copy cannot reproduce byte for byte", o.name)
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// copyInto creates the schema in the target, moves every row, and carries the two
|
|
// header fields a fresh database starts at zero.
|
|
//
|
|
// TABLES FIRST, THEN ROWS, THEN EVERYTHING ELSE. An index built as the rows arrive
|
|
// is built once per insert; a trigger present while they arrive would fire on them
|
|
// and write rows the source never had. So both wait until the data is in.
|
|
func copyInto(ctx context.Context, src *sql.Conn, dst *sql.DB, objs []object, inv inventory) error {
|
|
conn, err := dst.Conn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("cek: take a connection to the encrypted copy: %w", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
// A foreign key can point at a table that has not been created yet, and the
|
|
// rows arrive table by table, so a row can reference one that is still empty.
|
|
// The source already satisfies its own constraints; re-checking them mid-copy
|
|
// only makes the order of the copy a correctness question.
|
|
if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
|
|
return fmt.Errorf("cek: hold off foreign keys on the copy: %w", err)
|
|
}
|
|
|
|
for _, o := range objs {
|
|
if o.typ != "table" {
|
|
continue
|
|
}
|
|
if _, err := conn.ExecContext(ctx, o.ddl); err != nil {
|
|
return fmt.Errorf("cek: create %q in the copy: %w", o.name, err)
|
|
}
|
|
}
|
|
for _, o := range objs {
|
|
if o.typ != "table" {
|
|
continue
|
|
}
|
|
if err := copyRows(ctx, src, conn, o.name); err != nil {
|
|
return fmt.Errorf("cek: copy the rows of %q: %w", o.name, err)
|
|
}
|
|
}
|
|
for _, o := range objs {
|
|
if o.typ == "table" {
|
|
continue
|
|
}
|
|
if _, err := conn.ExecContext(ctx, o.ddl); err != nil {
|
|
return fmt.Errorf("cek: create %s %q in the copy: %w", o.typ, o.name, err)
|
|
}
|
|
}
|
|
|
|
// AUTOINCREMENT counters live in sqlite_sequence, which SQLite creates with the
|
|
// first such table and fills from the rows it is given — so after a copy it
|
|
// holds the highest rowid COPIED, which is the same number, unless a table's
|
|
// top rows were deleted. Then the next insert would reuse an id the source had
|
|
// already handed out. The source's own counters settle it.
|
|
if err := copySequence(ctx, src, conn); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The header fields sqlcipher_export did not carry either. Integers this
|
|
// process just read out of the source, and PRAGMA takes no parameters.
|
|
for i, p := range headerPragmas {
|
|
v := [2]int64{inv.userVersion, inv.applicationID}[i]
|
|
if _, err := conn.ExecContext(ctx, fmt.Sprintf("PRAGMA %s = %d", p, v)); err != nil {
|
|
return fmt.Errorf("cek: carry %s onto the copy: %w", p, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// copyRows moves one table, naming its columns explicitly.
|
|
//
|
|
// SELECT * would also read a GENERATED column, which cannot be inserted into, and
|
|
// would bind columns by position — so a table whose DDL SQLite normalised into a
|
|
// different order would be copied into the wrong columns. table_xinfo reports the
|
|
// generated ones (hidden 2 and 3) and it reports them in the target's order too,
|
|
// since both sides created the table from the same DDL.
|
|
func copyRows(ctx context.Context, src *sql.Conn, dst *sql.Conn, table string) error {
|
|
cols, err := userColumns(ctx, src, table)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(cols) == 0 {
|
|
return nil // every column is generated: there is nothing to write
|
|
}
|
|
quoted := make([]string, len(cols))
|
|
marks := make([]string, len(cols))
|
|
for i, c := range cols {
|
|
quoted[i] = quoteIdent(c)
|
|
marks[i] = "?"
|
|
}
|
|
list := strings.Join(quoted, ",")
|
|
|
|
rows, err := src.QueryContext(ctx, "SELECT "+list+" FROM "+quoteIdent(table))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
ins := "INSERT INTO " + quoteIdent(table) + " (" + list + ") VALUES (" + strings.Join(marks, ",") + ")"
|
|
tx, err := dst.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stmt, err := tx.PrepareContext(ctx, ins)
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
vals := make([]any, len(cols))
|
|
ptrs := make([]any, len(cols))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
for rows.Next() {
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
if _, err := stmt.ExecContext(ctx, vals...); err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// userColumns names the columns of table that hold stored values, in order.
|
|
func userColumns(ctx context.Context, conn *sql.Conn, table string) ([]string, error) {
|
|
rows, err := conn.QueryContext(ctx, "SELECT name,hidden FROM pragma_table_xinfo(?)", table)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
var out []string
|
|
for rows.Next() {
|
|
var name string
|
|
var hidden int
|
|
if err := rows.Scan(&name, &hidden); err != nil {
|
|
return nil, err
|
|
}
|
|
if hidden == 2 || hidden == 3 {
|
|
continue // VIRTUAL and STORED generated columns: computed, never written
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// copySequence carries the AUTOINCREMENT counters, for the tables that have one.
|
|
func copySequence(ctx context.Context, src *sql.Conn, dst *sql.Conn) error {
|
|
var present int
|
|
if err := src.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'`).Scan(&present); err != nil {
|
|
return fmt.Errorf("cek: look for the sequence table: %w", err)
|
|
}
|
|
if present == 0 {
|
|
return nil
|
|
}
|
|
rows, err := src.QueryContext(ctx, "SELECT name,seq FROM sqlite_sequence")
|
|
if err != nil {
|
|
return fmt.Errorf("cek: read the sequence table: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
for rows.Next() {
|
|
var name string
|
|
var seq int64
|
|
if err := rows.Scan(&name, &seq); err != nil {
|
|
return err
|
|
}
|
|
// The row exists already when the copy inserted into that table, and does
|
|
// not when it was empty — so REPLACE it rather than upsert: sqlite_sequence
|
|
// carries no primary key and no unique index, which is what makes an
|
|
// ON CONFLICT target invalid on it.
|
|
if _, err := dst.ExecContext(ctx, "DELETE FROM sqlite_sequence WHERE name = ?", name); err != nil {
|
|
return fmt.Errorf("cek: clear the sequence of %q: %w", name, err)
|
|
}
|
|
if _, err := dst.ExecContext(ctx,
|
|
"INSERT INTO sqlite_sequence(name,seq) VALUES(?,?)", name, seq); err != nil {
|
|
return fmt.Errorf("cek: carry the sequence of %q: %w", name, err)
|
|
}
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
// quoteIdent renders an identifier for a statement, doubling any embedded quote.
|
|
func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
|
|
|
|
// verify re-opens the encrypted copy exactly as the application will and asserts
|
|
// that it holds the same database: integrity_check ok, the same schema, and per
|
|
// table the same row count AND the same content hash.
|
|
//
|
|
// The content hash is what backs the claim that nothing was lost. It is a
|
|
// commutative sum over per-row hashes of the USER columns, so it catches a
|
|
// changed value, a coerced NULL or an added or dropped row — all of which a row
|
|
// count and integrity_check miss — while ignoring the implicit-rowid
|
|
// renumbering sqlcipher_export legitimately does.
|
|
func verify(tmp string, key []byte, src inventory) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), convertTimeout)
|
|
defer cancel()
|
|
|
|
db, err := openKeyed(tmp, key)
|
|
if err != nil {
|
|
return fmt.Errorf("reopen the encrypted copy: %w", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
db.SetMaxOpenConns(1)
|
|
if err := db.PingContext(ctx); err != nil {
|
|
return fmt.Errorf("the encrypted copy does not answer under its key: %w", err)
|
|
}
|
|
|
|
var ic string
|
|
if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&ic); err != nil {
|
|
return fmt.Errorf("integrity_check: %w", err)
|
|
}
|
|
if ic != "ok" {
|
|
return fmt.Errorf("integrity_check returned %q", ic)
|
|
}
|
|
|
|
conn, err := db.Conn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("take a connection to the encrypted copy: %w", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
dst, err := readInventory(ctx, conn)
|
|
if err != nil {
|
|
return fmt.Errorf("read what the encrypted copy holds: %w", err)
|
|
}
|
|
|
|
if dst.userVersion != src.userVersion {
|
|
return fmt.Errorf("user_version is %d in the source and %d in the copy", src.userVersion, dst.userVersion)
|
|
}
|
|
if dst.applicationID != src.applicationID {
|
|
return fmt.Errorf("application_id is %d in the source and %d in the copy", src.applicationID, dst.applicationID)
|
|
}
|
|
if dst.schema != src.schema {
|
|
return fmt.Errorf("the schemas differ")
|
|
}
|
|
if len(dst.tables) != len(src.tables) {
|
|
return fmt.Errorf("the source has %d tables, the copy %d", len(src.tables), len(dst.tables))
|
|
}
|
|
for tbl, s := range src.tables {
|
|
d, ok := dst.tables[tbl]
|
|
if !ok {
|
|
return fmt.Errorf("table %q is missing from the copy", tbl)
|
|
}
|
|
if d.count != s.count {
|
|
return fmt.Errorf("table %q has %d rows in the source and %d in the copy", tbl, s.count, d.count)
|
|
}
|
|
if d.content != s.content {
|
|
return fmt.Errorf("table %q has the same number of rows in the copy but different values in them", tbl)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// inventory is what a database holds, in the form two of them can be compared:
|
|
// the header fields an application sets, a schema hash, and per table a row
|
|
// count and a content hash.
|
|
type inventory struct {
|
|
userVersion int64
|
|
applicationID int64
|
|
schema [32]byte
|
|
tables map[string]tableStat
|
|
}
|
|
|
|
// headerPragmas are the values a database carries in its HEADER rather than in a
|
|
// table: an application's own schema version and its file-format tag. A copy
|
|
// does not inherit them — measured, not assumed (fidelity_test.go) — and losing
|
|
// user_version silently is how a store comes back with a migration framework
|
|
// convinced it is looking at version 0.
|
|
var headerPragmas = []string{"user_version", "application_id"}
|
|
|
|
type tableStat struct {
|
|
count int64
|
|
content [32]byte // commutative sum of per-row hashes over the user columns
|
|
}
|
|
|
|
func readInventory(ctx context.Context, conn *sql.Conn) (inventory, error) {
|
|
var header [2]int64
|
|
for i, p := range headerPragmas {
|
|
if err := conn.QueryRowContext(ctx, "PRAGMA "+p).Scan(&header[i]); err != nil {
|
|
return inventory{}, fmt.Errorf("read %s: %w", p, err)
|
|
}
|
|
}
|
|
|
|
rows, err := conn.QueryContext(ctx,
|
|
`SELECT type,name,COALESCE(tbl_name,''),COALESCE(sql,'') FROM sqlite_master `+
|
|
`WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name`)
|
|
if err != nil {
|
|
return inventory{}, err
|
|
}
|
|
var schemaLines, tables []string
|
|
for rows.Next() {
|
|
var typ, name, tbl, ddl string
|
|
if err := rows.Scan(&typ, &name, &tbl, &ddl); err != nil {
|
|
_ = rows.Close()
|
|
return inventory{}, err
|
|
}
|
|
schemaLines = append(schemaLines, typ+"\x1f"+name+"\x1f"+tbl+"\x1f"+ddl)
|
|
if typ == "table" {
|
|
tables = append(tables, name)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = rows.Close()
|
|
return inventory{}, err
|
|
}
|
|
_ = rows.Close()
|
|
|
|
sort.Strings(schemaLines)
|
|
h := sha256.New()
|
|
for _, l := range schemaLines {
|
|
writeLP(h, []byte(l))
|
|
}
|
|
inv := inventory{
|
|
userVersion: header[0],
|
|
applicationID: header[1],
|
|
tables: make(map[string]tableStat, len(tables)),
|
|
}
|
|
copy(inv.schema[:], h.Sum(nil))
|
|
|
|
for _, tbl := range tables {
|
|
st, err := tableContent(ctx, conn, tbl)
|
|
if err != nil {
|
|
return inventory{}, fmt.Errorf("table %q: %w", tbl, err)
|
|
}
|
|
inv.tables[tbl] = st
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
// tableContent streams every row and folds a per-row hash into a commutative
|
|
// 256-bit sum. Order-independent, so a different scan order does not register as
|
|
// a difference; addition rather than XOR, so duplicate rows do not cancel. O(1)
|
|
// memory per table.
|
|
func tableContent(ctx context.Context, conn *sql.Conn, tbl string) (tableStat, error) {
|
|
q := `SELECT * FROM "` + strings.ReplaceAll(tbl, `"`, `""`) + `"`
|
|
rows, err := conn.QueryContext(ctx, q)
|
|
if err != nil {
|
|
return tableStat{}, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
cols, err := rows.Columns()
|
|
if err != nil {
|
|
return tableStat{}, err
|
|
}
|
|
var st tableStat
|
|
scan := make([]any, len(cols))
|
|
ptrs := make([]any, len(cols))
|
|
for i := range scan {
|
|
ptrs[i] = &scan[i]
|
|
}
|
|
for rows.Next() {
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
return tableStat{}, err
|
|
}
|
|
rh := sha256.New()
|
|
for _, v := range scan {
|
|
encodeValue(rh, v)
|
|
}
|
|
var row [32]byte
|
|
copy(row[:], rh.Sum(nil))
|
|
addInto(&st.content, row)
|
|
st.count++
|
|
}
|
|
return st, rows.Err()
|
|
}
|
|
|
|
// encodeValue writes a type-tagged, length-prefixed encoding of a scanned value,
|
|
// so two equal rows hash the same and a NULL never collides with "" or 0.
|
|
func encodeValue(h hash.Hash, v any) {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
h.Write([]byte{0})
|
|
case int64:
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], uint64(x))
|
|
h.Write([]byte{1})
|
|
h.Write(b[:])
|
|
case float64:
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], math.Float64bits(x))
|
|
h.Write([]byte{2})
|
|
h.Write(b[:])
|
|
case bool:
|
|
h.Write([]byte{3})
|
|
if x {
|
|
h.Write([]byte{1})
|
|
} else {
|
|
h.Write([]byte{0})
|
|
}
|
|
case []byte:
|
|
h.Write([]byte{4})
|
|
writeLP(h, x)
|
|
case string:
|
|
h.Write([]byte{5})
|
|
writeLP(h, []byte(x))
|
|
case time.Time:
|
|
h.Write([]byte{6})
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], uint64(x.UnixNano()))
|
|
h.Write(b[:])
|
|
default:
|
|
h.Write([]byte{9})
|
|
writeLP(h, []byte(fmt.Sprintf("%v", x)))
|
|
}
|
|
}
|
|
|
|
// writeLP writes an 8-byte big-endian length and then the bytes, so a
|
|
// concatenation of them is unambiguous.
|
|
func writeLP(h hash.Hash, b []byte) {
|
|
var n [8]byte
|
|
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
|
|
h.Write(n[:])
|
|
h.Write(b)
|
|
}
|
|
|
|
// addInto computes acc = (acc + row) mod 2^256, big-endian.
|
|
func addInto(acc *[32]byte, row [32]byte) {
|
|
var carry uint16
|
|
for i := 31; i >= 0; i-- {
|
|
s := uint16(acc[i]) + uint16(row[i]) + carry
|
|
acc[i] = byte(s)
|
|
carry = s >> 8
|
|
}
|
|
}
|
|
|
|
// recoverInterrupted resumes a conversion that crashed between the commit steps,
|
|
// from the files alone.
|
|
//
|
|
// A derived key is what makes this readable: the key for a database is a
|
|
// function of its owner, so a copy left behind by a crash can always be opened
|
|
// and there is no per-file key material that could have been lost with it.
|
|
func recoverInterrupted(path string) error {
|
|
tmp := path + tmpSuffix
|
|
if fileExists(path) {
|
|
// The live file is there, so nothing is mid-swap. A leftover tmp is a dead
|
|
// attempt either way. The BACKUP is deliberately left alone: whether it may
|
|
// go depends on the live file opening under its key, which is settle's
|
|
// question and not one that can be answered from the file names.
|
|
if classify(path) != plaintext {
|
|
removeDBFiles(tmp)
|
|
}
|
|
return nil
|
|
}
|
|
switch {
|
|
case fileExists(tmp):
|
|
// Crashed during the swap. tmp is the verified encrypted copy and it
|
|
// opens under the derived key, so finishing is the right move.
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("cek: finish the interrupted swap into %q: %w", path, err)
|
|
}
|
|
_ = os.Remove(path + "-wal")
|
|
_ = os.Remove(path + "-shm")
|
|
syncDir(filepath.Dir(path))
|
|
// The backup stays until settle has opened what is now in place.
|
|
case fileExists(path + plainBakSuffix):
|
|
// The swap never happened. Put the plaintext back and let it be redone.
|
|
if err := os.Rename(path+plainBakSuffix, path); err != nil {
|
|
return fmt.Errorf("cek: restore the plaintext of %q: %w", path, err)
|
|
}
|
|
removeDBFiles(tmp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// state is what a file at a path IS, and it has four answers rather than two on
|
|
// purpose. A boolean "is this plaintext" folds absent, empty, truncated and
|
|
// encrypted into one false, and a caller that reads that false as "already
|
|
// encrypted, my work here is done" will delete the backup for three of them.
|
|
type state int
|
|
|
|
const (
|
|
absent state = iota // nothing there
|
|
truncated // there, but too small to be a database
|
|
plaintext // an UNENCRYPTED SQLite database
|
|
sealed // something else: encrypted, or unreadable
|
|
)
|
|
|
|
func classify(path string) state {
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
return absent
|
|
}
|
|
if fi.Size() < headerLen {
|
|
return truncated
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return sealed
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
var hdr [headerLen]byte
|
|
if _, err := f.ReadAt(hdr[:], 0); err != nil {
|
|
return sealed
|
|
}
|
|
if string(hdr[:]) == sqliteMagic {
|
|
return plaintext
|
|
}
|
|
return sealed
|
|
}
|
|
|
|
// shredPlainBak overwrites and removes the transient plaintext left by a commit.
|
|
// The overwrite is defence in depth — best effort on a copy-on-write or
|
|
// journalling filesystem, where the volume's own encryption is the real control.
|
|
// A no-op when there is nothing there.
|
|
func shredPlainBak(path string) {
|
|
bak := path + plainBakSuffix
|
|
fi, err := os.Stat(bak)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if f, err := os.OpenFile(bak, os.O_WRONLY, 0); err == nil {
|
|
overwrite(f, fi.Size())
|
|
_ = f.Sync()
|
|
_ = f.Close()
|
|
}
|
|
_ = os.Remove(bak)
|
|
}
|
|
|
|
func overwrite(f *os.File, size int64) {
|
|
const chunk = 1 << 20
|
|
buf := make([]byte, chunk)
|
|
for remaining := size; remaining > 0; {
|
|
n := int64(chunk)
|
|
if remaining < n {
|
|
n = remaining
|
|
}
|
|
if _, err := rand.Read(buf[:n]); err != nil {
|
|
return
|
|
}
|
|
if _, err := f.Write(buf[:n]); err != nil {
|
|
return
|
|
}
|
|
remaining -= n
|
|
}
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
_, err := os.Stat(p)
|
|
return err == nil
|
|
}
|
|
|
|
// removeDBFiles removes a database and every file SQLite keeps beside it. The
|
|
// rollback journal belongs in this list: an ATTACHed target journals in DELETE
|
|
// mode, so a crash mid-export leaves one, and clearing the database without it
|
|
// would leave a hot journal beside the NEXT attempt's fresh file.
|
|
func removeDBFiles(base string) {
|
|
for _, s := range []string{"", "-wal", "-shm", "-journal"} {
|
|
_ = os.Remove(base + s)
|
|
}
|
|
}
|
|
|
|
// syncFile forces a file's contents to the disk. A rename is only as durable as
|
|
// what it points at.
|
|
func syncFile(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// syncDir fsyncs a directory so a rename is durable. Best effort: a filesystem
|
|
// that refuses the open has already made the rename durable itself.
|
|
func syncDir(dir string) {
|
|
f, err := os.Open(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = f.Sync()
|
|
_ = f.Close()
|
|
}
|
|
|
|
// flock takes an exclusive lock beside the database, so two processes cannot
|
|
// convert the same file at once. The lock file is its own path and is never the
|
|
// database, so a lost lock cannot damage anything.
|
|
func flock(path string) (func(), error) {
|
|
lockPath := path + lockSuffix
|
|
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
|
return nil, fmt.Errorf("cek: make room for the lock beside %q: %w", path, err)
|
|
}
|
|
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cek: open the lock beside %q: %w", path, err)
|
|
}
|
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("cek: take the lock beside %q: %w", path, err)
|
|
}
|
|
return func() {
|
|
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
|
_ = f.Close()
|
|
}, nil
|
|
}
|