Rekey a database whose key is beside it

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
This commit is contained in:
2026-10-08 19:37:09 -07:00
parent 76e819803f
commit d09e9ac5f1
4 changed files with 348 additions and 3 deletions
+6
View File
@@ -15,6 +15,12 @@ maintain. A database is born encrypted or it does not exist.
Losing the master loses the data. That is the property you want from encryption
at rest, and the reason the master lives in KMS.
Two kinds of file predate that and are moved onto it in place, each verified row
for row before the original goes: `cek.Convert` encrypts a database written in the
clear, and `cek.Rekey` moves one written under a random key held in a `.dek`
sidecar beside it, which no copy of the database alone can open, to its derived
key. Both are idempotent, so a caller runs them before an open.
## Where it sits
| | |
+12 -3
View File
@@ -221,7 +221,7 @@ func convert(path string, key []byte) error {
tmp := path + tmpSuffix
removeDBFiles(tmp) // a previous attempt that did not commit
srcInv, err := export(path, tmp, key)
srcInv, err := export(path, tmp, nil, key)
if err != nil {
removeDBFiles(tmp)
return err
@@ -308,11 +308,20 @@ func convert(path string, key []byte) error {
// 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.
func export(path, tmp string, key []byte) (inventory, error) {
//
// 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()
src, err := openPlain(path)
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
}
+179
View File
@@ -0,0 +1,179 @@
package cek
import (
"fmt"
"os"
"path/filepath"
"github.com/hanzoai/namespace"
)
// Moving a database off a key beside it.
//
// A database with a sidecar (sidecar.go) is encrypted under a random DEK that
// only the sidecar holds, so the file alone is unreadable: a copy of it taken
// without its sidecar — a durable snapshot, a backup of the database file, a
// new pod hydrating one — is lost the day the disk holding the sidecar is. Rekey
// moves such a database to the key its owner and subsystem derive, which every
// process that holds the master can reproduce, and then the sidecar goes.
//
// It is Convert's commit discipline with one more file to carry. The copy is
// logical (export, under the DEK, into a fresh database under the derived key),
// verified row for row before anything moves, and committed in an order a crash
// can be read back from (recoverRekey). Until the commit the original and its
// sidecar are the source of truth, and until the copy has been read under its
// derived key the original is kept beside it with its sidecar.
const rekeyBakSuffix = ".rekey.bak" // the original under its DEK, until the copy settles
// Rekey moves the database at path from the key in its sidecar to the key ns and
// subsystem derive, in place, without losing a row.
//
// It is idempotent and cheap to repeat: a database with no sidecar and no
// interrupted rekey costs two stats, so a caller runs it before every open.
func Rekey(ns namespace.Namespace, subsystem, path string) error {
bak := path + rekeyBakSuffix
if !fileExists(path+sidecarSuffix) && !fileExists(bak) {
return nil
}
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 := recoverRekey(path); err != nil {
return err
}
if !fileExists(path + sidecarSuffix) {
if fileExists(bak) {
return settleRekey(path, key) // committed, not yet proven
}
return nil
}
dek, err := sidecarKey(m, ns, path)
if err != nil {
return err
}
defer zero(dek)
return rekey(path, dek, key)
}
// rekey holds the one-way trip, with the lock held and a sidecar beside path.
func rekey(path string, dek, key []byte) error {
tmp := path + tmpSuffix
bak := path + rekeyBakSuffix
removeDBFiles(tmp)
srcInv, err := export(path, tmp, dek, key)
if err != nil {
removeDBFiles(tmp)
return err
}
if classify(tmp) != sealed {
removeDBFiles(tmp)
return fmt.Errorf("cek: rekeying %q did not produce an encrypted file — this build cannot encrypt", path)
}
if err := verify(tmp, key, srcInv); err != nil {
removeDBFiles(tmp)
return fmt.Errorf("cek: the rekeyed copy of %q does not match it, so the original is untouched: %w", path, err)
}
for _, sidefile := range []string{"-wal", "-journal"} {
if fileExists(tmp + sidefile) {
removeDBFiles(tmp)
return fmt.Errorf("cek: the rekeyed copy of %q still has a %s file beside it; the original is untouched", path, sidefile)
}
}
if err := syncFile(tmp); err != nil {
removeDBFiles(tmp)
return fmt.Errorf("cek: flush the rekeyed copy of %q to disk: %w", path, err)
}
// Commit, in the order recoverRekey reads back:
// 1. the original → bak (its -wal was folded in by export, so it is whole),
// 2. its sidecar → bak's sidecar, so the pair stays readable together,
// 3. tmp → path, which now opens under the derived key with no sidecar.
if err := os.Rename(path, bak); err != nil {
removeDBFiles(tmp)
return fmt.Errorf("cek: set the original %q aside: %w", path, err)
}
_ = os.Remove(path + "-wal")
_ = os.Remove(path + "-shm")
if err := os.Rename(path+sidecarSuffix, bak+sidecarSuffix); err != nil {
_ = os.Rename(bak, path)
removeDBFiles(tmp)
return fmt.Errorf("cek: set the sidecar of %q aside: %w", path, err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Rename(bak+sidecarSuffix, path+sidecarSuffix)
_ = os.Rename(bak, path)
return fmt.Errorf("cek: swap the rekeyed copy into %q: %w", path, err)
}
syncDir(filepath.Dir(path))
return settleRekey(path, key)
}
// settleRekey proves the database at path reads under its derived key, and only
// then lets the original and its sidecar go.
func settleRekey(path string, key []byte) error {
if err := settle(path, key); err != nil {
return fmt.Errorf("cek: the rekeyed %q did not verify (the original is beside it as %s, with its sidecar): %w", path, rekeyBakSuffix, err)
}
bak := path + rekeyBakSuffix
removeDBFiles(bak)
_ = os.Remove(bak + sidecarSuffix)
syncDir(filepath.Dir(path))
return nil
}
// recoverRekey resumes a rekey that stopped between its commit steps, from the
// files alone. With path present the commit either never started (a leftover tmp
// is dead) or finished (the original waits beside it for settleRekey). With path
// absent the original was set aside: a verified copy at tmp finishes the swap,
// and no copy puts the original and its sidecar back.
func recoverRekey(path string) error {
tmp := path + tmpSuffix
bak := path + rekeyBakSuffix
if fileExists(path) {
if fileExists(path + sidecarSuffix) {
removeDBFiles(tmp)
}
return nil
}
if !fileExists(bak) {
return nil
}
if fileExists(tmp) {
if fileExists(path + sidecarSuffix) {
if err := os.Rename(path+sidecarSuffix, bak+sidecarSuffix); err != nil {
return fmt.Errorf("cek: finish setting the sidecar of %q aside: %w", path, err)
}
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("cek: finish the interrupted rekey of %q: %w", path, err)
}
syncDir(filepath.Dir(path))
return nil
}
if fileExists(bak + sidecarSuffix) {
if err := os.Rename(bak+sidecarSuffix, path+sidecarSuffix); err != nil {
return fmt.Errorf("cek: restore the sidecar of %q: %w", path, err)
}
}
if err := os.Rename(bak, path); err != nil {
return fmt.Errorf("cek: restore the original %q: %w", path, err)
}
syncDir(filepath.Dir(path))
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package cek
import (
"os"
"testing"
sqlitedrv "github.com/hanzoai/sqlite"
)
func readSecret(t *testing.T, path string, key []byte) string {
t.Helper()
db, err := sqlitedrv.OpenDB(path, key)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
var v string
if err := db.QueryRow(`SELECT v FROM secret WHERE k = 'UNIVERSE_PIN_TOKEN'`).Scan(&v); err != nil {
t.Fatalf("read: %v", err)
}
return v
}
// A database with its key beside it ends up under its derived key, with every
// row, and with no sidecar: the file alone is now readable by any process that
// holds the master, which is what a durable snapshot of it needs.
func TestRekeyMovesASidecarDatabaseToItsDerivedKey(t *testing.T) {
master := mk(11)
if err := SetMaster(master); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ns := mustNS(t, "acme", "")
path := writeWrappedDEKStore(t, master, ns, "finance", dir)
if err := Rekey(ns, "finance", path); err != nil {
t.Fatalf("Rekey: %v", err)
}
for _, left := range []string{path + sidecarSuffix, path + rekeyBakSuffix, path + rekeyBakSuffix + sidecarSuffix, path + tmpSuffix} {
if fileExists(left) {
t.Fatalf("%s is still there after a rekey", left)
}
}
key, err := DeriveKey(master, ns, "finance")
if err != nil {
t.Fatal(err)
}
if v := readSecret(t, path, key); v != "written-before-derived-keys" {
t.Fatalf("read %q under the derived key, want the row that was there", v)
}
db, err := Open(ns, "finance", dir)
if err != nil {
t.Fatalf("Open after rekey: %v", err)
}
db.Close()
// Again: nothing to do, and nothing changes.
before, _ := os.ReadFile(path)
if err := Rekey(ns, "finance", path); err != nil {
t.Fatalf("second Rekey: %v", err)
}
after, _ := os.ReadFile(path)
if string(before) != string(after) {
t.Fatal("a second rekey rewrote a database that had no sidecar")
}
}
// A database with no sidecar is not touched and nothing is made beside it.
func TestRekeyWithoutASidecarDoesNothing(t *testing.T) {
setTestMaster(t)
dir := t.TempDir()
ns := mustNS(t, "acme", "")
db, err := Open(ns, "plain", dir)
if err != nil {
t.Fatal(err)
}
db.Close()
path := dir + "/orgs/acme/plain.db"
if err := Rekey(ns, "plain", path); err != nil {
t.Fatalf("Rekey: %v", err)
}
if fileExists(path + lockSuffix) {
t.Fatal("a rekey with nothing to do took a lock file beside the database")
}
}
// Stopped after the original and its sidecar were set aside and before the copy
// moved in: the verified copy finishes the swap, and the original goes once it
// reads.
func TestAnInterruptedRekeyIsFinished(t *testing.T) {
master := mk(12)
if err := SetMaster(master); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ns := mustNS(t, "acme", "")
path := writeWrappedDEKStore(t, master, ns, "books", dir)
key, _ := DeriveKey(master, ns, "books")
dek, err := sidecarKey(master, ns, path)
if err != nil {
t.Fatal(err)
}
if _, err := export(path, path+tmpSuffix, dek, key); err != nil {
t.Fatalf("export: %v", err)
}
if err := os.Rename(path, path+rekeyBakSuffix); err != nil {
t.Fatal(err)
}
// The sidecar is still beside the absent path: stopped between steps 1 and 2.
if err := Rekey(ns, "books", path); err != nil {
t.Fatalf("Rekey after a stop: %v", err)
}
if fileExists(path+sidecarSuffix) || fileExists(path+rekeyBakSuffix) || fileExists(path+rekeyBakSuffix+sidecarSuffix) {
t.Fatal("the finished rekey left the original or a sidecar behind")
}
if v := readSecret(t, path, key); v != "written-before-derived-keys" {
t.Fatalf("read %q, want the row that was there", v)
}
}
// Stopped after the original was set aside with no copy to put in its place: the
// original and its sidecar go back, and still open as they did.
func TestARekeyWithNoCopyIsRolledBack(t *testing.T) {
master := mk(13)
if err := SetMaster(master); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ns := mustNS(t, "acme", "")
path := writeWrappedDEKStore(t, master, ns, "kms", dir)
if err := os.Rename(path, path+rekeyBakSuffix); err != nil {
t.Fatal(err)
}
if err := os.Rename(path+sidecarSuffix, path+rekeyBakSuffix+sidecarSuffix); err != nil {
t.Fatal(err)
}
if err := recoverRekey(path); err != nil {
t.Fatalf("recover: %v", err)
}
db, err := Open(ns, "kms", dir)
if err != nil {
t.Fatalf("Open after rollback: %v", err)
}
defer db.Close()
var v string
if err := db.QueryRow(`SELECT v FROM secret WHERE k = 'UNIVERSE_PIN_TOKEN'`).Scan(&v); err != nil || v != "written-before-derived-keys" {
t.Fatalf("the rolled-back original read %q, %v", v, err)
}
}