Skip to content
All writing
Technical · 11 min

The Flagship Encryption Feature That Never Actually Turned On

The eighth round of a self-pentest found MoodHaven's flagship 'encrypted at rest' feature had never engaged on any build — then a custom attack tool and a fix-the-fixes round closed the campaign. Part 3 of a four-part series.

Contents

Part 3 of a four-part series on the ten-round pentest of MoodHaven Journal. Part 1 set up the lab; Part 2 walked the findings and the pattern of fixes introducing new bugs. This is where that pattern delivers its sharpest example — and where the campaign finally converges.


The flagship encryption feature that never turned on

This is the finding I least wanted to write and most needed to.

Several rounds earlier, the “your database is readable” problem was fixed by adding SQLCipher — encrypting the entire database file at rest. It was the headline security feature of that whole stretch of work. It was documented. It passed subsequent pentest rounds. I believed it. Everyone reviewing it believed it. The trouble is that none of that is evidence the code actually does what it says.

The eighth round confirmed, on a real installed build, that the database was never encrypted on any install, on any operating system. Every copy of the app had been quietly running on a plaintext database the whole time — the exact problem the SQLCipher feature was supposed to have solved. The encryption migration was firing on first unlock, failing its own verification check, and silently falling back to the original plaintext file. Because the fallback was silent and the app kept working normally, nobody noticed.

The cause was a mismatch in how the same key was applied on the way in versus the way out. The code encrypted the database with a raw key, but read it back with a command that quietly ran that key through a key-derivation step first — producing a different key. So the file was written with one key and every read asked for a transformed version of it. The result was “file is not a database” on every reopen, the verification step failing, and the fallback to plaintext.

Two independent investigations landed on the same root cause. To be sure it wasn’t a misreading, I built a minimal standalone program that did nothing but encrypt a database one way and reopen it the other — and reproduced the exact failure in isolation. The same reproduction, run against the corrected key handling, now opens cleanly.

There was no test covering the encrypt-then-reopen round trip. That single missing test is the whole reason a non-functional security feature shipped and survived multiple rounds of adversarial review.

Exhibit 1 — Encryption-at-rest, finally real. This is a before/after of the database file’s header bytes: the “before” capture shows the readable SQLite magic string (SQLite format 3) of the inert build, and the “after” shows the high-entropy SQLCipher header of a database written and reopened with the corrected key handling — taken from the installed Windows build that now verifies clean end-to-end. (Capture will be anonymized: any file paths redacted.)

View raw capture — before/after xxd of the DB header (anonymized)
# BEFORE (inert build — plaintext DB on disk):
$ xxd moodhaven.db | head -1
00000000  53 51 4c 69 74 65 20 66  6f 72 6d 61 74 20 33 00   SQLite format 3.
#         └──────── readable "SQLite format 3" magic — file is NOT encrypted ────────┘

# AFTER (corrected key handling — installed Windows build, written and reopened cleanly):
$ xxd moodhaven.db | head -1
00000000  bd bc 97 0c 23 d5 0e 0f  1e db 42 21 3d b7 be 42   ....#.....B!=..B
#         └──────── no "SQLite format 3" magic — high-entropy SQLCipher ciphertext ──┘

# And the encryption-state file, post-migration:
$ cat db_state.json
{"encrypted":true,"salt":"<16-byte base64 salt>"}
#         └──────── encrypted=true, real salt present, no orphaned moodhaven_enc.db ──┘
For the technically inclined — the raw-key vs. KDF-key PRAGMA mismatch, and the standalone repro that proved it

The migration encrypted via ATTACH DATABASE ... KEY "x'<hex>'" — the x'...' literal form, which SQLCipher treats as a raw 256-bit key with no KDF. But every unlock and verify reopened the database with PRAGMA hexkey = '<hex>', which SQLCipher interprets differently: it decodes the hex to 32 bytes and then runs PBKDF2 over them, deriving a key that is not the raw key the file was written with.

write:  ATTACH ... KEY "x'<hex>'"   → raw key K
read:   PRAGMA hexkey = '<hex>'     → PBKDF2(decode(hex)) ≠ K
result: "file is not a database" → first-unlock verify fails → silent fallback to the plaintext file

