diff --git a/journal-master/journal/Journal.Core/Services/IJournalDatabaseService.cs b/journal-master/journal/Journal.Core/Services/IJournalDatabaseService.cs index 54b86bc..48f8722 100644 --- a/journal-master/journal/Journal.Core/Services/IJournalDatabaseService.cs +++ b/journal-master/journal/Journal.Core/Services/IJournalDatabaseService.cs @@ -19,11 +19,15 @@ public sealed record JournalDatabaseStatus( IReadOnlyList SchemaTables, string SchemaBootstrapPath, bool RuntimeReady, - string RuntimeMessage); + string RuntimeMessage, + string CipherVersion, + bool EncryptedAtRest); public sealed record JournalDatabaseHydrationResult( string DatabasePath, string SchemaBootstrapPath, int EntryFilesProcessed, bool RuntimeReady, - string Message); + string Message, + string CipherVersion, + bool EncryptedAtRest); diff --git a/journal-master/journal/Journal.Core/Services/JournalDatabaseService.cs b/journal-master/journal/Journal.Core/Services/JournalDatabaseService.cs index 73c0657..967abf4 100644 --- a/journal-master/journal/Journal.Core/Services/JournalDatabaseService.cs +++ b/journal-master/journal/Journal.Core/Services/JournalDatabaseService.cs @@ -11,6 +11,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv"); private static readonly object SqliteInitLock = new(); private static bool _sqliteInitialized; + private const string SqlitePlaintextHeader = "SQLite format 3"; private static readonly IReadOnlyList RequiredSchemaTables = ["entries", "sections", "fragments", "tags", "fragment_tags"]; @@ -126,7 +127,9 @@ public sealed class JournalDatabaseService : IJournalDatabaseService SchemaTables: tables, SchemaBootstrapPath: bootstrapPath, RuntimeReady: runtime.Ready, - RuntimeMessage: runtime.Message); + RuntimeMessage: runtime.Message, + CipherVersion: runtime.CipherVersion, + EncryptedAtRest: runtime.EncryptedAtRest); } public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null) @@ -136,9 +139,15 @@ public sealed class JournalDatabaseService : IJournalDatabaseService : dataDirectory; Directory.CreateDirectory(directory); - using var connection = OpenEncryptedConnection(password, directory); - CreateSchema(connection); - var runtimeReady = HasRequiredTables(connection); + bool runtimeReady; + string cipherVersion; + using (var connection = OpenEncryptedConnection(password, directory)) + { + CreateSchema(connection); + runtimeReady = HasRequiredTables(connection); + cipherVersion = QueryCipherVersion(connection); + } + var encryptedAtRest = IsDatabaseEncryptedAtRest(GetDatabasePath(directory)); var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length; var schemaPath = WriteSchemaBootstrap(directory); @@ -150,7 +159,9 @@ public sealed class JournalDatabaseService : IJournalDatabaseService RuntimeReady: runtimeReady, Message: runtimeReady ? "Workspace hydration completed with SQLCipher runtime schema validation." - : "Workspace hydration completed, but required schema tables were not found."); + : "Workspace hydration completed, but required schema tables were not found.", + CipherVersion: cipherVersion, + EncryptedAtRest: encryptedAtRest); } private static void EnsureSqliteInitialized() @@ -163,6 +174,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService if (_sqliteInitialized) return; + SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_e_sqlcipher()); SQLitePCL.Batteries_V2.Init(); _sqliteInitialized = true; } @@ -176,17 +188,25 @@ public sealed class JournalDatabaseService : IJournalDatabaseService EnsureSqliteInitialized(); var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};Mode=ReadWriteCreate;Pooling=False"); - connection.Open(); + try + { + connection.Open(); - using var keyCmd = connection.CreateCommand(); - keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";"; - keyCmd.ExecuteNonQuery(); + using var keyCmd = connection.CreateCommand(); + keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";"; + keyCmd.ExecuteNonQuery(); - using var verifyCmd = connection.CreateCommand(); - verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; - _ = verifyCmd.ExecuteScalar(); + using var verifyCmd = connection.CreateCommand(); + verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; + _ = verifyCmd.ExecuteScalar(); - return connection; + return connection; + } + catch + { + connection.Dispose(); + throw; + } } private void CreateSchema(SqliteConnection connection) @@ -214,20 +234,69 @@ public sealed class JournalDatabaseService : IJournalDatabaseService return RequiredSchemaTables.All(existing.Contains); } - private (bool Ready, string Message) ProbeRuntime(string password, string? dataDirectory) + private (bool Ready, string Message, string CipherVersion, bool EncryptedAtRest) ProbeRuntime(string password, string? dataDirectory) { try { - using var connection = OpenEncryptedConnection(password, dataDirectory); - CreateSchema(connection); - var ready = HasRequiredTables(connection); + bool hasRequiredTables; + string cipherVersion; + using (var connection = OpenEncryptedConnection(password, dataDirectory)) + { + CreateSchema(connection); + hasRequiredTables = HasRequiredTables(connection); + cipherVersion = QueryCipherVersion(connection); + } + var encryptedAtRest = IsDatabaseEncryptedAtRest(GetDatabasePath(dataDirectory)); + var hasCipherVersion = !string.IsNullOrWhiteSpace(cipherVersion); + var ready = hasRequiredTables && hasCipherVersion && encryptedAtRest; return ready - ? (true, "SQLCipher runtime is available and schema tables are present.") - : (false, "SQLCipher runtime opened, but required schema tables are missing."); + ? (true, "SQLCipher runtime is available, schema tables are present, and database file is encrypted at rest.", cipherVersion, encryptedAtRest) + : (false, BuildRuntimeFailureMessage(hasRequiredTables, hasCipherVersion, encryptedAtRest), cipherVersion, encryptedAtRest); } catch (Exception ex) { - return (false, $"SQLCipher runtime check failed: {ex.Message}"); + return (false, $"SQLCipher runtime check failed: {ex.Message}", "", false); } } + + private static string QueryCipherVersion(SqliteConnection connection) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA cipher_version;"; + var value = cmd.ExecuteScalar(); + return value?.ToString()?.Trim() ?? ""; + } + + private static bool IsDatabaseEncryptedAtRest(string databasePath) + { + if (!File.Exists(databasePath)) + return false; + + var probe = new byte[16]; + using var stream = new FileStream( + databasePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + var read = stream.Read(probe, 0, probe.Length); + if (read <= 0) + return false; + + var header = Encoding.ASCII.GetString(probe, 0, read); + return !header.StartsWith(SqlitePlaintextHeader, StringComparison.Ordinal); + } + + private static string BuildRuntimeFailureMessage(bool hasRequiredTables, bool hasCipherVersion, bool encryptedAtRest) + { + var failures = new List(); + if (!hasRequiredTables) + failures.Add("required schema tables are missing"); + if (!hasCipherVersion) + failures.Add("PRAGMA cipher_version returned empty (SQLCipher runtime not confirmed)"); + if (!encryptedAtRest) + failures.Add("database file appears plaintext at rest (SQLite header detected)"); + if (failures.Count == 0) + failures.Add("unknown runtime validation failure"); + return "SQLCipher runtime opened, but validation failed: " + string.Join("; ", failures) + "."; + } } diff --git a/journal-master/journal/Journal.SmokeTests/Program.cs b/journal-master/journal/Journal.SmokeTests/Program.cs index 55eeddf..5efcd9a 100644 --- a/journal-master/journal/Journal.SmokeTests/Program.cs +++ b/journal-master/journal/Journal.SmokeTests/Program.cs @@ -55,6 +55,7 @@ var tests = new List<(string Name, Func Run)> ("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync), ("Database schema parity tables are created", TestDatabaseSchemaParityAsync), ("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync), + ("Entry db.status rejects wrong key for existing encrypted database", TestEntryDatabaseStatusWrongKeyFailsAsync), ("Entry db.initialize_schema creates schema in data directory", TestEntryDatabaseInitializeSchemaAsync), ("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync), ("Config service exposes parity path, vault, AI, and speech settings", TestConfigServiceParityKeysAsync), @@ -1324,6 +1325,59 @@ static async Task TestEntryDatabaseStatusAsync() Assert(schemaBootstrapPath.ValueKind == JsonValueKind.String && File.Exists(schemaBootstrapPath.GetString()), "Expected db.status to emit existing schema bootstrap file path."); Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in db.status payload."); Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected SQLCipher runtime-ready status in db.status payload."); + Assert(data.TryGetProperty("CipherVersion", out var cipherVersion), "Expected CipherVersion in db.status payload."); + Assert(cipherVersion.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(cipherVersion.GetString()), "Expected non-empty SQLCipher cipher version."); + Assert(data.TryGetProperty("EncryptedAtRest", out var encryptedAtRest), "Expected EncryptedAtRest in db.status payload."); + Assert(encryptedAtRest.ValueKind == JsonValueKind.True, "Expected EncryptedAtRest=true for SQLCipher-backed database."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static async Task TestEntryDatabaseStatusWrongKeyFailsAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + File.WriteAllText(Path.Combine(root, "2026-02-20.md"), "one"); + var entry = NewEntry(); + + var hydrateRequest = JsonSerializer.Serialize(new + { + action = "db.hydrate_workspace", + payload = new + { + password = "vault-pass-123", + dataDirectory = root + } + }); + var hydrateResponse = await entry.HandleCommandAsync(hydrateRequest); + using (var hydrateDoc = JsonDocument.Parse(hydrateResponse)) + { + Assert(hydrateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected initial hydrate to succeed."); + } + + var wrongStatusRequest = JsonSerializer.Serialize(new + { + action = "db.status", + payload = new + { + password = "wrong-password", + dataDirectory = root + } + }); + + var wrongStatusResponse = await entry.HandleCommandAsync(wrongStatusRequest); + using var wrongDoc = JsonDocument.Parse(wrongStatusResponse); + Assert(wrongDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected db.status envelope to remain ok=true."); + var data = wrongDoc.RootElement.GetProperty("data"); + Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in wrong-key db.status payload."); + Assert(runtimeReady.ValueKind == JsonValueKind.False, "Expected RuntimeReady=false when using wrong key on existing encrypted database."); } finally { @@ -1402,6 +1456,18 @@ static async Task TestEntryDatabaseHydrateWorkspaceAsync() Assert(filesProcessed.ValueKind == JsonValueKind.Number && filesProcessed.GetInt32() == 2, "Expected hydrate to count markdown files in workspace."); Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in hydrate payload."); Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected RuntimeReady=true when SQLCipher runtime hydration succeeds."); + Assert(data.TryGetProperty("CipherVersion", out var cipherVersion), "Expected CipherVersion in hydrate payload."); + Assert(cipherVersion.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(cipherVersion.GetString()), "Expected non-empty SQLCipher cipher version in hydrate payload."); + Assert(data.TryGetProperty("EncryptedAtRest", out var encryptedAtRest), "Expected EncryptedAtRest in hydrate payload."); + Assert(encryptedAtRest.ValueKind == JsonValueKind.True, "Expected EncryptedAtRest=true when SQLCipher runtime hydration succeeds."); + + var dbPath = data.GetProperty("DatabasePath").GetString() ?? ""; + Assert(File.Exists(dbPath), "Expected hydrated database file to exist."); + using var stream = File.OpenRead(dbPath); + var headerBytes = new byte[16]; + var read = stream.Read(headerBytes, 0, headerBytes.Length); + var header = read > 0 ? System.Text.Encoding.ASCII.GetString(headerBytes, 0, read) : ""; + Assert(!header.StartsWith("SQLite format 3", StringComparison.Ordinal), "Expected SQLCipher database header to be non-plaintext."); } finally { diff --git a/journal-master/journal/README.md b/journal-master/journal/README.md index b07d408..c61004a 100644 --- a/journal-master/journal/README.md +++ b/journal-master/journal/README.md @@ -187,7 +187,7 @@ Search CLI flags: | `entries.list` | List decrypted markdown entries in a data directory | optional `payload.dataDirectory` | | `entries.load` | Load one entry file and return parsed metadata + raw content | `payload.filePath` | | `entries.save` | Save/merge entry content to file (fragment append or full merge path) | `payload.content`, optional `payload.filePath`, `payload.mode` | -| `db.status` | Return DB key/schema compatibility status snapshot | `payload.password`, optional `payload.dataDirectory` | +| `db.status` | Return DB key/schema compatibility + SQLCipher runtime snapshot | `payload.password`, optional `payload.dataDirectory` | | `db.initialize_schema` | Write SQL schema bootstrap (`journal_schema.sql`) for parity tables | optional `payload.dataDirectory` | | `db.hydrate_workspace` | Perform C# DB hydration step for decrypted workspace (schema bootstrap + metadata) | `payload.password`, optional `payload.dataDirectory` | | `config.get` | Return current backend config snapshot | — | @@ -228,10 +228,14 @@ ai.health → IAiService (implemented bridge) ai.summarize_* → IAiService (implemented bridge) ai.chat → IAiService (implemented bridge) ai.embed → IAiService (implemented bridge) -db.status → IJournalDatabaseService (in-progress DB parity) +db.status → IJournalDatabaseService (implemented SQLCipher parity/runtime checks) search.query → ISearchService (future) ``` +`db.status` and `db.hydrate_workspace` now include: +- `CipherVersion` (from `PRAGMA cipher_version`) +- `EncryptedAtRest` (true when DB header is not plaintext SQLite) + To add a module: 1. Create model, DTO, repository, and service in `Journal.Core/` 2. Register the new service in `ServiceCollectionExtensions.cs` diff --git a/wiki/Project_Journal-Python.wiki b/wiki/Project_Journal-Python.wiki new file mode 160000 index 0000000..6d33cd7 --- /dev/null +++ b/wiki/Project_Journal-Python.wiki @@ -0,0 +1 @@ +Subproject commit 6d33cd748fcc9c0a376e9788eb61daf029891722