Skip to content

Latest commit

 

History

History
436 lines (354 loc) · 27.6 KB

android.md

File metadata and controls

436 lines (354 loc) · 27.6 KB

Android app

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.

Scope

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.

Components

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.

Shared 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.

Rust bindings

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.

Storage

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.

Unlock and locking

The vault key lives in Rust memory while the vault is unlocked.

  • Master password: the vault-client unlock, 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), and setUnlockedDeviceRequired(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 on StrongBoxUnavailableException. On API 29 a locked device does not stop Cipher.init on this key; the per-use biometric still stops doFinal. It wraps the vault key, and each use goes through the platform BiometricPrompt with a CryptoObject; androidx.biometric is not used.
  • A new enrolment makes Cipher.init throw KeyPermanentlyInvalidatedException before 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_nonce of the state it belongs to. A commit that changes wrap_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.

Idle lock

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

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.

Transport

Requests go through HttpsURLConnection, with redirects off, caches off, and TLS 1.2 or later.

  • With fp, a custom X509TrustManager accepts the connection only when the SHA-256 of the leaf certificate's DER encoding equals fp, through vault-core's pin check; the chain, the name, and the dates are ignored. A HostnameVerifier checks the same pin on the session's peer certificate instead of returning true. OkHttp's CertificatePinner is 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 http everywhere, because every app on a phone shares the loopback interface and any of them can listen on the port. Debug builds allow http to 127.0.0.1 and [::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. localhost is never resolved; a debug setup URL with localhost connects to 127.0.0.1.
  • The Date header comes from getHeaderFieldDate.
  • Release builds strip Log calls 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.

Autofill

The app is an AutofillService. Nothing about the vault reaches system_server or the requesting app until the user picks an item.

  1. onFillRequest checks the idle lock, then reads the AssistStructure: the requesting package from activityComponent.packageName, the username and password fields from autofill hints, HTML attributes (Firefox reports an inputType of 0), and input types, and the webDomain and webScheme of the focused field: from its own node or its nearest ancestor with a non-empty webDomain. 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 returns null for 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 with FLAG_COMPATIBILITY_MODE_REQUEST, and when it finds no field. The service declares no compatibility packages.
  2. 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 Dataset whose values are all null, authenticated by an IntentSender to the picker activity. The intent carries only the token, under a request code unique to the response: with a shared request code, FLAG_UPDATE_CURRENT gives an earlier response the later one's extras, and FLAG_CANCEL_CURRENT cancels the earlier one. The presentation reads "Fill with vault" and names no item.
  3. 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 IntentSender in its own process, so it can add extras, EXTRA_ASSIST_STRUCTURE included, 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 chosen Dataset through EXTRA_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.

Pages

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.

Web matching

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.

App matching

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 uris entry androidapp://<package>, as a Bitwarden import writes it.
  • A bound association is an apps entry, {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.

Save offers

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.

Setup

  1. Scan the setup QR code from vault serve token add, or paste its text. CameraX ImageAnalysis feeds ZXing core, decoding QR codes only.
  2. Scan the secret key's QR code from vault key show --qr or the emergency kit, or type it, and optionally the vault ID from the kit.
  3. Enter the master password. The app runs the vault-client setup machine.
  4. 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.

Clipboard

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.

Screen and memory

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.

Android 10 compatibility

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.

Dependencies

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.

Build and tests

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.

Distribution

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.

Open checks

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