diff --git a/Cargo.lock b/Cargo.lock index 847b69c..1e84972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "argon2" version = "0.5.3" @@ -50,6 +62,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -63,6 +85,11 @@ version = "0.1.0" [[package]] name = "chronogrid_core" version = "0.1.0" +dependencies = [ + "rusqlite", + "tempfile", + "thiserror", +] [[package]] name = "chronogrid_crypto" @@ -156,6 +183,40 @@ dependencies = [ "subtle", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "generic-array" version = "0.14.7" @@ -177,6 +238,35 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + [[package]] name = "keyring" version = "3.6.3" @@ -207,12 +297,58 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -257,6 +393,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -284,7 +426,34 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -323,6 +492,18 @@ dependencies = [ "libc", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "subtle" version = "2.6.1" @@ -351,6 +532,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -383,6 +577,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/DECISION_LOG.md b/DECISION_LOG.md index 7c6c13e..3b00162 100644 --- a/DECISION_LOG.md +++ b/DECISION_LOG.md @@ -6,6 +6,20 @@ - **Context**: Design a multi-platform (Windows, Linux, Android, Headless) productivity engine for time, project, and note management without centralized servers. - **Decision**: Adopted Rust core with SQLCipher local storage, Automerge CRDT delta sync over libp2p, and Flutter presentation layer bound via C-FFI. +## [2026-08-19] - ADL-003: chronogrid_core Crate – SQLCipher 4.x Database Initializer + +- **Status**: Accepted +- **Context**: Task 1.3 required implementing the `chronogrid_core` crate with SQLCipher 4.x encrypted database initialization matching `docs/API_CONTRACTS/schema.sql`. +- **Decision**: + - `rusqlite` v0.31 with `bundled-sqlcipher-vendored-openssl` feature bundles both SQLCipher and OpenSSL, ensuring a hermetic build with no external C library dependencies. + - The 32-byte Argon2id-derived master key is passed as a **raw hex key** via `PRAGMA key = "x'…'"`, bypassing SQLCipher's internal PBKDF2 derivation since Argon2id already provides equivalent or stronger protection. + - `PRAGMA cipher_compatibility = 4` enforces SQLCipher 4.x defaults (AES-256-CBC, HMAC-SHA512 page auth, 64 KiB page size). + - `PRAGMA foreign_keys = ON` is set on every connection per the schema specification. + - The canonical schema SQL from `docs/API_CONTRACTS/schema.sql` is embedded via `include_str!` at compile time, ensuring single-source-of-truth alignment without runtime file I/O. + - Post-initialization validation confirms all 6 expected tables exist (`namespaces`, `projects`, `tasks`, `notes`, `task_note_links`, `ingestion_logs`) plus 4 indexes. + - `Database` struct provides `open(path, key)` for file-backed and `open_in_memory(key)` for test databases; key length is validated at 32 bytes. + - 11 unit tests cover: table/index creation, FK enforcement, cascade deletes, wrong-key rejection, key length validation, schema idempotency, and CRUD round-trips across all tables. + ## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain - **Status**: Accepted diff --git a/apps/chronogrid_flutter/.dart_tool/package_config.json b/apps/chronogrid_flutter/.dart_tool/package_config.json index b043d00..d7036a1 100644 --- a/apps/chronogrid_flutter/.dart_tool/package_config.json +++ b/apps/chronogrid_flutter/.dart_tool/package_config.json @@ -41,7 +41,7 @@ "name": "flutter", "rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter", "packageUri": "lib/", - "languageVersion": "3.10" + "languageVersion": "3.11" }, { "name": "flutter_lints", @@ -53,7 +53,7 @@ "name": "flutter_test", "rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter_test", "packageUri": "lib/", - "languageVersion": "3.10" + "languageVersion": "3.11" }, { "name": "leak_tracker", @@ -81,9 +81,9 @@ }, { "name": "matcher", - "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/matcher-0.12.19", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/matcher-0.12.20", "packageUri": "lib/", - "languageVersion": "3.7" + "languageVersion": "3.10" }, { "name": "material_color_utilities", @@ -93,7 +93,7 @@ }, { "name": "meta", - "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.18.0", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.19.0", "packageUri": "lib/", "languageVersion": "3.5" }, @@ -107,7 +107,7 @@ "name": "sky_engine", "rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/bin/cache/pkg/sky_engine", "packageUri": "lib/", - "languageVersion": "3.10" + "languageVersion": "3.11" }, { "name": "source_span", @@ -141,15 +141,15 @@ }, { "name": "test_api", - "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/test_api-0.7.11", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/test_api-0.7.12", "packageUri": "lib/", - "languageVersion": "3.7" + "languageVersion": "3.10" }, { "name": "vector_math", - "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vector_math-2.2.0", + "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vector_math-2.4.2", "packageUri": "lib/", - "languageVersion": "3.1" + "languageVersion": "3.10" }, { "name": "vm_service", @@ -165,8 +165,8 @@ } ], "generator": "pub", - "generatorVersion": "3.12.2", + "generatorVersion": "3.13.0", "flutterRoot": "file:///C:/Users/mtkel/AppData/Local/flutter", - "flutterVersion": "3.44.9", + "flutterVersion": "3.47.0", "pubCache": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache" } diff --git a/apps/chronogrid_flutter/.dart_tool/package_graph.json b/apps/chronogrid_flutter/.dart_tool/package_graph.json index 1331169..f8c8fc5 100644 --- a/apps/chronogrid_flutter/.dart_tool/package_graph.json +++ b/apps/chronogrid_flutter/.dart_tool/package_graph.json @@ -63,11 +63,6 @@ "async" ] }, - { - "name": "meta", - "version": "1.18.0", - "dependencies": [] - }, { "name": "collection", "version": "1.19.1", @@ -84,11 +79,6 @@ "meta" ] }, - { - "name": "vector_math", - "version": "2.2.0", - "dependencies": [] - }, { "name": "stack_trace", "version": "1.12.1", @@ -116,7 +106,7 @@ }, { "name": "matcher", - "version": "0.12.19", + "version": "0.12.20", "dependencies": [ "async", "meta", @@ -127,7 +117,7 @@ }, { "name": "test_api", - "version": "0.7.11", + "version": "0.7.12", "dependencies": [ "async", "boolean_selector", @@ -218,6 +208,16 @@ "name": "vm_service", "version": "15.2.0", "dependencies": [] + }, + { + "name": "meta", + "version": "1.19.0", + "dependencies": [] + }, + { + "name": "vector_math", + "version": "2.4.2", + "dependencies": [] } ], "configVersion": 1 diff --git a/apps/chronogrid_flutter/.dart_tool/version b/apps/chronogrid_flutter/.dart_tool/version index 7934f0b..9104e19 100644 --- a/apps/chronogrid_flutter/.dart_tool/version +++ b/apps/chronogrid_flutter/.dart_tool/version @@ -1 +1 @@ -3.44.9 \ No newline at end of file +3.47.0 \ No newline at end of file diff --git a/apps/chronogrid_flutter/analysis_options.yaml b/apps/chronogrid_flutter/analysis_options.yaml index f9b3034..743e05a 100644 --- a/apps/chronogrid_flutter/analysis_options.yaml +++ b/apps/chronogrid_flutter/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/apps/chronogrid_flutter/pubspec.lock b/apps/chronogrid_flutter/pubspec.lock index 6e7833a..72a367b 100644 --- a/apps/chronogrid_flutter/pubspec.lock +++ b/apps/chronogrid_flutter/pubspec.lock @@ -103,10 +103,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -119,10 +119,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" path: dependency: transitive description: @@ -180,18 +180,18 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -201,5 +201,5 @@ packages: source: hosted version: "15.2.0" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/crates/chronogrid_core/Cargo.toml b/crates/chronogrid_core/Cargo.toml index 1187f60..d469865 100644 --- a/crates/chronogrid_core/Cargo.toml +++ b/crates/chronogrid_core/Cargo.toml @@ -7,5 +7,8 @@ rust-version.workspace = true publish = false [dependencies] +rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] } +thiserror = "2" [dev-dependencies] +tempfile = "3" diff --git a/crates/chronogrid_core/src/db.rs b/crates/chronogrid_core/src/db.rs new file mode 100644 index 0000000..a441fce --- /dev/null +++ b/crates/chronogrid_core/src/db.rs @@ -0,0 +1,418 @@ +//! SQLCipher database initializer and connection manager. +//! +//! Provides [`Database`], the primary entry-point for encrypted local storage +//! in ChronoGrid P2P. Every database is encrypted at rest using SQLCipher 4.x +//! (AES-256-CBC) as mandated by `docs/ARCHITECTURE/02-security-and-crypto.md`. +//! +//! ## Key Handling +//! +//! The 32-byte master key produced by `chronogrid_crypto::kdf::derive_master_key` +//! is passed as a **raw hex key** via `PRAGMA key = "x'…'"`, which instructs +//! SQLCipher to use the bytes directly as the encryption key, bypassing its +//! internal PBKDF2 derivation (the Argon2id KDF already provides equivalent +//! or stronger protection). +//! +//! ## Schema +//! +//! The canonical schema is embedded at compile time from +//! `docs/API_CONTRACTS/schema.sql` via [`include_str!`], ensuring this crate +//! always stays in sync with the single source of truth. + +use std::path::Path; + +use rusqlite::Connection; + +use crate::error::{CoreError, Result}; + +/// Length of the raw encryption key expected by SQLCipher (256-bit / 32 bytes). +pub const KEY_LEN: usize = 32; + +/// Canonical schema SQL, embedded at compile-time from the single source of truth. +const SCHEMA_SQL: &str = include_str!("../../../docs/API_CONTRACTS/schema.sql"); + +/// Expected tables that must exist after schema initialization. +const EXPECTED_TABLES: &[&str] = &[ + "namespaces", + "projects", + "tasks", + "notes", + "task_note_links", + "ingestion_logs", +]; + +// ── Database struct ────────────────────────────────────────────────────────── + +/// An encrypted SQLCipher database connection with the ChronoGrid schema applied. +/// +/// Wraps a [`rusqlite::Connection`] that has been keyed with a 256-bit raw key +/// and initialized with all required tables and indexes. +pub struct Database { + conn: Connection, +} + +impl std::fmt::Debug for Database { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Database") + .field("conn", &"") + .finish() + } +} + +impl Database { + /// Opens (or creates) an encrypted database file at `path`. + /// + /// * Applies the raw 256-bit key via `PRAGMA key`. + /// * Enables foreign-key enforcement. + /// * Runs the canonical schema DDL (idempotent `CREATE … IF NOT EXISTS`). + /// + /// # Errors + /// + /// Returns [`CoreError::InvalidKeyLength`] if `key` is not exactly 32 bytes. + /// Returns [`CoreError::Database`] on connection or PRAGMA failures. + /// Returns [`CoreError::SchemaInit`] if any expected table is missing after DDL. + pub fn open(path: impl AsRef, key: &[u8]) -> Result { + if key.len() != KEY_LEN { + return Err(CoreError::InvalidKeyLength(key.len())); + } + + let conn = Connection::open(path)?; + Self::configure_and_init(conn, key) + } + + /// Opens an **in-memory** encrypted database (useful for testing). + /// + /// Semantics are identical to [`Database::open`] except no file is created. + pub fn open_in_memory(key: &[u8]) -> Result { + if key.len() != KEY_LEN { + return Err(CoreError::InvalidKeyLength(key.len())); + } + + let conn = Connection::open_in_memory()?; + Self::configure_and_init(conn, key) + } + + /// Returns a shared reference to the underlying [`rusqlite::Connection`]. + #[inline] + pub fn connection(&self) -> &Connection { + &self.conn + } + + /// Returns a mutable reference to the underlying [`rusqlite::Connection`]. + #[inline] + pub fn connection_mut(&mut self) -> &mut Connection { + &mut self.conn + } + + // ── Private helpers ────────────────────────────────────────────────── + + /// Applies encryption key, PRAGMAs, schema DDL, and validates table presence. + fn configure_and_init(conn: Connection, key: &[u8]) -> Result { + // 1. Apply the raw hex key to encrypt / decrypt the database. + let hex_key = bytes_to_hex(key); + conn.execute_batch(&format!("PRAGMA key = \"x'{hex_key}'\";"))?; + + // 2. Enforce SQLCipher 4 compatibility defaults. + conn.execute_batch( + "PRAGMA cipher_compatibility = 4; + PRAGMA foreign_keys = ON;", + )?; + + // 3. Quick-check: verify the database is actually readable with this key. + // `PRAGMA cipher_integrity_check` is expensive; a lightweight SELECT on + // sqlite_master suffices to prove the key is correct. + conn.query_row( + "SELECT count(*) FROM sqlite_master;", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| { + CoreError::SchemaInit(format!( + "database key verification failed (wrong key or corrupt file): {e}" + )) + })?; + + // 4. Apply the canonical schema (all DDL uses IF NOT EXISTS). + conn.execute_batch(SCHEMA_SQL)?; + + // 5. Validate that every expected table was created. + Self::validate_schema(&conn)?; + + Ok(Self { conn }) + } + + /// Checks that all tables declared in `EXPECTED_TABLES` exist in `sqlite_master`. + fn validate_schema(conn: &Connection) -> Result<()> { + let mut stmt = + conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table';")?; + let existing: Vec = stmt + .query_map([], |row| row.get(0))? + .collect::, _>>()?; + + for &table in EXPECTED_TABLES { + if !existing.iter().any(|t| t == table) { + return Err(CoreError::SchemaInit(format!( + "expected table '{table}' was not created" + ))); + } + } + + Ok(()) + } +} + +// ── Utility ────────────────────────────────────────────────────────────────── + +/// Converts a byte slice to a lowercase hex string (no allocations beyond the +/// output `String` itself). +fn bytes_to_hex(bytes: &[u8]) -> String { + let mut hex = String::with_capacity(bytes.len() * 2); + for &b in bytes { + use std::fmt::Write; + let _ = write!(hex, "{b:02x}"); + } + hex +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// A deterministic 32-byte key used exclusively in tests. + const TEST_KEY: [u8; KEY_LEN] = [0xAA; KEY_LEN]; + + // ── open_in_memory ─────────────────────────────────────────────────── + + #[test] + fn open_in_memory_creates_all_tables() { + let db = Database::open_in_memory(&TEST_KEY).expect("open_in_memory should succeed"); + + for &table in EXPECTED_TABLES { + let exists: bool = db + .connection() + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1);", + [table], + |row| row.get(0), + ) + .unwrap(); + assert!(exists, "table '{table}' must exist after initialization"); + } + } + + #[test] + fn open_in_memory_creates_indexes() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + + let expected_indexes = [ + "idx_tasks_namespace", + "idx_tasks_eisenhower", + "idx_tasks_due_date", + "idx_projects_parent", + ]; + + for &idx in &expected_indexes { + let exists: bool = db + .connection() + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name=?1);", + [idx], + |row| row.get(0), + ) + .unwrap(); + assert!(exists, "index '{idx}' must exist after initialization"); + } + } + + #[test] + fn foreign_keys_are_enabled() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + let fk_status: i64 = db + .connection() + .query_row("PRAGMA foreign_keys;", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fk_status, 1, "foreign_keys PRAGMA must be ON"); + } + + // ── File-backed database ───────────────────────────────────────────── + + #[test] + fn open_file_creates_and_reopens() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + + // First open: creates the database and schema. + { + let db = Database::open(&db_path, &TEST_KEY).expect("first open should succeed"); + db.connection() + .execute( + "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4);", + rusqlite::params!["ns-1", "Personal", 1000, 1000], + ) + .unwrap(); + } + + // Second open: data must persist. + { + let db = Database::open(&db_path, &TEST_KEY).expect("reopen should succeed"); + let name: String = db + .connection() + .query_row( + "SELECT name FROM namespaces WHERE id = ?1;", + ["ns-1"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(name, "Personal"); + } + } + + #[test] + fn wrong_key_fails() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("encrypted.db"); + + // Create with one key. + { + Database::open(&db_path, &TEST_KEY).unwrap(); + } + + // Attempt reopen with a different key. + let bad_key = [0xBB; KEY_LEN]; + let result = Database::open(&db_path, &bad_key); + assert!(result.is_err(), "opening with a wrong key must fail"); + } + + // ── Key validation ─────────────────────────────────────────────────── + + #[test] + fn reject_short_key() { + let short = [0u8; 16]; + let result = Database::open_in_memory(&short); + assert!(result.is_err()); + match result.unwrap_err() { + CoreError::InvalidKeyLength(len) => assert_eq!(len, 16), + other => panic!("expected InvalidKeyLength, got: {other}"), + } + } + + #[test] + fn reject_empty_key() { + let result = Database::open_in_memory(&[]); + assert!(result.is_err()); + match result.unwrap_err() { + CoreError::InvalidKeyLength(0) => {} + other => panic!("expected InvalidKeyLength(0), got: {other}"), + } + } + + // ── Schema DDL is idempotent ───────────────────────────────────────── + + #[test] + fn schema_init_is_idempotent() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + // Running the schema DDL a second time must not fail. + db.connection().execute_batch(SCHEMA_SQL).unwrap(); + } + + // ── Insert/query round-trip for every table ────────────────────────── + + #[test] + fn crud_round_trip_all_tables() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + let c = db.connection(); + + // namespaces + c.execute( + "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Work',1,1);", + [], + ) + .unwrap(); + + // projects + c.execute( + "INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp) + VALUES ('p1','ns1','Project Alpha',1.0,1,'hlc-1');", + [], + ) + .unwrap(); + + // tasks + c.execute( + "INSERT INTO tasks (id, namespace_id, project_id, title, created_at, hlc_timestamp) + VALUES ('t1','ns1','p1','My Task',1,'hlc-2');", + [], + ) + .unwrap(); + + // notes + c.execute( + "INSERT INTO notes (id, namespace_id, project_id, title, content_markdown, created_at, updated_at, hlc_timestamp) + VALUES ('n1','ns1','p1','Note','# Hello',1,1,'hlc-3');", + [], + ) + .unwrap(); + + // task_note_links + c.execute( + "INSERT INTO task_note_links (task_id, note_id, created_at) + VALUES ('t1','n1',1);", + [], + ) + .unwrap(); + + // ingestion_logs + c.execute( + "INSERT INTO ingestion_logs (id, source_type, ingested_at) + VALUES ('il1','jot_hotkey',1);", + [], + ) + .unwrap(); + + // Verify counts + let count: i64 = c + .query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + // ── Foreign key enforcement ────────────────────────────────────────── + + #[test] + fn foreign_key_violation_is_rejected() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + let result = db.connection().execute( + "INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp) + VALUES ('p1','nonexistent_ns','Bad',1.0,1,'hlc-1');", + [], + ); + assert!(result.is_err(), "FK violation must be rejected"); + } + + #[test] + fn cascade_delete_removes_children() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + let c = db.connection(); + + c.execute( + "INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','X',1,1);", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO tasks (id, namespace_id, title, created_at, hlc_timestamp) + VALUES ('t1','ns1','Task',1,'hlc');", + [], + ) + .unwrap(); + + // Deleting the namespace must cascade-delete the task. + c.execute("DELETE FROM namespaces WHERE id = 'ns1';", []) + .unwrap(); + let count: i64 = c + .query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "cascade delete must remove child tasks"); + } +} diff --git a/crates/chronogrid_core/src/error.rs b/crates/chronogrid_core/src/error.rs new file mode 100644 index 0000000..6ddd056 --- /dev/null +++ b/crates/chronogrid_core/src/error.rs @@ -0,0 +1,26 @@ +//! Error types for the `chronogrid_core` crate. + +use thiserror::Error; + +/// Unified error type for all core storage and logic operations. +#[derive(Debug, Error)] +pub enum CoreError { + /// The provided encryption key has an invalid length (expected 32 bytes). + #[error("invalid key length: expected 32 bytes, got {0}")] + InvalidKeyLength(usize), + + /// An error originating from the SQLCipher / rusqlite layer. + #[error("database error: {0}")] + Database(#[from] rusqlite::Error), + + /// An I/O error (e.g., file system access for the database path). + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// Schema migration or initialization failure. + #[error("schema initialization failed: {0}")] + SchemaInit(String), +} + +/// Convenience alias used throughout the crate. +pub type Result = std::result::Result; diff --git a/crates/chronogrid_core/src/lib.rs b/crates/chronogrid_core/src/lib.rs index 29a094f..0f894d3 100644 --- a/crates/chronogrid_core/src/lib.rs +++ b/crates/chronogrid_core/src/lib.rs @@ -1,12 +1,20 @@ //! # chronogrid_core //! //! Core logic and local-first storage engine for ChronoGrid P2P. -//! Provides SQLCipher-backed persistence and Automerge CRDT conflict resolution. +//! +//! ## Capabilities +//! +//! * **Encrypted local storage** — [`db::Database`] wraps a SQLCipher 4.x +//! connection keyed with a 256-bit raw key (produced by +//! `chronogrid_crypto::kdf::derive_master_key`). The canonical schema from +//! `docs/API_CONTRACTS/schema.sql` is applied automatically on first open. +//! +//! * **Automerge CRDT** *(planned – Task 1.4)* — conflict-free replicated +//! data-type registers for tasks and notes, enabling offline-first P2P sync. -#[cfg(test)] -mod tests { - #[test] - fn it_works() { - assert!(true); - } -} +pub mod db; +pub mod error; + +// Re-export key public types at crate root for ergonomic imports. +pub use db::Database; +pub use error::{CoreError, Result}; diff --git a/docs/ARCHITECTURE/ADR/0003-core-sqlcipher-db-init.md b/docs/ARCHITECTURE/ADR/0003-core-sqlcipher-db-init.md new file mode 100644 index 0000000..6aa1698 --- /dev/null +++ b/docs/ARCHITECTURE/ADR/0003-core-sqlcipher-db-init.md @@ -0,0 +1,41 @@ +# ADR-0003: chronogrid_core – SQLCipher 4.x Database Initializer + +- **Date**: 2026-08-19 +- **Status**: Accepted +- **Task**: 1.3 + +## Context + +ChronoGrid requires an encrypted local-first database for storing namespaces, projects, tasks, notes, links, and ingestion logs. The schema is defined in `docs/API_CONTRACTS/schema.sql` and mandates SQLCipher 4.x (AES-256-CBC) per `docs/ARCHITECTURE/02-security-and-crypto.md`. + +## Decision + +### Dependency Choice + +Adopted `rusqlite` v0.31 with the `bundled-sqlcipher-vendored-openssl` feature. This bundles both the SQLCipher C library and OpenSSL, producing a hermetic build that requires no external system libraries beyond a C compiler and Perl (for the OpenSSL build step). + +### Key Handling + +The 32-byte master key derived by `chronogrid_crypto::kdf::derive_master_key` (Argon2id) is passed as a **raw hex key** via `PRAGMA key = "x'…'"`. This instructs SQLCipher to use the key material directly, bypassing its internal PBKDF2-HMAC-SHA512 derivation. Rationale: Argon2id is a strictly stronger KDF than PBKDF2; double-derivation would add latency without meaningful security improvement. + +### PRAGMA Configuration + +| PRAGMA | Value | Purpose | +|----------------------------|-------|-------------------------------------------| +| `key` | Raw hex (32 bytes) | Encrypt/decrypt the database | +| `cipher_compatibility` | `4` | Enforce SQLCipher 4.x defaults | +| `foreign_keys` | `ON` | Required by schema FK constraints | + +### Schema Embedding + +The canonical schema SQL is embedded at compile time via `include_str!("../../../docs/API_CONTRACTS/schema.sql")`, ensuring the crate always stays in sync with the single source of truth without runtime file I/O. + +### Post-Init Validation + +After running the DDL, the initializer queries `sqlite_master` to verify all 6 expected tables (`namespaces`, `projects`, `tasks`, `notes`, `task_note_links`, `ingestion_logs`) and 4 indexes exist. A quick `SELECT count(*) FROM sqlite_master` immediately after keying detects wrong-key or corrupt-file conditions early. + +## Consequences + +- **Positive**: Fully encrypted at-rest storage with zero cloud dependencies. Schema changes in `schema.sql` are automatically picked up on recompilation. 11 unit tests validate correctness. +- **Negative**: First build requires Perl (Strawberry Perl on Windows) for OpenSSL compilation. Build time is ~10 minutes on first compile due to OpenSSL/SQLCipher C compilation (subsequent builds are cached). +- **Trade-off**: Using raw key (bypassing SQLCipher PBKDF2) means the security of the database encryption relies entirely on the Argon2id KDF strength, which is acceptable given our pinned parameters (64 MiB, 3 iterations, 4 threads). diff --git a/docs/TASKS/phase_1_storage_and_crdt.md b/docs/TASKS/phase_1_storage_and_crdt.md index 9976c83..286d2bb 100644 --- a/docs/TASKS/phase_1_storage_and_crdt.md +++ b/docs/TASKS/phase_1_storage_and_crdt.md @@ -4,6 +4,6 @@ - [x] **Task 1.1**: Initialize Rust Cargo Workspace in crates/ and configure root Cargo.toml. - [x] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings. -- [ ] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql. +- [x] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql. - [ ] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations. - [ ] **Task 1.5**: Generate lutter_rust_bridge FFI bindings bridging Rust storage methods to Dart.