feat(datastore): add opt-in SQLCipher encryption support#584
feat(datastore): add opt-in SQLCipher encryption support#584TimeToBuildBob wants to merge 5 commits intoActivityWatch:masterfrom
Conversation
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 ActivityWatch#435
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #584 +/- ##
==========================================
- Coverage 70.81% 67.85% -2.97%
==========================================
Files 51 54 +3
Lines 2916 3201 +285
==========================================
+ Hits 2065 2172 +107
- Misses 851 1029 +178 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Greptile SummaryThis PR adds opt-in SQLCipher encryption via two new Cargo feature flags ( Confidence Score: 4/5Safe to merge for functionality; one P2 test-coverage gap could hide a silent encryption regression in CI All previously-flagged P0/P1 issues are resolved. Remaining finding is P2 (missing wrong-key and plaintext-readability tests). Score is 4 rather than 5 because without a negative test, CI cannot detect a build misconfiguration where encryption is silently inactive — the primary safety guarantee of this feature. aw-datastore/tests/datastore.rs — needs a wrong-key rejection test and an at-rest encryption verification Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User
participant M as main.rs
participant DW as DatastoreWorker thread
participant SC as SQLCipher rusqlite
U->>M: --db-password flag or AW_DB_PASSWORD
M->>M: parse CLI opts
M->>M: clear AW_DB_PASSWORD from environment
M->>DW: spawn worker via new_encrypted
DW->>SC: Connection open path
DW->>SC: pragma_update key with passphrase
SC-->>DW: always succeeds no validation
DW->>SC: pragma_query_value user_version
alt correct passphrase or new file
SC-->>DW: version integer returned
DW->>DW: schema migration runs
loop normal operation
M->>DW: datastore commands
DW-->>M: responses
end
else wrong passphrase or unencrypted file
SC-->>DW: error file is not a database
DW->>DW: worker thread panics with clear message
end
Reviews (4): Last reviewed commit: "fix(server): clear AW_DB_PASSWORD from e..." | Re-trigger Greptile |
| #[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 | ||
| } |
There was a problem hiding this comment.
Wrong-key failure is a silent worker-thread panic
pragma_update for key succeeds unconditionally — SQLCipher does not validate the passphrase at that point. The first actual read (DatastoreInstance::new(&conn, true).unwrap()) will panic inside the detached worker thread when the key is wrong. The main thread's next request then sees a dead sender and panics separately, with no indication that the cause is a bad passphrase.
Add an explicit verification step immediately after setting the key so the failure is diagnosed clearly:
conn.pragma_update(None, "key", key)
.expect("Failed to set SQLCipher encryption key");
// Verify the key is correct — PRAGMA user_version is a simple read that
// will return an error if the key cannot decrypt the database header.
conn.pragma_query_value(None, "user_version", |r| r.get::<_, i32>(0))
.expect("SQLCipher key is incorrect or database is not encrypted");This produces a human-readable panic message instead of a cryptic SQLite error buried inside a thread.
There was a problem hiding this comment.
Fixed in 67e9062 — added PRAGMA user_version read immediately after PRAGMA key with a clear error message: "Failed to open encrypted database: wrong passphrase or not an encrypted database".
| /// 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) |
There was a problem hiding this comment.
Key material not zeroed on drop
The encryption key is held as a plain String inside FileEncrypted. When the DatastoreMethod value is dropped (after the worker exits), the key bytes remain in process memory until overwritten by the allocator. For an encryption feature this is worth addressing with zeroize::Zeroizing<String>:
// In Cargo.toml (aw-datastore)
zeroize = { version = "1", optional = true }
encryption = ["rusqlite/bundled-sqlcipher", "zeroize"]
encryption-vendored = ["rusqlite/bundled-sqlcipher-vendored-openssl", "zeroize"]
// In lib.rs / worker.rs
#[cfg(any(feature = "encryption", feature = "encryption-vendored"))]
use zeroize::Zeroizing;
FileEncrypted(String, Zeroizing<String>)This ensures the passphrase is securely wiped when the enum is dropped.
There was a problem hiding this comment.
Acknowledged — I'll leave the zeroize improvement as a follow-up. The key is stored in DatastoreMethod only until the worker thread starts and extracts it at the top of work_loop, so the exposure window is short. But you're right that Zeroizing<String> would be the clean solution for a hardened build. I'll track it separately rather than block this PR on it.
…ssphrase 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 '<redacted>'. 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.
|
Addressed the two P1 findings from Greptile in 67e9062:
The P2 (key zeroing via |
1 similar comment
|
Addressed the two P1 findings from Greptile in 67e9062:
The P2 (key zeroing via |
|
@greptileai review |
| #[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))] | ||
| let datastore = aw_datastore::Datastore::new(db_path, legacy_import); |
There was a problem hiding this comment.
AW_DB_PASSWORD silently ignored in non-encryption builds
When the binary is built without the encryption feature (the default), the db_password field is cfg-gated out of Opts, so clap never registers the AW_DB_PASSWORD env var. A user who sets AW_DB_PASSWORD against a stock binary receives no error, no warning, and no indication that their database is being stored in plaintext — directly contradicting their security expectation.
Add a runtime guard in the non-encryption path:
#[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 (missing `encryption` feature). \
The database will NOT be encrypted and the password is ignored."
);
}
aw_datastore::Datastore::new(db_path, legacy_import)
}There was a problem hiding this comment.
I feel like having it set with encryption not built should trigger a panic...
There was a problem hiding this comment.
That's the right call for a security-critical misconfiguration. A warning can be missed; a panic makes the failure impossible to ignore. Here's the updated suggestion:
| #[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))] | |
| let datastore = 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() { | |
| panic!( | |
| "AW_DB_PASSWORD is set but this binary was not compiled with \ | |
| encryption support (missing `encryption` or `encryption-vendored` feature). \ | |
| Refusing to start to avoid storing data in plaintext against your expectations. \ | |
| Rebuild with `--features encryption` or unset AW_DB_PASSWORD." | |
| ); | |
| } | |
| aw_datastore::Datastore::new(db_path, legacy_import) | |
| } |
The panic message explains both why it's failing and how to fix it, which is the important part — users who hit this will know exactly what to do.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules and .greptile/config.
There was a problem hiding this comment.
Good catch — addressed in 4597c2c. Changed warn! to panic! so the binary refuses to start when AW_DB_PASSWORD is set but encryption support was not compiled in. The user can either rebuild with the encryption/encryption-vendored feature or unset the env var to use an unencrypted DB knowingly.
|
@greptileai review |
|
@greptileai review |
Per Erik's review on PR ActivityWatch#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.
Summary
Implements encrypted database storage at rest using SQLCipher (via the
rusqlite/bundled-sqlcipherfeature). Data remains fully encrypted on disk; decryption only happens in-process after the key is supplied.Closes #435
Design
SQLCipher is a drop-in replacement for SQLite that transparently encrypts every database page. All existing schema/migration code works unchanged — only the connection-open step gains an extra
PRAGMA keycall.This is implemented as a Cargo feature flag so the default binary stays unchanged. Users who want encryption build with:
cargo build --no-default-features --features encryption # or, for a fully self-contained binary that also vendors OpenSSL: cargo build --no-default-features --features encryption-vendoredFeature flags
bundled(default)encryptionencryption-vendoredbundledandencryption*are mutually exclusive (libsqlite3-sys enforces this). Use--no-default-featureswhen enabling encryption.API changes
aw-datastore:
DatastoreMethod::FileEncrypted(path, key)variant (cfg-gated)Datastore::new_encrypted(dbpath, key, legacy_import)constructoraw-server:
--db-password <KEY>CLI flag (also readable fromAW_DB_PASSWORDenv var)Usage
Changes
aw-datastore/Cargo.toml— restructure rusqlite features; addencryptionandencryption-vendoredaw-datastore/src/lib.rs— addDatastoreMethod::FileEncryptedvariantaw-datastore/src/worker.rs— open encrypted connection withPRAGMA key; addDatastore::new_encrypted()aw-server/Cargo.toml— forward encryption features from aw-datastore; changeaw-datastoredep todefault-features = falseaw-server/src/main.rs—--db-password/AW_DB_PASSWORDoption; selectnew_encrypted()when key is presentaw-datastore/tests/datastore.rs—test_encrypted_datastore_roundtrip: creates encrypted DB, writes events, closes, reopens with same key, verifies data is intactTest plan
cargo check) passes with no errorscargo test --no-default-features --features encryption -- test_encrypted_datastore_roundtrip(requires OpenSSL)aw-server --db-password foo, send heartbeats, stop, verifyfile aw-server-rust.dbshows "SQLite database" is no longer readable as plain SQLite