Set the master once, not at every call

There is one master key per process — injected at boot from KMS, used by every
database that process opens. Passing it to each Open would not make it less
global, only harder to see, and it would put a key parameter on all 80 call
sites in cloud for no property gained.

SetMaster installs it; Open takes the namespace, the subsystem and the data
directory. Without a master, Open fails: a service that comes up in plaintext
because a key was missing has failed silently at the only thing this does.

SetDevMaster covers a process with no KMS and is honest about the consequence —
a new random master cannot open the previous run's files, so nothing survives
the process, by construction.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-01 18:35:10 -07:00
co-authored by Hanzo Dev
parent f9255eacf9
commit 70acf18fe9
3 changed files with 127 additions and 47 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
Opens a namespace's SQLite database encrypted at rest.
```go
db, err := cek.Open(master, ns, "treasury", dataDir)
cek.SetMaster(k) // once, at boot, from KMS
db, err := cek.Open(ns, "treasury", dataDir) // everywhere else
```
The key is derived from the master and the namespace. It is not generated, not
+72 -32
View File
@@ -2,7 +2,8 @@
//
// There is one way to do it:
//
// db, err := cek.Open(master, ns, "treasury", dataDir)
// cek.SetMaster(k) // once, at boot, from KMS
// db, err := cek.Open(ns, "treasury", dir) // everywhere else
//
// The key is derived from the master and the namespace. It is not generated,
// not wrapped, not stored, and not rotated in place — so there is no unwrap
@@ -11,6 +12,10 @@
// the master loses the data, which is the property you want from encryption at
// rest and the reason the master lives in KMS.
//
// The master is process state because that is what it is: one key, injected at
// boot, for every database this process opens. Threading it through every
// caller would not make it less global, only harder to see.
//
// The split of responsibilities is deliberate:
//
// namespace names the entity and where its file lives
@@ -23,39 +28,82 @@
package cek
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"io"
"sync"
"github.com/hanzoai/namespace"
sqlitedrv "github.com/hanzoai/sqlite"
"golang.org/x/crypto/hkdf"
)
// KeyLen is the length of both the master key and every derived key.
// KeyLen is the length of the master key and of every key derived from it.
const KeyLen = 32
// info binds a derived key to this scheme, this namespace and this subsystem.
// Changing any part of it changes every key, which is why the version is in
// it: a future scheme is a new prefix, not a silent reinterpretation of the
// infoPrefix binds a derived key to this scheme. The version is part of it so
// a future scheme is a new prefix rather than a silent reinterpretation of the
// same bytes.
const infoPrefix = "hanzo/cek/v1/"
// ErrNoMaster reports a master key that is missing or the wrong length. It is
// deliberately fatal to Open rather than falling back to plaintext: a service
// that starts unencrypted because a key was absent has failed silently at the
// only job this package has.
var ErrNoMaster = errors.New("cek: master key must be " + itoa(KeyLen) + " bytes")
// ErrNoMaster reports that no master key has been set, or that one of the
// wrong length was offered.
//
// Open fails with it rather than falling back to an unencrypted file: a
// service that comes up in plaintext because a key was missing has failed
// silently at the only thing this package does.
var ErrNoMaster = errors.New("cek: no master key; call SetMaster with 32 bytes from KMS")
var (
masterMu sync.RWMutex
master []byte
)
// SetMaster installs the master key every database of this process is keyed
// from. Call it once at boot, before the first Open, with the key resolved
// from KMS.
func SetMaster(k []byte) error {
if len(k) != KeyLen {
return ErrNoMaster
}
masterMu.Lock()
defer masterMu.Unlock()
master = append([]byte(nil), k...)
return nil
}
// SetDevMaster installs a random master for a process with no KMS — tests, and
// a laptop. It reports the key it generated so a caller can log that this is
// what happened. Nothing it writes survives the process, by construction: a
// new random master cannot open the previous run's files.
func SetDevMaster() ([]byte, error) {
k := make([]byte, KeyLen)
if _, err := rand.Read(k); err != nil {
return nil, fmt.Errorf("cek: generate dev master: %w", err)
}
if err := SetMaster(k); err != nil {
return nil, err
}
return k, nil
}
// HasMaster reports whether a master key has been installed.
func HasMaster() bool {
masterMu.RLock()
defer masterMu.RUnlock()
return len(master) == KeyLen
}
// DeriveKey returns the key for one database: the master, bound to the
// namespace that owns it and the subsystem it holds.
//
// It is a pure function of its inputs. The same namespace and subsystem always
// produce the same key, so a file can be opened again after a restart with
// nothing persisted alongside it, and two different databases never share a
// key because the subsystem is part of the binding.
// It is a pure function of its inputs, so the same namespace and subsystem
// always produce the same key. A file therefore reopens after a restart with
// nothing persisted beside it, and two databases never share a key because the
// subsystem is part of the binding.
func DeriveKey(master []byte, ns namespace.Namespace, subsystem string) ([]byte, error) {
if len(master) != KeyLen {
return nil, ErrNoMaster
@@ -79,9 +127,16 @@ func DeriveKey(master []byte, ns namespace.Namespace, subsystem string) ([]byte,
// creating it if it does not exist. The returned handle is already keyed;
// callers use it as an ordinary *sql.DB.
//
// The error never contains the key or a DSN that holds it.
func Open(master []byte, ns namespace.Namespace, subsystem, dir string) (*sql.DB, error) {
key, err := DeriveKey(master, ns, subsystem)
// The location comes from namespace, so a file and its durable slot are two
// renderings of one name and cannot drift apart.
//
// No error returned here contains the key or a DSN holding it.
func Open(ns namespace.Namespace, subsystem, dir string) (*sql.DB, error) {
masterMu.RLock()
m := master
masterMu.RUnlock()
key, err := DeriveKey(m, ns, subsystem)
if err != nil {
return nil, err
}
@@ -97,18 +152,3 @@ func Open(master []byte, ns namespace.Namespace, subsystem, dir string) (*sql.DB
}
return db, nil
}
// itoa avoids importing strconv for one constant in an error string.
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [4]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+53 -14
View File
@@ -2,13 +2,14 @@ package cek
import (
"bytes"
"errors"
"path/filepath"
"testing"
"github.com/hanzoai/namespace"
)
func master(b byte) []byte { return bytes.Repeat([]byte{b}, KeyLen) }
func mk(b byte) []byte { return bytes.Repeat([]byte{b}, KeyLen) }
func mustNS(t *testing.T, org, project string) namespace.Namespace {
t.Helper()
@@ -23,11 +24,11 @@ func mustNS(t *testing.T, org, project string) namespace.Namespace {
// reopened after a restart.
func TestDeriveIsDeterministic(t *testing.T) {
ns := mustNS(t, "acme", "")
a, err := DeriveKey(master(1), ns, "treasury")
a, err := DeriveKey(mk(1), ns, "treasury")
if err != nil {
t.Fatal(err)
}
b, _ := DeriveKey(master(1), ns, "treasury")
b, _ := DeriveKey(mk(1), ns, "treasury")
if !bytes.Equal(a, b) {
t.Fatal("same inputs produced different keys")
}
@@ -39,17 +40,17 @@ func TestDeriveIsDeterministic(t *testing.T) {
// Every axis must change the key. If any of these collide, two databases share
// a key and one account's file opens another's.
func TestDeriveSeparatesEveryAxis(t *testing.T) {
base, _ := DeriveKey(master(1), mustNS(t, "acme", ""), "treasury")
base, _ := DeriveKey(mk(1), mustNS(t, "acme", ""), "treasury")
for _, tc := range []struct {
name string
key func() ([]byte, error)
}{
{"different master", func() ([]byte, error) { return DeriveKey(master(2), mustNS(t, "acme", ""), "treasury") }},
{"different org", func() ([]byte, error) { return DeriveKey(master(1), mustNS(t, "globex", ""), "treasury") }},
{"different subsystem", func() ([]byte, error) { return DeriveKey(master(1), mustNS(t, "acme", ""), "audit") }},
{"a project of that org", func() ([]byte, error) { return DeriveKey(master(1), mustNS(t, "acme", "web"), "treasury") }},
{"the system namespace", func() ([]byte, error) { return DeriveKey(master(1), namespace.System(), "treasury") }},
{"different master", func() ([]byte, error) { return DeriveKey(mk(2), mustNS(t, "acme", ""), "treasury") }},
{"different org", func() ([]byte, error) { return DeriveKey(mk(1), mustNS(t, "globex", ""), "treasury") }},
{"different subsystem", func() ([]byte, error) { return DeriveKey(mk(1), mustNS(t, "acme", ""), "audit") }},
{"a project of that org", func() ([]byte, error) { return DeriveKey(mk(1), mustNS(t, "acme", "web"), "treasury") }},
{"the system namespace", func() ([]byte, error) { return DeriveKey(mk(1), namespace.System(), "treasury") }},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.key()
@@ -74,8 +75,8 @@ func TestDeriveRefusesBadInput(t *testing.T) {
}{
{"no master", nil, ns, "treasury"},
{"short master", bytes.Repeat([]byte{1}, 16), ns, "treasury"},
{"zero namespace", master(1), namespace.Namespace{}, "treasury"},
{"empty subsystem", master(1), ns, ""},
{"zero namespace", mk(1), namespace.Namespace{}, "treasury"},
{"empty subsystem", mk(1), ns, ""},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := DeriveKey(tc.master, tc.ns, tc.sub); err == nil {
@@ -89,8 +90,11 @@ func TestDeriveRefusesBadInput(t *testing.T) {
func TestOpenUsesTheNamespaceLocation(t *testing.T) {
dir := t.TempDir()
ns := mustNS(t, "acme", "")
if err := SetMaster(mk(1)); err != nil {
t.Fatal(err)
}
db, err := Open(master(1), ns, "treasury", dir)
db, err := Open(ns, "treasury", dir)
if err != nil {
t.Fatal(err)
}
@@ -110,11 +114,46 @@ func TestOpenUsesTheNamespaceLocation(t *testing.T) {
// An error must never carry the key or a DSN holding it.
func TestOpenErrorHoldsNoKey(t *testing.T) {
_, err := Open(master(1), namespace.Namespace{}, "treasury", t.TempDir())
if err := SetMaster(mk(1)); err != nil {
t.Fatal(err)
}
_, err := Open(namespace.Namespace{}, "treasury", t.TempDir())
if err == nil {
t.Fatal("expected an error")
}
if bytes.Contains([]byte(err.Error()), master(1)) {
if bytes.Contains([]byte(err.Error()), mk(1)) {
t.Fatal("error leaked the key")
}
}
// Open must refuse rather than write an unencrypted file when no key is set.
func TestOpenRefusesWithoutAMaster(t *testing.T) {
masterMu.Lock()
master = nil
masterMu.Unlock()
if HasMaster() {
t.Fatal("master should be unset")
}
if _, err := Open(mustNS(t, "acme", ""), "treasury", t.TempDir()); !errorIs(err, ErrNoMaster) {
t.Fatalf("got %v, want ErrNoMaster — a missing key must never open plaintext", err)
}
}
// A dev master must actually key the database, not skip encryption.
func TestDevMasterKeys(t *testing.T) {
k, err := SetDevMaster()
if err != nil {
t.Fatal(err)
}
if len(k) != KeyLen || !HasMaster() {
t.Fatal("dev master not installed")
}
db, err := Open(mustNS(t, "acme", ""), "treasury", t.TempDir())
if err != nil {
t.Fatal(err)
}
db.Close()
}
func errorIs(err, target error) bool { return err != nil && errors.Is(err, target) }