Files
sqlite/encryption_proof_test.go
zooqueen cc040ca365 fix(crypto,ci): bind principal in DEK wrap AAD (V4) + link codec in Dockerfile (V7)
V7 (was CI-theater): the Dockerfile built/tested with the inert
-tags "sqlite_fts5 sqlcipher" recipe -> CodecLinked()=false ->
TestEncryptionProof SKIPPED, so the gate ran green while asserting
nothing about ciphertext. Switch to -tags "libsqlite3 sqlite_fts5"
+ the codec CFLAGS/LDFLAGS (-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1
-lsqlcipher, headers under /usr/include/sqlcipher) and run the test
stage with SQLITE_REQUIRE_CODEC=1, which turns the 'codec unlinked'
skip into a HARD FAILURE. The image's own gate now asserts real
ciphertext; a regression to the inert recipe fails the build.

V4 (defense-in-depth): WrapDEK/UnwrapDEK now take a principal-binding
GCM aad; add PrincipalAAD(type,id) = the SAME length-prefixed injective
encoding used for the KEK's HKDF info (DRY). The on-disk AAD is
version-byte||aad, so a wrapped DEK is bound to its principal AND
downgrade-proof; a sidecar moved to another principal fails the tag
even if a KEK derivation ever collided. TestEnvelopeRotation extended
with cross-principal negative checks.

PATCH bump v0.1.3 -> v0.1.4. Verified: CGO=0 green; CGO=1+codec green
with proof RUN (not skipped); inert-tag+REQUIRE fails as intended.
2026-06-23 00:58:39 -07:00

245 lines
8.8 KiB
Go

