Files
zeekay e3b9899a59 feat: pure-Go SQLCipher codec envelope — native Go always encrypts
Converges the driver to ONE way, always encrypted, on every build. Encryption no
longer flips with a build tag: EncryptionAvailable() is always true and a keyed
store is never written as plaintext, because the pure-Go hanzoai/sqlcipher codec
is compiled into every build.

envelope.go (tag-neutral): a keyed open decrypts the SQLCipher file to a plaintext
copy on a RAM-backed filesystem (tmpfs), the database/sql engine (modernc or
csqlite) reads/writes that copy honouring the 80-byte reserve, and Close/Checkpoint
re-encrypts it to the real path under the file's salt via a temp+atomic-rename. The
real path is always SQLCipher ciphertext, byte-compatible with C libsqlcipher.

Routing:
  - pure-Go (driver_nocgo): a key always routes to the envelope. The old
    ErrEncryptionUnavailable dead-end is REMOVED — native Go now encrypts, it does
    not reject keys.
  - cgo (driver_cgo): the CodecLinked runtime probe now SELECTS the route — live
    libsqlcipher when it is proven to encrypt (per-commit durability), otherwise the
    pure-Go envelope. A broken/absent libsqlcipher degrades to the pure-Go codec,
    never to plaintext, never to a dead end.
  - EncryptionAvailable() is one tag-neutral value: true (detect.go).

Fail-closed confidentiality: the decrypted copy only ever lives on tmpfs/ramfs; if
no RAM-backed scratch dir exists the open fails closed rather than decrypt to
persistent storage (ramfsBase, isRAMBacked via statfs). Durability is at
Close/Checkpoint (documented); single writer per file.

Checkpoint(db) re-encrypts an open envelope mid-session to bound the crash-loss
window (no-op on the live-codec / unencrypted paths).

Tests: TestKeyedRoundTripAndCheckpoint, TestKeyedWrongKeyRejected,
TestEnvelopeFailsClosedWithoutRAMFS (tag-neutral); TestBrokenLibsqlcipherFalls\
BackToEnvelope and TestEnvelopeByteCompatWithLibsqlcipher (cgo: the envelope's
output and the live codec read each other's files, both directions, in one binary).
TestEncryptionProof now asserts ciphertext on every backend (no refuse branch).

Requires hanzoai/sqlcipher v0.1.1 (EmptyPlaintext seed); pinned to the sibling
checkout until that tag is published (see the replace in go.mod).
2026-07-23 10:37:34 -07:00

44 lines
2.2 KiB
Go

package sqlite
import "bytes"
// EncryptionAvailable reports whether this build can encrypt a keyed store at rest.
// It is ALWAYS true: the pure-Go hanzoai/sqlcipher codec is compiled into every
// build, so a keyed open always encrypts — through the live libsqlcipher codec when
// it is linked and proven (CodecLinked), otherwise through the pure-Go codec
// envelope (envelope.go). There is no build, and no build-tag or environment
// switch, under which a keyed store falls back to plaintext.
//
// It is a value, not a capability probe: "can a keyed store be encrypted here?" is
// unconditionally yes. Which mechanism does it — live C codec vs pure-Go envelope —
// is CodecLinked's concern, and a broken libsqlcipher is caught by that runtime
// probe (never silent plaintext), which is orthogonal to whether encryption is
// available at all.
func EncryptionAvailable() bool { return true }
// sqliteMagic is the 16-byte header of an UNENCRYPTED SQLite database.
//
// A SQLCipher 4 database has NO plaintext header: page 1 on disk is
// [ salt(16) | ciphertext | IV(16) | HMAC-SHA512(64) ], so its first bytes are
// the random per-database KDF salt, never this magic (see the SQLCipher 4 page
// format in github.com/hanzoai/sqlcipher). The presence of this magic in a file
// that was opened under a key is therefore proof the codec did NOT encrypt — the
// key was silently ignored and the pages were written in the clear.
var sqliteMagic = []byte("SQLite format 3\x00")
// encryptsAtRest reports whether raw — the on-disk bytes of a database that was
// written under an encryption key — is genuinely ciphertext. It is the pure
// decision predicate behind the codec probe (EncryptionAvailable): the bytes must
// NOT begin with the plaintext SQLite magic AND must NOT contain the sentinel
// marker in the clear. Either condition means the linked engine no-op'd the key
// and wrote plaintext, so the build cannot be trusted to encrypt at rest.
func encryptsAtRest(raw, marker []byte) bool {
if bytes.HasPrefix(raw, sqliteMagic) {
return false // plaintext SQLite header ⇒ codec not encrypting
}
if bytes.Contains(raw, marker) {
return false // sentinel visible in the clear ⇒ not encrypted
}
return true
}