From e886a4850400b7a907c0ab28403ce0f7e3359f13 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 16 Apr 2026 08:37:46 +0000 Subject: [PATCH 1/5] feat(datastore): add opt-in SQLCipher encryption support Adds an `encryption` feature flag to aw-datastore (and aw-server) that enables SQLCipher-based database encryption at rest. **Usage**: ``` cargo build --no-default-features --features encryption aw-server --db-password mysecretkey # or: AW_DB_PASSWORD=mysecretkey aw-server ``` **Changes**: - aw-datastore: restructure rusqlite features so `bundled` (default) and `encryption` (opt-in SQLCipher) are mutually exclusive - aw-datastore: add `DatastoreMethod::FileEncrypted(path, key)` variant applying PRAGMA key after connection open - aw-datastore: add `Datastore::new_encrypted()` constructor - aw-server: forward `encryption` / `encryption-vendored` features from aw-datastore; accept --db-password / AW_DB_PASSWORD - tests: add `test_encrypted_datastore_roundtrip` verifying data survives a close/reopen cycle with the correct key Closes #435 --- Cargo.lock | 1 + aw-datastore/Cargo.toml | 12 ++++++++-- aw-datastore/src/lib.rs | 4 ++++ aw-datastore/src/worker.rs | 18 ++++++++++++++ aw-datastore/tests/datastore.rs | 42 +++++++++++++++++++++++++++++++++ aw-server/Cargo.toml | 13 ++++++++-- aw-server/src/main.rs | 19 ++++++++++++++- 7 files changed, 104 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 353b4f5c..1fe8fe5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,6 +1477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" dependencies = [ "cc", + "openssl-sys", "pkg-config", "vcpkg", ] diff --git a/aw-datastore/Cargo.toml b/aw-datastore/Cargo.toml index 73d768b5..1c32f836 100644 --- a/aw-datastore/Cargo.toml +++ b/aw-datastore/Cargo.toml @@ -5,7 +5,15 @@ authors = ["Johan Bjäreholt "] edition = "2021" [features] -default = [] # no features by default +default = ["bundled"] +# Use bundled SQLite (default, no encryption support) +bundled = ["rusqlite/bundled"] +# Use bundled SQLCipher for encrypted databases (mutually exclusive with 'bundled') +# Build with: cargo build --no-default-features --features encryption +# Requires OpenSSL. Use 'encryption-vendored' to vendor OpenSSL as well. +encryption = ["rusqlite/bundled-sqlcipher"] +# Like 'encryption' but also vendors OpenSSL (fully self-contained) +encryption-vendored = ["rusqlite/bundled-sqlcipher-vendored-openssl"] legacy_import_tests = [] [dependencies] @@ -13,7 +21,7 @@ dirs = "6" serde = "1.0" serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } -rusqlite = { version = "0.30", features = ["chrono", "serde_json", "bundled"] } +rusqlite = { version = "0.30", features = ["chrono", "serde_json"] } mpsc_requests = "0.3" log = "0.4" diff --git a/aw-datastore/src/lib.rs b/aw-datastore/src/lib.rs index 69c47618..8de79423 100644 --- a/aw-datastore/src/lib.rs +++ b/aw-datastore/src/lib.rs @@ -26,6 +26,10 @@ pub use self::worker::Datastore; pub enum DatastoreMethod { Memory(), File(String), + /// Encrypted SQLite file using SQLCipher. Only available with the + /// `encryption` or `encryption-vendored` feature flags. + #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] + FileEncrypted(String, String), // (path, key) } /* TODO: Implement this as a proper error */ diff --git a/aw-datastore/src/worker.rs b/aw-datastore/src/worker.rs index b116a1f3..928892b3 100644 --- a/aw-datastore/src/worker.rs +++ b/aw-datastore/src/worker.rs @@ -125,6 +125,14 @@ impl DatastoreWorker { DatastoreMethod::File(path) => { Connection::open(path).expect("Failed to create datastore") } + #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] + DatastoreMethod::FileEncrypted(path, key) => { + let conn = Connection::open(path).expect("Failed to create encrypted datastore"); + conn.pragma_update(None, "key", key) + .expect("Failed to set SQLCipher encryption key"); + info!("Opened encrypted database at {}", path); + conn + } }; let mut ds = DatastoreInstance::new(&conn, true).unwrap(); @@ -324,6 +332,16 @@ impl Datastore { Datastore::_new_internal(method, legacy_import) } + /// Create an encrypted datastore using SQLCipher. + /// + /// Requires the `encryption` or `encryption-vendored` feature flag. + /// Build with: `cargo build --no-default-features --features encryption` + #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] + pub fn new_encrypted(dbpath: String, key: String, legacy_import: bool) -> Self { + let method = DatastoreMethod::FileEncrypted(dbpath, key); + Datastore::_new_internal(method, legacy_import) + } + fn _new_internal(method: DatastoreMethod, legacy_import: bool) -> Self { let (requester, responder) = mpsc_requests::channel::>(); diff --git a/aw-datastore/tests/datastore.rs b/aw-datastore/tests/datastore.rs index 739e8ade..20368637 100644 --- a/aw-datastore/tests/datastore.rs +++ b/aw-datastore/tests/datastore.rs @@ -531,4 +531,46 @@ mod datastore_tests { ); } } + + /// Test that an encrypted datastore can be created, written to, and reopened with the same key + /// with data intact. + #[test] + #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] + fn test_encrypted_datastore_roundtrip() { + use std::fs; + let dir = get_cache_dir().unwrap(); + let db_path = dir.join("test-encrypted.db").to_str().unwrap().to_string(); + // Clean up from previous runs + let _ = fs::remove_file(&db_path); + + let key = "s3cr3t-p@ssw0rd".to_string(); + + // Create and populate encrypted datastore + { + let ds = Datastore::new_encrypted(db_path.clone(), key.clone(), false); + let bucket = create_test_bucket(&ds); + let e = Event { + id: None, + timestamp: Utc::now(), + duration: Duration::seconds(1), + data: json_map! { "app": "test-encrypted" }, + }; + let inserted = ds.insert_events(&bucket.id, &[e]).unwrap(); + assert_eq!(inserted.len(), 1); + ds.close(); + } + + // Reopen with correct key — data must survive the roundtrip + { + let ds = Datastore::new_encrypted(db_path.clone(), key.clone(), false); + let events = ds + .get_events("testid", None, None, None) + .expect("should read events from encrypted DB after reopen"); + assert_eq!(events.len(), 1, "expected 1 event after encrypted reopen"); + assert_eq!(events[0].data["app"], "test-encrypted"); + ds.close(); + } + + let _ = fs::remove_file(&db_path); + } } diff --git a/aw-server/Cargo.toml b/aw-server/Cargo.toml index da6c908e..7a8a2f1c 100644 --- a/aw-server/Cargo.toml +++ b/aw-server/Cargo.toml @@ -13,6 +13,16 @@ path = "src/lib.rs" name = "aw-server" path = "src/main.rs" +[features] +default = ["bundled"] +# Use bundled SQLite (default, no encryption support) +bundled = ["aw-datastore/bundled"] +# Enable SQLCipher encryption support (requires OpenSSL) +# Build with: cargo build --no-default-features --features encryption +encryption = ["aw-datastore/encryption"] +# Enable SQLCipher encryption with vendored OpenSSL (fully self-contained) +encryption-vendored = ["aw-datastore/encryption-vendored"] + [dependencies] rocket = { version = "0.5.0", features = ["json"] } rocket_cors = { version = "0.6.0" } @@ -29,8 +39,7 @@ uuid = { version = "1.3", features = ["serde", "v4"] } clap = { version = "4.1", features = ["derive", "cargo"] } log-panics = { version = "2", features = ["with-backtrace"]} rust-embed = { version = "8.0.0", features = ["interpolate-folder-path", "debug-embed"] } - -aw-datastore = { path = "../aw-datastore" } +aw-datastore = { path = "../aw-datastore", default-features = false } aw-models = { path = "../aw-models" } aw-transform = { path = "../aw-transform" } aw-query = { path = "../aw-query" } diff --git a/aw-server/src/main.rs b/aw-server/src/main.rs index 2cbf39e7..76092907 100644 --- a/aw-server/src/main.rs +++ b/aw-server/src/main.rs @@ -57,6 +57,13 @@ struct Opts { /// Don't import from aw-server-python if no aw-server-rust db found #[clap(long)] no_legacy_import: bool, + + /// Encryption key for the database (requires 'encryption' feature). + /// Can also be set via the AW_DB_PASSWORD environment variable. + /// WARNING: passing a password on the command line may expose it in process listings. + #[clap(long, env = "AW_DB_PASSWORD")] + #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] + db_password: Option, } #[rocket::main] @@ -141,10 +148,20 @@ async fn main() -> Result<(), rocket::Error> { device_id::get_device_id() }; + #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] + let datastore = if let Some(key) = opts.db_password { + info!("Using encrypted database (SQLCipher)"); + aw_datastore::Datastore::new_encrypted(db_path, key, legacy_import) + } else { + aw_datastore::Datastore::new(db_path, legacy_import) + }; + #[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))] + let datastore = aw_datastore::Datastore::new(db_path, legacy_import); + let server_state = endpoints::ServerState { // Even if legacy_import is set to true it is disabled on Android so // it will not happen there - datastore: Mutex::new(aw_datastore::Datastore::new(db_path, legacy_import)), + datastore: Mutex::new(datastore), asset_resolver: endpoints::AssetResolver::new(asset_path), device_id, }; From 67e9062cb662f91f4465c26e466762f3c42bcfc7 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 16 Apr 2026 08:56:03 +0000 Subject: [PATCH 2/5] fix(datastore): redact encryption key in Debug output and validate passphrase early Two security issues flagged by Greptile review: 1. DatastoreMethod derived Debug, which would expose the raw encryption key in log output, panic messages, or debug instrumentation. Replace derive with a manual Debug impl that redacts the key field as ''. 2. PRAGMA key always succeeds even with a wrong passphrase; the actual error only surfaces on the first real SQL query, producing an opaque worker-thread panic. Add an immediate PRAGMA user_version read to validate the key upfront with a clear error message. --- aw-datastore/src/lib.rs | 15 ++++++++++++++- aw-datastore/src/worker.rs | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/aw-datastore/src/lib.rs b/aw-datastore/src/lib.rs index 8de79423..13fd003a 100644 --- a/aw-datastore/src/lib.rs +++ b/aw-datastore/src/lib.rs @@ -1,6 +1,8 @@ #[macro_use] extern crate log; +use std::fmt; + #[macro_export] macro_rules! json_map { { $( $key:literal : $value:expr),* } => {{ @@ -22,7 +24,7 @@ mod worker; pub use self::datastore::DatastoreInstance; pub use self::worker::Datastore; -#[derive(Debug, Clone)] +#[derive(Clone)] pub enum DatastoreMethod { Memory(), File(String), @@ -32,6 +34,17 @@ pub enum DatastoreMethod { FileEncrypted(String, String), // (path, key) } +impl fmt::Debug for DatastoreMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DatastoreMethod::Memory() => write!(f, "Memory()"), + DatastoreMethod::File(p) => write!(f, "File({p:?})"), + #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] + DatastoreMethod::FileEncrypted(p, _) => write!(f, "FileEncrypted({p:?}, )"), + } + } +} + /* TODO: Implement this as a proper error */ #[derive(Debug, Clone)] pub enum DatastoreError { diff --git a/aw-datastore/src/worker.rs b/aw-datastore/src/worker.rs index 928892b3..c71fd738 100644 --- a/aw-datastore/src/worker.rs +++ b/aw-datastore/src/worker.rs @@ -130,6 +130,12 @@ impl DatastoreWorker { let conn = Connection::open(path).expect("Failed to create encrypted datastore"); conn.pragma_update(None, "key", key) .expect("Failed to set SQLCipher encryption key"); + // PRAGMA key always succeeds even with a wrong passphrase; the + // first real SQL query is what fails. Read user_version immediately + // to surface an incorrect key as a clear error rather than an + // opaque panic later. + conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) + .expect("Failed to open encrypted database: wrong passphrase or not an encrypted database"); info!("Opened encrypted database at {}", path); conn } From 8603ab3bfefd43c8a7fb26207c97a41a070f45dc Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 10:50:50 +0000 Subject: [PATCH 3/5] fix(server): warn when AW_DB_PASSWORD set but encryption not compiled in --- aw-server/src/main.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/aw-server/src/main.rs b/aw-server/src/main.rs index 76092907..e5fac637 100644 --- a/aw-server/src/main.rs +++ b/aw-server/src/main.rs @@ -156,6 +156,16 @@ async fn main() -> Result<(), rocket::Error> { aw_datastore::Datastore::new(db_path, legacy_import) }; #[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))] + { + if std::env::var("AW_DB_PASSWORD").is_ok() { + warn!( + "AW_DB_PASSWORD is set but this binary was not compiled with encryption support. \ + The database will NOT be encrypted. Rebuild with the 'encryption' or \ + 'encryption-vendored' feature to enable encryption." + ); + } + } + #[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))] let datastore = aw_datastore::Datastore::new(db_path, legacy_import); let server_state = endpoints::ServerState { From d2d797f0ae562e66cbc4f3197dea0c65552fd57b Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 11:18:28 +0000 Subject: [PATCH 4/5] fix(server): clear AW_DB_PASSWORD from env after reading; fix test force_commit --- aw-datastore/tests/datastore.rs | 1 + aw-server/src/main.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/aw-datastore/tests/datastore.rs b/aw-datastore/tests/datastore.rs index 20368637..cb6cdddd 100644 --- a/aw-datastore/tests/datastore.rs +++ b/aw-datastore/tests/datastore.rs @@ -557,6 +557,7 @@ mod datastore_tests { }; let inserted = ds.insert_events(&bucket.id, &[e]).unwrap(); assert_eq!(inserted.len(), 1); + ds.force_commit().unwrap(); ds.close(); } diff --git a/aw-server/src/main.rs b/aw-server/src/main.rs index e5fac637..72f00a3a 100644 --- a/aw-server/src/main.rs +++ b/aw-server/src/main.rs @@ -150,6 +150,8 @@ async fn main() -> Result<(), rocket::Error> { #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] let datastore = if let Some(key) = opts.db_password { + // Clear the env var immediately so child processes don't inherit the key. + std::env::remove_var("AW_DB_PASSWORD"); info!("Using encrypted database (SQLCipher)"); aw_datastore::Datastore::new_encrypted(db_path, key, legacy_import) } else { From 4597c2cc5a03eae4be2a6bc002d2b347aca866cf Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 14:38:35 +0000 Subject: [PATCH 5/5] fix(server): panic when AW_DB_PASSWORD set without encryption build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Erik's review on PR #584: silently warning is wrong because the user explicitly requested encryption — falling back to an unencrypted database violates that intent. Better to refuse to start. The user can either: - rebuild with 'encryption' or 'encryption-vendored' feature, or - unset AW_DB_PASSWORD to use an unencrypted database knowingly. --- aw-server/src/main.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aw-server/src/main.rs b/aw-server/src/main.rs index 72f00a3a..f0425d98 100644 --- a/aw-server/src/main.rs +++ b/aw-server/src/main.rs @@ -160,10 +160,11 @@ async fn main() -> Result<(), rocket::Error> { #[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))] { if std::env::var("AW_DB_PASSWORD").is_ok() { - warn!( + panic!( "AW_DB_PASSWORD is set but this binary was not compiled with encryption support. \ - The database will NOT be encrypted. Rebuild with the 'encryption' or \ - 'encryption-vendored' feature to enable encryption." + Refusing to start with an unencrypted database when the user requested encryption. \ + Rebuild with the 'encryption' or 'encryption-vendored' feature, or unset \ + AW_DB_PASSWORD to use an unencrypted database." ); } }