package sqlite
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// TestEncryptionProof is the anti-silent-plaintext gate.
//
// On the !cgo (pure-Go) backend, encryption is unavailable: Open with a key MUST
// return ErrEncryptionUnavailable and never write a file.
//
// On the cgo backend, EncryptionAvailable() reports true — but that is only a
// backend-capability flag. A cgo build that forgot to link libsqlcipher (e.g. a
// regression back to the inert `-tags sqlcipher`) links plain sqlite and silently
// writes PLAINTEXT. This test forces the issue: under cgo it writes a known
// marker under a key and asserts the on-disk bytes are real ciphertext (no
// "SQLite format 3" header, marker absent) AND that the data survives a reopen
// with the same key. A mis-linked production build therefore FAILS CI instead of
// shipping a plaintext database.
func TestEncryptionProof(t *testing.T) {
const marker = "anti-plaintext-canary-7f3a"
key := make([]byte, 32)
for i := range key {
key[i] = byte(i*7 + 1)
}
dir := t.TempDir()
dbPath := filepath.Join(dir, "proof.db")
if !EncryptionAvailable() {
// Pure-Go backend: a keyed Open must be refused, with no file created.
if _, err := Open(dbPath, WithRawKey(key)); err != ErrEncryptionUnavailable {
t.Fatalf("!cgo Open with key: got err=%v, want ErrEncryptionUnavailable", err)
}
if _, statErr := os.Stat(dbPath); !os.IsNotExist(statErr) {
t.Fatalf("!cgo backend created a file for a refused encrypted open: %v", statErr)
}
return
}
// CGO build advertised as encryption-capable, but the codec may not be
// linked (a CGO build without libsqlcipher silently writes plaintext). SKIP
// rather than fail in that case — the hard ciphertext gate runs in the
// Dockerfile build, which links libsqlcipher. CI pins CGO_ENABLED=0 (the
// refuse branch above); a local `-tags libsqlite3` + libsqlcipher build runs
// the full ciphertext assertions below.
//
// EXCEPTION: when SQLITE_REQUIRE_CODEC=1 the skip becomes a HARD FAILURE. The
// Dockerfile sets it so the image's own test stage cannot go green on the
// inert-tag recipe (the V7 "CI-theater" footgun): a build that claims to ship
// encryption but didn't link the codec FAILS here instead of skipping.
if !CodecLinked() {
if os.Getenv("SQLITE_REQUIRE_CODEC") == "1" {
t.Fatal("SQLITE_REQUIRE_CODEC=1 but CodecLinked()=false: this build advertises encryption (cgo) yet did NOT link libsqlcipher — it would ship PLAINTEXT. Build with -tags \"libsqlite3 sqlite_fts5\" + CGO_CFLAGS=-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1 + CGO_LDFLAGS=-lsqlcipher")
}
t.Skip("cgo build without libsqlcipher linked; encryption assertions skipped (Dockerfile build is the hard gate)")
}
// cgo backend with codec linked: prove the bytes on disk are ciphertext.
db, err := Open(dbPath, WithRawKey(key))
if err != nil {
t.Fatalf("cgo Open with key: %v", err)
}
if _, err := db.Exec(`CREATE TABLE c (v TEXT)`); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := db.Exec(`INSERT INTO c (v) VALUES (?)`, marker); err != nil {
t.Fatalf("insert: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close: %v", err)
}
raw, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read db file: %v", err)
}
if bytes.HasPrefix(raw, []byte("SQLite format 3\x00")) {
t.Fatal("ENCRYPTION FAILURE: encrypted db has a plaintext SQLite header — libsqlcipher is not linked (build with -tags libsqlite3 + libsqlcipher, CGO_CFLAGS=-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1)")
}
if bytes.Contains(raw, []byte(marker)) {
t.Fatal("ENCRYPTION FAILURE: plaintext marker found in encrypted db file — data is NOT encrypted at rest")
}
// Reopen-with-key must read the row back (proves keyed reopen works).
db2, err := Open(dbPath, WithRawKey(key))
if err != nil {
t.Fatalf("reopen with key: %v", err)
}
defer db2.Close()
var got string
if err := db2.QueryRow(`SELECT v FROM c`).Scan(&got); err != nil {
t.Fatalf("reopen read: %v", err)
}
if got != marker {
t.Fatalf("reopen read = %q, want %q", got, marker)
}
// A different key must be rejected on reopen.
wrong := make([]byte, 32)
copy(wrong, key)
wrong[0] ^= 0xFF
db3, err := Open(dbPath, WithRawKey(wrong))
if err == nil {
var x string
err = db3.QueryRow(`SELECT v FROM c`).Scan(&x)
db3.Close()
}
if err == nil {
t.Fatal("ENCRYPTION FAILURE: wrong key read succeeded — key is not enforced")
}
}
// TestEnvelopeRotation proves the envelope contract that makes master-key
// rotation non-destructive (finding B3). It is pure-Go and runs under BOTH build
// tags. A random DEK is wrapped under a KEK derived from masterA, then rewrapped
// under a KEK derived from masterB; the SAME DEK must come back out — i.e. the
// page key (and therefore the encrypted file) is untouched by rotation.
func TestEnvelopeRotation(t *testing.T) {
masterA := make([]byte, 32)
masterB := make([]byte, 32)
for i := range masterA {
masterA[i] = byte(i + 1)
masterB[i] = byte(0xA0 + i)
}
dek, err := NewDEK()
if err != nil {
t.Fatalf("NewDEK: %v", err)
}
kekA, err := DeriveKey(masterA, PrincipalOrg, "acme")
if err != nil {
t.Fatalf("DeriveKey A: %v", err)
}
aad := PrincipalAAD(PrincipalOrg, "acme")
blobA, err := WrapDEK(kekA, dek, aad)
if err != nil {
t.Fatalf("WrapDEK A: %v", err)
}
// Unwrap under the same KEK + aad yields the original DEK.
got, err := UnwrapDEK(kekA, blobA, aad)
if err != nil {
t.Fatalf("UnwrapDEK A: %v", err)
}
if !bytes.Equal(got, dek) {
t.Fatal("envelope: unwrapped DEK != original under same KEK")
}
// A different KEK must NOT unwrap (GCM tag rejects it) — no partial key.
kekWrong, _ := DeriveKey(masterB, PrincipalOrg, "acme")
if _, err := UnwrapDEK(kekWrong, blobA, aad); err == nil {
t.Fatal("envelope: wrong KEK unwrapped the DEK — isolation broken")
}
// V4 defense-in-depth: the SAME KEK but a DIFFERENT principal AAD must NOT
// unwrap — the blob is cryptographically bound to its principal, so a
// sidecar lifted onto another principal fails the GCM tag even if a KEK
// derivation ever collided.
if _, err := UnwrapDEK(kekA, blobA, PrincipalAAD(PrincipalOrg, "globex")); err == nil {
t.Fatal("envelope: blob unwrapped under a different principal AAD — principal binding broken")
}
if _, err := UnwrapDEK(kekA, blobA, PrincipalAAD(PrincipalGlobal, "acme")); err == nil {
t.Fatal("envelope: blob unwrapped under a different principal-type AAD — type binding broken")
}
// ROTATION: unwrap with old KEK, rewrap with new KEK (same aad). DEK unchanged.
mid, err := UnwrapDEK(kekA, blobA, aad)
if err != nil {
t.Fatalf("rotation unwrap: %v", err)
}
kekB, _ := DeriveKey(masterB, PrincipalOrg, "acme")
blobB, err := WrapDEK(kekB, mid, aad)
if err != nil {
t.Fatalf("rewrap B: %v", err)
}
rotated, err := UnwrapDEK(kekB, blobB, aad)
if err != nil {
t.Fatalf("UnwrapDEK B: %v", err)
}
if !bytes.Equal(rotated, dek) {
t.Fatal("rotation: DEK changed across rewrap — pages would be unreadable (BRICK)")
}
// Old blob must no longer unwrap under the new KEK.
if _, err := UnwrapDEK(kekB, blobA, aad); err == nil {
t.Fatal("rotation: old blob unwrapped under new KEK — rewrap is not binding")
}
// Tamper detection: flip a ciphertext byte, expect failure.
tampered := append([]byte(nil), blobB...)
tampered[len(tampered)-1] ^= 0x01
if _, err := UnwrapDEK(kekB, tampered, aad); err == nil {
t.Fatal("envelope: tampered blob unwrapped — GCM integrity not enforced")
}
}
// TestHKDFInfoInjective proves the length-prefixed HKDF info is injective across
// the type/id boundary (finding M5): (org, "a:b") and ("org:a", "b") must derive
// DIFFERENT keys. The old "%s:%s" form collided.
func TestHKDFInfoInjective(t *testing.T) {
master := make([]byte, 32)
for i := range master {
master[i] = byte(i)
}
k1, err := DeriveKey(master, PrincipalType("org"), "a:b")
if err != nil {
t.Fatalf("derive k1: %v", err)
}
k2, err := DeriveKey(master, PrincipalType("org:a"), "b")
if err != nil {
t.Fatalf("derive k2: %v", err)
}
if bytes.Equal(k1, k2) {
t.Fatal("HKDF info collision: (org,'a:b') == ('org:a','b') — info not injective")
}
}
// TestKeyHierarchy proves a per-user KEK is bound to its parent (org) KEK
// (finding M6 primitive): the same user ID under two different org KEKs derives
// different child keys.
func TestKeyHierarchy(t *testing.T) {
master := make([]byte, 32)
for i := range master {
master[i] = byte(i*5 + 2)
}
orgA, _ := DeriveKey(master, PrincipalOrg, "acme")
orgB, _ := DeriveKey(master, PrincipalOrg, "globex")
userA, err := DeriveChildKey(orgA, PrincipalUser, "u-123")
if err != nil {
t.Fatalf("child A: %v", err)
}
userB, err := DeriveChildKey(orgB, PrincipalUser, "u-123")
if err != nil {
t.Fatalf("child B: %v", err)
}
if bytes.Equal(userA, userB) {
t.Fatal("hierarchy: same user under different orgs derived same key — not org-bound")
}
}