SQLCipher encryption in .NET
Encrypted-at-rest SQLite via SQLCipher-compatible e_sqlite3mc (SQLitePCLRaw bundle). The passphrase flows through SqliteConnectionStringBuilder.Password. All claims below were MEASURED on macOS + net10 in the reference project
(2026-08-05).
The raw-key channel (strongest option)
Password = "x'<64-hex>'" keys the bank with a RAW 256-bit key — no KDF, no passphrase brute-force surface. Verified end-to-end: create → reopen → wrong-key rejection (SqliteException code 26). Prefer raw keys over passphrases whenever
the key material can be a random/derived 32 bytes (file, vault, derivation). Passphrase mode is fine but weaker:
weak passphrases are brute-forceable offline (the header salt + HMAC are on disk).
Deriving a key from an ed25519 SSH private key
- Parse the OpenSSH private key (
openssh-key-v1 magic, RFC 8709): ciphername must be
none; the 32-byte seed is the first 32 bytes of the private half.
- Derive:
SHA-256("<stable-label>" || seed) → raw 32 bytes → x'<hex>'. The label is a STABILITY CONTRACT — changing it silently breaks every existing bank.
- ed25519 only. RSA: private-key encodings are non-canonical (PKCS#1/PKCS#8/OpenSSH) → unstable derivation. Passphrase-protected keys: seed is bcrypt_pbkdf-encrypted → reject (detect the
bcrypt cipher field).
- Test vectors must be pinned: synthetic seed
00..1f → x'277bf7…281b' for label
<app>-db-key/v1; generate a real ssh-keygen -t ed25519 fixture for the second.
Rekey constraints
PRAGMA rekey with the target x'…' literal (build via SELECT quote(?) to avoid quoting bugs). Rekey legs verified: raw↔raw, passphrase→raw, plaintext→raw.
- WAL is unsupported for rekey — switch the journal to DELETE for the rekey, then back.
- Persist order for source switches: rekey the bank FIRST, then write the source marker, then mirror to settings — a crash mid-sequence must be recoverable (see retry legs below).
Key-source architecture (pluggable providers)
Pattern for any encrypted store with multiple key sources (env / keychain / vault):
IEncryptionKeyProvider family: one provider per source, a resolver that picks by a marker, an eager open at startup so refusal-to-start is LOUD before serving.
- The pre-open selection problem: the source selection cannot live inside the encrypted store (unreadable before the key). Use a small UNENCRYPTED sidecar next to the store (e.g.
memory.db.source, JSON {source, …}): absence =
default source; corrupt sidecar = loud error naming the file; atomic write (temp + rename); 0600. Settings rows inside the store are only a post-open mirror.
- Bootstrap problem for remote vaults: the vault's own credential must be readable pre-open — existing CLI login state (
~/.azure, ~/.aws), OS keychain, or env. Never inside the encrypted store (circular). Interactive-at-start is
rejected when the server is MCP-spawned (no terminal).
- Crash-window retry legs: if the open with the new source fails but the old source's key still works (e.g. env passphrase), retry with it, report "bank is -keyed; source not switched", and keep the marker consistent.
bws (Bitwarden Secrets Manager CLI) integration
bws secret get <secret-id> — use the secret ID (stable), not the name. Token:
BWS_ACCESS_TOKEN env at server start; -t <token> on argv ONLY at config time, never persisted. 15 s timeout; non-zero exit → surface stderr; empty stdout → error.
- Presence check first: missing bws → actionable install-guidance error, nothing changed.
- Test with a fake-bws executable at an ABSOLUTE path (no PATH mutation): a script that validates a known token and prints a fixed ed25519 key.
- Rotation trap: rotating the secret in the Bitwarden web UI without
PRAGMA rekey
bricks the bank — the config command must warn.
- The C# SDK (
Bitwarden.Secrets.Sdk 1.0.0) is beta, synchronous-only, custom license,
~7 MB native binary — prefer the CLI when available.
Gotchas
- SecureString: deprecated (DE0001), no in-memory encryption on .NET Core — NOT a solution for key handling or storage.
- Env vars: visible via
/proc/<pid>/environ on Linux (macOS hides them from ps); the bigger exposure is plaintext client configs and backups.
- Offline behavior for network sources: refuse to start loudly; an offline cache is an explicit opt-in, never a default.
Dapper mapping pitfalls over SQLite3MC (MEASURED 2026-08-06)
Dapper's typed deserializer is built EAGERLY from the reader schema — it fails at materialization time even when zero rows would be returned:
count(*) columns are Int64. A mapping record with int Count fails with
"matching signature (String, Int64) is required". Use long in the Dapper mapping record and cast at the call site, or ExecuteScalarAsync<int> for plain counts.
- Aggregates over an empty set come back NULL-typed →
byte[]. avg(...) with no rows, and GROUP BY with zero rows, make Dapper demand a ctor with
System.Byte[] — the classic "parameterless default constructor or one matching signature (…, System.Byte[]) is required" error, thrown even when nothing is materialized. Fixes that work:
- aggregates:
ExecuteScalarAsync<double?> (NULL maps cleanly to null) — wrap the aggregate in CAST(avg(…) AS REAL) when the value is real;
- empty-GROUP-BY tables: use the dynamic
QueryAsync() (no type argument) and build the dictionary row by row — the dynamic path materializes per row and skips the eager typed deserializer entirely.
- Records with
double? positional params don't match Dapper's ctor lookup for a
double column — keep nullable aggregates on the scalar path, not in mapping records.
References
references/bitwarden-bws-integration.md — provisioned IDs, SDK facts, bootstrap ranking, and the full owner-decision record from an encryption-key-source work package; read when choosing an encryption-key source.
1---2name: dotnet-sqlcipher-encryption3description: Use when working with SQLCipher-encrypted SQLite in .NET (e_sqlite3mc / SQLitePCLRaw bundle): raw 256-bit keys via Password='x'<hex>'', deriving keys from ed25519 SSH keys, PRAGMA rekey constraints (WAL unsupported), pluggable key-source providers (env/keychain/vault) with the pre-open sidecar pattern, and Dapper-over-SQLite3MC mapping traps.4license: MIT5---67# SQLCipher encryption in .NET89Encrypted-at-rest SQLite via SQLCipher-compatible `e_sqlite3mc` (SQLitePCLRaw bundle). The passphrase flows through `SqliteConnectionStringBuilder.Password`. All claims below were MEASURED on macOS + net10 in the reference project10(2026-08-05).1112## The raw-key channel (strongest option)1314`Password = "x'<64-hex>'"` keys the bank with a RAW 256-bit key — **no KDF**, no passphrase brute-force surface. Verified end-to-end: create → reopen → wrong-key rejection (SqliteException code 26). Prefer raw keys over passphrases whenever15the key material can be a random/derived 32 bytes (file, vault, derivation). Passphrase mode is fine but weaker:16weak passphrases are brute-forceable offline (the header salt + HMAC are on disk).1718## Deriving a key from an ed25519 SSH private key1920- Parse the OpenSSH private key (`openssh-key-v1` magic, RFC 8709): ciphername must be21 `none`; the 32-byte seed is the first 32 bytes of the private half.22- Derive: `SHA-256("<stable-label>" || seed)` → raw 32 bytes → `x'<hex>'`. The label is a STABILITY CONTRACT — changing it silently breaks every existing bank.23- ed25519 only. RSA: private-key encodings are non-canonical (PKCS#1/PKCS#8/OpenSSH) → unstable derivation. Passphrase-protected keys: seed is bcrypt_pbkdf-encrypted → reject (detect the `bcrypt` cipher field).24- Test vectors must be pinned: synthetic seed `00..1f` → `x'277bf7…281b'` for label25 `<app>-db-key/v1`; generate a real `ssh-keygen -t ed25519` fixture for the second.2627## Rekey constraints2829- `PRAGMA rekey` with the target `x'…'` literal (build via `SELECT quote(?)` to avoid quoting bugs). Rekey legs verified: raw↔raw, passphrase→raw, plaintext→raw.30- **WAL is unsupported for rekey** — switch the journal to DELETE for the rekey, then back.31- Persist order for source switches: rekey the bank FIRST, then write the source marker, then mirror to settings — a crash mid-sequence must be recoverable (see retry legs below).3233## Key-source architecture (pluggable providers)3435Pattern for any encrypted store with multiple key sources (env / keychain / vault):36371. `IEncryptionKeyProvider` family: one provider per source, a resolver that picks by a marker, an eager open at startup so refusal-to-start is LOUD before serving.382. **The pre-open selection problem**: the source selection cannot live inside the encrypted store (unreadable before the key). Use a small UNENCRYPTED sidecar next to the store (e.g. `memory.db.source`, JSON `{source, …}`): absence =39 default source; corrupt sidecar = loud error naming the file; atomic write (temp + rename); 0600. Settings rows inside the store are only a post-open mirror.403. **Bootstrap problem for remote vaults**: the vault's own credential must be readable pre-open — existing CLI login state (`~/.azure`, `~/.aws`), OS keychain, or env. Never inside the encrypted store (circular). Interactive-at-start is41 rejected when the server is MCP-spawned (no terminal).424. Crash-window retry legs: if the open with the new source fails but the old source's key still works (e.g. env passphrase), retry with it, report "bank is <old>-keyed; source not switched", and keep the marker consistent.4344## bws (Bitwarden Secrets Manager CLI) integration4546- `bws secret get <secret-id>` — use the secret ID (stable), not the name. Token:47 `BWS_ACCESS_TOKEN` env at server start; `-t <token>` on argv ONLY at config time, never persisted. 15 s timeout; non-zero exit → surface stderr; empty stdout → error.48- Presence check first: missing bws → actionable install-guidance error, nothing changed.49- Test with a fake-bws executable at an ABSOLUTE path (no PATH mutation): a script that validates a known token and prints a fixed ed25519 key.50- **Rotation trap**: rotating the secret in the Bitwarden web UI without `PRAGMA rekey`51 bricks the bank — the config command must warn.52- The C# SDK (`Bitwarden.Secrets.Sdk` 1.0.0) is beta, synchronous-only, custom license,53 ~7 MB native binary — prefer the CLI when available.5455## Gotchas56- SecureString: deprecated (DE0001), no in-memory encryption on .NET Core — NOT a solution for key handling or storage.57- Env vars: visible via `/proc/<pid>/environ` on Linux (macOS hides them from `ps`); the bigger exposure is plaintext client configs and backups.58- Offline behavior for network sources: refuse to start loudly; an offline cache is an explicit opt-in, never a default.5960## Dapper mapping pitfalls over SQLite3MC (MEASURED 2026-08-06)6162Dapper's typed deserializer is built EAGERLY from the reader schema — it fails at materialization time even when zero rows would be returned:6364- **`count(*)` columns are Int64.** A mapping record with `int Count` fails with65 "matching signature (String, Int64) is required". Use `long` in the Dapper mapping record and cast at the call site, or `ExecuteScalarAsync<int>` for plain counts.66- **Aggregates over an empty set come back NULL-typed → `byte[]`.** `avg(...)` with no rows, and `GROUP BY` with zero rows, make Dapper demand a ctor with67 `System.Byte[]` — the classic "parameterless default constructor or one matching signature (…, System.Byte[]) is required" error, thrown even when nothing is materialized. Fixes that work:68 - aggregates: `ExecuteScalarAsync<double?>` (NULL maps cleanly to null) — wrap the aggregate in `CAST(avg(…) AS REAL)` when the value is real;69 - empty-GROUP-BY tables: use the dynamic `QueryAsync()` (no type argument) and build the dictionary row by row — the dynamic path materializes per row and skips the eager typed deserializer entirely.70- Records with `double?` positional params don't match Dapper's ctor lookup for a71 `double` column — keep nullable aggregates on the scalar path, not in mapping records.7273## References7475- `references/bitwarden-bws-integration.md` — provisioned IDs, SDK facts, bootstrap ranking, and the full owner-decision record from an encryption-key-source work package; read when choosing an encryption-key source.