Skip to content

feat(datastore): add opt-in SQLCipher encryption support#584

Open
TimeToBuildBob wants to merge 5 commits intoActivityWatch:masterfrom
TimeToBuildBob:feat/sqlcipher-encryption
Open

feat(datastore): add opt-in SQLCipher encryption support#584
TimeToBuildBob wants to merge 5 commits intoActivityWatch:masterfrom
TimeToBuildBob:feat/sqlcipher-encryption

Conversation

@TimeToBuildBob
Copy link
Copy Markdown
Contributor

Summary

Implements encrypted database storage at rest using SQLCipher (via the rusqlite/bundled-sqlcipher feature). 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 key call.

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-vendored

Feature flags

Feature SQLite backend OpenSSL
bundled (default) bundled plain SQLite
encryption bundled SQLCipher system OpenSSL required
encryption-vendored bundled SQLCipher vendored via openssl-sys

bundled and encryption* are mutually exclusive (libsqlite3-sys enforces this). Use --no-default-features when enabling encryption.

API changes

aw-datastore:

  • New DatastoreMethod::FileEncrypted(path, key) variant (cfg-gated)
  • New Datastore::new_encrypted(dbpath, key, legacy_import) constructor

aw-server:

  • New --db-password <KEY> CLI flag (also readable from AW_DB_PASSWORD env var)

Usage

# Start server with encryption (key via flag)
aw-server --db-password "my-secret-passphrase"

# Start server with encryption (key via env var — preferred for scripts)
export AW_DB_PASSWORD="my-secret-passphrase"
aw-server

⚠️ Warning: The key is stored in-memory for the lifetime of the process. Passing it on the CLI exposes it in ps output. Prefer AW_DB_PASSWORD in production.

Changes

  • aw-datastore/Cargo.toml — restructure rusqlite features; add encryption and encryption-vendored
  • aw-datastore/src/lib.rs — add DatastoreMethod::FileEncrypted variant
  • aw-datastore/src/worker.rs — open encrypted connection with PRAGMA key; add Datastore::new_encrypted()
  • aw-server/Cargo.toml — forward encryption features from aw-datastore; change aw-datastore dep to default-features = false
  • aw-server/src/main.rs--db-password / AW_DB_PASSWORD option; select new_encrypted() when key is present
  • aw-datastore/tests/datastore.rstest_encrypted_datastore_roundtrip: creates encrypted DB, writes events, closes, reopens with same key, verifies data is intact

Test plan

  • Default build (cargo check) passes with no errors
  • Encryption build: cargo test --no-default-features --features encryption -- test_encrypted_datastore_roundtrip (requires OpenSSL)
  • Manual smoke test: start aw-server --db-password foo, send heartbeats, stop, verify file aw-server-rust.db shows "SQLite database" is no longer readable as plain SQLite

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
Copy link
Copy Markdown

codecov Bot commented Apr 16, 2026

Codecov Report

❌ Patch coverage is 27.27273% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.85%. Comparing base (656f3c9) to head (4597c2c).
⚠️ Report is 43 commits behind head on master.

Files with missing lines Patch % Lines
aw-datastore/src/lib.rs 0.00% 4 Missing ⚠️
aw-server/src/main.rs 0.00% 4 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 16, 2026

Greptile Summary

This PR adds opt-in SQLCipher encryption via two new Cargo feature flags (encryption, encryption-vendored), a DatastoreMethod::FileEncrypted variant, and a --db-password/AW_DB_PASSWORD server option. Previously-raised P0/P1 concerns — key exposure via Debug, opaque wrong-key panics, and AW_DB_PASSWORD being silently ignored in non-encryption builds — have all been addressed in follow-up commits. The only remaining gap is test coverage: the roundtrip test verifies the happy path but does not assert that a wrong passphrase is rejected, nor that the on-disk file is unreadable without a key.

Confidence Score: 4/5

Safe 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

Filename Overview
aw-datastore/src/lib.rs Adds cfg-gated FileEncrypted(path, key) variant; custom Debug impl redacts the key — both previously-flagged concerns are addressed
aw-datastore/src/worker.rs Encrypted connection opened correctly; PRAGMA key followed by immediate PRAGMA user_version read for early wrong-key detection; new_encrypted constructor added
aw-server/src/main.rs Adds --db-password/AW_DB_PASSWORD flag (cfg-gated); env var cleared immediately after reading; issues a warn! when AW_DB_PASSWORD is set in a non-encryption build
aw-datastore/tests/datastore.rs Roundtrip test (correct key, data intact) added; missing negative tests for wrong-key rejection and plaintext-readability check
aw-datastore/Cargo.toml Feature flags cleanly structured: bundled (default), encryption (bundled-sqlcipher), encryption-vendored (bundled-sqlcipher-vendored-openssl)
aw-server/Cargo.toml Forwarding encryption features to aw-datastore; default-features = false on aw-datastore dep correctly prevents double-linking plain SQLite and SQLCipher

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (4): Last reviewed commit: "fix(server): clear AW_DB_PASSWORD from e..." | Re-trigger Greptile

Comment on lines +128 to +135
#[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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Comment thread aw-datastore/src/lib.rs
/// 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

Addressed the two P1 findings from Greptile in 67e9062:

  1. Debug key exposure — replaced #[derive(Debug)] on DatastoreMethod with a manual Debug impl that renders the key as <redacted>.
  2. Silent wrong-key panic — added PRAGMA user_version read immediately after PRAGMA key to validate the passphrase upfront, with a clear error message.

The P2 (key zeroing via zeroize) is acknowledged and can be a follow-up.

1 similar comment
@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

Addressed the two P1 findings from Greptile in 67e9062:

  1. Debug key exposure — replaced #[derive(Debug)] on DatastoreMethod with a manual Debug impl that renders the key as <redacted>.
  2. Silent wrong-key panic — added PRAGMA user_version read immediately after PRAGMA key to validate the passphrase upfront, with a clear error message.

The P2 (key zeroing via zeroize) is acknowledged and can be a follow-up.

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-server/src/main.rs
Comment on lines +158 to +159
#[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))]
let datastore = aw_datastore::Datastore::new(db_path, legacy_import);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like having it set with encryption not built should trigger a panic...

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
#[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

@ErikBjare
Copy link
Copy Markdown
Member

@TimeToBuildBob See #584 (comment)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Encryption at rest for database files

2 participants