Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions include/SQLiteCpp/Database.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 30 additions & 2 deletions src/Database.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand All @@ -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)
{
Expand Down
160 changes: 143 additions & 17 deletions tests/Database_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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");
}

Expand All @@ -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<std::streamsize>(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<char>(0xa5));
disguisedDb.write(unreadableData.data(), static_cast<std::streamsize>(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);
Expand All @@ -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);
Expand All @@ -682,30 +808,30 @@ 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");
EXPECT_TRUE(db.tableExists("test"));
} // 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");
db.rekey("");
} // 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
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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(""));
Expand Down
Loading