diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db623b6..80e8943 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ jobs: name: Release Build runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ ubuntu-latest, @@ -23,12 +24,28 @@ jobs: ] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ matrix.toolchain }} + - name: Install vcpkg Packages + if: matrix.os == 'windows-latest' + uses: johnwason/vcpkg-action@v6 + id: vcpkg + with: + pkgs: libsodium + triplet: x64-windows-release + token: ${{ github.token }} + github-binarycache: true + + - name: Make Release Windows + if: matrix.os == 'windows-latest' + run: |- + $env:SODIUM_LIB_DIR="$(pwd)\vcpkg\packages\libsodium_x64-windows-release\lib" + make release + - name: Make Release run: make release diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index a952965..38d89c3 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -11,6 +11,7 @@ jobs: name: Static Analysis runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ ubuntu-latest, @@ -21,7 +22,7 @@ jobs: ] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Toolchain uses: actions-rs/toolchain@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd98785..62c8f88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,7 @@ jobs: name: Test runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ ubuntu-latest, @@ -23,12 +24,29 @@ jobs: ] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ matrix.toolchain }} + - name: Install vcpkg Packages + if: matrix.os == 'windows-latest' + uses: johnwason/vcpkg-action@v6 + id: vcpkg + with: + pkgs: libsodium + triplet: x64-windows-release + token: ${{ github.token }} + github-binarycache: true + + - name: Make Test Windows + if: matrix.os == 'windows-latest' + run: |- + $env:SODIUM_LIB_DIR="$(pwd)\vcpkg\packages\libsodium_x64-windows-release\lib" + make test + - name: Make Test + if: matrix.os != 'windows-latest' run: make test diff --git a/crates/lair_keystore/CHANGELOG.md b/crates/lair_keystore/CHANGELOG.md index 62c8de4..60243f3 100644 --- a/crates/lair_keystore/CHANGELOG.md +++ b/crates/lair_keystore/CHANGELOG.md @@ -1 +1,9 @@ +## 0.4.1 + +- Add a way to migrate unencrypted databases to encrypted by providing an environment variable `LAIR_MIGRATE_UNENCRYPTED="true"`, Lair will detect databases which can't be opened and attempt migration. #121 + +# 0.4.0 + +- pin serde and rmp-serde #119 + ## 0.0.2 diff --git a/crates/lair_keystore/src/lib.rs b/crates/lair_keystore/src/lib.rs index f902596..ad75d60 100644 --- a/crates/lair_keystore/src/lib.rs +++ b/crates/lair_keystore/src/lib.rs @@ -54,6 +54,8 @@ include!(concat!(env!("OUT_DIR"), "/ver.rs")); /// Re-exported dependencies. pub mod dependencies { + // Not sure why Clippy picks this up as unused, it's exported to be used elsewhere + #[allow(unused_imports)] pub use hc_seed_bundle::dependencies::*; pub use lair_keystore_api; pub use lair_keystore_api::dependencies::*; @@ -75,6 +77,3 @@ pub mod store_sqlite; #[doc(inline)] pub use store_sqlite::create_sql_pool_factory; - -#[cfg(test)] -mod server_test; diff --git a/crates/lair_keystore/src/store_sqlite.rs b/crates/lair_keystore/src/store_sqlite.rs index 9ab18c5..1c82a70 100644 --- a/crates/lair_keystore/src/store_sqlite.rs +++ b/crates/lair_keystore/src/store_sqlite.rs @@ -111,20 +111,18 @@ impl SqlCon { /// extension trait for execute that we don't care about results trait ExecExt { - fn execute_optional

(&self, sql: &str, params: P) -> LairResult<()> + fn execute_optional

