Repository navigation
Refuse plaintext on request - #9
Conversation
…e a database in the clear With plain SQLite the server stores plaintext, as documented. That is also what a build gets that meant to link SQLCipher and linked plain SQLite instead: PRAGMA key is an unknown pragma there and is ignored, and the VFS sends the pages as they are, without an error. kupferdraht hit the same silent downgrade through a root [patch] its consumers did not inherit (its ADR-0042). Opening through encrypted_name() is safe from it, because that VFS name exists only with SQLite3 Multiple Ciphers, but the plain name is not. Config::refuse_plaintext, off by default, makes the VFS refuse a read or a write of page 1 that carries an unencrypted SQLite header: the magic string, the fixed bytes 21 to 23 and the 20 reserved zero bytes from 72 on. Every cipher of SQLCipher and SQLite3 Multiple Ciphers destroys at least one of these: the default ones replace the magic string with a salt, the others encrypt from byte 24 on at the latest, and a plaintext header of 32 bytes still leaves the reserved bytes encrypted. The write is refused before the page reaches the blocks in memory, so a commit cannot send it; SQLite gets SQLITE_PERM and rolls the transaction back. The read is refused so that a database the server already holds in plaintext is noticed. RemoteVfs::failure reports Failure::Plaintext. The C interface gains refuse_plaintext at the end of the struct. A program built against the header of 0.5.0 passes the smaller struct_size and is accepted with the default; the registration copies struct_size bytes into a zeroed struct instead of referencing the caller's struct as a whole. The layout test covers the new field. Integration tests against a server show an encrypted database passing, the first commit of an unencrypted one refused with nothing reaching the server, a database stored in plaintext refused on read, and plain SQLite refused through the static C interface. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… extension tests The read test opened the database through the helper that sets the journal mode, and that pragma already reads page 1: the refusal came one statement earlier than the test expected. It now opens the connection directly and asserts on the first statement. The extension workspace formats itself; the main workspace's fmt had not covered its tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SQLite3 Multiple Ciphers reads page 1 when it opens the file, plain SQLite at the first statement. The read test only allowed the second, so it failed on the native jobs, which link SQLite3 Multiple Ciphers, while the extension job with plain SQLite passed. Either call failing with SQLITE_PERM is the refusal the test is about. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ize on every target The size of the 0.5.0 struct was taken as the offset of refuse_plaintext, which equals it only where the 0.5.0 struct has no tail padding. On a target with 4-byte pointers and 8-byte alignment of uint64_t, such as wasm32, the struct ends in 4 bytes of padding that an int fills without changing the size: both versions are 80 bytes, a 0.5.0 caller is taken for a current one, and refuse_plaintext is read from its padding. The field is now 64 bits wide, the 0.5.0 size comes from a verbatim copy of that struct, and the crate asserts at compile time that the sizes differ and that no byte of a 0.5.0 struct lands in the new field. The layout test compares the copy with the C compiler's sizeof of the same struct. A write of page 1 on a broken database now fails for the reason the database broke, as a read does, instead of being checked for plaintext first. The README no longer claims that encrypted_name() needs no guard: a forgotten PRAGMA key stores plaintext there too, and the guard catches it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…at each crate builds on its own The build script of libsqlite3-sys refers to pkg-config and vcpkg unconditionally, and only its default feature brings them. With default-features = false, `cargo test -p sqlite-remote-vfs-ffi` failed in that build script. The other two crates only built because their rusqlite dev-dependency enables the default features, and a workspace build unifies them, which is why CI did not notice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The check needs the reserved bytes 72 to 91 of page 1 encrypted. SQLite3 Multiple Ciphers allows a plaintext header of up to 100 bytes for its ciphers, the default ChaCha20 included, and SQLCipher multiples of 16. With more than 72 bytes, page 1 of an encrypted database can pass as plaintext, from 92 bytes on it always does. Config, the C header and the README say so now, and a test holds the limit. The README also names the empty key, with which SQLite3 Multiple Ciphers does not encrypt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, the detection and the C ABI handling look solid. I pushed one documentation commit on top (a955f37): Plaintext header limit. The check relies on the reserved bytes 72–91 of page 1 being encrypted. SQLite3 Multiple Ciphers allows Empty key. With SQLite3 Multiple Ciphers, For the release: 🤖 Generated with Claude Code |
Second of two branches from the kupferdraht comparison. The first, #8, hardened the client against values from the server; this one closes the weak form of kupferdraht's ADR-0042 gap: a build that meant to encrypt and silently did not.
The gap
With plain SQLite the server stores plaintext, as the README says. A build that meant to link SQLCipher and linked plain SQLite instead gets exactly that:
PRAGMA keyis an unknown pragma there and is ignored, and the VFS sends the pages as they are, without an error. Opening throughencrypted_name()is safe from it, becausemultipleciphers-<name>exists only with SQLite3 Multiple Ciphers. Opening throughname(), the SQLCipher path, is not.What changes
Config::refuse_plaintext, off by default. When set, the VFS refuses a read or a write of page 1 that carries an unencrypted SQLite header. SQLite getsSQLITE_PERM, a write transaction rolls back before anything reaches the server, andRemoteVfs::failure()reports the newFailure::Plaintext. The read side makes a database the server already holds in plaintext noticeable.vfs.rs): magic string at 0, the fixed bytes 64/32/32 at 21, and the 20 reserved zero bytes from 72. Checked against the SQLite3 Multiple Ciphers sources invendor/: the default ciphers replace the magic with a salt, the others encrypt from byte 24 on at the latest (CIPHER_PAGE1_OFFSET), and SQLCipher'scipher_plaintext_header_sizeof 32 still leaves the reserved bytes encrypted. Only a plaintext header of 96 bytes or more would pass as plaintext.refuse_plaintextat the end ofsqlite_remote_vfs_config. A program built against the 0.5.0 header passes the smallerstruct_sizeand is accepted with the default. The registration now copiesstruct_sizebytes into a zeroed struct instead of referencing the caller's struct as a whole, which would be undefined for the shorter one. The layout test inextension/covers the field.encrypted_name()and needs no guard.Configgains a public field; constructors such asConfig::newandConfig::serverset it, struct literals need the field.Tests
SQLITE_PERMand a second VFS sees an empty database on the server; a database stored in plaintext fails on read; plain SQLite through the static C interface is refused; the 0.5.0 struct size is accepted.Verified locally:
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings, the same inextension/and onwasm32-unknown-unknowninbrowser/,cargo test --workspace, the extension workspace's tests including the C layout test,cargo docwith-D warnings. The integration tests against a server ran only in CI, there is no Go toolchain on this machine.🤖 Generated with Claude Code