From e461cd5c049d9176d57d5f4c0c1dbd02fdc5fe3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 12:19:00 +0200 Subject: [PATCH 1/5] Add a SQLite file signature check Expose the existing 16-byte header comparison as hasSQLiteHeader(), so callers can request a cheap signature check without assuming the database contents are unencrypted. --- include/SQLiteCpp/Database.h | 12 ++++++++++ src/Database.cpp | 28 ++++++++++++++++++++++ tests/Database_test.cpp | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/include/SQLiteCpp/Database.h b/include/SQLiteCpp/Database.h index 04d27763..3d367751 100644 --- a/include/SQLiteCpp/Database.h +++ b/include/SQLiteCpp/Database.h @@ -574,6 +574,18 @@ class SQLITECPP_API Database */ void rekey(const void* apNewKey, std::size_t aSize) const; + /** + * @brief Test whether a file has the standard SQLite database signature. + * + * Reads and compares the first 16 bytes without opening the file through SQLite. + * An encrypted or corrupt database may still have this signature. + * + * @param[in] aFilename Path to the database file + * @return true if the file starts with the SQLite format signature + * @throw SQLite::Exception if the file cannot be opened + */ + static bool hasSQLiteHeader(const std::string& aFilename); + /** * @brief Test if a file contains an unencrypted database. * diff --git a/src/Database.cpp b/src/Database.cpp index e35fbf8c..8da9fb92 100644 --- a/src/Database.cpp +++ b/src/Database.cpp @@ -299,6 +299,34 @@ void Database::rekey(const void* apNewKey, const std::size_t aSize) const #endif // SQLITE_HAS_CODEC } +// Test whether a file starts with the standard SQLite format signature. +bool Database::hasSQLiteHeader(const std::string& aFilename) +{ + if (aFilename.empty()) + { + throw SQLite::Exception("Could not open database, the aFilename parameter was empty."); + } + + std::ifstream fileBuffer(aFilename.c_str(), std::ios::in | std::ios::binary); + char header[16] = {}; + if (fileBuffer.is_open()) + { + fileBuffer.seekg(0, std::ios::beg); + fileBuffer.read(header, 16); + fileBuffer.close(); + if (fileBuffer.gcount() != 16) + { + return false; + } + } + else + { + throw SQLite::Exception("Error opening file: " + aFilename); + } + + return memcmp(header, "SQLite format 3\000", 16) == 0; +} + // Test if a file contains an unencrypted database. bool Database::isUnencrypted(const std::string& aFilename) { diff --git a/tests/Database_test.cpp b/tests/Database_test.cpp index 0daf00fd..97e2c5a6 100644 --- a/tests/Database_test.cpp +++ b/tests/Database_test.cpp @@ -607,6 +607,51 @@ TEST(Database, getHeaderInfo) remove("test.db3"); } +TEST(Database, hasSQLiteHeader) +{ + remove("test.db3"); + { + // Empty filename and non-existent file are reported as errors + EXPECT_THROW(SQLite::Database::hasSQLiteHeader(""), SQLite::Exception); + EXPECT_THROW(SQLite::Database::hasSQLiteHeader("test.db3"), SQLite::Exception); + + // A real SQLite database file has the expected signature + { + SQLite::Database db("test.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)"); + } + EXPECT_TRUE(SQLite::Database::hasSQLiteHeader("test.db3")); + remove("test.db3"); + + // A file shorter than the 16-byte header must not match and must not crash + { + const char shortData[] = "SQLite format"; // 13 bytes, no trailing NUL written + remove("short.db3"); + std::ofstream shortDb; + shortDb.open("short.db3", std::ios::trunc | std::ios::binary); + shortDb.write(shortData, sizeof(shortData) - 1); + shortDb.close(); + + EXPECT_FALSE(SQLite::Database::hasSQLiteHeader("short.db3")); + remove("short.db3"); + } + + // A 16-byte file with a garbage header does not match the magic string + { + const char garbage[16] = "garbage header"; + remove("garbage.db3"); + std::ofstream garbageDb; + garbageDb.open("garbage.db3", std::ios::trunc | std::ios::binary); + garbageDb.write(garbage, sizeof(garbage)); + garbageDb.close(); + + EXPECT_FALSE(SQLite::Database::hasSQLiteHeader("garbage.db3")); + remove("garbage.db3"); + } + } + remove("test.db3"); +} + TEST(Database, isUnencryptedHeaderCheck) { remove("test.db3"); From 7bd898127593f70bb0e28e25c44a113e045e102f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 12:19:32 +0200 Subject: [PATCH 2/5] Check database readability without a key Query the schema through SQLite instead of treating its header signature as proof that a database is unencrypted. This detects ciphered databases that retain a plaintext header. --- include/SQLiteCpp/Database.h | 17 +++++++---------- src/Database.cpp | 31 ++++++++++++++++--------------- tests/Database_test.cpp | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/include/SQLiteCpp/Database.h b/include/SQLiteCpp/Database.h index 3d367751..a46a49f5 100644 --- a/include/SQLiteCpp/Database.h +++ b/include/SQLiteCpp/Database.h @@ -587,18 +587,15 @@ class SQLITECPP_API Database static bool hasSQLiteHeader(const std::string& aFilename); /** - * @brief Test if a file contains an unencrypted database. + * @brief Test whether SQLite can read a database without an encryption key. * - * This is a simple test that reads the first bytes of a database file and - * compares them to the standard header for unencrypted databases. If the - * header does not match the standard string, we assume that we have an - * encrypted file. + * Opens the database read-only and queries its schema. Returns false for + * encrypted, corrupt, or unsupported database files; this does not prove + * the file is encrypted. SQLite also accepts empty database files. * - * @param[in] aFilename path/uri to a file - * - * @return true if the database has the standard header. - * - * @throw SQLite::Exception in case of error + * @param[in] aFilename SQLite database filename + * @return true if SQLite can read its schema without an encryption key + * @throw SQLite::Exception on file access or other operational errors */ static bool isUnencrypted(const std::string& aFilename); diff --git a/src/Database.cpp b/src/Database.cpp index 8da9fb92..9b3cc2e3 100644 --- a/src/Database.cpp +++ b/src/Database.cpp @@ -327,7 +327,7 @@ bool Database::hasSQLiteHeader(const std::string& aFilename) return memcmp(header, "SQLite format 3\000", 16) == 0; } -// Test if a file contains an unencrypted database. +// Test whether SQLite can read the database schema without a key. bool Database::isUnencrypted(const std::string& aFilename) { if (aFilename.empty()) @@ -335,24 +335,25 @@ bool Database::isUnencrypted(const std::string& aFilename) throw SQLite::Exception("Could not open database, the aFilename parameter was empty."); } - std::ifstream fileBuffer(aFilename.c_str(), std::ios::in | std::ios::binary); - char header[16] = {}; - if (fileBuffer.is_open()) + Database db(aFilename, SQLite::OPEN_READONLY); + const int ret = db.tryExec("SELECT count(*) FROM sqlite_schema"); + + if (SQLITE_OK == ret) { - fileBuffer.seekg(0, std::ios::beg); - fileBuffer.read(header, 16); - fileBuffer.close(); - if (fileBuffer.gcount() != 16) - { - return false; - } + return true; } - else + + switch (ret & 0xff) { - throw SQLite::Exception("Error opening file: " + aFilename); + case SQLITE_NOTADB: + case SQLITE_CORRUPT: + case SQLITE_ERROR: + return false; + + default: + db.check(ret); + return false; // Unreachable } - - return memcmp(header, "SQLite format 3\000", 16) == 0; } // Parse header data from a database. diff --git a/tests/Database_test.cpp b/tests/Database_test.cpp index 97e2c5a6..e72ce796 100644 --- a/tests/Database_test.cpp +++ b/tests/Database_test.cpp @@ -652,7 +652,7 @@ TEST(Database, hasSQLiteHeader) remove("test.db3"); } -TEST(Database, isUnencryptedHeaderCheck) +TEST(Database, isUnencryptedReadability) { remove("test.db3"); { @@ -660,14 +660,42 @@ TEST(Database, isUnencryptedHeaderCheck) EXPECT_THROW(SQLite::Database::isUnencrypted(""), SQLite::Exception); EXPECT_THROW(SQLite::Database::isUnencrypted("test.db3"), SQLite::Exception); - // A real SQLite database file is reported as unencrypted + // A real SQLite database can be read without a key { SQLite::Database db("test.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)"); } EXPECT_TRUE(SQLite::Database::isUnencrypted("test.db3")); + + // Reproduce a plaintext header with unreadable (encrypted-like) contents. + // Only the first 32 bytes are genuine SQLite header bytes. + { + char prefix[32] = {}; + std::ifstream plainDb("test.db3", std::ios::binary); + plainDb.read(prefix, sizeof(prefix)); + ASSERT_EQ(static_cast(sizeof(prefix)), plainDb.gcount()); + + std::ofstream disguisedDb("plaintext-header.db3", std::ios::trunc | std::ios::binary); + disguisedDb.write(prefix, sizeof(prefix)); + const std::string unreadableData(8192 - sizeof(prefix), static_cast(0xa5)); + disguisedDb.write(unreadableData.data(), static_cast(unreadableData.size())); + disguisedDb.close(); + + EXPECT_TRUE(SQLite::Database::hasSQLiteHeader("plaintext-header.db3")); + EXPECT_FALSE(SQLite::Database::isUnencrypted("plaintext-header.db3")); + remove("plaintext-header.db3"); + } remove("test.db3"); + // SQLite accepts a zero-byte file as an empty unencrypted database. + { + std::ofstream emptyDb("empty.db3", std::ios::trunc | std::ios::binary); + emptyDb.close(); + EXPECT_FALSE(SQLite::Database::hasSQLiteHeader("empty.db3")); + EXPECT_TRUE(SQLite::Database::isUnencrypted("empty.db3")); + remove("empty.db3"); + } + // A file shorter than the 16-byte header must not match and must not crash { const char shortData[] = "SQLite format"; // 13 bytes, no trailing NUL written From 4a8eaa7c2ced08db8742859b7625b2a372a5dc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 12:19:40 +0200 Subject: [PATCH 3/5] Rename the database readability API Use isReadableWithoutKey() to describe what the schema query tests. Avoid implying that a failed read proves database encryption. --- include/SQLiteCpp/Database.h | 2 +- src/Database.cpp | 2 +- tests/Database_test.cpp | 36 ++++++++++++++++++------------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/include/SQLiteCpp/Database.h b/include/SQLiteCpp/Database.h index a46a49f5..75cb3d1c 100644 --- a/include/SQLiteCpp/Database.h +++ b/include/SQLiteCpp/Database.h @@ -597,7 +597,7 @@ class SQLITECPP_API Database * @return true if SQLite can read its schema without an encryption key * @throw SQLite::Exception on file access or other operational errors */ - static bool isUnencrypted(const std::string& aFilename); + static bool isReadableWithoutKey(const std::string& aFilename); /** * @brief Parse SQLite header data from a database file. diff --git a/src/Database.cpp b/src/Database.cpp index 9b3cc2e3..2478da55 100644 --- a/src/Database.cpp +++ b/src/Database.cpp @@ -328,7 +328,7 @@ bool Database::hasSQLiteHeader(const std::string& aFilename) } // Test whether SQLite can read the database schema without a key. -bool Database::isUnencrypted(const std::string& aFilename) +bool Database::isReadableWithoutKey(const std::string& aFilename) { if (aFilename.empty()) { diff --git a/tests/Database_test.cpp b/tests/Database_test.cpp index e72ce796..1880b6ae 100644 --- a/tests/Database_test.cpp +++ b/tests/Database_test.cpp @@ -652,20 +652,20 @@ TEST(Database, hasSQLiteHeader) remove("test.db3"); } -TEST(Database, isUnencryptedReadability) +TEST(Database, isReadableWithoutKey) { remove("test.db3"); { // Empty filename and non-existent file are reported as errors - EXPECT_THROW(SQLite::Database::isUnencrypted(""), SQLite::Exception); - EXPECT_THROW(SQLite::Database::isUnencrypted("test.db3"), SQLite::Exception); + EXPECT_THROW(SQLite::Database::isReadableWithoutKey(""), SQLite::Exception); + EXPECT_THROW(SQLite::Database::isReadableWithoutKey("test.db3"), SQLite::Exception); // A real SQLite database can be read without a key { SQLite::Database db("test.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)"); } - EXPECT_TRUE(SQLite::Database::isUnencrypted("test.db3")); + EXPECT_TRUE(SQLite::Database::isReadableWithoutKey("test.db3")); // Reproduce a plaintext header with unreadable (encrypted-like) contents. // Only the first 32 bytes are genuine SQLite header bytes. @@ -682,7 +682,7 @@ TEST(Database, isUnencryptedReadability) disguisedDb.close(); EXPECT_TRUE(SQLite::Database::hasSQLiteHeader("plaintext-header.db3")); - EXPECT_FALSE(SQLite::Database::isUnencrypted("plaintext-header.db3")); + EXPECT_FALSE(SQLite::Database::isReadableWithoutKey("plaintext-header.db3")); remove("plaintext-header.db3"); } remove("test.db3"); @@ -692,7 +692,7 @@ TEST(Database, isUnencryptedReadability) std::ofstream emptyDb("empty.db3", std::ios::trunc | std::ios::binary); emptyDb.close(); EXPECT_FALSE(SQLite::Database::hasSQLiteHeader("empty.db3")); - EXPECT_TRUE(SQLite::Database::isUnencrypted("empty.db3")); + EXPECT_TRUE(SQLite::Database::isReadableWithoutKey("empty.db3")); remove("empty.db3"); } @@ -705,7 +705,7 @@ TEST(Database, isUnencryptedReadability) shortDb.write(shortData, sizeof(shortData) - 1); shortDb.close(); - EXPECT_FALSE(SQLite::Database::isUnencrypted("short.db3")); + EXPECT_FALSE(SQLite::Database::isReadableWithoutKey("short.db3")); remove("short.db3"); } @@ -718,7 +718,7 @@ TEST(Database, isUnencryptedReadability) garbageDb.write(garbage, sizeof(garbage)); garbageDb.close(); - EXPECT_FALSE(SQLite::Database::isUnencrypted("garbage.db3")); + EXPECT_FALSE(SQLite::Database::isReadableWithoutKey("garbage.db3")); remove("garbage.db3"); } } @@ -744,8 +744,8 @@ TEST(Database, encryptAndDecrypt) { // Try to open the non-existing database EXPECT_THROW(SQLite::Database not_found("test.db3"), SQLite::Exception); - EXPECT_THROW(SQLite::Database::isUnencrypted("test.db3"), SQLite::Exception); - EXPECT_THROW(SQLite::Database::isUnencrypted(""), SQLite::Exception); + EXPECT_THROW(SQLite::Database::isReadableWithoutKey("test.db3"), SQLite::Exception); + EXPECT_THROW(SQLite::Database::isReadableWithoutKey(""), SQLite::Exception); // Create a new database SQLite::Database db("test.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); @@ -755,14 +755,14 @@ TEST(Database, encryptAndDecrypt) } // Close DB test.db3 { // Reopen the database file and encrypt it - EXPECT_TRUE(SQLite::Database::isUnencrypted("test.db3")); + EXPECT_TRUE(SQLite::Database::isReadableWithoutKey("test.db3")); SQLite::Database db("test.db3", SQLite::OPEN_READWRITE); // Encrypt the database db.rekey("123secret"); } // Close DB test.db3 { // Reopen the database file and try to use it - EXPECT_FALSE(SQLite::Database::isUnencrypted("test.db3")); + EXPECT_FALSE(SQLite::Database::isReadableWithoutKey("test.db3")); SQLite::Database db("test.db3", SQLite::OPEN_READONLY); EXPECT_THROW(db.tableExists("test"), SQLite::Exception); db.key("123secret"); @@ -770,7 +770,7 @@ TEST(Database, encryptAndDecrypt) } // Close DB test.db3 { // Reopen the database file and decrypt it - EXPECT_FALSE(SQLite::Database::isUnencrypted("test.db3")); + EXPECT_FALSE(SQLite::Database::isReadableWithoutKey("test.db3")); SQLite::Database db("test.db3", SQLite::OPEN_READWRITE); // Decrypt the database db.key("123secret"); @@ -778,7 +778,7 @@ TEST(Database, encryptAndDecrypt) } // Close DB test.db3 { // Reopen the database file and use it - EXPECT_TRUE(SQLite::Database::isUnencrypted("test.db3")); + EXPECT_TRUE(SQLite::Database::isReadableWithoutKey("test.db3")); SQLite::Database db("test.db3", SQLite::OPEN_READWRITE); EXPECT_TRUE(db.tableExists("test")); } // Close DB test.db3 @@ -806,7 +806,7 @@ TEST(Database, encryptAndDecryptWithBinaryKey) db.rekey(nullptr, 0); } // Close DB binary-key.db3 { - EXPECT_TRUE(SQLite::Database::isUnencrypted("binary-key.db3")); + EXPECT_TRUE(SQLite::Database::isReadableWithoutKey("binary-key.db3")); SQLite::Database db("binary-key.db3", SQLite::OPEN_READWRITE); EXPECT_TRUE(db.tableExists("test")); } // Close DB binary-key.db3 @@ -819,8 +819,8 @@ TEST(Database, encryptAndDecrypt) { // Try to open the non-existing database EXPECT_THROW(SQLite::Database not_found("test.db3"), SQLite::Exception); - EXPECT_THROW(SQLite::Database::isUnencrypted("test.db3"), SQLite::Exception); - EXPECT_THROW(SQLite::Database::isUnencrypted(""), SQLite::Exception); + EXPECT_THROW(SQLite::Database::isReadableWithoutKey("test.db3"), SQLite::Exception); + EXPECT_THROW(SQLite::Database::isReadableWithoutKey(""), SQLite::Exception); // Create a new database SQLite::Database db("test.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); @@ -830,7 +830,7 @@ TEST(Database, encryptAndDecrypt) } // Close DB test.db3 { // Reopen the database file and encrypt it - EXPECT_TRUE(SQLite::Database::isUnencrypted("test.db3")); + EXPECT_TRUE(SQLite::Database::isReadableWithoutKey("test.db3")); SQLite::Database db("test.db3", SQLite::OPEN_READWRITE); // An empty key is a no-op even when built without encryption support EXPECT_NO_THROW(db.key("")); From 15ce2199ae79ec7b58ac2e1cf8754bcc41d77c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 12:38:58 +0200 Subject: [PATCH 4/5] Cover database readability error paths Check SQLITE_BUSY and SQLITE_CORRUPT results explicitly and keep unsupported formats unreadable. Remove unreachable fallback code. --- src/Database.cpp | 3 +-- tests/Database_test.cpp | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Database.cpp b/src/Database.cpp index 2478da55..2bfe2540 100644 --- a/src/Database.cpp +++ b/src/Database.cpp @@ -351,8 +351,7 @@ bool Database::isReadableWithoutKey(const std::string& aFilename) return false; default: - db.check(ret); - return false; // Unreachable + throw SQLite::Exception(db.getHandle(), ret); } } diff --git a/tests/Database_test.cpp b/tests/Database_test.cpp index 1880b6ae..ac24ede1 100644 --- a/tests/Database_test.cpp +++ b/tests/Database_test.cpp @@ -725,6 +725,59 @@ TEST(Database, isReadableWithoutKey) remove("test.db3"); } +TEST(Database, isReadableWithoutKeyErrors) +{ + remove("unreadable.db3"); + { + SQLite::Database db("unreadable.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY)"); + + // A lock is an operational error, not evidence of encryption. + db.exec("BEGIN EXCLUSIVE"); + try + { + (void)SQLite::Database::isReadableWithoutKey("unreadable.db3"); + FAIL() << "Expected SQLITE_BUSY"; + } + catch (const SQLite::Exception& e) + { + EXPECT_EQ(SQLITE_BUSY, e.getErrorCode()); + } + db.exec("ROLLBACK"); + } + + // An invalid b-tree page is corrupt even with a valid file signature. + { + std::fstream file("unreadable.db3", std::ios::in | std::ios::out | std::ios::binary); + ASSERT_TRUE(file.is_open()); + file.seekp(100, std::ios::beg); + const char invalidPageType = 0; + file.write(&invalidPageType, 1); + } + EXPECT_TRUE(SQLite::Database::hasSQLiteHeader("unreadable.db3")); + { + SQLite::Database db("unreadable.db3", SQLite::OPEN_READONLY); + EXPECT_EQ(SQLITE_CORRUPT, db.tryExec("SELECT count(*) FROM sqlite_schema")); + } + EXPECT_FALSE(SQLite::Database::isReadableWithoutKey("unreadable.db3")); + remove("unreadable.db3"); + + // An unsupported SQLite schema format also makes the schema unreadable. + { + SQLite::Database db("unreadable.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY)"); + } + { + std::fstream file("unreadable.db3", std::ios::in | std::ios::out | std::ios::binary); + ASSERT_TRUE(file.is_open()); + file.seekp(47, std::ios::beg); + const char unsupportedFormat = 15; + file.write(&unsupportedFormat, 1); + } + EXPECT_FALSE(SQLite::Database::isReadableWithoutKey("unreadable.db3")); + remove("unreadable.db3"); +} + TEST(Database, keyAndRekeyValidateBufferArguments) { SQLite::Database db(":memory:", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); From 1ac2c943cac35f621f69835adbf44b30834ce546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 12:39:31 +0200 Subject: [PATCH 5/5] Document database detection fix Record the SQLite header probe and the renamed readability API for SQLiteCpp 4.0 in the unreleased changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fd0daa..17dd1f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix Meson builds with a custom assertion handler (#590) - Instrument unit tests and examples in UBSan builds (#593) - Fix column metadata API visibility for Meson consumers (#595) +- Fix keyless database readability detection and add `hasSQLiteHeader()` (#608) - Refresh cached result-column metadata after SQLite reprepares a statement (#598) ### Added