Files
base/plugins/org/base_encrypted_test.go
zeekayandHanzo Dev b7a526bbec an org's Base is encrypted with the org's own key
The key derivation was already right and had no caller: OrgDEK derived a
per-org DEK from the master, and the Base was then opened plaintext. The package
doc said so plainly — "physical is all it is" — so every tenant's data sat in
the clear on disk while a deployment that had configured a master key believed
otherwise.

encryptedConnect turns the org's DEK into the connector the Base opens with, and
sqlite.OpenDB is the whole mechanism. It means the same thing under both builds:
SQLCipher when Base is linked with cgo, the pure-Go codec envelope otherwise.
The two are byte-compatible, so a file written by one opens under the other —
which matters because CI ships CGO_ENABLED=0 and an operator may not. Note the
driver refuses to help you get this wrong: DSN(path, key) panics on the pure-Go
backend rather than hand back a DSN that would open a keyed file in the clear.

The key is per org, so one tenant's file will not open with another's and a
leaked key is worth one tenant rather than all of them.

Dev mode is unchanged: no master key means no DEK, no connector, and the default
plaintext open. A master key of the wrong length was already an error rather
than a silent downgrade, and stays one.

The test reads the bytes, not the code path. "Encryption is wired" is a claim
about a call; "this file does not begin with SQLite format 3, and the value I
just wrote is not in it" is a claim about the disk. It carries a control that
opens the same table plaintext and asserts that file DOES have the header and
DOES contain the value — otherwise the two assertions above would pass for an
empty file and prove nothing.

Still plaintext while open on the pure-Go backend: the envelope decrypts to a
sidecar for the life of the handle. At rest is at rest; a running process holds a
readable copy.

plugins/org and core pass.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 12:42:00 -07:00

144 lines
4.2 KiB
Go

package org
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/hanzoai/base/core"
)
// TestOrgBaseIsEncryptedAtRest reads the bytes rather than the code path.
//
// "Encryption is wired" is a claim about a call; "this file is not a SQLite
// database" is a claim about the disk, and only the second one is worth
// anything. A plaintext SQLite file opens with the string "SQLite format 3" at
// offset zero, so its absence is the whole assertion — and its presence would
// mean a deployment that configured a master key was still writing tenant data
// in the clear.
func TestOrgBaseIsEncryptedAtRest(t *testing.T) {
dir := t.TempDir()
const master = "0123456789abcdef0123456789abcdef" // 32 bytes, as KMS hands out
keyed := NewOrgDB(nil, master)
dek, err := keyed.OrgDEK("acme")
if err != nil {
t.Fatal(err)
}
if dek == "" {
t.Fatal("a master key was configured and no org key came back")
}
// The same org under a different master derives a different key: one
// tenant's file must not open with another tenant's — or another
// deployment's.
other := NewOrgDB(nil, "fedcba9876543210fedcba9876543210")
otherDEK, err := other.OrgDEK("acme")
if err != nil {
t.Fatal(err)
}
if otherDEK == dek {
t.Fatal("two different master keys derived the same org key")
}
// And two orgs under one master differ, which is what makes a leaked key
// worth one tenant instead of all of them.
sibling, err := keyed.OrgDEK("globex")
if err != nil {
t.Fatal(err)
}
if sibling == dek {
t.Fatal("two orgs derived the same key")
}
// Dev mode stays dev mode: no master, no key, no encryption, and no error.
plain := NewOrgDB(nil, "")
devDEK, err := plain.OrgDEK("acme")
if err != nil {
t.Fatalf("an unconfigured master key is dev mode, not a failure: %v", err)
}
if devDEK != "" {
t.Fatalf("dev mode produced a key: %q", devDEK)
}
// Now the bytes. Open a keyed database through the same call the Base open
// path uses, write something recognisable, close it, and look at the file.
connect, err := encryptedConnect(keyed, "acme")
if err != nil {
t.Fatal(err)
}
if connect == nil {
t.Fatal("a configured master key produced no encrypted connector")
}
path := filepath.Join(dir, "org.db")
db, err := connect(path)
if err != nil {
t.Fatal(err)
}
if _, err := db.NewQuery("CREATE TABLE secrets (v TEXT)").Execute(); err != nil {
t.Fatal(err)
}
if _, err := db.NewQuery("INSERT INTO secrets (v) VALUES ('CANARY_PLAINTEXT')").Execute(); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(raw) == 0 {
t.Fatal("the keyed database wrote no file")
}
if bytes.HasPrefix(raw, []byte("SQLite format 3")) {
t.Fatal("the file on disk is a plaintext SQLite database")
}
if bytes.Contains(raw, []byte("CANARY_PLAINTEXT")) {
t.Fatal("a value written through the keyed handle is readable in the file")
}
// Control, so the two assertions above cannot pass for some unrelated
// reason. Dev mode returns no connector, the Base opens on the default
// plaintext path, and that file DOES carry the header and the value — which
// is what makes their absence above evidence of encryption rather than
// evidence of an empty file.
control, err := encryptedConnect(plain, "acme")
if err != nil {
t.Fatal(err)
}
if control != nil {
t.Fatal("dev mode produced an encrypted connector")
}
clearPath := filepath.Join(dir, "clear.db")
clear, err := core.DefaultDBConnect(clearPath)
if err != nil {
t.Fatal(err)
}
if _, err := clear.NewQuery("CREATE TABLE secrets (v TEXT)").Execute(); err != nil {
t.Fatal(err)
}
if _, err := clear.NewQuery("INSERT INTO secrets (v) VALUES ('CANARY_PLAINTEXT')").Execute(); err != nil {
t.Fatal(err)
}
if err := clear.Close(); err != nil {
t.Fatal(err)
}
clearRaw, err := os.ReadFile(clearPath)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(clearRaw, []byte("SQLite format 3")) {
t.Fatal("the control is not a plaintext SQLite file, so the check above proves nothing")
}
if !bytes.Contains(clearRaw, []byte("CANARY_PLAINTEXT")) {
t.Fatal("the control does not carry the value, so the check above proves nothing")
}
}