State the sidecar scheme here, so the driver can stop holding crypto

sqlite v0.5.0 removes DeriveKey, WrapDEK, UnwrapDEK and PrincipalAAD, which is
right — a driver opens a file under a key it is handed and has no business
deriving one. v0.2.2 borrowed exactly those to read a sidecar, so it cannot be
built against v0.5.0, and taking v0.5.0 meant dropping back to v0.2.1: a cloud
that derives a key for files whose key came from crypto/rand.

But a format is not retired by the code that used to write it. The bytes on disk
still have it, in 211 org directories. Key material is this package's concern, so
this package states it: HKDF-SHA256 over the master bound to (principal, file id)
by a length-prefixed info, AES-256-GCM with the version byte leading the AAD.

Golden vectors produced under the original implementation pin every part of it —
info composition, AAD, version byte, nonce split. A silent drift now fails a
test instead of a volume.

Still read-only: no wrap, no mint, no rewrite. The test seals its own fixtures
because production never needs to.

Builds and passes against sqlite v0.5.0 and v0.4.0 alike.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-02 13:54:40 -07:00
co-authored by Hanzo Dev
parent d080617cf6
commit d909c823f0
5 changed files with 275 additions and 57 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.5
require (
github.com/hanzoai/namespace v1.2.0
github.com/hanzoai/sqlite v0.4.0
github.com/hanzoai/sqlite v0.5.0
golang.org/x/crypto v0.54.0
)
+2
View File
@@ -12,6 +12,8 @@ github.com/hanzoai/sqlcipher v0.1.1 h1:GARjSiUEa1lwhd1/f87XRaujZBG5s1ZwxrZW2Es/A
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/hanzoai/sqlite v0.5.0 h1:1YydiyNAvL+WcXC1lUqZsUDaR4/7YkVb+wZm9qq9DSc=
github.com/hanzoai/sqlite v0.5.0/go.mod h1:7hlAtZspL0Ggx/j0cSo6npPFtUeikvIxnMDb7yTaJD0=
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=
+120 -42
View File
@@ -1,52 +1,78 @@
package cek
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"os"
"github.com/hanzoai/namespace"
sqlitedrv "github.com/hanzoai/sqlite"
"golang.org/x/crypto/hkdf"
)
// Reading a database written by the wrapped-DEK scheme.
// Reading a database whose key is beside it.
//
// Before derived keys, every database was born with a DEK from crypto/rand,
// wrapped under a key derived from its owner and a random file id, and stored
// beside it as "<database>.dek". The pages are encrypted under that random DEK
// and under nothing else — so no derivation reproduces it, and a derived key
// meets those pages as SQLCipher meets a wrong key: "file is not a database",
// at the first statement rather than at open.
// meets those pages as SQLCipher meets a wrong key: "wrong key or corrupted
// page", at the first statement rather than at open.
//
// Those files exist. Deriving a key for them and calling it a day would not be
// a migration, it would be the data becoming unreadable at the moment of a
// deploy. So Open asks the one question that decides which key a file has: is
// there a sidecar next to it? A sidecar means the key is in the sidecar. No
// sidecar means the key is derived. The file says which scheme it belongs to,
// which is the only thing that can say it.
// Those files exist, per org and per subsystem. Deriving a key for them and
// calling it a day would not be a migration, it would be the data becoming
// unreadable at the moment of a deploy. So Open asks the one question that
// decides which key a file has: is there a sidecar next to it. A sidecar means
// the key is in the sidecar. No sidecar means the key is derived. The file says
// which scheme it belongs to, which is the only thing that can say it.
//
// THE SCHEME IS REPRODUCED HERE, NOT IMPORTED. It used to come from the sqlite
// driver, which has since dropped its crypto — correctly: a driver opens a file
// under a key it is handed and has no business deriving one. But a format is not
// retired by the code that used to write it, because the bytes on disk still
// have it. Key material is cek's concern, so cek states it. It is HKDF and GCM,
// pinned by golden vectors produced under the original implementation, so a
// silent drift fails a test rather than a volume.
//
// This is a READ path and deliberately nothing more. It mints no sidecar, wraps
// no key and rewrites no file, so a database opened here stays exactly as it is
// and can be converted later by something whose job that is. New databases are
// born with derived keys and no sidecar, so this code governs a set that only
// shrinks and can be deleted with the last of them.
// born with derived keys and no sidecar, so this governs a set that only
// shrinks, and it can be deleted with the last of them.
// sidecarSuffix is the sidecar's name beside the database it holds the key for.
const sidecarSuffix = ".dek"
const (
// sidecarSuffix is the sidecar's name beside the database it holds a key for.
sidecarSuffix = ".dek"
// fileIDLen is the random file id the sidecar opens with. It binds the
// wrapping key to this one file: sidecar = fileID(16) || wrapped-DEK.
fileIDLen = 16
// wrapVersion is byte 0 of a wrapped DEK, and also leads the GCM additional
// data, so a downgrade cannot authenticate.
wrapVersion = 1
// dekLen is the length of an unwrapped DEK.
dekLen = 32
)
// fileIDLen is the length of the random file id the sidecar opens with. It binds
// the wrapping key to this one file: sidecar = fileID(16) || wrapped-DEK.
const fileIDLen = 16
// The principal a file was wrapped under. These strings went into the HKDF info
// that already exists on disk, so they are format, not naming.
const (
principalGlobal = "global"
principalOrg = "org"
)
// sidecarKey returns the DEK held beside path, or nil when there is no sidecar
// and the key is therefore derived.
//
// An unreadable or malformed sidecar is an ERROR, never a fallthrough to the
// derived key. Falling through would turn "this store's key material is damaged"
// into "file is not a database" three frames later, which reads like corruption
// of the database rather than of the sidecar and sends the reader to the wrong
// file. It also refuses to be the path by which a store silently reopens under a
// key that cannot possibly decrypt it.
// into "wrong key or corrupted page" three frames later, which reads as
// corruption of the database rather than of the sidecar and sends the reader to
// the wrong file. It also refuses to be the path by which a store silently
// reopens under a key that cannot possibly decrypt it.
func sidecarKey(master []byte, ns namespace.Namespace, path string) ([]byte, error) {
blob, err := os.ReadFile(path + sidecarSuffix)
if os.IsNotExist(err) {
@@ -62,35 +88,87 @@ func sidecarKey(master []byte, ns namespace.Namespace, path string) ([]byte, err
fileID, wrapped := blob[:fileIDLen], blob[fileIDLen:]
ptype, id := wrapIdentity(ns, fileID)
kek, err := sqlitedrv.DeriveKey(master, ptype, id)
kek, err := wrapKey(master, ptype, id)
if err != nil {
return nil, fmt.Errorf("cek: derive the sidecar's wrapping key: %w", err)
return nil, err
}
defer zero(kek)
dek, err := sqlitedrv.UnwrapDEK(kek, wrapped, sqlitedrv.PrincipalAAD(ptype, id))
if err != nil {
// GCM authenticated the blob and it did not verify: a different master
// key, or a damaged sidecar. Both are "the key is not here", and neither
// yields a partial key.
return nil, fmt.Errorf("cek: unwrap the key beside this database (wrong master key or damaged sidecar): %w", err)
}
return dek, nil
return unwrapDEK(kek, wrapped, principalInfo(ptype, id))
}
// wrapIdentity is the identity the sidecar's wrapping key was derived under:
// the owner and the file, in that order, so neither alone determines the key.
// A system namespace owned its files as the platform and carries the bare file
// id; every other namespace carries its own id ahead of it.
//
// This has to reproduce the old scheme EXACTLY — it is reading key material that
// already exists, not choosing a convention.
func wrapIdentity(ns namespace.Namespace, fileID []byte) (sqlitedrv.PrincipalType, string) {
id := hex.EncodeToString(fileID)
// wrapIdentity is the identity a sidecar's wrapping key was derived under: the
// owner and the file, in that order, so neither alone determines the key. A
// system namespace owned its files as the platform and carries the bare file id;
// every other namespace carries its own id ahead of it.
func wrapIdentity(ns namespace.Namespace, fileID []byte) (ptype, id string) {
f := hex.EncodeToString(fileID)
if ns.Kind() == namespace.KindSystem {
return sqlitedrv.PrincipalGlobal, id
return principalGlobal, f
}
return sqlitedrv.PrincipalOrg, ns.ID() + "/" + id
return principalOrg, ns.ID() + "/" + f
}
// principalInfo is the injective HKDF info, and the GCM additional data, for one
// (type, id):
//
// uvarint(len(type)) || type || uvarint(len(id)) || id
//
// The length prefixes make it injective across the type/id boundary, so no two
// identities render to the same bytes.
func principalInfo(ptype, id string) []byte {
t, i := []byte(ptype), []byte(id)
out := make([]byte, 0, 2*binary.MaxVarintLen64+len(t)+len(i))
var n [binary.MaxVarintLen64]byte
out = append(out, n[:binary.PutUvarint(n[:], uint64(len(t)))]...)
out = append(out, t...)
out = append(out, n[:binary.PutUvarint(n[:], uint64(len(i)))]...)
return append(out, i...)
}
// wrapKey derives the key a sidecar's DEK was wrapped under: HKDF-SHA256 over
// the master, no salt, bound to the principal.
func wrapKey(master []byte, ptype, id string) ([]byte, error) {
if len(master) != KeyLen {
return nil, ErrNoMaster
}
kek := make([]byte, KeyLen)
if _, err := io.ReadFull(hkdf.New(sha256.New, master, nil, principalInfo(ptype, id)), kek); err != nil {
return nil, fmt.Errorf("cek: derive the sidecar's wrapping key: %w", err)
}
return kek, nil
}
// unwrapDEK opens a wrapped DEK — version(1) || nonce(12) || ciphertext||tag,
// AES-256-GCM — with the version byte leading the additional data.
//
// A failure here is "the key is not here": a different master, or a damaged
// sidecar. GCM authenticates before it returns anything, so there is no partial
// key to hand back.
func unwrapDEK(kek, blob, aad []byte) ([]byte, error) {
block, err := aes.NewCipher(kek)
if err != nil {
return nil, fmt.Errorf("cek: aes-256: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("cek: aes-256-gcm: %w", err)
}
ns := gcm.NonceSize()
if len(blob) < 1+ns+gcm.Overhead() {
return nil, fmt.Errorf("cek: wrapped key is %d bytes, too short", len(blob))
}
if blob[0] != wrapVersion {
return nil, fmt.Errorf("cek: wrapped key version %d is not one this reads", blob[0])
}
dek, err := gcm.Open(nil, blob[1:1+ns], blob[1+ns:], append([]byte{wrapVersion}, aad...))
if err != nil {
return nil, fmt.Errorf("cek: unwrap the key beside this database (wrong master key or damaged sidecar): %w", err)
}
if len(dek) != dekLen {
return nil, fmt.Errorf("cek: unwrapped key is %d bytes, not %d", len(dek), dekLen)
}
return dek, nil
}
// zero clears key material once it is no longer needed. It is not a guarantee
+120
View File
@@ -0,0 +1,120 @@
package cek
import (
"bytes"
"encoding/hex"
"testing"
)
// Golden vectors produced by the ORIGINAL implementation — hanzoai/sqlite
// v0.4.0's DeriveKey / PrincipalAAD / WrapDEK, the code that wrote every sidecar
// on disk today. sidecar.go reproduces that scheme rather than importing it,
// because the driver has since dropped its crypto while the bytes it wrote
// remain. These pin the reproduction to the original, byte for byte.
//
// A drift in the HKDF info, the AAD composition, the version byte or the nonce
// split fails HERE, at `go test`, instead of on a volume holding 211 orgs.
//
// master is 32 bytes of i*7; the DEK is 32 bytes of 255-i.
var goldenSidecars = []struct {
name string
ptype string
id string
kek string // expected wrapping key
blob string // wrapped DEK as stored
dek string // expected key after unwrap
}{
{
name: "an org's file",
ptype: principalOrg,
id: "hanzo/0011223344556677889aabbccddeeff",
kek: "ff3899c56317d16b84954f2b3be171bc4d1f013d7febbf83a7d49d8812148239",
blob: "0109910f7370de21d05566a4f35440e9cdbb0a4702d8e23fba6faccc041cb2205b6b93052973df3ed75f76c2f145f25f03ce9b3df0a213bc33a751fb16",
dek: "fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0efeeedecebeae9e8e7e6e5e4e3e2e1e0",
},
{
name: "the platform's own file",
ptype: principalGlobal,
id: "0011223344556677889aabbccddeeff0",
kek: "006910cbbcfac247d7fb17ac188ee571fd0e80c34de6b7ef469b2d0b4c6f9b76",
blob: "016c891901185f031cae6f64c6054482196c8941c83b121dae47c679e718d0b185592c511948ca11a94157827afbee645410332c0a8fbd2675a183bffd",
dek: "fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0efeeedecebeae9e8e7e6e5e4e3e2e1e0",
},
}
func goldenMaster() []byte {
m := make([]byte, KeyLen)
for i := range m {
m[i] = byte(i * 7)
}
return m
}
// The wrapping key this derives must be the one the original derived, or every
// sidecar on disk is unreadable while every test that builds its own passes.
func TestWrapKeyMatchesTheOriginal(t *testing.T) {
for _, g := range goldenSidecars {
t.Run(g.name, func(t *testing.T) {
kek, err := wrapKey(goldenMaster(), g.ptype, g.id)
if err != nil {
t.Fatalf("wrapKey: %v", err)
}
if got := hex.EncodeToString(kek); got != g.kek {
t.Fatalf("wrapping key drifted from the scheme on disk:\n got %s\nwant %s", got, g.kek)
}
})
}
}
// And the wrapped key opens to exactly the DEK that was sealed.
func TestUnwrapMatchesTheOriginal(t *testing.T) {
for _, g := range goldenSidecars {
t.Run(g.name, func(t *testing.T) {
kek, err := wrapKey(goldenMaster(), g.ptype, g.id)
if err != nil {
t.Fatalf("wrapKey: %v", err)
}
blob, err := hex.DecodeString(g.blob)
if err != nil {
t.Fatal(err)
}
dek, err := unwrapDEK(kek, blob, principalInfo(g.ptype, g.id))
if err != nil {
t.Fatalf("unwrap a sidecar the original wrote: %v", err)
}
want, _ := hex.DecodeString(g.dek)
if !bytes.Equal(dek, want) {
t.Fatalf("unwrapped %x, want %x", dek, want)
}
})
}
}
// The version byte is authenticated, not merely checked, so a blob renumbered to
// a scheme this does not read cannot be opened by stripping the check.
func TestTheWrapVersionIsAuthenticated(t *testing.T) {
g := goldenSidecars[0]
kek, err := wrapKey(goldenMaster(), g.ptype, g.id)
if err != nil {
t.Fatal(err)
}
blob, _ := hex.DecodeString(g.blob)
blob[0] = 2
if _, err := unwrapDEK(kek, blob, principalInfo(g.ptype, g.id)); err == nil {
t.Fatal("a renumbered wrapped key opened anyway")
}
}
// A sidecar is bound to its principal: the same bytes under another identity
// must not open, or a file carried into another tenant's directory would.
func TestASidecarIsBoundToItsPrincipal(t *testing.T) {
g := goldenSidecars[0]
kek, err := wrapKey(goldenMaster(), g.ptype, g.id)
if err != nil {
t.Fatal(err)
}
blob, _ := hex.DecodeString(g.blob)
if _, err := unwrapDEK(kek, blob, principalInfo(principalOrg, "someone-else/0011223344556677889aabbccddeeff")); err == nil {
t.Fatal("a wrapped key opened under another principal's binding")
}
}
+32 -14
View File
@@ -1,8 +1,9 @@
package cek
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"os"
"path/filepath"
"testing"
@@ -11,6 +12,29 @@ import (
sqlitedrv "github.com/hanzoai/sqlite"
)
// wrapForTest seals a DEK the way the original scheme did. It lives in the test
// because cek only ever READS a sidecar — production mints none, so a wrap in
// the package itself would be a capability nothing needs. The golden vectors in
// sidecar_golden_test.go are what prove this agrees with the original; this only
// has to build fixtures.
func wrapForTest(t *testing.T, kek, dek, aad []byte) []byte {
t.Helper()
block, err := aes.NewCipher(kek)
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
t.Fatal(err)
}
out := append([]byte{wrapVersion}, nonce...)
return gcm.Seal(out, nonce, dek, append([]byte{wrapVersion}, aad...))
}
// writeWrappedDEKStore builds a database exactly the way the wrapped-DEK scheme
// did: a DEK from crypto/rand, wrapped under a key derived from the owner and a
// random file id, stored in a sidecar beside the database. It returns the path.
@@ -32,23 +56,17 @@ func writeWrappedDEKStore(t *testing.T, master []byte, ns namespace.Namespace, s
if _, err := rand.Read(fileID); err != nil {
t.Fatalf("file id: %v", err)
}
dek, err := sqlitedrv.NewDEK()
if err != nil {
t.Fatalf("NewDEK: %v", err)
dek := make([]byte, dekLen)
if _, err := rand.Read(dek); err != nil {
t.Fatalf("dek: %v", err)
}
ptype, id := sqlitedrv.PrincipalOrg, ns.ID()+"/"+hex.EncodeToString(fileID)
if ns.Kind() == namespace.KindSystem {
ptype, id = sqlitedrv.PrincipalGlobal, hex.EncodeToString(fileID)
}
kek, err := sqlitedrv.DeriveKey(master, ptype, id)
ptype, id := wrapIdentity(ns, fileID)
kek, err := wrapKey(master, ptype, id)
if err != nil {
t.Fatalf("DeriveKey: %v", err)
}
wrapped, err := sqlitedrv.WrapDEK(kek, dek, sqlitedrv.PrincipalAAD(ptype, id))
if err != nil {
t.Fatalf("WrapDEK: %v", err)
t.Fatalf("wrapKey: %v", err)
}
wrapped := wrapForTest(t, kek, dek, principalInfo(ptype, id))
if err := os.WriteFile(path+sidecarSuffix, append(append([]byte{}, fileID...), wrapped...), 0o600); err != nil {
t.Fatalf("write sidecar: %v", err)
}