Skip to content

Raw KEY+SALT value is not handled properly when opening an existing database with plaintext header #226

@jagerman

Description

@jagerman

When opening a database with PRAGMA plaintext_header_size and providing a combined long-form combined key+salt value to PRAGMA key, the salt value appears to only be read when the database does not exist (or is an empty file). If the database does exist, the salt is always set to SQLite format 3\x00, and the salt value I pass in via PRAGMA key (or via the sqlite3_key API, which is how I originally encountered this) gets ignored. For AEGIS/ChaCha20/Ascon128 this doesn't actually matter because the salt is irrelevant when using a raw key, but for SQLCipher it breaks the ability to open databases because the salt does matter there, even with raw keys.

I patched some debugging into the AEGIS code locally to confirm this:

diff --git a/src/cipher_aegis.c b/src/cipher_aegis.c
index debf5a6..a6f93b0 100644
--- a/src/cipher_aegis.c
+++ b/src/cipher_aegis.c
@@ -256,11 +256,18 @@ GenerateKeyAegisCipher(void* cipher, char* userPassword, int passwordLength, int
   int keyOnly = 1;
   if (rekey || cipherSalt == NULL)
   {
+    fprintf(stderr, "GenKey %s, making random salt\n", rekey ? "rekey" : "no salt");
     chacha20_rng(aegisCipher->m_salt, SALTLENGTH_AEGIS);
     keyOnly = 0;
   }
   else
   {
+    fprintf(stderr, "GenKey called with cipherSalt: ");
+    for (int i = 0; i < SALTLENGTH_AEGIS; i++) {
+      unsigned char c = cipherSalt[i];
+      fprintf(stderr, c >= 0x20 && c < 0x7f ? "%c" : "\\x%02x", c);
+    }
+    fprintf(stderr, "\n");
     memcpy(aegisCipher->m_salt, cipherSalt, SALTLENGTH_AEGIS);
   }
 
@@ -268,6 +275,14 @@ GenerateKeyAegisCipher(void* cipher, char* userPassword, int passwordLength, int
   int bypass = sqlite3mcExtractRawKey(userPassword, passwordLength,
                                       keyOnly, aegisCipher->m_keyLength, SALTLENGTH_AEGIS,
                                       aegisCipher->m_key, aegisCipher->m_salt);
+
+  fprintf(stderr, "SALT after ExtractRawKey: ");
+  for (int i = 0; i < SALTLENGTH_AEGIS; i++) {
+    unsigned char c = aegisCipher->m_salt[i];
+    fprintf(stderr, c >= 0x20 && c < 0x7f ? "%c" : "\\x%02x", c);
+  }
+  fprintf(stderr, "\n");
+
   if (!bypass)
   {
     int rc = argon2id_hash_raw((uint32_t) aegisCipher->m_argon2Tcost,

With this patch applied I get the following behaviour. With no database (I've edited out the result of the PRAGMAs for brevity, but all were the expected values):

$ ./sqlite3shell foo.db
-- Loading resources from /home/jagerman/.sqliterc
SQLite version 3.51.2 2026-01-09 17:27:48 (SQLite3 Multiple Ciphers 2.2.7)
Enter ".help" for usage hints.
sqlite> PRAGMA cipher = 'aegis';
sqlite> PRAGMA algorithm = 'aegis-256';
sqlite> PRAGMA plaintext_header_size = 24;
sqlite> PRAGMA key = "x'0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0123456789abcdef0123456789abcdef'";
GenKey no salt, making random salt
SALT after ExtractRawKey: \x01#Eg\x89\xab\xcd\xef\x01#Eg\x89\xab\xcd\xef
sqlite> vacuum;
sqlite> 

I.e. it correctly got the salt I gave it at the end of the key.

But then running it again, with that just-created database:

$ ./sqlite3shell foo.db
-- Loading resources from /home/jagerman/.sqliterc
SQLite version 3.51.2 2026-01-09 17:27:48 (SQLite3 Multiple Ciphers 2.2.7)
Enter ".help" for usage hints.
sqlite> PRAGMA cipher = 'aegis';
sqlite> PRAGMA algorithm = 'aegis-256';
sqlite> PRAGMA plaintext_header_size = 24;
sqlite> PRAGMA key = "x'0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0123456789abcdef0123456789abcdef'";
GenKey called with cipherSalt: SQLite format 3\x00
SALT after ExtractRawKey: SQLite format 3\x00
sqlite> select count(*) from sqlite_master;
┌──────────┐
│ count(*) │
├──────────┤
│ 0        │
└──────────┘
sqlite> 

I.e. you can see that the salt at the end of the key value simply gets ignored here, and the plaintext "SQLite format 3\0" (which is where the salt would have been if I wasn't using plaintext header mode) is getting used as a salt value, preempting the value in the key.

AEGIS, Ascon128, ChaCha20: who cares

For AEGIS, Ascon128, and ChaCha20 this is completely irrelevant, which is probably why this hasn't been noticed: the salt is simply not used anywhere when specifying a raw key and using plaintext header mode. This is entirely sensible: the only purpose of the salt is for plaintext password hashing to get to a raw 32 byte key, and so my initial salt, or "SQLite format 3\0" or any other value is entirely irrelevant and the database decrypts successfully, as shown above.

As a side note: This total irrelevance of the salt should ideally be documented somewhere: it would be nice to have confirmed in the docs that when using one of these with a raw key I can simply entirely ignore the salt and not worry about needing to store it externally when it raw key + plaintext header mode with the better ciphers. Or to put it another way, the only thing the salt does with a raw key is specify the random first 16 bytes of the file when not using a plaintext header, and the salt does absolutely nothing when combining a raw key with a plaintext header. That's a feature, not a bug, but would be nice to be documented somewhere so that a past me could have not worried about preserving the salt with raw keys. (Or maybe it is documented already and I just missed it?)

SQLCipher breaks

For SQLCipher, however, the salt does get used even when using a raw key, because in "raw key" mode there is still an extra round of salted HMAC-SHA512, and thus this issue breaks the ability to open SQLCipher databases passing the salt this way. (It's totally pointless cryptographically to salt this final round, but SQLCipher's priorities are apparently going after the corporate world where such security theatre probably scores some extra checkbox ticks somewhere).

The effect with using the sqlcipher pragma to open SQLCipher databases (which, unfortunately, I need to support for migrating current SQLCipher databases) is that using a salt embedded in PRAGMA key fails to decrypt the database.

I applied essentially the same debugging patch to cipher_sqlcipher.c, and repeated the above experiment with an SQLCipher4 database:

$ ./sqlite3shell sqlcipher.db
-- Loading resources from /home/jagerman/.sqliterc
SQLite version 3.51.2 2026-01-09 17:27:48 (SQLite3 Multiple Ciphers 2.2.7)
Enter ".help" for usage hints.
sqlite> PRAGMA cipher = 'sqlcipher';    
sqlite> PRAGMA legacy = 4;
sqlite> PRAGMA plaintext_header_size = 32;    
sqlite> PRAGMA key = "x'0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0123456789abcdef0123456789abcdef'";
SQLCipherGenKey no salt, making random salt
SALT after ExtractRawKey: \x01#Eg\x89\xab\xcd\xef\x01#Eg\x89\xab\xcd\xef
sqlite> vacuum;
sqlite> 

$ ./sqlite3shell sqlcipher.db
-- Loading resources from /home/jagerman/.sqliterc
SQLite version 3.51.2 2026-01-09 17:27:48 (SQLite3 Multiple Ciphers 2.2.7)
Enter ".help" for usage hints.
sqlite> PRAGMA cipher = 'sqlcipher';
sqlite> PRAGMA legacy = 4;
sqlite> PRAGMA plaintext_header_size = 32;
sqlite> PRAGMA key = "x'0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0123456789abcdef0123456789abcdef'";
SQLCipherGenKey called with cipherSalt: SQLite format 3\x00
SALT after ExtractRawKey: SQLite format 3\x00
sqlite> select count(*) from sqlite_master;
Parse error: file is not a database (26)
sqlite> 

Note also that this does work properly if I use PRAGMA cipher_salt instead of putting the salt into the key

sqlite> PRAGMA cipher = 'sqlcipher';
sqlite> PRAGMA legacy = 4;
sqlite> PRAGMA plaintext_header_size = 32;
sqlite> PRAGMA cipher_salt = '0123456789abcdef0123456789abcdef';
sqlite> PRAGMA key = "x'0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff'";
SQLCipherGenKey called with cipherSalt: \x01#Eg\x89\xab\xcd\xef\x01#Eg\x89\xab\xcd\xef
SALT after ExtractRawKey: \x01#Eg\x89\xab\xcd\xef\x01#Eg\x89\xab\xcd\xef
sqlite> select count(*) from sqlite_master;
┌──────────┐
│ count(*) │
├──────────┤
│ 0        │
└──────────┘
sqlite> 

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions