verified my system and backend used my sqlchiper install.
This commit is contained in:
parent
71df9a2b9a
commit
e520133460
@ -19,11 +19,15 @@ public sealed record JournalDatabaseStatus(
|
||||
IReadOnlyList<string> 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);
|
||||
|
||||
@ -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<string> 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<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) + ".";
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,6 +55,7 @@ var tests = new List<(string Name, Func<Task> 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
|
||||
{
|
||||
|
||||
@ -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`
|
||||
|
||||
1
wiki/Project_Journal-Python.wiki
Submodule
1
wiki/Project_Journal-Python.wiki
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 6d33cd748fcc9c0a376e9788eb61daf029891722
|
||||
Loading…
x
Reference in New Issue
Block a user