Skip to content

Refuse plaintext on request - #9

Merged
mtrossbach merged 6 commits into
mainfrom
plaintext-guard
Nov 3, 2026
Merged

mtrossbach merged 6 commits into
mainfrom
plaintext-guard

Conversation

@reklov

@reklov reklov commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

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 key is an unknown pragma there and is ignored, and the VFS sends the pages as they are, without an error. Opening through encrypted_name() is safe from it, because multipleciphers-<name> exists only with SQLite3 Multiple Ciphers. Opening through name(), 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 gets SQLITE_PERM, a write transaction rolls back before anything reaches the server, and RemoteVfs::failure() reports the new Failure::Plaintext. The read side makes a database the server already holds in plaintext noticeable.
  • Detection (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 in vendor/: the default ciphers replace the magic with a salt, the others encrypt from byte 24 on at the latest (CIPHER_PAGE1_OFFSET), and SQLCipher's cipher_plaintext_header_size of 32 still leaves the reserved bytes encrypted. Only a plaintext header of 96 bytes or more would pass as plaintext.
  • C interface: refuse_plaintext at the end of sqlite_remote_vfs_config. A program built against the 0.5.0 header passes the smaller struct_size and is accepted with the default. The registration now copies struct_size bytes 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 in extension/ covers the field.
  • Default stays off: the README documents plain SQLite as a mode, the extension workspace tests with it, and kupferdraht opens through encrypted_name() and needs no guard. Config gains a public field; constructors such as Config::new and Config::server set it, struct literals need the field.

Tests

  • Unit: the detector on a plain page 1 (full page and the 100-byte header read), on salted, from-24-encrypted, 32-byte-plaintext-header and fully encrypted variants, on other pages and zero pages.
  • Integration (need a server, run in CI): an encrypted database passes with the guard on; the first commit of an unencrypted one fails with SQLITE_PERM and 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 in extension/ and on wasm32-unknown-unknown in browser/, cargo test --workspace, the extension workspace's tests including the C layout test, cargo doc with -D warnings. The integration tests against a server ran only in CI, there is no Go toolchain on this machine.

🤖 Generated with Claude Code

…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>
@reklov
reklov requested a review from mtrossbach November 1, 2026 16:00
reklov and others added 5 commits November 1, 2026 17:11
… 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>
@mtrossbach

Copy link
Copy Markdown
Member

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 plaintext_header_size up to 100 for its ciphers, the default ChaCha20 included (SQLCipher: multiples of 16). With more than 72 bytes, page 1 of an encrypted database can pass as plaintext by chance, from 92 bytes on it always does. Config::refuse_plaintext, the C header and the README now say so, and a unit test holds the limit (72 passes as encrypted, 92 is detected as plaintext).

Empty key. With SQLite3 Multiple Ciphers, PRAGMA key with an empty key attaches no cipher, so it writes plaintext without an error (sqlite3mcCodecAttach, nKey <= 0). For us, opening through encrypted_name(), that and a forgotten key are the cases the guard catches; the README names the empty key now.

For the release: Config gains a public field, so struct literals break. The next version should be 0.6.0.

🤖 Generated with Claude Code

@mtrossbach
mtrossbach merged commit bb80676 into main Nov 3, 2026
15 checks passed
@mtrossbach
mtrossbach deleted the plaintext-guard branch November 3, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants