mirror of
https://github.com/hanzoai/sqlite.git
synced 2026-10-30 18:22:26 +00:00
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.
245 lines
8.8 KiB
Go
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")
|
|
}
|
|
}
|