Skip to the content.

Passdroid++ internals

How Passdroid++ is built and how it works: the structure of the code, the session and logout handling, the key derivation and encryption, the database and file formats, the security design, testing, making changes and releasing.

Contents

Overview

Building

You need JDK 21 and the Android SDK with platform 37 and build tools 36.0.0. Point ANDROID_HOME or sdk.dir in local.properties to the SDK.

./gradlew assembleDebug
./gradlew testDebugUnitTest lintDebug

The “Build” workflow, .github/workflows/build.yml, runs on every push and pull request. It installs platform android-37.0 and build tools 36.0.0, runs testDebugUnitTest lintDebug assembleDebug assembleRelease with the default version, and uploads the debug APK. The release build is there to check that R8 keeps what the app needs. The debug APK is signed with a debug key generated for each run, so the debug APKs of different runs can’t be installed over each other. Releases are made with the “Publish” workflow, see Releasing.

Source layout

All code is in app/src/main/java/com/kodholken/passdroid/.

Area Classes
Startup PassdroidApplication, MainActivity, InitializeActivity, PasswordLengthHint, LoginActivity
Session Session, the four Timeout*Activity base classes, ScreenOffListener
Crypto MasterKey (key derivation), Crypto (legacy key, AES helpers), PasswordEntry (field encryption), Base64
Data db/PasswordData, db/SystemData, db/DbMigration, PasswordModel, PasswordModelListener
Entries PasswordListActivity, PasswordAdapter, FullWidthLayout, ShowActivity, AddActivity, EditActivity, PasswordToggle, DateField
Clipboard Utils.copyPasswordToClipboard(), Utils.copyToClipboard(), ClipboardClearReceiver
Fingerprint BiometricUnlock, SettingsActivity
Export and import ImportExportActivity, FileExportActivity, FileExporter, FileImportActivity, FileImporter, FileImporterException, ImportPasswordActivity, SettingsBackup
2FA Totp, TotpListActivity, QrScan, ScanActivity, QrDecoder, QrEncoder
Generator GeneratePasswordActivity, DisplayPasswordActivity, PasswordStrength
Settings SettingsActivity, res/xml/settings.xml, ThemeSetting, FontSetting, ChangePasswordActivity
Misc Utils, Version, Constants, AboutActivity, OverscrollListView

Resources worth knowing: res/values-night/ (dark theme colors), res/layout-land/ (landscape layouts of the About screen and the generator), res/values/arrays.xml (setting values, generator character sets, strength ratings), assets/fonts/ (Roboto Mono and its license).

Unit tests are in app/src/test/java/com/kodholken/passdroid/, see Tests.

Startup and navigation

  1. PassdroidApplication.onCreate() registers the ScreenOffListener. This runs whenever the process starts, also when Android restarts the process for the activity that was on top, which doesn’t go through MainActivity.
  2. MainActivity (the launcher activity) decides where to go in onResume():
    • logged in: the password list;
    • otherwise it makes sure the system table exists, runs DbMigration.preLoginMigration(), which stores the data version if there is none (see Database), and starts LoginActivity if a master key record exists, else InitializeActivity.
  3. InitializeActivity creates the master key from the new password (at least 12 characters), stores the record and logs in.
  4. LoginActivity derives the key in a background thread, runs the data migrations (DbMigration.postLoginMigration()), upgrades a legacy master key (see Master key), and starts PasswordListActivity. It stays below the list, so a logout returns to it.

Back handling: back on the login screen exits the app. It sets Session.setExitMain(true) so that MainActivity finishes itself instead of starting the login again. Back on the password list closes the search, or else asks whether to log out. On Android 13 and later back is handled with an OnBackInvokedCallback, since Android 16 doesn’t deliver onBackPressed() to apps that target it.

Session and automatic logout

Session is a process-wide singleton with the key, the login state, a “reload the list” flag and the timeout state. Nothing of it is persisted: when the process dies, the user is logged out.

Logging out

Session.setLoggedIn(false) is the single place where a logout happens, whatever the reason. It zeroes the key array, drops the reference, and calls PasswordModel.clear() to drop the decrypted entries. logout() (the menu item and back on the list) also sets loggedOutByUser, so the login screen doesn’t open the fingerprint prompt right away.

