Open a namespace's database encrypted, one way

A key derived from the master and the namespace: not generated, not wrapped,
not stored, not rotated in place. That removes the unwrap step, the rewrap
step, the per-file key material, and the plaintext-to-encrypted migration —
every one of which existed to carry old data forward, and none of which is a
property anyone wanted.

A database is born encrypted or it does not exist. The same namespace and
subsystem always derive the same key, so a file reopens after a restart with
nothing persisted beside it, and two databases never share a key because the
subsystem is part of the binding.

Losing the master loses the data. That is what encryption at rest means, and
why the master lives in KMS.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-01 18:20:05 -07:00
co-authored by Hanzo Dev
commit f9255eacf9
6 changed files with 369 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hanzo AI Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
# cek
Opens a namespace's SQLite database encrypted at rest.
```go
db, err := cek.Open(master, ns, "treasury", dataDir)
```
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 step, no
rewrap step, no per-file key material to lose, and no migration path to
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.
## Where it sits
| | |
|---|---|
| `hanzoai/namespace` | names the entity, and where its file lives |
| `hanzoai/cek` | turns the master + that name into the file's key, and opens it |
| `hanzoai/sqlite` | opens a file under a raw key; knows nothing about who owns it |
| `hanzoai/kms` | holds the master |
Nothing here knows about orgs, users, billing or plugins. It knows a namespace,
a subsystem, and a master key.
+114
View File
@@ -0,0 +1,114 @@
// Package cek opens a namespace's SQLite database encrypted at rest.
//
// There is one way to do it:
//
// db, err := cek.Open(master, ns, "treasury", dataDir)
//
// 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
// step, no rewrap step, no per-file key material to lose, and no migration
// path to maintain. A database is born encrypted or it does not exist. Losing
// the master loses the data, which is the property you want from encryption at
// rest and the reason the master lives in KMS.
//
// The split of responsibilities is deliberate:
//
// namespace names the entity and where its file lives
// cek turns the master + that name into the file's key, and opens it
// sqlite opens a file under a raw key and knows nothing about who owns it
// kms holds the master
//
// Nothing here knows about orgs, users, billing or plugins. It knows a
// namespace, a subsystem, and a master key.
package cek
import (
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"io"
"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.
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
// 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")
// 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.
func DeriveKey(master []byte, ns namespace.Namespace, subsystem string) ([]byte, error) {
if len(master) != KeyLen {
return nil, ErrNoMaster
}
if ns.IsZero() {
return nil, errors.New("cek: the zero namespace owns no database")
}
if subsystem == "" {
return nil, errors.New("cek: empty subsystem")
}
key := make([]byte, KeyLen)
info := []byte(infoPrefix + ns.String() + "/" + subsystem)
if _, err := io.ReadFull(hkdf.New(sha256.New, master, nil, info), key); err != nil {
return nil, fmt.Errorf("cek: derive key: %w", err)
}
return key, nil
}
// Open opens the encrypted database holding subsystem for ns, under dir,
// 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)
if err != nil {
return nil, err
}
path, err := namespace.Path(dir, ns, subsystem)
if err != nil {
return nil, err
}
db, err := sqlitedrv.OpenDB(path, key)
if err != nil {
return nil, fmt.Errorf("cek: open %s database for %s: %w", subsystem, ns, err)
}
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:])
}
+120
View File
@@ -0,0 +1,120 @@
package cek
import (
"bytes"
"path/filepath"
"testing"
"github.com/hanzoai/namespace"
)
func master(b byte) []byte { return bytes.Repeat([]byte{b}, KeyLen) }
func mustNS(t *testing.T, org, project string) namespace.Namespace {
t.Helper()
ns, err := namespace.OrgProject(org, project)
if err != nil {
t.Fatal(err)
}
return ns
}
// The same inputs must always give the same key, or a database cannot be
// reopened after a restart.
func TestDeriveIsDeterministic(t *testing.T) {
ns := mustNS(t, "acme", "")
a, err := DeriveKey(master(1), ns, "treasury")
if err != nil {
t.Fatal(err)
}
b, _ := DeriveKey(master(1), ns, "treasury")
if !bytes.Equal(a, b) {
t.Fatal("same inputs produced different keys")
}
if len(a) != KeyLen {
t.Fatalf("key is %d bytes, want %d", len(a), KeyLen)
}
}
// 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")
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") }},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.key()
if err != nil {
t.Fatal(err)
}
if bytes.Equal(base, got) {
t.Fatalf("%s produced the SAME key — two databases would share it", tc.name)
}
})
}
}
// A missing or short master must fail, never fall back to opening in plaintext.
func TestDeriveRefusesBadInput(t *testing.T) {
ns := mustNS(t, "acme", "")
for _, tc := range []struct {
name string
master []byte
ns namespace.Namespace
sub string
}{
{"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, ""},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := DeriveKey(tc.master, tc.ns, tc.sub); err == nil {
t.Fatal("expected an error, got a key")
}
})
}
}
// Open must write where namespace says, so the file and its durable slot agree.
func TestOpenUsesTheNamespaceLocation(t *testing.T) {
dir := t.TempDir()
ns := mustNS(t, "acme", "")
db, err := Open(master(1), ns, "treasury", dir)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE t (v TEXT)`); err != nil {
t.Fatal(err)
}
want, err := namespace.Path(dir, ns, "treasury")
if err != nil {
t.Fatal(err)
}
if _, err := filepath.Abs(want); err != nil {
t.Fatal(err)
}
}
// 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 == nil {
t.Fatal("expected an error")
}
if bytes.Contains([]byte(err.Error()), master(1)) {
t.Fatal("error leaked the key")
}
}
+25
View File
@@ -0,0 +1,25 @@
module github.com/hanzoai/cek
go 1.26.5
require (
github.com/hanzoai/namespace v1.2.0
github.com/hanzoai/sqlite v0.4.0
golang.org/x/crypto v0.54.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 // indirect
github.com/hanzoai/csqlite v0.1.0 // indirect
github.com/hanzoai/sqlcipher v0.1.1 // indirect
github.com/luxfi/crypto v1.20.2 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.51.0 // indirect
)
+62
View File
@@ -0,0 +1,62 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 h1:UBg1xk+oAsIVbFuGg6hdfAm7EvCv3EL80vFxJNsslqw=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hanzoai/csqlite v0.1.0 h1:suwC3dh0INlfP/U0Es6cDf6JNQ+2+GVLLATPWCUux6k=
github.com/hanzoai/csqlite v0.1.0/go.mod h1:H31a/O6VXuklR9UBkgY++bmAK5uzVfXPqU0F6P9Wsos=
github.com/hanzoai/namespace v1.2.0 h1:FLEHzR/GyE/OltXozMvT2n2FfUUxTuVVbILKZBkEjLI=
github.com/hanzoai/namespace v1.2.0/go.mod h1:vGuxMDHg5MdhH5q2bqqjZL/afINBWJubP4WD1YwZTm4=
github.com/hanzoai/sqlcipher v0.1.1 h1:GARjSiUEa1lwhd1/f87XRaujZBG5s1ZwxrZW2Es/ADI=
github.com/hanzoai/sqlcipher v0.1.1/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
github.com/hanzoai/sqlite v0.4.0 h1:xlF6w6xzn2VSeAVBKFrZfIq0ISqRSTHz2g3QN3eCcnE=
github.com/hanzoai/sqlite v0.4.0/go.mod h1:sE5uvENQ+q+rI011AwFuAuJ7DwOoDnywyCQstZ0ekHQ=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/luxfi/crypto v1.20.2 h1:L81WEsU/hs2A76F5PWBusG0yU74QqkDdUqqgexWUxh4=
github.com/luxfi/crypto v1.20.2/go.mod h1:qYHOM0lO4PRh7LEaObxFQUIMjmT1/paVm/WgZkobT1k=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=