Cleanthes

An Android password manager I build as a security case study. The source is public, so every claim below points at code you can read, and I state what it does not protect.

One random key encrypts the vault, and your password only wraps it

When you create a vault, Cleanthes generates a random 256-bit key. Entry fields are encrypted with it using AES-GCM, with a fresh random 12-byte nonce each time. Your master password never encrypts data directly. Password unlock and biometric unlock are two independent ways to unwrap the same vault key.

The master password is stretched 310,000 times first

PBKDF2-HMAC-SHA256 with 310,000 iterations and a 256-bit output, using random 16-byte salts. The hash that verifies a login and the key that wraps the vault key use separate salts.

Biometric unlock is bound to a hardware-backed key, not just a prompt

The biometric path wraps the vault key under an Android Keystore AES-256 key that requires user authentication. The prompt accepts strong biometrics only, and the vault key cannot be unwrapped unless the prompt returns the unlocked cipher. Hardware backing depends on the device, and Cleanthes does not require StrongBox.

Autofill is weaker by design. It asks for a biometric confirmation on top of an already unlocked session. That is an identity check, not a cryptographic one.

The key is in memory only while the vault is unlocked

A session manager holds it. The app locks after 30 seconds in the background or 2 minutes without activity. Code reaches the key only through a scoped function that returns nothing when the vault is locked, and the timers have unit tests.

Locking drops the reference. It does not overwrite the key bytes, and decrypted values are ordinary Kotlin strings. Anything readable from process memory while the vault is unlocked is exposed.

Secret screens resist capture, and backups are off

Login, setup, the autofill prompt and the ten authenticated screens set the secure-window flag. Screenshots and screen recording are blocked, and the content is hidden in the app switcher. App backup is disabled. Release builds are minified and resource-shrunk with R8.

Two features make network requests, and each sends little

The favicon feature sends only a site's hostname to my own proxy, then caches the icon on the device. The proxy therefore learns which sites you store logins for.

The breach check runs only when you tap it, after re-authentication. It sends the first five characters of the password's SHA-1 hash to the Pwned Passwords range API with padding on, and compares the rest on the device.

A device check that reports, and does not enforce

Settings shows whether the device's Keystore reports software, TEE or StrongBox security. It generates a throwaway key, validates the certificate chain against Google's attestation roots bundled in the app, and reads the security level and boot state from the certificate. It does not gate unlocking, and it checks a probe key, not the vault key.

Known gaps: it does not compare the challenge, it does not read the device-locked flag, and revocation checking is off.

What it does not protect

The category, timestamps, the favourite flag, and the TOTP issuer, digits, period and algorithm are stored unencrypted. The database file as a whole is not encrypted, only individual fields.

How it is built

21 Gradle modules: feature modules, a domain layer, and api/impl splits for data, session and attestation, with fakes for tests. Kotlin, Jetpack Compose, Hilt, Room 2.7 with exported schemas and migrations, Java 17.