Repository navigation
Proposal: encrypt temporary files with a random key per file - #273
Conversation
|
Hi Markus,
And this is really an important question. Even if one follows the recommendation to set
In many situations it is difficult or even impossible to detect to which connection or database temporary data belong and to determine the cipher scheme in use. Therefore using a random key is the best option one has. The situation is made even more difficult by the fact that SQLite usually starts with temporary data in memory and spills over to disk only if the cache overflows. As a consequence temporary database pages don't have reserved bytes (which are important for most of SQLite3MC's cipher schemes).
Especially the latter is a strong argument to implement encryption for temporary files.
Right. Actually, there are only 2 types of temporary files: files that hold database pages, and files that hold rollback journals.
Correct.
IMHO we should try to avoid encrypting all temporary files unconditionally due to the overhead in processing time. If a database connection doesn't use encryption it shouldn't have to encrypt temporary files. Yes, that means to add patches to SQLite, because the VFS alone has no means to detect whether to use encryption for temporary files or not.
While it is possible to process the file without regard to its internal structure, it might be more effective to take the structure into account.
Most likely the logic will become simpler, if the structure of the temporary file is taken into account.
I made a few experiments and I think we can get away with 2 small patches to solve this question. Plus 1 small patch to determine the page size of temporary database files.
That sounds like a huge overhead, but I guess the encryption itself is only a few percent while most of the time is required to read from and write to the temporary file.
This is more in the range I would expect for added encryption.
In principle, yes. However, as mentioned above I think we should use the knowledge about the content structure. Most likely that would make the process more efficient.
I guess the optimum would be to activate encryption only where it is really needed. I will apply the necessary patches to SQLite to accomplish that.
I'm pretty sure that AES is the slowest cipher, even with hardware support (which is actually not available for WebAssembly). ChaCha20 is a well-known stream cipher and we already have its implementation in SQLite3MC. So, it is the natural choice. If we find better options in the future, we can change the cipher scheme any time later on, because we are talking about temporary files after all.
Yes, that is an excellent idea. |
|
My concern with the current approach is the amount of memory required for holding administrative data per block. With the next release we will define Therefore I would propose the following modification: use a 64-bit write operation counter for each temporary file, increase the counter for each block write, prepend the counter value to the block buffer and write the combination to disk - this increases the file size and it requires to adjust the offset within the file according to the number of the block, but it saves us from having to keep the counter for each block in memory. Only the last block should be incomplete, if at all. With the counter prepended to the data buffer, the last buffer can be written incompletely, so that the file size tells us how many bytes are valid in the last block. Thus, it should be sufficient to keep the actual file size in memory, instead of number of used bytes per block. For temporary or transient databases (flags In case of flag |
d588fd2 to
a581b53
Compare
|
Done, I rebuilt it that way. Each block now has its counter value in front of it, 8 bytes. The VFS shifts the offsets in the file accordingly. The counter is the nonce, and it is read back from the file, so I don't have to keep anything per block in memory. Temporary and transient databases use their page size as block size, the rest 4 KiB. For now the page size comes from the first write, so the PR builds without your patch. When yours lands, it can come from there. A bit more than the file size stayed in memory, though. For the last block I keep its counter value and how much of its keystream is used, 16 bytes. Otherwise every append rewrites the partly filled block, and a journal writes about twice as much as it does today. In an early version that was 54.7 MB for 21.8 MB of data. There is also a short list of blocks whose write failed. Reading one of them returns an I/O error, because otherwise SQLite would get back half a block that is still encrypted with the old counter value. A block nobody ever wrote has the counter value 0 and reads as zeros. Temporary databases write their pages in the order they fall out of the cache, so the files have holes, and they stay sparse. If a write fails, whatever it left behind the end of the file is removed right away now. Otherwise it would look like a normal block as soon as the file grows past it again. The stress test found that. Something else from testing, in case it helps with your patches. The sorter files for And the overflow pages you asked about. When a row doesn't fit on one page, the rest of it lands in overflow pages. When SQLite reads such a value, it sometimes takes the piece it needs straight from the file, past the pager. Your codec check keeps that away from encrypted databases, but a temporary database has no codec, so there a read can start in the middle of a page. With blocks that is no problem. If it ever becomes one, the VFS can stop reporting The cost is the same as before. On an M4 with If you would like it different anywhere, just say so. |
Great!!! Thank you very much!
If we use the page size for temporary and transient databases, I guess we will have to change this member of the uint8_t buffer[MC_TEMP_HEADER + MC_TEMP_BLOCK];
I will create a separate branch for implementing the handling of temporary files. And I will then apply the PR to that branch and adjust how the buffer size is set.
Yes, that is the drawback of the block prefix approach that for blocks in the middle of the file one has to read, modify, and write the block. We will have to analyze the impact that will have. As said this problem will not occur for temporary or transient databases, because only full blocks are read and written, but for temporary journals this could be really bad depending on how SQLite writes it. If data are appended only, it may be tolerable.
Yes, I found out about that, too, recently. Unfortunately, SQLite uses
Is that really so? I thought such reads start always at the beginning of a page, but read the page only partially.
We will have to carefully check whether this can be a serious issue or not.
I still don't understand why this takes 60 percent longer.
It would be good to have this test program for further analysis of the runtime behaviour.
At first glance it looks good, but I will contact you, if I have questions or would like to have adjustments. |
Temporary files (temporary and transient databases, their journals, sorter files and statement journals) are opened without a name. They can't be linked to the codec of a database, so they were written in plaintext whenever temporary data went to disk. Encrypt every file opened without a name with ChaCha20 and a random key that exists only in memory while the file is open. The file is split into blocks, and every block is stored with the value of a counter in front of it. That counter is the nonce of the block and is read back from the file, so no state per block is kept in memory and a keystream is never used twice. Appends continue the keystream of the last block, so journals need no read-modify-write. A failed write never turns into wrong data: its bytes read as zeros, or the block reports an I/O error until it is written again. The compile-time option SQLITE3MC_ENCRYPT_TEMP_FILES turns this on or off.
The test writes to, reads from and truncates an anonymous temporary file at random and compares every result with a model of the file. A second VFS below checks the writes on their way to the disk: every byte must be the plaintext XOR the keystream of the counter value that is stored in front of its block, counter values never go backwards, and no keystream byte is used twice, not even after a failed write. Disk errors can be injected in the middle of a write, and a wrong value without an error is a failure. In a build without the encryption of temporary files, the test checks the model only.
a581b53 to
2f767c5
Compare
|
Good points, thanks. The buffer is dynamic now. The block size also follows the page size for every size up to 64 kB, not just below 4 kB as before. While testing that, the stress test with 8 and 16 kB pages found a leftover: the zero buffer for filling up the last block was still 4 kB. So your hint caught a real bug. You are right about the overflow pages, I had that wrong. The direct read starts at the beginning of the page and only reads part of it, "data is required from the start of this overflow page" is even in the comment above it. So nothing starts in the middle of a page there. About the read-modify-write: I counted the bytes that reach the temporary files with and without encryption. Sort 77.8 against 77.9 MiB, temporary table 21.8 against 21.8, CREATE INDEX 20.8 against 20.9, VACUUM 43.5 against 43.6. So no extra I/O. Journals only append, and an append continues the keystream of the last block, which is what the 16 bytes per file are for. What does change is that every write now sits 8 bytes off an alignment boundary. The 60 percent bothered me too, so I measured it. I built the same code with the cipher call turned into a no-op, everything else unchanged. Sort: 134 ms without encryption, 139 ms with the block layout but no cipher, 229 ms with the cipher. Temporary table: 100, 107, 176. So the layout, the offsets and the copies cost 4 to 7 percent, the rest is ChaCha20 itself. I also timed the cipher inside the VFS. Per run the sort pushes 155.7 MiB through the temporary file and spends 88 ms of the 95 ms difference on encryption, the temporary table 87.4 MiB and 52 ms, CREATE INDEX 41.7 MiB and 24 ms, VACUUM 85.2 MiB and 48 ms. That is about 1.8 GB/s, which is what ChaCha20 does on this machine. The temporary files never leave the page cache in this benchmark, so there is no disk time for the cipher to hide behind. On real storage the share would be a lot smaller. The stress test is in the PR now, as |
For temporary and transient databases we will soon just take the known page size immediately. Regarding other temporary files I'm not so sure whether changing the page size from 4 kB to anything else is really a useful step to take. For rollback journals the "optimal" block size would be "pagesize + 8" (after a 24 byte file header). I don't know whether temporary index or sort files have an "optimal" block size.
Thanks for confirming.
I think the latter really doesn't matter for journal files, because pages aren't aligned anyway, due to the page record format.
I hadn't expected that ChaCha20 would be that slow. However, you said you tested on Apple M4. That is, ChaCha20 could benefit from hardware acceleration, but it isn't enabled by default, because the implementation is not yet officially included in libsodium. Most likely performance will be better using
I haven't checked yet whether your test application enables hardware acceleration. If not, and if enabling it makes a big difference, we should make a decision whether to enable hardware acceleration for ChaCha20 by default despite of the fact that the implementation is not yet officially part of libsodium.
Thanks a lot. I merged your PR into the new branch |
|
I committed my changes and additions to branch temp-crypt, see commit 1e0e406. I did not remove the symbol I also left the mechanism to determine the blocksize on the first write operation for temporary and transient databases as is, although the member |
|
While checking where I have to patch the SQLite amalgamation to intercept the opening of temporary files, I found out that subjournals (flag |
Hi Ulrich,
after the
temp_storechange in #271 I got curious about what actually ends up on disk when temporary data does go to a file, and one thing led to another. This is a proposal for option 1 from #148: every temporary file gets its own random key, behind a compile-time switch.Type: Feature / Enhancement (security)
What ends up on disk today
With
PRAGMA temp_store=FILE, or a build that setsSQLITE_TEMP_STOREto 0 or 1, temporary data is written in plaintext. To see how much, I put a small VFS below the SQLite3 Multiple Ciphers VFS and searched every write for a marker string from the table data. It showed up in temporary tables, in sorting, inDISTINCT, in statement journals and duringVACUUM. AVACUUM, and a rekey that changes the cipher scheme, even write a full copy of the database to a temporary file. As you wrote in #148, keeping everything in memory isn't always possible when memory is tight.The idea
SQLite opens temporary files without a name, and the
xOpendocumentation guarantees that such a file is temporary and deleted when it is closed. So every file that arrives without a name gets its own random 256-bit key and is encrypted with ChaCha20. The key comes fromchacha20_rng, exists only in memory while the file is open and is wiped when it closes, so nobody has to know or store it. That covers the temporary and transient databases, their journals, the sorter files and the statement journals.Main databases, journals and WAL files stay untouched, and no SQLite patches are needed. Everything sits in
src/sqlite3mc_vfs.c: one new section of about 240 lines plus a few lines in the I/O methods.SQLITE3MC_ENCRYPT_TEMP_FILESturns it on and off at compile time.How it works
ChaCha20 as a stream cipher keeps the data the same length, so SQLite can still read and write any byte range. The file is divided into blocks: temporary and transient databases use their page size, everything else 4 KiB. Each block is stored with an 8-byte counter value in front of it, so a block sits 8 bytes further into the file for each block before it, and the VFS moves the offsets accordingly.
That counter is the nonce. It counts up for every block the file writes and comes back from the file when the block is read, so no state per block has to be kept in memory. A keystream is therefore never used twice, not after a truncate and not after a failed write. Appends continue the keystream of the last block, so journals, whose records never line up with the blocks, cause the same I/O as without encryption; read-modify-write is left for real overwrites in the middle of a block. A block that was never written holds the counter value 0 and reads as zeros, which keeps the gaps sparse that a temporary database gets when pages are written in the order they leave the cache.
What stays in memory per open file is the key, the size, the counter, the last block's counter value with the part of its keystream that is used, and a 4 KiB work buffer. Nothing of it grows with the file.
If a write fails, the bytes it should have written read as zeros afterwards, as if it had never happened, and whatever it left behind the end of the file is removed right away. If the write reached into the part of the block that was already valid, that block reports an I/O error until it is written completely again, so SQLite never gets half a block that was encrypted with the previous counter value. Memory mapping is disabled for these files; SQLite then simply uses
xRead.What it doesn't do, and what it costs
It protects confidentiality, not integrity, which matches the state today. Temporary files of unencrypted databases are encrypted as well, because the VFS can't tell which connection a temporary file belongs to; you estimated 4-6 SQLite patches for that in #148.
The cost only appears when temporary data really goes to disk, and then it is one pass of ChaCha20 over every byte that is written or read. On an Apple M4 with
temp_store=FILE: a temporary table took about 1.6 times as long as with plaintext temporary files, a large sort about 1.7 times,CREATE INDEXandVACUUM6 to 8 percent longer. Keeping temporary data in memory is still faster than both.Open questions
test/tempfiletest.c. I'd be glad to add the plaintext check as well, because it would catch a regression after a SQLite update.How I tested it
Most of the work went into the testing, so here is the short version. The stress test below is part of the PR as
test/tempfiletest.c.The plaintext check from above runs for temporary tables, sorting,
DISTINCT, statement journals,VACUUMand a rekey, on macOS and on Linux with gcc and clang, and the marker no longer appears anywhere. A differential test runs the same 12 steps once withtemp_store=FILEand once withtemp_store=MEMORYand compares the results, also with four sorter threads.The main test is a stress test on a single temporary file: random unaligned writes, appends, reads and truncates against a model of the file, with a VFS underneath that checks every write on its own. The bytes on disk have to match the keystream for the expected nonce, and no keystream byte may be used twice. The same test runs with disk errors injected at random points, and wrong data without an error never came out of it.
At SQL level I let one write, half a write, all following writes, a read, a truncate or one memory allocation fail at every position of a workload, and checked afterwards that
integrity_checkis ok and the database is in the state before or after the failed statement. To see whether the tests really catch something, I broke the new code on purpose in nine different ways; every one of them made a test fail.The sanitizers for memory, leaks, undefined behaviour and threads report nothing, the SQL tests with
PRAGMA temp_store=FILEon every connection andcryptotestpass, and there are no new compiler warnings.Best regards,
Markus