This was confirmed two ways: a standalone cargo reproduction (case D = the app’s read path fails; case E = PRAGMA key = "x'..."' succeeds), and a trace into the vendored SQLCipher C source (the raw-key branch requires the literal x'...' wrapper; hexkey pre-decodes to 32 bytes, fails the raw-key length test, and falls through to PBKDF2).

Editor’s note (added later): SQLCipher’s documented behavior treats PRAGMA hexkey as a raw key with no KDF — the same as the x'...' literal form — so the raw-vs-PBKDF2 framing above may be specific to the vendored build I traced rather than to SQLCipher in general. What’s not in question is the symptom (the readers failed to open a file the migration had raw-keyed) or the fix (standardizing every read path on PRAGMA key = "x'<hex>'"); only the precise reason the old hexkey path mismatched is worth treating with some caution.

Fix: the three read-path pragmas — in apply_key, and in encrypt_in_place’s verify step and final open — now all use PRAGMA key = "x'<hex>'", the same raw x'...' literal form as the encryption path. It is fully backward-compatible: existing files were always raw-keyed, so only the readers were wrong; they now open the already-encrypted files with no re-encryption needed. A regression test now covers the full encrypt (via ATTACH + sqlcipher_export) → close → reopen (via PRAGMA key) round trip. Commit e6fb416 on PR #133.

Status — verified end-to-end on the green Windows installed build: root cause confirmed two independent ways; fix applied and proven by the standalone reproduction and the new regression test; and — the part I withheld claiming until it was true — re-validated on a real installed Windows build. After a fresh setup the migration now completes: db_state.json reads {"encrypted": true} with a real salt, there is no orphaned moodhaven_enc.db left behind, the on-disk database header is high-entropy ciphertext (no SQLite format 3 magic), the app unlocks cleanly, and the peer-sync server starts. Linux (Ubuntu/“purple”) re-validation of the same migration is a deferred residual — still pending, not yet claimed. See commit e6fb416, PR #133, branch fix/security-pt6-acl-lockguard.

The irony is total and worth sitting with: an earlier round fixed the readable-database problem by adding SQLCipher, and the eighth round found that the fix never took effect. The earlier round wasn’t wrong about the design — it was wrong to believe the design was running. That gap, between “we wrote the fix” and “the fix executes correctly on a real machine,” is the entire reason this campaign keeps going. And it’s exactly why I refused to call this one done until the corrected build was reinstalled from scratch and the encrypted-on-disk, clean-unlock result was confirmed by hand on Windows.

One more from the same round — the Windows “Erase & Start Fresh” failure

The eighth round also surfaced a real Windows-only bug in “Erase & Start Fresh”: the app held its own open handle to the SQLite database file, and Windows refuses to delete or rename a file that a process still has open — so the factory reset failed outright. Fix: release the app’s own open database connection before touching the file (and surface the real error instead of swallowing it). Commit b142f31. A close cousin of the Windows file-locking quirk that broke the encryption migration in an earlier round — same operating system, same lesson about open handles.

And the lab itself grew this round: a third machine, an Ubuntu victim, was brought online so the campaign now runs across all three targets — Kali attacker, Windows victim, Ubuntu victim. At this point the campaign was far from finished — two more rounds followed before it closed.


The ninth round — when the custom attack tool started finding bugs by itself

The eighth round fixed the encryption-at-rest feature. The ninth round did something different: instead of reading the code looking for what might be wrong, I pointed a piece of instrumentation I’d built — a from-scratch reimplementation of the app’s own encrypted sync protocol (described in the next section) — directly at the running app and watched what broke. Six issues came out of it, and the most interesting one I’d never have found by reading code at all.

The bug the attack tool found that code review couldn’t

My custom sync client connected to the app, completed the full cryptographic handshake, and then — on Windows — the app kept dropping the connection partway through. Not rejecting it, not erroring: silently dropping trusted peers mid-handshake. That’s the kind of thing that doesn’t show up in source review because the code looks correct; it only manifests as a timing-dependent failure on one operating system, under real network conditions, against a real client. Watching my own tool get hung up on it is what exposed it. Fix: force each accepted connection back into blocking mode (a default that differs subtly across platforms) so the read loop behaves identically everywhere. Committed in 949f9a9.

Two new data-loss bugs hiding inside the eighth round’s encryption fixes

The “fixed it, then broke it” pattern struck again — twice — and both new bugs lived inside the encryption work from the previous round:

  • The recovery path could destroy data. When the encryption migration is interrupted at exactly the wrong moment, a recovery routine cleans up the half-finished encrypted file. The bug: it could promote that half-finished file into place without first checking that it actually opened with the key — so a crashed or corrupt migration could overwrite the user’s good database with a broken one. Fix: never promote the temporary encrypted file until it has been re-opened and key-verified; if it fails verification, the original untouched database is preserved. Committed in 0774a3e, with two new regression tests (one proving a corrupt temp file leaves the original intact, one proving a valid temp file is promoted only after it verifies).
  • The “Sync from Another Device” restore could permanently lock you out of the restored copy. When a fresh device pulls your whole database from an existing one, it receives the encrypted database bytes — but the transfer was never sending the small piece of data needed to derive the decryption key (the database’s salt). The restored device would receive a perfectly good encrypted database it could never open: every unlock attempt failed with “encryption record is missing.” The feature was non-functional end-to-end. Fix: the restore protocol now carries the encryption state (encrypted flag + salt) alongside the data, and the restored device writes a matching state file so the same password derives the same key. Committed in 07e9d44.

Both of these are fixed and committed, and proven by reproductions/regression tests — and both got attacked again in the very next round. That tenth round red-teamed these two fixes specifically and found a real bug in each (covered below): the recovery fix could be fooled into accepting an empty-database decoy, and the restore-salt fix wrote an attacker-supplied salt with no validation. Both follow-on bugs were fixed in turn. What’s still honestly outstanding is the live, GUI-driven re-validation — actually restoring onto a fresh device over the network and force-killing a real migration on the installed builds. That’s a residual, deferred item; the code paths are correct and tested, and I won’t pretend the on-real-hardware re-run has happened when it hasn’t.

Three more, smaller but real

  • Two more locked-while-unlocked gaps. The access-control sweep from earlier rounds had missed two commands — one that reports database statistics, one that regenerates two-factor backup codes — which could still run while the app was locked. Both now require an unlocked session. And the restore-arming window (the “I’m setting up a new device right now” switch from an earlier round) is now cleared whenever the app locks, so walking away re-secures it. All committed in 949f9a9.
  • The pairing QR code rendered nothing. Not a vulnerability, but a real correctness bug found in the same pass: the QR code on the device-pairing screen silently failed to draw (a dynamic module load that broke in the production build), leaving a perpetual spinner. The whole point of the QR code is to let you pair without typing — so this quietly forced everyone onto manual PIN entry. Fix: render it with a statically-bundled component instead of a runtime import, plus a regression test that asserts the QR actually appears. Committed in 4443a2b.
For the technically inclined — the non-blocking accept, the deferred key-verified promotion, the restore-salt protocol change, and the lock-guard additions

Non-blocking accepted socket (949f9a9). On Windows, a TcpStream accepted from a listener could end up in non-blocking mode, so the sync read loop hit WouldBlock mid-handshake and the connection was torn down before the v2 key exchange finished. The fix calls set_nonblocking(false) on every accepted stream (plus a read_timeout for liveness), making the post-handshake blocking read loop behave identically on all platforms. This surfaced live via the e3 sync-client emulator — the standalone client repeatedly failed to complete a handshake that the source code said should succeed, which is what pointed at the socket mode rather than the protocol.

Deferred, key-verified promotion in the recovery path (0774a3e). encrypt_in_place exports the plaintext DB into a temporary encrypted file, then promotes it over the original. The regression: the startup recovery/promotion path could rename the temp file into place before confirming it opened with the derived key. If the migration had crashed mid-export, the temp file was truncated/corrupt — and promoting it overwrote a good plaintext DB with an unopenable one. The fix defers promotion until apply_key re-opens the temp and key-verifies it; a crashed/corrupt temp is discarded and the original is preserved. Two tests were added (corrupt-temp-preserves-original; valid-temp-promotes-after-verify); the suite went to 172 passing.

Restore-salt transfer (07e9d44). “Sync from Another Device” streamed only the encrypted SQLCipher database bytes and never the source’s db_state.json salt. On the restored device db_salt() returned None, so verify_password failed with “Database encryption record is missing” — a permanent, un-unlockable state. The fix propagates the source’s encryption state (encrypted + salt) through the restore completion so the restored device writes a matching db_state.json and derives the same SQLCipher key via PBKDF2(password, salt). The salt is not a secret — it already lives in plaintext in db_state.json on every device; the same password across a user’s devices is the security boundary, exactly as the peer-sync model intends.

Lock guards + restore-arm clear-on-lock (949f9a9). get_data_stats and regenerate_backup_codes were missing the require_unlocked guard and now have it; the restore-arm state is cleared on lock_app so a momentary “set up a new device” window doesn’t survive the session locking. One honestly-deferred LOW remains: the restore-arm window’s clear-on-lock is in, but per-device targeting (scoping an armed window to a specific incoming device) is still pending, and a couple of Zeroizing sweeps on TOTP/hardware-key material are noted as deferred — not claimed as done.

QR render fix (4443a2b). The pairing screen used a dynamic import('qrcode') + toDataURL path that failed silently under Tauri’s production chunk loading (CJS/ESM interop), leaving a spinner and no code. Swapped to the already-bundled qrcode.react <QRCodeSVG> (static, synchronous), with a render regression test over the pairing tab. Not a security finding — a correctness regression with a security-adjacent effect (it forced manual PIN entry).


The tenth round — attacking the ninth round’s fixes, then a clean pass

Every round so far had attacked the app. The tenth round attacked the previous round’s patches — on the premise that has driven this entire campaign: a fix is new, untested code, and the code most likely to hide a fresh bug is the code I wrote last, while convinced I’d just made things safer. So I took the ninth round’s two trickiest fixes — the recovery path and the restore-salt transfer — and red-teamed them as if they were a stranger’s pull request. Each one had a real bug in it.

The recovery fix that could be handed an empty-database decoy

The ninth round’s recovery fix was careful to never promote a half-finished encrypted database until it verified the file opened with the key. But the check it used to decide whether the user’s original database was still good plaintext had a hole. SQLCipher’s connection-open call creates the file if it’s missing, and an unkeyed SELECT count(*) succeeds against a freshly created empty database. So a local attacker could delete or swap the original, and the recovery probe would happily auto-create an empty database, see that it “opened,” and accept that empty decoy as the user’s real journal — silently substituting an empty journal for the real one. Rated HIGH (local-access).

Fix: open existing-setup databases with no-create semantics, so a missing file is an honest error rather than a fabricated empty decoy; and require the probe to find a real journal_entries table before it accepts a file as the user’s original, so a zero-table file is never mistaken for the genuine database. Two regression tests now assert the decoy is neither fabricated nor accepted. Committed in 2334269.

The restore-salt fix that wrote an attacker-controlled salt unchecked

The ninth round’s restore-salt fix made “Sync from Another Device” actually work by carrying the encryption salt along with the database. But it wrote the incoming salt and encrypted flag straight to the new device’s state file with no validation, and the integrity checksum covered only the database bytes — not the state file. A trusted-but-compromised source peer could therefore send a garbage or wrong salt and permanently lock out the freshly restored device (the same password would derive the wrong key, forever), and a local attacker could swap just the salt past the checksum. Rated HIGH (lockout / availability).

Fix: validate the incoming salt at the moment of receipt — encrypted: false must carry no salt; encrypted: true must carry a standard-base64, exactly-16-byte salt; anything else is rejected and the restore aborts before any state file is written. The integrity digest was rebound to cover the database bytes and the state JSON together, so the salt can no longer be tampered with independently, and a missing checksum now discards the restore instead of proceeding unverified. Seven new tests; the same-password happy path is preserved exactly. Committed in fa2d299.

The part that actually closed the campaign

Finding a bug in each of the two fixes I’d specifically set out to attack was, oddly, the encouraging result — it meant the method was working. The decisive moment came after. With both follow-on bugs fixed, I ran an independent verification hunt — a fresh-context pass looking for new high-severity issues and re-checking that every happy path still worked — and it came back clean. No new HIGH. No journal-content exposure in any round. The same-password restore, the encrypt-and-reopen round trip, the peer handshake, the lock guards — all still intact.

That clean pass is what let me stop, and it’s worth being precise about why it’s a legitimate stopping point rather than fatigue dressed up as confidence:

  • The externally-reachable surface converged to zero. Everything an attacker on your network — or hitting your app without first owning your machine — could reach was closed across the ten rounds. The bugs that remained at the end were all local-access or lockout-class: they require a thief who already holds your unlocked device, or a device you once paired and then lost. That’s a real but fundamentally different threat tier, and I flag each remaining residual — the deferred live memory dump, the Linux re-validation, the per-device restore-arm scoping, the last Zeroizing sweeps — at the point it comes up rather than burying it.
  • The worst-case shifted from confidentiality to availability. The tenth round’s two findings could deny you access (an empty decoy, a lockout) — they could not read your journal. That direction of travel matters: a campaign that keeps finding “attacker can read your data” bugs hasn’t converged; one whose residuals are “attacker who already owns your laptop can make you re-set-up” has.
  • The one invariant held all ten rounds. Across every round, on every OS, no attack ever exposed journal content to a party not meant to see it. The zero-knowledge core — entries are AES-256-GCM ciphertext, the key derives from your password and is never stored — was never broken. Everything fixed was around that core, never through it.

Convergence, not perfection, is the honest claim. You do not attack your way to a proof that zero bugs remain — no finite test can show that. What you can do is drive the reachable surface to zero, watch the residuals migrate from “reads your data” to “needs your unlocked laptop and only annoys you,” confirm the central invariant never bent, and then run one more independent hunt and have it come back empty. When that happens, the loop has terminated honestly. That’s where the tenth round left it.

For the technically inclined — the create-on-missing decoy, the unvalidated salt, and the integrity rebind

Recovery empty-DB decoy (2334269). Two coupled defects in the 0774a3e recovery refactor. First, Database::new and the startup recovery probe opened databases via SQLCipher’s Connection::open, which creates-on-missing — and an unkeyed SELECT count(*) succeeds against a fresh empty DB — so the “is the original a readable plaintext database?” probe accepted an auto-created empty file as the user’s original, and Database::new fabricated empty DBs for missing-but-expected files. Fixes: open existing-setup DBs (db_state.encrypted || salt present) with SQLITE_OPEN_READ_WRITE and no CREATE, so a missing file errors ("database file missing") instead of fabricating a decoy (the genuine fresh-install path still creates); and promote_pending_tmp’s original-is-plaintext probe now opens without CREATE and requires a real journal_entries table, so an empty/zero-table file is never accepted — the tmp is left untouched rather than discarded. Two more fixes rode along: a revert-to-encrypted:false path now preserves the existing salt (it had written salt: None, stranding a recoverable encrypted DB), and moodhaven_enc.db-wal/-shm are cleaned before the atomic promote. +2 tests; 174 pass.

Unvalidated restore salt + integrity rebind (fa2d299). The 07e9d44 change wrote the attacker-controlled salt/encrypted from RestoreEnd to db_state.json with no validation, and the SHA-256 covered only the DB bytes — so a compromised source peer could send a garbage salt for a permanent lockout, and a local attacker could swap just the salt past the checksum. Fixes: validate_restore_salt() at receipt (encrypted:false ⇒ salt:None; encrypted:true ⇒ Some + standard base64 + exactly 16 bytes; true+None rejected), with do_full_restore_client aborting (drop handle, remove tmp) before writing any companion file on violation; restore_integrity_digest() = SHA-256(db_bytes || dbstate_json) so the writer and both verifiers (peer_apply_and_restart and the lib.rs startup check) recompute a bound digest and re-validate the salt before applying, and a missing checksum now discards rather than proceeding unverified; plus a cleanup of stale pending/.tmp/.sha256/.dbstate at restore start. Auth/consent ordering and serde-default wire-compatibility preserved; the same-password happy path is unchanged. +7 tests; 181 pass.

The verification hunt. After both fixes, an independent fresh-context pass hunted for new high-severity findings and re-exercised the happy paths (same-password restore, encrypt→close→reopen round trip, peer handshake, lock guards). It produced no new HIGH and confirmed the paths intact — and added regression coverage rather than new findings (updater-integrity and peer-sync password-mismatch tests in 6b678f5). That empty result against intact happy paths is the close-out signal.


What’s left to show

The bugs are all on the table now — fixed and accounted for. What I haven’t shown yet is the part that made the whole campaign possible, and, honestly, the part I’m proudest of.

Part 4 is the meta: the bespoke tooling I had to build (no off-the-shelf scanner can speak a private encrypted desktop protocol), the two dozen attacks that failed and why that counts, the full ten-round scoreboard, and the lessons that generalize well past this one app.

Share LinkedInXBlueskyReddit