Activities that show data extend one of four base classes: TimeoutActivity, TimeoutListActivity, TimeoutPreferenceActivity and SherlockTimeoutActivity. They are copies of the same code on different platform base classes, and SherlockTimeoutActivity is identical to TimeoutActivity. Each:

The timeout

Screen off

Background work

Utils.runWithProgress() runs slow work (key derivation, an import with a password, reading a QR code from an image) in a plain thread behind a non-cancelable progress dialog, and the completion on the UI thread, unless the activity has been destroyed or is finishing. Screens guard against starting the same work twice with a flag.

Master key

Current format

MasterKey derives a 256 bit key with PBKDF2-HMAC-SHA256, 600,000 iterations and a random 16 byte salt. The system table stores the record

pbkdf2-sha256$<iterations>$<base64 salt>$<base64 verifier>

with verifier = SHA-256("passdroid-key-verifier" || key). A password is checked by deriving the key with the stored salt and iteration count and comparing the verifier in constant time (MessageDigest.isEqual).

Legacy format

The original Passdroid derives the key without a salt or iterations:

At a successful login with a legacy record, LoginActivity also creates a new PBKDF2 key from the password and calls DbMigration.changeKey(), which re-encrypts all entries and stores the new record in one transaction. If that fails, the legacy key stays in use and the upgrade is tried again at the next login. Fingerprint unlock can’t be turned on before the upgrade.

Changing the master password

ChangePasswordActivity derives the key from the old password (which verifies it) and a new key from the new password in a background thread, then calls DbMigration.changeKey(). On success the old session key is zeroed and replaced, and fingerprint unlock is turned off, since its stored key is the old one.

DbMigration.changeKey() decrypts every entry with the old key and only continues if every field decrypted. It then re-encrypts all entries, updates the key record and deletes the stored fingerprint key, all in one SQLite transaction. It runs on the UI thread.

Database

One SQLite database, password.db (Constants.DBNAME), in the app’s private storage. Schema version 3 (Constants.DBVERSION).

CREATE TABLE data (id INTEGER PRIMARY KEY AUTOINCREMENT,
                   system TEXT NOT NULL, username TEXT, password TEXT,
                   note TEXT, url TEXT, email TEXT, totp TEXT, date TEXT);

CREATE TABLE system (id INTEGER PRIMARY KEY AUTOINCREMENT,
                     attribute TEXT NOT NULL, value TEXT);

Every value in data except id is encrypted, see Entry encryption. system holds attributes:

Attribute Value
key The master key record
data_version The data version, DbMigration.DATA_VERSION (3)
version An app version, e.g. 2.4, which older databases have instead of data_version. Replaced by data_version at the next start
biometric_key The master key encrypted for fingerprint unlock, see Fingerprint unlock

Two SQLiteOpenHelper classes, PasswordData and SystemData, open the same database. Either can trigger the upgrade, so both onUpgrade() methods only call DbMigration.handleDatabaseUpgrade(). Each table is created on demand by its helper’s verifyTable().

There are two kinds of migration:

Entry encryption

Each field is encrypted separately (PasswordEntry.encrypt()):

base64( IV (16 random bytes) || AES-256-CBC-PKCS7(key, IV, UTF-8 value) )

decrypt() returns null when a value can’t be decrypted. DbMigration.changeKey() uses that to refuse re-encrypting a database with the wrong key.

Loading entries

PasswordModel is a process-wide singleton over the data table.

Screens that change entries write to the database directly and set Session.setNeedReload(true), which makes the list reload in onResume().

Screens and data flow

Screens pass only the entry id in intents (ShowActivity.EXTRA_ID, EditActivity.EXTRA_ID) and look the entry up in PasswordModel, so no decrypted values go through the system in intents. Two intents carry a secret that isn’t stored yet: the generated password to DisplayPasswordActivity, and the text of a scanned QR code from ScanActivity.

Clipboard

Fingerprint unlock

BiometricUnlock, Android 9 and later.

Export and import

XML

Both cleartext exports and the content of encrypted exports are this XML, UTF-8 encoded:

