diff --git a/Cargo.toml b/Cargo.toml index d01f3307eb..41d05245c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,10 @@ url = "2.2.2" rand = "0.8.4" rand_xoshiro = "0.6.0" hex = "0.4.3" +tempdir = "0.3.7" +# Needed to test SQLCipher +libsqlite3-sys = { version = "*", features = ["bundled-sqlcipher"] } + # # Any # @@ -198,6 +202,11 @@ name = "sqlite-derives" path = "tests/sqlite/derives.rs" required-features = ["sqlite", "macros"] +[[test]] +name = "sqlcipher" +path = "tests/sqlite/sqlcipher.rs" +required-features = ["sqlite"] + [[test]] name = "sqlite-test-attr" path = "tests/sqlite/test-attr.rs" diff --git a/sqlx-core/src/sqlite/options/mod.rs b/sqlx-core/src/sqlite/options/mod.rs index d5ec65c271..2e21bf5590 100644 --- a/sqlx-core/src/sqlite/options/mod.rs +++ b/sqlx-core/src/sqlite/options/mod.rs @@ -102,6 +102,42 @@ impl SqliteConnectOptions { // SQLCipher special case: if the `key` pragma is set, it must be executed first. pragmas.insert("key".into(), None); + // Other SQLCipher pragmas that has to be after the key, but before any other operation on the database. + // https://www.zetetic.net/sqlcipher/sqlcipher-api/ + + // Bytes of the database file that is not encrypted + // Default for SQLCipher v4 is 0 + // If greater than zero 'cipher_salt' pragma must be also defined + pragmas.insert("cipher_plaintext_header_size".into(), None); + + // Allows to provide salt manually + // By default SQLCipher sets salt automatically, use only in conjunction with + // 'cipher_plaintext_header_size' pragma + pragmas.insert("cipher_salt".into(), None); + + // Number of iterations used in PBKDF2 key derivation. + // Default for SQLCipher v4 is 256000 + pragmas.insert("kdf_iter".into(), None); + + // Define KDF algorithm to be used. + // Default for SQLCipher v4 is PBKDF2_HMAC_SHA512. + pragmas.insert("cipher_kdf_algorithm".into(), None); + + // Enable or disable HMAC functionality. + // Default for SQLCipher v4 is 1. + pragmas.insert("cipher_use_hmac".into(), None); + + // Set default encryption settings depending on the version 1,2,3, or 4. + pragmas.insert("cipher_compatibility".into(), None); + + // Page size of encrypted database. + // Default for SQLCipher v4 is 4096. + pragmas.insert("cipher_page_size".into(), None); + + // Choose algorithm used for HMAC. + // Default for SQLCipher v4 is HMAC_SHA512. + pragmas.insert("cipher_hmac_algorithm".into(), None); + // Normally, page_size must be set before any other action on the database. // Defaults to 4096 for new databases. pragmas.insert("page_size".into(), None); @@ -284,9 +320,9 @@ impl SqliteConnectOptions { /// Note this excerpt: /// > The collating function must obey the following properties for all strings A, B, and C: /// > - /// > If A==B then B==A. - /// > If A==B and B==C then A==C. - /// > If A\A. + /// > If A==B then B==A. + /// > If A==B and B==C then A==C. + /// > If A\A. /// > If A /// > If a collating function fails any of the above constraints and that collating function is @@ -328,7 +364,7 @@ impl SqliteConnectOptions { /// ### Note /// Setting this to `true` may help if you are getting access violation errors or segmentation /// faults, but will also incur a significant performance penalty. You should leave this - /// set to `false` if at all possible. + /// set to `false` if at all possible. /// /// If you do end up needing to set this to `true` for some reason, please /// [open an issue](https://github.com/launchbadge/sqlx/issues/new/choose) as this may indicate diff --git a/tests/sqlite/sqlcipher.rs b/tests/sqlite/sqlcipher.rs new file mode 100644 index 0000000000..0a2a4499ea --- /dev/null +++ b/tests/sqlite/sqlcipher.rs @@ -0,0 +1,202 @@ +use std::str::FromStr; + +use sqlx::sqlite::SqliteQueryResult; +use sqlx::{query, Connection, SqliteConnection}; +use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions}; +use sqlx_rt::fs::File; +use tempdir::TempDir; + +async fn new_db_url() -> anyhow::Result<(String, TempDir)> { + let dir = TempDir::new("sqlcipher_test")?; + let filepath = dir.path().join("database.sqlite3"); + + // Touch the file, so DB driver will not complain it does not exist + File::create(filepath.as_path()).await?; + + Ok((format!("sqlite://{}", filepath.display()), dir)) +} + +async fn fill_db(conn: &mut SqliteConnection) -> anyhow::Result { + conn.transaction(|tx| { + Box::pin(async move { + query( + " + CREATE TABLE Company( + Id INT PRIMARY KEY NOT NULL, + Name TEXT NOT NULL, + Salary REAL + ); + ", + ) + .execute(&mut *tx) + .await?; + + query( + r#" + INSERT INTO Company(Id, Name, Salary) + VALUES + (1, "aaa", 111), + (2, "bbb", 222) + "#, + ) + .execute(tx) + .await + }) + }) + .await + .map_err(|e| e.into()) +} + +#[sqlx_macros::test] +async fn it_encrypts() -> anyhow::Result<()> { + let (url, _dir) = new_db_url().await?; + + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("key", "the_password") + .connect() + .await?; + + fill_db(&mut conn).await?; + + // Create another connection without key, query should fail + let mut conn = SqliteConnectOptions::from_str(&url)?.connect().await?; + + assert!(conn + .transaction(|tx| { + Box::pin(async move { query("SELECT * FROM Company;").fetch_all(tx).await }) + }) + .await + .is_err()); + + Ok(()) +} + +#[sqlx_macros::test] +async fn it_can_store_and_read_encrypted_data() -> anyhow::Result<()> { + let (url, _dir) = new_db_url().await?; + + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("key", "the_password") + .connect() + .await?; + + fill_db(&mut conn).await?; + + // Create another connection with valid key + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("key", "the_password") + .connect() + .await?; + + let result = conn + .transaction(|tx| { + Box::pin(async move { query("SELECT * FROM Company;").fetch_all(tx).await }) + }) + .await?; + + assert!(result.len() > 0); + + Ok(()) +} + +#[sqlx_macros::test] +async fn it_fails_if_password_is_incorrect() -> anyhow::Result<()> { + let (url, _dir) = new_db_url().await?; + + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("key", "the_password") + .connect() + .await?; + + fill_db(&mut conn).await?; + + // Connection with invalid key should not allow to execute queries + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("key", "BADBADBAD") + .connect() + .await?; + + assert!(conn + .transaction(|tx| { + Box::pin(async move { query("SELECT * FROM Company;").fetch_all(tx).await }) + }) + .await + .is_err()); + + Ok(()) +} + +#[sqlx_macros::test] +async fn it_honors_order_of_encryption_pragmas() -> anyhow::Result<()> { + let (url, _dir) = new_db_url().await?; + + // Make call of cipher configuration mixed with other pragmas, + // it should have no effect, encryption related pragmas should be + // executed first and allow to establish valid connection + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("cipher_kdf_algorithm", "PBKDF2_HMAC_SHA1") + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .pragma("cipher_page_size", "1024") + .pragma("key", "the_password") + .foreign_keys(true) + .pragma("kdf_iter", "64000") + .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental) + .pragma("cipher_hmac_algorithm", "HMAC_SHA1") + .connect() + .await?; + + fill_db(&mut conn).await?; + + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("dummy", "pragma") + // The cipher configuration set on first connection is + // version 3 of SQLCipher, so for second it's enough to set + // the compatibility mode. + .pragma("cipher_compatibility", "3") + .pragma("key", "the_password") + .connect() + .await?; + + let result = conn + .transaction(|tx| { + Box::pin(async move { query("SELECT * FROM COMPANY;").fetch_all(tx).await }) + }) + .await?; + + assert!(result.len() > 0); + + Ok(()) +} + +#[sqlx_macros::test] +async fn it_allows_to_rekey_the_db() -> anyhow::Result<()> { + let (url, _dir) = new_db_url().await?; + + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("key", "the_password") + .connect() + .await?; + + fill_db(&mut conn).await?; + + // The 'pragma rekey' can be called at any time + query("PRAGMA rekey = new_password;") + .execute(&mut conn) + .await?; + + let mut conn = SqliteConnectOptions::from_str(&url)? + .pragma("dummy", "pragma") + .pragma("key", "new_password") + .connect() + .await?; + + let result = conn + .transaction(|tx| { + Box::pin(async move { query("SELECT * FROM COMPANY;").fetch_all(tx).await }) + }) + .await?; + + assert!(result.len() > 0); + + Ok(()) +}