verified my system and backend used my sqlchiper install.

This commit is contained in:
stan44 2026-02-23 21:07:04 -06:00
parent 71df9a2b9a
commit e520133460
5 changed files with 168 additions and 24 deletions

View File

@ -19,11 +19,15 @@ public sealed record JournalDatabaseStatus(
IReadOnlyList<string> SchemaTables, IReadOnlyList<string> SchemaTables,
string SchemaBootstrapPath, string SchemaBootstrapPath,
bool RuntimeReady, bool RuntimeReady,
string RuntimeMessage); string RuntimeMessage,
string CipherVersion,
bool EncryptedAtRest);
public sealed record JournalDatabaseHydrationResult( public sealed record JournalDatabaseHydrationResult(
string DatabasePath, string DatabasePath,
string SchemaBootstrapPath, string SchemaBootstrapPath,
int EntryFilesProcessed, int EntryFilesProcessed,
bool RuntimeReady, bool RuntimeReady,
string Message); string Message,
string CipherVersion,
bool EncryptedAtRest);

View File

@ -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 byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv");
private static readonly object SqliteInitLock = new(); private static readonly object SqliteInitLock = new();
private static bool _sqliteInitialized; private static bool _sqliteInitialized;
private const string SqlitePlaintextHeader = "SQLite format 3";
private static readonly IReadOnlyList<string> RequiredSchemaTables = private static readonly IReadOnlyList<string> RequiredSchemaTables =
["entries", "sections", "fragments", "tags", "fragment_tags"]; ["entries", "sections", "fragments", "tags", "fragment_tags"];
@ -126,7 +127,9 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
SchemaTables: tables, SchemaTables: tables,
SchemaBootstrapPath: bootstrapPath, SchemaBootstrapPath: bootstrapPath,
RuntimeReady: runtime.Ready, RuntimeReady: runtime.Ready,
RuntimeMessage: runtime.Message); RuntimeMessage: runtime.Message,
CipherVersion: runtime.CipherVersion,
EncryptedAtRest: runtime.EncryptedAtRest);
} }
public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null) public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null)
@ -136,9 +139,15 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
: dataDirectory; : dataDirectory;
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
using var connection = OpenEncryptedConnection(password, directory); bool runtimeReady;
CreateSchema(connection); string cipherVersion;
var runtimeReady = HasRequiredTables(connection); 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 entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length;
var schemaPath = WriteSchemaBootstrap(directory); var schemaPath = WriteSchemaBootstrap(directory);
@ -150,7 +159,9 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
RuntimeReady: runtimeReady, RuntimeReady: runtimeReady,
Message: runtimeReady Message: runtimeReady
? "Workspace hydration completed with SQLCipher runtime schema validation." ? "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() private static void EnsureSqliteInitialized()
@ -163,6 +174,7 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
if (_sqliteInitialized) if (_sqliteInitialized)
return; return;
SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_e_sqlcipher());
SQLitePCL.Batteries_V2.Init(); SQLitePCL.Batteries_V2.Init();
_sqliteInitialized = true; _sqliteInitialized = true;
} }
@ -176,17 +188,25 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
EnsureSqliteInitialized(); EnsureSqliteInitialized();
var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};Mode=ReadWriteCreate;Pooling=False"); var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};Mode=ReadWriteCreate;Pooling=False");
connection.Open(); try
{
connection.Open();
using var keyCmd = connection.CreateCommand(); using var keyCmd = connection.CreateCommand();
keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";"; keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";";
keyCmd.ExecuteNonQuery(); keyCmd.ExecuteNonQuery();
using var verifyCmd = connection.CreateCommand(); using var verifyCmd = connection.CreateCommand();
verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;";
_ = verifyCmd.ExecuteScalar(); _ = verifyCmd.ExecuteScalar();
return connection; return connection;
}
catch
{
connection.Dispose();
throw;
}
} }
private void CreateSchema(SqliteConnection connection) private void CreateSchema(SqliteConnection connection)
@ -214,20 +234,69 @@ public sealed class JournalDatabaseService : IJournalDatabaseService
return RequiredSchemaTables.All(existing.Contains); 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 try
{ {
using var connection = OpenEncryptedConnection(password, dataDirectory); bool hasRequiredTables;
CreateSchema(connection); string cipherVersion;
var ready = HasRequiredTables(connection); 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 return ready
? (true, "SQLCipher runtime is available and schema tables are present.") ? (true, "SQLCipher runtime is available, schema tables are present, and database file is encrypted at rest.", cipherVersion, encryptedAtRest)
: (false, "SQLCipher runtime opened, but required schema tables are missing."); : (false, BuildRuntimeFailureMessage(hasRequiredTables, hasCipherVersion, encryptedAtRest), cipherVersion, encryptedAtRest);
} }
catch (Exception ex) 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<string>();
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) + ".";
}
} }

View File

@ -55,6 +55,7 @@ var tests = new List<(string Name, Func<Task> Run)>
("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync), ("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync),
("Database schema parity tables are created", TestDatabaseSchemaParityAsync), ("Database schema parity tables are created", TestDatabaseSchemaParityAsync),
("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync), ("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.initialize_schema creates schema in data directory", TestEntryDatabaseInitializeSchemaAsync),
("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync), ("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync),
("Config service exposes parity path, vault, AI, and speech settings", TestConfigServiceParityKeysAsync), ("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(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(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(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 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(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(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(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 finally
{ {

View File

@ -187,7 +187,7 @@ Search CLI flags:
| `entries.list` | List decrypted markdown entries in a data directory | optional `payload.dataDirectory` | | `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.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` | | `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.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` | | `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 | — | | `config.get` | Return current backend config snapshot | — |
@ -228,10 +228,14 @@ ai.health → IAiService (implemented bridge)
ai.summarize_* → IAiService (implemented bridge) ai.summarize_* → IAiService (implemented bridge)
ai.chat → IAiService (implemented bridge) ai.chat → IAiService (implemented bridge)
ai.embed → 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) 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: To add a module:
1. Create model, DTO, repository, and service in `Journal.Core/` 1. Create model, DTO, repository, and service in `Journal.Core/`
2. Register the new service in `ServiceCollectionExtensions.cs` 2. Register the new service in `ServiceCollectionExtensions.cs`

@ -0,0 +1 @@
Subproject commit 6d33cd748fcc9c0a376e9788eb61daf029891722