The Android app is a standalone client. It runs vault-core and vault-client as a native
library through UniFFI, talks to vault serve directly, and works offline from its local copy. It
follows the spec Sync and Autofill
matching, and the Android rules in the
requirements. It needs Android 10 (API 29) or later and no
Google Play services. Its application ID is com.codeheadsystems.vault.
Its measurements come from a spike on API 29 and API 36 emulators on 2026-10-13; no physical phone was available. Open checks lists what still needs a real device or a test.
Phase 4 builds AND-1 to AND-9.
| ID | Feature |
|---|---|
| AND-1 | Setup by scanning the setup QR code, then the secret key, then the master password |
| AND-2 | Browse, search, view, and copy; copied secrets flagged sensitive |
| AND-3 | Autofill service for apps and allowlisted browsers, with save offers |
| AND-4 | Biometric unlock through a Keystore key invalidated by a new enrolment |
| AND-5 | Add, edit, and generate |
| AND-6 | Offline use from the local copy |
| AND-7 | Android 10 and later |
| AND-8 | Show the secret key as text and a QR code after the master password |
| AND-9 | No plain http in release builds; the certificate pinned to fp |
Bitwarden import and export stay in the CLI. Credential change, recovery, restore, and token repair
run on Android through the same vault-client machines as the CLI, so the phone can recover the
vault when it is the last unlocked device.
| Component | Path | Role |
|---|---|---|
vault_client::session |
crates/vault-client/ |
The unlocked session, the procedure driver, and the commit sequence, shared by both bindings |
vault-android |
crates/vault-android/ |
UniFFI bindings over vault-client, built as a cdylib; owns the state lock and the Public Suffix List |
| App | android/app/ |
Kotlin: the activities, the autofill service, file and Keystore I/O, and transport |
| Generated bindings | android/app/build/ |
Kotlin generated by uniffi-bindgen at build time, not committed |
The app is one Gradle module, with Jetpack Compose and Material 3. Every component runs in the one
default process; no component sets android:process, and a manifest test checks it. The process
holds the one unlocked session.
The session, the procedure driver, and the commit sequence move from vault-wasm into
vault_client::session as typed Rust, with no serde_json and no binding types. vault-wasm
keeps its JSON and camelCase conversions and its API.md unchanged, and vault-android wraps the
same session in a Mutex, because UniFFI objects are shared and take &self. The save ordering
of a pending secret key (store the key, then commit, then write the state), now written out in the
CLI and in vault-wasm, becomes commit::apply_save, and all three clients call it.
vault-android exposes the same coarse operations as vault-wasm, so secrets cross into Kotlin
only where the UI needs them: unlock, lock, and the session state; the vault-client machines,
stepped from Kotlin; item list entries without secrets, one item in full, and edits; autofill
matching and save offer decisions; password generation; and the pin check. The unlocked body stays
in Rust memory. Errors cross as their stable code strings.
vault-android owns the state lock and the read, apply, and write sequence. Kotlin supplies raw
file and key-slot I/O through a UniFFI foreign trait, so it never parses local.json. The vault
key crosses into Kotlin in one place: the biometric Cipher, which wraps and unwraps it in a
ByteArray that the code zeroes when done.
UniFFI is pinned to an exact version (=0.32.x), as wasm-bindgen is. uniffi-bindgen is a
binary behind a feature of vault-android, run with cargo run --locked. UniFFI generates about
8,000 lines of Kotlin, loaded through JNA. No function is async; Kotlin calls the library from a
background dispatcher. The crate compiles under #![forbid(unsafe_code)]; the unsafe code is in
UniFFI's scaffolding and in JNA.
| Measure | Value |
|---|---|
cargo ndk -t arm64-v8a -t x86_64 -P 29 with the android profile, from clean |
25 s |
Stripped library, arm64-v8a and x86_64 |
1,762 KB and 1,984 KB |
Release APK, R8, arm64-v8a and x86_64, with Compose, CameraX, and ZXing |
8.0 MB |
| Floor unlock, Argon2id only, API 29 and API 36 emulators | 150 to 214 ms and 227 to 353 ms |
| Full unlock of a real vault file, API 29 emulator | 123 to 162 ms |
| Ceiling, 256 MiB and 10 iterations, API 29 emulator | about 2.4 s |
The emulators run on x86_64, where the argon2 crate uses AVX2. It has no NEON path, so a phone
runs it as scalar code: an estimate, not a measurement, puts the floor at 0.3 to 0.5 s on a recent
phone and 1 to 1.5 s on a 2019 Cortex-A53 phone. Argon2id allocates from the native heap, outside
the Java heap limit; a 2 GiB API 29 emulator ran the ceiling without a low-memory kill. NDK r28
aligns the library to 16 KB pages, which Android 15 requires. JNA ships seven ABIs, so the build
keeps arm64-v8a and x86_64 only. About 1 MB of each library is code, mostly the vault-client
machines and the UniFFI scaffolding, and 454 KB is the Public Suffix List's rules; the library keeps
opt-level = 3 for Argon2id's speed.
| Data | Location |
|---|---|
Local state (local.json contents, including the vault file) |
filesDir |
| Server URL, token, and fingerprint | filesDir |
| Device ID | noBackupFilesDir |
| Secret key, and a pending one, wrapped by the storage key | noBackupFilesDir |
Vault key wrapped by the biometric key, with the wrap_nonce it belongs to |
noBackupFilesDir |
| Settings without secrets | SharedPreferences |
The storage key is a Keystore AES-256-GCM key that needs no user authentication but is usable
only while the device is unlocked (setUnlockedDeviceRequired(true)). It wraps the secret key, so
neither a copy of the app's files nor code running as the app on a locked phone yields the secret
key. The device ID and the wrapped keys live in noBackupFilesDir, which no backup or transfer
copies. While the device is locked, Cipher.init on the storage key throws InvalidKeyException
with the cause "Device locked", so setup, a credential change, and showing the secret key cannot run
until the device is unlocked. Writes go to a temporary file, then fsync, then a rename.
The manifest sets android:allowBackup="false", a fullBackupContent rule that excludes every
domain (Android 10 and 11), and dataExtractionRules that exclude every domain from cloud backup
and device transfer (Android 12 and later). On an API 34 emulator, allowBackup="false" alone did
not stop a device-to-device transfer from copying filesDir, which holds the token.
The vault key lives in Rust memory while the vault is unlocked.
- Master password: the
vault-clientunlock, typed only in the app's own activities, never in an activity started for another app. - Biometric (AND-4), offered after a master password unlock: a Keystore AES-256-GCM key with
setUserAuthenticationRequired(true),setInvalidatedByBiometricEnrollment(true), andsetUnlockedDeviceRequired(true), requiring strong biometric authentication for every use (setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG)on API 30 and later,setUserAuthenticationValidityDurationSeconds(-1)on API 29), backed by StrongBox where the device has it; generation retries without StrongBox onStrongBoxUnavailableException. On API 29 a locked device does not stopCipher.initon this key; the per-use biometric still stopsdoFinal. It wraps the vault key, and each use goes through the platformBiometricPromptwith aCryptoObject;androidx.biometricis not used. - A new enrolment makes
Cipher.initthrowKeyPermanentlyInvalidatedExceptionbefore any prompt. The app then deletes the key and the wrap, asks for the master password, and offers to set up the biometric again. - The wrap is stored with the
wrap_nonceof the state it belongs to. A commit that changeswrap_nonce, made here or adopted from another device, deletes the wrap. After the commit, and outside the state lock, the app offers to wrap the new vault key with one biometric prompt. When a sync finds a credential change made elsewhere, the app asks for the new master password, as the CLI does. - A credential change and a restore each need a fresh biometric prompt or the master password immediately before they start, even while unlocked. Tokens are revoked on the server, not from the app. On a locked device they stay available after a master password or biometric unlock, which keeps the threat model's recovery path.
A frozen or cached process runs no timer and receives broadcasts late, so the idle lock follows the
extension's rule rather than a timer. Rust records the last activity time on
SystemClock.elapsedRealtime, and every entry point (a fill request, the picker, an activity's
onStart, and every session call) first compares it with the idle time (default 5 minutes) and
locks when it has passed, before doing anything else. Only actions in the app's own activities
count as activity; requests from other apps never do. The app also locks 30 seconds after it
leaves the foreground (ProcessLifecycleOwner ON_STOP), and when a process dies, the vault is
locked. Locking zeroes the session and the vault key in Rust memory.
A procedure that started unlocked and is interrupted by a lock still commits its result, because the server may already hold it, but does not unlock the session again.
Sync runs on unlock (pull first), after every local change, and every 5 minutes while unlocked and
in the foreground. A locked vault cannot merge, so nothing syncs in the background while locked.
One sync runs at a time; a request during a sync sets a flag that runs one more afterwards. A sync
that needs a prompt records stopped, and the main screen names the action that resolves it.
Requests go through HttpsURLConnection, with redirects off, caches off, and TLS 1.2 or later.
- With
fp, a customX509TrustManageraccepts the connection only when the SHA-256 of the leaf certificate's DER encoding equalsfp, throughvault-core's pin check; the chain, the name, and the dates are ignored. AHostnameVerifierchecks the same pin on the session's peer certificate instead of returningtrue. OkHttp'sCertificatePinneris not used: it pins the public key and still requires a valid chain. - Without
fp, ordinary validation uses the system authorities only. The network security configuration excludes user-installed authorities. - Release builds refuse plain
httpeverywhere, because every app on a phone shares the loopback interface and any of them can listen on the port. Debug builds allowhttpto127.0.0.1and[::1]for tests. The rule is enforced in Kotlin before any request and again by the network security configuration, whose cleartext entry for[::1]needs the brackets.localhostis never resolved; a debug setup URL withlocalhostconnects to127.0.0.1. - The
Dateheader comes fromgetHeaderFieldDate. - Release builds strip
Logcalls through R8.
The spike confirmed each of these against a real vault serve reached at 10.0.2.2: the right
pin succeeds, a wrong pin fails, an expired certificate with the right pin succeeds, and cleartext
to anything but loopback is refused.
The app is an AutofillService. Nothing about the vault reaches system_server or the requesting
app until the user picks an item.
onFillRequestchecks the idle lock, then reads theAssistStructure: the requesting package fromactivityComponent.packageName, the username and password fields from autofill hints, HTML attributes (Firefox reports aninputTypeof 0), and input types, and thewebDomainandwebSchemeof the focused field: from its own node or its nearest ancestor with a non-emptywebDomain. Firefox sets them on every node of a form; Chrome sets them on a container node; Firefox's root node has none, or an empty one. It returnsnullfor the app's own package, for Settings (com.android.settings) and System UI (com.android.systemui), whose password fields hold the device PIN, for a request withFLAG_COMPATIBILITY_MODE_REQUEST, and when it finds no field. The service declares no compatibility packages.- It records the page, as the next sections define it, and the fillable field IDs in an in-process
registry under a random single-use token that expires after 5 minutes. It answers with one
Datasetwhose values are allnull, authenticated by anIntentSenderto the picker activity. The intent carries only the token, under a request code unique to the response: with a shared request code,FLAG_UPDATE_CURRENTgives an earlier response the later one's extras, andFLAG_CANCEL_CURRENTcancels the earlier one. The presentation reads "Fill with vault" and names no item. - The picker activity takes the page and fields from the registry and refuses when the token is
unknown, used, or expired. It reads no other extra: the requesting app starts the
IntentSenderin its own process, so it can add extras,EXTRA_ASSIST_STRUCTUREincluded, though it cannot replace one the service set. It unlocks only by biometric prompt, which Android draws and another app cannot imitate. Without a biometric, it shows "Open vault to unlock", and the master password is typed in the app's own task. It then lists the candidates, asks for any confirmation, and returns the chosenDatasetthroughEXTRA_AUTHENTICATION_RESULT.
Only the launcher activity and the autofill service are exported. The picker and save activities
are exported="false", excludeFromRecents, and noHistory, and every component's export status
is stated in the manifest and checked by a test. The launcher activity has no intent filter but
MAIN and LAUNCHER and reads no extras; there is no vault:// deep link, so no app or page can
supply a setup URL and token.
The picker, save, and unlock screens call setFilterTouchesWhenObscured(true) and drop touches
that carry FLAG_WINDOW_IS_PARTIALLY_OBSCURED, and on API 31 and later hide other apps' overlays
(HIDE_OVERLAY_WINDOWS), so an overlay cannot steer a confirmation.
The page the matching rules see is one of three kinds, typed in vault_client::autofill:
| Kind | Source | Holds |
|---|---|---|
| Web | Firefox, from the tab URL | A parsed URL |
| Android web | An allowlisted browser's webDomain and webScheme |
A scheme and a host, with no port, path, or query |
| App | Any other app | A package and its signing certificates |
The spec defines the Android web and app rules in new subsections of Autofill matching, with
vectors in autofill.json checked in Rust, through both bindings, and in tools/vectors-check.
A webDomain is used only when the requesting package and its signing certificate digest are on the
browser allowlist, and the browser reports a webScheme. The allowlist holds Chrome
(com.android.chrome) and Firefox (org.mozilla.firefox) with their current certificate digests;
The digests come from Google's list of privileged browsers for passkeys
(gstatic.com/gpm-passkeys-privileged-apps/apps.json): two for Chrome,
f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83 (seen on the emulator) and
1975b2f17177bc89a5dff31f9e64a6cae281a53dc1d1d59b1d147fe1c82afa00, and one for Firefox,
a78b62a5165b4494b2fead9e76a280d22d937fee6251aece599446b2ea319b04, which Mozilla's
assetlinks.json also publishes. A browser matches when its current certificate is on the list.
Changing the allowlist needs an unlocked vault. A browser that reports no webScheme is treated as
not allowlisted. From any app not on the allowlist, a webDomain is ignored and matching follows
the app rules, so a WebView in an ordinary app cannot ask for a website's credentials.
An Android web page has no port and no path, so the match modes read:
match |
Outcome |
|---|---|
base_domain |
As the spec defines it |
host |
fill when the hosts are equal, the item URI names no port, and the page host is not a local host; confirm when the hosts are equal otherwise |
starts_with, exact |
confirm when the schemes and hosts are equal |
never |
Never |
The scheme rule applies unchanged.
Each field reports its own frame's host, but the structure does not say which frame is the top
page, or whether a frame is involved at all; on Firefox each frame's form is a sibling subtree
under the root, in no stable order. Firefox writes a dataset into every field it names, in any
frame, with no origin check of its own. The page is therefore the focused field's frame, and a fill
reaches only the visible fields in the focused field's window whose own webDomain and
webScheme equal the focused field's; nodes with no or an empty webDomain are ignored. The spike
verified that this confines a Firefox fill to the focused frame, from the top page and from a
cross-origin frame. A frame from the same host on another port reports the same webDomain and
cannot be told apart, and a framed page cannot be told from a top-level one.
Chrome 133 sends only the focused form, under a container that carries the top page's host even when the form is in a cross-origin frame, so the service sees the same structure for a top-page form and a frame's form, and Chrome writes the chosen credential into the frame. A cross-origin frame on a matched site therefore receives a Chrome fill when the user picks an item in it. The requirements record both residual risks.
Known suffixes come from a copy of the Public Suffix List compiled into vault-android behind
vault_client::autofill::SuffixList, with its own tests, and updated with each release. Android
has no public API for the list.
Chrome fills through another autofill service only after the user turns on "Autofill using another service" in Chrome's settings and restarts Chrome. Setup ends with a screen that explains this. Firefox needs no setting beyond the Android autofill service selection.
The requesting app's identity is its package name and its signing certificates from
getPackageInfo(package, GET_SIGNING_CERTIFICATES). API 30 hides other packages without a
declaration, so the manifest has a <queries> element for launcher activities;
QUERY_ALL_PACKAGES is not requested. A lookup of the package, its signing information, or its
label that fails gives confirm, showing the package only.
An app's association with a login item is either unbound or bound:
- An unbound association is a
urisentryandroidapp://<package>, as a Bitwarden import writes it. - A bound association is an
appsentry,{package, cert_sha256}.
| Requesting app | Item association | Outcome |
|---|---|---|
One signer, current certificate equals cert_sha256 |
Bound | fill |
One signer, cert_sha256 only in signingCertificateHistory |
Bound | confirm; a confirmed fill rebinds to the current certificate |
One signer, cert_sha256 not in the history |
Bound | confirm, warning that the app's signature changed; a confirmed fill does not rebind |
| One signer | Unbound | confirm; a confirmed fill adds a bound association |
| More than one signer | Either | confirm; nothing is ever bound |
The picker lists only the items with an association for the requesting package. Any other item
needs "Choose another item", then a confirmation, and is refused for an http web page that is not
a local host, as on Firefox. Every confirmation names the item and the destination, such as "Fill
'GitHub' into 'GitHub' (com.github.android)". An app's label is untrusted: the picker strips
bidirectional controls, shortens it, and always shows the package.
A binding is a local edit, and the merge keeps the newer item whole, so a binding made on a phone that has not synced could revert a newer password set on another device. The app therefore binds only after a successful pull in the same unlock; offline, it fills without binding.
The fill response carries SaveInfo for the password field. onSaveRequest takes the submitted
values and the page as the structure reports them, keeps them in the in-process registry, and
starts the save activity through SaveCallback.onSuccess(IntentSender), whose intent carries only
a token; no submitted value passes through system_server in an extra. vault_client::autofill
decides the offer as on Firefox, and nothing is saved without the user's confirmation. A new item
for an app is named by its package, not its label, and records a bound association, or an unbound
one for an app with more than one signer or with signing information that could not be read. A new
item for an Android web page records its scheme and host with base_domain. A save request that
arrives while the vault is locked is dropped.
- Scan the setup QR code from
vault serve token add, or paste its text. CameraXImageAnalysisfeeds ZXing core, decoding QR codes only. - Scan the secret key's QR code from
vault key show --qror the emergency kit, or type it, and optionally the vault ID from the kit. - Enter the master password. The app runs the
vault-clientsetup machine. - Offer biometric unlock, and explain Chrome's autofill setting.
vault key show (AND-8) shows the secret key as text and a QR code after the master password, as
the CLI does.
A copied username or password carries ClipDescription.EXTRA_IS_SENSITIVE (the string
android.content.extra.IS_SENSITIVE below API 33), which Android 13 and later honour by hiding the
preview. Before Android 13, a clipboard manager can read a copied secret until the clear. A copy
starts a short foreground service (shortService on API 34 and later) that clears the clipboard
after 45 seconds and stops; on API 36 the cached-app freezer stopped an ordinary timer in a
backgrounded app. The service clears only when the clip is still the app's own, which it learns
from OnPrimaryClipChangedListener, ignoring events within 500 ms of its own copy, because
Android 10 and later refuse clipboard reads from the background.
Every activity sets FLAG_SECURE, which keeps the app out of screenshots and the recent apps
preview. On API 34 and later, the picker list, item views, and the secret key screen are marked
setAccessibilityDataSensitive, which FLAG_SECURE does not cover.
Secret text fields use a password keyboard type with learning off, are excluded from autofill, and
are cleared on submit. No secret is held in rememberSaveable state or a retained ViewModel,
because saved state is kept by system_server. A password crossing UniFFI passes through a buffer
that is freed without zeroing, and a Kotlin String cannot be zeroed; both are accepted.
| API level | Feature | Below it |
|---|---|---|
| 30 | setUserAuthenticationParameters, BiometricPrompt.setAllowedAuthenticators |
setUserAuthenticationValidityDurationSeconds(-1); the biometric-only default |
| 30 | Package visibility | <queries> is ignored |
| 31 | PendingIntent.FLAG_MUTABLE, dataExtractionRules, HIDE_OVERLAY_WINDOWS |
No flag needed; fullBackupContent; touch filtering only |
| 33 | Dataset.Builder(Presentations), EXTRA_IS_SENSITIVE constant |
RemoteViews presentations; the string |
| 34 | shortService, setAccessibilityDataSensitive |
An ordinary foreground service; nothing |
Lint with NewApi as an error checks the guards.
UniFFI is MPL-2.0, as are its Kotlin templates and the Public Suffix List; MPL-2.0 is on the
allowed licence list. Every Gradle runtime artefact is Apache-2.0, except camera-core (Apache-2.0
and BSD-3-Clause) and JNA, which is offered as LGPL-2.1-or-later or Apache-2.0 and is taken as
Apache-2.0. CameraX brings about 50 AndroidX artefacts. No artefact comes from Google Play services.
A licence check holds Gradle runtime artefacts to the licences in deny.toml, and Gradle dependency
verification pins their checksums, as package-lock.json does for npm. deny.toml adds the two
Android targets to its graph. The Public Suffix List is not a crate, so the requirements' Licence
row records it.
AGP 9.4 with Gradle 9.7, on JDK 17 or later. AGP 9 includes Kotlin, so no Kotlin plugin is
applied. Gradle calls cargo ndk and uniffi-bindgen through its own Exec tasks, not a
third-party plugin.
| Command | Purpose |
|---|---|
./gradlew assembleRelease in android/ |
The library, the bindings, and the APK, signed when the vault.signing.storeFile, vault.signing.storePassword, vault.signing.keyAlias, and vault.signing.keyPassword properties are set |
./gradlew lint test in android/ |
Lint and JVM unit tests, including the spec vectors through the generated Kotlin |
android/e2e/run.sh |
The instrumented end-to-end steps on an emulator against a real vault serve and the CLI; needs ANDROID_HOME, JAVA_HOME, and an emulator with the PIN 1234 and fingerprint 1 enrolled |
./gradlew connectedAndroidTest -Pvault.testBuildType=release -Pandroid.testInstrumentationRunnerArguments.class=com.codeheadsystems.vault.ReleaseGateTest in android/ |
The release gate against the release APK |
cargo test -p vault-android |
The Rust side of the bindings and the Public Suffix List |
The Kotlin bindings are generated from an unstripped host build, because the android profile
strips UniFFI's metadata. ./gradlew test builds vault-android for the host and runs the spec
vectors through the generated Kotlin and JNA, as the Node run does for WASM. The end-to-end steps
need the driver's work on the host between them, such as CLI commands and fingerprint touches, so a
bare connectedCheck is not the entry point. The instrumented tests cover setup, unlock, biometric
unlock and invalidation (on the emulator's fingerprint), the idle lock after a freeze, sync in both
directions with the CLI, a credential change on either side, recovery, restore, the pin and
cleartext refusals, app and web fills through the autofill framework, a forged picker intent, save
offers, and the clipboard clear. The release gate runs against the library inside the release APK.
CI gains a manual android job: the runner's SDK with a pinned NDK r28, cargo-ndk installed
with --locked, and an unsigned ./gradlew assembleRelease lint test with dependency verification.
Instrumented tests run in a separate emulator job, or locally.
Release APKs are signed with a key the owner holds and installed from a file; updates install over them. Android refuses an update signed with a different key, so the signing key and the application ID are fixed from the first install that holds a real vault. The app's own signing key plays no part in matching.
| Check | Needed for |
|---|---|
| Biometric key invalidated by a new enrolment on a real Android 10 phone; a class 2 biometric cannot unwrap it | AND-4 on Android 10 |
| Argon2id floor time on a real arm64 phone | The Unlock row of the requirements |
| What a current Chrome reports for a cross-origin frame (the emulator's Chrome 133 could not be updated) | Web matching; a frame-aware structure allows the Firefox rule for Chrome too |
| Chrome on a real phone: the "Autofill using another service" setting and the certificate digest | The allowlist and setup |
| The setup, unlock, item, credential, restore, and key screens, by hand; they have no automated UI tests, and the end-to-end run drives the same flows below the UI | AND-1, AND-2, AND-5, and AND-8 |