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 diff --git a/include/SQLiteCpp/Database.h b/include/SQLiteCpp/Database.h index 04d27763..75cb3d1c 100644 --- a/include/SQLiteCpp/Database.h +++ b/include/SQLiteCpp/Database.h @@ -575,20 +575,29 @@ class SQLITECPP_API Database void rekey(const void* apNewKey, std::size_t aSize) const; /** - * @brief Test if a file contains an unencrypted database. + * @brief Test whether a file has the standard SQLite database signature. * - * 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. + * 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/uri to a file + * @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 whether SQLite can read a database without an encryption key. * - * @return true if the database has the standard header. + * 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. * - * @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); + 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 e35fbf8c..2bfe2540 100644 --- a/src/Database.cpp +++ b/src/Database.cpp @@ -299,8 +299,8 @@ void Database::rekey(const void* apNewKey, const std::size_t aSize) const #endif // SQLITE_HAS_CODEC } -// Test if a file contains an unencrypted database. -bool Database::isUnencrypted(const std::string& aFilename) +// Test whether a file starts with the standard SQLite format signature. +bool Database::hasSQLiteHeader(const std::string& aFilename) { if (aFilename.empty()) { @@ -327,6 +327,34 @@ bool Database::isUnencrypted(const std::string& aFilename) return memcmp(header, "SQLite format 3\000", 16) == 0; } +// Test whether SQLite can read the database schema without a key. +bool Database::isReadableWithoutKey(const std::string& aFilename) +{ + if (aFilename.empty()) + { + throw SQLite::Exception("Could not open database, the aFilename parameter was empty."); + } + + Database db(aFilename, SQLite::OPEN_READONLY); + const int ret = db.tryExec("SELECT count(*) FROM sqlite_schema"); + + if (SQLITE_OK == ret) + { + return true; + } + + switch (ret & 0xff) + { + case SQLITE_NOTADB: + case SQLITE_CORRUPT: + case SQLITE_ERROR: + return false; + + default: + throw SQLite::Exception(db.getHandle(), ret); + } +} + // Parse header data from a database. Header Database::getHeaderInfo(const std::string& aFilename) { diff --git a/tests/Database_test.cpp b/tests/Database_test.cpp index 0daf00fd..ac24ede1 100644 --- a/tests/Database_test.cpp +++ b/tests/Database_test.cpp @@ -607,20 +607,20 @@ TEST(Database, getHeaderInfo) remove("test.db3"); } -TEST(Database, isUnencryptedHeaderCheck) +TEST(Database, hasSQLiteHeader) { 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::hasSQLiteHeader(""), SQLite::Exception); + EXPECT_THROW(SQLite::Database::hasSQLiteHeader("test.db3"), SQLite::Exception); - // A real SQLite database file is reported as unencrypted + // 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::isUnencrypted("test.db3")); + 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 @@ -632,7 +632,7 @@ TEST(Database, isUnencryptedHeaderCheck) shortDb.write(shortData, sizeof(shortData) - 1); shortDb.close(); - EXPECT_FALSE(SQLite::Database::isUnencrypted("short.db3")); + EXPECT_FALSE(SQLite::Database::hasSQLiteHeader("short.db3")); remove("short.db3"); } @@ -645,13 +645,139 @@ TEST(Database, isUnencryptedHeaderCheck) garbageDb.write(garbage, sizeof(garbage)); garbageDb.close(); - EXPECT_FALSE(SQLite::Database::isUnencrypted("garbage.db3")); + EXPECT_FALSE(SQLite::Database::hasSQLiteHeader("garbage.db3")); remove("garbage.db3"); } } remove("test.db3"); } +TEST(Database, isReadableWithoutKey) +{ + remove("test.db3"); + { + // Empty filename and non-existent file are reported as errors + 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::isReadableWithoutKey("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::isReadableWithoutKey("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::isReadableWithoutKey("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 + 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::isReadableWithoutKey("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::isReadableWithoutKey("garbage.db3")); + remove("garbage.db3"); + } + } + 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); @@ -671,8 +797,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); @@ -682,14 +808,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"); @@ -697,7 +823,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"); @@ -705,7 +831,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 @@ -733,7 +859,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 @@ -746,8 +872,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); @@ -757,7 +883,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(""));