All posts

Where Cipher's database key lives, and what happens when it disappears

ยท by Sk Masum Ali

Cipher stores your transactions in a SQLCipher database with AES-256. That moves the problem rather than solving it: now the passphrase for the database has to live somewhere. This post is about where, and about the one decision I'm most careful with, which is what to do when the passphrase can't be read.

A random passphrase, wrapped by the Keystore

On first run Cipher generates 64 random bytes with SecureRandom and uses them as the database passphrase. It never stores them as they are. They are encrypted with an AES/GCM key that lives in the Android Keystore, and only the encrypted form goes into preferences. The Keystore key itself never leaves secure storage, and Cipher asks for StrongBox first and falls back to the normal Keystore if the device doesn't have it.

Moving off EncryptedSharedPreferences

The passphrase used to be kept in EncryptedSharedPreferences. Existing installs have to keep working, so the read path checks for the new location first. If it only finds a legacy value, it re-encrypts that value with the Keystore wrapper, saves it in the new place and clears the old one. A migration must never lose the key, so the new copy is written before the old one is cleared.

if (legacyPassphrase != null) {
    val encryptedHex = keystoreManager.encrypt(legacyPassphrase)
    sharedPrefs.edit { putString(KEY_DB_PASSPHRASE_V2, encryptedHex) }
    legacyPrefs.edit { clear() }
    return decodePassphrase(legacyPassphrase)
}

When the key can't be read

Keystore keys don't survive everything. A restore onto a new phone is the classic case: the encrypted passphrase comes along, the key that unlocks it doesn't. The easy way out is to notice the failure and generate a fresh passphrase. That would work, and it would also quietly orphan every transaction the user has, because the old database can no longer be opened.

So decrypt returns null on any failure, and the caller turns that into a dedicated exception instead of making a new key:

class DatabaseKeyUnavailableException :
    IllegalStateException("The stored database key can no longer be decrypted")

The app catches it, explains what happened and offers recovery. Discarding the key is a separate, explicit step. That is also why app data is excluded from phone-to-phone transfer: people move with a password-protected backup file instead, and nobody ends up with a database they can't open.

Command menu

Jump to a page, open a profile, or run an action