<?xml version="1.0" encoding="UTF-8"?>
<passdroid version="3" app-version="0.0.0-dev">
  <system name="GitHub">
    <email><![CDATA[alice@example.com]]></email>
    <username><![CDATA[alice]]></username>
    <password><![CDATA[s3cret]]></password>
    <totp><![CDATA[JBSWY3DPEHPK3PXP]]></totp>
    <note><![CDATA[Recovery codes in the safe]]></note>
    <url><![CDATA[https://github.com]]></url>
    <date><![CDATA[2026-10-25T14:30]]></date>
  </system>
  <settings>
    <setting name="clear_clipboard">true</setting>
    <setting name="close_logout">true</setting>
    <setting name="display_username">true</setting>
    <setting name="font">default</setting>
    <setting name="font_size">normal</setting>
    <setting name="logout_delay">5</setting>
    <setting name="screen_off_logout">true</setting>
    <setting name="theme">system</setting>
  </settings>
</passdroid>

Encrypted export (“sq2”)

offset  size  content
0       3     "sq2"
3       4     PBKDF2 iteration count, big endian
7       1     salt length n
8       n     PBKDF2 salt
8+n     12    AES-GCM nonce (random)
20+n    ...   AES-256-GCM(XML), followed by the 16 byte tag

Legacy encrypted export (“sqt”)

"sqt" followed by AES-256-CBC with a zero IV over 2 random bytes and the XML, with the legacy key. The original Passdroid writes these. Passdroid++ only writes one for a database whose legacy key couldn’t be upgraded, and always reads them.

Exporting

FileExportActivity asks for a file with ACTION_CREATE_DOCUMENT (the Storage Access Framework, no storage permission), opens it with mode "wt" so an existing file is truncated, and writes the XML of PasswordModel.getDecryptedPasswords(), encrypted or not. The stream is closed before success is reported, since some document providers only store the data then.

Importing

FileImportActivity:

  1. picks a file with ACTION_OPEN_DOCUMENT and reads it into memory, at most 10 MB;
  2. detects the format by the first bytes (sq2, sqt, else XML);
  3. encrypted: tries the current session key first. If the key is wrong, ImportPasswordActivity asks for the password, and parseEncryptedWithPassword() derives the key: PBKDF2 with the salt and iterations from the header for sq2, the legacy HMAC for sqt. FileImporterException.isWrongKey() tells whether another password could help. An sq2 file that decrypts is authenticated, so a later error, such as a newer format, is an error of the file and is shown as such. sqt is not authenticated, so all its errors count as a wrong key;
  4. cleartext: parsed directly; afterwards the app offers to delete the file (DocumentsContract.deleteDocument());
  5. stores the entries with PasswordModel.setPasswords(), which replaces all entries, and applies the settings of the file with SettingsBackup.apply().

FileImporter.parse() decodes the bytes as UTF-8 itself (dropping a byte order mark) and hands the parser a StringReader, so the parser can’t pick another encoding. It then rejects a document type declaration before the root element (hasDoctype()): Android’s parser doesn’t support turning off entity expansion, and a DTD could define entities that expand to gigabytes. Values inside the elements may contain the same text.

2FA codes

Secrets

Totp.parse() accepts:

otpauth://hotp/ (counter-based) URIs are rejected.

getCode(time) implements RFC 6238 on top of the RFC 4226 dynamic truncation, and is tested against the RFC 6238 test vectors.

Storing a scanned secret

Totp.getSecretToStore(): for a URI with the default parameters only the base32 key is stored, the same text the user would type; otherwise the whole URI, so the parameters are kept. QrScan fills empty system, email (if the account contains @) or username fields from the issuer and account of the URI. QR codes without a 2FA secret are rejected, with a specific message for the export codes of authenticator apps (otpauth-migration://).

Scanning

Showing a secret as a QR code

ShowActivity builds an otpauth://totp/ URI with Totp.toUri() (issuer = system, account = username or else email; percent encoded, with %20 for spaces; a stored URI is used as it is), encodes it with QrEncoder (ZXing QRCodeWriter, error correction M, quiet zone of 4 modules, UTF-8 only when needed) and shows it in a dialog. The dialog sets FLAG_SECURE itself, since a dialog has its own window, and is dismissed in onPause().

2FA codes screen

TotpListActivity decrypts the secrets of all entries each time it is shown and keeps only the Totp generators, which hold the decoded keys. A handler runs every 50 ms to move the bar for the 30 second period and rebinds the list once a second. Entries with another period show their own seconds left. A tap copies the code, a long press opens the entry. The title is the system, followed by the username (or the email) in parentheses if display_username is on.

Password generator

Settings, theme and fonts

Settings are default SharedPreferences, defined in res/xml/settings.xml:

Key Default Used by Exported
close_logout true Session.setTimeoutTimer() yes
logout_delay 5 (seconds: 5, 30, 60, 300) Session.getTimeout() yes
screen_off_logout true Session.screenOff(), clearTimeoutTimer() yes
display_username true the password list and the 2FA codes screen yes
clear_clipboard true ClipboardClearReceiver yes
theme system (light, dark) ThemeSetting yes
font default (monospace) FontSetting yes
font_size normal (small, large) FontSetting yes
biometric_unlock not persisted SettingsActivity no
passwordLength, passwordCharset 16, 0 the generator no

SettingsBackup.get() collects the exported settings (after applying the defaults, so unchanged settings are included too). SettingsBackup.apply() only takes known keys with valid values: true or false for the check boxes, a value of the setting’s array resource for the lists. Other values are ignored and keep the current setting.

Android platform details

Behavior that depends on the Android version, and where it is handled:

Android What Where
7.0–7.1 No platform PBKDF2-HMAC-SHA256 MasterKey.deriveKey() fallback
9 clearPrimaryClip() ClipboardClearReceiver
9–10 Biometric availability checks BiometricUnlock.isAvailable()
10 No clipboard reads in the background ClipboardClearReceiver
11 BIOMETRIC_STRONG, setUserAuthenticationParameters() BiometricUnlock
12 Immutable PendingIntents, exported launcher activity Session, ClipboardClearReceiver, manifest
13 RECEIVER_NOT_EXPORTED, photo picker, sensitive clips, back callbacks Utils.registerNotExportedReceiver(), QrScan, Utils, activities
14 Held-back broadcasts for cached apps, accessibilityDataSensitive Session.clearTimeoutTimer(), layouts
15 Edge-to-edge windows Utils.applySystemBarInsets()
16 No onBackPressed() for apps that target it OnBackInvokedCallback

Other settings:

Security

How Passdroid++ protects its data, the trade-offs that were made on purpose, and what is still open. The sections above describe how each part works.

Threat model

Passdroid++ protects passwords and 2FA secrets at rest:

It has no network access (no INTERNET permission), so nothing it holds can be sent anywhere by the app itself.

Out of scope, as for any app on Android:

Everything rests on the master password: anyone with a copy of the data can try passwords offline, as fast as their hardware allows.

Design

Master key

Rough strength: one high-end GPU tries in the order of 10,000–20,000 master passwords per second against this derivation. 8 random lowercase letters last months, a common word or a leaked password minutes, and a random 5 word passphrase is out of reach.

Database

Exports and imports

Legacy formats

Fingerprint unlock

Android 9 and later, off by default.

Session and logout

Data in memory

Clipboard

Screens and input

2FA

Password generator

Android integration

Release signing

Decisions

Trade-offs that were made on purpose:

Open items

Not done, in rough order of value:

  1. Master password minimum 15 instead of 12, to match NIST SP 800-63B-4 for a password that is the only factor. A one-line change; existing shorter passwords would only get the reminder.
  2. zxcvbn strength meter for the master password, which knows common words, patterns and leaked passwords. Needs a library with word lists of about 1–2 MB.
  3. “Vault format v2”: AES-GCM for the database fields with the entry id and field name as associated data, and Argon2id instead of PBKDF2, in one migration at login. The riskiest kind of change, since it rewrites the whole vault. Only with device testing and a fresh export at hand.
  4. Imported security settings: an import can turn off “Log out on close”, the screen off logout and the clipboard clearing, without saying which settings changed. It could keep the current values of those, or list the changes and ask.
  5. Optionally a default generator length of 17, which rates excellent instead of good.

Limitations that are not about security are listed in Known limitations.

Recommendations

Sources

Tests

Local JVM unit tests (./gradlew testDebugUnitTest):

Class Covers
FileExportImportTest Cleartext, sq2 and sqt round trips with awkward values (]]>, <!DOCTYPE in values, Unicode); invalid characters; DTD rejection, also behind a comment and in UTF-16; files without some fields; settings; wrong key, modified file, unreasonable iteration count; a wrong key told apart from a file that can’t be imported; format versions of older files, files of each older format, newer and invalid versions
MasterKeyTest PBKDF2 against the RFC 7914 vectors, empty and long passwords, UTF-8, create/unlock, random salts, legacy records, malformed records, minimum length
PasswordEntryTest Reading and writing entries compatible with BouncyCastle, the zero IV and ECB conversions, on-demand decryption, decrypted copies, old entries without the newer fields
TotpTest RFC 6238 vectors, key formats, URI parameters, invalid secrets, what is stored for a scan, issuer and account, URIs for QR codes
QrDecoderTest Decoding images and luminance data, light on dark codes, no code, encoder round trip
PasswordStrengthTest Bits and ratings
SessionTest Both kinds of logout wipe the key
DbMigrationTest Data versions of databases that stored the app version, invalid versions

BouncyCastle (bcprov-jdk18on) is only a test dependency, to check that the default provider reads and writes what the BouncyCastle provider wrote. There are no instrumented tests; screens, the Keystore, the camera and the clipboard are only tested by hand, see Manual tests.

Lint runs in CI (lintDebug) and must have no errors.

Manual tests

What the unit tests don’t cover, worth checking on a device before a release:

Making changes

Adding a field to entries

  1. PasswordEntry: the enc/dec fields with getters and setters, decryptAll(), encryptAll(), decryptedCopy(), and a decryptX() method if it is decrypted on demand.
  2. PasswordData: COLUMNS, onCreate(), readEntry(), getValues().
  3. Constants.DBVERSION + 1, and an ALTER TABLE step in DbMigration.handleDatabaseUpgrade().
  4. DbMigration.isDecrypted(), so a password change checks the field too.
  5. FileExporter.createXmlTree() and FileImporter’s parseSystemNode(). Old files must still import without it. Increase FileExporter.FORMAT_VERSION, so earlier versions refuse the files instead of leaving the field out.
  6. The add, edit and show layouts and activities, FontSetting if it shows a value.
  7. Tests in FileExportImportTest and PasswordEntryTest.

Adding a setting

Add it to res/xml/settings.xml. If it should be exported, add it to SettingsBackup (the boolean list, or the list map with the array resource of its allowed values). Settings that only make sense on one device, like fingerprint unlock, are not exported.

Changing a file format

Keep reading every older format; exports are the users’ backups. Write a new format only with a new signature or a higher FileExporter.FORMAT_VERSION, add it to the table in FileImporter.getFormatVersion(), and add a round trip test plus a test that reads a file in the old format. If entries are stored differently in the database, increase DbMigration.DATA_VERSION and add the conversion to postLoginMigration().

Releasing

Releases are made with the “Publish” workflow, .github/workflows/publish.yml, from the Actions tab (“Run workflow”). It runs the tests and lint, builds and signs the APK, tags the commit v<version>, and creates a GitHub release with the APK and its checksum. The work is done by publish.sh, which runs locally as well, see ./publish.sh --help.

Version and version code

Steps

  1. Build (publish.sh build): testDebugUnitTest lintDebug assembleRelease with that version.
  2. Package (publish.sh package): checks with aapt2 that the unsigned APK has that version, signs it with apksigner (v2 and v3, no v4 file), verifies it, and writes release/passdroid-<version>.apk and release/checksums.txt. Without the signing secrets the run fails: Android never installs an unsigned APK.
  3. Release, in a second job, the only one with write access: tags the built commit v<version> (publish.sh release; the tag may already be there when a failed release is run again) and creates the GitHub release with gh release create --generate-notes, as a pre-release if the version has a -.

The “Step” switches turn off building, packaging or releasing, e.g. to sign an APK for testing without a release; releasing needs the other two. “Source branch” builds another branch with the publish.sh of the branch the workflow runs from. Runs don’t overlap (concurrency), so two runs can’t pick the same version. The run summary shows the version code, the commit and the checksum. Both workflows pin their actions by commit hash.

Signing key

The APK is signed with the key from these repository secrets:

GitHub hides the values of secrets in the logs, so a secret must not be a word that appears in them, like “passdroid”.

Create the keystore yourself, on your own computer, never in CI or in shared storage, for example with

keytool -genkeypair -v -keystore passdroid-release.jks -alias passdroid -keyalg RSA -keysize 4096 -validity 10000

Keep it and its password backed up: Android only installs an update that is signed with the same key. Locally, publish.sh package signs with the keystore in KEYSTORE_FILE, and reads the passwords from KEYSTORE_PASSWORD and KEY_PASSWORD or asks for them.

Known limitations

Code that does nothing:

Limitations:

The open security items, e.g. that an import can turn off the automatic logout settings, are listed in Open items.