using Microsoft.Data.Sqlite; using System.Text; using WxAgent.Core; namespace WxAgent.Windows; public sealed record DatabaseMetadata(string CipherVersion, string SqliteVersion, long SchemaObjectCount, IReadOnlyList SchemaObjects, bool WritesRejected); public static class SqlCipherDatabaseReader { private static readonly Lazy Initialized = new(() => { SQLitePCL.Batteries_V2.Init(); return true; }); public static async Task ReadMetadataAsync(string databasePath, string hexKey, CancellationToken cancellationToken) { Validate(databasePath, hexKey); _ = Initialized.Value; try { await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false); var cipherVersion = await ReadCipherVersionAsync(connection, cancellationToken).ConfigureAwait(false); var sqliteVersion = await ReadSqliteVersionAsync(connection, cancellationToken).ConfigureAwait(false); var count = await ReadSchemaCountAsync(connection, cancellationToken).ConfigureAwait(false); var names = new List(); await using (var command = connection.CreateCommand()) { command.CommandText = "SELECT name FROM sqlite_master WHERE name IS NOT NULL ORDER BY name LIMIT 100;"; command.CommandTimeout = 10; await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var name = reader.GetString(0); names.Add(name.Length <= 256 ? name : name[..256]); } } // Inspect SQLite's connection state; never attempt writes against the user's database. var writesRejected = SQLitePCL.raw.sqlite3_db_readonly(connection.Handle, "main") == 1; if (!writesRejected) { throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher connection did not report read-only mode."); } return new DatabaseMetadata(cipherVersion, sqliteVersion, count, names, writesRejected); } catch (SqliteException exception) { throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not open or read the selected database in read-only mode.", exception); } } /// Runs a caller-provided read-only SQL query and returns rows keyed by column name. Values are limited to scalars and UTF-8 strings. public static async Task>> QueryRowsAsync( string databasePath, string hexKey, string sql, IReadOnlyList>? parameters, CancellationToken cancellationToken) { Validate(databasePath, hexKey); _ = Initialized.Value; var rows = new List>(); try { await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false); await using var command = connection.CreateCommand(); command.CommandText = sql; command.CommandTimeout = 10; if (parameters is not null) { foreach (var parameter in parameters) { command.Parameters.AddWithValue(parameter.Key, parameter.Value ?? DBNull.Value); } } await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var row = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < reader.FieldCount; index++) { var value = reader.IsDBNull(index) ? null : reader.GetValue(index); row[reader.GetName(index)] = Normalize(value); } rows.Add(row); } return rows; } catch (SqliteException exception) { throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not run the read-only query.", exception); } } private static void Validate(string databasePath, string hexKey) { ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); if (hexKey.Length != 64 || !hexKey.All(Uri.IsHexDigit)) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database key must be exactly 64 hexadecimal characters."); } } private static async Task OpenReadOnlyAsync(string databasePath, string hexKey, CancellationToken cancellationToken) { var page = new byte[SqlCipherPageVerifier.PageSize]; await using (var file = new FileStream(databasePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, SqlCipherPageVerifier.PageSize, FileOptions.Asynchronous)) await file.ReadExactlyAsync(page, cancellationToken).ConfigureAwait(false); if (!SqlCipherPageVerifier.VerifyHexKey(page, hexKey)) throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "The cached key failed current database page-1 HMAC verification; refresh the selected account key."); var connectionString = new SqliteConnectionStringBuilder { DataSource = Path.GetFullPath(databasePath), Mode = SqliteOpenMode.ReadOnly, Cache = SqliteCacheMode.Private, Pooling = false, DefaultTimeout = 10 }.ToString(); var connection = new SqliteConnection(connectionString); try { await connection.OpenAsync(cancellationToken).ConfigureAwait(false); await using (var command = connection.CreateCommand()) { // nosemgrep:csharp-sqli - hexKey is validated to exactly 64 hex chars before this call; no injection is possible. command.CommandText = $"PRAGMA key = \"x'{hexKey}'\";"; command.CommandTimeout = 10; await command.ExecuteNonQueryAsync(cancellationToken); } await using (var command = connection.CreateCommand()) { command.CommandText = "PRAGMA cipher_compatibility = 4; PRAGMA query_only = ON;"; command.CommandTimeout = 10; await command.ExecuteNonQueryAsync(cancellationToken); } return connection; } catch { await connection.DisposeAsync(); throw; } } private static object? Normalize(object? value) => value switch { byte[] bytes => bytes.Length <= 256 ? Encoding.UTF8.GetString(bytes) : $"[bytes:{bytes.Length}]", DBNull => null, _ => value }; private static async Task ReadCipherVersionAsync(SqliteConnection connection, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.CommandText = "PRAGMA cipher_version;"; command.CommandTimeout = 10; return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; } private static async Task ReadSqliteVersionAsync(SqliteConnection connection, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.CommandText = "SELECT sqlite_version();"; command.CommandTimeout = 10; return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; } private static async Task ReadSchemaCountAsync(SqliteConnection connection, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.CommandText = "SELECT count(*) FROM sqlite_master;"; command.CommandTimeout = 10; return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture); } }