mirror of
https://github.com/hanzoai/cek.git
synced 2026-10-09 02:37:18 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user