(&self, sql: &str, params: P) -> rusqlite::Result<()> where P: rusqlite::Params; } impl ExecExt for rusqlite::Connection { - fn execute_optional

(&self, sql: &str, params: P) -> LairResult<()> + fn execute_optional

(&self, sql: &str, params: P) -> rusqlite::Result<()> where P: rusqlite::Params, { use rusqlite::OptionalExtension; - self.query_row(sql, params, |_| Ok(())) - .optional() - .map_err(one_err::OneErr::new)?; + self.query_row(sql, params, |_| Ok(())).optional()?; Ok(()) } } @@ -163,18 +161,38 @@ impl SqlPool { // initialize the sqlcipher key pragma let key_pragma = secure_write_key_pragma(dbk_secret)?; - // open a single write connection to the database - let mut write_con = rusqlite::Connection::open_with_flags( - &path, - OpenFlags::SQLITE_OPEN_READ_WRITE - | OpenFlags::SQLITE_OPEN_CREATE - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_URI, - ) - .map_err(one_err::OneErr::new)?; - - // set generic pragmas - set_pragmas(&write_con, key_pragma.clone())?; + let mut write_con = + match create_configured_db_connection(&path, key_pragma.clone()) { + Ok(con) => con, + Err( + err @ rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ffi::ErrorCode::NotADatabase, + .. + }, + .., + ), + ) => { + if "true" + == std::env::var("LAIR_MIGRATE_UNENCRYPTED") + .unwrap_or_default() + .as_str() + { + encrypt_unencrypted_database( + &path, + key_pragma.clone(), + )?; + create_configured_db_connection( + &path, + key_pragma.clone(), + ) + .map_err(one_err::OneErr::new)? + } else { + return Err(one_err::OneErr::new(err)); + } + } + Err(e) => return Err(one_err::OneErr::new(e)), + }; // only set WAL mode on the first write connection // it's a slow operation, and not needed on subsequent connections. @@ -213,7 +231,8 @@ impl SqlPool { .map_err(one_err::OneErr::new)?; // set generic pragmas - set_pragmas(&read_con, key_pragma.clone())?; + set_pragmas(&read_con, key_pragma.clone()) + .map_err(one_err::OneErr::new)?; *rc_mut = Some(read_con); } @@ -289,6 +308,27 @@ impl SqlPool { } } +fn create_configured_db_connection( + path: &std::path::PathBuf, + key_pragma: BufRead, +) -> rusqlite::Result { + use rusqlite::OpenFlags; + + // open a single write connection to the database + let write_con = rusqlite::Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_URI, + )?; + + // set generic pragmas + set_pragmas(&write_con, key_pragma)?; + + Ok(write_con) +} + impl AsLairStore for SqlPool { fn get_bidi_ctx_key(&self) -> sodoken::BufReadSized<32> { self.0.lock().ctx_secret.clone() @@ -486,21 +526,90 @@ fn secure_write_key_pragma( fn set_pragmas( con: &rusqlite::Connection, key_pragma: BufRead, -) -> LairResult<()> { - con.busy_timeout(std::time::Duration::from_millis(30_000)) - .map_err(one_err::OneErr::new)?; +) -> rusqlite::Result<()> { + con.busy_timeout(std::time::Duration::from_millis(30_000))?; con.execute_optional( std::str::from_utf8(&key_pragma.read_lock()).unwrap(), [], )?; - con.pragma_update(None, "trusted_schema", "0".to_string()) - .map_err(one_err::OneErr::new)?; + con.pragma_update(None, "trusted_schema", "0".to_string())?; + + con.pragma_update(None, "synchronous", "1".to_string())?; - con.pragma_update(None, "synchronous", "1".to_string()) + Ok(()) +} + +fn encrypt_unencrypted_database( + path: &std::path::PathBuf, + key_pragma: BufRead, +) -> LairResult<()> { + // e.g. keystore/store_file -> keystore/store_file-encrypted + let encrypted_path = path + .parent() + .ok_or_else(|| -> one_err::OneErr { + format!("Database file path has no parent: {:?}", path).into() + })? + .join( + path.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| -> one_err::OneErr { + format!("Database file path has no name: {:?}", path).into() + })? + .to_string() + + "-encrypted" + + &path + .extension() + .and_then(|s| s.to_str()) + .map(|p| ".".to_string() + p) + .unwrap_or_default(), + ); + + tracing::warn!( + "Attempting encryption of unencrypted database: {:?} -> {:?}", + path, + encrypted_path + ); + + // Migrate the database + { + let conn = + rusqlite::Connection::open(path).map_err(one_err::OneErr::new)?; + + // Ensure everything in the WAL is written to the main database + conn.execute("VACUUM", ()).map_err(one_err::OneErr::new)?; + + // Start an exclusive transaction to avoid anybody writing to the database while we're migrating it + conn.execute("BEGIN EXCLUSIVE", ()) + .map_err(one_err::OneErr::new)?; + + let lock = key_pragma.read_lock(); + conn.execute( + "ATTACH DATABASE :db_name AS encrypted KEY :key", + rusqlite::named_params! { + ":db_name": encrypted_path.to_str(), + ":key": &lock[14..81], + }, + ) .map_err(one_err::OneErr::new)?; + conn.query_row("SELECT sqlcipher_export('encrypted')", (), |_| Ok(0)) + .map_err(one_err::OneErr::new)?; + + conn.execute("COMMIT", ()).map_err(one_err::OneErr::new)?; + + conn.execute("DETACH DATABASE encrypted", ()) + .map_err(one_err::OneErr::new)?; + conn.close() + .map_err(|(_, err)| err) + .map_err(one_err::OneErr::new)?; + } + + // Swap the databases over + std::fs::remove_file(path)?; + std::fs::rename(encrypted_path, path)?; + Ok(()) } diff --git a/crates/lair_keystore/tests/common.rs b/crates/lair_keystore/tests/common.rs new file mode 100644 index 0000000..55a1671 --- /dev/null +++ b/crates/lair_keystore/tests/common.rs @@ -0,0 +1,48 @@ +use lair_keystore::dependencies::*; +use lair_keystore_api::prelude::*; +use std::sync::Arc; + +pub async fn create_config( + tmpdir: &tempdir::TempDir, + passphrase: sodoken::BufRead, +) -> Arc { + // create the config for the test server + Arc::new( + hc_seed_bundle::PwHashLimits::Minimum + .with_exec(|| { + LairServerConfigInner::new(tmpdir.path(), passphrase.clone()) + }) + .await + .unwrap(), + ) +} + +pub async fn connect_with_config( + config: Arc, + passphrase: sodoken::BufRead, +) -> LairResult { + // execute the server + lair_keystore::server::StandaloneServer::new(config.clone()) + .await? + .run(passphrase.clone()) + .await?; + + // create a client connection + lair_keystore_api::ipc_keystore::ipc_keystore_connect( + config.connection_url.clone(), + passphrase, + ) + .await +} + +#[allow(dead_code)] +pub async fn connect( + tmpdir: &tempdir::TempDir, +) -> lair_keystore_api::LairClient { + // set up a passphrase + let passphrase = sodoken::BufRead::from(&b"passphrase"[..]); + + let config = create_config(tmpdir, passphrase.clone()).await; + + connect_with_config(config, passphrase).await.unwrap() +} diff --git a/crates/lair_keystore/tests/migrate_unencrypted.rs b/crates/lair_keystore/tests/migrate_unencrypted.rs new file mode 100644 index 0000000..6735f65 --- /dev/null +++ b/crates/lair_keystore/tests/migrate_unencrypted.rs @@ -0,0 +1,47 @@ +use common::{connect_with_config, create_config}; +use lair_keystore_api::dependencies::{sodoken, tokio}; + +mod common; + +#[cfg(not(windows))] // No encryption on Windows, ignore this test +#[tokio::test(flavor = "multi_thread")] +async fn migrate_unencrypted() { + use rusqlite::Connection; + + let tmpdir = tempdir::TempDir::new("lair keystore test").unwrap(); + + let passphrase = sodoken::BufRead::from(&b"passphrase"[..]); + + let config = create_config(&tmpdir, passphrase.clone()).await; + + // Set up an unencrypted database, by not setting a key on the connection + { + let conn = Connection::open(&config.store_file).unwrap(); + + // Needs to contain data otherwise encryption will just succeed! + conn.execute("CREATE TABLE migrate_me (name TEXT NOT NULL)", ()) + .unwrap(); + conn.execute( + "INSERT INTO migrate_me (name) VALUES ('hello_migrated')", + (), + ) + .unwrap(); + + conn.close().unwrap(); + } + + match connect_with_config(config.clone(), passphrase.clone()).await { + Ok(_) => { + panic!("Shouldn't have been able to spawn lair-keystore"); + } + Err(_) => { + // That's good, we shouldn't have been able to connect because the database won't auto-migrate without `LAIR_MIGRATE_UNENCRYPTED` + } + } + + std::env::set_var("LAIR_MIGRATE_UNENCRYPTED", "true"); + + connect_with_config(config.clone(), passphrase.clone()) + .await + .unwrap(); +} diff --git a/crates/lair_keystore/src/server_test.rs b/crates/lair_keystore/tests/server_test.rs similarity index 80% rename from crates/lair_keystore/src/server_test.rs rename to crates/lair_keystore/tests/server_test.rs index 0fe0b13..bae44f9 100644 --- a/crates/lair_keystore/src/server_test.rs +++ b/crates/lair_keystore/tests/server_test.rs @@ -1,36 +1,8 @@ -use crate::*; -use std::sync::Arc; - -async fn connect(tmpdir: &tempdir::TempDir) -> lair_keystore_api::LairClient { - // set up a passphrase - let passphrase = sodoken::BufRead::from(&b"passphrase"[..]); - - // create the config for the test server - let config = Arc::new( - hc_seed_bundle::PwHashLimits::Minimum - .with_exec(|| { - LairServerConfigInner::new(tmpdir.path(), passphrase.clone()) - }) - .await - .unwrap(), - ); - - // execute the server - crate::server::StandaloneServer::new(config.clone()) - .await - .unwrap() - .run(passphrase.clone()) - .await - .unwrap(); +use common::connect; +use lair_keystore::dependencies::*; +use lair_keystore_api::prelude::*; - // create a client connection - lair_keystore_api::ipc_keystore::ipc_keystore_connect( - config.connection_url.clone(), - passphrase, - ) - .await - .unwrap() -} +mod common; #[tokio::test(flavor = "multi_thread")] async fn server_test_happy_path() {