[feat] [biometric] Adding the capability of encrypting/decrypting data using biometric authentication on Android (Ref. #2306) (#2454)

* –feat: adding the ability to encrypt/decrypt data based on the biometric auth

Adding the method biometricCipher() that allows to use the biometric authentication to use a generated key from android that can be accessed only through the biometric authentication. The method uses this key to encrypt/decrypt the data provided by the caller in the "options" parameter, with "dataToEncrypt" or "dataToDecrypt" filled.

Changes were done both on Kotlin, Rust and JS sides.

* Improving the way we encrypt data by allowing only if the biometric was successful.

* Adding a warning on the JS side

* Adding doc changes in readme.md

* Adding doc changes in readme.md

* Update README.md - it was updated incomplete

* update docs

* enhance error handling

* update example

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
Charles SchaeferandLucas Nogueira authored and GitHub committed 2026-10-12 10:38:16 -03:00
1 parent b3e52ce036
commit 0374bec1e2
19 files changed
+915 -141

No files matched your search

+25 -25
View File
@@ -64,31 +64,31 @@ The example only registers each plugin on the platforms it supports, so the suit
split three ways: desktop-only plugins are skipped on mobile, mobile-only plugins are
skipped on desktop, and the rest run everywhere with the odd test gated.
| Plugin | Coverage | On mobile |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. |
| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. |
| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. |
| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. |
| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. |
| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`), the only path that skips the prompt. | Android only; skipped on desktop. |
| mobile-only plugins | `barcode-scanner`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
| Plugin | Coverage | On mobile |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. |
| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. |
| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. |
| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. |
| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. |
| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`). On an emulator without a lock screen, a PIN and fingerprint are enrolled (then removed) to cover `biometricCipher`: the encrypt/decrypt round trip, tampered and malformed input, and canceling the prompt, with the sensor touched over `adb emu`. | Android only; skipped on desktop. |
| mobile-only plugins | `barcode-scanner`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
The notification permission specs are desktop-only: a mobile app starts out ungranted and
`requestPermission` puts up a system dialog the session would then block on. The rest of the
+315 -17
View File
@@ -3,35 +3,208 @@
// SPDX-License-Identifier: MIT
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { expect } from '@wdio/globals'
import { tauri, describePlugin, platform } from '../helpers/index.js'
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
import { browser, expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
eventually,
platform
} from '../helpers/index.js'
// A successful authentication needs a finger or a face, which the driver cannot
// provide, so only the paths that reject without showing the prompt are covered.
// The driver cannot provide a finger or a face, so `authenticate` is only
// covered on the paths that reject without showing the prompt. On an Android
// emulator a fingerprint can be enrolled and touched over `adb emu`, which is
// what the `biometricCipher` specs below rely on.
const appId = 'com.tauri.api'
/** Lock screen PIN set for the fingerprint enrollment, cleared afterwards. */
const PIN = '1234'
/** `adb` from the Android SDK (else `PATH`), on `E2E_ANDROID_DEVICE` if set. */
function adb(args: string[]): SpawnSyncReturns<string> {
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
const device = process.env.E2E_ANDROID_DEVICE
? ['-s', process.env.E2E_ANDROID_DEVICE]
: []
return spawnSync(binary, [...device, ...args], {
encoding: 'utf8',
timeout: 20_000
})
}
/**
* Whether the Android device has no lock screen credential (the default on a
* fresh emulator). `locksettings verify` succeeds without a credential only then.
*/
function androidHasNoLockCredential(): boolean {
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
const adb = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
const device = process.env.E2E_ANDROID_DEVICE
? ['-s', process.env.E2E_ANDROID_DEVICE]
: []
const result = spawnSync(
adb,
[...device, 'shell', 'locksettings', 'verify'],
{ encoding: 'utf8', timeout: 20_000 }
)
const result = adb(['shell', 'locksettings', 'verify'])
return result.status === 0 && /verified successfully/.test(result.stdout)
}
/** Whether the Android device is an emulator, whose fingerprint sensor `adb emu` drives. */
function androidIsEmulator(): boolean {
const result = adb(['shell', 'getprop', 'ro.kernel.qemu'])
const legacy = result.stdout?.trim() === '1'
return (
legacy || adb(['shell', 'getprop', 'ro.boot.qemu']).stdout?.trim() === '1'
)
}
/** Puts finger 1 on the emulator's sensor and lifts it again. */
function touchFingerprintSensor(): void {
adb(['emu', 'finger', 'touch', '1'])
adb(['emu', 'finger', 'remove', '1'])
}
/**
* Sets the lock screen PIN and enrolls finger 1 through the Settings
* enrollment flow, driven from the native context. The screens differ between
* Android versions, so this answers whichever one is showing until the flow
* reports the fingerprint as added.
*/
async function enrollFingerprint(): Promise<void> {
const pin = adb(['shell', 'locksettings', 'set-pin', PIN])
if (pin.status !== 0) {
throw new Error(`locksettings set-pin failed: ${pin.stderr || pin.stdout}`)
}
adb(['shell', 'am', 'start', '-a', 'android.settings.FINGERPRINT_ENROLL'])
await browser.switchAppiumContext('NATIVE_APP')
const pinEntry = browser.$('id=com.android.settings:id/password_entry')
const done = browser.$(
'android=new UiSelector().clickable(true).textMatches("(?i)^done$")'
)
// Intro and education screens. "More" scrolls the consent text until it
// turns into "I agree".
const proceed = browser.$(
'android=new UiSelector().clickable(true).textMatches("(?i)^(i agree|more|next|start|continue|ok)$")'
)
await browser.waitUntil(
async () => {
if (await done.isExisting()) {
await done.click()
return true
}
if (await pinEntry.isExisting()) {
await pinEntry.setValue(PIN)
await browser.pressKeyCode(66 /* KEYCODE_ENTER */)
} else if (await proceed.isExisting()) {
await proceed.click()
} else {
// "Touch the sensor" and the enrollment progress screens.
touchFingerprintSensor()
}
return false
},
{
timeout: 120_000,
interval: 1000,
timeoutMsg: 'the fingerprint enrollment flow did not complete'
}
)
}
/** Brings the app back to the foreground and returns to its webview. */
async function returnToApp(): Promise<void> {
await browser.activateApp(appId)
await switchToWebview()
}
/** Switches back to the app's webview once it is attachable again. */
async function switchToWebview(): Promise<void> {
await browser.waitUntil(
async () => {
try {
await browser.switchAppiumContext(`WEBVIEW_${appId}`)
return true
} catch {
return false
}
},
{
timeout: 30_000,
interval: 1000,
timeoutMsg: 'the webview did not come back'
}
)
}
type CipherOptions = { dataToEncrypt: string } | { dataToDecrypt: string }
type CipherOutcome =
| { ok: true; data: string }
| { ok: false; code: string | null; message: string }
/**
* Starts a `biometricCipher` call without waiting for it, since it only
* settles once the prompt is answered. See {@link cipherOutcome}.
*/
async function startCipher(options: CipherOptions): Promise<void> {
await tauri((api, options) => {
const page = window as unknown as { __e2eCipher?: unknown }
page.__e2eCipher = undefined
api.biometric.biometricCipher('e2e', options).then(
(result) => {
page.__e2eCipher = { ok: true, data: result.data }
},
(error: { code?: string; message?: string } | string) => {
page.__e2eCipher =
typeof error === 'string'
? { ok: false, code: null, message: error }
: {
ok: false,
code: error.code ?? null,
message: error.message ?? ''
}
}
)
}, options)
}
/** The outcome of the last {@link startCipher} call, or `null` while it is pending. */
async function cipherOutcome(): Promise<CipherOutcome | null> {
return await tauri(
() =>
(window as unknown as { __e2eCipher?: CipherOutcome }).__e2eCipher ?? null
)
}
/** Runs `biometricCipher`, answering its prompt with the enrolled finger. */
async function cipherWithFingerprint(
options: CipherOptions
): Promise<CipherOutcome> {
await startCipher(options)
return await eventually(
async () => {
const outcome = await cipherOutcome()
if (outcome) {
return outcome
}
// Touching before the prompt is up is harmless, so keep at it.
touchFingerprintSensor()
throw new Error('biometricCipher did not settle')
},
{ timeout: 60_000, interval: 1000 }
)
}
async function encrypt(data: string): Promise<string> {
const outcome = await cipherWithFingerprint({ dataToEncrypt: data })
if (!outcome.ok) {
throw new Error(`encryption failed: ${outcome.message} (${outcome.code})`)
}
return outcome.data
}
describePlugin('biometric', { mobileOnly: true }, () => {
// Android only, where the lock screen state can be read over adb.
const itNoLockCredential =
platform === 'android' && androidHasNoLockCredential() ? it : it.skip
const noLockCredential =
platform === 'android' && androidHasNoLockCredential()
const itNoLockCredential = noLockCredential ? it : it.skip
itNoLockCredential(
'rejects with passcodeNotSet when the fallback is allowed and no credential is set',
@@ -49,4 +222,129 @@ describePlugin('biometric', { mobileOnly: true }, () => {
expect(code).toBe('passcodeNotSet')
}
)
const itAndroid = platform === 'android' ? it : it.skip
itAndroid(
'biometricCipher rejects without data to encrypt or decrypt',
async () => {
const message = await tauriError((api) =>
api.biometric.biometricCipher('e2e', {})
)
expect(message).toMatch(/dataToEncrypt or dataToDecrypt/)
}
)
// Enrolling changes the device's lock screen, so this only runs on an
// emulator that has none (which it then restores).
const describeEnrolled =
noLockCredential && androidIsEmulator() ? describe : describe.skip
describeEnrolled('biometricCipher with an enrolled fingerprint', () => {
before(async () => {
try {
await enrollFingerprint()
} finally {
await returnToApp()
}
const status = await tauri((api) => api.biometric.checkStatus())
expect(status.isAvailable).toBe(true)
})
after(() => {
// Removing the PIN also removes the enrolled fingerprints.
adb(['shell', 'locksettings', 'clear', '--old', PIN])
})
it('round-trips data through encryption and decryption', async () => {
const plain = 'e2e sécret 🔐'
const encrypted = await encrypt(plain)
// Base64 `data;iv`
expect(encrypted).toMatch(/^[A-Za-z0-9+/]+=*;[A-Za-z0-9+/]+=*$/)
expect(encrypted).not.toContain(plain)
const decrypted = await cipherWithFingerprint({
dataToDecrypt: encrypted
})
expect(decrypted).toEqual({ ok: true, data: plain })
})
it('uses a fresh IV for every encryption', async () => {
const first = await encrypt('same input')
const second = await encrypt('same input')
expect(first).not.toBe(second)
})
it('rejects decrypting data that was tampered with', async () => {
const [data, iv] = (await encrypt('do not touch')).split(';')
const bytes = Buffer.from(data, 'base64')
bytes[0] ^= 0xff
const outcome = await cipherWithFingerprint({
dataToDecrypt: `${bytes.toString('base64')};${iv}`
})
expect(outcome.ok).toBe(false)
expect(!outcome.ok && outcome.message).toMatch(/Failed to decrypt/)
})
it('rejects malformed encrypted data before prompting', async () => {
await startCipher({ dataToDecrypt: 'not encrypted data' })
const outcome = await eventually(
async () => {
const outcome = await cipherOutcome()
if (!outcome) {
throw new Error('biometricCipher did not settle')
}
return outcome
},
{ timeout: 15_000 }
)
expect(outcome.ok).toBe(false)
expect(!outcome.ok && outcome.message).toMatch(
/Failed to initialize the cipher/
)
})
it('rejects with userCancel when the prompt is canceled', async () => {
await startCipher({ dataToEncrypt: 'canceled' })
await browser.switchAppiumContext('NATIVE_APP')
try {
const cancel = browser.$(
'android=new UiSelector().clickable(true).textMatches("(?i)^cancel$")'
)
await cancel.waitForDisplayed({ timeout: 30_000 })
// A click while the prompt is still animating in is ignored, so keep
// clicking until it is gone.
await browser.waitUntil(
async () => {
if (!(await cancel.isExisting())) {
return true
}
await cancel.click().catch(() => {})
return false
},
{
timeout: 15_000,
interval: 500,
timeoutMsg: 'the biometric prompt did not close'
}
)
} finally {
// The app comes back on its own once the prompt closes. Activating it
// here instead would tear down a prompt that is still up, which the
// plugin reports as systemCancel.
await switchToWebview()
}
const outcome = await eventually(
async () => {
const outcome = await cipherOutcome()
if (!outcome) {
throw new Error('biometricCipher did not settle')
}
return outcome
},
{ timeout: 15_000 }
)
expect(outcome).toMatchObject({ ok: false, code: 'userCancel' })
})
})
})
+1 -1
View File
@@ -23,7 +23,7 @@ type Surface<T> = { [P in keyof T]: (keyof T[P])[] }
/** Plugins the example registers on every platform. */
const commonSurface: Surface<CommonPluginApi> = {
biometric: ['BiometryType', 'checkStatus', 'authenticate'],
biometric: ['BiometryType', 'checkStatus', 'authenticate', 'biometricCipher'],
clipboardManager: [
'writeText',
'readText',